@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.
@@ -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.forever(
128
- Effect.sleep(heartbeatEvery).pipe(
129
- Effect.andThen(gate(
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.logWarning(
145
- "Artifact backup lease was reclaimed while its backup was still running"
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
- // A failed heartbeat is retried on the next tick, but a
158
- // minute of them lets the marker go stale, so each one is
159
- // reported rather than dropped.
160
- Effect.catch((cause) => Effect.logWarning("Artifact backup lease heartbeat failed", cause))
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
  }))
@@ -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 type * as Duration from "effect/Duration"
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
- /** Age past which a lock or claim is stale. Defaults to 60 000 ms. */
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.
@@ -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
- if (endpoint.username !== "" || endpoint.password !== "" || endpoint.search !== "" || endpoint.hash !== "") {
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
  )