@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.
- package/CHANGELOG.md +77 -0
- package/LICENSE +21 -0
- package/README.md +175 -2
- package/dist/cjs/ArtifactBackupLease.d.ts +34 -0
- package/dist/cjs/ArtifactBackupLease.d.ts.map +1 -0
- package/dist/cjs/ArtifactBackupLease.js +183 -0
- package/dist/cjs/ArtifactBackupLease.js.map +7 -0
- package/dist/cjs/ArtifactStore.d.ts +129 -0
- package/dist/cjs/ArtifactStore.d.ts.map +1 -0
- package/dist/cjs/ArtifactStore.js +73 -0
- package/dist/cjs/ArtifactStore.js.map +7 -0
- package/dist/cjs/ArtifactStoreError.d.ts +82 -0
- package/dist/cjs/ArtifactStoreError.d.ts.map +1 -0
- package/dist/cjs/ArtifactStoreError.js +72 -0
- package/dist/cjs/ArtifactStoreError.js.map +7 -0
- package/dist/cjs/ArtifactStoreMetrics.d.ts +70 -0
- package/dist/cjs/ArtifactStoreMetrics.d.ts.map +1 -0
- package/dist/cjs/ArtifactStoreMetrics.js +51 -0
- package/dist/cjs/ArtifactStoreMetrics.js.map +7 -0
- package/dist/cjs/ArtifactSweep.d.ts +185 -0
- package/dist/cjs/ArtifactSweep.d.ts.map +1 -0
- package/dist/cjs/ArtifactSweep.js +171 -0
- package/dist/cjs/ArtifactSweep.js.map +7 -0
- package/dist/cjs/CombinedArtifacts.d.ts +103 -0
- package/dist/cjs/CombinedArtifacts.d.ts.map +1 -0
- package/dist/cjs/CombinedArtifacts.js +189 -0
- package/dist/cjs/CombinedArtifacts.js.map +7 -0
- package/dist/cjs/FileLease.d.ts +117 -0
- package/dist/cjs/FileLease.d.ts.map +1 -0
- package/dist/cjs/FileLease.js +208 -0
- package/dist/cjs/FileLease.js.map +7 -0
- package/dist/cjs/FileSystemArtifactStore.d.ts +93 -0
- package/dist/cjs/FileSystemArtifactStore.d.ts.map +1 -0
- package/dist/cjs/FileSystemArtifactStore.js +276 -0
- package/dist/cjs/FileSystemArtifactStore.js.map +7 -0
- package/dist/cjs/MemoryArtifactStore.d.ts +25 -0
- package/dist/cjs/MemoryArtifactStore.d.ts.map +1 -0
- package/dist/cjs/MemoryArtifactStore.js +84 -0
- package/dist/cjs/MemoryArtifactStore.js.map +7 -0
- package/dist/cjs/NoopArtifactStore.d.ts +17 -0
- package/dist/cjs/NoopArtifactStore.d.ts.map +1 -0
- package/dist/cjs/NoopArtifactStore.js +46 -0
- package/dist/cjs/NoopArtifactStore.js.map +7 -0
- package/dist/cjs/RemoteArtifacts.d.ts +237 -0
- package/dist/cjs/RemoteArtifacts.d.ts.map +1 -0
- package/dist/cjs/RemoteArtifacts.js +405 -0
- package/dist/cjs/RemoteArtifacts.js.map +7 -0
- package/dist/cjs/index.d.ts +65 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +47 -0
- package/dist/cjs/index.js.map +7 -0
- package/dist/cjs/internal/ArtifactLocks.d.ts +30 -0
- package/dist/cjs/internal/ArtifactLocks.d.ts.map +1 -0
- package/dist/cjs/internal/ArtifactLocks.js +93 -0
- package/dist/cjs/internal/ArtifactLocks.js.map +7 -0
- package/dist/cjs/internal/ArtifactPath.d.ts +19 -0
- package/dist/cjs/internal/ArtifactPath.d.ts.map +1 -0
- package/dist/cjs/internal/ArtifactPath.js +74 -0
- package/dist/cjs/internal/ArtifactPath.js.map +7 -0
- package/dist/cjs/measureBytes.d.ts +18 -0
- package/dist/cjs/measureBytes.d.ts.map +1 -0
- package/dist/cjs/measureBytes.js +43 -0
- package/dist/cjs/measureBytes.js.map +7 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/snapshotBytes.d.ts +19 -0
- package/dist/cjs/snapshotBytes.d.ts.map +1 -0
- package/dist/cjs/snapshotBytes.js +41 -0
- package/dist/cjs/snapshotBytes.js.map +7 -0
- package/dist/cjs/validateDigest.d.ts +22 -0
- package/dist/cjs/validateDigest.d.ts.map +1 -0
- package/dist/cjs/validateDigest.js +42 -0
- package/dist/cjs/validateDigest.js.map +7 -0
- package/dist/esm/ArtifactBackupLease.d.ts +34 -0
- package/dist/esm/ArtifactBackupLease.d.ts.map +1 -0
- package/dist/esm/ArtifactBackupLease.js +126 -0
- package/dist/esm/ArtifactBackupLease.js.map +1 -0
- package/dist/esm/ArtifactStore.d.ts +129 -0
- package/dist/esm/ArtifactStore.d.ts.map +1 -0
- package/dist/esm/ArtifactStore.js +83 -0
- package/dist/esm/ArtifactStore.js.map +1 -0
- package/dist/esm/ArtifactStoreError.d.ts +82 -0
- package/dist/esm/ArtifactStoreError.d.ts.map +1 -0
- package/dist/esm/ArtifactStoreError.js +77 -0
- package/dist/esm/ArtifactStoreError.js.map +1 -0
- package/dist/esm/ArtifactStoreMetrics.d.ts +70 -0
- package/dist/esm/ArtifactStoreMetrics.d.ts.map +1 -0
- package/dist/esm/ArtifactStoreMetrics.js +77 -0
- package/dist/esm/ArtifactStoreMetrics.js.map +1 -0
- package/dist/esm/ArtifactSweep.d.ts +185 -0
- package/dist/esm/ArtifactSweep.d.ts.map +1 -0
- package/dist/esm/ArtifactSweep.js +210 -0
- package/dist/esm/ArtifactSweep.js.map +1 -0
- package/dist/esm/CombinedArtifacts.d.ts +103 -0
- package/dist/esm/CombinedArtifacts.d.ts.map +1 -0
- package/dist/esm/CombinedArtifacts.js +214 -0
- package/dist/esm/CombinedArtifacts.js.map +1 -0
- package/dist/esm/FileLease.d.ts +117 -0
- package/dist/esm/FileLease.d.ts.map +1 -0
- package/dist/esm/FileLease.js +249 -0
- package/dist/esm/FileLease.js.map +1 -0
- package/dist/esm/FileSystemArtifactStore.d.ts +93 -0
- package/dist/esm/FileSystemArtifactStore.d.ts.map +1 -0
- package/dist/esm/FileSystemArtifactStore.js +336 -0
- package/dist/esm/FileSystemArtifactStore.js.map +1 -0
- package/dist/esm/MemoryArtifactStore.d.ts +25 -0
- package/dist/esm/MemoryArtifactStore.d.ts.map +1 -0
- package/dist/esm/MemoryArtifactStore.js +67 -0
- package/dist/esm/MemoryArtifactStore.js.map +1 -0
- package/dist/esm/NoopArtifactStore.d.ts +17 -0
- package/dist/esm/NoopArtifactStore.d.ts.map +1 -0
- package/dist/esm/NoopArtifactStore.js +27 -0
- package/dist/esm/NoopArtifactStore.js.map +1 -0
- package/dist/esm/RemoteArtifacts.d.ts +237 -0
- package/dist/esm/RemoteArtifacts.d.ts.map +1 -0
- package/dist/esm/RemoteArtifacts.js +557 -0
- package/dist/esm/RemoteArtifacts.js.map +1 -0
- package/dist/esm/index.d.ts +65 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +65 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/internal/ArtifactLocks.d.ts +30 -0
- package/dist/esm/internal/ArtifactLocks.d.ts.map +1 -0
- package/dist/esm/internal/ArtifactLocks.js +86 -0
- package/dist/esm/internal/ArtifactLocks.js.map +1 -0
- package/dist/esm/internal/ArtifactPath.d.ts +19 -0
- package/dist/esm/internal/ArtifactPath.d.ts.map +1 -0
- package/dist/esm/internal/ArtifactPath.js +59 -0
- package/dist/esm/internal/ArtifactPath.js.map +1 -0
- package/dist/esm/measureBytes.d.ts +18 -0
- package/dist/esm/measureBytes.d.ts.map +1 -0
- package/dist/esm/measureBytes.js +22 -0
- package/dist/esm/measureBytes.js.map +1 -0
- package/dist/esm/snapshotBytes.d.ts +19 -0
- package/dist/esm/snapshotBytes.d.ts.map +1 -0
- package/dist/esm/snapshotBytes.js +30 -0
- package/dist/esm/snapshotBytes.js.map +1 -0
- package/dist/esm/validateDigest.d.ts +22 -0
- package/dist/esm/validateDigest.d.ts.map +1 -0
- package/dist/esm/validateDigest.js +26 -0
- package/dist/esm/validateDigest.js.map +1 -0
- package/docs/README.md +202 -0
- package/docs/api.md +484 -0
- package/docs/concepts/content-addressing.md +174 -0
- package/docs/concepts/coordination.md +141 -0
- package/docs/concepts/tiers.md +147 -0
- package/docs/guides/fence-a-backup.md +123 -0
- package/docs/guides/reclaim-disk-space.md +143 -0
- package/docs/guides/serve-the-artifact-protocol.md +140 -0
- package/docs/guides/share-artifacts-across-machines.md +180 -0
- package/docs/guides/test-against-an-artifact-store.md +158 -0
- package/docs/installation.md +102 -0
- package/docs/quickstart.md +183 -0
- package/docs/troubleshooting.md +232 -0
- package/package.json +179 -3
- package/src/ArtifactBackupLease.ts +223 -0
- package/src/ArtifactStore.ts +152 -0
- package/src/ArtifactStoreError.ts +96 -0
- package/src/ArtifactStoreMetrics.ts +83 -0
- package/src/ArtifactSweep.ts +346 -0
- package/src/CombinedArtifacts.ts +369 -0
- package/src/FileLease.ts +397 -0
- package/src/FileSystemArtifactStore.ts +459 -0
- package/src/MemoryArtifactStore.ts +83 -0
- package/src/NoopArtifactStore.ts +30 -0
- package/src/RemoteArtifacts.ts +814 -0
- package/src/index.ts +71 -0
- package/src/internal/ArtifactLocks.ts +110 -0
- package/src/internal/ArtifactPath.ts +72 -0
- package/src/measureBytes.ts +28 -0
- package/src/snapshotBytes.ts +34 -0
- 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.
|