@zukhruf/election 0.3.18 → 0.3.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -19
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/leader-election.d.ts +16 -3
- package/dist/leader-election.d.ts.map +1 -1
- package/dist/leader-election.js +16 -16
- package/dist/leader-election.js.map +1 -1
- package/dist/sqlite/sqlite-election.d.ts +10 -2
- package/dist/sqlite/sqlite-election.d.ts.map +1 -1
- package/dist/sqlite/sqlite-election.js +42 -5
- package/dist/sqlite/sqlite-election.js.map +1 -1
- package/dist/term.d.ts +25 -5
- package/dist/term.d.ts.map +1 -1
- package/dist/term.js +18 -7
- package/dist/term.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -50,6 +50,8 @@ next term: 2n
|
|
|
50
50
|
|
|
51
51
|
- `campaign({ timeout, signal })` resolves with a `Term`, or with `undefined` when another candidate still leads after `timeout` milliseconds. Without a `timeout`, it tries once. When `signal` aborts, it rejects with `signal.reason`, and a claim that it won meanwhile is given up.
|
|
52
52
|
- `claimFile` and `epochFile` are files in `directory`. Candidates with the same directory and the same file names are one group. Never delete `claimFile` while candidates run: a new file lets a second leader win.
|
|
53
|
+
- `epochFile` holds the epoch of the last term as decimal digits. When it holds other text, `campaign` rejects with a `SyntaxError` that names the file, gives the claim up, and does not change the file. An example is the empty file that `@zukhruf/mutex` 0.2.0 or earlier can leave after a power loss, because those versions did not sync the file to the disk. Thus the epochs never start again at 1, and each new term has a higher epoch than each earlier term.
|
|
54
|
+
- A term that resigns clean writes a third file, `<claimFile>.clean`, beside `claimFile`. It holds the epoch of that term. The next term reads it and removes it.
|
|
53
55
|
- The directory must be on a local file system. On a network file system, `campaign` rejects with `NetworkDirectoryError`.
|
|
54
56
|
- `pollInterval` is the time between two tries, in milliseconds. It defaults to 10. A try never blocks the process.
|
|
55
57
|
|
|
@@ -58,7 +60,9 @@ next term: 2n
|
|
|
58
60
|
A term is a lease on leadership. `Term` implements the `Lease` interface of [`@zukhruf/lease`](../lease/README.md), so a function that takes a `Lease` also takes a term. See the glossary of [leases](../lease/CONTEXT.md).
|
|
59
61
|
|
|
60
62
|
- `term.epoch` is the number of the term. Each epoch is below 2^31.
|
|
61
|
-
- `term.resign()` ends the term and gives the claim up. A second call waits for the first. `await using` resigns at the end of its scope.
|
|
63
|
+
- `term.resign()` ends the term and gives the claim up. A second call waits for the first, and its options do nothing. `await using` resigns at the end of its scope.
|
|
64
|
+
- `term.resign({ clean: true })` records a clean shutdown for the next term, and then ends the term. Use it only when the leader leaves nothing for the next term to recover, for example no work in progress. A lost term records nothing, also with `clean`. When the record fails, the claim is freed, and `resign` rejects with that error.
|
|
65
|
+
- `term.afterCleanShutdown` is `true` only when the term just before this one resigned clean. Then the new leader knows that it has nothing to recover. It is `false` after a crash, after a lost term, after a resign without `clean`, and for the first term.
|
|
62
66
|
- `term.signal` aborts with `LeaseLostError` when the term is lost while its leader still runs. The `subject` of the error is `'leadership'`, and its `cause` is the reason of the backend. The signal does not abort when the leader resigns.
|
|
63
67
|
|
|
64
68
|
A leader that acts for the group must stop when `term.signal` aborts, because another candidate can lead then. `SqliteElection` never loses a living term, so its signal never aborts. A backend that can take the claim away can lose one.
|
|
@@ -76,22 +80,24 @@ const token = await tokens.next('orders');
|
|
|
76
80
|
|
|
77
81
|
## Write a backend
|
|
78
82
|
|
|
79
|
-
`LeaderElection` is an abstract class. It runs the campaign, and it owns the term. A backend is a subclass that implements
|
|
83
|
+
`LeaderElection` is an abstract class. It runs the campaign, and it owns the term. A backend is a subclass that implements six steps for its claim:
|
|
80
84
|
|
|
81
|
-
| Step
|
|
82
|
-
|
|
|
83
|
-
| `open(signal)`
|
|
84
|
-
| `tryClaim(claim, signal)`
|
|
85
|
-
| `watch(claim, lose)`
|
|
86
|
-
| `
|
|
87
|
-
| `
|
|
85
|
+
| Step | What it does |
|
|
86
|
+
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
87
|
+
| `open(signal)` | Prepares one attempt, for example a connection. The campaign calls `tryClaim` on it again and again. |
|
|
88
|
+
| `tryClaim(claim, signal)` | Tries the claim once. Resolves with `{ epoch, afterCleanShutdown }` for the term it won, or with `undefined` while another candidate holds the claim. |
|
|
89
|
+
| `watch(claim, lose)` | Watches a won claim for as long as its term lasts. Calls `lose` when the backend takes the claim away. Returns a `Disposable` that stops the watch. |
|
|
90
|
+
| `recordCleanShutdown(claim, epoch)` | Records that the term of `epoch` resigned clean. The campaign calls it before `release`, while the term still holds the claim. |
|
|
91
|
+
| `release(claim)` | Gives a won claim up when the leader resigns. |
|
|
92
|
+
| `close(claim)` | Frees the resources of an attempt, at each end. |
|
|
88
93
|
|
|
89
|
-
A backend must keep
|
|
94
|
+
A backend must keep five rules:
|
|
90
95
|
|
|
91
96
|
1. `tryClaim` gives the epoch in the same step that wins the claim. Each epoch is higher than each earlier one, and below 2^31.
|
|
92
|
-
2. `
|
|
93
|
-
3.
|
|
94
|
-
4. The campaign calls `
|
|
97
|
+
2. `tryClaim` gives `afterCleanShutdown: true` only when the term just before this one recorded a clean shutdown. It reads each record one time only. Thus a crash after a clean term never reads as clean.
|
|
98
|
+
3. `watch` calls `lose` before the backend can give the claim to another candidate. For a claim that the backend keeps only for a time, renew it well before that time ends, and count the time on a monotonic clock. Then an old leader stops before a new one starts.
|
|
99
|
+
4. The campaign never calls `recordCleanShutdown` or `release` after a loss, because the claim can be another candidate's then.
|
|
100
|
+
5. The campaign calls `close` after each end: a won term, a lost term, a failed attempt, or an attempt that never won.
|
|
95
101
|
|
|
96
102
|
This backend elects among the candidates of one process. Its claim is an entry in a map:
|
|
97
103
|
|
|
@@ -99,6 +105,7 @@ This backend elects among the candidates of one process. Its claim is an entry i
|
|
|
99
105
|
import { LeaderElection } from '@zukhruf/election';
|
|
100
106
|
|
|
101
107
|
const holders = new Map<string, bigint>();
|
|
108
|
+
const cleanShutdowns = new Set<string>();
|
|
102
109
|
let lastEpoch = 0n;
|
|
103
110
|
|
|
104
111
|
class MapElection extends LeaderElection<string> {
|
|
@@ -115,9 +122,12 @@ class MapElection extends LeaderElection<string> {
|
|
|
115
122
|
|
|
116
123
|
protected async tryClaim(name: string) {
|
|
117
124
|
if (holders.has(name)) return undefined;
|
|
125
|
+
// Only the holder records, and each claim removes the record. Thus a
|
|
126
|
+
// record is always from the term just before this one.
|
|
127
|
+
const afterCleanShutdown = cleanShutdowns.delete(name);
|
|
118
128
|
lastEpoch += 1n;
|
|
119
129
|
holders.set(name, lastEpoch);
|
|
120
|
-
return lastEpoch;
|
|
130
|
+
return { epoch: lastEpoch, afterCleanShutdown };
|
|
121
131
|
}
|
|
122
132
|
|
|
123
133
|
// Nothing takes an entry of the map away, so there is nothing to watch.
|
|
@@ -125,6 +135,10 @@ class MapElection extends LeaderElection<string> {
|
|
|
125
135
|
return { [Symbol.dispose]() {} };
|
|
126
136
|
}
|
|
127
137
|
|
|
138
|
+
protected async recordCleanShutdown(name: string) {
|
|
139
|
+
cleanShutdowns.add(name);
|
|
140
|
+
}
|
|
141
|
+
|
|
128
142
|
protected async release(name: string) {
|
|
129
143
|
holders.delete(name);
|
|
130
144
|
}
|
|
@@ -135,11 +149,10 @@ class MapElection extends LeaderElection<string> {
|
|
|
135
149
|
const term = await new MapElection('jobs').campaign();
|
|
136
150
|
console.log('leader:', term?.epoch);
|
|
137
151
|
console.log('rival:', await new MapElection('jobs').campaign());
|
|
138
|
-
await term?.resign();
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
);
|
|
152
|
+
await term?.resign({ clean: true });
|
|
153
|
+
const next = await new MapElection('jobs').campaign();
|
|
154
|
+
console.log('rival after the resign:', next?.epoch);
|
|
155
|
+
console.log('after a clean shutdown:', next?.afterCleanShutdown);
|
|
143
156
|
```
|
|
144
157
|
|
|
145
158
|
Output:
|
|
@@ -148,6 +161,7 @@ Output:
|
|
|
148
161
|
leader: 1n
|
|
149
162
|
rival: undefined
|
|
150
163
|
rival after the resign: 2n
|
|
164
|
+
after a clean shutdown: true
|
|
151
165
|
```
|
|
152
166
|
|
|
153
167
|
The socket lock store of `@zukhruf/mutex` uses `SqliteElection`, with the claim file `leader.lock` and the epoch file `leader.epoch`. Its leader serves the other candidates over a socket in the directory, so it works on one host only, also with another backend. Its server stops when `term.signal` aborts. `@zukhruf/single-flight` uses `SqliteElection` the same way, with the claim file `flight.lock` and the epoch file `flight.epoch`, so the two packages never join one election in a shared directory.
|
|
@@ -166,6 +180,7 @@ The socket lock store of `@zukhruf/mutex` uses `SqliteElection`, with the claim
|
|
|
166
180
|
- [Recipe: Run a job in only one process](./docs/recipes/singleton-job-with-leader-election.md)
|
|
167
181
|
- [ADR 0001: Leader election is a package, and each backend is a subclass of one campaign](./docs/adr/0001-leader-election-is-a-package-and-each-backend-is-a-subclass.md)
|
|
168
182
|
- [ADR 0002: A term is a lease on leadership, with no fencing](./docs/adr/0002-a-term-is-a-lease-on-leadership-with-no-fencing.md)
|
|
183
|
+
- [ADR 0003: A term that shuts down cleanly tells its successor](./docs/adr/0003-a-term-that-shuts-down-cleanly-tells-its-successor.md)
|
|
169
184
|
- [Code copied into this package](./docs/copied-code.md)
|
|
170
185
|
|
|
171
186
|
## Development
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { type CampaignOptions, LeaderElection } from './leader-election.ts';
|
|
2
|
-
export { Term, type TermSteps } from './term.ts';
|
|
2
|
+
export { type ResignOptions, Term, type TermSteps } from './term.ts';
|
|
3
3
|
export { SqliteElection, type SqliteElectionOptions, } from './sqlite/sqlite-election.ts';
|
|
4
4
|
export { NetworkDirectoryError } from '@zukhruf/fs';
|
|
5
5
|
export { LeaseLostError } from '@zukhruf/lease';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,eAAe,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAC5E,OAAO,EAAE,IAAI,EAAE,KAAK,SAAS,EAAE,MAAM,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,eAAe,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAC5E,OAAO,EAAE,KAAK,aAAa,EAAE,IAAI,EAAE,KAAK,SAAS,EAAE,MAAM,WAAW,CAAC;AACrE,OAAO,EACL,cAAc,EACd,KAAK,qBAAqB,GAC3B,MAAM,6BAA6B,CAAC;AACrC,OAAO,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAC;AACpD,OAAO,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC"}
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAwB,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAC5E,OAAO,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAwB,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAC5E,OAAO,EAAsB,IAAI,EAAkB,MAAM,WAAW,CAAC;AACrE,OAAO,EACL,cAAc,GAEf,MAAM,6BAA6B,CAAC;AACrC,OAAO,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAC;AACpD,OAAO,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC"}
|
|
@@ -11,9 +11,12 @@ export interface CampaignOptions {
|
|
|
11
11
|
* up, watch the won claim, and hand it to a term. Each backend is a
|
|
12
12
|
* subclass that implements the steps for its claim.
|
|
13
13
|
*
|
|
14
|
-
* A backend keeps
|
|
14
|
+
* A backend keeps five rules:
|
|
15
15
|
* - `tryClaim` returns the epoch of the term it won, in the same step, and
|
|
16
16
|
* each epoch is higher than every earlier one and below 2^31.
|
|
17
|
+
* - `tryClaim` reports `afterCleanShutdown` only when the term just before
|
|
18
|
+
* it recorded a clean shutdown with `recordCleanShutdown`, and it reports
|
|
19
|
+
* each record once.
|
|
17
20
|
* - `watch` calls `lose` before the backend can grant the claim to another
|
|
18
21
|
* candidate, so an old leader stops before a new one starts.
|
|
19
22
|
* - `release` is never called after a loss: the claim may be another
|
|
@@ -27,10 +30,20 @@ export declare abstract class LeaderElection<Claim> {
|
|
|
27
30
|
campaign({ timeout, signal }?: CampaignOptions): Promise<Term | undefined>;
|
|
28
31
|
/** Prepares one attempt: the resources that `tryClaim` uses again and again. */
|
|
29
32
|
protected abstract open(signal: AbortSignal | undefined): Promise<Claim>;
|
|
30
|
-
/**
|
|
31
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Tries the claim once. Resolves with the epoch of the term it won and
|
|
35
|
+
* whether the term before it resigned clean, or `undefined` while another
|
|
36
|
+
* candidate holds it.
|
|
37
|
+
*/
|
|
38
|
+
protected abstract tryClaim(claim: Claim, signal: AbortSignal | undefined): Promise<Pick<Term, 'epoch' | 'afterCleanShutdown'> | undefined>;
|
|
32
39
|
/** Watches a won claim for as long as its term lasts, and calls `lose` when the backend takes it away. */
|
|
33
40
|
protected abstract watch(claim: Claim, lose: (reason: Error) => void): Disposable;
|
|
41
|
+
/**
|
|
42
|
+
* Records that the term of `epoch` resigned clean, so that the next
|
|
43
|
+
* `tryClaim` reports it. Called while the claim is held, before `release`,
|
|
44
|
+
* and never after a loss.
|
|
45
|
+
*/
|
|
46
|
+
protected abstract recordCleanShutdown(claim: Claim, epoch: bigint): Promise<void>;
|
|
34
47
|
/** Gives a won claim up. Never called after a loss. */
|
|
35
48
|
protected abstract release(claim: Claim): Promise<void>;
|
|
36
49
|
/** Frees the resources of an attempt, whether it won, lost or failed. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"leader-election.d.ts","sourceRoot":"","sources":["../src/leader-election.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"leader-election.d.ts","sourceRoot":"","sources":["../src/leader-election.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAEjC,MAAM,WAAW,eAAe;IAC9B,mGAAmG;IACnG,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7B,4GAA4G;IAC5G,MAAM,CAAC,EAAE,WAAW,GAAG,SAAS,CAAC;CAClC;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,8BAAsB,cAAc,CAAC,KAAK;;IAGxC,SAAS,aAAa,YAAY,EAAE,MAAM;IAI1C,qGAAqG;IAC/F,QAAQ,CAAC,EAAE,OAAW,EAAE,MAAM,EAAE,GAAE,eAAoB,GAAG,OAAO,CACpE,IAAI,GAAG,SAAS,CACjB;IAUD,gFAAgF;IAChF,SAAS,CAAC,QAAQ,CAAC,IAAI,CAAC,MAAM,EAAE,WAAW,GAAG,SAAS,GAAG,OAAO,CAAC,KAAK,CAAC;IAExE;;;;OAIG;IACH,SAAS,CAAC,QAAQ,CAAC,QAAQ,CACzB,KAAK,EAAE,KAAK,EACZ,MAAM,EAAE,WAAW,GAAG,SAAS,GAC9B,OAAO,CAAC,IAAI,CAAC,IAAI,EAAE,OAAO,GAAG,oBAAoB,CAAC,GAAG,SAAS,CAAC;IAElE,0GAA0G;IAC1G,SAAS,CAAC,QAAQ,CAAC,KAAK,CACtB,KAAK,EAAE,KAAK,EACZ,IAAI,EAAE,CAAC,MAAM,EAAE,KAAK,KAAK,IAAI,GAC5B,UAAU;IAEb;;;;OAIG;IACH,SAAS,CAAC,QAAQ,CAAC,mBAAmB,CACpC,KAAK,EAAE,KAAK,EACZ,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,IAAI,CAAC;IAEhB,uDAAuD;IACvD,SAAS,CAAC,QAAQ,CAAC,OAAO,CAAC,KAAK,EAAE,KAAK,GAAG,OAAO,CAAC,IAAI,CAAC;IAEvD,yEAAyE;IACzE,SAAS,CAAC,QAAQ,CAAC,KAAK,CAAC,KAAK,EAAE,KAAK,GAAG,OAAO,CAAC,IAAI,CAAC;CA+CtD"}
|
package/dist/leader-election.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { untilAborted } from '@zukhruf/async';
|
|
1
|
+
import { poll } from '@zukhruf/async';
|
|
3
2
|
import { Term } from "./term.js";
|
|
4
3
|
/**
|
|
5
4
|
* Elects one leader among candidates. The campaign is the same for each
|
|
@@ -7,9 +6,12 @@ import { Term } from "./term.js";
|
|
|
7
6
|
* up, watch the won claim, and hand it to a term. Each backend is a
|
|
8
7
|
* subclass that implements the steps for its claim.
|
|
9
8
|
*
|
|
10
|
-
* A backend keeps
|
|
9
|
+
* A backend keeps five rules:
|
|
11
10
|
* - `tryClaim` returns the epoch of the term it won, in the same step, and
|
|
12
11
|
* each epoch is higher than every earlier one and below 2^31.
|
|
12
|
+
* - `tryClaim` reports `afterCleanShutdown` only when the term just before
|
|
13
|
+
* it recorded a clean shutdown with `recordCleanShutdown`, and it reports
|
|
14
|
+
* each record once.
|
|
13
15
|
* - `watch` calls `lose` before the backend can grant the claim to another
|
|
14
16
|
* candidate, so an old leader stops before a new one starts.
|
|
15
17
|
* - `release` is never called after a loss: the claim may be another
|
|
@@ -34,34 +36,32 @@ export class LeaderElection {
|
|
|
34
36
|
}
|
|
35
37
|
async #attempt(deadline, signal) {
|
|
36
38
|
const claim = await this.open(signal);
|
|
37
|
-
let
|
|
39
|
+
let won;
|
|
38
40
|
try {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
break;
|
|
45
|
-
await untilAborted(delay(this.#pollInterval, undefined, { signal }), signal);
|
|
46
|
-
}
|
|
41
|
+
won = await poll(() => this.tryClaim(claim, signal), {
|
|
42
|
+
interval: this.#pollInterval,
|
|
43
|
+
signal,
|
|
44
|
+
deadline,
|
|
45
|
+
});
|
|
47
46
|
}
|
|
48
47
|
catch (error) {
|
|
49
48
|
await this.close(claim).catch(() => { });
|
|
50
49
|
throw error;
|
|
51
50
|
}
|
|
52
|
-
if (
|
|
51
|
+
if (won === undefined) {
|
|
53
52
|
await this.close(claim);
|
|
54
53
|
return undefined;
|
|
55
54
|
}
|
|
56
|
-
return this.#begin(claim,
|
|
55
|
+
return this.#begin(claim, won, signal);
|
|
57
56
|
}
|
|
58
57
|
/** Hands a won claim to its term, or gives it up when the caller left meanwhile. */
|
|
59
|
-
async #begin(claim,
|
|
58
|
+
async #begin(claim, won, signal) {
|
|
60
59
|
let term;
|
|
61
60
|
try {
|
|
62
61
|
signal?.throwIfAborted();
|
|
63
|
-
term = new Term(
|
|
62
|
+
term = new Term(won, {
|
|
64
63
|
watch: (lose) => this.watch(claim, lose),
|
|
64
|
+
recordCleanShutdown: () => this.recordCleanShutdown(claim, won.epoch),
|
|
65
65
|
release: () => this.release(claim),
|
|
66
66
|
close: () => this.close(claim),
|
|
67
67
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"leader-election.js","sourceRoot":"","sources":["../src/leader-election.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"leader-election.js","sourceRoot":"","sources":["../src/leader-election.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { FileLock } from '@zukhruf/fs';
|
|
2
2
|
import { LeaderElection } from '../leader-election.ts';
|
|
3
|
+
import type { Term } from '../term.ts';
|
|
3
4
|
export interface SqliteElectionOptions {
|
|
4
5
|
/** The local folder where the candidates meet. */
|
|
5
6
|
directory: string;
|
|
6
7
|
/**
|
|
7
8
|
* The file in `directory` whose file lock is the claim.
|
|
8
9
|
* Never delete it while candidates run: a new file would let a second
|
|
9
|
-
* leader win.
|
|
10
|
+
* leader win. A term that resigns clean writes `<claimFile>.clean` beside
|
|
11
|
+
* it, and the next term reads and removes it.
|
|
10
12
|
*/
|
|
11
13
|
claimFile: string;
|
|
12
14
|
/** The file in `directory` that records the epoch of the last term, as decimal text. */
|
|
@@ -24,9 +26,15 @@ export declare class SqliteElection extends LeaderElection<FileLock> {
|
|
|
24
26
|
#private;
|
|
25
27
|
constructor({ directory, claimFile, epochFile, pollInterval, }: SqliteElectionOptions);
|
|
26
28
|
protected open(): Promise<FileLock>;
|
|
27
|
-
protected tryClaim(claim: FileLock): Promise<
|
|
29
|
+
protected tryClaim(claim: FileLock): Promise<Pick<Term, 'epoch' | 'afterCleanShutdown'> | undefined>;
|
|
28
30
|
/** The kernel holds the claim for as long as the process lives, so there is nothing to watch. */
|
|
29
31
|
protected watch(): Disposable;
|
|
32
|
+
/**
|
|
33
|
+
* A lost note costs the next term only its recovery, so the note needs no
|
|
34
|
+
* sync to the disk: an atomic replace is enough, and a reader never sees a
|
|
35
|
+
* part of it.
|
|
36
|
+
*/
|
|
37
|
+
protected recordCleanShutdown(_claim: FileLock, epoch: bigint): Promise<void>;
|
|
30
38
|
protected release(claim: FileLock): Promise<void>;
|
|
31
39
|
protected close(claim: FileLock): Promise<void>;
|
|
32
40
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sqlite-election.d.ts","sourceRoot":"","sources":["../../src/sqlite/sqlite-election.ts"],"names":[],"mappings":"AAGA,OAAO,EACL,QAAQ,
|
|
1
|
+
{"version":3,"file":"sqlite-election.d.ts","sourceRoot":"","sources":["../../src/sqlite/sqlite-election.ts"],"names":[],"mappings":"AAGA,OAAO,EACL,QAAQ,EAKT,MAAM,aAAa,CAAC;AAErB,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AACvD,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,YAAY,CAAC;AAEvC,MAAM,WAAW,qBAAqB;IACpC,kDAAkD;IAClD,SAAS,EAAE,MAAM,CAAC;IAClB;;;;;OAKG;IACH,SAAS,EAAE,MAAM,CAAC;IAClB,wFAAwF;IACxF,SAAS,EAAE,MAAM,CAAC;IAClB,kFAAkF;IAClF,YAAY,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CACnC;AAED;;;;;GAKG;AACH,qBAAa,cAAe,SAAQ,cAAc,CAAC,QAAQ,CAAC;;gBAM9C,EACV,SAAS,EACT,SAAS,EACT,SAAS,EACT,YAAiB,GAClB,EAAE,qBAAqB;cAQR,IAAI,IAAI,OAAO,CAAC,QAAQ,CAAC;cAMzB,QAAQ,CACtB,KAAK,EAAE,QAAQ,GACd,OAAO,CAAC,IAAI,CAAC,IAAI,EAAE,OAAO,GAAG,oBAAoB,CAAC,GAAG,SAAS,CAAC;IAgBlE,iGAAiG;IACjG,SAAS,CAAC,KAAK,IAAI,UAAU;IAI7B;;;;OAIG;cACa,mBAAmB,CACjC,MAAM,EAAE,QAAQ,EAChB,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,IAAI,CAAC;cAIA,OAAO,CAAC,KAAK,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;cAIvC,KAAK,CAAC,KAAK,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;CAGtD"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { mkdir, readFile } from 'node:fs/promises';
|
|
1
|
+
import { mkdir, readFile, rm } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import { FileLock, assertLocalDirectory, durableWrite, isErrno, } from '@zukhruf/fs';
|
|
3
|
+
import { FileLock, assertLocalDirectory, atomicWrite, durableWrite, isErrno, } from '@zukhruf/fs';
|
|
4
4
|
import { LeaderElection } from "../leader-election.js";
|
|
5
5
|
/**
|
|
6
6
|
* Elects one leader among the processes of one host that share `directory`.
|
|
@@ -12,11 +12,13 @@ export class SqliteElection extends LeaderElection {
|
|
|
12
12
|
#directory;
|
|
13
13
|
#claimPath;
|
|
14
14
|
#epochPath;
|
|
15
|
+
#cleanShutdownPath;
|
|
15
16
|
constructor({ directory, claimFile, epochFile, pollInterval = 10, }) {
|
|
16
17
|
super(pollInterval);
|
|
17
18
|
this.#directory = directory;
|
|
18
19
|
this.#claimPath = join(directory, claimFile);
|
|
19
20
|
this.#epochPath = join(directory, epochFile);
|
|
21
|
+
this.#cleanShutdownPath = `${this.#claimPath}.clean`;
|
|
20
22
|
}
|
|
21
23
|
async open() {
|
|
22
24
|
await assertLocalDirectory(this.#directory);
|
|
@@ -27,14 +29,29 @@ export class SqliteElection extends LeaderElection {
|
|
|
27
29
|
if (!claim.tryLock())
|
|
28
30
|
return undefined;
|
|
29
31
|
// Safe without further locking: only the holder of the claim gets here.
|
|
30
|
-
const
|
|
32
|
+
const previous = await readEpoch(this.#epochPath);
|
|
33
|
+
// Only a note that names the epoch just before this one tells of a clean
|
|
34
|
+
// shutdown: an older note is from a term that ended long ago.
|
|
35
|
+
const afterCleanShutdown = (await readCleanShutdown(this.#cleanShutdownPath)) ===
|
|
36
|
+
previous.toString();
|
|
37
|
+
const epoch = previous + 1n;
|
|
31
38
|
await durableWrite(this.#epochPath, epoch.toString());
|
|
32
|
-
|
|
39
|
+
// Read once: if the epochs ever start again, an old note could name one of them.
|
|
40
|
+
await rm(this.#cleanShutdownPath, { force: true });
|
|
41
|
+
return { epoch, afterCleanShutdown };
|
|
33
42
|
}
|
|
34
43
|
/** The kernel holds the claim for as long as the process lives, so there is nothing to watch. */
|
|
35
44
|
watch() {
|
|
36
45
|
return { [Symbol.dispose]() { } };
|
|
37
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* A lost note costs the next term only its recovery, so the note needs no
|
|
49
|
+
* sync to the disk: an atomic replace is enough, and a reader never sees a
|
|
50
|
+
* part of it.
|
|
51
|
+
*/
|
|
52
|
+
async recordCleanShutdown(_claim, epoch) {
|
|
53
|
+
await atomicWrite(this.#cleanShutdownPath, epoch.toString());
|
|
54
|
+
}
|
|
38
55
|
async release(claim) {
|
|
39
56
|
claim.unlock();
|
|
40
57
|
}
|
|
@@ -42,14 +59,34 @@ export class SqliteElection extends LeaderElection {
|
|
|
42
59
|
claim[Symbol.dispose]();
|
|
43
60
|
}
|
|
44
61
|
}
|
|
62
|
+
/** The epoch that the note names, as its text, or `undefined` when there is no note. */
|
|
63
|
+
async function readCleanShutdown(path) {
|
|
64
|
+
try {
|
|
65
|
+
return await readFile(path, 'utf8');
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (isErrno(error, 'ENOENT'))
|
|
69
|
+
return undefined;
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The epoch of the last term, or 0 when there is no epoch file. Only decimal
|
|
75
|
+
* digits are an epoch: `BigInt` alone would also read `''` as 0, and the
|
|
76
|
+
* epochs would start again at 1.
|
|
77
|
+
*/
|
|
45
78
|
async function readEpoch(path) {
|
|
79
|
+
let text;
|
|
46
80
|
try {
|
|
47
|
-
|
|
81
|
+
text = await readFile(path, 'utf8');
|
|
48
82
|
}
|
|
49
83
|
catch (error) {
|
|
50
84
|
if (isErrno(error, 'ENOENT'))
|
|
51
85
|
return 0n;
|
|
52
86
|
throw error;
|
|
53
87
|
}
|
|
88
|
+
if (!/^\d+$/.test(text))
|
|
89
|
+
throw new SyntaxError(`The epoch file ${path} holds ${JSON.stringify(text)}, not decimal digits.`);
|
|
90
|
+
return BigInt(text);
|
|
54
91
|
}
|
|
55
92
|
//# sourceMappingURL=sqlite-election.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sqlite-election.js","sourceRoot":"","sources":["../../src/sqlite/sqlite-election.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;
|
|
1
|
+
{"version":3,"file":"sqlite-election.js","sourceRoot":"","sources":["../../src/sqlite/sqlite-election.ts"],"names":[],"mappings":"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"}
|
package/dist/term.d.ts
CHANGED
|
@@ -3,11 +3,24 @@ import { type Lease } from '@zukhruf/lease';
|
|
|
3
3
|
export interface TermSteps {
|
|
4
4
|
/** Starts watching the claim; `lose` reports that the backend took it away. */
|
|
5
5
|
watch(lose: (reason: Error) => void): Disposable;
|
|
6
|
+
/**
|
|
7
|
+
* Records that the term resigned clean, while it still holds the claim.
|
|
8
|
+
* Called only for a term that was not lost, before `release`.
|
|
9
|
+
*/
|
|
10
|
+
recordCleanShutdown(): Promise<void>;
|
|
6
11
|
/** Gives the claim up. Called only for a term that was not lost. */
|
|
7
12
|
release(): Promise<void>;
|
|
8
13
|
/** Frees the claim's resources. Called at each end. */
|
|
9
14
|
close(): Promise<void>;
|
|
10
15
|
}
|
|
16
|
+
export interface ResignOptions {
|
|
17
|
+
/**
|
|
18
|
+
* The leader says that it leaves nothing for its successor to recover,
|
|
19
|
+
* for example no work in progress. The next term then reads
|
|
20
|
+
* `afterCleanShutdown` as true. A lost term never says it.
|
|
21
|
+
*/
|
|
22
|
+
clean: boolean;
|
|
23
|
+
}
|
|
11
24
|
/**
|
|
12
25
|
* One term of the elected leader: a lease on leadership. It ends when the
|
|
13
26
|
* leader resigns, when its process dies, or when the backend takes the claim
|
|
@@ -18,15 +31,22 @@ export declare class Term implements Lease, AsyncDisposable {
|
|
|
18
31
|
#private;
|
|
19
32
|
/** Higher than the epoch of each earlier term, so a newer leader can always outrank an older one. */
|
|
20
33
|
readonly epoch: bigint;
|
|
21
|
-
|
|
34
|
+
/**
|
|
35
|
+
* True only when the term just before this one resigned clean: its leader
|
|
36
|
+
* left nothing to recover. False after a crash, a lost term, a resign that
|
|
37
|
+
* did not say it was clean, and for the first term.
|
|
38
|
+
*/
|
|
39
|
+
readonly afterCleanShutdown: boolean;
|
|
40
|
+
constructor({ epoch, afterCleanShutdown }: Pick<Term, 'epoch' | 'afterCleanShutdown'>, steps: TermSteps);
|
|
22
41
|
/** Aborts with `LeaseLostError` when the term ends while its leader still runs. */
|
|
23
42
|
get signal(): AbortSignal;
|
|
24
43
|
/**
|
|
25
|
-
* Ends the term and gives the claim up.
|
|
26
|
-
*
|
|
27
|
-
*
|
|
44
|
+
* Ends the term and gives the claim up. With `clean`, it first records a
|
|
45
|
+
* clean shutdown for the next term. A second call waits for the first, and
|
|
46
|
+
* its options do nothing. After a loss, it only waits until the claim's
|
|
47
|
+
* resources are free, records nothing, and never rejects.
|
|
28
48
|
*/
|
|
29
|
-
resign(): Promise<void>;
|
|
49
|
+
resign(options?: ResignOptions): Promise<void>;
|
|
30
50
|
[Symbol.asyncDispose](): Promise<void>;
|
|
31
51
|
}
|
|
32
52
|
//# sourceMappingURL=term.d.ts.map
|
package/dist/term.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"term.d.ts","sourceRoot":"","sources":["../src/term.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,KAAK,EAAmB,MAAM,gBAAgB,CAAC;AAE7D,4FAA4F;AAC5F,MAAM,WAAW,SAAS;IACxB,+EAA+E;IAC/E,KAAK,CAAC,IAAI,EAAE,CAAC,MAAM,EAAE,KAAK,KAAK,IAAI,GAAG,UAAU,CAAC;IACjD,oEAAoE;IACpE,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACzB,uDAAuD;IACvD,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAKD;;;;;GAKG;AACH,qBAAa,IAAK,YAAW,KAAK,EAAE,eAAe;;IACjD,qGAAqG;IACrG,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"term.d.ts","sourceRoot":"","sources":["../src/term.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,KAAK,EAAmB,MAAM,gBAAgB,CAAC;AAE7D,4FAA4F;AAC5F,MAAM,WAAW,SAAS;IACxB,+EAA+E;IAC/E,KAAK,CAAC,IAAI,EAAE,CAAC,MAAM,EAAE,KAAK,KAAK,IAAI,GAAG,UAAU,CAAC;IACjD;;;OAGG;IACH,mBAAmB,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACrC,oEAAoE;IACpE,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACzB,uDAAuD;IACvD,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAED,MAAM,WAAW,aAAa;IAC5B;;;;OAIG;IACH,KAAK,EAAE,OAAO,CAAC;CAChB;AAKD;;;;;GAKG;AACH,qBAAa,IAAK,YAAW,KAAK,EAAE,eAAe;;IACjD,qGAAqG;IACrG,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB;;;;OAIG;IACH,QAAQ,CAAC,kBAAkB,EAAE,OAAO,CAAC;gBASnC,EAAE,KAAK,EAAE,kBAAkB,EAAE,EAAE,IAAI,CAAC,IAAI,EAAE,OAAO,GAAG,oBAAoB,CAAC,EACzE,KAAK,EAAE,SAAS;IAQlB,mFAAmF;IACnF,IAAI,MAAM,IAAI,WAAW,CAExB;IAED;;;;;OAKG;IACG,MAAM,CAAC,OAAO,CAAC,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC;IAUpD,CAAC,MAAM,CAAC,YAAY,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC;CAiDvC"}
|
package/dist/term.js
CHANGED
|
@@ -9,14 +9,21 @@ import { LeaseController } from '@zukhruf/lease';
|
|
|
9
9
|
export class Term {
|
|
10
10
|
/** Higher than the epoch of each earlier term, so a newer leader can always outrank an older one. */
|
|
11
11
|
epoch;
|
|
12
|
+
/**
|
|
13
|
+
* True only when the term just before this one resigned clean: its leader
|
|
14
|
+
* left nothing to recover. False after a crash, a lost term, a resign that
|
|
15
|
+
* did not say it was clean, and for the first term.
|
|
16
|
+
*/
|
|
17
|
+
afterCleanShutdown;
|
|
12
18
|
#steps;
|
|
13
19
|
#lease = new LeaseController('leadership');
|
|
14
20
|
/** Aborts once the term starts to end, by resign or by loss. */
|
|
15
21
|
#ending = new AbortController();
|
|
16
22
|
#ended = new Latch();
|
|
17
23
|
#watching = new DisposableStack();
|
|
18
|
-
constructor(epoch, steps) {
|
|
24
|
+
constructor({ epoch, afterCleanShutdown }, steps) {
|
|
19
25
|
this.epoch = epoch;
|
|
26
|
+
this.afterCleanShutdown = afterCleanShutdown;
|
|
20
27
|
this.#steps = steps;
|
|
21
28
|
this.#watching.use(steps.watch((reason) => this.#lose(reason)));
|
|
22
29
|
}
|
|
@@ -25,15 +32,16 @@ export class Term {
|
|
|
25
32
|
return this.#lease.signal;
|
|
26
33
|
}
|
|
27
34
|
/**
|
|
28
|
-
* Ends the term and gives the claim up.
|
|
29
|
-
*
|
|
30
|
-
*
|
|
35
|
+
* Ends the term and gives the claim up. With `clean`, it first records a
|
|
36
|
+
* clean shutdown for the next term. A second call waits for the first, and
|
|
37
|
+
* its options do nothing. After a loss, it only waits until the claim's
|
|
38
|
+
* resources are free, records nothing, and never rejects.
|
|
31
39
|
*/
|
|
32
|
-
async resign() {
|
|
40
|
+
async resign(options) {
|
|
33
41
|
if (!this.#ending.signal.aborted) {
|
|
34
42
|
this.#ending.abort();
|
|
35
43
|
this.#lease.end();
|
|
36
|
-
this.#ended.open(await this.#giveUp());
|
|
44
|
+
this.#ended.open(await this.#giveUp(options?.clean === true));
|
|
37
45
|
}
|
|
38
46
|
const ending = await this.#ended.wait();
|
|
39
47
|
if (ending.failed)
|
|
@@ -56,9 +64,12 @@ export class Term {
|
|
|
56
64
|
.then(() => this.#freeLost())
|
|
57
65
|
.then((ending) => this.#ended.open(ending));
|
|
58
66
|
}
|
|
59
|
-
async #giveUp() {
|
|
67
|
+
async #giveUp(clean) {
|
|
60
68
|
try {
|
|
61
69
|
this.#watching.dispose();
|
|
70
|
+
// Before the release: until then no other candidate can hold the claim.
|
|
71
|
+
if (clean)
|
|
72
|
+
await this.#steps.recordCleanShutdown();
|
|
62
73
|
await this.#steps.release();
|
|
63
74
|
}
|
|
64
75
|
catch (error) {
|
package/dist/term.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"term.js","sourceRoot":"","sources":["../src/term.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,gBAAgB,CAAC;AACvC,OAAO,EAAc,eAAe,EAAE,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"term.js","sourceRoot":"","sources":["../src/term.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zukhruf/election",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.20",
|
|
4
4
|
"description": "Elect one leader among the processes that share a directory, with a term that ends when the leader dies and an epoch that grows with each term. Backends are subclasses of one campaign.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|