@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,174 @@
1
+ ---
2
+ title: "Content addressing"
3
+ description: "Why an artifact's address is a measurement of its bytes, what that buys, and the four invariants the store enforces to keep an address and its content from ever disagreeing."
4
+ sidebar:
5
+ order: 1
6
+ ---
7
+
8
+ An artifact has no name. Its address is the SHA-256 digest of its own bytes,
9
+ spelled as 64 lowercase hexadecimal characters, and the store computed it by
10
+ hashing what the caller handed over. That single decision is where every other
11
+ property in this package comes from.
12
+
13
+ Three things follow immediately. Two callers who produce the same bytes produce
14
+ the same address, so storing the second copy is free. An address is a proof
15
+ obligation the store can check at any moment, so a read can verify rather than
16
+ trust. And a digest is safe to write into a durable row, ship over a network,
17
+ or print in a log, because it reveals nothing about where the bytes live or who
18
+ produced them.
19
+
20
+ Smithers uses the same idea one layer up: a step key is derived from canonical
21
+ content rather than from an activity's name, which is what
22
+ [content addressing on smithers.sh](/docs/concepts/content-addressing/)
23
+ describes. This package is the byte-level half of that story.
24
+
25
+ ## A digest is a value, and an untrusted one
26
+
27
+ `ArtifactStore.Digest` is both the branded type and the schema for the
28
+ canonical representation. `put` returns the brand, because it measured the
29
+ bytes itself and can vouch for the form.
30
+
31
+ Every read operation accepts a plain `string` instead. That is deliberate: a
32
+ digest read back out of a durable row, a cache entry, or an HTTP body is
33
+ untrusted input, and asking every caller to re-brand a persisted column would
34
+ only move the trust question somewhere less careful. The store validates
35
+ instead. `ArtifactStore.validateDigest` refuses anything that is not exactly 64
36
+ lowercase hexadecimal characters, and it runs before any implementation logs a
37
+ digest or interpolates one into a path or a URL:
38
+
39
+ ```ts
40
+ import * as ArtifactStore from "@smthrs/artifacts/ArtifactStore"
41
+ import * as Effect from "effect/Effect"
42
+
43
+ /** Prints `invalid_digest` rather than reaching for `../../etc/passwd`. */
44
+ const checked = ArtifactStore.validateDigest("../../etc/passwd").pipe(
45
+ Effect.catchTag("@smthrs/artifacts/ArtifactStoreError", (failure) => Effect.succeed(failure.code))
46
+ )
47
+
48
+ console.log(await Effect.runPromise(checked))
49
+ ```
50
+
51
+ The failure message is a constant. A hostile multi-megabyte value cannot copy
52
+ itself into a log line or a durable error by being rejected.
53
+
54
+ ## The four invariants
55
+
56
+ ### Every read is digest verified
57
+
58
+ `get` measures the bytes it found and compares them against the address it was
59
+ asked for. A mismatch is `ArtifactCorruption`, carrying both the recorded and
60
+ the measured digest, and the bytes are never returned. This is what makes a
61
+ truncated blob from a crashing writer, a failing disk, or a shared tier serving
62
+ the wrong object into a loud failure rather than a silently wrong replay.
63
+
64
+ The in-memory store is the deliberate exception. Its address space is a private
65
+ `Map` keyed by the digest it measured on the way in, `put` stores a copy of the
66
+ caller's array, and `get` hands out a copy of the stored one. No reference a
67
+ caller can still mutate aliases the stored content, so there is no window in
68
+ which the address and the content can disagree. The filesystem and HTTP stores
69
+ verify because their address spaces are genuinely shared.
70
+
71
+ ### Publication is atomic
72
+
73
+ A plain write to the canonical path could be observed, or survive a crash, as a
74
+ partial file that every later read of that digest would trust. The filesystem
75
+ store instead writes to a temp path inside the destination fanout directory,
76
+ fsyncs it, and renames it into place. The rename never crosses a filesystem
77
+ because the temp path is a sibling of the blob.
78
+
79
+ Temp names carry a random per-store token drawn from Effect's `Random`, so two
80
+ processes publishing the same digest into one workspace never share a scratch
81
+ path and never clobber each other into torn bytes at the content address. The
82
+ token enters no persisted identity, so its randomness is invisible to replay.
83
+
84
+ `FileSystemOptions.durability` controls the fsync. `required`, the default,
85
+ reports success only after syncing the blob, its fanout directory, the objects
86
+ directory, and each ancestor through the relative or filesystem root. This
87
+ persists new fanout and lock directory entries and nested configured directories.
88
+ Every put repeats these barriers, including a deduplicated retry after an
89
+ interrupted publication. A sync refusal fails with `ArtifactStoreError` code
90
+ `unavailable`. `best-effort` is the explicit weaker capability for a
91
+ host that cannot sync file or directory handles. Both modes require exclusive
92
+ writable handles and symlink inspection; unsupported hosts fail as `unavailable`.
93
+
94
+ ### An existing blob is verified on every put
95
+
96
+ The store does not treat "the path exists" as "the bytes are correct", and it
97
+ does not remember a verification it already did. The objects directory is
98
+ shared by the whole workspace, so a blob can change behind one store's back. A
99
+ remembered proof would let a later put report success over corrupt bytes
100
+ without repairing them, and `get` would then refuse that digest forever even
101
+ though every put held the cure.
102
+
103
+ So every put reads the existing blob, measures it, and only skips the write on
104
+ a verified match. A mismatch, or a read the host refuses, falls through to the
105
+ atomic rewrite and heals the address. Re-verifying costs a constant factor, not
106
+ a new asymptote: a put already pays one hash over its own input.
107
+
108
+ ### A deduplicated put freshens the blob
109
+
110
+ When a put deduplicates, the store stamps the blob's modification time. This is
111
+ git's loose-object freshening, and the touch Bazel's disk cache performs on a
112
+ hit. It matters because mtime is the age evidence a retention sweep fences its
113
+ deletions on. Re-publishing old bytes has to read as a recent reference, or the
114
+ grace period cannot protect the cache entry recorded moments later.
115
+
116
+ The freshen is best effort. On a host with no `utimes`, a failure over a blob
117
+ that still exists keeps the dedupe skip and accepts git's own
118
+ freshen-versus-prune race. A failure over a blob that vanished, because a sweep
119
+ won, falls through to the atomic rewrite instead.
120
+
121
+ ## What an address does not tell you
122
+
123
+ An address says what the bytes are. It says nothing about whether anything
124
+ still wants them. This package therefore never reclaims a published artifact as
125
+ a side effect of a store call: `ArtifactSweep` is the deletion surface, and the
126
+ mark phase that decides which digests are live belongs to
127
+ [`@smthrs/engine-store`](/api/engine-store)'s `ArtifactGc`, the only place the
128
+ durable roots are visible. See
129
+ [Reclaim disk space](../guides/reclaim-disk-space.md).
130
+
131
+ The one thing a store does clean up on its own is its own scratch: `.tmp-*`
132
+ payloads and `.locks/` files older than an hour, swept once per store instance
133
+ on its first publication. Those are crash orphans that nothing else observes.
134
+
135
+ ## The layout on disk
136
+
137
+ The filesystem store publishes at `<directory>/<first two hex>/<digest>`, with
138
+ the directory defaulting to `ArtifactStore.defaultDirectory`, `.flows/objects`.
139
+
140
+ The two-hex fanout is Bazel's `DiskCacheClient` layout, taken "to bypass
141
+ possible folder file count limits". The directory is workspace-relative rather
142
+ than absolute so a workspace can be moved or copied whole and still resolve its
143
+ own artifacts, and so a sandbox that mounts the workspace inherits them.
144
+
145
+ Addresses published under the pre-1.0 flat `<directory>/<digest>` layout are
146
+ cache misses that re-publish. There is no compatibility shim.
147
+
148
+ ## Prior art
149
+
150
+ The contract's ergonomics follow Effect's own `KeyValueStore`
151
+ (`effect/persistence/KeyValueStore`): one small set of total
152
+ operations over one address space, so a memory, filesystem, or network
153
+ implementation is the same shape. `findMissing` is Bazel's
154
+ `MissingDigestsFinder`, one batched round trip whose result is guaranteed to be
155
+ a subset of its input, because a per-digest existence probe over a network tier
156
+ is the wrong shape entirely.
157
+
158
+ The mechanics come from Bazel's remote-cache classes:
159
+
160
+ | Taken from | What |
161
+ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
162
+ | [`disk/DiskCacheClient.java`](https://github.com/bazelbuild/bazel/blob/master/src/main/java/com/google/devtools/build/lib/remote/disk/DiskCacheClient.java) | The two-hex fanout layout and the fsync of the temp file before the rename |
163
+ | [`http/HttpCacheClient.java`](https://github.com/bazelbuild/bazel/blob/master/src/main/java/com/google/devtools/build/lib/remote/http/HttpCacheClient.java) | The wire protocol: CAS blobs under `/cas/base16-key`, `PUT` to upload, `GET` to download |
164
+ | [`common/MissingDigestsFinder.java`](https://github.com/bazelbuild/bazel/blob/master/src/main/java/com/google/devtools/build/lib/remote/common/MissingDigestsFinder.java) | `findMissing` as one batched probe whose result is a subset of its input |
165
+ | [`CombinedCache.java`](https://github.com/bazelbuild/bazel/blob/master/src/main/java/com/google/devtools/build/lib/remote/CombinedCache.java) | Local first, remote second, write back what the remote returned |
166
+
167
+ Where this package deviates is documented in [The three tiers](./tiers.md).
168
+
169
+ ## Related
170
+
171
+ - [The three tiers](./tiers.md): how local, shared, and combined stores compose.
172
+ - [Coordination between processes](./coordination.md): the locks and fences
173
+ that keep two processes out of each other's way in one objects directory.
174
+ - [API reference](../api.md): the exact signatures and error shapes.
@@ -0,0 +1,141 @@
1
+ ---
2
+ title: "Coordination between processes"
3
+ description: "How one objects directory stays safe when several processes write and sweep it: per-digest lock files, heartbeats, the stale-reclaim window, the backup lease, and their limits."
4
+ sidebar:
5
+ order: 3
6
+ ---
7
+
8
+ An objects directory belongs to a workspace, not to a process. A CLI run, a
9
+ gateway, a sandboxed step, and a garbage collector can all be holding the same
10
+ `.flows/objects` open at once. Publication is already atomic, so two writers
11
+ racing on one digest is harmless. The dangerous overlap is a writer and a
12
+ sweeper: one is deduplicating against a blob the other is deleting.
13
+
14
+ `FileSystemOptions.coordination` and `SweepOptions.coordination` are the dial
15
+ for that overlap.
16
+
17
+ | Mode | What it does |
18
+ | -------------------- | -------------------------------------------------------------------------------------------------------------- |
19
+ | `required` (default) | An in-process semaphore plus heartbeat-backed lock files under `.locks/`, shared by every cooperating process. |
20
+ | `process` | The in-process semaphore only. The explicit weaker browser and test mode. |
21
+
22
+ ## The lock protocol
23
+
24
+ Under `required`, a put or a sweep deletion takes the lock file for its digest
25
+ before touching the blob:
26
+
27
+ | Bound | Value |
28
+ | ------------------------------ | ------------------------------ |
29
+ | Heartbeat interval | 10 seconds |
30
+ | Stale after | 60 seconds without a heartbeat |
31
+ | Acquisition deadline | 2 minutes |
32
+ | Retry interval while contended | 25 milliseconds |
33
+
34
+ A holder that crashes stops heartbeating, and the next contender reclaims its
35
+ lock a minute later. A contender that cannot acquire within two minutes fails
36
+ rather than waiting forever. This deadline includes the in-process semaphore
37
+ wait, directory creation, and lockfile acquisition. It ends before the protected
38
+ operation starts. Uninterruptible host completion and release finalizers can
39
+ extend cancellation latency. Semaphores are scoped by filesystem service,
40
+ objects directory, and digest; unrelated objects roots do not share permits.
41
+
42
+ ## The fence is bounded, not absolute
43
+
44
+ Reclaiming a stale lock is serialized per lock generation. A contender reads
45
+ the owner token, then measures the age. If the lock is stale, the contender
46
+ races for a `wx` claim file named after that token. Only the claim winner
47
+ renames the lock away, and only after re-reading that the path still holds the
48
+ same stale token. Two contenders that measured the same lock as stale
49
+ therefore move it at most once, and neither moves the fresh lock the other
50
+ just took.
51
+
52
+ The fence still assumes a holder that stops heartbeating is dead. A holder
53
+ whose host stalls past 60 seconds is reaped while it is still running, and its
54
+ heartbeat only logs the loss. Neither the mtime check nor the backup gate
55
+ independently protects against that loss of mutual exclusion. The mtime check
56
+ is followed by a separate delete, and the backup gate uses the same lock
57
+ protocol.
58
+
59
+ ## Both sides must agree
60
+
61
+ A lock fences only the parties that take it. Build a store and its sweep with
62
+ the same `directory` and the same `coordination`:
63
+
64
+ ```ts
65
+ import * as ArtifactStore from "@smthrs/artifacts/ArtifactStore"
66
+ import * as ArtifactSweep from "@smthrs/artifacts/ArtifactSweep"
67
+ import * as Layer from "effect/Layer"
68
+
69
+ const directory = ".flows/objects"
70
+
71
+ export const artifacts = Layer.merge(
72
+ ArtifactStore.layerFileSystem({ directory, coordination: "required" }),
73
+ ArtifactSweep.layerFileSystem({ directory, coordination: "required" })
74
+ )
75
+ ```
76
+
77
+ Nothing checks the pairing, and a mismatch is silent. A sweep on `required`
78
+ beside a store on `process` takes lock files no writer ever observes: the fence
79
+ reads as armed and protects nothing.
80
+
81
+ A store on `process` also gives up cross-process exclusion between writers and
82
+ sweep deletion, and a sweep on `process` skips the backup lease entirely.
83
+
84
+ ## The backup lease
85
+
86
+ A filesystem backup freezes a database and then copies the blobs that database
87
+ references. Publication during that window is fine, because a new blob cannot
88
+ already be referenced by a frozen snapshot. Deletion is not: a sweep that
89
+ removes a blob the frozen database names leaves a backup that cannot be
90
+ restored.
91
+
92
+ `ArtifactBackupLease` is the exclusion for exactly that. The backup holds one
93
+ heartbeat-backed marker, `.backup-lease`, for the whole copy, with the same 10
94
+ second heartbeat and 60 second staleness bound as the digest locks. Sweep
95
+ deletion checks that marker while holding a short-lived workspace-global gate,
96
+ `.backup-lease-gate`, so the check and the delete cannot be separated. A
97
+ crashed lease becomes reclaimable once its heartbeat goes stale. Marker cleanup
98
+ is registered before acquisition; interruption during marker creation or gate
99
+ release still removes the marker owned by that acquisition. Failed gate release
100
+ also runs marker cleanup, which may wait for the gate to become reclaimable.
101
+
102
+ `ArtifactBackupLease.unlessActive` answers `None` when a live backup fenced the
103
+ operation, and `ArtifactSweep.remove` turns that into `false`. See
104
+ [Fence a backup against the sweep](../guides/fence-a-backup.md).
105
+
106
+ ## What coordination costs
107
+
108
+ Under `required`, one sweep deletion takes two locks, the per-digest lock and
109
+ then the workspace-global backup-lease gate. That is roughly ten filesystem
110
+ operations and two forked heartbeat fibers per blob. The gate is one file for
111
+ the whole workspace, so concurrent deletions serialize through it.
112
+
113
+ That is the price of never deleting a blob a running backup already recorded.
114
+ Size a collection window with it in mind: a sweep of ten thousand blobs is
115
+ tens of thousands of filesystem operations, not ten thousand unlinks.
116
+
117
+ ## Crash orphans
118
+
119
+ Lock files and temp payloads outlive a hard-killed process. Nothing else
120
+ observes either, because reads resolve only canonical paths and a lock is
121
+ reclaimed on contention alone, so a digest nobody publishes again would keep
122
+ its lock file forever.
123
+
124
+ Each filesystem store therefore runs one conservative sweep of its own scratch
125
+ on its first publication: `.tmp-*` payloads and `.locks/` entries whose
126
+ modification time is more than an hour old. An hour is far beyond any live
127
+ writer's publication window and sixty times the lock staleness bound, so a file
128
+ that old belongs to no living holder. A file whose age the host cannot report
129
+ says nothing about its owner and survives.
130
+
131
+ This is a sweep of scratch, not garbage collection. Reclaiming published
132
+ artifacts is always an explicit `ArtifactSweep` call.
133
+
134
+ ## Related
135
+
136
+ - [Reclaim disk space](../guides/reclaim-disk-space.md): the sweep, its
137
+ inventory, and the mtime fence.
138
+ - [Fence a backup against the sweep](../guides/fence-a-backup.md): holding the
139
+ lease from a backup tool.
140
+ - [Troubleshooting](../troubleshooting.md): what a lock timeout looks like and
141
+ what to change.
@@ -0,0 +1,147 @@
1
+ ---
2
+ title: "The three tiers"
3
+ description: "One store contract with a filesystem, memory, and HTTP implementation, how CombinedArtifacts composes a local and a shared tier, and what the download policy changes about a read."
4
+ sidebar:
5
+ order: 2
6
+ ---
7
+
8
+ `ArtifactStore.Service` is four operations over one address space:
9
+
10
+ | Operation | Meaning |
11
+ | ---------------------- | ---------------------------------------------------------------------------------------- |
12
+ | `put(bytes)` | Store the bytes under their own digest and return that address. Idempotent. |
13
+ | `get(digest)` | Return the bytes at the address, verified against it. |
14
+ | `has(digest)` | Whether this tier holds an artifact at the address. |
15
+ | `findMissing(digests)` | Which of these addresses this tier does not hold, as a deduplicated subset of the input. |
16
+
17
+ Every implementation in this package is that same shape, which is the whole
18
+ reason a composition can swap one for another without touching a caller.
19
+
20
+ ## The implementations
21
+
22
+ | Constructor | Backing | Notes |
23
+ | ------------------------------------------------- | --------------------------------------------------- | --------------------------------------------------------------------------------------------- |
24
+ | `ArtifactStore.makeFileSystem(fs, path, options)` | A directory reached through Effect's `FileSystem` | The durable host tier. Atomic publication, digest-verified reads, cross-process coordination. |
25
+ | `ArtifactStore.makeMemory()` | A private `Map` | Tests and browser hosts with no durable filesystem. Copies on both boundaries. |
26
+ | `ArtifactStore.makeNoop(overrides)` | Nothing | Every operation fails as `unavailable`, with per-method overrides. The honest absence. |
27
+ | `RemoteArtifacts.make(options)` | An HTTP cache reached through Effect's `HttpClient` | The shared tier. Bounded, digest-verified downloads. |
28
+ | `CombinedArtifacts.make(options)` | Two of the above | Local first, shared second, with write-back. |
29
+
30
+ `RemoteArtifacts.Service` extends the store contract with one field,
31
+ `downloadPolicy`, so a composition can read the shared tier's own preference
32
+ instead of being told it twice. `RemoteArtifacts.downloadPolicyOf` reads that
33
+ field off any store and answers `undefined` for one that declares none, which
34
+ every local store does.
35
+
36
+ ## What a combined read does
37
+
38
+ `CombinedArtifacts.get` asks the local tier first and treats its three possible
39
+ answers differently:
40
+
41
+ | Local answer | What happens |
42
+ | -------------------- | -------------------------------------------------------------------------------------------- |
43
+ | Bytes | Returned. The shared tier is not contacted. |
44
+ | `ArtifactMissing` | Falls through to the shared tier. |
45
+ | `ArtifactCorruption` | Falls through to the shared tier, and the fetched bytes are written back under every policy. |
46
+ | `ArtifactStoreError` | The read fails. |
47
+
48
+ That last row is the one worth pausing on. A host that refused the read has not
49
+ answered the question, and quietly paying for a network round trip instead
50
+ would hide a broken local tier behind a working shared one. A miss is an
51
+ answer; a refusal is not.
52
+
53
+ The write-back is opportunistic in the same way the upload is. The bytes are
54
+ already in hand, so a full disk, a read-only mount, or a refused sync costs the
55
+ next read a round trip rather than failing this one. `writeBackTimeout` bounds
56
+ the write-back, defaulting to 60 seconds. At expiry the write-back is interrupted;
57
+ the read returns the verified remote bytes after interruption cleanup finishes.
58
+
59
+ ## What a combined write does
60
+
61
+ `put` publishes to the local tier first and returns the local digest. The local
62
+ tier is the one this machine's replays resolve against, so an unreachable
63
+ shared tier must not stop an artifact from being recorded.
64
+
65
+ The upload that follows is opportunistic, and its refusal is dropped rather
66
+ than propagated. Failing here would fail whatever produced the bytes because a
67
+ cache was unreachable, which is the opposite of the line above. Two properties
68
+ keep it contained:
69
+
70
+ - **Deduplicated in flight by digest.** Two callers in one process that publish
71
+ the same artifact join one upload instead of both pushing the same bytes.
72
+ The entry is removed before its deferred is completed, so a later put starts
73
+ a fresh upload rather than replaying a stale outcome, and an interrupted
74
+ upload resolves waiters with a typed failure instead of tearing them down.
75
+ - **Bounded by `uploadTimeout`,** 60 seconds by default. A shared tier that
76
+ stalls instead of refusing must not hold the local answer hostage.
77
+
78
+ Nothing depends on that upload. What actually guarantees a shared cache entry's
79
+ blobs are durable is the publication protocol that runs before the entry is
80
+ published: `findMissing`, then upload, then confirm. A dropped upload here
81
+ costs that protocol one re-upload, never correctness.
82
+
83
+ `has` asks local, then shared. `findMissing` asks the shared tier only about
84
+ what the local tier could not answer, which is one network round trip over the
85
+ smallest possible set, and the result stays a subset of the input because each
86
+ stage filters the previous stage's output.
87
+
88
+ ## The download policy
89
+
90
+ The dial is Bazel's `--remote_download_{all,toplevel,minimal}`, and it lives on
91
+ the shared tier because the shared tier is the thing being conserved. Set it
92
+ with `RemoteArtifacts.Options.downloadPolicy`, or override it for one
93
+ composition with `CombinedArtifacts.Options.downloadPolicy`. With neither, the
94
+ policy is `all`.
95
+
96
+ | Policy | Prefetch on replay admission | Write-back on a read |
97
+ | ---------- | ---------------------------- | ------------------------------------------ |
98
+ | `all` | Every referenced blob | Yes |
99
+ | `toplevel` | Nothing | Yes, on the first read that needs the blob |
100
+ | `minimal` | Nothing | No |
101
+
102
+ Two different modules honor different halves. `CombinedArtifacts.get` honors
103
+ the write-back column. [`@smthrs/engine-store`](/api/engine-store)'s
104
+ `ArtifactSync.hydrate` honors the prefetch column.
105
+
106
+ `minimal` still writes back after local corruption. An address the local tier
107
+ already claims, one that `has` and `findMissing` both report as present, must
108
+ be one it can serve, or the publication protocol has been told a lie no later
109
+ read can correct. Replacing such an address is repair, not growth.
110
+
111
+ Choosing between them is a job, not a concept:
112
+ [Share artifacts across machines](../guides/share-artifacts-across-machines.md)
113
+ walks the composition.
114
+
115
+ ## What the metrics count
116
+
117
+ `ArtifactStoreMetrics` defines two counters, `flows_artifact_puts` and
118
+ `flows_artifact_gets`. Only the filesystem and memory implementations update
119
+ them; `RemoteArtifacts` is deliberately uninstrumented, and the counters carry
120
+ no tier attribute. Read them as local artifact store traffic:
121
+
122
+ - A combined read the local tier serves counts one get.
123
+ - A combined read the shared tier serves counts no get, because no local store
124
+ answered it.
125
+ - The write-back that materializes such a read counts a put, indistinguishable
126
+ from a producer publishing new bytes.
127
+ - A deduplicated put still counts: the caller stored bytes and received an
128
+ address either way.
129
+ - Missing and corrupt reads count nothing. They are error evidence, not
130
+ throughput.
131
+
132
+ A third counter, `flows_artifact_remote_failures`, counts the shared uploads
133
+ (`operation: put`) and local write-backs (`operation: write_back`) a combined
134
+ store dropped. Neither fails the caller, so this counter and its warning are
135
+ the only evidence that the shared tier stopped filling.
136
+
137
+ No exporter ships in this package; provide one, for example
138
+ [`@smthrs/observability`](/api/observability), and the counters appear in it.
139
+
140
+ ## Related
141
+
142
+ - [Content addressing](./content-addressing.md): why a read can verify rather
143
+ than trust.
144
+ - [Coordination between processes](./coordination.md): what happens when two
145
+ processes share one objects directory.
146
+ - [Serve the artifact protocol](../guides/serve-the-artifact-protocol.md): the
147
+ HTTP surface a shared tier owes.
@@ -0,0 +1,123 @@
1
+ ---
2
+ title: "Fence a backup against the sweep"
3
+ description: "Hold ArtifactBackupLease.withLease around a filesystem backup so a concurrent sweep cannot delete a blob the frozen database references, and use unlessActive when you are the one deleting."
4
+ sidebar:
5
+ order: 4
6
+ ---
7
+
8
+ A filesystem backup freezes a database and then copies the blobs that database
9
+ references. Between the freeze and the last copy, a garbage collector running
10
+ in another process can delete a blob the frozen snapshot names, leaving a
11
+ backup that cannot be restored.
12
+
13
+ `ArtifactBackupLease` is the cross-process exclusion for exactly that window.
14
+
15
+ ## Before you start
16
+
17
+ - The objects directory the store publishes into. The lease marker lives inside
18
+ it, so a backup and a sweep pointed at different directories fence nothing.
19
+ - Both sides on `coordination: "required"`. A sweep built with `process` skips
20
+ the lease entirely.
21
+
22
+ ## Hold the lease around the copy
23
+
24
+ `withLease` acquires the marker, heartbeats it while your effect runs, and
25
+ releases it afterwards. Pass a `failure` function that maps an unknown host
26
+ cause into your own error type:
27
+
28
+ ```ts
29
+ import * as ArtifactBackupLease from "@smthrs/artifacts/ArtifactBackupLease"
30
+ import * as ArtifactStore from "@smthrs/artifacts/ArtifactStore"
31
+ import * as Effect from "effect/Effect"
32
+ import * as FileSystem from "effect/FileSystem"
33
+
34
+ const leaseFailure = (cause: unknown): ArtifactStore.ArtifactStoreError =>
35
+ new ArtifactStore.ArtifactStoreError({
36
+ code: "unavailable",
37
+ message: "the artifact backup lease could not be held",
38
+ cause
39
+ })
40
+
41
+ /** Runs `copy` while no sweep in any process can delete a referenced blob. */
42
+ export const underLease = <A, E, R>(copy: Effect.Effect<A, E, R>) =>
43
+ Effect.gen(function*() {
44
+ const fs = yield* FileSystem.FileSystem
45
+ return yield* ArtifactBackupLease.withLease(fs, ArtifactStore.defaultDirectory, copy, leaseFailure)
46
+ })
47
+ ```
48
+
49
+ Wrap the whole backup, from the database freeze through the last blob copy. A
50
+ lease held only around the copy leaves the freeze unprotected, which is where
51
+ the reference list came from.
52
+
53
+ ## What the lease stops, and what it does not
54
+
55
+ It stops deletion. It does not stop publication: new blobs may appear
56
+ throughout a backup, and that is harmless, because a blob published after the
57
+ freeze cannot be referenced by the frozen database.
58
+
59
+ The bounds are the same ones the per-digest locks use:
60
+
61
+ | Bound | Value |
62
+ | -------------------- | ------------------------------ |
63
+ | Heartbeat interval | 10 seconds |
64
+ | Stale after | 60 seconds without a heartbeat |
65
+ | Acquisition deadline | 2 minutes |
66
+
67
+ A crashed backup stops heartbeating, and its marker becomes reclaimable a
68
+ minute later, so a hard-killed process cannot fence collection forever. The
69
+ flip side is that a backup whose host stalls past 60 seconds can have its lease
70
+ reaped while it is still copying.
71
+
72
+ Releasing is tolerant of that. A lease whose heartbeat lapsed has its marker
73
+ reaped by whoever noticed, so finding nothing to release is the ordinary end of
74
+ a slow lease rather than a fault.
75
+
76
+ Acquisition is bounded rather than infinite: if another backup holds the lease
77
+ for more than two minutes, `withLease` fails through your `failure` function
78
+ instead of waiting.
79
+
80
+ ## When you are the one deleting
81
+
82
+ `ArtifactSweep.remove` already consults the lease under
83
+ `coordination: "required"`, so a collector built on that surface needs nothing
84
+ here. Use `ArtifactBackupLease.unlessActive` only when you are writing a
85
+ different deletion path over the same objects directory:
86
+
87
+ `unlessActive` runs your effect only when no live lease exists, and it holds
88
+ the workspace-global gate across both the check and the effect, so the two
89
+ cannot be separated by a backup starting in between. It answers `Option.none()`
90
+ when a backup deliberately fenced the operation, which is what
91
+ `ArtifactSweep.remove` turns into `false`:
92
+
93
+ ```ts
94
+ import * as ArtifactBackupLease from "@smthrs/artifacts/ArtifactBackupLease"
95
+ import * as Effect from "effect/Effect"
96
+ import * as FileSystem from "effect/FileSystem"
97
+ import * as Option from "effect/Option"
98
+
99
+ /** Deletes one path unless a live backup fenced the deletion. */
100
+ export const removeUnlessBackingUp = (directory: string, path: string) =>
101
+ Effect.gen(function*() {
102
+ const fs = yield* FileSystem.FileSystem
103
+ const outcome = yield* ArtifactBackupLease.unlessActive(
104
+ fs,
105
+ directory,
106
+ fs.remove(path).pipe(Effect.as(true), Effect.mapError(leaseFailure)),
107
+ leaseFailure
108
+ )
109
+ return Option.getOrElse(outcome, () => false)
110
+ })
111
+ ```
112
+
113
+ Treat `none` as "nothing was reclaimed, retry after the backup finishes", never
114
+ as "the blob is gone".
115
+
116
+ ## Related
117
+
118
+ - [Reclaim disk space](./reclaim-disk-space.md): the collector side, and why
119
+ its `false` is ambiguous on purpose.
120
+ - [Coordination between processes](../concepts/coordination.md): the gate, the
121
+ markers, and the stale-reclaim window they share.
122
+ - [Retention and cleanup](/docs/guides/retention/): backing up a project before
123
+ collecting it, on smithers.sh.