lock: distributed lock

Forked from @stevekrouse/dlock! 🙏

Example:

Create valimport { acquireLock } from "https://esm.town/v/postpostscript/lock"; using exampleLock = await acquireLock(); // lock will be released when it goes out of context

Full Example

Options:

Create valusing exampleLock = await acquireLock({ id: "lockExample", retries: 0, ttl: 5, // seconds retryTimeout: 1000, // ms });
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
import { sleep } from "https://deno.land/x/sleep/mod.ts";
import { fetchJSON } from "https://esm.town/v/stevekrouse/fetchJSON";
import { parentReference } from "https://esm.town/v/stevekrouse/parentReference";
import { searchParams } from "https://esm.town/v/stevekrouse/searchParams";
export async function acquireLock(opts?: LockOptions): Promise<{
release: () => Promise<LockResponse | undefined>;
}> {
let id = opts?.id ?? parentReference().userHandle + "-" + parentReference().valName;
id = id.replace(/[^\w]+/g, "_");
const ttl = opts?.ttl ?? 3;
async function handleError(error: string) {
if (typeof opts.retries === "number" && (opts.retry ?? 0) >= opts.retries) {
throw new LockError(`LockError: failed with message "${error || "unknown error"}"`);
}
await sleep((opts?.retryTimeout ?? 1000) / 1000);
return acquireLock({
...opts,
id,
ttl,
retry: (opts.retry ?? 0) + 1,
});
}
const res = await makeRequest(id, "acquire", {
ttl,
});
const { lease } = res;
if (!lease) {
return handleError(res.error);
}
const acquired = await makeRequest(id, "acquire", {
ttl,
lease,
});
if (!acquired.lease) {
return handleError(res.error);
}
let released = false;
async function release(): Promise<LockResponse | undefined> {
if (released) return;
released = true;
return makeRequest(id, "release", {
lease,
});
}
return {
release,
[Symbol.dispose]: release,
};
}
export class LockError extends Error {}
export type LockOptions = {
id?: string;
ttl?: number;
retry?: number;
retries?: number;
retryTimeout?: number;
};
export type LockResponse = {
lease?: number;
deadline: number;
error?: "string";
};
export async function makeRequest(id: string, method: "release" | "acquire", params): Promise<LockResponse> {
return fetchJSON(
`https://dlock.univalent.net/lock/${id}/${method}?${searchParams(params)}`,
);
}

Testing is work in progress, this still has bugs!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
import { sleep } from "https://esm.town/v/stevekrouse/sleep?v=1";
import { debug } from "https://esm.town/v/karfau/debug";
import { searchParams } from "https://esm.town/v/stevekrouse/searchParams?v=9";
import { mainReference } from "https://esm.town/v/karfau/mainReference";
/**
* Tries to acquire a distributed lock for `ttl` seconds.
* - the `id` defaults to `val_town-userHandle-valName` of the calling val
* - (currently) rejects ids longer then 128 chars
* - the `ttl` defauls to 3 sec
* - rejects ttl that are not safe integers or are <= 0
* @see https://dlock.univalent.net/
* be aware that the `deadline` is in seconds, not the usual millisecond
* and that it is floored, so it is very liely up to a second shorter then the ttl
* @see https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html
* Forked from @stevekrouse.dlock
*/
export async function dlock({ id, ttl = 3 }: {
// lock id by default it is based on the name of the parent
id?: string;
// seconds
ttl?: number;
} = {}, _fetch = fetch): Promise<Lock> {
if (!id) {
id = mainReference(({ userHandle, valName }) =>
`val_town-${userHandle}-${valName}`
);
}
// https://github.com/losfair/dlock/blob/5f042396e4a42b7fcd384d188c9a3a81e973eaaa/src/index.ts#L19
if (id.length > 128) {
return Promise.reject(
new RangeError(
`id needs to be max 128 characters but was ${id.length}(${id})`,
),
);
// IDEA: (always) do a SHA512 hash from the composed or passed id, to avoid this problem
// https://examples.deno.land/hashing (needs imports from deno.land to provide types)
}
// https:github.com/losfair/dlock/blob/5f042396e4a42b7fcd384d188c9a3a81e973eaaa/src/index.ts#L53
if (!Number.isSafeInteger(ttl) || ttl <= 0) {
throw new RangeError("ttl has to be a safe integer above 0 but was " + ttl);
}
const throttleMs = ttl * 251;
let _lease = undefined;
let _deadline;
// https://github.com/losfair/dlock/blob/5f042396e4a42b7fcd384d188c9a3a81e973eaaa/src/index.ts#L48
const isExpired = (): boolean => _deadline < (Date.now() / 1000);
const release = async () =>
!isExpired() &&
void _fetch(
`https://dlock.univalent.net/lock/${id}/release?${
searchParams({ lease: _lease })
}`,
).catch(console.error);
const refresh = async () => {
if (isExpired()) {
throw new Error("lock is expired");
}
return _fetch(
`https://dlock.univalent.net/lock/${id}/aquire?${
searchParams({ lease: _lease, ttl })
}`,
).then(async (res) => {
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || res.status + " " + res.statusText);
}
_deadline = data.deadline;
return _deadline;
});
};
const acquire = async (max = Date.now() + ttl * 1000): Promise<Lock> => {
try {
const lockRes = await _fetch(
`https://dlock.univalent.net/lock/${id}/aquire?${
searchParams({ lease: _lease, ttl })
}`,
);
const { lease, error, deadline } = debug(
await lockRes.json(),
id,
);
if (error && deadline) {
const deadlineMs = deadline * 1000;
if (deadlineMs >= max) {
throw new Error(error + " (and deadline > max)");
}
else {
let waitTime = deadlineMs - Date.now() + 1;
if (waitTime <= 0) {
waitTime = throttleMs;
}
await sleep(waitTime);
return acquire(max);
}
}
else if (!lockRes.ok || error) {
throw new Error(error || lockRes.status + " " + lockRes.statusText);
}
else if (lease && deadline) {
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { searchParams } from "https://esm.town/v/stevekrouse/searchParams?v=9";
import { fetchJSON } from "https://esm.town/v/stevekrouse/fetchJSON?v=41";
import { parentReference } from "https://esm.town/v/stevekrouse/parentReference?v=3";
export async function dlock({ id, ttl, release, lease }: {
id?: string;
ttl?: number;
release?: boolean;
lease?: number;
} = {}): Promise<{
lease?: number;
deadline: number;
error?: "string";
}> {
id = id ??
parentReference().userHandle + "-" +
parentReference().valName;
ttl = ttl ?? 3; // seconds
let method = release ? "release" : "acquire";
return fetchJSON(
`https://dlock.univalent.net/lock/${id}/${method}?${
searchParams({ ttl, lease })
}`,
);
}
// Forked from @stevekrouse.dlock
1
Next