@smthrs/artifacts 0.0.0-stage → 1.0.0-rc.12

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.
Files changed (171) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/LICENSE +21 -0
  3. package/README.md +175 -2
  4. package/dist/cjs/ArtifactBackupLease.d.ts +34 -0
  5. package/dist/cjs/ArtifactBackupLease.d.ts.map +1 -0
  6. package/dist/cjs/ArtifactBackupLease.js +183 -0
  7. package/dist/cjs/ArtifactBackupLease.js.map +7 -0
  8. package/dist/cjs/ArtifactStore.d.ts +129 -0
  9. package/dist/cjs/ArtifactStore.d.ts.map +1 -0
  10. package/dist/cjs/ArtifactStore.js +73 -0
  11. package/dist/cjs/ArtifactStore.js.map +7 -0
  12. package/dist/cjs/ArtifactStoreError.d.ts +82 -0
  13. package/dist/cjs/ArtifactStoreError.d.ts.map +1 -0
  14. package/dist/cjs/ArtifactStoreError.js +72 -0
  15. package/dist/cjs/ArtifactStoreError.js.map +7 -0
  16. package/dist/cjs/ArtifactStoreMetrics.d.ts +70 -0
  17. package/dist/cjs/ArtifactStoreMetrics.d.ts.map +1 -0
  18. package/dist/cjs/ArtifactStoreMetrics.js +51 -0
  19. package/dist/cjs/ArtifactStoreMetrics.js.map +7 -0
  20. package/dist/cjs/ArtifactSweep.d.ts +185 -0
  21. package/dist/cjs/ArtifactSweep.d.ts.map +1 -0
  22. package/dist/cjs/ArtifactSweep.js +171 -0
  23. package/dist/cjs/ArtifactSweep.js.map +7 -0
  24. package/dist/cjs/CombinedArtifacts.d.ts +103 -0
  25. package/dist/cjs/CombinedArtifacts.d.ts.map +1 -0
  26. package/dist/cjs/CombinedArtifacts.js +189 -0
  27. package/dist/cjs/CombinedArtifacts.js.map +7 -0
  28. package/dist/cjs/FileLease.d.ts +117 -0
  29. package/dist/cjs/FileLease.d.ts.map +1 -0
  30. package/dist/cjs/FileLease.js +208 -0
  31. package/dist/cjs/FileLease.js.map +7 -0
  32. package/dist/cjs/FileSystemArtifactStore.d.ts +93 -0
  33. package/dist/cjs/FileSystemArtifactStore.d.ts.map +1 -0
  34. package/dist/cjs/FileSystemArtifactStore.js +276 -0
  35. package/dist/cjs/FileSystemArtifactStore.js.map +7 -0
  36. package/dist/cjs/MemoryArtifactStore.d.ts +25 -0
  37. package/dist/cjs/MemoryArtifactStore.d.ts.map +1 -0
  38. package/dist/cjs/MemoryArtifactStore.js +84 -0
  39. package/dist/cjs/MemoryArtifactStore.js.map +7 -0
  40. package/dist/cjs/NoopArtifactStore.d.ts +17 -0
  41. package/dist/cjs/NoopArtifactStore.d.ts.map +1 -0
  42. package/dist/cjs/NoopArtifactStore.js +46 -0
  43. package/dist/cjs/NoopArtifactStore.js.map +7 -0
  44. package/dist/cjs/RemoteArtifacts.d.ts +237 -0
  45. package/dist/cjs/RemoteArtifacts.d.ts.map +1 -0
  46. package/dist/cjs/RemoteArtifacts.js +405 -0
  47. package/dist/cjs/RemoteArtifacts.js.map +7 -0
  48. package/dist/cjs/index.d.ts +65 -0
  49. package/dist/cjs/index.d.ts.map +1 -0
  50. package/dist/cjs/index.js +47 -0
  51. package/dist/cjs/index.js.map +7 -0
  52. package/dist/cjs/internal/ArtifactLocks.d.ts +30 -0
  53. package/dist/cjs/internal/ArtifactLocks.d.ts.map +1 -0
  54. package/dist/cjs/internal/ArtifactLocks.js +93 -0
  55. package/dist/cjs/internal/ArtifactLocks.js.map +7 -0
  56. package/dist/cjs/internal/ArtifactPath.d.ts +19 -0
  57. package/dist/cjs/internal/ArtifactPath.d.ts.map +1 -0
  58. package/dist/cjs/internal/ArtifactPath.js +74 -0
  59. package/dist/cjs/internal/ArtifactPath.js.map +7 -0
  60. package/dist/cjs/measureBytes.d.ts +18 -0
  61. package/dist/cjs/measureBytes.d.ts.map +1 -0
  62. package/dist/cjs/measureBytes.js +43 -0
  63. package/dist/cjs/measureBytes.js.map +7 -0
  64. package/dist/cjs/package.json +1 -0
  65. package/dist/cjs/snapshotBytes.d.ts +19 -0
  66. package/dist/cjs/snapshotBytes.d.ts.map +1 -0
  67. package/dist/cjs/snapshotBytes.js +41 -0
  68. package/dist/cjs/snapshotBytes.js.map +7 -0
  69. package/dist/cjs/validateDigest.d.ts +22 -0
  70. package/dist/cjs/validateDigest.d.ts.map +1 -0
  71. package/dist/cjs/validateDigest.js +42 -0
  72. package/dist/cjs/validateDigest.js.map +7 -0
  73. package/dist/esm/ArtifactBackupLease.d.ts +34 -0
  74. package/dist/esm/ArtifactBackupLease.d.ts.map +1 -0
  75. package/dist/esm/ArtifactBackupLease.js +126 -0
  76. package/dist/esm/ArtifactBackupLease.js.map +1 -0
  77. package/dist/esm/ArtifactStore.d.ts +129 -0
  78. package/dist/esm/ArtifactStore.d.ts.map +1 -0
  79. package/dist/esm/ArtifactStore.js +83 -0
  80. package/dist/esm/ArtifactStore.js.map +1 -0
  81. package/dist/esm/ArtifactStoreError.d.ts +82 -0
  82. package/dist/esm/ArtifactStoreError.d.ts.map +1 -0
  83. package/dist/esm/ArtifactStoreError.js +77 -0
  84. package/dist/esm/ArtifactStoreError.js.map +1 -0
  85. package/dist/esm/ArtifactStoreMetrics.d.ts +70 -0
  86. package/dist/esm/ArtifactStoreMetrics.d.ts.map +1 -0
  87. package/dist/esm/ArtifactStoreMetrics.js +77 -0
  88. package/dist/esm/ArtifactStoreMetrics.js.map +1 -0
  89. package/dist/esm/ArtifactSweep.d.ts +185 -0
  90. package/dist/esm/ArtifactSweep.d.ts.map +1 -0
  91. package/dist/esm/ArtifactSweep.js +210 -0
  92. package/dist/esm/ArtifactSweep.js.map +1 -0
  93. package/dist/esm/CombinedArtifacts.d.ts +103 -0
  94. package/dist/esm/CombinedArtifacts.d.ts.map +1 -0
  95. package/dist/esm/CombinedArtifacts.js +214 -0
  96. package/dist/esm/CombinedArtifacts.js.map +1 -0
  97. package/dist/esm/FileLease.d.ts +117 -0
  98. package/dist/esm/FileLease.d.ts.map +1 -0
  99. package/dist/esm/FileLease.js +249 -0
  100. package/dist/esm/FileLease.js.map +1 -0
  101. package/dist/esm/FileSystemArtifactStore.d.ts +93 -0
  102. package/dist/esm/FileSystemArtifactStore.d.ts.map +1 -0
  103. package/dist/esm/FileSystemArtifactStore.js +336 -0
  104. package/dist/esm/FileSystemArtifactStore.js.map +1 -0
  105. package/dist/esm/MemoryArtifactStore.d.ts +25 -0
  106. package/dist/esm/MemoryArtifactStore.d.ts.map +1 -0
  107. package/dist/esm/MemoryArtifactStore.js +67 -0
  108. package/dist/esm/MemoryArtifactStore.js.map +1 -0
  109. package/dist/esm/NoopArtifactStore.d.ts +17 -0
  110. package/dist/esm/NoopArtifactStore.d.ts.map +1 -0
  111. package/dist/esm/NoopArtifactStore.js +27 -0
  112. package/dist/esm/NoopArtifactStore.js.map +1 -0
  113. package/dist/esm/RemoteArtifacts.d.ts +237 -0
  114. package/dist/esm/RemoteArtifacts.d.ts.map +1 -0
  115. package/dist/esm/RemoteArtifacts.js +557 -0
  116. package/dist/esm/RemoteArtifacts.js.map +1 -0
  117. package/dist/esm/index.d.ts +65 -0
  118. package/dist/esm/index.d.ts.map +1 -0
  119. package/dist/esm/index.js +65 -0
  120. package/dist/esm/index.js.map +1 -0
  121. package/dist/esm/internal/ArtifactLocks.d.ts +30 -0
  122. package/dist/esm/internal/ArtifactLocks.d.ts.map +1 -0
  123. package/dist/esm/internal/ArtifactLocks.js +86 -0
  124. package/dist/esm/internal/ArtifactLocks.js.map +1 -0
  125. package/dist/esm/internal/ArtifactPath.d.ts +19 -0
  126. package/dist/esm/internal/ArtifactPath.d.ts.map +1 -0
  127. package/dist/esm/internal/ArtifactPath.js +59 -0
  128. package/dist/esm/internal/ArtifactPath.js.map +1 -0
  129. package/dist/esm/measureBytes.d.ts +18 -0
  130. package/dist/esm/measureBytes.d.ts.map +1 -0
  131. package/dist/esm/measureBytes.js +22 -0
  132. package/dist/esm/measureBytes.js.map +1 -0
  133. package/dist/esm/snapshotBytes.d.ts +19 -0
  134. package/dist/esm/snapshotBytes.d.ts.map +1 -0
  135. package/dist/esm/snapshotBytes.js +30 -0
  136. package/dist/esm/snapshotBytes.js.map +1 -0
  137. package/dist/esm/validateDigest.d.ts +22 -0
  138. package/dist/esm/validateDigest.d.ts.map +1 -0
  139. package/dist/esm/validateDigest.js +26 -0
  140. package/dist/esm/validateDigest.js.map +1 -0
  141. package/docs/README.md +202 -0
  142. package/docs/api.md +484 -0
  143. package/docs/concepts/content-addressing.md +174 -0
  144. package/docs/concepts/coordination.md +141 -0
  145. package/docs/concepts/tiers.md +147 -0
  146. package/docs/guides/fence-a-backup.md +123 -0
  147. package/docs/guides/reclaim-disk-space.md +143 -0
  148. package/docs/guides/serve-the-artifact-protocol.md +140 -0
  149. package/docs/guides/share-artifacts-across-machines.md +180 -0
  150. package/docs/guides/test-against-an-artifact-store.md +158 -0
  151. package/docs/installation.md +102 -0
  152. package/docs/quickstart.md +183 -0
  153. package/docs/troubleshooting.md +232 -0
  154. package/package.json +179 -3
  155. package/src/ArtifactBackupLease.ts +223 -0
  156. package/src/ArtifactStore.ts +152 -0
  157. package/src/ArtifactStoreError.ts +96 -0
  158. package/src/ArtifactStoreMetrics.ts +83 -0
  159. package/src/ArtifactSweep.ts +346 -0
  160. package/src/CombinedArtifacts.ts +369 -0
  161. package/src/FileLease.ts +397 -0
  162. package/src/FileSystemArtifactStore.ts +459 -0
  163. package/src/MemoryArtifactStore.ts +83 -0
  164. package/src/NoopArtifactStore.ts +30 -0
  165. package/src/RemoteArtifacts.ts +814 -0
  166. package/src/index.ts +71 -0
  167. package/src/internal/ArtifactLocks.ts +110 -0
  168. package/src/internal/ArtifactPath.ts +72 -0
  169. package/src/measureBytes.ts +28 -0
  170. package/src/snapshotBytes.ts +34 -0
  171. package/src/validateDigest.ts +31 -0
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Standard metric definitions for content-addressed artifact storage.
3
+ *
4
+ * This module only defines the metric handles, following the shape of Effect's
5
+ * `ClusterMetrics`. No exporter ships in this package; provide one — for
6
+ * example `@smthrs/observability` — and these counters appear in it.
7
+ *
8
+ * Only the local `ArtifactStore` implementations, filesystem and memory, update
9
+ * them. `RemoteArtifacts` is deliberately uninstrumented, and the counters carry
10
+ * no tier attribute, so read them as *local artifact store traffic* rather than
11
+ * as artifact operations:
12
+ *
13
+ * - a `CombinedArtifacts` read the local tier serves counts one get;
14
+ * - a read the shared tier serves counts NO get, because no local store
15
+ * answered it;
16
+ * - the write-back that materializes such a read counts a put, indistinguishable
17
+ * from a producer publishing new bytes.
18
+ *
19
+ * Attributing operations per tier needs the tier in the metric, which would
20
+ * change the published counter shape; until then this is what the numbers mean.
21
+ *
22
+ * The one shared-tier signal is {@link remoteFailure}: a `CombinedArtifacts`
23
+ * upload or write-back that was refused, or abandoned at its deadline.
24
+ *
25
+ * @since 1.0.0-rc.0
26
+ */
27
+
28
+ import * as Metric from "effect/Metric"
29
+
30
+ /**
31
+ * Counter over successful artifact puts. A put that deduplicated against an
32
+ * existing verified blob still counts: the caller stored bytes and received
33
+ * an address either way.
34
+ *
35
+ * @category metrics
36
+ * @since 1.0.0-rc.0
37
+ * @slop
38
+ */
39
+ export const puts = Metric.counter("flows_artifact_puts", {
40
+ description: "Successful artifact puts, including deduplicated ones"
41
+ })
42
+
43
+ /**
44
+ * Counter over successful artifact gets. Missing and corrupt reads fail with
45
+ * their typed errors and are deliberately not counted here; they are error
46
+ * evidence, not throughput.
47
+ *
48
+ * @category metrics
49
+ * @since 1.0.0-rc.0
50
+ * @slop
51
+ */
52
+ export const gets = Metric.counter("flows_artifact_gets", {
53
+ description: "Successful artifact gets"
54
+ })
55
+
56
+ /**
57
+ * Counter over opportunistic `CombinedArtifacts` transfers that failed or hit
58
+ * their deadline and were dropped: `put`'s upload to the shared tier and
59
+ * `get`'s write-back into the local tier.
60
+ *
61
+ * Read the attributed views on {@link remoteFailure}: every update carries an
62
+ * `operation` attribute, so this bare handle aggregates nothing and always
63
+ * reads zero.
64
+ *
65
+ * @category metrics
66
+ * @since 1.0.0-rc.1
67
+ */
68
+ export const remoteFailures = Metric.counter("flows_artifact_remote_failures", {
69
+ description: "Dropped combined-artifact transfers by operation"
70
+ })
71
+
72
+ /**
73
+ * `remoteFailures` views keyed by the dropped transfer.
74
+ *
75
+ * @category metrics
76
+ * @since 1.0.0-rc.1
77
+ */
78
+ export const remoteFailure: {
79
+ readonly [Operation in "put" | "write_back"]: Metric.Metric<number, Metric.CounterState<number>>
80
+ } = {
81
+ put: Metric.withAttributes(remoteFailures, { operation: "put" }),
82
+ write_back: Metric.withAttributes(remoteFailures, { operation: "write_back" })
83
+ }
@@ -0,0 +1,346 @@
1
+ /**
2
+ * The sweep half of artifact garbage collection: enumerate this host's blobs
3
+ * and delete one, fenced by its age.
4
+ *
5
+ * This surface is deliberately NOT part of `ArtifactStore.Service`. The store
6
+ * contract is shared by memory, filesystem, and network tiers, and a remote
7
+ * tier can neither enumerate its address space nor accept a delete — Bazel
8
+ * draws the same line: its disk-cache collector
9
+ * (class `DiskCacheGarbageCollector` in
10
+ * `com.google.devtools.build.lib.remote.disk`) walks the local directory, while
11
+ * the remote tier owns its own retention. Only the
12
+ * host-local filesystem store implements this.
13
+ *
14
+ * The *policy* — which digests are live, how old a dead blob must be before it
15
+ * goes — belongs to the engine composition, which is the only place the
16
+ * durable roots (attempt rows, cache entries) are visible. This module ships
17
+ * mechanics alone; `@smthrs/engine-store` owns the mark policy and grace
18
+ * period.
19
+ *
20
+ * @since 1.0.0-rc.0
21
+ */
22
+
23
+ import * as Context from "effect/Context"
24
+ import * as Effect from "effect/Effect"
25
+ import * as FileSystem from "effect/FileSystem"
26
+ import * as Layer from "effect/Layer"
27
+ import * as Option from "effect/Option"
28
+ import * as PlatformError from "effect/PlatformError"
29
+ import * as ArtifactBackupLease from "./ArtifactBackupLease.ts"
30
+ import * as ArtifactStore from "./ArtifactStore.ts"
31
+ import * as ArtifactLocks from "./internal/ArtifactLocks.ts"
32
+ import * as ArtifactPath from "./internal/ArtifactPath.ts"
33
+
34
+ /**
35
+ * One enumerated blob: its content address, when it was last written or
36
+ * freshened, and its size.
37
+ *
38
+ * `modifiedAtMs` is the age evidence a sweep fences on — the same signal git
39
+ * (`git prune --expire`) and Bazel's disk-cache collector read, because it is
40
+ * the one timestamp a filesystem maintains without any bookkeeping of ours.
41
+ *
42
+ * @category models
43
+ * @since 1.0.0-rc.0
44
+ * @slop
45
+ */
46
+ export interface BlobStat {
47
+ readonly digest: string
48
+ readonly modifiedAtMs: number
49
+ readonly sizeBytes: number
50
+ }
51
+
52
+ /**
53
+ * Fencing predicate for a sweep deletion.
54
+ *
55
+ * @category models
56
+ * @since 1.0.0-rc.0
57
+ * @slop
58
+ */
59
+ export interface RemoveOptions {
60
+ /**
61
+ * Deletes the blob only while its mtime is still at or before this bound.
62
+ * A blob freshened after the caller computed its live set — a concurrent
63
+ * `put` re-referencing the same bytes — fails the fence and survives, the
64
+ * same shape as `CacheStore.evict`'s `ifRecordedBy`: the guard rides in the
65
+ * delete, never in a prior read alone.
66
+ */
67
+ readonly ifUnmodifiedSinceMs?: number | undefined
68
+ }
69
+
70
+ /**
71
+ * Host-local blob enumeration and fenced deletion.
72
+ *
73
+ * @category models
74
+ * @since 1.0.0-rc.0
75
+ * @slop
76
+ */
77
+ export interface Service {
78
+ /**
79
+ * Every content-addressed blob this host's store currently holds. In-flight
80
+ * `.tmp-*` scratch files, foreign files, and blobs whose age cannot be
81
+ * measured are excluded — what the sweep cannot judge it must not touch.
82
+ */
83
+ readonly inventory: Effect.Effect<Array<BlobStat>, ArtifactStore.ArtifactStoreError>
84
+ /**
85
+ * Deletes the blob at `digest`, returning whether bytes were removed. A
86
+ * missing blob reports `false` rather than failing, so a crashed and
87
+ * re-run sweep converges instead of erroring on its own progress.
88
+ *
89
+ * `false` covers three outcomes a caller cannot tell apart, all of which mean
90
+ * "nothing was reclaimed, and retrying later is safe": the blob was already
91
+ * gone, it failed the `ifUnmodifiedSinceMs` fence, or a live backup lease
92
+ * fenced the deletion (see `ArtifactBackupLease`). The last one is not
93
+ * progress — the blob is still there — so a collector that counts reclaimed
94
+ * bytes must not count it, and the next pass after the backup finishes
95
+ * removes it.
96
+ */
97
+ readonly remove: (
98
+ digest: string,
99
+ options?: RemoveOptions
100
+ ) => Effect.Effect<boolean, ArtifactStore.ArtifactStoreError>
101
+ }
102
+
103
+ /**
104
+ * Service tag for host-local artifact sweeping.
105
+ *
106
+ * The identity string equals this module's package path, per the house rule
107
+ * that a new identity is the defining module path.
108
+ *
109
+ * @category services
110
+ * @since 1.0.0-rc.0
111
+ * @slop
112
+ */
113
+ export class ArtifactSweep extends Context.Service<ArtifactSweep, Service>()("@smthrs/artifacts/ArtifactSweep") {}
114
+
115
+ const error = (message: string, cause?: unknown): ArtifactStore.ArtifactStoreError =>
116
+ new ArtifactStore.ArtifactStoreError({
117
+ code: "unavailable",
118
+ message,
119
+ ...(cause === undefined ? {} : { cause })
120
+ })
121
+
122
+ const hostFailure = (cause: unknown): ArtifactStore.ArtifactStoreError =>
123
+ error(`the host filesystem refused a sweep operation: ${String(cause)}`, cause)
124
+
125
+ const isNotFound = (cause: unknown): boolean =>
126
+ cause instanceof PlatformError.PlatformError && cause.reason._tag === "NotFound"
127
+
128
+ /**
129
+ * How the sweep reaches the objects directory.
130
+ *
131
+ * Deliberately not `ArtifactStore.FileSystemOptions`: the sweep never writes a
132
+ * blob, so `durability` would be an accepted argument it ignores. Both fields
133
+ * must match the store built over the same directory — see `coordination`.
134
+ *
135
+ * @category models
136
+ * @since 1.0.0-rc.0
137
+ */
138
+ export interface SweepOptions {
139
+ /**
140
+ * The objects directory to enumerate, the one the paired
141
+ * `ArtifactStore.makeFileSystem` publishes into. Defaults to
142
+ * `ArtifactStore.defaultDirectory`.
143
+ */
144
+ readonly directory?: string | undefined
145
+ /**
146
+ * `required` takes the same per-digest lock file a publishing writer takes,
147
+ * and consults the backup lease, before deleting. It fences nothing unless
148
+ * the paired store was built with `required` too: a store on `process` never
149
+ * takes the lock this sweep waits on, so the fence would read as armed while
150
+ * protecting nothing. `process` is the explicit weaker browser/test mode.
151
+ */
152
+ readonly coordination?: "required" | "process" | undefined
153
+ }
154
+
155
+ /**
156
+ * Builds the filesystem-backed sweep surface over the same objects directory
157
+ * an `ArtifactStore.makeFileSystem` publishes into.
158
+ *
159
+ * Enumeration is conservative by construction. Only paths in the store's
160
+ * canonical fanout shape — `xx/<digest>` with `xx` equal to the digest's
161
+ * two-hex prefix — are blobs; anything else in the directory (a temp file, a
162
+ * lock file, a foreign file, a fanout directory itself) is skipped, never
163
+ * deleted. A blob that vanishes between listing and stat was removed by someone
164
+ * else and is likewise skipped.
165
+ *
166
+ * Pair it with the store over the SAME `directory` and the SAME `coordination`.
167
+ * Nothing checks the pairing, and a mismatch is silent: a sweep on `required`
168
+ * beside a store on `process` deletes blobs no writer is fenced against.
169
+ *
170
+ * Under `required`, one deletion costs two lock acquisitions — the per-digest
171
+ * lock, then the workspace-global backup-lease gate — which is roughly ten
172
+ * filesystem operations and two forked heartbeat fibers per blob. The gate is
173
+ * one file for the whole workspace, so concurrent deletions serialize through
174
+ * it. That is the price of never deleting a blob a running backup already
175
+ * recorded; size a collection window with it in mind.
176
+ *
177
+ * @category constructors
178
+ * @since 1.0.0-rc.0
179
+ * @slop
180
+ */
181
+ export const makeFileSystem = (
182
+ fs: FileSystem.FileSystem,
183
+ options: SweepOptions = {}
184
+ ): Service => {
185
+ const directory = (options.directory ?? ArtifactStore.defaultDirectory).replace(/([^/])\/+$/, "$1")
186
+ const coordination = options.coordination ?? "required"
187
+ const blobPath = (digest: string): string => `${directory}/${digest.slice(0, 2)}/${digest}`
188
+
189
+ const inventory: Service["inventory"] = Effect.gen(function*() {
190
+ // A store that never published anything has no directory; that is an
191
+ // empty inventory, not a failure.
192
+ const checkRoot = yield* ArtifactPath.guard(fs, directory).pipe(Effect.mapError(hostFailure))
193
+ const parents = yield* fs.readDirectory(directory).pipe(
194
+ Effect.catch((cause) => isNotFound(cause) ? Effect.succeed([] as Array<string>) : Effect.fail(hostFailure(cause)))
195
+ )
196
+ const entries: Array<string> = []
197
+ for (const parent of parents) {
198
+ if (!/^[0-9a-f]{2}$/.test(parent)) continue
199
+ yield* checkRoot.pipe(Effect.mapError(hostFailure))
200
+ const safe = yield* ArtifactPath.guard(fs, `${directory}/${parent}`).pipe(Effect.option)
201
+ if (Option.isNone(safe)) continue
202
+ const children = yield* fs.readDirectory(`${directory}/${parent}`).pipe(
203
+ Effect.orElseSucceed(() => [] as Array<string>)
204
+ )
205
+ for (const child of children) entries.push(`${parent}/${child}`)
206
+ }
207
+ const candidates = entries.filter((entry) => {
208
+ if (entry.includes(".tmp-")) return false
209
+ const separator = entry.indexOf("/")
210
+ const fan = entry.slice(0, separator)
211
+ const digest = entry.slice(separator + 1)
212
+ return /^[0-9a-f]{64}$/.test(digest) && fan === digest.slice(0, 2)
213
+ })
214
+ const blobs = yield* Effect.forEach(candidates, (entry) =>
215
+ Effect.gen(function*() {
216
+ const separator = entry.indexOf("/")
217
+ const fan = entry.slice(0, separator)
218
+ const digest = entry.slice(separator + 1)
219
+ // The stat is per-candidate and tolerant: a blob another process
220
+ // removed between the listing and here simply is not in the
221
+ // inventory, and one whose mtime the host cannot report offers no
222
+ // age evidence, so the sweep must never judge it.
223
+ const info = yield* Effect.gen(function*() {
224
+ yield* checkRoot
225
+ yield* ArtifactPath.guard(fs, `${directory}/${fan}`)
226
+ yield* ArtifactPath.guard(fs, `${directory}/${entry}`, "File")
227
+ return yield* fs.stat(`${directory}/${entry}`)
228
+ }).pipe(Effect.option)
229
+ if (Option.isNone(info) || info.value.type !== "File") return undefined
230
+ const mtime = Option.getOrUndefined(info.value.mtime)
231
+ if (mtime === undefined) return undefined
232
+ return {
233
+ digest,
234
+ modifiedAtMs: mtime.getTime(),
235
+ sizeBytes: Number(info.value.size)
236
+ }
237
+ }), { concurrency: 16 })
238
+ return blobs.filter((blob) => blob !== undefined)
239
+ })
240
+
241
+ const remove: Service["remove"] = Effect.fn("ArtifactSweep.remove")((digest, removeOptions) =>
242
+ Effect.gen(function*() {
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
+ }
255
+ yield* Effect.annotateCurrentSpan({ digest: validated })
256
+ const checkRoot = yield* ArtifactPath.guard(fs, directory).pipe(Effect.mapError(hostFailure))
257
+ const checkParent = yield* ArtifactPath.guard(fs, `${directory}/${validated.slice(0, 2)}`).pipe(
258
+ Effect.mapError(hostFailure)
259
+ )
260
+ return yield* ArtifactLocks.withDigest(
261
+ fs,
262
+ directory,
263
+ validated,
264
+ coordination === "process"
265
+ ? removeBlob()
266
+ : ArtifactBackupLease.unlessActive(fs, directory, removeBlob(), hostFailure).pipe(
267
+ Effect.map(Option.getOrElse(() => false))
268
+ ),
269
+ hostFailure,
270
+ coordination
271
+ )
272
+
273
+ function removeBlob(): Effect.Effect<boolean, ArtifactStore.ArtifactStoreError> {
274
+ return Effect.gen(function*() {
275
+ const path = blobPath(validated)
276
+ yield* checkRoot.pipe(Effect.mapError(hostFailure))
277
+ yield* checkParent.pipe(Effect.mapError(hostFailure))
278
+ const checkBlob = yield* ArtifactPath.guard(fs, path, "File").pipe(Effect.mapError(hostFailure))
279
+ if (bound !== undefined) {
280
+ const info = yield* fs.stat(path).pipe(Effect.option)
281
+ // Already gone is a completed deletion, and an unmeasurable mtime is
282
+ // no proof of age: both refuse rather than delete.
283
+ if (Option.isNone(info)) return false
284
+ const mtime = Option.getOrUndefined(info.value.mtime)
285
+ if (mtime === undefined || mtime.getTime() > bound) return false
286
+ }
287
+ yield* checkRoot.pipe(Effect.mapError(hostFailure))
288
+ yield* checkParent.pipe(Effect.mapError(hostFailure))
289
+ yield* checkBlob.pipe(Effect.mapError(hostFailure))
290
+ return yield* fs.remove(path).pipe(
291
+ Effect.as(true),
292
+ // The blob may have been removed between the fence and here; only a
293
+ // refusal over bytes that still exist is a host failure.
294
+ Effect.catch((cause) =>
295
+ fs.exists(path).pipe(
296
+ Effect.mapError((probeCause) => hostFailure({ remove: cause, existenceProbe: probeCause })),
297
+ Effect.flatMap((present) => present ? Effect.fail(hostFailure(cause)) : Effect.succeed(false))
298
+ )
299
+ )
300
+ )
301
+ })
302
+ }
303
+ })
304
+ )
305
+
306
+ return { inventory, remove }
307
+ }
308
+
309
+ /**
310
+ * Provides the filesystem-backed sweep surface.
311
+ *
312
+ * Give it the same `directory` and `coordination` as the `ArtifactStore` layer
313
+ * it sweeps behind; see {@link makeFileSystem}.
314
+ *
315
+ * @category layers
316
+ * @since 1.0.0-rc.0
317
+ * @slop
318
+ */
319
+ export const layerFileSystem = (
320
+ options: SweepOptions = {}
321
+ ): Layer.Layer<ArtifactSweep, never, FileSystem.FileSystem> =>
322
+ Layer.effect(ArtifactSweep)(Effect.map(FileSystem.FileSystem, (fs) => makeFileSystem(fs, options)))
323
+
324
+ /**
325
+ * Builds a sweep surface whose every operation fails as unavailable, with
326
+ * per-method overrides.
327
+ *
328
+ * @category constructors
329
+ * @since 1.0.0-rc.0
330
+ * @slop
331
+ */
332
+ export const makeNoop = (overrides: Partial<Service> = {}): Service => ({
333
+ inventory: Effect.fail(error("inventory is unavailable")),
334
+ remove: Effect.fn("ArtifactSweep.remove")(() => Effect.fail(error("remove is unavailable"))),
335
+ ...overrides
336
+ })
337
+
338
+ /**
339
+ * Provides a no-op sweep surface.
340
+ *
341
+ * @category layers
342
+ * @since 1.0.0-rc.0
343
+ * @slop
344
+ */
345
+ export const layerNoop = (overrides: Partial<Service> = {}): Layer.Layer<ArtifactSweep> =>
346
+ Layer.succeed(ArtifactSweep)(makeNoop(overrides))