@zukhruf/single-flight 0.0.0-stage → 0.3.13
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/LICENSE +21 -0
- package/README.md +203 -3
- package/dist/errors.d.ts +22 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +34 -0
- package/dist/errors.js.map +1 -0
- package/dist/file-flight-records.d.ts +22 -0
- package/dist/file-flight-records.d.ts.map +1 -0
- package/dist/file-flight-records.js +124 -0
- package/dist/file-flight-records.js.map +1 -0
- package/dist/flight-records.d.ts +67 -0
- package/dist/flight-records.d.ts.map +1 -0
- package/dist/flight-records.js +38 -0
- package/dist/flight-records.js.map +1 -0
- package/dist/flight.d.ts +12 -0
- package/dist/flight.d.ts.map +1 -0
- package/dist/flight.js +33 -0
- package/dist/flight.js.map +1 -0
- package/dist/follow.d.ts +31 -0
- package/dist/follow.d.ts.map +1 -0
- package/dist/follow.js +60 -0
- package/dist/follow.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/patiently.d.ts +3 -0
- package/dist/patiently.d.ts.map +1 -0
- package/dist/patiently.js +31 -0
- package/dist/patiently.js.map +1 -0
- package/dist/shared-flight.d.ts +25 -0
- package/dist/shared-flight.d.ts.map +1 -0
- package/dist/shared-flight.js +89 -0
- package/dist/shared-flight.js.map +1 -0
- package/dist/single-flight.d.ts +23 -0
- package/dist/single-flight.d.ts.map +1 -0
- package/dist/single-flight.js +39 -0
- package/dist/single-flight.js.map +1 -0
- package/dist/until-aborted.d.ts +3 -0
- package/dist/until-aborted.d.ts.map +1 -0
- package/dist/until-aborted.js +19 -0
- package/dist/until-aborted.js.map +1 -0
- package/package.json +47 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ezzabuzaid
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,4 +1,204 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @zukhruf/single-flight
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
Callers of one key share the flight in progress, in one process or across the processes of a host. A caller that comes while a flight runs does not start a second flight and does not fail with a "busy" error. It joins the flight and gets its outcome. This pattern is also known as single-flight or request coalescing, as in Go's `golang.org/x/sync/singleflight`.
|
|
4
|
+
|
|
5
|
+
The words in these documents have one meaning each. See the glossary in [CONTEXT.md](./CONTEXT.md).
|
|
6
|
+
|
|
7
|
+
## The problem
|
|
8
|
+
|
|
9
|
+
A user runs `sync`. A second `sync` starts while the first one runs. The mutex of `@zukhruf/mutex` gives two choices, and the user wants neither:
|
|
10
|
+
|
|
11
|
+
- In the acquire mode skip if busy, the second `sync` fails with a "busy" error.
|
|
12
|
+
- In the acquire mode wait, the second `sync` waits for the first one, and then runs a second sync.
|
|
13
|
+
|
|
14
|
+
The user wanted a sync to happen. Thus the second `sync` must tell the user that a sync runs, wait for it, and report its outcome. That is a join:
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
18
|
+
|
|
19
|
+
import { SingleFlight } from '@zukhruf/single-flight';
|
|
20
|
+
|
|
21
|
+
const flights = new SingleFlight<string>();
|
|
22
|
+
let builds = 0;
|
|
23
|
+
|
|
24
|
+
async function buildReport() {
|
|
25
|
+
builds += 1;
|
|
26
|
+
await delay(100); // Building the report takes time.
|
|
27
|
+
return `report ${builds}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const calls = [1, 2, 3].map(() =>
|
|
31
|
+
flights.run('report:daily', buildReport, {
|
|
32
|
+
onJoin: () => console.log('A report is being built. Waiting for it…'),
|
|
33
|
+
}),
|
|
34
|
+
);
|
|
35
|
+
console.log(await Promise.all(calls), { builds });
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Output:
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
A report is being built. Waiting for it…
|
|
42
|
+
A report is being built. Waiting for it…
|
|
43
|
+
[
|
|
44
|
+
{ value: 'report 1', joined: false },
|
|
45
|
+
{ value: 'report 1', joined: true },
|
|
46
|
+
{ value: 'report 1', joined: true }
|
|
47
|
+
] { builds: 1 }
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The first caller is the leader: its call runs the flight. The other two callers join it. `onJoin` runs once for each joiner, before it waits. A call after the flight ended starts a new flight: a flight is not a cache. To keep a value after its flight, see the mutex recipe [Compute a value once and share it](../mutex/docs/recipes/compute-once-and-share-it.md).
|
|
51
|
+
|
|
52
|
+
## Use it
|
|
53
|
+
|
|
54
|
+
This project is an experiment.
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
npm install @zukhruf/single-flight @zukhruf/mutex
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`@zukhruf/mutex` is a peer dependency. `SharedFlight` uses its mutex, and `SingleFlight` does not.
|
|
61
|
+
|
|
62
|
+
## In one process: `SingleFlight`
|
|
63
|
+
|
|
64
|
+
`flights.run(key, work, { onJoin, signal })` resolves with `{ value, joined }`. `joined` is `false` for the leader and `true` for each joiner.
|
|
65
|
+
|
|
66
|
+
- A failed flight rejects each caller with the original error of the work.
|
|
67
|
+
- `work` gets a signal that aborts when the flight is abandoned. Read it only when the work can stop early.
|
|
68
|
+
|
|
69
|
+
## Across processes: `SharedFlight`
|
|
70
|
+
|
|
71
|
+
A `SharedFlight` makes a flight visible to the other processes of the host. It uses three parts:
|
|
72
|
+
|
|
73
|
+
- A `SingleFlight`: the callers in one process share one flight first.
|
|
74
|
+
- The mutex: the holder of the key is the leader. The other processes learn that the key is busy.
|
|
75
|
+
- The records: the leader writes the flight record, and joiners in other processes read it.
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
79
|
+
import { tmpdir } from 'node:os';
|
|
80
|
+
import { join } from 'node:path';
|
|
81
|
+
|
|
82
|
+
import { Mutex, SqliteStore } from '@zukhruf/mutex';
|
|
83
|
+
import { FileFlightRecords, SharedFlight } from '@zukhruf/single-flight';
|
|
84
|
+
|
|
85
|
+
const directory = await mkdtemp(join(tmpdir(), 'app-'));
|
|
86
|
+
|
|
87
|
+
const flights = new SharedFlight({
|
|
88
|
+
mutex: new Mutex(new SqliteStore(join(directory, 'locks'))),
|
|
89
|
+
records: new FileFlightRecords(join(directory, 'flights')),
|
|
90
|
+
parse: (value) => {
|
|
91
|
+
if (typeof value !== 'string') throw new TypeError('Not a sync summary.');
|
|
92
|
+
return value;
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const { value, joined } = await flights.run(
|
|
97
|
+
'sync',
|
|
98
|
+
async () => 'synced 42 files',
|
|
99
|
+
{
|
|
100
|
+
onJoin: () => console.log('A sync is running. Waiting for it…'),
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
console.log({ value, joined });
|
|
104
|
+
await rm(directory, { recursive: true, force: true });
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Output:
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
{ value: 'synced 42 files', joined: false }
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The program with three processes is in the recipe [Join a run that is still in flight](./docs/recipes/join-a-run-in-flight.md).
|
|
114
|
+
|
|
115
|
+
| Option | What it does |
|
|
116
|
+
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
117
|
+
| `mutex` | Decides which caller is the leader. All processes must use one lock directory, with a host lock store such as `SqliteStore`. |
|
|
118
|
+
| `records` | Keeps the flight records. All processes must use the same records. Give the records a directory of their own, never the lock directory. |
|
|
119
|
+
| `parse` | Turns a value read from a flight record into your type. The value travels as JSON, so a `Date` arrives as a string. The leader also gets `parse` of the JSON value. |
|
|
120
|
+
| `pollInterval` | Milliseconds between two reads of a flight record while a caller joins. Defaults to `100`. |
|
|
121
|
+
|
|
122
|
+
The work gets the lease of the key. A leader whose lease is lost records the flight as interrupted, and the call rejects with the reason of the lease.
|
|
123
|
+
|
|
124
|
+
A joiner never acquires the key. It reads the flight record until the flight has an outcome, and it asks the mutex only whether a running flight still has a holder. [ADR 0001](./docs/adr/0001-a-joiner-follows-its-flight-record-without-acquiring-the-key.md) tells why.
|
|
125
|
+
|
|
126
|
+
## Errors
|
|
127
|
+
|
|
128
|
+
| The caller is | The flight | The call rejects with |
|
|
129
|
+
| -------------------------------------- | ------------------------------------------------------ | ------------------------------ |
|
|
130
|
+
| The leader, or a joiner in its process | Failed | The original error of the work |
|
|
131
|
+
| A joiner in another process | Failed | `FlightFailedError` |
|
|
132
|
+
| A joiner in another process | Interrupted: the holder stopped, or its lease was lost | `FlightInterruptedError` |
|
|
133
|
+
| A joiner in another process | Its flight record is gone, before the joiner read it | `FlightOutcomeLostError` |
|
|
134
|
+
| The leader, or a joiner in its process | Ended, and the records refused its outcome | The error of the records |
|
|
135
|
+
| A joiner in another process | Ended, and the records refused its outcome | `FlightInterruptedError` |
|
|
136
|
+
|
|
137
|
+
`FlightFailedError` has the `key` and the `failure`: the `name`, the `message` and the `code` of the error. A thrown value that is not an error keeps only its text. A value that JSON cannot carry, such as a `BigInt`, fails the flight.
|
|
138
|
+
|
|
139
|
+
When the holder of a flight stops and the next holder begins a new flight, the new flight is the successor. A joiner of the stopped flight continues with the successor and gets its outcome.
|
|
140
|
+
|
|
141
|
+
## Cancel a wait
|
|
142
|
+
|
|
143
|
+
Give a signal to stop the wait of one caller: `flights.run(key, work, { signal: AbortSignal.timeout(30_000) })`. The call rejects with the reason of the signal. The flight continues for the other callers, also when the leader's caller cancels.
|
|
144
|
+
|
|
145
|
+
When all callers of a flight cancel, the flight is abandoned:
|
|
146
|
+
|
|
147
|
+
- The next call of the key starts a new flight.
|
|
148
|
+
- In `SingleFlight`, the signal of the work aborts.
|
|
149
|
+
- In `SharedFlight`, a process that joins stops to read the flight record, and it never starts a flight for callers that left. A flight that a process leads continues to its end, because joiners in other processes can wait for its outcome.
|
|
150
|
+
|
|
151
|
+
[ADR 0002](./docs/adr/0002-a-flight-that-all-callers-left-is-abandoned.md) tells how other tools solve this.
|
|
152
|
+
|
|
153
|
+
## Records
|
|
154
|
+
|
|
155
|
+
`new FileFlightRecords(directory, { keepFor })` keeps one JSON file for each key. The name of the file is the SHA-256 of the key, so any key is a valid file name. The leader replaces the file in one step, so a joiner never reads a part of it.
|
|
156
|
+
|
|
157
|
+
`keepFor` is the keep window, in milliseconds. It defaults to `60_000`. Keep it at least 20 times the `pollInterval` of each joiner. A joiner that reads the flight record after the keep window gets `FlightOutcomeLostError`.
|
|
158
|
+
|
|
159
|
+
To keep the flight records in another place, for example a table of your database, write your own `FlightRecords`:
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
interface FlightRecords {
|
|
163
|
+
begin(key: string): Promise<string>;
|
|
164
|
+
finish(key: string, id: string, outcome: Finished): Promise<void>;
|
|
165
|
+
latest(key: string): Promise<LatestFlight | undefined>;
|
|
166
|
+
outcome(key: string, id: string): Promise<Outcome>;
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
- `begin` records a new running flight and returns a new id. A flight of the key that is still running becomes interrupted, and the new flight is its successor.
|
|
171
|
+
- `finish` records the outcome. It does nothing when the flight is not running.
|
|
172
|
+
- `outcome` returns `running`, the outcome, or `missing` when the flight record is gone.
|
|
173
|
+
- Only the holder of the key calls `begin` and `finish`. Joiners call only `latest` and `outcome`.
|
|
174
|
+
|
|
175
|
+
## Documentation
|
|
176
|
+
|
|
177
|
+
- [Join a run that is still in flight](./docs/recipes/join-a-run-in-flight.md): three processes, one sync.
|
|
178
|
+
- [ADR 0001: A joiner follows its flight record without acquiring the key](./docs/adr/0001-a-joiner-follows-its-flight-record-without-acquiring-the-key.md)
|
|
179
|
+
- [ADR 0002: A flight that all callers left is abandoned](./docs/adr/0002-a-flight-that-all-callers-left-is-abandoned.md)
|
|
180
|
+
|
|
181
|
+
## Development
|
|
182
|
+
|
|
183
|
+
This package is part of the [zukhruf](../../README.md) workspace. Run the commands from the workspace root.
|
|
184
|
+
|
|
185
|
+
```sh
|
|
186
|
+
npm install
|
|
187
|
+
npx nx run single-flight:test # builds, then runs the tests in src/
|
|
188
|
+
npx nx run single-flight:typecheck # formats, lints, then type checks
|
|
189
|
+
npx nx run single-flight:build # compiles src/ to dist/
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
The tests run from `src/`, not from `dist/`. The tests across processes start `src/testing/caller.fixture.ts` in child processes.
|
|
193
|
+
|
|
194
|
+
```
|
|
195
|
+
src/
|
|
196
|
+
single-flight.ts SingleFlight: the flights of one process
|
|
197
|
+
flight.ts one flight of this process and its callers
|
|
198
|
+
shared-flight.ts SharedFlight: the leader, and joiners across processes
|
|
199
|
+
follow.ts how a joiner finds and follows a flight record
|
|
200
|
+
flight-records.ts the FlightRecords interface and the outcomes
|
|
201
|
+
file-flight-records.ts FileFlightRecords: one JSON file for each key
|
|
202
|
+
errors.ts FlightFailedError, FlightInterruptedError, FlightOutcomeLostError
|
|
203
|
+
testing/ the caller process that the tests start
|
|
204
|
+
```
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { RecordedError } from './flight-records.ts';
|
|
2
|
+
/** The flight that this caller joined in another process failed. `failure` is the error that its leader recorded. */
|
|
3
|
+
export declare class FlightFailedError extends Error {
|
|
4
|
+
readonly key: string;
|
|
5
|
+
readonly failure: RecordedError;
|
|
6
|
+
constructor(key: string, failure: RecordedError);
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The holder of the flight that this caller joined stopped before the flight
|
|
10
|
+
* had an outcome: its process stopped, its lease was lost, or its records
|
|
11
|
+
* refused the outcome.
|
|
12
|
+
*/
|
|
13
|
+
export declare class FlightInterruptedError extends Error {
|
|
14
|
+
readonly key: string;
|
|
15
|
+
constructor(key: string);
|
|
16
|
+
}
|
|
17
|
+
/** The record of the flight that this caller joined was gone before the caller read its outcome. */
|
|
18
|
+
export declare class FlightOutcomeLostError extends Error {
|
|
19
|
+
readonly key: string;
|
|
20
|
+
constructor(key: string);
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=errors.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEzD,qHAAqH;AACrH,qBAAa,iBAAkB,SAAQ,KAAK;IAC1C,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAC;gBAEpB,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,aAAa;CAQhD;AAED;;;;GAIG;AACH,qBAAa,sBAAuB,SAAQ,KAAK;IAC/C,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;gBAET,GAAG,EAAE,MAAM;CAOxB;AAED,oGAAoG;AACpG,qBAAa,sBAAuB,SAAQ,KAAK;IAC/C,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;gBAET,GAAG,EAAE,MAAM;CAOxB"}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** The flight that this caller joined in another process failed. `failure` is the error that its leader recorded. */
|
|
2
|
+
export class FlightFailedError extends Error {
|
|
3
|
+
key;
|
|
4
|
+
failure;
|
|
5
|
+
constructor(key, failure) {
|
|
6
|
+
super(`The flight of ${JSON.stringify(key)} failed: ${failure.name}: ${failure.message}`);
|
|
7
|
+
this.name = 'FlightFailedError';
|
|
8
|
+
this.key = key;
|
|
9
|
+
this.failure = failure;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The holder of the flight that this caller joined stopped before the flight
|
|
14
|
+
* had an outcome: its process stopped, its lease was lost, or its records
|
|
15
|
+
* refused the outcome.
|
|
16
|
+
*/
|
|
17
|
+
export class FlightInterruptedError extends Error {
|
|
18
|
+
key;
|
|
19
|
+
constructor(key) {
|
|
20
|
+
super(`The flight of ${JSON.stringify(key)} that this caller joined stopped before it had an outcome.`);
|
|
21
|
+
this.name = 'FlightInterruptedError';
|
|
22
|
+
this.key = key;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** The record of the flight that this caller joined was gone before the caller read its outcome. */
|
|
26
|
+
export class FlightOutcomeLostError extends Error {
|
|
27
|
+
key;
|
|
28
|
+
constructor(key) {
|
|
29
|
+
super(`The record of the flight of ${JSON.stringify(key)} that this caller joined was gone before the caller read its outcome.`);
|
|
30
|
+
this.name = 'FlightOutcomeLostError';
|
|
31
|
+
this.key = key;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=errors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAEA,qHAAqH;AACrH,MAAM,OAAO,iBAAkB,SAAQ,KAAK;IACjC,GAAG,CAAS;IACZ,OAAO,CAAgB;IAEhC,YAAY,GAAW,EAAE,OAAsB;QAC7C,KAAK,CACH,iBAAiB,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,YAAY,OAAO,CAAC,IAAI,KAAK,OAAO,CAAC,OAAO,EAAE,CACnF,CAAC;QACF,IAAI,CAAC,IAAI,GAAG,mBAAmB,CAAC;QAChC,IAAI,CAAC,GAAG,GAAG,GAAG,CAAC;QACf,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC;IACzB,CAAC;CACF;AAED;;;;GAIG;AACH,MAAM,OAAO,sBAAuB,SAAQ,KAAK;IACtC,GAAG,CAAS;IAErB,YAAY,GAAW;QACrB,KAAK,CACH,iBAAiB,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,4DAA4D,CACjG,CAAC;QACF,IAAI,CAAC,IAAI,GAAG,wBAAwB,CAAC;QACrC,IAAI,CAAC,GAAG,GAAG,GAAG,CAAC;IACjB,CAAC;CACF;AAED,oGAAoG;AACpG,MAAM,OAAO,sBAAuB,SAAQ,KAAK;IACtC,GAAG,CAAS;IAErB,YAAY,GAAW;QACrB,KAAK,CACH,+BAA+B,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,uEAAuE,CAC1H,CAAC;QACF,IAAI,CAAC,IAAI,GAAG,wBAAwB,CAAC;QACrC,IAAI,CAAC,GAAG,GAAG,GAAG,CAAC;IACjB,CAAC;CACF"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type Finished, type FlightRecords, type LatestFlight, type Outcome } from './flight-records.ts';
|
|
2
|
+
export interface FileFlightRecordsOptions {
|
|
3
|
+
/**
|
|
4
|
+
* Milliseconds that the outcome of a flight stays readable after the flight
|
|
5
|
+
* ended. A joiner reads it once per poll interval, so keep it at least 20
|
|
6
|
+
* times the `pollInterval` of every joiner. Defaults to 60 000.
|
|
7
|
+
*/
|
|
8
|
+
keepFor?: number;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Keeps the flight records of each key in one JSON file in `directory`. Give
|
|
12
|
+
* the records a directory of their own, never the lock directory of a mutex.
|
|
13
|
+
*/
|
|
14
|
+
export declare class FileFlightRecords implements FlightRecords {
|
|
15
|
+
#private;
|
|
16
|
+
constructor(directory: string, { keepFor }?: FileFlightRecordsOptions);
|
|
17
|
+
begin(key: string): Promise<string>;
|
|
18
|
+
finish(key: string, id: string, outcome: Finished): Promise<void>;
|
|
19
|
+
latest(key: string): Promise<LatestFlight | undefined>;
|
|
20
|
+
outcome(key: string, id: string): Promise<Outcome>;
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=file-flight-records.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"file-flight-records.d.ts","sourceRoot":"","sources":["../src/file-flight-records.ts"],"names":[],"mappings":"AAIA,OAAO,EACL,KAAK,QAAQ,EACb,KAAK,aAAa,EAClB,KAAK,YAAY,EACjB,KAAK,OAAO,EAGb,MAAM,qBAAqB,CAAC;AAyB7B,MAAM,WAAW,wBAAwB;IACvC;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;;GAGG;AACH,qBAAa,iBAAkB,YAAW,aAAa;;gBAKnD,SAAS,EAAE,MAAM,EACjB,EAAE,OAAgB,EAAE,GAAE,wBAA6B;IAW/C,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAmBnC,MAAM,CAAC,GAAG,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;IAejE,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,GAAG,SAAS,CAAC;IAKtD,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;CA0DzD"}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { isOutcome, isRecord, } from "./flight-records.js";
|
|
5
|
+
import { patiently } from "./patiently.js";
|
|
6
|
+
const isEntry = (value) => isRecord(value) &&
|
|
7
|
+
typeof value['id'] === 'string' &&
|
|
8
|
+
isOutcome(value['outcome']) &&
|
|
9
|
+
value['outcome'].status !== 'missing' &&
|
|
10
|
+
(value['endedAt'] === undefined || typeof value['endedAt'] === 'number');
|
|
11
|
+
const isFlightFile = (value) => isRecord(value) &&
|
|
12
|
+
Array.isArray(value['flights']) &&
|
|
13
|
+
value['flights'].every(isEntry);
|
|
14
|
+
/**
|
|
15
|
+
* Keeps the flight records of each key in one JSON file in `directory`. Give
|
|
16
|
+
* the records a directory of their own, never the lock directory of a mutex.
|
|
17
|
+
*/
|
|
18
|
+
export class FileFlightRecords {
|
|
19
|
+
#directory;
|
|
20
|
+
#keepFor;
|
|
21
|
+
constructor(directory, { keepFor = 60_000 } = {}) {
|
|
22
|
+
if (!(keepFor >= 0) || !Number.isFinite(keepFor)) {
|
|
23
|
+
throw new RangeError(`keepFor must be a finite number of milliseconds >= 0, got ${keepFor}.`);
|
|
24
|
+
}
|
|
25
|
+
this.#directory = directory;
|
|
26
|
+
this.#keepFor = keepFor;
|
|
27
|
+
}
|
|
28
|
+
async begin(key) {
|
|
29
|
+
const id = randomUUID();
|
|
30
|
+
const endedAt = Date.now();
|
|
31
|
+
const flights = (await this.#read(key)).map((flight) => flight.outcome.status === 'running'
|
|
32
|
+
? {
|
|
33
|
+
id: flight.id,
|
|
34
|
+
outcome: { status: 'interrupted', successor: id },
|
|
35
|
+
endedAt,
|
|
36
|
+
}
|
|
37
|
+
: flight);
|
|
38
|
+
await this.#write(key, [
|
|
39
|
+
...flights,
|
|
40
|
+
{ id, outcome: { status: 'running' } },
|
|
41
|
+
]);
|
|
42
|
+
return id;
|
|
43
|
+
}
|
|
44
|
+
async finish(key, id, outcome) {
|
|
45
|
+
const flights = await this.#read(key);
|
|
46
|
+
const running = flights.some((flight) => flight.id === id && flight.outcome.status === 'running');
|
|
47
|
+
if (!running)
|
|
48
|
+
return;
|
|
49
|
+
const endedAt = Date.now();
|
|
50
|
+
await this.#write(key, flights.map((flight) => flight.id === id ? { id, outcome, endedAt } : flight));
|
|
51
|
+
}
|
|
52
|
+
async latest(key) {
|
|
53
|
+
const last = (await this.#read(key)).at(-1);
|
|
54
|
+
return last && { id: last.id, status: last.outcome.status };
|
|
55
|
+
}
|
|
56
|
+
async outcome(key, id) {
|
|
57
|
+
const flight = (await this.#read(key)).find((flight) => flight.id === id);
|
|
58
|
+
return flight?.outcome ?? { status: 'missing' };
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* A key can hold characters that no file name can, such as `:` on Windows
|
|
62
|
+
* or `/`, and two keys can differ only by case, which a case-insensitive
|
|
63
|
+
* file system ignores. The hex digest has none of these problems. It digests
|
|
64
|
+
* the UTF-16 code units: UTF-8 would turn every lone surrogate into U+FFFD
|
|
65
|
+
* and give such keys one record.
|
|
66
|
+
*/
|
|
67
|
+
#pathOf(key) {
|
|
68
|
+
const name = createHash('sha256').update(key, 'utf16le').digest('hex');
|
|
69
|
+
return join(this.#directory, `${name}.json`);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* A finished flight leaves once it ended `keepFor` ago. The flight that
|
|
73
|
+
* began last always stays, so `latest` still knows it.
|
|
74
|
+
*/
|
|
75
|
+
#kept(flights, now) {
|
|
76
|
+
return flights.filter((flight, index) => index === flights.length - 1 ||
|
|
77
|
+
flight.endedAt === undefined ||
|
|
78
|
+
now - flight.endedAt < this.#keepFor);
|
|
79
|
+
}
|
|
80
|
+
async #read(key) {
|
|
81
|
+
const path = this.#pathOf(key);
|
|
82
|
+
let text;
|
|
83
|
+
try {
|
|
84
|
+
text = await patiently(() => readFile(path, 'utf8'));
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
if (isErrno(error, 'ENOENT'))
|
|
88
|
+
return [];
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
const content = parseJson(text, path);
|
|
92
|
+
if (!isFlightFile(content))
|
|
93
|
+
throw notAFlightFile(path);
|
|
94
|
+
return content.flights;
|
|
95
|
+
}
|
|
96
|
+
/** Replaces the file in one step, so a joiner reads the old records or the new ones, never a part. */
|
|
97
|
+
async #write(key, flights) {
|
|
98
|
+
const path = this.#pathOf(key);
|
|
99
|
+
const draft = `${path}.${randomUUID()}.tmp`;
|
|
100
|
+
const kept = this.#kept(flights, Date.now());
|
|
101
|
+
await mkdir(this.#directory, { recursive: true });
|
|
102
|
+
try {
|
|
103
|
+
await writeFile(draft, JSON.stringify({ key, flights: kept }));
|
|
104
|
+
await patiently(() => rename(draft, path));
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
await rm(draft, { force: true });
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function parseJson(text, path) {
|
|
113
|
+
try {
|
|
114
|
+
return JSON.parse(text);
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
throw notAFlightFile(path, { cause: error });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const notAFlightFile = (path, options) => new Error(`${JSON.stringify(path)} is not a file of flight records. Give the records a directory of their own.`, options);
|
|
121
|
+
function isErrno(error, code) {
|
|
122
|
+
return error instanceof Error && 'code' in error && error.code === code;
|
|
123
|
+
}
|
|
124
|
+
//# sourceMappingURL=file-flight-records.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"file-flight-records.js","sourceRoot":"","sources":["../src/file-flight-records.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/** An error as a flight record keeps it: the parts that JSON carries to another process. */
|
|
2
|
+
export interface RecordedError {
|
|
3
|
+
readonly name: string;
|
|
4
|
+
readonly message: string;
|
|
5
|
+
readonly code?: string | number;
|
|
6
|
+
}
|
|
7
|
+
/** Where a flight is: still running, or how it ended. */
|
|
8
|
+
export type Status = 'running' | 'succeeded' | 'failed' | 'interrupted';
|
|
9
|
+
/** How a flight ended, as its leader records it. */
|
|
10
|
+
export type Finished = {
|
|
11
|
+
readonly status: 'succeeded';
|
|
12
|
+
readonly value: unknown;
|
|
13
|
+
} | {
|
|
14
|
+
readonly status: 'failed';
|
|
15
|
+
readonly error: RecordedError;
|
|
16
|
+
} | {
|
|
17
|
+
readonly status: 'interrupted';
|
|
18
|
+
};
|
|
19
|
+
/** What a joiner reads about a flight: running, how it ended, or missing when its record is gone. */
|
|
20
|
+
export type Outcome = {
|
|
21
|
+
readonly status: 'running';
|
|
22
|
+
} | {
|
|
23
|
+
readonly status: 'succeeded';
|
|
24
|
+
readonly value: unknown;
|
|
25
|
+
} | {
|
|
26
|
+
readonly status: 'failed';
|
|
27
|
+
readonly error: RecordedError;
|
|
28
|
+
} | {
|
|
29
|
+
readonly status: 'interrupted';
|
|
30
|
+
readonly successor?: string;
|
|
31
|
+
} | {
|
|
32
|
+
readonly status: 'missing';
|
|
33
|
+
};
|
|
34
|
+
/** The flight that a key began last. */
|
|
35
|
+
export interface LatestFlight {
|
|
36
|
+
readonly id: string;
|
|
37
|
+
readonly status: Status;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Where the outcomes of flights live, so that callers in other processes can
|
|
41
|
+
* join a flight. Only the holder of a key writes the records of the key.
|
|
42
|
+
* Joiners only read them.
|
|
43
|
+
*/
|
|
44
|
+
export interface FlightRecords {
|
|
45
|
+
/**
|
|
46
|
+
* Records a new running flight of `key` and returns its id. A flight of the
|
|
47
|
+
* key that is still running stopped before it had an outcome: it becomes
|
|
48
|
+
* interrupted, with the new flight as its successor.
|
|
49
|
+
*/
|
|
50
|
+
begin(key: string): Promise<string>;
|
|
51
|
+
/** Records how flight `id` ended. Does nothing when the flight is no longer running. */
|
|
52
|
+
finish(key: string, id: string, outcome: Finished): Promise<void>;
|
|
53
|
+
/** The flight that `key` began last, or `undefined` when the key has no record. */
|
|
54
|
+
latest(key: string): Promise<LatestFlight | undefined>;
|
|
55
|
+
/**
|
|
56
|
+
* The outcome of flight `id`. An outcome stays readable for a time after
|
|
57
|
+
* its flight ended, so a joiner that reads it late finds it after newer
|
|
58
|
+
* flights of the key began.
|
|
59
|
+
*/
|
|
60
|
+
outcome(key: string, id: string): Promise<Outcome>;
|
|
61
|
+
}
|
|
62
|
+
/** The parts of a thrown value that a record keeps. A value that is not an error keeps only its text. */
|
|
63
|
+
export declare function recordedError(error: unknown): RecordedError;
|
|
64
|
+
export declare const isRecord: (value: unknown) => value is Readonly<Record<string, unknown>>;
|
|
65
|
+
export declare function isRecordedError(value: unknown): value is RecordedError;
|
|
66
|
+
export declare function isOutcome(value: unknown): value is Outcome;
|
|
67
|
+
//# sourceMappingURL=flight-records.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"flight-records.d.ts","sourceRoot":"","sources":["../src/flight-records.ts"],"names":[],"mappings":"AAAA,4FAA4F;AAC5F,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;CACjC;AAED,yDAAyD;AACzD,MAAM,MAAM,MAAM,GAAG,SAAS,GAAG,WAAW,GAAG,QAAQ,GAAG,aAAa,CAAC;AAExE,oDAAoD;AACpD,MAAM,MAAM,QAAQ,GAChB;IAAE,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAA;CAAE,GACzD;IAAE,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,aAAa,CAAA;CAAE,GAC5D;IAAE,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAA;CAAE,CAAC;AAEvC,qGAAqG;AACrG,MAAM,MAAM,OAAO,GACf;IAAE,QAAQ,CAAC,MAAM,EAAE,SAAS,CAAA;CAAE,GAC9B;IAAE,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAA;CAAE,GACzD;IAAE,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,aAAa,CAAA;CAAE,GAC5D;IAAE,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;IAAC,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,GAC/D;IAAE,QAAQ,CAAC,MAAM,EAAE,SAAS,CAAA;CAAE,CAAC;AAEnC,wCAAwC;AACxC,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED;;;;GAIG;AACH,MAAM,WAAW,aAAa;IAC5B;;;;OAIG;IACH,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACpC,wFAAwF;IACxF,MAAM,CAAC,GAAG,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAClE,mFAAmF;IACnF,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,GAAG,SAAS,CAAC,CAAC;IACvD;;;;OAIG;IACH,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACpD;AAED,yGAAyG;AACzG,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,GAAG,aAAa,CAQ3D;AAED,eAAO,MAAM,QAAQ,GACnB,OAAO,OAAO,KACb,KAAK,IAAI,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CACC,CAAC;AAE9C,wBAAgB,eAAe,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,aAAa,CAQtE;AAED,wBAAgB,SAAS,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,OAAO,CAkB1D"}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** The parts of a thrown value that a record keeps. A value that is not an error keeps only its text. */
|
|
2
|
+
export function recordedError(error) {
|
|
3
|
+
if (!(error instanceof Error))
|
|
4
|
+
return { name: 'Error', message: String(error) };
|
|
5
|
+
const { name, message } = error;
|
|
6
|
+
const code = 'code' in error ? error.code : undefined;
|
|
7
|
+
return typeof code === 'string' || typeof code === 'number'
|
|
8
|
+
? { name, message, code }
|
|
9
|
+
: { name, message };
|
|
10
|
+
}
|
|
11
|
+
export const isRecord = (value) => typeof value === 'object' && value !== null;
|
|
12
|
+
export function isRecordedError(value) {
|
|
13
|
+
if (!isRecord(value))
|
|
14
|
+
return false;
|
|
15
|
+
const { name, message, code } = value;
|
|
16
|
+
return (typeof name === 'string' &&
|
|
17
|
+
typeof message === 'string' &&
|
|
18
|
+
(code === undefined || typeof code === 'string' || typeof code === 'number'));
|
|
19
|
+
}
|
|
20
|
+
export function isOutcome(value) {
|
|
21
|
+
if (!isRecord(value))
|
|
22
|
+
return false;
|
|
23
|
+
switch (value['status']) {
|
|
24
|
+
case 'running':
|
|
25
|
+
case 'missing':
|
|
26
|
+
return true;
|
|
27
|
+
case 'succeeded':
|
|
28
|
+
return 'value' in value;
|
|
29
|
+
case 'failed':
|
|
30
|
+
return isRecordedError(value['error']);
|
|
31
|
+
case 'interrupted':
|
|
32
|
+
return (value['successor'] === undefined ||
|
|
33
|
+
typeof value['successor'] === 'string');
|
|
34
|
+
default:
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
//# sourceMappingURL=flight-records.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"flight-records.js","sourceRoot":"","sources":["../src/flight-records.ts"],"names":[],"mappings":"AAsDA,yGAAyG;AACzG,MAAM,UAAU,aAAa,CAAC,KAAc;IAC1C,IAAI,CAAC,CAAC,KAAK,YAAY,KAAK,CAAC;QAC3B,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC;IACnD,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,GAAG,KAAK,CAAC;IAChC,MAAM,IAAI,GAAG,MAAM,IAAI,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS,CAAC;IACtD,OAAO,OAAO,IAAI,KAAK,QAAQ,IAAI,OAAO,IAAI,KAAK,QAAQ;QACzD,CAAC,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE;QACzB,CAAC,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC;AACxB,CAAC;AAED,MAAM,CAAC,MAAM,QAAQ,GAAG,CACtB,KAAc,EAC8B,EAAE,CAC9C,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,IAAI,CAAC;AAE9C,MAAM,UAAU,eAAe,CAAC,KAAc;IAC5C,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC;IACnC,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,GAAG,KAAK,CAAC;IACtC,OAAO,CACL,OAAO,IAAI,KAAK,QAAQ;QACxB,OAAO,OAAO,KAAK,QAAQ;QAC3B,CAAC,IAAI,KAAK,SAAS,IAAI,OAAO,IAAI,KAAK,QAAQ,IAAI,OAAO,IAAI,KAAK,QAAQ,CAAC,CAC7E,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,SAAS,CAAC,KAAc;IACtC,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC;IACnC,QAAQ,KAAK,CAAC,QAAQ,CAAC,EAAE,CAAC;QACxB,KAAK,SAAS,CAAC;QACf,KAAK,SAAS;YACZ,OAAO,IAAI,CAAC;QACd,KAAK,WAAW;YACd,OAAO,OAAO,IAAI,KAAK,CAAC;QAC1B,KAAK,QAAQ;YACX,OAAO,eAAe,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC;QACzC,KAAK,aAAa;YAChB,OAAO,CACL,KAAK,CAAC,WAAW,CAAC,KAAK,SAAS;gBAChC,OAAO,KAAK,CAAC,WAAW,CAAC,KAAK,QAAQ,CACvC,CAAC;QACJ;YACE,OAAO,KAAK,CAAC;IACjB,CAAC;AACH,CAAC"}
|
package/dist/flight.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** One run of the work for a key in this process. Every caller that joins it gets how it ended. */
|
|
2
|
+
export declare class Flight<T> {
|
|
3
|
+
#private;
|
|
4
|
+
constructor(work: (abandoned: AbortSignal) => Promise<T>);
|
|
5
|
+
/** Settles as the work settled. */
|
|
6
|
+
get ended(): Promise<T>;
|
|
7
|
+
/** Aborts when every caller cancelled its wait before the flight ended. */
|
|
8
|
+
get abandoned(): AbortSignal;
|
|
9
|
+
/** Waits for the flight for one caller, until it ends or `signal` aborts. */
|
|
10
|
+
wait(signal: AbortSignal | undefined): Promise<T>;
|
|
11
|
+
}
|
|
12
|
+
//# sourceMappingURL=flight.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"flight.d.ts","sourceRoot":"","sources":["../src/flight.ts"],"names":[],"mappings":"AAEA,mGAAmG;AACnG,qBAAa,MAAM,CAAC,CAAC;;gBAMP,IAAI,EAAE,CAAC,SAAS,EAAE,WAAW,KAAK,OAAO,CAAC,CAAC,CAAC;IAOxD,mCAAmC;IACnC,IAAI,KAAK,IAAI,OAAO,CAAC,CAAC,CAAC,CAEtB;IAED,2EAA2E;IAC3E,IAAI,SAAS,IAAI,WAAW,CAE3B;IAED,6EAA6E;IAC7E,IAAI,CAAC,MAAM,EAAE,WAAW,GAAG,SAAS,GAAG,OAAO,CAAC,CAAC,CAAC;CAWlD"}
|
package/dist/flight.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { untilAborted } from "./until-aborted.js";
|
|
2
|
+
/** One run of the work for a key in this process. Every caller that joins it gets how it ended. */
|
|
3
|
+
export class Flight {
|
|
4
|
+
#ended = Promise.withResolvers();
|
|
5
|
+
#abandoned = new AbortController();
|
|
6
|
+
/** The callers that wait for the flight now. */
|
|
7
|
+
#waiting = 0;
|
|
8
|
+
constructor(work) {
|
|
9
|
+
Promise.try(work, this.#abandoned.signal).then(this.#ended.resolve, this.#ended.reject);
|
|
10
|
+
}
|
|
11
|
+
/** Settles as the work settled. */
|
|
12
|
+
get ended() {
|
|
13
|
+
return this.#ended.promise;
|
|
14
|
+
}
|
|
15
|
+
/** Aborts when every caller cancelled its wait before the flight ended. */
|
|
16
|
+
get abandoned() {
|
|
17
|
+
return this.#abandoned.signal;
|
|
18
|
+
}
|
|
19
|
+
/** Waits for the flight for one caller, until it ends or `signal` aborts. */
|
|
20
|
+
wait(signal) {
|
|
21
|
+
this.#waiting++;
|
|
22
|
+
const leave = () => {
|
|
23
|
+
this.#waiting--;
|
|
24
|
+
if (this.#waiting === 0)
|
|
25
|
+
this.#abandoned.abort();
|
|
26
|
+
};
|
|
27
|
+
signal?.addEventListener('abort', leave, { once: true });
|
|
28
|
+
const stopListening = () => signal?.removeEventListener('abort', leave);
|
|
29
|
+
this.#ended.promise.then(stopListening, stopListening);
|
|
30
|
+
return untilAborted(this.#ended.promise, signal);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=flight.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"flight.js","sourceRoot":"","sources":["../src/flight.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAElD,mGAAmG;AACnG,MAAM,OAAO,MAAM;IACR,MAAM,GAAG,OAAO,CAAC,aAAa,EAAK,CAAC;IACpC,UAAU,GAAG,IAAI,eAAe,EAAE,CAAC;IAC5C,gDAAgD;IAChD,QAAQ,GAAG,CAAC,CAAC;IAEb,YAAY,IAA4C;QACtD,OAAO,CAAC,GAAG,CAAC,IAAI,EAAE,IAAI,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC,IAAI,CAC5C,IAAI,CAAC,MAAM,CAAC,OAAO,EACnB,IAAI,CAAC,MAAM,CAAC,MAAM,CACnB,CAAC;IACJ,CAAC;IAED,mCAAmC;IACnC,IAAI,KAAK;QACP,OAAO,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC;IAC7B,CAAC;IAED,2EAA2E;IAC3E,IAAI,SAAS;QACX,OAAO,IAAI,CAAC,UAAU,CAAC,MAAM,CAAC;IAChC,CAAC;IAED,6EAA6E;IAC7E,IAAI,CAAC,MAA+B;QAClC,IAAI,CAAC,QAAQ,EAAE,CAAC;QAChB,MAAM,KAAK,GAAG,GAAG,EAAE;YACjB,IAAI,CAAC,QAAQ,EAAE,CAAC;YAChB,IAAI,IAAI,CAAC,QAAQ,KAAK,CAAC;gBAAE,IAAI,CAAC,UAAU,CAAC,KAAK,EAAE,CAAC;QACnD,CAAC,CAAC;QACF,MAAM,EAAE,gBAAgB,CAAC,OAAO,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC;QACzD,MAAM,aAAa,GAAG,GAAG,EAAE,CAAC,MAAM,EAAE,mBAAmB,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;QACxE,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,aAAa,EAAE,aAAa,CAAC,CAAC;QACvD,OAAO,YAAY,CAAC,IAAI,CAAC,MAAM,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;IACnD,CAAC;CACF"}
|
package/dist/follow.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Mutex } from '@zukhruf/mutex';
|
|
2
|
+
import type { FlightRecords, LatestFlight } from './flight-records.ts';
|
|
3
|
+
/** What a joiner watches while it waits for a flight in another process, and how often it looks. */
|
|
4
|
+
export interface Watch {
|
|
5
|
+
readonly records: FlightRecords;
|
|
6
|
+
readonly mutex: Mutex;
|
|
7
|
+
readonly key: string;
|
|
8
|
+
readonly pollInterval: number;
|
|
9
|
+
/** Aborts when no caller in this process waits any more: the watch then stops. */
|
|
10
|
+
readonly abandoned: AbortSignal;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The flight that holds the key now, found by its record. `undefined` when
|
|
14
|
+
* the key became free and no new flight began, so the caller can try to lead.
|
|
15
|
+
*/
|
|
16
|
+
export declare function findFlight({ records, mutex, key, pollInterval, abandoned }: Watch, before: LatestFlight | undefined): Promise<string | undefined>;
|
|
17
|
+
/** How following ended: with the value of the flight, or with no flight to follow, so the caller tries to lead. */
|
|
18
|
+
export type Followed = {
|
|
19
|
+
readonly followed: true;
|
|
20
|
+
readonly value: unknown;
|
|
21
|
+
} | {
|
|
22
|
+
readonly followed: false;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Waits for flight `id` to end and gives its value. It reads the record, not
|
|
26
|
+
* the lock: back-to-back holders can keep the key held at every look, so
|
|
27
|
+
* waiting for a free key could wait forever. The lock only tells whether a
|
|
28
|
+
* running flight still has a holder.
|
|
29
|
+
*/
|
|
30
|
+
export declare function follow({ records, mutex, key, pollInterval, abandoned }: Watch, first: string): Promise<Followed>;
|
|
31
|
+
//# sourceMappingURL=follow.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"follow.d.ts","sourceRoot":"","sources":["../src/follow.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,gBAAgB,CAAC;AAO5C,OAAO,KAAK,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAEvE,oGAAoG;AACpG,MAAM,WAAW,KAAK;IACpB,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAC;IAChC,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC;IACtB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,kFAAkF;IAClF,QAAQ,CAAC,SAAS,EAAE,WAAW,CAAC;CACjC;AAED;;;GAGG;AACH,wBAAsB,UAAU,CAC9B,EAAE,OAAO,EAAE,KAAK,EAAE,GAAG,EAAE,YAAY,EAAE,SAAS,EAAE,EAAE,KAAK,EACvD,MAAM,EAAE,YAAY,GAAG,SAAS,GAC/B,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAQ7B;AAED,mHAAmH;AACnH,MAAM,MAAM,QAAQ,GAChB;IAAE,QAAQ,CAAC,QAAQ,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAA;CAAE,GACpD;IAAE,QAAQ,CAAC,QAAQ,EAAE,KAAK,CAAA;CAAE,CAAC;AAEjC;;;;;GAKG;AACH,wBAAsB,MAAM,CAC1B,EAAE,OAAO,EAAE,KAAK,EAAE,GAAG,EAAE,YAAY,EAAE,SAAS,EAAE,EAAE,KAAK,EACvD,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,QAAQ,CAAC,CAkCnB"}
|
package/dist/follow.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
2
|
+
import { FlightFailedError, FlightInterruptedError, FlightOutcomeLostError, } from "./errors.js";
|
|
3
|
+
/**
|
|
4
|
+
* The flight that holds the key now, found by its record. `undefined` when
|
|
5
|
+
* the key became free and no new flight began, so the caller can try to lead.
|
|
6
|
+
*/
|
|
7
|
+
export async function findFlight({ records, mutex, key, pollInterval, abandoned }, before) {
|
|
8
|
+
for (;;) {
|
|
9
|
+
await delay(pollInterval, undefined, { signal: abandoned });
|
|
10
|
+
const now = await records.latest(key);
|
|
11
|
+
if (now && (now.id !== before?.id || before.status === 'running'))
|
|
12
|
+
return now.id;
|
|
13
|
+
if (!(await mutex.isHeld(key)))
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Waits for flight `id` to end and gives its value. It reads the record, not
|
|
19
|
+
* the lock: back-to-back holders can keep the key held at every look, so
|
|
20
|
+
* waiting for a free key could wait forever. The lock only tells whether a
|
|
21
|
+
* running flight still has a holder.
|
|
22
|
+
*/
|
|
23
|
+
export async function follow({ records, mutex, key, pollInterval, abandoned }, first) {
|
|
24
|
+
let id = first;
|
|
25
|
+
let seenHeld = false;
|
|
26
|
+
for (;;) {
|
|
27
|
+
const outcome = await records.outcome(key, id);
|
|
28
|
+
switch (outcome.status) {
|
|
29
|
+
case 'succeeded':
|
|
30
|
+
return { followed: true, value: outcome.value };
|
|
31
|
+
case 'failed':
|
|
32
|
+
throw new FlightFailedError(key, outcome.error);
|
|
33
|
+
case 'interrupted':
|
|
34
|
+
if (outcome.successor === undefined)
|
|
35
|
+
throw new FlightInterruptedError(key);
|
|
36
|
+
// The next holder closed a flight whose holder stopped, and began its own.
|
|
37
|
+
id = outcome.successor;
|
|
38
|
+
seenHeld = false;
|
|
39
|
+
continue;
|
|
40
|
+
case 'running':
|
|
41
|
+
if (await mutex.isHeld(key)) {
|
|
42
|
+
seenHeld = true;
|
|
43
|
+
await delay(pollInterval, undefined, { signal: abandoned });
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
// The holder can finish between the two looks: read the record again.
|
|
47
|
+
if ((await records.outcome(key, id)).status !== 'running')
|
|
48
|
+
continue;
|
|
49
|
+
// A record that was running before this joiner came was left by a
|
|
50
|
+
// holder that stopped long ago: there is no flight to join.
|
|
51
|
+
if (!seenHeld)
|
|
52
|
+
return { followed: false };
|
|
53
|
+
// This joiner saw the holder run the flight, and the holder stopped.
|
|
54
|
+
throw new FlightInterruptedError(key);
|
|
55
|
+
case 'missing':
|
|
56
|
+
throw new FlightOutcomeLostError(key);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
//# sourceMappingURL=follow.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"follow.js","sourceRoot":"","sources":["../src/follow.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,IAAI,KAAK,EAAE,MAAM,sBAAsB,CAAC;AAI3D,OAAO,EACL,iBAAiB,EACjB,sBAAsB,EACtB,sBAAsB,GACvB,MAAM,aAAa,CAAC;AAarB;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,UAAU,CAC9B,EAAE,OAAO,EAAE,KAAK,EAAE,GAAG,EAAE,YAAY,EAAE,SAAS,EAAS,EACvD,MAAgC;IAEhC,SAAS,CAAC;QACR,MAAM,KAAK,CAAC,YAAY,EAAE,SAAS,EAAE,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC,CAAC;QAC5D,MAAM,GAAG,GAAG,MAAM,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;QACtC,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC,EAAE,KAAK,MAAM,EAAE,EAAE,IAAI,MAAM,CAAC,MAAM,KAAK,SAAS,CAAC;YAC/D,OAAO,GAAG,CAAC,EAAE,CAAC;QAChB,IAAI,CAAC,CAAC,MAAM,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;YAAE,OAAO,SAAS,CAAC;IACnD,CAAC;AACH,CAAC;AAOD;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,MAAM,CAC1B,EAAE,OAAO,EAAE,KAAK,EAAE,GAAG,EAAE,YAAY,EAAE,SAAS,EAAS,EACvD,KAAa;IAEb,IAAI,EAAE,GAAG,KAAK,CAAC;IACf,IAAI,QAAQ,GAAG,KAAK,CAAC;IACrB,SAAS,CAAC;QACR,MAAM,OAAO,GAAG,MAAM,OAAO,CAAC,OAAO,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;QAC/C,QAAQ,OAAO,CAAC,MAAM,EAAE,CAAC;YACvB,KAAK,WAAW;gBACd,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,KAAK,EAAE,OAAO,CAAC,KAAK,EAAE,CAAC;YAClD,KAAK,QAAQ;gBACX,MAAM,IAAI,iBAAiB,CAAC,GAAG,EAAE,OAAO,CAAC,KAAK,CAAC,CAAC;YAClD,KAAK,aAAa;gBAChB,IAAI,OAAO,CAAC,SAAS,KAAK,SAAS;oBACjC,MAAM,IAAI,sBAAsB,CAAC,GAAG,CAAC,CAAC;gBACxC,2EAA2E;gBAC3E,EAAE,GAAG,OAAO,CAAC,SAAS,CAAC;gBACvB,QAAQ,GAAG,KAAK,CAAC;gBACjB,SAAS;YACX,KAAK,SAAS;gBACZ,IAAI,MAAM,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC;oBAC5B,QAAQ,GAAG,IAAI,CAAC;oBAChB,MAAM,KAAK,CAAC,YAAY,EAAE,SAAS,EAAE,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC,CAAC;oBAC5D,SAAS;gBACX,CAAC;gBACD,sEAAsE;gBACtE,IAAI,CAAC,MAAM,OAAO,CAAC,OAAO,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,KAAK,SAAS;oBAAE,SAAS;gBACpE,kEAAkE;gBAClE,4DAA4D;gBAC5D,IAAI,CAAC,QAAQ;oBAAE,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,CAAC;gBAC1C,qEAAqE;gBACrE,MAAM,IAAI,sBAAsB,CAAC,GAAG,CAAC,CAAC;YACxC,KAAK,SAAS;gBACZ,MAAM,IAAI,sBAAsB,CAAC,GAAG,CAAC,CAAC;QAC1C,CAAC;IACH,CAAC;AACH,CAAC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { type FlightValue, type RunOptions, SingleFlight, } from './single-flight.ts';
|
|
2
|
+
export type { Finished, FlightRecords, LatestFlight, Outcome, RecordedError, Status, } from './flight-records.ts';
|
|
3
|
+
export { FileFlightRecords, type FileFlightRecordsOptions, } from './file-flight-records.ts';
|
|
4
|
+
export { SharedFlight, type SharedFlightOptions } from './shared-flight.ts';
|
|
5
|
+
export { FlightFailedError, FlightInterruptedError, FlightOutcomeLostError, } from './errors.ts';
|
|
6
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,WAAW,EAChB,KAAK,UAAU,EACf,YAAY,GACb,MAAM,oBAAoB,CAAC;AAC5B,YAAY,EACV,QAAQ,EACR,aAAa,EACb,YAAY,EACZ,OAAO,EACP,aAAa,EACb,MAAM,GACP,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EACL,iBAAiB,EACjB,KAAK,wBAAwB,GAC9B,MAAM,0BAA0B,CAAC;AAClC,OAAO,EAAE,YAAY,EAAE,KAAK,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAC5E,OAAO,EACL,iBAAiB,EACjB,sBAAsB,EACtB,sBAAsB,GACvB,MAAM,aAAa,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { SingleFlight, } from "./single-flight.js";
|
|
2
|
+
export { FileFlightRecords, } from "./file-flight-records.js";
|
|
3
|
+
export { SharedFlight } from "./shared-flight.js";
|
|
4
|
+
export { FlightFailedError, FlightInterruptedError, FlightOutcomeLostError, } from "./errors.js";
|
|
5
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,YAAY,GACb,MAAM,oBAAoB,CAAC;AAS5B,OAAO,EACL,iBAAiB,GAElB,MAAM,0BAA0B,CAAC;AAClC,OAAO,EAAE,YAAY,EAA4B,MAAM,oBAAoB,CAAC;AAC5E,OAAO,EACL,iBAAiB,EACjB,sBAAsB,EACtB,sBAAsB,GACvB,MAAM,aAAa,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"patiently.d.ts","sourceRoot":"","sources":["../src/patiently.ts"],"names":[],"mappings":"AAoBA,uFAAuF;AACvF,wBAAsB,SAAS,CAAC,CAAC,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAc1E"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
2
|
+
/** How long to retry a file operation that Windows refuses for a moment. */
|
|
3
|
+
const WINDOWS_PATIENCE = 1000;
|
|
4
|
+
/**
|
|
5
|
+
* Windows refuses a file for a moment while another process has it open, for
|
|
6
|
+
* example a joiner that reads the record while the holder replaces it. The
|
|
7
|
+
* error code is the same as for a real permission denial, so a refusal is
|
|
8
|
+
* retried only for a limited time.
|
|
9
|
+
*/
|
|
10
|
+
function isRefusedForNow(error) {
|
|
11
|
+
return (process.platform === 'win32' &&
|
|
12
|
+
error instanceof Error &&
|
|
13
|
+
'code' in error &&
|
|
14
|
+
['EPERM', 'EACCES', 'EBUSY'].includes(String(error.code)));
|
|
15
|
+
}
|
|
16
|
+
/** Runs `operation`, and runs it again while Windows refuses the file for a moment. */
|
|
17
|
+
export async function patiently(operation) {
|
|
18
|
+
const started = performance.now();
|
|
19
|
+
for (let attempt = 0;; attempt++) {
|
|
20
|
+
try {
|
|
21
|
+
return await operation();
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
if (!isRefusedForNow(error) ||
|
|
25
|
+
performance.now() - started > WINDOWS_PATIENCE)
|
|
26
|
+
throw error;
|
|
27
|
+
await delay(Math.min(5 * 2 ** attempt, 100));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
//# sourceMappingURL=patiently.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"patiently.js","sourceRoot":"","sources":["../src/patiently.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,IAAI,KAAK,EAAE,MAAM,sBAAsB,CAAC;AAE3D,4EAA4E;AAC5E,MAAM,gBAAgB,GAAG,IAAI,CAAC;AAE9B;;;;;GAKG;AACH,SAAS,eAAe,CAAC,KAAc;IACrC,OAAO,CACL,OAAO,CAAC,QAAQ,KAAK,OAAO;QAC5B,KAAK,YAAY,KAAK;QACtB,MAAM,IAAI,KAAK;QACf,CAAC,OAAO,EAAE,QAAQ,EAAE,OAAO,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAC1D,CAAC;AACJ,CAAC;AAED,uFAAuF;AACvF,MAAM,CAAC,KAAK,UAAU,SAAS,CAAI,SAA2B;IAC5D,MAAM,OAAO,GAAG,WAAW,CAAC,GAAG,EAAE,CAAC;IAClC,KAAK,IAAI,OAAO,GAAG,CAAC,GAAI,OAAO,EAAE,EAAE,CAAC;QAClC,IAAI,CAAC;YACH,OAAO,MAAM,SAAS,EAAE,CAAC;QAC3B,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,IACE,CAAC,eAAe,CAAC,KAAK,CAAC;gBACvB,WAAW,CAAC,GAAG,EAAE,GAAG,OAAO,GAAG,gBAAgB;gBAE9C,MAAM,KAAK,CAAC;YACd,MAAM,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,IAAI,OAAO,EAAE,GAAG,CAAC,CAAC,CAAC;QAC/C,CAAC;IACH,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type Lease, type Mutex } from '@zukhruf/mutex';
|
|
2
|
+
import { type FlightRecords } from './flight-records.ts';
|
|
3
|
+
import { type FlightValue, type RunOptions } from './single-flight.ts';
|
|
4
|
+
export interface SharedFlightOptions<T> {
|
|
5
|
+
/** Decides who flies: only the holder of the key runs the work. */
|
|
6
|
+
mutex: Mutex;
|
|
7
|
+
/** Where the outcomes live, so callers in other processes can join. Never the lock directory of the mutex. */
|
|
8
|
+
records: FlightRecords;
|
|
9
|
+
/** Turns a value read back from a record into `T`. Every caller, the leader too, gets `parse` of the value as JSON. */
|
|
10
|
+
parse: (value: unknown) => T;
|
|
11
|
+
/** Milliseconds between two looks at a record while a caller joins. Defaults to 100. */
|
|
12
|
+
pollInterval?: number;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Callers of one key share the flight in progress, in this process and in
|
|
16
|
+
* other processes. Callers in this process share one flight first. The holder
|
|
17
|
+
* of the key leads the flight, and the records let callers in other
|
|
18
|
+
* processes join it.
|
|
19
|
+
*/
|
|
20
|
+
export declare class SharedFlight<T> {
|
|
21
|
+
#private;
|
|
22
|
+
constructor({ mutex, records, parse, pollInterval, }: SharedFlightOptions<T>);
|
|
23
|
+
run(key: string, work: (lease: Lease) => Promise<T>, options?: RunOptions): Promise<FlightValue<T>>;
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=shared-flight.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"shared-flight.d.ts","sourceRoot":"","sources":["../src/shared-flight.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,KAAK,EAAS,KAAK,KAAK,EAAE,MAAM,gBAAgB,CAAC;AAE/D,OAAO,EAAE,KAAK,aAAa,EAAiB,MAAM,qBAAqB,CAAC;AAExE,OAAO,EACL,KAAK,WAAW,EAChB,KAAK,UAAU,EAEhB,MAAM,oBAAoB,CAAC;AAE5B,MAAM,WAAW,mBAAmB,CAAC,CAAC;IACpC,mEAAmE;IACnE,KAAK,EAAE,KAAK,CAAC;IACb,8GAA8G;IAC9G,OAAO,EAAE,aAAa,CAAC;IACvB,uHAAuH;IACvH,KAAK,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,CAAC,CAAC;IAC7B,wFAAwF;IACxF,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAcD;;;;;GAKG;AACH,qBAAa,YAAY,CAAC,CAAC;;gBAOb,EACV,KAAK,EACL,OAAO,EACP,KAAK,EACL,YAAkB,GACnB,EAAE,mBAAmB,CAAC,CAAC,CAAC;IAYnB,GAAG,CACP,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,OAAO,CAAC,CAAC,CAAC,EAClC,OAAO,GAAE,UAAe,GACvB,OAAO,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC;CA0E3B"}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { Modes } from '@zukhruf/mutex';
|
|
2
|
+
import { recordedError } from "./flight-records.js";
|
|
3
|
+
import { findFlight, follow } from "./follow.js";
|
|
4
|
+
import { SingleFlight, } from "./single-flight.js";
|
|
5
|
+
const skipIfBusy = Modes.skipIfBusy();
|
|
6
|
+
/** Values travel as JSON, so every caller gets the value in the shape that a joiner in another process reads. */
|
|
7
|
+
const encode = (value) => JSON.parse(JSON.stringify(value) ?? 'null');
|
|
8
|
+
/**
|
|
9
|
+
* Callers of one key share the flight in progress, in this process and in
|
|
10
|
+
* other processes. Callers in this process share one flight first. The holder
|
|
11
|
+
* of the key leads the flight, and the records let callers in other
|
|
12
|
+
* processes join it.
|
|
13
|
+
*/
|
|
14
|
+
export class SharedFlight {
|
|
15
|
+
#local = new SingleFlight();
|
|
16
|
+
#mutex;
|
|
17
|
+
#records;
|
|
18
|
+
#parse;
|
|
19
|
+
#pollInterval;
|
|
20
|
+
constructor({ mutex, records, parse, pollInterval = 100, }) {
|
|
21
|
+
if (!(pollInterval >= 0) || !Number.isFinite(pollInterval)) {
|
|
22
|
+
throw new RangeError(`pollInterval must be a finite number of milliseconds >= 0, got ${pollInterval}.`);
|
|
23
|
+
}
|
|
24
|
+
this.#mutex = mutex;
|
|
25
|
+
this.#records = records;
|
|
26
|
+
this.#parse = parse;
|
|
27
|
+
this.#pollInterval = pollInterval;
|
|
28
|
+
}
|
|
29
|
+
async run(key, work, options = {}) {
|
|
30
|
+
const { value, joined } = await this.#local.run(key, (abandoned) => this.#leadOrFollow(key, work, options.onJoin, abandoned), options);
|
|
31
|
+
return { value: value.value, joined: joined || value.followed };
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Runs for the first caller in this process. The callers that joined it
|
|
35
|
+
* here heard `onJoin` already; this caller hears it once it joins a flight
|
|
36
|
+
* of another process. Once no caller here waits (`abandoned`), it stops
|
|
37
|
+
* looking and never takes the key; a flight that it leads runs to its end,
|
|
38
|
+
* because callers in other processes can wait for its outcome.
|
|
39
|
+
*/
|
|
40
|
+
async #leadOrFollow(key, work, onJoin, abandoned) {
|
|
41
|
+
const watch = {
|
|
42
|
+
records: this.#records,
|
|
43
|
+
mutex: this.#mutex,
|
|
44
|
+
key,
|
|
45
|
+
pollInterval: this.#pollInterval,
|
|
46
|
+
abandoned,
|
|
47
|
+
};
|
|
48
|
+
let told = false;
|
|
49
|
+
for (;;) {
|
|
50
|
+
const before = await this.#records.latest(key);
|
|
51
|
+
const led = await this.#mutex.acquire(key, (lease) => this.#lead(key, work, lease), { mode: skipIfBusy, signal: abandoned });
|
|
52
|
+
if (led.acquired)
|
|
53
|
+
return { value: led.value, followed: false };
|
|
54
|
+
const id = await findFlight(watch, before);
|
|
55
|
+
if (id === undefined)
|
|
56
|
+
continue;
|
|
57
|
+
if (!told)
|
|
58
|
+
onJoin?.();
|
|
59
|
+
told = true;
|
|
60
|
+
const followed = await follow(watch, id);
|
|
61
|
+
if (followed.followed)
|
|
62
|
+
return { value: this.#parse(followed.value), followed: true };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
async #lead(key, work, lease) {
|
|
66
|
+
const id = await this.#records.begin(key);
|
|
67
|
+
let value;
|
|
68
|
+
try {
|
|
69
|
+
// A value that JSON cannot carry fails the flight: no joiner could read it.
|
|
70
|
+
value = encode(await work(lease));
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
await this.#records.finish(key, id, {
|
|
74
|
+
status: 'failed',
|
|
75
|
+
error: recordedError(error),
|
|
76
|
+
});
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
// Another holder may have run the work too: the value is not the outcome of one flight.
|
|
80
|
+
if (lease.signal.aborted) {
|
|
81
|
+
await this.#records.finish(key, id, { status: 'interrupted' });
|
|
82
|
+
throw lease.signal.reason;
|
|
83
|
+
}
|
|
84
|
+
// A throw here rejects the leader, although the work did its side effects.
|
|
85
|
+
await this.#records.finish(key, id, { status: 'succeeded', value });
|
|
86
|
+
return this.#parse(value);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
//# sourceMappingURL=shared-flight.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"shared-flight.js","sourceRoot":"","sources":["../src/shared-flight.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface RunOptions {
|
|
2
|
+
/** Called once, before this caller starts to wait for a flight that another caller started. */
|
|
3
|
+
onJoin?: (() => void) | undefined;
|
|
4
|
+
/** Stops the wait of this caller only, which then rejects with `signal.reason`. The flight runs on. */
|
|
5
|
+
signal?: AbortSignal | undefined;
|
|
6
|
+
}
|
|
7
|
+
/** What a caller gets: the value of the flight, and whether the caller joined a flight that another caller started. */
|
|
8
|
+
export interface FlightValue<T> {
|
|
9
|
+
readonly value: T;
|
|
10
|
+
readonly joined: boolean;
|
|
11
|
+
}
|
|
12
|
+
/** Callers of one key in this process share the flight in progress. */
|
|
13
|
+
export declare class SingleFlight<T> {
|
|
14
|
+
#private;
|
|
15
|
+
/**
|
|
16
|
+
* Runs `work` for `key`, or joins the flight of `key` in progress. `work`
|
|
17
|
+
* gets a signal that aborts only when every caller of the flight cancelled:
|
|
18
|
+
* work that can stop early may read it. Work that ignores it runs on, and
|
|
19
|
+
* the next call starts a new flight either way.
|
|
20
|
+
*/
|
|
21
|
+
run(key: string, work: (abandoned: AbortSignal) => Promise<T>, { onJoin, signal }?: RunOptions): Promise<FlightValue<T>>;
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=single-flight.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"single-flight.d.ts","sourceRoot":"","sources":["../src/single-flight.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,UAAU;IACzB,+FAA+F;IAC/F,MAAM,CAAC,EAAE,CAAC,MAAM,IAAI,CAAC,GAAG,SAAS,CAAC;IAClC,uGAAuG;IACvG,MAAM,CAAC,EAAE,WAAW,GAAG,SAAS,CAAC;CAClC;AAED,uHAAuH;AACvH,MAAM,WAAW,WAAW,CAAC,CAAC;IAC5B,QAAQ,CAAC,KAAK,EAAE,CAAC,CAAC;IAClB,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;CAC1B;AAED,uEAAuE;AACvE,qBAAa,YAAY,CAAC,CAAC;;IAIzB;;;;;OAKG;IACG,GAAG,CACP,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,CAAC,SAAS,EAAE,WAAW,KAAK,OAAO,CAAC,CAAC,CAAC,EAC5C,EAAE,MAAM,EAAE,MAAM,EAAE,GAAE,UAAe,GAClC,OAAO,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC;CAyB3B"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Flight } from "./flight.js";
|
|
2
|
+
/** Callers of one key in this process share the flight in progress. */
|
|
3
|
+
export class SingleFlight {
|
|
4
|
+
/** Only the flights in progress: a flight that ended leaves, so the next call starts a new one. */
|
|
5
|
+
#flights = new Map();
|
|
6
|
+
/**
|
|
7
|
+
* Runs `work` for `key`, or joins the flight of `key` in progress. `work`
|
|
8
|
+
* gets a signal that aborts only when every caller of the flight cancelled:
|
|
9
|
+
* work that can stop early may read it. Work that ignores it runs on, and
|
|
10
|
+
* the next call starts a new flight either way.
|
|
11
|
+
*/
|
|
12
|
+
async run(key, work, { onJoin, signal } = {}) {
|
|
13
|
+
// A caller that already cancelled neither starts nor joins a flight.
|
|
14
|
+
signal?.throwIfAborted();
|
|
15
|
+
// No await before the flight is in the map: a second caller must find it.
|
|
16
|
+
const inProgress = this.#flights.get(key);
|
|
17
|
+
const flight = inProgress ?? this.#start(key, work);
|
|
18
|
+
const joined = inProgress !== undefined;
|
|
19
|
+
if (joined)
|
|
20
|
+
onJoin?.();
|
|
21
|
+
// The signal stops only this caller's wait. The work never sees it, so
|
|
22
|
+
// the other callers still get the value.
|
|
23
|
+
return { value: await flight.wait(signal), joined };
|
|
24
|
+
}
|
|
25
|
+
#start(key, work) {
|
|
26
|
+
const flight = new Flight(work);
|
|
27
|
+
this.#flights.set(key, flight);
|
|
28
|
+
const leave = () => {
|
|
29
|
+
if (this.#flights.get(key) === flight)
|
|
30
|
+
this.#flights.delete(key);
|
|
31
|
+
};
|
|
32
|
+
flight.ended.then(leave, leave);
|
|
33
|
+
// In the same step as the abort, so no caller joins a flight that nobody
|
|
34
|
+
// waits for (Go issue 22724: a new lookup joined a cancelled one).
|
|
35
|
+
flight.abandoned.addEventListener('abort', leave, { once: true });
|
|
36
|
+
return flight;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=single-flight.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"single-flight.js","sourceRoot":"","sources":["../src/single-flight.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAerC,uEAAuE;AACvE,MAAM,OAAO,YAAY;IACvB,mGAAmG;IAC1F,QAAQ,GAAG,IAAI,GAAG,EAAqB,CAAC;IAEjD;;;;;OAKG;IACH,KAAK,CAAC,GAAG,CACP,GAAW,EACX,IAA4C,EAC5C,EAAE,MAAM,EAAE,MAAM,KAAiB,EAAE;QAEnC,qEAAqE;QACrE,MAAM,EAAE,cAAc,EAAE,CAAC;QACzB,0EAA0E;QAC1E,MAAM,UAAU,GAAG,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QAC1C,MAAM,MAAM,GAAG,UAAU,IAAI,IAAI,CAAC,MAAM,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;QACpD,MAAM,MAAM,GAAG,UAAU,KAAK,SAAS,CAAC;QACxC,IAAI,MAAM;YAAE,MAAM,EAAE,EAAE,CAAC;QACvB,uEAAuE;QACvE,yCAAyC;QACzC,OAAO,EAAE,KAAK,EAAE,MAAM,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IACtD,CAAC;IAED,MAAM,CAAC,GAAW,EAAE,IAA4C;QAC9D,MAAM,MAAM,GAAG,IAAI,MAAM,CAAC,IAAI,CAAC,CAAC;QAChC,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;QAC/B,MAAM,KAAK,GAAG,GAAG,EAAE;YACjB,IAAI,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,GAAG,CAAC,KAAK,MAAM;gBAAE,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;QACnE,CAAC,CAAC;QACF,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;QAChC,yEAAyE;QACzE,mEAAmE;QACnE,MAAM,CAAC,SAAS,CAAC,gBAAgB,CAAC,OAAO,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC;QAClE,OAAO,MAAM,CAAC;IAChB,CAAC;CACF"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"until-aborted.d.ts","sourceRoot":"","sources":["../src/until-aborted.ts"],"names":[],"mappings":"AAAA,wFAAwF;AACxF,wBAAgB,YAAY,CAAC,CAAC,EAC5B,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,EACnB,MAAM,EAAE,WAAW,GAAG,SAAS,GAC9B,OAAO,CAAC,CAAC,CAAC,CAiBZ"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Settles like `promise`, or rejects with `signal.reason` first if `signal` aborts. */
|
|
2
|
+
export function untilAborted(promise, signal) {
|
|
3
|
+
if (!signal)
|
|
4
|
+
return promise;
|
|
5
|
+
return new Promise((resolve, reject) => {
|
|
6
|
+
const abort = () => reject(signal.reason);
|
|
7
|
+
if (signal.aborted)
|
|
8
|
+
return abort();
|
|
9
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
10
|
+
promise.then((value) => {
|
|
11
|
+
signal.removeEventListener('abort', abort);
|
|
12
|
+
resolve(value);
|
|
13
|
+
}, (error) => {
|
|
14
|
+
signal.removeEventListener('abort', abort);
|
|
15
|
+
reject(error);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
//# sourceMappingURL=until-aborted.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"until-aborted.js","sourceRoot":"","sources":["../src/until-aborted.ts"],"names":[],"mappings":"AAAA,wFAAwF;AACxF,MAAM,UAAU,YAAY,CAC1B,OAAmB,EACnB,MAA+B;IAE/B,IAAI,CAAC,MAAM;QAAE,OAAO,OAAO,CAAC;IAC5B,OAAO,IAAI,OAAO,CAAI,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACxC,MAAM,KAAK,GAAG,GAAG,EAAE,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;QAC1C,IAAI,MAAM,CAAC,OAAO;YAAE,OAAO,KAAK,EAAE,CAAC;QACnC,MAAM,CAAC,gBAAgB,CAAC,OAAO,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC;QACxD,OAAO,CAAC,IAAI,CACV,CAAC,KAAK,EAAE,EAAE;YACR,MAAM,CAAC,mBAAmB,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;YAC3C,OAAO,CAAC,KAAK,CAAC,CAAC;QACjB,CAAC,EACD,CAAC,KAAc,EAAE,EAAE;YACjB,MAAM,CAAC,mBAAmB,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;YAC3C,MAAM,CAAC,KAAK,CAAC,CAAC;QAChB,CAAC,CACF,CAAC;IACJ,CAAC,CAAC,CAAC;AACL,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zukhruf/single-flight",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.3.13",
|
|
4
|
+
"description": "Callers of one key share the run already in flight, in one process or across processes, and get its outcome instead of a busy error.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/JanuaryLabs/zukhruf",
|
|
9
|
+
"directory": "packages/single-flight"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/JanuaryLabs/zukhruf/issues"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/JanuaryLabs/zukhruf/tree/main/packages/single-flight#readme",
|
|
15
|
+
"keywords": [
|
|
16
|
+
"single-flight",
|
|
17
|
+
"singleflight",
|
|
18
|
+
"request-coalescing",
|
|
19
|
+
"deduplication",
|
|
20
|
+
"mutex",
|
|
21
|
+
"inter-process"
|
|
22
|
+
],
|
|
23
|
+
"type": "module",
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"exports": {
|
|
26
|
+
"./package.json": "./package.json",
|
|
27
|
+
".": {
|
|
28
|
+
"@zukhruf/source": "./src/index.ts",
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"import": "./dist/index.js",
|
|
31
|
+
"default": "./dist/index.js"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"dist",
|
|
36
|
+
"!dist/spec/**",
|
|
37
|
+
"!**/*.tsbuildinfo",
|
|
38
|
+
"!**/*.test.*"
|
|
39
|
+
],
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"@zukhruf/mutex": "^0.3.11"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@zukhruf/mutex": "^0.3.11"
|
|
48
|
+
}
|
|
49
|
+
}
|