@zukhruf/election 0.3.17 → 0.3.19
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 -20
- 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 +12 -8
- 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 +32 -4
- 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,7 @@ 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
|
+
- 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
54
|
- The directory must be on a local file system. On a network file system, `campaign` rejects with `NetworkDirectoryError`.
|
|
54
55
|
- `pollInterval` is the time between two tries, in milliseconds. It defaults to 10. A try never blocks the process.
|
|
55
56
|
|
|
@@ -58,7 +59,9 @@ next term: 2n
|
|
|
58
59
|
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
60
|
|
|
60
61
|
- `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.
|
|
62
|
+
- `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.
|
|
63
|
+
- `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.
|
|
64
|
+
- `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
65
|
- `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
66
|
|
|
64
67
|
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 +79,24 @@ const token = await tokens.next('orders');
|
|
|
76
79
|
|
|
77
80
|
## Write a backend
|
|
78
81
|
|
|
79
|
-
`LeaderElection` is an abstract class. It runs the campaign, and it owns the term. A backend is a subclass that implements
|
|
82
|
+
`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
83
|
|
|
81
|
-
| Step
|
|
82
|
-
|
|
|
83
|
-
| `open(signal)`
|
|
84
|
-
| `tryClaim(claim, signal)`
|
|
85
|
-
| `watch(claim, lose)`
|
|
86
|
-
| `
|
|
87
|
-
| `
|
|
84
|
+
| Step | What it does |
|
|
85
|
+
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
86
|
+
| `open(signal)` | Prepares one attempt, for example a connection. The campaign calls `tryClaim` on it again and again. |
|
|
87
|
+
| `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. |
|
|
88
|
+
| `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. |
|
|
89
|
+
| `recordCleanShutdown(claim, epoch)` | Records that the term of `epoch` resigned clean. The campaign calls it before `release`, while the term still holds the claim. |
|
|
90
|
+
| `release(claim)` | Gives a won claim up when the leader resigns. |
|
|
91
|
+
| `close(claim)` | Frees the resources of an attempt, at each end. |
|
|
88
92
|
|
|
89
|
-
A backend must keep
|
|
93
|
+
A backend must keep five rules:
|
|
90
94
|
|
|
91
95
|
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 `
|
|
96
|
+
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.
|
|
97
|
+
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.
|
|
98
|
+
4. The campaign never calls `recordCleanShutdown` or `release` after a loss, because the claim can be another candidate's then.
|
|
99
|
+
5. The campaign calls `close` after each end: a won term, a lost term, a failed attempt, or an attempt that never won.
|
|
95
100
|
|
|
96
101
|
This backend elects among the candidates of one process. Its claim is an entry in a map:
|
|
97
102
|
|
|
@@ -99,6 +104,7 @@ This backend elects among the candidates of one process. Its claim is an entry i
|
|
|
99
104
|
import { LeaderElection } from '@zukhruf/election';
|
|
100
105
|
|
|
101
106
|
const holders = new Map<string, bigint>();
|
|
107
|
+
const cleanShutdowns = new Set<string>();
|
|
102
108
|
let lastEpoch = 0n;
|
|
103
109
|
|
|
104
110
|
class MapElection extends LeaderElection<string> {
|
|
@@ -115,9 +121,12 @@ class MapElection extends LeaderElection<string> {
|
|
|
115
121
|
|
|
116
122
|
protected async tryClaim(name: string) {
|
|
117
123
|
if (holders.has(name)) return undefined;
|
|
124
|
+
// Only the holder records, and each claim removes the record. Thus a
|
|
125
|
+
// record is always from the term just before this one.
|
|
126
|
+
const afterCleanShutdown = cleanShutdowns.delete(name);
|
|
118
127
|
lastEpoch += 1n;
|
|
119
128
|
holders.set(name, lastEpoch);
|
|
120
|
-
return lastEpoch;
|
|
129
|
+
return { epoch: lastEpoch, afterCleanShutdown };
|
|
121
130
|
}
|
|
122
131
|
|
|
123
132
|
// Nothing takes an entry of the map away, so there is nothing to watch.
|
|
@@ -125,6 +134,10 @@ class MapElection extends LeaderElection<string> {
|
|
|
125
134
|
return { [Symbol.dispose]() {} };
|
|
126
135
|
}
|
|
127
136
|
|
|
137
|
+
protected async recordCleanShutdown(name: string) {
|
|
138
|
+
cleanShutdowns.add(name);
|
|
139
|
+
}
|
|
140
|
+
|
|
128
141
|
protected async release(name: string) {
|
|
129
142
|
holders.delete(name);
|
|
130
143
|
}
|
|
@@ -135,11 +148,10 @@ class MapElection extends LeaderElection<string> {
|
|
|
135
148
|
const term = await new MapElection('jobs').campaign();
|
|
136
149
|
console.log('leader:', term?.epoch);
|
|
137
150
|
console.log('rival:', await new MapElection('jobs').campaign());
|
|
138
|
-
await term?.resign();
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
);
|
|
151
|
+
await term?.resign({ clean: true });
|
|
152
|
+
const next = await new MapElection('jobs').campaign();
|
|
153
|
+
console.log('rival after the resign:', next?.epoch);
|
|
154
|
+
console.log('after a clean shutdown:', next?.afterCleanShutdown);
|
|
143
155
|
```
|
|
144
156
|
|
|
145
157
|
Output:
|
|
@@ -148,9 +160,10 @@ Output:
|
|
|
148
160
|
leader: 1n
|
|
149
161
|
rival: undefined
|
|
150
162
|
rival after the resign: 2n
|
|
163
|
+
after a clean shutdown: true
|
|
151
164
|
```
|
|
152
165
|
|
|
153
|
-
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`
|
|
166
|
+
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.
|
|
154
167
|
|
|
155
168
|
## Errors
|
|
156
169
|
|
|
@@ -166,6 +179,7 @@ The socket lock store of `@zukhruf/mutex` uses `SqliteElection`, with the claim
|
|
|
166
179
|
- [Recipe: Run a job in only one process](./docs/recipes/singleton-job-with-leader-election.md)
|
|
167
180
|
- [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
181
|
- [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)
|
|
182
|
+
- [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
183
|
- [Code copied into this package](./docs/copied-code.md)
|
|
170
184
|
|
|
171
185
|
## 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":"AAIA,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
|
|
1
|
+
{"version":3,"file":"leader-election.d.ts","sourceRoot":"","sources":["../src/leader-election.ts"],"names":[],"mappings":"AAIA,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;CAmDtD"}
|
package/dist/leader-election.js
CHANGED
|
@@ -7,9 +7,12 @@ import { Term } from "./term.js";
|
|
|
7
7
|
* up, watch the won claim, and hand it to a term. Each backend is a
|
|
8
8
|
* subclass that implements the steps for its claim.
|
|
9
9
|
*
|
|
10
|
-
* A backend keeps
|
|
10
|
+
* A backend keeps five rules:
|
|
11
11
|
* - `tryClaim` returns the epoch of the term it won, in the same step, and
|
|
12
12
|
* each epoch is higher than every earlier one and below 2^31.
|
|
13
|
+
* - `tryClaim` reports `afterCleanShutdown` only when the term just before
|
|
14
|
+
* it recorded a clean shutdown with `recordCleanShutdown`, and it reports
|
|
15
|
+
* each record once.
|
|
13
16
|
* - `watch` calls `lose` before the backend can grant the claim to another
|
|
14
17
|
* candidate, so an old leader stops before a new one starts.
|
|
15
18
|
* - `release` is never called after a loss: the claim may be another
|
|
@@ -34,11 +37,11 @@ export class LeaderElection {
|
|
|
34
37
|
}
|
|
35
38
|
async #attempt(deadline, signal) {
|
|
36
39
|
const claim = await this.open(signal);
|
|
37
|
-
let
|
|
40
|
+
let won;
|
|
38
41
|
try {
|
|
39
42
|
for (;;) {
|
|
40
|
-
|
|
41
|
-
if (
|
|
43
|
+
won = await this.tryClaim(claim, signal);
|
|
44
|
+
if (won !== undefined)
|
|
42
45
|
break;
|
|
43
46
|
if (performance.now() >= deadline)
|
|
44
47
|
break;
|
|
@@ -49,19 +52,20 @@ export class LeaderElection {
|
|
|
49
52
|
await this.close(claim).catch(() => { });
|
|
50
53
|
throw error;
|
|
51
54
|
}
|
|
52
|
-
if (
|
|
55
|
+
if (won === undefined) {
|
|
53
56
|
await this.close(claim);
|
|
54
57
|
return undefined;
|
|
55
58
|
}
|
|
56
|
-
return this.#begin(claim,
|
|
59
|
+
return this.#begin(claim, won, signal);
|
|
57
60
|
}
|
|
58
61
|
/** Hands a won claim to its term, or gives it up when the caller left meanwhile. */
|
|
59
|
-
async #begin(claim,
|
|
62
|
+
async #begin(claim, won, signal) {
|
|
60
63
|
let term;
|
|
61
64
|
try {
|
|
62
65
|
signal?.throwIfAborted();
|
|
63
|
-
term = new Term(
|
|
66
|
+
term = new Term(won, {
|
|
64
67
|
watch: (lose) => this.watch(claim, lose),
|
|
68
|
+
recordCleanShutdown: () => this.recordCleanShutdown(claim, won.epoch),
|
|
65
69
|
release: () => this.release(claim),
|
|
66
70
|
close: () => this.close(claim),
|
|
67
71
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"leader-election.js","sourceRoot":"","sources":["../src/leader-election.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,IAAI,KAAK,EAAE,MAAM,sBAAsB,CAAC;AAE3D,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAE9C,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AASjC
|
|
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,6 +59,17 @@ 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
|
+
}
|
|
45
73
|
async function readEpoch(path) {
|
|
46
74
|
try {
|
|
47
75
|
return BigInt(await readFile(path, 'utf8'));
|
|
@@ -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.19",
|
|
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": {
|