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

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 +173 -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 +175 -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 +163 -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 +113 -0
  29. package/dist/cjs/FileLease.d.ts.map +1 -0
  30. package/dist/cjs/FileLease.js +201 -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 +117 -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 +202 -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 +113 -0
  98. package/dist/esm/FileLease.d.ts.map +1 -0
  99. package/dist/esm/FileLease.js +239 -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 +554 -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 +200 -0
  142. package/docs/api.md +474 -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 +98 -0
  152. package/docs/quickstart.md +182 -0
  153. package/docs/troubleshooting.md +232 -0
  154. package/package.json +179 -3
  155. package/src/ArtifactBackupLease.ts +213 -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 +336 -0
  160. package/src/CombinedArtifacts.ts +369 -0
  161. package/src/FileLease.ts +380 -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 +809 -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,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.
@@ -0,0 +1,143 @@
1
+ ---
2
+ title: "Reclaim disk space"
3
+ description: "Enumerate an objects directory with ArtifactSweep, delete a blob behind the mtime fence, count only what you actually reclaimed, and hand the policy to the engine collector that owns it."
4
+ sidebar:
5
+ order: 3
6
+ ---
7
+
8
+ An objects directory grows forever unless something removes from it. This
9
+ package ships the mechanics: enumerate what is there, and delete one blob
10
+ safely. It ships no policy, because deciding which digests are still live needs
11
+ the durable roots, and only the engine composition can see those.
12
+
13
+ Most projects never call this API directly.
14
+ [`@smthrs/engine-store`](/api/engine-store)'s `ArtifactGc` is the collector
15
+ built on it: its mark phase walks the durable roots, and `gc()` is an explicit
16
+ call that nothing schedules, because deletion is the irreversible direction.
17
+ Note that [`smthrs gc`](/cli/gc) collects terminal runs and does not touch the
18
+ artifact tier at all.
19
+ [Retention and cleanup](/docs/guides/retention/#4-sweep-unreferenced-artifacts)
20
+ is the operator's version of this page. Read on if you are writing a collector
21
+ of your own.
22
+
23
+ ## Before you start
24
+
25
+ - The objects directory the store publishes into. Get it wrong and you
26
+ enumerate somewhere nothing was ever written.
27
+ - A live set: the digests something still references. Producing one is your
28
+ collector's job, not this package's.
29
+
30
+ ## 1. Provide the sweep beside the store
31
+
32
+ Build both with the same `directory` and the same `coordination`. Nothing
33
+ checks the pairing, and a mismatch is silent:
34
+
35
+ ```ts
36
+ import * as ArtifactStore from "@smthrs/artifacts/ArtifactStore"
37
+ import * as ArtifactSweep from "@smthrs/artifacts/ArtifactSweep"
38
+ import * as Layer from "effect/Layer"
39
+
40
+ const directory = ".flows/objects"
41
+
42
+ export const artifacts = Layer.merge(
43
+ ArtifactStore.layerFileSystem({ directory, coordination: "required" }),
44
+ ArtifactSweep.layerFileSystem({ directory, coordination: "required" })
45
+ )
46
+ ```
47
+
48
+ `SweepOptions` has no `durability` field, deliberately: a sweep never writes a
49
+ blob, so accepting the option and ignoring it would be a lie.
50
+
51
+ ## 2. Enumerate what is on disk
52
+
53
+ `inventory` returns a `BlobStat` for each canonical blob: its digest, its
54
+ modification time in milliseconds, and its size in bytes.
55
+
56
+ It is conservative by construction. Only paths in the store's canonical fanout
57
+ shape are blobs, so temp files, lock files, foreign files, nested paths, and a
58
+ directory sitting at a blob address are all skipped rather than deleted. A blob
59
+ that vanishes between the listing and the stat was removed by someone else and
60
+ is skipped too. So is one whose modification time the host cannot report: what
61
+ the sweep cannot judge, it must not touch.
62
+
63
+ A store that never published anything has no directory at all. That is an empty
64
+ inventory, not a failure.
65
+
66
+ ## 3. Delete behind the fence
67
+
68
+ `RemoveOptions.ifUnmodifiedSinceMs` rides inside the deletion rather than in a
69
+ prior read. A blob freshened after you computed your live set, by a concurrent
70
+ put re-referencing the same bytes, fails the fence and survives:
71
+
72
+ ```ts
73
+ import * as ArtifactSweep from "@smthrs/artifacts/ArtifactSweep"
74
+ import * as Effect from "effect/Effect"
75
+
76
+ /** Removes every blob outside `live` that has not been touched since `keepAfterMs`. */
77
+ export const collect = (live: ReadonlySet<string>, keepAfterMs: number) =>
78
+ Effect.gen(function*() {
79
+ const sweep = yield* ArtifactSweep.ArtifactSweep
80
+ let reclaimedBytes = 0
81
+ for (const blob of yield* sweep.inventory) {
82
+ if (live.has(blob.digest) || blob.modifiedAtMs > keepAfterMs) continue
83
+ const removed = yield* sweep.remove(blob.digest, { ifUnmodifiedSinceMs: keepAfterMs })
84
+ if (removed) reclaimedBytes += blob.sizeBytes
85
+ }
86
+ return reclaimedBytes
87
+ })
88
+ ```
89
+
90
+ The inventory check is the cheap filter; the fence in `remove` is the one that
91
+ actually holds, because the world can change between the two.
92
+
93
+ `keepAfterMs` is where a grace period lives. Pick an instant far enough in the
94
+ past that a blob published or freshened while your mark phase was running
95
+ survives this pass: `ArtifactGc` defaults to two weeks.
96
+
97
+ ## 4. Read the boolean correctly
98
+
99
+ `remove` reports whether bytes were removed. A missing blob reports `false`
100
+ rather than failing, so a crashed and re-run sweep converges instead of erroring
101
+ on its own progress.
102
+
103
+ `false` covers three outcomes a caller cannot tell apart:
104
+
105
+ - The blob was already gone.
106
+ - It failed the `ifUnmodifiedSinceMs` fence.
107
+ - A live backup lease fenced the deletion.
108
+
109
+ All three mean nothing was reclaimed and retrying later is safe. The third is
110
+ not progress: the blob is still there, so a collector counting reclaimed bytes
111
+ must not count it, and the next pass after the backup finishes removes it. The
112
+ loop above already gets this right by adding `blob.sizeBytes` only on `true`.
113
+
114
+ ## 5. Budget for the coordination cost
115
+
116
+ Under `coordination: "required"`, one deletion takes the per-digest lock and
117
+ then the workspace-global backup-lease gate: roughly ten filesystem operations
118
+ and two forked heartbeat fibers per blob. The gate is one file for the whole
119
+ workspace, so concurrent deletions serialize through it.
120
+
121
+ That is the price of never deleting a blob a running backup already recorded.
122
+ Size a collection window with it in mind rather than assuming one unlink per
123
+ blob.
124
+
125
+ ## Verify it worked
126
+
127
+ Count the blobs before and after:
128
+
129
+ ```bash
130
+ find .flows/objects -type f -name '????????????????????????????????????????????????????????????????' | wc -l
131
+ ```
132
+
133
+ The name pattern matches a 64-character digest, so temp and lock files stay out
134
+ of the count.
135
+
136
+ ## Related
137
+
138
+ - [Fence a backup against the sweep](./fence-a-backup.md): the other side of
139
+ the lease this sweep consults.
140
+ - [Coordination between processes](../concepts/coordination.md): the lock
141
+ protocol, its bounds, and what still holds when the lock does not.
142
+ - [Retention and cleanup](/docs/guides/retention/): the operator's runbook on
143
+ smithers.sh.