@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.
- package/CHANGELOG.md +77 -0
- package/LICENSE +21 -0
- package/README.md +173 -2
- package/dist/cjs/ArtifactBackupLease.d.ts +34 -0
- package/dist/cjs/ArtifactBackupLease.d.ts.map +1 -0
- package/dist/cjs/ArtifactBackupLease.js +175 -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 +163 -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 +113 -0
- package/dist/cjs/FileLease.d.ts.map +1 -0
- package/dist/cjs/FileLease.js +201 -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 +117 -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 +202 -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 +113 -0
- package/dist/esm/FileLease.d.ts.map +1 -0
- package/dist/esm/FileLease.js +239 -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 +554 -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 +200 -0
- package/docs/api.md +474 -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 +98 -0
- package/docs/quickstart.md +182 -0
- package/docs/troubleshooting.md +232 -0
- package/package.json +179 -3
- package/src/ArtifactBackupLease.ts +213 -0
- package/src/ArtifactStore.ts +152 -0
- package/src/ArtifactStoreError.ts +96 -0
- package/src/ArtifactStoreMetrics.ts +83 -0
- package/src/ArtifactSweep.ts +336 -0
- package/src/CombinedArtifacts.ts +369 -0
- package/src/FileLease.ts +380 -0
- package/src/FileSystemArtifactStore.ts +459 -0
- package/src/MemoryArtifactStore.ts +83 -0
- package/src/NoopArtifactStore.ts +30 -0
- package/src/RemoteArtifacts.ts +809 -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,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.
|