@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 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 five steps for its claim:
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 | What it does |
82
- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
83
- | `open(signal)` | Prepares one attempt, for example a connection. The campaign calls `tryClaim` on it again and again. |
84
- | `tryClaim(claim, signal)` | Tries the claim once. Resolves with the epoch of the term it won, or `undefined` while another candidate holds the claim. |
85
- | `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. |
86
- | `release(claim)` | Gives a won claim up when the leader resigns. |
87
- | `close(claim)` | Frees the resources of an attempt, at each end. |
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 four rules:
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. `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.
93
- 3. The campaign never calls `release` after a loss, because the claim can be another candidate's then.
94
- 4. The campaign calls `close` after each end: a won term, a lost term, a failed attempt, or an attempt that never won.
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
- console.log(
140
- 'rival after the resign:',
141
- (await new MapElection('jobs').campaign())?.epoch,
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` does not use this package yet, and keeps its own copy of the election ([copied code](./docs/copied-code.md)).
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';
@@ -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;AACjD,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"}
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,EAAE,IAAI,EAAkB,MAAM,WAAW,CAAC;AACjD,OAAO,EACL,cAAc,GAEf,MAAM,6BAA6B,CAAC;AACrC,OAAO,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAC;AACpD,OAAO,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC"}
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 four rules:
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
- /** Tries the claim once. Resolves with the epoch of the term it won, or `undefined` while another candidate holds it. */
31
- protected abstract tryClaim(claim: Claim, signal: AbortSignal | undefined): Promise<bigint | undefined>;
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;;;;;;;;;;;;;;GAcG;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,yHAAyH;IACzH,SAAS,CAAC,QAAQ,CAAC,QAAQ,CACzB,KAAK,EAAE,KAAK,EACZ,MAAM,EAAE,WAAW,GAAG,SAAS,GAC9B,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC;IAE9B,0GAA0G;IAC1G,SAAS,CAAC,QAAQ,CAAC,KAAK,CACtB,KAAK,EAAE,KAAK,EACZ,IAAI,EAAE,CAAC,MAAM,EAAE,KAAK,KAAK,IAAI,GAC5B,UAAU;IAEb,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;CAkDtD"}
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"}
@@ -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 four rules:
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 epoch;
40
+ let won;
38
41
  try {
39
42
  for (;;) {
40
- epoch = await this.tryClaim(claim, signal);
41
- if (epoch !== undefined)
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 (epoch === undefined) {
55
+ if (won === undefined) {
53
56
  await this.close(claim);
54
57
  return undefined;
55
58
  }
56
- return this.#begin(claim, epoch, signal);
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, epoch, signal) {
62
+ async #begin(claim, won, signal) {
60
63
  let term;
61
64
  try {
62
65
  signal?.throwIfAborted();
63
- term = new Term(epoch, {
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":"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"}
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<bigint | undefined>;
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,EAIT,MAAM,aAAa,CAAC;AAErB,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAEvD,MAAM,WAAW,qBAAqB;IACpC,kDAAkD;IAClD,SAAS,EAAE,MAAM,CAAC;IAClB;;;;OAIG;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;;gBAK9C,EACV,SAAS,EACT,SAAS,EACT,SAAS,EACT,YAAiB,GAClB,EAAE,qBAAqB;cAOR,IAAI,IAAI,OAAO,CAAC,QAAQ,CAAC;cAMzB,QAAQ,CAAC,KAAK,EAAE,QAAQ,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC;IAQtE,iGAAiG;IACjG,SAAS,CAAC,KAAK,IAAI,UAAU;cAIb,OAAO,CAAC,KAAK,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;cAIvC,KAAK,CAAC,KAAK,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;CAGtD"}
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 epoch = (await readEpoch(this.#epochPath)) + 1n;
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
- return epoch;
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;AACnD,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAEjC,OAAO,EACL,QAAQ,EACR,oBAAoB,EACpB,YAAY,EACZ,OAAO,GACR,MAAM,aAAa,CAAC;AAErB,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAiBvD;;;;;GAKG;AACH,MAAM,OAAO,cAAe,SAAQ,cAAwB;IACjD,UAAU,CAAS;IACnB,UAAU,CAAS;IACnB,UAAU,CAAS;IAE5B,YAAY,EACV,SAAS,EACT,SAAS,EACT,SAAS,EACT,YAAY,GAAG,EAAE,GACK;QACtB,KAAK,CAAC,YAAY,CAAC,CAAC;QACpB,IAAI,CAAC,UAAU,GAAG,SAAS,CAAC;QAC5B,IAAI,CAAC,UAAU,GAAG,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,CAAC;QAC7C,IAAI,CAAC,UAAU,GAAG,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,CAAC;IAC/C,CAAC;IAES,KAAK,CAAC,IAAI;QAClB,MAAM,oBAAoB,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;QAC5C,MAAM,KAAK,CAAC,IAAI,CAAC,UAAU,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QAClD,OAAO,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;IACxC,CAAC;IAES,KAAK,CAAC,QAAQ,CAAC,KAAe;QACtC,IAAI,CAAC,KAAK,CAAC,OAAO,EAAE;YAAE,OAAO,SAAS,CAAC;QACvC,wEAAwE;QACxE,MAAM,KAAK,GAAG,CAAC,MAAM,SAAS,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,GAAG,EAAE,CAAC;QACtD,MAAM,YAAY,CAAC,IAAI,CAAC,UAAU,EAAE,KAAK,CAAC,QAAQ,EAAE,CAAC,CAAC;QACtD,OAAO,KAAK,CAAC;IACf,CAAC;IAED,iGAAiG;IACvF,KAAK;QACb,OAAO,EAAE,CAAC,MAAM,CAAC,OAAO,CAAC,KAAI,CAAC,EAAE,CAAC;IACnC,CAAC;IAES,KAAK,CAAC,OAAO,CAAC,KAAe;QACrC,KAAK,CAAC,MAAM,EAAE,CAAC;IACjB,CAAC;IAES,KAAK,CAAC,KAAK,CAAC,KAAe;QACnC,KAAK,CAAC,MAAM,CAAC,OAAO,CAAC,EAAE,CAAC;IAC1B,CAAC;CACF;AAED,KAAK,UAAU,SAAS,CAAC,IAAY;IACnC,IAAI,CAAC;QACH,OAAO,MAAM,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC;IAC9C,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAI,OAAO,CAAC,KAAK,EAAE,QAAQ,CAAC;YAAE,OAAO,EAAE,CAAC;QACxC,MAAM,KAAK,CAAC;IACd,CAAC;AACH,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
- constructor(epoch: bigint, steps: TermSteps);
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. A second call waits for the first.
26
- * After a loss, it only waits until the claim's resources are free, and it
27
- * never rejects.
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
@@ -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;gBAQX,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS;IAM3C,mFAAmF;IACnF,IAAI,MAAM,IAAI,WAAW,CAExB;IAED;;;;OAIG;IACG,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC;IAU7B,CAAC,MAAM,CAAC,YAAY,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC;CA+CvC"}
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. A second call waits for the first.
29
- * After a loss, it only waits until the claim's resources are free, and it
30
- * never rejects.
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":"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"}
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.17",
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": {