@smthrs/artifacts 1.0.0-rc.7 → 1.0.0-rc.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/dist/cjs/ArtifactBackupLease.d.ts.map +1 -1
- package/dist/cjs/ArtifactBackupLease.js +21 -13
- package/dist/cjs/ArtifactBackupLease.js.map +2 -2
- package/dist/cjs/ArtifactSweep.d.ts.map +1 -1
- package/dist/cjs/ArtifactSweep.js +9 -1
- package/dist/cjs/ArtifactSweep.js.map +2 -2
- package/dist/cjs/FileLease.d.ts +6 -2
- package/dist/cjs/FileLease.d.ts.map +1 -1
- package/dist/cjs/FileLease.js +7 -0
- package/dist/cjs/FileLease.js.map +2 -2
- package/dist/cjs/RemoteArtifacts.d.ts.map +1 -1
- package/dist/cjs/RemoteArtifacts.js +1 -1
- package/dist/cjs/RemoteArtifacts.js.map +2 -2
- package/dist/esm/ArtifactBackupLease.d.ts.map +1 -1
- package/dist/esm/ArtifactBackupLease.js +26 -17
- package/dist/esm/ArtifactBackupLease.js.map +1 -1
- package/dist/esm/ArtifactSweep.d.ts.map +1 -1
- package/dist/esm/ArtifactSweep.js +9 -1
- package/dist/esm/ArtifactSweep.js.map +1 -1
- package/dist/esm/FileLease.d.ts +6 -2
- package/dist/esm/FileLease.d.ts.map +1 -1
- package/dist/esm/FileLease.js +10 -0
- package/dist/esm/FileLease.js.map +1 -1
- package/dist/esm/RemoteArtifacts.d.ts.map +1 -1
- package/dist/esm/RemoteArtifacts.js +4 -1
- package/dist/esm/RemoteArtifacts.js.map +1 -1
- package/docs/README.md +3 -1
- package/docs/api.md +4 -1
- package/docs/installation.md +5 -1
- package/docs/quickstart.md +2 -1
- package/package.json +2 -2
- package/src/ArtifactBackupLease.ts +24 -14
- package/src/ArtifactSweep.ts +11 -1
- package/src/FileLease.ts +19 -2
- package/src/RemoteArtifacts.ts +6 -1
|
@@ -124,9 +124,10 @@ export const withLease = <A, E, R, E2>(
|
|
|
124
124
|
acquire.pipe(Effect.andThen(
|
|
125
125
|
Effect.scoped(Effect.gen(function*() {
|
|
126
126
|
yield* Effect.forkScoped(
|
|
127
|
-
Effect.
|
|
128
|
-
|
|
129
|
-
Effect.
|
|
127
|
+
Effect.gen(function*() {
|
|
128
|
+
while (true) {
|
|
129
|
+
yield* Effect.sleep(heartbeatEvery)
|
|
130
|
+
const reclaimed = yield* gate(
|
|
130
131
|
fs,
|
|
131
132
|
directory,
|
|
132
133
|
fs.readFileString(marker).pipe(
|
|
@@ -139,27 +140,36 @@ export const withLease = <A, E, R, E2>(
|
|
|
139
140
|
// lease stale and reaped it: deletion is no longer
|
|
140
141
|
// fenced while the backup still copies. The sibling
|
|
141
142
|
// `internal/ArtifactLocks.ts` heartbeat reports the same
|
|
142
|
-
// loss the same way.
|
|
143
|
+
// loss the same way. A reaped marker is never handed
|
|
144
|
+
// back, so the loss is reported once and the heartbeat
|
|
145
|
+
// ends instead of repeating it every tick.
|
|
143
146
|
if (Option.getOrUndefined(found) !== owner) {
|
|
144
|
-
return Effect.
|
|
145
|
-
|
|
147
|
+
return Effect.as(
|
|
148
|
+
Effect.logWarning(
|
|
149
|
+
"Artifact backup lease was reclaimed while its backup was still running"
|
|
150
|
+
),
|
|
151
|
+
true
|
|
146
152
|
)
|
|
147
153
|
}
|
|
148
154
|
return Effect.flatMap(Clock.currentTimeMillis, (now) => {
|
|
149
155
|
const timestamp = new Date(now)
|
|
150
|
-
return fs.utimes(marker, timestamp, timestamp)
|
|
156
|
+
return Effect.as(fs.utimes(marker, timestamp, timestamp), false)
|
|
151
157
|
})
|
|
152
158
|
}),
|
|
153
159
|
Effect.mapError(failure)
|
|
154
160
|
),
|
|
155
161
|
failure
|
|
156
|
-
)
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
162
|
+
).pipe(
|
|
163
|
+
// A failed heartbeat is retried on the next tick, but a
|
|
164
|
+
// minute of them lets the marker go stale, so each one is
|
|
165
|
+
// reported rather than dropped.
|
|
166
|
+
Effect.catch((cause) =>
|
|
167
|
+
Effect.as(Effect.logWarning("Artifact backup lease heartbeat failed", cause), false)
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
if (reclaimed) return
|
|
171
|
+
}
|
|
172
|
+
})
|
|
163
173
|
)
|
|
164
174
|
return yield* effect
|
|
165
175
|
}))
|
package/src/ArtifactSweep.ts
CHANGED
|
@@ -241,6 +241,17 @@ export const makeFileSystem = (
|
|
|
241
241
|
const remove: Service["remove"] = Effect.fn("ArtifactSweep.remove")((digest, removeOptions) =>
|
|
242
242
|
Effect.gen(function*() {
|
|
243
243
|
const validated = yield* ArtifactStore.validateDigest(digest)
|
|
244
|
+
const bound = removeOptions?.ifUnmodifiedSinceMs
|
|
245
|
+
// NaN fails every comparison, so the fence below would read it as "not
|
|
246
|
+
// modified since" and delete a blob a concurrent put just freshened.
|
|
247
|
+
if (bound !== undefined && (typeof bound !== "number" || Number.isNaN(bound))) {
|
|
248
|
+
return yield* Effect.fail(
|
|
249
|
+
new ArtifactStore.ArtifactStoreError({
|
|
250
|
+
code: "invalid_configuration",
|
|
251
|
+
message: "invalid sweep option: ifUnmodifiedSinceMs"
|
|
252
|
+
})
|
|
253
|
+
)
|
|
254
|
+
}
|
|
244
255
|
yield* Effect.annotateCurrentSpan({ digest: validated })
|
|
245
256
|
const checkRoot = yield* ArtifactPath.guard(fs, directory).pipe(Effect.mapError(hostFailure))
|
|
246
257
|
const checkParent = yield* ArtifactPath.guard(fs, `${directory}/${validated.slice(0, 2)}`).pipe(
|
|
@@ -265,7 +276,6 @@ export const makeFileSystem = (
|
|
|
265
276
|
yield* checkRoot.pipe(Effect.mapError(hostFailure))
|
|
266
277
|
yield* checkParent.pipe(Effect.mapError(hostFailure))
|
|
267
278
|
const checkBlob = yield* ArtifactPath.guard(fs, path, "File").pipe(Effect.mapError(hostFailure))
|
|
268
|
-
const bound = removeOptions?.ifUnmodifiedSinceMs
|
|
269
279
|
if (bound !== undefined) {
|
|
270
280
|
const info = yield* fs.stat(path).pipe(Effect.option)
|
|
271
281
|
// Already gone is a completed deletion, and an unmeasurable mtime is
|
package/src/FileLease.ts
CHANGED
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
|
|
38
38
|
import * as Clock from "effect/Clock"
|
|
39
39
|
import * as Deferred from "effect/Deferred"
|
|
40
|
-
import
|
|
40
|
+
import * as Duration from "effect/Duration"
|
|
41
41
|
import * as Effect from "effect/Effect"
|
|
42
42
|
import type * as FileSystem from "effect/FileSystem"
|
|
43
43
|
import * as Option from "effect/Option"
|
|
@@ -57,7 +57,11 @@ export interface Options {
|
|
|
57
57
|
readonly annotations?: Readonly<Record<string, unknown>> | undefined
|
|
58
58
|
/** How often the holder refreshes the lock's mtime. Defaults to 10 seconds. */
|
|
59
59
|
readonly heartbeatEvery?: Duration.Input | undefined
|
|
60
|
-
/**
|
|
60
|
+
/**
|
|
61
|
+
* Age past which a lock or claim is stale. Defaults to 60 000 ms. Must be a
|
|
62
|
+
* finite positive number longer than `heartbeatEvery`; anything else fails
|
|
63
|
+
* the lease through `failure` before the lock is touched.
|
|
64
|
+
*/
|
|
61
65
|
readonly staleAfterMs?: number | undefined
|
|
62
66
|
/** How long acquisition may wait. Defaults to 2 minutes. */
|
|
63
67
|
readonly acquireWithin?: Duration.Input | undefined
|
|
@@ -132,6 +136,19 @@ export const hold = <A, E, R, E2>(
|
|
|
132
136
|
const heartbeatEvery = options.heartbeatEvery ?? "10 seconds"
|
|
133
137
|
const staleAfterMs = options.staleAfterMs ?? defaultStaleAfterMs
|
|
134
138
|
const retryEvery = options.retryEvery ?? "25 millis"
|
|
139
|
+
// A holder heartbeats its lock to stay fresh, so a stale bound at or below
|
|
140
|
+
// the heartbeat, or one that is not a positive finite number, would let a
|
|
141
|
+
// contender read a live holder's lock as abandoned and move it, or would
|
|
142
|
+
// never reclaim a dead holder's lock at all.
|
|
143
|
+
const heartbeatMs = Option.map(Duration.fromInput(heartbeatEvery), Duration.toMillis)
|
|
144
|
+
if (
|
|
145
|
+
typeof staleAfterMs !== "number" || !Number.isFinite(staleAfterMs) || staleAfterMs <= 0 ||
|
|
146
|
+
Option.isNone(heartbeatMs) || !(heartbeatMs.value > 0 && heartbeatMs.value < staleAfterMs)
|
|
147
|
+
) {
|
|
148
|
+
return yield* Effect.fail(
|
|
149
|
+
failure(new RangeError(`${label} needs 0 < heartbeatEvery < staleAfterMs, with a finite staleAfterMs`))
|
|
150
|
+
)
|
|
151
|
+
}
|
|
135
152
|
const owner = yield* token
|
|
136
153
|
/**
|
|
137
154
|
* Whether this call created the lock file, and therefore owes a release.
|
package/src/RemoteArtifacts.ts
CHANGED
|
@@ -376,7 +376,12 @@ export const make = (
|
|
|
376
376
|
if (endpoint.protocol !== "https:" && !(endpoint.protocol === "http:" && loopback)) {
|
|
377
377
|
return yield* Effect.fail(configurationFailure("remote artifact endpoint must use HTTPS"))
|
|
378
378
|
}
|
|
379
|
-
|
|
379
|
+
// An empty query or fragment ("https://h/p?") parses as "" yet survives
|
|
380
|
+
// serialization, so the delimiter itself is refused in the href.
|
|
381
|
+
if (
|
|
382
|
+
endpoint.username !== "" || endpoint.password !== "" || endpoint.search !== "" || endpoint.hash !== "" ||
|
|
383
|
+
endpoint.href.includes("?") || endpoint.href.includes("#")
|
|
384
|
+
) {
|
|
380
385
|
return yield* Effect.fail(
|
|
381
386
|
configurationFailure("remote artifact endpoint must not contain credentials, a query, or a fragment")
|
|
382
387
|
)
|