@smthrs/artifacts 0.0.0-stage → 1.0.0-rc.3
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/CHANGELOG.md +77 -0
- package/LICENSE +21 -0
- package/README.md +173 -2
- package/dist/cjs/ArtifactBackupLease.d.ts +34 -0
- package/dist/cjs/ArtifactBackupLease.d.ts.map +1 -0
- package/dist/cjs/ArtifactBackupLease.js +175 -0
- package/dist/cjs/ArtifactBackupLease.js.map +7 -0
- package/dist/cjs/ArtifactStore.d.ts +129 -0
- package/dist/cjs/ArtifactStore.d.ts.map +1 -0
- package/dist/cjs/ArtifactStore.js +73 -0
- package/dist/cjs/ArtifactStore.js.map +7 -0
- package/dist/cjs/ArtifactStoreError.d.ts +82 -0
- package/dist/cjs/ArtifactStoreError.d.ts.map +1 -0
- package/dist/cjs/ArtifactStoreError.js +72 -0
- package/dist/cjs/ArtifactStoreError.js.map +7 -0
- package/dist/cjs/ArtifactStoreMetrics.d.ts +70 -0
- package/dist/cjs/ArtifactStoreMetrics.d.ts.map +1 -0
- package/dist/cjs/ArtifactStoreMetrics.js +51 -0
- package/dist/cjs/ArtifactStoreMetrics.js.map +7 -0
- package/dist/cjs/ArtifactSweep.d.ts +185 -0
- package/dist/cjs/ArtifactSweep.d.ts.map +1 -0
- package/dist/cjs/ArtifactSweep.js +163 -0
- package/dist/cjs/ArtifactSweep.js.map +7 -0
- package/dist/cjs/CombinedArtifacts.d.ts +103 -0
- package/dist/cjs/CombinedArtifacts.d.ts.map +1 -0
- package/dist/cjs/CombinedArtifacts.js +189 -0
- package/dist/cjs/CombinedArtifacts.js.map +7 -0
- package/dist/cjs/FileLease.d.ts +113 -0
- package/dist/cjs/FileLease.d.ts.map +1 -0
- package/dist/cjs/FileLease.js +201 -0
- package/dist/cjs/FileLease.js.map +7 -0
- package/dist/cjs/FileSystemArtifactStore.d.ts +93 -0
- package/dist/cjs/FileSystemArtifactStore.d.ts.map +1 -0
- package/dist/cjs/FileSystemArtifactStore.js +276 -0
- package/dist/cjs/FileSystemArtifactStore.js.map +7 -0
- package/dist/cjs/MemoryArtifactStore.d.ts +25 -0
- package/dist/cjs/MemoryArtifactStore.d.ts.map +1 -0
- package/dist/cjs/MemoryArtifactStore.js +84 -0
- package/dist/cjs/MemoryArtifactStore.js.map +7 -0
- package/dist/cjs/NoopArtifactStore.d.ts +17 -0
- package/dist/cjs/NoopArtifactStore.d.ts.map +1 -0
- package/dist/cjs/NoopArtifactStore.js +46 -0
- package/dist/cjs/NoopArtifactStore.js.map +7 -0
- package/dist/cjs/RemoteArtifacts.d.ts +237 -0
- package/dist/cjs/RemoteArtifacts.d.ts.map +1 -0
- package/dist/cjs/RemoteArtifacts.js +405 -0
- package/dist/cjs/RemoteArtifacts.js.map +7 -0
- package/dist/cjs/index.d.ts +65 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +47 -0
- package/dist/cjs/index.js.map +7 -0
- package/dist/cjs/internal/ArtifactLocks.d.ts +30 -0
- package/dist/cjs/internal/ArtifactLocks.d.ts.map +1 -0
- package/dist/cjs/internal/ArtifactLocks.js +93 -0
- package/dist/cjs/internal/ArtifactLocks.js.map +7 -0
- package/dist/cjs/internal/ArtifactPath.d.ts +19 -0
- package/dist/cjs/internal/ArtifactPath.d.ts.map +1 -0
- package/dist/cjs/internal/ArtifactPath.js +74 -0
- package/dist/cjs/internal/ArtifactPath.js.map +7 -0
- package/dist/cjs/measureBytes.d.ts +18 -0
- package/dist/cjs/measureBytes.d.ts.map +1 -0
- package/dist/cjs/measureBytes.js +43 -0
- package/dist/cjs/measureBytes.js.map +7 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/snapshotBytes.d.ts +19 -0
- package/dist/cjs/snapshotBytes.d.ts.map +1 -0
- package/dist/cjs/snapshotBytes.js +41 -0
- package/dist/cjs/snapshotBytes.js.map +7 -0
- package/dist/cjs/validateDigest.d.ts +22 -0
- package/dist/cjs/validateDigest.d.ts.map +1 -0
- package/dist/cjs/validateDigest.js +42 -0
- package/dist/cjs/validateDigest.js.map +7 -0
- package/dist/esm/ArtifactBackupLease.d.ts +34 -0
- package/dist/esm/ArtifactBackupLease.d.ts.map +1 -0
- package/dist/esm/ArtifactBackupLease.js +117 -0
- package/dist/esm/ArtifactBackupLease.js.map +1 -0
- package/dist/esm/ArtifactStore.d.ts +129 -0
- package/dist/esm/ArtifactStore.d.ts.map +1 -0
- package/dist/esm/ArtifactStore.js +83 -0
- package/dist/esm/ArtifactStore.js.map +1 -0
- package/dist/esm/ArtifactStoreError.d.ts +82 -0
- package/dist/esm/ArtifactStoreError.d.ts.map +1 -0
- package/dist/esm/ArtifactStoreError.js +77 -0
- package/dist/esm/ArtifactStoreError.js.map +1 -0
- package/dist/esm/ArtifactStoreMetrics.d.ts +70 -0
- package/dist/esm/ArtifactStoreMetrics.d.ts.map +1 -0
- package/dist/esm/ArtifactStoreMetrics.js +77 -0
- package/dist/esm/ArtifactStoreMetrics.js.map +1 -0
- package/dist/esm/ArtifactSweep.d.ts +185 -0
- package/dist/esm/ArtifactSweep.d.ts.map +1 -0
- package/dist/esm/ArtifactSweep.js +202 -0
- package/dist/esm/ArtifactSweep.js.map +1 -0
- package/dist/esm/CombinedArtifacts.d.ts +103 -0
- package/dist/esm/CombinedArtifacts.d.ts.map +1 -0
- package/dist/esm/CombinedArtifacts.js +214 -0
- package/dist/esm/CombinedArtifacts.js.map +1 -0
- package/dist/esm/FileLease.d.ts +113 -0
- package/dist/esm/FileLease.d.ts.map +1 -0
- package/dist/esm/FileLease.js +239 -0
- package/dist/esm/FileLease.js.map +1 -0
- package/dist/esm/FileSystemArtifactStore.d.ts +93 -0
- package/dist/esm/FileSystemArtifactStore.d.ts.map +1 -0
- package/dist/esm/FileSystemArtifactStore.js +336 -0
- package/dist/esm/FileSystemArtifactStore.js.map +1 -0
- package/dist/esm/MemoryArtifactStore.d.ts +25 -0
- package/dist/esm/MemoryArtifactStore.d.ts.map +1 -0
- package/dist/esm/MemoryArtifactStore.js +67 -0
- package/dist/esm/MemoryArtifactStore.js.map +1 -0
- package/dist/esm/NoopArtifactStore.d.ts +17 -0
- package/dist/esm/NoopArtifactStore.d.ts.map +1 -0
- package/dist/esm/NoopArtifactStore.js +27 -0
- package/dist/esm/NoopArtifactStore.js.map +1 -0
- package/dist/esm/RemoteArtifacts.d.ts +237 -0
- package/dist/esm/RemoteArtifacts.d.ts.map +1 -0
- package/dist/esm/RemoteArtifacts.js +554 -0
- package/dist/esm/RemoteArtifacts.js.map +1 -0
- package/dist/esm/index.d.ts +65 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +65 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/internal/ArtifactLocks.d.ts +30 -0
- package/dist/esm/internal/ArtifactLocks.d.ts.map +1 -0
- package/dist/esm/internal/ArtifactLocks.js +86 -0
- package/dist/esm/internal/ArtifactLocks.js.map +1 -0
- package/dist/esm/internal/ArtifactPath.d.ts +19 -0
- package/dist/esm/internal/ArtifactPath.d.ts.map +1 -0
- package/dist/esm/internal/ArtifactPath.js +59 -0
- package/dist/esm/internal/ArtifactPath.js.map +1 -0
- package/dist/esm/measureBytes.d.ts +18 -0
- package/dist/esm/measureBytes.d.ts.map +1 -0
- package/dist/esm/measureBytes.js +22 -0
- package/dist/esm/measureBytes.js.map +1 -0
- package/dist/esm/snapshotBytes.d.ts +19 -0
- package/dist/esm/snapshotBytes.d.ts.map +1 -0
- package/dist/esm/snapshotBytes.js +30 -0
- package/dist/esm/snapshotBytes.js.map +1 -0
- package/dist/esm/validateDigest.d.ts +22 -0
- package/dist/esm/validateDigest.d.ts.map +1 -0
- package/dist/esm/validateDigest.js +26 -0
- package/dist/esm/validateDigest.js.map +1 -0
- package/docs/README.md +200 -0
- package/docs/api.md +474 -0
- package/docs/concepts/content-addressing.md +174 -0
- package/docs/concepts/coordination.md +141 -0
- package/docs/concepts/tiers.md +147 -0
- package/docs/guides/fence-a-backup.md +123 -0
- package/docs/guides/reclaim-disk-space.md +143 -0
- package/docs/guides/serve-the-artifact-protocol.md +140 -0
- package/docs/guides/share-artifacts-across-machines.md +180 -0
- package/docs/guides/test-against-an-artifact-store.md +158 -0
- package/docs/installation.md +98 -0
- package/docs/quickstart.md +182 -0
- package/docs/troubleshooting.md +232 -0
- package/package.json +179 -3
- package/src/ArtifactBackupLease.ts +213 -0
- package/src/ArtifactStore.ts +152 -0
- package/src/ArtifactStoreError.ts +96 -0
- package/src/ArtifactStoreMetrics.ts +83 -0
- package/src/ArtifactSweep.ts +336 -0
- package/src/CombinedArtifacts.ts +369 -0
- package/src/FileLease.ts +380 -0
- package/src/FileSystemArtifactStore.ts +459 -0
- package/src/MemoryArtifactStore.ts +83 -0
- package/src/NoopArtifactStore.ts +30 -0
- package/src/RemoteArtifacts.ts +809 -0
- package/src/index.ts +71 -0
- package/src/internal/ArtifactLocks.ts +110 -0
- package/src/internal/ArtifactPath.ts +72 -0
- package/src/measureBytes.ts +28 -0
- package/src/snapshotBytes.ts +34 -0
- package/src/validateDigest.ts +31 -0
package/src/FileLease.ts
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A cross-process lease on one lock file.
|
|
3
|
+
*
|
|
4
|
+
* The lock is a file created with `wx` (exclusive create) that holds a unique
|
|
5
|
+
* owner token. The holder refreshes its mtime every `heartbeatEvery`; a lock
|
|
6
|
+
* whose mtime is older than `staleAfterMs` belongs to a crashed or hard-killed
|
|
7
|
+
* owner and is reclaimed. Reclaimers of one lock generation race for a `wx`
|
|
8
|
+
* claim file first, so a stale generation is moved away at most once and a
|
|
9
|
+
* fresh lock is never displaced. Acquisition is bounded by `acquireWithin`,
|
|
10
|
+
* so a waiter fails with a typed error instead of waiting forever on a lock
|
|
11
|
+
* nothing will release.
|
|
12
|
+
*
|
|
13
|
+
* | Setting | Default |
|
|
14
|
+
* | ---------------- | ----------- |
|
|
15
|
+
* | `heartbeatEvery` | 10 seconds |
|
|
16
|
+
* | `staleAfterMs` | 60 000 ms |
|
|
17
|
+
* | `acquireWithin` | 2 minutes |
|
|
18
|
+
* | `retryEvery` | 25 millis |
|
|
19
|
+
*
|
|
20
|
+
* The fence is bounded, not absolute: a holder stalled past `staleAfterMs`
|
|
21
|
+
* (a suspended laptop, a stopped debugger) is reaped while it still runs. Its
|
|
22
|
+
* heartbeat then logs `<label> was reclaimed while its holder was still
|
|
23
|
+
* running`, and the protected effect continues unfenced.
|
|
24
|
+
*
|
|
25
|
+
* A directory at the lock path, left by a release that used an exclusive
|
|
26
|
+
* `makeDirectory` as its lock, is treated as a lock with no readable owner:
|
|
27
|
+
* it is reclaimed once its mtime is past `staleAfterMs`, and waited on
|
|
28
|
+
* before that.
|
|
29
|
+
*
|
|
30
|
+
* The host needs exclusive `wx` file creation, `readFileString`, `stat` with
|
|
31
|
+
* an mtime, `utimes`, `rename`, and `remove`. The claim file and the tombstone
|
|
32
|
+
* a reclaim renames the stale lock to are siblings of the lock:
|
|
33
|
+
* `<lockPath>.reclaim-<token>` and `<lockPath>.stale-<token>`.
|
|
34
|
+
*
|
|
35
|
+
* @since 1.0.0-rc.1
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import * as Clock from "effect/Clock"
|
|
39
|
+
import * as Deferred from "effect/Deferred"
|
|
40
|
+
import type * as Duration from "effect/Duration"
|
|
41
|
+
import * as Effect from "effect/Effect"
|
|
42
|
+
import type * as FileSystem from "effect/FileSystem"
|
|
43
|
+
import * as Option from "effect/Option"
|
|
44
|
+
import * as PlatformError from "effect/PlatformError"
|
|
45
|
+
import * as Random from "effect/Random"
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Tuning for one lease.
|
|
49
|
+
*
|
|
50
|
+
* @category models
|
|
51
|
+
* @since 1.0.0-rc.1
|
|
52
|
+
*/
|
|
53
|
+
export interface Options {
|
|
54
|
+
/** Names the lock in the reclaimed-holder warning. Defaults to `"File lease"`. */
|
|
55
|
+
readonly label?: string | undefined
|
|
56
|
+
/** Extra fields logged with the reclaimed-holder warning. */
|
|
57
|
+
readonly annotations?: Readonly<Record<string, unknown>> | undefined
|
|
58
|
+
/** How often the holder refreshes the lock's mtime. Defaults to 10 seconds. */
|
|
59
|
+
readonly heartbeatEvery?: Duration.Input | undefined
|
|
60
|
+
/** Age past which a lock or claim is stale. Defaults to 60 000 ms. */
|
|
61
|
+
readonly staleAfterMs?: number | undefined
|
|
62
|
+
/** How long acquisition may wait. Defaults to 2 minutes. */
|
|
63
|
+
readonly acquireWithin?: Duration.Input | undefined
|
|
64
|
+
/** How long a waiter sleeps between attempts. Defaults to 25 millis. */
|
|
65
|
+
readonly retryEvery?: Duration.Input | undefined
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* {@link Options} for {@link hold}, which leaves the deadline to its caller.
|
|
70
|
+
*
|
|
71
|
+
* @category models
|
|
72
|
+
* @since 1.0.0-rc.1
|
|
73
|
+
*/
|
|
74
|
+
export interface HoldOptions extends Omit<Options, "acquireWithin"> {
|
|
75
|
+
/** Runs once, the moment the lease is held and before `effect` starts. */
|
|
76
|
+
readonly onAcquired?: Effect.Effect<void> | undefined
|
|
77
|
+
/**
|
|
78
|
+
* Runs before every create, heartbeat, rename, and remove of the lock, its
|
|
79
|
+
* claim, or its tombstone. A failure refuses that step through `failure`,
|
|
80
|
+
* so a caller can re-check that the lock directory was not replaced (for
|
|
81
|
+
* example by a symlink) after it was created.
|
|
82
|
+
*/
|
|
83
|
+
readonly guard?: Effect.Effect<void, unknown> | undefined
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The default acquisition deadline.
|
|
88
|
+
*
|
|
89
|
+
* @category constants
|
|
90
|
+
* @since 1.0.0-rc.1
|
|
91
|
+
*/
|
|
92
|
+
export const defaultAcquireWithin: Duration.Input = "2 minutes"
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The default stale bound, in milliseconds.
|
|
96
|
+
*
|
|
97
|
+
* @category constants
|
|
98
|
+
* @since 1.0.0-rc.1
|
|
99
|
+
*/
|
|
100
|
+
export const defaultStaleAfterMs = 60_000
|
|
101
|
+
|
|
102
|
+
const isReason = (cause: unknown, tag: PlatformError.SystemErrorTag): boolean =>
|
|
103
|
+
cause instanceof PlatformError.PlatformError && cause.reason._tag === tag
|
|
104
|
+
|
|
105
|
+
/** The generation name of a lock with no readable owner: a directory. */
|
|
106
|
+
const directoryGeneration = "legacy-directory"
|
|
107
|
+
|
|
108
|
+
const token = Effect.gen(function*() {
|
|
109
|
+
const first = yield* Random.nextIntBetween(0, Number.MAX_SAFE_INTEGER, { halfOpen: true })
|
|
110
|
+
const second = yield* Random.nextIntBetween(0, Number.MAX_SAFE_INTEGER, { halfOpen: true })
|
|
111
|
+
return `${first.toString(36)}-${second.toString(36)}-${yield* Clock.currentTimeMillis}`
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Runs `effect` while holding the lease at `lockPath`, with no acquisition
|
|
116
|
+
* deadline. Use it when the caller already bounds a wider wait (an
|
|
117
|
+
* in-process semaphore plus this lease) with its own deadline; otherwise use
|
|
118
|
+
* {@link withLease}.
|
|
119
|
+
*
|
|
120
|
+
* @category combinators
|
|
121
|
+
* @since 1.0.0-rc.1
|
|
122
|
+
*/
|
|
123
|
+
export const hold = <A, E, R, E2>(
|
|
124
|
+
fs: FileSystem.FileSystem,
|
|
125
|
+
lockPath: string,
|
|
126
|
+
effect: Effect.Effect<A, E, R>,
|
|
127
|
+
failure: (cause: unknown) => E2,
|
|
128
|
+
options: HoldOptions = {}
|
|
129
|
+
): Effect.Effect<A, E | E2, R> =>
|
|
130
|
+
Effect.gen(function*() {
|
|
131
|
+
const label = options.label ?? "File lease"
|
|
132
|
+
const heartbeatEvery = options.heartbeatEvery ?? "10 seconds"
|
|
133
|
+
const staleAfterMs = options.staleAfterMs ?? defaultStaleAfterMs
|
|
134
|
+
const retryEvery = options.retryEvery ?? "25 millis"
|
|
135
|
+
const owner = yield* token
|
|
136
|
+
/**
|
|
137
|
+
* Whether this call created the lock file, and therefore owes a release.
|
|
138
|
+
* It is set in the same uninterruptible step that creates the file, so
|
|
139
|
+
* interruption striking between the two cannot leak a lock nothing
|
|
140
|
+
* releases — the only party that would ever reclaim it is a later
|
|
141
|
+
* acquirer of the same path, which may never come.
|
|
142
|
+
*/
|
|
143
|
+
let acquired = false
|
|
144
|
+
|
|
145
|
+
const guard = options.guard === undefined ? Effect.void : options.guard.pipe(Effect.mapError(failure))
|
|
146
|
+
const guarded = <B, F, S>(mutation: Effect.Effect<B, F, S>): Effect.Effect<B, F | E2, S> =>
|
|
147
|
+
Effect.andThen(guard, mutation)
|
|
148
|
+
|
|
149
|
+
const statIfPresent = (path: string): Effect.Effect<Option.Option<FileSystem.File.Info>, E2> =>
|
|
150
|
+
fs.stat(path).pipe(
|
|
151
|
+
Effect.map(Option.some),
|
|
152
|
+
Effect.catch((cause): Effect.Effect<Option.Option<FileSystem.File.Info>, E2> =>
|
|
153
|
+
isReason(cause, "NotFound") ? Effect.succeed(Option.none()) : Effect.fail(failure(cause))
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
const refuse = (cause: unknown): Effect.Effect<never, E2> => Effect.fail(failure(cause))
|
|
158
|
+
|
|
159
|
+
const readOwner = (
|
|
160
|
+
path: string,
|
|
161
|
+
onRefused: (cause: unknown) => Effect.Effect<Option.Option<string>, E2> = refuse
|
|
162
|
+
): Effect.Effect<Option.Option<string>, E2> =>
|
|
163
|
+
fs.readFileString(path).pipe(
|
|
164
|
+
Effect.map(Option.some),
|
|
165
|
+
Effect.catch((cause) => isReason(cause, "NotFound") ? Effect.succeed(Option.none()) : onRefused(cause))
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Names the lock generation at `path`: its owner token, or
|
|
170
|
+
* `directoryGeneration` for a directory, which has no readable owner.
|
|
171
|
+
* A read refused for any other reason is propagated, not guessed at.
|
|
172
|
+
*/
|
|
173
|
+
const observe = (path: string): Effect.Effect<Option.Option<string>, E2> =>
|
|
174
|
+
readOwner(path, (cause) =>
|
|
175
|
+
fs.stat(path).pipe(
|
|
176
|
+
Effect.matchEffect({
|
|
177
|
+
onFailure: () => refuse(cause),
|
|
178
|
+
onSuccess: (info) =>
|
|
179
|
+
info.type === "Directory" ? Effect.succeed(Option.some(directoryGeneration)) : refuse(cause)
|
|
180
|
+
})
|
|
181
|
+
))
|
|
182
|
+
|
|
183
|
+
const isStale = (path: string): Effect.Effect<boolean, E2> =>
|
|
184
|
+
Effect.gen(function*() {
|
|
185
|
+
const info = yield* statIfPresent(path)
|
|
186
|
+
const modified = Option.isSome(info) ? Option.getOrUndefined(info.value.mtime) : undefined
|
|
187
|
+
return modified !== undefined && (yield* Clock.currentTimeMillis) - modified.getTime() > staleAfterMs
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
const removeIfOwnedBy = (path: string, expected: string): Effect.Effect<void> =>
|
|
191
|
+
fs.readFileString(path).pipe(
|
|
192
|
+
Effect.flatMap((found) => found === expected ? guarded(fs.remove(path)) : Effect.void),
|
|
193
|
+
Effect.ignore
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
const acquire = Effect.gen(function*() {
|
|
197
|
+
while (true) {
|
|
198
|
+
const created = yield* Effect.uninterruptible(
|
|
199
|
+
guarded(fs.writeFileString(lockPath, owner, { flag: "wx", mode: 0o600 })).pipe(
|
|
200
|
+
Effect.andThen(Effect.sync(() => {
|
|
201
|
+
acquired = true
|
|
202
|
+
return true
|
|
203
|
+
})),
|
|
204
|
+
Effect.catch((cause): Effect.Effect<boolean, E2> =>
|
|
205
|
+
isReason(cause, "AlreadyExists") ? Effect.succeed(false) : Effect.fail(failure(cause))
|
|
206
|
+
)
|
|
207
|
+
)
|
|
208
|
+
)
|
|
209
|
+
if (created) return
|
|
210
|
+
|
|
211
|
+
// The owner is read before the age, so a stale verdict always
|
|
212
|
+
// belongs to the lock generation this read named.
|
|
213
|
+
const observed = yield* observe(lockPath)
|
|
214
|
+
if (Option.isNone(observed)) continue
|
|
215
|
+
if (yield* isStale(lockPath)) {
|
|
216
|
+
const reclaimed = yield* reclaim(observed.value)
|
|
217
|
+
if (reclaimed) continue
|
|
218
|
+
}
|
|
219
|
+
yield* Effect.sleep(retryEvery)
|
|
220
|
+
}
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Moves away the stale lock generation `observed` names, at most once.
|
|
225
|
+
*
|
|
226
|
+
* Measuring a lock and renaming it are two steps, so two contenders
|
|
227
|
+
* that both measure the same generation as stale would otherwise both
|
|
228
|
+
* rename, and the second would move away the fresh lock the first just
|
|
229
|
+
* took. Reclaimers of one generation therefore race for a `wx` claim
|
|
230
|
+
* named after its owner token first. Only the winner renames, and only
|
|
231
|
+
* after re-reading that the path still holds that stale generation. A
|
|
232
|
+
* loser, or a winner whose generation is already gone, moves nothing.
|
|
233
|
+
* Owner tokens are unique per acquisition, so a claim never outlives
|
|
234
|
+
* the generation it names in any way that matters.
|
|
235
|
+
*/
|
|
236
|
+
const reclaim = (observed: string): Effect.Effect<boolean, E2> => {
|
|
237
|
+
const claimPath = `${lockPath}.reclaim-${observed.replace(/[^0-9A-Za-z-]/g, "_").slice(0, 96)}`
|
|
238
|
+
return Effect.acquireUseRelease(
|
|
239
|
+
guarded(fs.writeFileString(claimPath, owner, { flag: "wx", mode: 0o600 })).pipe(
|
|
240
|
+
Effect.as(true),
|
|
241
|
+
Effect.catch((cause): Effect.Effect<boolean, E2> =>
|
|
242
|
+
isReason(cause, "AlreadyExists") ? Effect.succeed(false) : Effect.fail(failure(cause))
|
|
243
|
+
)
|
|
244
|
+
),
|
|
245
|
+
(claimed) =>
|
|
246
|
+
Effect.gen(function*() {
|
|
247
|
+
if (!claimed) {
|
|
248
|
+
// A claimant that died mid-reclaim leaves its claim behind.
|
|
249
|
+
// It ages out on the same bound as a lock.
|
|
250
|
+
const holder = yield* readOwner(claimPath)
|
|
251
|
+
if (Option.isSome(holder) && (yield* isStale(claimPath))) {
|
|
252
|
+
yield* removeIfOwnedBy(claimPath, holder.value)
|
|
253
|
+
}
|
|
254
|
+
return false
|
|
255
|
+
}
|
|
256
|
+
const current = yield* observe(lockPath)
|
|
257
|
+
if (Option.isNone(current) || current.value !== observed || !(yield* isStale(lockPath))) return true
|
|
258
|
+
const tombstone = `${lockPath}.stale-${owner}`
|
|
259
|
+
const moved = yield* guarded(fs.rename(lockPath, tombstone)).pipe(
|
|
260
|
+
Effect.as(true),
|
|
261
|
+
Effect.catch((cause): Effect.Effect<boolean, E2> =>
|
|
262
|
+
isReason(cause, "NotFound") ? Effect.succeed(false) : Effect.fail(failure(cause))
|
|
263
|
+
)
|
|
264
|
+
)
|
|
265
|
+
if (!moved) return true
|
|
266
|
+
// Only a stalled owner releasing between the re-read and the
|
|
267
|
+
// rename, and a new owner acquiring in that gap, puts a live
|
|
268
|
+
// lock here. Hand it back with an atomic create, which cannot
|
|
269
|
+
// displace anyone who took the path since.
|
|
270
|
+
const displaced = yield* observe(tombstone)
|
|
271
|
+
if (
|
|
272
|
+
Option.isSome(displaced) && displaced.value !== observed && displaced.value !== directoryGeneration
|
|
273
|
+
) {
|
|
274
|
+
yield* guarded(fs.writeFileString(lockPath, displaced.value, { flag: "wx", mode: 0o600 })).pipe(
|
|
275
|
+
Effect.ignore
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
yield* guarded(fs.remove(tombstone, { recursive: true })).pipe(Effect.ignore)
|
|
279
|
+
return true
|
|
280
|
+
}),
|
|
281
|
+
(claimed) => claimed ? removeIfOwnedBy(claimPath, owner) : Effect.void
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const release = fs.readFileString(lockPath).pipe(
|
|
286
|
+
Effect.flatMap((found) => found === owner ? guarded(fs.remove(lockPath)) : Effect.void),
|
|
287
|
+
// A concurrent stale-lock reaper can win release. `NotFound`
|
|
288
|
+
// means no lock remains for this owner to release or leak.
|
|
289
|
+
Effect.catch((cause): Effect.Effect<void, E2> =>
|
|
290
|
+
isReason(cause, "NotFound") ? Effect.void : Effect.fail(failure(cause))
|
|
291
|
+
)
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
// The finalizer is attached around acquisition itself, not just around
|
|
295
|
+
// the protected effect, so a lock created moments before an interruption
|
|
296
|
+
// is still released. A call that never created one owes nothing and must
|
|
297
|
+
// not touch a file another owner holds.
|
|
298
|
+
return yield* acquire.pipe(
|
|
299
|
+
Effect.andThen(options.onAcquired ?? Effect.void),
|
|
300
|
+
Effect.andThen(Effect.scoped(Effect.gen(function*() {
|
|
301
|
+
yield* Effect.forkScoped(
|
|
302
|
+
Effect.gen(function*() {
|
|
303
|
+
while (true) {
|
|
304
|
+
yield* Effect.sleep(heartbeatEvery)
|
|
305
|
+
// Freshen only a lock this call still owns, the same comparison
|
|
306
|
+
// the release below makes. A holder stalled past the stale
|
|
307
|
+
// bound has already been reaped and replaced, and touching that
|
|
308
|
+
// pathname anyway would hold a stranger's lock fresh forever: a
|
|
309
|
+
// replacement that is then hard-killed could never be judged
|
|
310
|
+
// stale while this zombie ran, so every later acquirer would
|
|
311
|
+
// burn the full acquisition deadline and fail.
|
|
312
|
+
const beat = yield* fs.readFileString(lockPath).pipe(
|
|
313
|
+
Effect.map((found) => found === owner ? "own" as const : "foreign" as const),
|
|
314
|
+
Effect.catch((cause) =>
|
|
315
|
+
Effect.succeed(isReason(cause, "NotFound") ? "gone" as const : "unreadable" as const)
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
if (beat === "own") {
|
|
319
|
+
const timestamp = new Date(yield* Clock.currentTimeMillis)
|
|
320
|
+
yield* Effect.ignore(guarded(fs.utimes(lockPath, timestamp, timestamp)))
|
|
321
|
+
continue
|
|
322
|
+
}
|
|
323
|
+
// A read the host refused for any other reason is no evidence
|
|
324
|
+
// either way, so this beat is skipped and the next one retries.
|
|
325
|
+
// Ending the heartbeat on it would retire a lock this call
|
|
326
|
+
// still holds: the file goes stale within the minute, another
|
|
327
|
+
// process reaps it, and this holder keeps working unfenced.
|
|
328
|
+
if (beat === "unreadable") continue
|
|
329
|
+
// `gone` and `foreign` are the two states worth an operator
|
|
330
|
+
// signal: this call's lock was reaped, and possibly already
|
|
331
|
+
// replaced, while the effect it fences is still running, so
|
|
332
|
+
// the rest of that effect proceeds unfenced against every other
|
|
333
|
+
// process. The heartbeat is the only party that ever observes
|
|
334
|
+
// it — the protected effect is not interrupted and the caller
|
|
335
|
+
// is not failed — so retiring the fiber silently would leave no
|
|
336
|
+
// trace at all.
|
|
337
|
+
yield* Effect.logWarning(
|
|
338
|
+
`${label} was reclaimed while its holder was still running`,
|
|
339
|
+
{ ...options.annotations, state: beat }
|
|
340
|
+
)
|
|
341
|
+
return
|
|
342
|
+
}
|
|
343
|
+
})
|
|
344
|
+
)
|
|
345
|
+
return yield* effect
|
|
346
|
+
}))),
|
|
347
|
+
Effect.onExit(() => acquired ? release : Effect.void)
|
|
348
|
+
)
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Runs `effect` while holding the lease at `lockPath`.
|
|
353
|
+
*
|
|
354
|
+
* Acquisition fails with `failure(TimeoutError)` once `acquireWithin` passes
|
|
355
|
+
* without the lease; every other host refusal reaches `failure` with the
|
|
356
|
+
* refusing `PlatformError`. The deadline ends when the lease is held: the
|
|
357
|
+
* protected effect and its release are never timed out.
|
|
358
|
+
*
|
|
359
|
+
* @category combinators
|
|
360
|
+
* @since 1.0.0-rc.1
|
|
361
|
+
*/
|
|
362
|
+
export const withLease = <A, E, R, E2>(
|
|
363
|
+
fs: FileSystem.FileSystem,
|
|
364
|
+
lockPath: string,
|
|
365
|
+
effect: Effect.Effect<A, E, R>,
|
|
366
|
+
failure: (cause: unknown) => E2,
|
|
367
|
+
options: Options = {}
|
|
368
|
+
): Effect.Effect<A, E | E2, R> =>
|
|
369
|
+
Effect.suspend(() => {
|
|
370
|
+
const ready = Deferred.makeUnsafe<void>()
|
|
371
|
+
const deadline = Deferred.await(ready).pipe(
|
|
372
|
+
Effect.timeout(options.acquireWithin ?? defaultAcquireWithin),
|
|
373
|
+
Effect.catchTag("TimeoutError", (cause) => Effect.fail(failure(cause))),
|
|
374
|
+
Effect.andThen(Effect.never)
|
|
375
|
+
)
|
|
376
|
+
return hold(fs, lockPath, effect, failure, {
|
|
377
|
+
...options,
|
|
378
|
+
onAcquired: Deferred.succeed(ready, undefined).pipe(Effect.asVoid)
|
|
379
|
+
}).pipe(Effect.raceFirst(deadline))
|
|
380
|
+
})
|