@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
package/docs/api.md
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "API reference"
|
|
3
|
+
description: "Every export of @smthrs/artifacts: the store contract and its errors, the filesystem, memory, no-op, HTTP, and combined implementations, the sweep, backup lease, and file lease, and the two metrics."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
`@smthrs/artifacts` exports seven modules. Each is available from the root as a
|
|
7
|
+
namespace and from its own subpath:
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { ArtifactStore } from "@smthrs/artifacts"
|
|
11
|
+
import * as ArtifactStoreSubpath from "@smthrs/artifacts/ArtifactStore"
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Addresses are the SHA-256 digests [`@smthrs/crypto`](/api/crypto) computes
|
|
15
|
+
through the injected `Crypto` service, which with `effect` is the whole
|
|
16
|
+
dependency list.
|
|
17
|
+
|
|
18
|
+
## Example
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { ArtifactStore } from "@smthrs/artifacts"
|
|
22
|
+
|
|
23
|
+
const program = Effect.gen(function*() {
|
|
24
|
+
const store = yield* ArtifactStore.ArtifactStore
|
|
25
|
+
const address = yield* store.put(new TextEncoder().encode("build output"))
|
|
26
|
+
return yield* store.get(address)
|
|
27
|
+
})
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## ArtifactStore
|
|
31
|
+
|
|
32
|
+
`@smthrs/artifacts/ArtifactStore`. The contract, its errors, and the three
|
|
33
|
+
local implementations.
|
|
34
|
+
|
|
35
|
+
### The contract
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
interface Service {
|
|
39
|
+
readonly put: (bytes: Uint8Array) => Effect<Digest, ArtifactStoreError, Crypto>
|
|
40
|
+
readonly get: (
|
|
41
|
+
digest: string
|
|
42
|
+
) => Effect<Uint8Array, ArtifactMissing | ArtifactCorruption | ArtifactStoreError, Crypto>
|
|
43
|
+
readonly has: (digest: string) => Effect<boolean, ArtifactStoreError>
|
|
44
|
+
readonly findMissing: (digests: Iterable<string>) => Effect<Array<string>, ArtifactStoreError>
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
| Member | Behavior |
|
|
49
|
+
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
50
|
+
| `put` | Measures `bytes`, publishes them under that address, and returns it. Idempotent: storing the same bytes twice returns the same address and stores one copy. Every implementation snapshots the caller's buffer when the Effect begins and never retains the caller's reference. |
|
|
51
|
+
| `get` | Returns the bytes at `digest`, having verified that they still hash to it. Every successful read returns a new array. Fails with `ArtifactMissing` for a valid address with no bytes, `ArtifactCorruption` for a mismatch, and `ArtifactStoreError` for a host or transport refusal. |
|
|
52
|
+
| `has` | Whether this tier holds an artifact at `digest`. Does not read or hash the bytes, so it needs no `Crypto`. |
|
|
53
|
+
| `findMissing` | Which of `digests` this tier does not hold. The result is guaranteed to be a subset of the input, deduplicated in first-seen order. |
|
|
54
|
+
|
|
55
|
+
`ArtifactStore` is the service tag, with identity
|
|
56
|
+
`@smthrs/artifacts/ArtifactStore`.
|
|
57
|
+
|
|
58
|
+
Read operations accept a plain `string` rather than the `Digest` brand on
|
|
59
|
+
purpose: an address read back out of a durable row is untrusted input, so the
|
|
60
|
+
store validates it instead of asking every caller to re-brand a persisted
|
|
61
|
+
column. `put` returns the brand, because it measured the bytes itself.
|
|
62
|
+
|
|
63
|
+
### Digests
|
|
64
|
+
|
|
65
|
+
| Export | Meaning |
|
|
66
|
+
| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
67
|
+
| `Digest` | Schema and branded type for exactly 64 lowercase hexadecimal SHA-256 characters, re-exported from `@smthrs/crypto`. |
|
|
68
|
+
| `validateDigest(digest: string)` | `Effect<Digest, ArtifactStoreError>`. Refuses anything other than the canonical representation with code `invalid_digest`. The failure text is a bounded constant, so a hostile value cannot copy itself into a log or a durable error. |
|
|
69
|
+
| `measureBytes(bytes: Uint8Array)` | `Effect<Digest, ArtifactStoreError, Crypto>`. Computes an address with the injected `Crypto` service, never retaining the bytes in an error. |
|
|
70
|
+
| `snapshotBytes(bytes: Uint8Array)` | `Effect<Uint8Array, ArtifactStoreError>`. Copies a caller-owned buffer when the Effect begins. A refused copy fails as `unavailable`, the code for a host refusal, because `Crypto` has not been consulted yet. |
|
|
71
|
+
|
|
72
|
+
Every implementation validates an address before logging it or interpolating it
|
|
73
|
+
into a path or a URL.
|
|
74
|
+
|
|
75
|
+
### Errors
|
|
76
|
+
|
|
77
|
+
| Export | Shape |
|
|
78
|
+
| ------------------------ | --------------------------------------------------------------------------------------------------------------------- |
|
|
79
|
+
| `ArtifactStoreError` | Tag `@smthrs/artifacts/ArtifactStoreError`, fields `code`, `message`, optional `cause`. |
|
|
80
|
+
| `ArtifactMissing` | Tag `@smthrs/artifacts/ArtifactMissing`, fields `code: "artifact_missing"` and `digest`. |
|
|
81
|
+
| `ArtifactCorruption` | Tag `@smthrs/artifacts/ArtifactCorruption`, fields `code: "artifact_corruption"`, `recordedDigest`, `measuredDigest`. |
|
|
82
|
+
| `ArtifactStoreErrorCode` | Schema and type for the five stable codes. |
|
|
83
|
+
|
|
84
|
+
A miss and a corruption are separate tags rather than codes because a
|
|
85
|
+
read-through composition acts on them differently: a miss is an ordinary
|
|
86
|
+
outcome a second tier may satisfy, and a corruption is a violation of the
|
|
87
|
+
store's strongest invariant.
|
|
88
|
+
|
|
89
|
+
| Code | Retryable | Meaning |
|
|
90
|
+
| ----------------------- | --------- | ----------------------------------------------------------- |
|
|
91
|
+
| `invalid_configuration` | No | The caller supplied an option the store refuses. Permanent. |
|
|
92
|
+
| `invalid_digest` | No | The value is not a canonical SHA-256 address. Permanent. |
|
|
93
|
+
| `digest_failed` | Depends | The `Crypto` service failed to compute a digest. |
|
|
94
|
+
| `unavailable` | Depends | A host refused, or a no-op store has no implementation. |
|
|
95
|
+
| `transport_failed` | Depends | The shared tier refused, answered unusably, or timed out. |
|
|
96
|
+
|
|
97
|
+
### The filesystem store
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
declare const makeFileSystem: (fs: FileSystem, path: Path, options?: FileSystemOptions) => Service
|
|
101
|
+
declare const layerFileSystem: (
|
|
102
|
+
options?: FileSystemOptions
|
|
103
|
+
) => Layer<ArtifactStore, never, FileSystem | Path>
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`path` is the host's `Path` service (`NodePath.layer` on Node). The directory
|
|
107
|
+
sync walks the objects directory's ancestors with it, so a Windows drive path
|
|
108
|
+
syncs its real parents.
|
|
109
|
+
|
|
110
|
+
Publishes at `${directory}/${digest.slice(0, 2)}/${digest}`. Bytes land at a
|
|
111
|
+
unique temp path in the destination fanout directory, are synced when required,
|
|
112
|
+
and are renamed into place. Every deduplicated `put` verifies the existing blob
|
|
113
|
+
and rewrites an unreadable or mismatched address atomically, then freshens the
|
|
114
|
+
blob's modification time so a retention sweep reads it as recently referenced.
|
|
115
|
+
|
|
116
|
+
`findMissing` deduplicates and validates the full input before filesystem
|
|
117
|
+
probes, then checks at most 16 addresses concurrently. Missing digests retain
|
|
118
|
+
first-request order; a failed probe fails the batch as `unavailable`.
|
|
119
|
+
|
|
120
|
+
| `FileSystemOptions` field | Default | Meaning |
|
|
121
|
+
| ------------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
122
|
+
| `directory` | `defaultDirectory` | Where blobs are stored. Workspace-relative, so a workspace can be moved or copied whole. An `ArtifactSweep` over the same store must name the same directory. |
|
|
123
|
+
| `fileMode` | `0o600` | Creation mode for new payload files, restricted by the host umask. Existing blobs are unchanged. |
|
|
124
|
+
| `directoryMode` | `0o700` | Creation mode for objects and fanout directories, restricted by the host umask. Lock directories remain `0700`. |
|
|
125
|
+
| `durability` | `"required"` | `required` syncs the blob, fanout directory, objects directory, and its ancestors and propagates any refusal. `best-effort` tolerates sync refusals. Both modes require exclusive writable handles and symlink inspection. |
|
|
126
|
+
| `coordination` | `"required"` | `required` combines an in-process semaphore with heartbeat-backed lock files shared by every cooperating process. `process` keeps only the semaphore, giving up cross-process exclusion between writers and sweep deletion. |
|
|
127
|
+
|
|
128
|
+
`defaultDirectory` is `.flows/objects`. It is exported because the store and
|
|
129
|
+
its sweep must name the same directory, and a second private copy of the
|
|
130
|
+
literal is exactly how they would drift apart.
|
|
131
|
+
|
|
132
|
+
`required` bounds the writer-versus-sweeper race rather than eliminating it. A
|
|
133
|
+
lock is heartbeated every 10 seconds, reclaimed once it is 60 seconds stale, and
|
|
134
|
+
acquisition gives up after 2 minutes. Contenders that find the same stale lock
|
|
135
|
+
race for a claim file named after its owner, so the lock is moved at most once
|
|
136
|
+
and a fresh replacement is never displaced. A holder whose host stalls past 60
|
|
137
|
+
seconds is still reaped while running. Neither
|
|
138
|
+
`ArtifactSweep.RemoveOptions.ifUnmodifiedSinceMs` nor the backup lease protects
|
|
139
|
+
against that, because both depend on the same lock. Build a
|
|
140
|
+
store and its sweep with the same directory and coordination mode; no runtime
|
|
141
|
+
check can detect a mismatch. See
|
|
142
|
+
[Coordination between processes](./concepts/coordination.md).
|
|
143
|
+
|
|
144
|
+
The store also sweeps its own crash orphans, `.tmp-*` payloads and `.locks/`
|
|
145
|
+
entries older than one hour, once per store instance on its first publication.
|
|
146
|
+
That is scratch reclamation, never garbage collection of published blobs.
|
|
147
|
+
|
|
148
|
+
### The memory store
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
declare const makeMemory: () => Service
|
|
152
|
+
declare const layerMemory: Layer<ArtifactStore>
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
For tests and for a browser host with no durable filesystem. It copies at both
|
|
156
|
+
boundaries around a private map, so no reference a caller can still mutate
|
|
157
|
+
aliases the stored content. It does not rehash a read, because its address
|
|
158
|
+
space is private and cannot disagree with itself.
|
|
159
|
+
|
|
160
|
+
`layerMemory` is a value, not a function.
|
|
161
|
+
|
|
162
|
+
### The no-op store
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
declare const makeNoop: (overrides?: Partial<Service>) => Service
|
|
166
|
+
declare const layerNoop: (overrides?: Partial<Service>) => Layer<ArtifactStore>
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Every operation fails with an `unavailable` `ArtifactStoreError` whose message
|
|
170
|
+
is `<method> is unavailable`. Overrides replace individual methods, which is
|
|
171
|
+
how a test states the one behavior it cares about.
|
|
172
|
+
|
|
173
|
+
## RemoteArtifacts
|
|
174
|
+
|
|
175
|
+
`@smthrs/artifacts/RemoteArtifacts`. The shared tier, spoken over Effect's
|
|
176
|
+
`HttpClient`.
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
declare const make: (options: Options) => Effect<Service, ArtifactStoreError, HttpClient>
|
|
180
|
+
declare const layer: (
|
|
181
|
+
options: Options
|
|
182
|
+
) => Layer<ArtifactStore.ArtifactStore, ArtifactStoreError, HttpClient>
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
`Service` extends `ArtifactStore.Service` with one field, `downloadPolicy`.
|
|
186
|
+
Composing `layer` alone makes every read and write a network round trip; the
|
|
187
|
+
intended production shape is `CombinedArtifacts.layer` with this as its remote
|
|
188
|
+
tier.
|
|
189
|
+
|
|
190
|
+
The wire protocol is `GET`, `PUT`, and `HEAD` at `/cas/{digest}` and `POST` at
|
|
191
|
+
`/cas/findMissing`, resolved beneath the endpoint. Every download is bounded and
|
|
192
|
+
digest-verified before it is returned. Redirects are never followed: a fetch
|
|
193
|
+
transport runs with `redirect: "manual"`, and any `3xx` other than the
|
|
194
|
+
resumable-upload `308` fails as an unexpected status, so credential headers
|
|
195
|
+
never reach another origin. The complete server-side contract is in
|
|
196
|
+
[Serve the artifact protocol](./guides/serve-the-artifact-protocol.md).
|
|
197
|
+
|
|
198
|
+
### Options
|
|
199
|
+
|
|
200
|
+
| Field | Default | Meaning |
|
|
201
|
+
| ----------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
202
|
+
| `endpoint` | required | The cache root, for example `https://cache.example.com`. A trailing slash is ignored. Refused at construction as `invalid_configuration` when it is not a string, when no `URL` parser accepts it, when the scheme is not `https:` (plain `http:` is allowed only for the literal loopback hosts `localhost`, `127.0.0.1`, and `[::1]`), and when it carries userinfo, a query, or a fragment. The refusal names only the violated rule and never echoes the endpoint. |
|
|
203
|
+
| `headers` | none | Headers sent with every request: this is the credential seam. Construction-time on purpose, because a credential arriving as a step input would be hashed into a step key and persisted everywhere the journal goes. Applied before the protocol's own headers, so no configuration can strip a `content-type`, `content-length`, or `content-range`. |
|
|
204
|
+
| `downloadTimeout` | 60 seconds | One download, including the complete response body. |
|
|
205
|
+
| `uploadTimeout` | 60 seconds | One upload, including every resume probe and chunk. |
|
|
206
|
+
| `requestTimeout` | 60 seconds | One `HEAD` probe, or one `findMissing` batch and its response. |
|
|
207
|
+
| `maxDownloadBytes` | 256 MiB | The largest download buffered. A `Content-Length` past the bound is refused before a body byte is read; an incremental read stops at most one chunk past it. |
|
|
208
|
+
| `maxFindMissingResponseBytes` | 256 KiB | The largest `findMissing` response accepted. May only lower the protocol bound. |
|
|
209
|
+
| `chunkBytes` | absent | Above this many bytes an upload travels as `Content-Range` requests, resuming from the prefix the tier kept. |
|
|
210
|
+
| `downloadPolicy` | `"all"` | How eagerly a composition reading through this tier materializes blobs locally. |
|
|
211
|
+
|
|
212
|
+
`findMissing` validates its input before sending anything, sends at most 1,000
|
|
213
|
+
digests per batch, and filters the response back to requested digests, so a
|
|
214
|
+
tier answering with an unrequested digest cannot make a caller upload bytes it
|
|
215
|
+
never asked about.
|
|
216
|
+
|
|
217
|
+
### Download policy
|
|
218
|
+
|
|
219
|
+
| Export | Meaning |
|
|
220
|
+
| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
|
221
|
+
| `DownloadPolicy` | Schema and type for `"all"`, `"toplevel"`, `"minimal"`. |
|
|
222
|
+
| `downloadPolicies` | A frozen array of all three, in materialization order. |
|
|
223
|
+
| `downloadPolicyOf(store)` | Reads the policy a store declares, or `undefined` for one that declares none, which is every local store and any foreign implementation. |
|
|
224
|
+
|
|
225
|
+
`all` prefetches every referenced blob when a replay is admitted. `toplevel`
|
|
226
|
+
prefetches nothing and materializes a blob on the first read that needs it.
|
|
227
|
+
`minimal` prefetches nothing and materializes nothing.
|
|
228
|
+
`CombinedArtifacts.get` honors the last two;
|
|
229
|
+
[`@smthrs/engine-store`](/api/engine-store)'s `ArtifactSync.hydrate` honors the
|
|
230
|
+
first.
|
|
231
|
+
|
|
232
|
+
### Chunked uploads
|
|
233
|
+
|
|
234
|
+
With `chunkBytes` set, the client sends a `HEAD` existence probe, then an empty
|
|
235
|
+
`Content-Range: bytes */{total}` probe to discover a retained prefix, then
|
|
236
|
+
ranged chunks:
|
|
237
|
+
|
|
238
|
+
| Response | Client action |
|
|
239
|
+
| ----------------------------- | ----------------------------------------------------------- |
|
|
240
|
+
| `308` | Continue after the reported `Range: bytes=0-{last}` prefix. |
|
|
241
|
+
| `2xx` on the completing chunk | Confirm the complete length with `HEAD`. |
|
|
242
|
+
| `2xx` before completion | Treat the tier as range-unaware and send the whole blob. |
|
|
243
|
+
| `400`, `411`, or `416` | Send the whole blob. |
|
|
244
|
+
| Any other status | Fail the upload as a transport error. |
|
|
245
|
+
|
|
246
|
+
The offset never moves backward. If a server ignores ranges, omits a confirming
|
|
247
|
+
length, or stores a partial body, the whole-blob `PUT` overwrites the partial
|
|
248
|
+
result. Both Smithers cache services answer a ranged `PUT` with `400` and cap
|
|
249
|
+
one request body at 16 MiB, so `chunkBytes` against them degrades
|
|
250
|
+
to one whole-blob `PUT` and a larger artifact is refused with `413`.
|
|
251
|
+
|
|
252
|
+
## CombinedArtifacts
|
|
253
|
+
|
|
254
|
+
`@smthrs/artifacts/CombinedArtifacts`. Local first, shared second, with
|
|
255
|
+
write-back.
|
|
256
|
+
|
|
257
|
+
```ts
|
|
258
|
+
declare const make: (options: Options) => Effect<RemoteArtifacts.Service, ArtifactStoreError>
|
|
259
|
+
declare const layer: <EL, RL, ER, RR>(
|
|
260
|
+
options: LayerOptions<EL, RL, ER, RR>
|
|
261
|
+
) => Layer<ArtifactStore.ArtifactStore, EL | ER | ArtifactStoreError, RL | RR>
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
| `Options` field | Default | Meaning |
|
|
265
|
+
| ------------------ | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
|
|
266
|
+
| `local` | required | The fast, machine-local tier, as a `Service`. Every read tries this one first. |
|
|
267
|
+
| `remote` | required | The shared tier, as a `Service`. Consulted only on a local miss or corruption. |
|
|
268
|
+
| `uploadTimeout` | 60 seconds | How long a `put` waits for its opportunistic upload before abandoning it. An abandoned upload is dropped exactly like a refused one. |
|
|
269
|
+
| `writeBackTimeout` | 60 seconds | How long a `get` waits for opportunistic local write-back before interrupting it and returning the verified remote bytes. |
|
|
270
|
+
| `downloadPolicy` | the remote tier's, else `"all"` | Overrides the shared tier's declared policy for this composition. |
|
|
271
|
+
|
|
272
|
+
`LayerOptions` is the same shape with `local` and `remote` as `Effect`s. Both
|
|
273
|
+
tiers are supplied as effects rather than layers because they inhabit the same
|
|
274
|
+
tag: composing two `Layer<ArtifactStore>` would shadow one with the other. Pair
|
|
275
|
+
`Effect.map(Effect.all([FileSystem.FileSystem, Path.Path]), ([fs, path]) => ArtifactStore.makeFileSystem(fs, path))`
|
|
276
|
+
with `RemoteArtifacts.make`.
|
|
277
|
+
|
|
278
|
+
Both timeout options must be finite, positive durations. Invalid values fail
|
|
279
|
+
construction with `ArtifactStoreError` code `invalid_configuration`.
|
|
280
|
+
|
|
281
|
+
**Reads.** The local tier answers first. A hit returns. `ArtifactMissing` and
|
|
282
|
+
`ArtifactCorruption` both fall through to the shared tier, and they are kept
|
|
283
|
+
apart because only corruption earns a write-back that `minimal` would otherwise
|
|
284
|
+
skip. A local `ArtifactStoreError` fails the read rather than falling through:
|
|
285
|
+
a host that refused has not answered the question, and paying for the network
|
|
286
|
+
instead would hide a broken local tier behind a working shared one.
|
|
287
|
+
Local write-back after a remote read is opportunistic. A typed failure or
|
|
288
|
+
`writeBackTimeout` expiry returns the verified remote bytes. Timeout interrupts
|
|
289
|
+
the write-back and waits for its interruption cleanup before returning.
|
|
290
|
+
|
|
291
|
+
**Writes.** `put` publishes locally first and returns the local digest. The
|
|
292
|
+
remote upload is opportunistic, deduplicated in flight by digest, and bounded by
|
|
293
|
+
`uploadTimeout`. A remote refusal or timeout does not fail the operation that
|
|
294
|
+
produced the bytes. The shared-cache publication protocol uses `findMissing`,
|
|
295
|
+
upload, and confirmation before it publishes a cache entry, so an abandoned
|
|
296
|
+
upload costs another transfer rather than correctness.
|
|
297
|
+
|
|
298
|
+
**Probes.** `has` asks local, then remote. `findMissing` asks the remote tier
|
|
299
|
+
only about what the local tier could not answer, so the result stays a subset of
|
|
300
|
+
the input because each stage filters the previous stage's output.
|
|
301
|
+
|
|
302
|
+
`minimal` still writes back after local corruption, because replacing an
|
|
303
|
+
address the local tier already claims is repair rather than growth.
|
|
304
|
+
|
|
305
|
+
## ArtifactSweep
|
|
306
|
+
|
|
307
|
+
`@smthrs/artifacts/ArtifactSweep`. Host-local enumeration and fenced deletion.
|
|
308
|
+
This surface is deliberately not part of `ArtifactStore.Service`: a remote tier
|
|
309
|
+
can neither enumerate its address space nor accept a delete, and only the
|
|
310
|
+
filesystem store implements it.
|
|
311
|
+
|
|
312
|
+
```ts
|
|
313
|
+
interface Service {
|
|
314
|
+
readonly inventory: Effect<Array<BlobStat>, ArtifactStoreError>
|
|
315
|
+
readonly remove: (digest: string, options?: RemoveOptions) => Effect<boolean, ArtifactStoreError>
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
declare const makeFileSystem: (fs: FileSystem, options?: SweepOptions) => Service
|
|
319
|
+
declare const layerFileSystem: (options?: SweepOptions) => Layer<ArtifactSweep, never, FileSystem>
|
|
320
|
+
declare const makeNoop: (overrides?: Partial<Service>) => Service
|
|
321
|
+
declare const layerNoop: (overrides?: Partial<Service>) => Layer<ArtifactSweep>
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
`ArtifactSweep` is the service tag, with identity
|
|
325
|
+
`@smthrs/artifacts/ArtifactSweep`.
|
|
326
|
+
|
|
327
|
+
| Export | Fields |
|
|
328
|
+
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
329
|
+
| `BlobStat` | `digest`, `modifiedAtMs`, `sizeBytes`. |
|
|
330
|
+
| `RemoveOptions` | `ifUnmodifiedSinceMs`: delete only while the blob's mtime is at or before this bound. |
|
|
331
|
+
| `SweepOptions` | `directory` (defaults to `ArtifactStore.defaultDirectory`) and `coordination` (defaults to `required`). No `durability`, because a sweep never writes a blob. |
|
|
332
|
+
|
|
333
|
+
`inventory` returns a `BlobStat` only for canonical fanout files whose
|
|
334
|
+
modification time is measurable. It skips temp files, lock files, foreign
|
|
335
|
+
paths, nested paths, directories sitting at blob addresses, and entries that
|
|
336
|
+
disappear during the scan. A directory that was never created is an empty
|
|
337
|
+
inventory, not a failure.
|
|
338
|
+
|
|
339
|
+
Candidate metadata reads run with concurrency 16 and retain directory-listing
|
|
340
|
+
order. Each candidate keeps its path checks; a failed metadata read skips only
|
|
341
|
+
that candidate.
|
|
342
|
+
|
|
343
|
+
`remove` returns `false` for three outcomes a caller cannot tell apart, all of
|
|
344
|
+
which mean nothing was reclaimed and retrying later is safe: the blob was
|
|
345
|
+
already gone, it failed the `ifUnmodifiedSinceMs` fence, or a live backup lease
|
|
346
|
+
fenced the deletion. The last one is not progress, so a collector counting
|
|
347
|
+
reclaimed bytes must not count it.
|
|
348
|
+
|
|
349
|
+
Under `required`, one deletion costs two lock acquisitions, the per-digest lock
|
|
350
|
+
and the workspace-global backup-lease gate, which is roughly ten filesystem
|
|
351
|
+
operations and two forked heartbeat fibers per blob. The gate is one file for
|
|
352
|
+
the whole workspace, so concurrent deletions serialize through it.
|
|
353
|
+
|
|
354
|
+
The policy this mechanism serves, which digests are live and how long a dead
|
|
355
|
+
blob is kept, belongs to [`@smthrs/engine-store`](/api/engine-store)'s
|
|
356
|
+
`ArtifactGc`. See [Reclaim disk space](./guides/reclaim-disk-space.md).
|
|
357
|
+
|
|
358
|
+
## ArtifactBackupLease
|
|
359
|
+
|
|
360
|
+
`@smthrs/artifacts/ArtifactBackupLease`. Cross-process exclusion between a
|
|
361
|
+
filesystem backup and sweep deletion.
|
|
362
|
+
|
|
363
|
+
```ts
|
|
364
|
+
declare const withLease: <A, E, R, E2>(
|
|
365
|
+
fs: FileSystem,
|
|
366
|
+
directory: string,
|
|
367
|
+
effect: Effect<A, E, R>,
|
|
368
|
+
failure: (cause: unknown) => E2
|
|
369
|
+
) => Effect<A, E | E2, R>
|
|
370
|
+
|
|
371
|
+
declare const unlessActive: <A, E, R, E2>(
|
|
372
|
+
fs: FileSystem,
|
|
373
|
+
directory: string,
|
|
374
|
+
effect: Effect<A, E, R>,
|
|
375
|
+
failure: (cause: unknown) => E2
|
|
376
|
+
) => Effect<Option<A>, E | E2, R>
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
`withLease` holds a heartbeat-backed marker while `effect` runs, so no sweep in
|
|
380
|
+
any process using `directory` can delete a blob. Publication stays
|
|
381
|
+
unconstrained: new blobs may appear during a backup, but a blob already
|
|
382
|
+
referenced by the frozen database cannot disappear underneath it. Acquisition
|
|
383
|
+
gives up after 2 minutes and fails through `failure`. A crashed lease becomes
|
|
384
|
+
reclaimable once its heartbeat is 60 seconds stale, and finding no marker to
|
|
385
|
+
release is the ordinary end of a slow lease rather than a fault.
|
|
386
|
+
|
|
387
|
+
`unlessActive` checks the marker and runs `effect` under the same
|
|
388
|
+
workspace-global gate, so the check and the operation cannot be separated. It
|
|
389
|
+
returns `None` when a live backup deliberately fenced the operation, which
|
|
390
|
+
`ArtifactSweep.remove` reports as `false`.
|
|
391
|
+
|
|
392
|
+
`failure` maps an unknown host cause into the caller's error type, which is how
|
|
393
|
+
these combinators stay usable from a tool with its own error channel. See
|
|
394
|
+
[Fence a backup against the sweep](./guides/fence-a-backup.md).
|
|
395
|
+
|
|
396
|
+
## FileLease
|
|
397
|
+
|
|
398
|
+
`@smthrs/artifacts/FileLease`. The cross-process lock-file lease under the
|
|
399
|
+
per-digest locks and `ArtifactBackupLease`, exported for other packages that
|
|
400
|
+
need the same fence; `@smthrs/engine-store` uses it for the workspace commit
|
|
401
|
+
lock.
|
|
402
|
+
|
|
403
|
+
```ts
|
|
404
|
+
declare const withLease: <A, E, R, E2>(
|
|
405
|
+
fs: FileSystem,
|
|
406
|
+
lockPath: string,
|
|
407
|
+
effect: Effect<A, E, R>,
|
|
408
|
+
failure: (cause: unknown) => E2,
|
|
409
|
+
options?: Options
|
|
410
|
+
) => Effect<A, E | E2, R>
|
|
411
|
+
|
|
412
|
+
declare const hold: <A, E, R, E2>(
|
|
413
|
+
fs: FileSystem,
|
|
414
|
+
lockPath: string,
|
|
415
|
+
effect: Effect<A, E, R>,
|
|
416
|
+
failure: (cause: unknown) => E2,
|
|
417
|
+
options?: HoldOptions
|
|
418
|
+
) => Effect<A, E | E2, R>
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
`withLease` creates `lockPath` with `wx`, writes a unique owner token, and
|
|
422
|
+
refreshes its mtime while `effect` runs. It reclaims a lock whose mtime is
|
|
423
|
+
older than the stale bound, at most once per lock generation, and fails with
|
|
424
|
+
`failure(TimeoutError)` when acquisition misses its deadline. Every other host
|
|
425
|
+
refusal reaches `failure` as the `PlatformError`. A directory at `lockPath` is
|
|
426
|
+
treated as a lock with no readable owner and reclaimed on the same bound.
|
|
427
|
+
`hold` is the same lease without a deadline, for a caller that bounds a wider
|
|
428
|
+
wait itself; `onAcquired` runs once the lease is held. `guard`, when given,
|
|
429
|
+
runs before every create, heartbeat, rename, and remove of the lock, claim, or
|
|
430
|
+
tombstone, and its failure refuses that step through `failure`.
|
|
431
|
+
|
|
432
|
+
| Option | Default | Meaning |
|
|
433
|
+
| ---------------- | -------------- | ------------------------------------------------ |
|
|
434
|
+
| `heartbeatEvery` | `"10 seconds"` | How often the holder refreshes the lock's mtime. |
|
|
435
|
+
| `staleAfterMs` | `60_000` | Age past which a lock or reclaim claim is stale. |
|
|
436
|
+
| `acquireWithin` | `"2 minutes"` | Acquisition deadline (`withLease` only). |
|
|
437
|
+
| `retryEvery` | `"25 millis"` | Sleep between attempts while contended. |
|
|
438
|
+
| `label` | `"File lease"` | Names the lock in the reclaimed-holder warning. |
|
|
439
|
+
| `annotations` | none | Extra fields logged with that warning. |
|
|
440
|
+
|
|
441
|
+
The host needs exclusive `wx` file creation, `readFileString`, `stat` with an
|
|
442
|
+
mtime, `utimes`, `rename`, and `remove`. Reclaim writes siblings named
|
|
443
|
+
`<lockPath>.reclaim-<token>` and `<lockPath>.stale-<token>`. The fence is
|
|
444
|
+
bounded: see [Coordination](./concepts/coordination.md).
|
|
445
|
+
|
|
446
|
+
## ArtifactStoreMetrics
|
|
447
|
+
|
|
448
|
+
`@smthrs/artifacts/ArtifactStoreMetrics`. Metric handles only; no exporter
|
|
449
|
+
ships in this package.
|
|
450
|
+
|
|
451
|
+
| Export | Counter | Updated by |
|
|
452
|
+
| ---------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------- |
|
|
453
|
+
| `puts` | `flows_artifact_puts` | Successful filesystem and memory puts, including deduplicated ones. |
|
|
454
|
+
| `gets` | `flows_artifact_gets` | Successful filesystem and memory gets. |
|
|
455
|
+
| `remoteFailures` | `flows_artifact_remote_failures` | Shared uploads and local write-backs `CombinedArtifacts` dropped, by `operation` (`put`, `write_back`). |
|
|
456
|
+
|
|
457
|
+
`remoteFailure.put` and `remoteFailure.write_back` are the attributed views.
|
|
458
|
+
Drops also log a warning naming the operation and error code, at most once a
|
|
459
|
+
minute per operation.
|
|
460
|
+
|
|
461
|
+
`RemoteArtifacts` is deliberately uninstrumented and the counters carry no tier
|
|
462
|
+
attribute, so read them as local artifact store traffic. A combined read the
|
|
463
|
+
shared tier serves increments no get; the write-back that materializes it
|
|
464
|
+
increments a put indistinguishable from a producer publishing new bytes.
|
|
465
|
+
Missing and corrupt reads are counted nowhere: they are error evidence, not
|
|
466
|
+
throughput.
|
|
467
|
+
|
|
468
|
+
## Reclaiming published blobs
|
|
469
|
+
|
|
470
|
+
Reclaiming published blobs never happens as a side effect of a store call. The
|
|
471
|
+
filesystem store sweeps only stale scratch and lock files. The mark policy and
|
|
472
|
+
grace period for published blobs belong to
|
|
473
|
+
[`@smthrs/engine-store`](/api/engine-store), which also owns artifact
|
|
474
|
+
publication ordering.
|
|
@@ -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/unstable/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.
|