@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,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-process exclusion between a filesystem artifact backup and garbage
|
|
3
|
+
* collection.
|
|
4
|
+
*
|
|
5
|
+
* A backup holds one heartbeat-backed marker for the duration of its database
|
|
6
|
+
* snapshot and blob copy. Sweep deletion checks that marker while holding the
|
|
7
|
+
* same short-lived global gate. Publication remains unconstrained: new blobs
|
|
8
|
+
* may appear during a backup, but a blob already referenced by the frozen
|
|
9
|
+
* database cannot disappear underneath it.
|
|
10
|
+
*
|
|
11
|
+
* @since 1.0.0-rc.0
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as Clock from "effect/Clock"
|
|
15
|
+
import * as Effect from "effect/Effect"
|
|
16
|
+
import type * as FileSystem from "effect/FileSystem"
|
|
17
|
+
import * as Option from "effect/Option"
|
|
18
|
+
import * as PlatformError from "effect/PlatformError"
|
|
19
|
+
import * as Random from "effect/Random"
|
|
20
|
+
import * as ArtifactLocks from "./internal/ArtifactLocks.ts"
|
|
21
|
+
|
|
22
|
+
const markerName = ".backup-lease"
|
|
23
|
+
const gateName = ".backup-lease-gate"
|
|
24
|
+
const heartbeatEvery = "10 seconds"
|
|
25
|
+
const staleAfterMs = 60_000
|
|
26
|
+
const acquireWithin = "2 minutes"
|
|
27
|
+
const retryEvery = "25 millis"
|
|
28
|
+
|
|
29
|
+
const isReason = (cause: unknown, tag: PlatformError.SystemErrorTag): boolean =>
|
|
30
|
+
cause instanceof PlatformError.PlatformError && cause.reason._tag === tag
|
|
31
|
+
|
|
32
|
+
const token = Effect.gen(function*() {
|
|
33
|
+
const first = yield* Random.nextIntBetween(0, Number.MAX_SAFE_INTEGER, { halfOpen: true })
|
|
34
|
+
const second = yield* Random.nextIntBetween(0, Number.MAX_SAFE_INTEGER, { halfOpen: true })
|
|
35
|
+
return `${first.toString(36)}-${second.toString(36)}-${yield* Clock.currentTimeMillis}`
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const gate = <A, E, R, E2>(
|
|
39
|
+
fs: FileSystem.FileSystem,
|
|
40
|
+
directory: string,
|
|
41
|
+
effect: Effect.Effect<A, E, R>,
|
|
42
|
+
failure: (cause: unknown) => E2
|
|
43
|
+
): Effect.Effect<A, E | E2, R> => ArtifactLocks.withDigest(fs, directory, gateName, effect, failure)
|
|
44
|
+
|
|
45
|
+
const activeMarker = <E>(
|
|
46
|
+
fs: FileSystem.FileSystem,
|
|
47
|
+
directory: string,
|
|
48
|
+
failure: (cause: unknown) => E
|
|
49
|
+
): Effect.Effect<boolean, E> =>
|
|
50
|
+
Effect.gen(function*() {
|
|
51
|
+
const path = `${directory}/${markerName}`
|
|
52
|
+
const info = yield* fs.stat(path).pipe(
|
|
53
|
+
Effect.map(Option.some),
|
|
54
|
+
Effect.catch((cause): Effect.Effect<Option.Option<FileSystem.File.Info>, E> =>
|
|
55
|
+
isReason(cause, "NotFound") ? Effect.succeed(Option.none()) : Effect.fail(failure(cause))
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
if (Option.isNone(info)) return false
|
|
59
|
+
const modified = Option.getOrUndefined(info.value.mtime)
|
|
60
|
+
const now = yield* Clock.currentTimeMillis
|
|
61
|
+
if (modified === undefined || now - modified.getTime() <= staleAfterMs) return true
|
|
62
|
+
yield* fs.remove(path).pipe(
|
|
63
|
+
Effect.catch((cause): Effect.Effect<void, E> =>
|
|
64
|
+
isReason(cause, "NotFound") ? Effect.void : Effect.fail(failure(cause))
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
return false
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Runs `effect` while sweep deletion is fenced across every process using
|
|
72
|
+
* this objects directory. A crashed holder is recovered after its heartbeat
|
|
73
|
+
* becomes stale.
|
|
74
|
+
*
|
|
75
|
+
* @category combinators
|
|
76
|
+
* @since 1.0.0-rc.0
|
|
77
|
+
*/
|
|
78
|
+
export const withLease = <A, E, R, E2>(
|
|
79
|
+
fs: FileSystem.FileSystem,
|
|
80
|
+
directory: string,
|
|
81
|
+
effect: Effect.Effect<A, E, R>,
|
|
82
|
+
failure: (cause: unknown) => E2
|
|
83
|
+
): Effect.Effect<A, E | E2, R> =>
|
|
84
|
+
Effect.gen(function*() {
|
|
85
|
+
const owner = yield* token
|
|
86
|
+
const marker = `${directory}/${markerName}`
|
|
87
|
+
yield* fs.makeDirectory(directory, { recursive: true }).pipe(Effect.mapError(failure))
|
|
88
|
+
|
|
89
|
+
let ownsMarker = false
|
|
90
|
+
const acquire = Effect.gen(function*() {
|
|
91
|
+
while (true) {
|
|
92
|
+
const acquired = yield* gate(
|
|
93
|
+
fs,
|
|
94
|
+
directory,
|
|
95
|
+
Effect.gen(function*() {
|
|
96
|
+
if (yield* activeMarker(fs, directory, failure)) return false
|
|
97
|
+
return yield* Effect.uninterruptible(
|
|
98
|
+
fs.writeFileString(marker, owner, { flag: "wx", mode: 0o600 }).pipe(
|
|
99
|
+
Effect.andThen(Effect.sync(() => {
|
|
100
|
+
ownsMarker = true
|
|
101
|
+
return true
|
|
102
|
+
})),
|
|
103
|
+
Effect.catch((cause): Effect.Effect<boolean, E2> =>
|
|
104
|
+
isReason(cause, "AlreadyExists") ? Effect.succeed(false) : Effect.fail(failure(cause))
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
}),
|
|
109
|
+
failure
|
|
110
|
+
)
|
|
111
|
+
if (acquired) return
|
|
112
|
+
yield* Effect.sleep(retryEvery)
|
|
113
|
+
}
|
|
114
|
+
}).pipe(
|
|
115
|
+
Effect.timeout(acquireWithin),
|
|
116
|
+
Effect.catchTag("TimeoutError", (cause) => Effect.fail(failure(cause)))
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
// Install marker cleanup before acquisition, including gate release. The
|
|
120
|
+
// marker write and ownership bookkeeping complete in one interruption mask.
|
|
121
|
+
return yield* Effect.acquireUseRelease(
|
|
122
|
+
Effect.void,
|
|
123
|
+
() =>
|
|
124
|
+
acquire.pipe(Effect.andThen(
|
|
125
|
+
Effect.scoped(Effect.gen(function*() {
|
|
126
|
+
yield* Effect.forkScoped(
|
|
127
|
+
Effect.forever(
|
|
128
|
+
Effect.sleep(heartbeatEvery).pipe(
|
|
129
|
+
Effect.andThen(gate(
|
|
130
|
+
fs,
|
|
131
|
+
directory,
|
|
132
|
+
fs.readFileString(marker).pipe(
|
|
133
|
+
Effect.map(Option.some),
|
|
134
|
+
Effect.catch((cause) =>
|
|
135
|
+
isReason(cause, "NotFound") ? Effect.succeed(Option.none<string>()) : Effect.fail(cause)
|
|
136
|
+
),
|
|
137
|
+
Effect.flatMap((found) => {
|
|
138
|
+
// A missing or foreign marker means a sweep judged this
|
|
139
|
+
// lease stale and reaped it: deletion is no longer
|
|
140
|
+
// fenced while the backup still copies. The sibling
|
|
141
|
+
// `internal/ArtifactLocks.ts` heartbeat reports the same
|
|
142
|
+
// loss the same way.
|
|
143
|
+
if (Option.getOrUndefined(found) !== owner) {
|
|
144
|
+
return Effect.logWarning(
|
|
145
|
+
"Artifact backup lease was reclaimed while its backup was still running"
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
return Effect.flatMap(Clock.currentTimeMillis, (now) => {
|
|
149
|
+
const timestamp = new Date(now)
|
|
150
|
+
return fs.utimes(marker, timestamp, timestamp)
|
|
151
|
+
})
|
|
152
|
+
}),
|
|
153
|
+
Effect.mapError(failure)
|
|
154
|
+
),
|
|
155
|
+
failure
|
|
156
|
+
)),
|
|
157
|
+
// A failed heartbeat is retried on the next tick, but a
|
|
158
|
+
// minute of them lets the marker go stale, so each one is
|
|
159
|
+
// reported rather than dropped.
|
|
160
|
+
Effect.catch((cause) => Effect.logWarning("Artifact backup lease heartbeat failed", cause))
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
return yield* effect
|
|
165
|
+
}))
|
|
166
|
+
)),
|
|
167
|
+
() =>
|
|
168
|
+
ownsMarker
|
|
169
|
+
? gate(
|
|
170
|
+
fs,
|
|
171
|
+
directory,
|
|
172
|
+
fs.readFileString(marker).pipe(
|
|
173
|
+
Effect.flatMap((found) => found === owner ? fs.remove(marker) : Effect.void),
|
|
174
|
+
// A backup whose heartbeat lapsed has its marker reaped by whoever
|
|
175
|
+
// noticed, so `NotFound` is the ordinary end of a slow lease, not a
|
|
176
|
+
// fault: there is nothing left to release and nothing left to leak.
|
|
177
|
+
// The sibling release in `internal/ArtifactLocks.ts` classifies the
|
|
178
|
+
// same cause the same way.
|
|
179
|
+
Effect.catch((cause): Effect.Effect<void, PlatformError.PlatformError> =>
|
|
180
|
+
isReason(cause, "NotFound") ? Effect.void : Effect.fail(cause)
|
|
181
|
+
)
|
|
182
|
+
),
|
|
183
|
+
failure
|
|
184
|
+
).pipe(
|
|
185
|
+
Effect.catch((cause) => Effect.logWarning("Artifact backup lease release failed", cause))
|
|
186
|
+
)
|
|
187
|
+
: Effect.void
|
|
188
|
+
)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Runs one sweep deletion only when no live backup lease exists. The marker
|
|
193
|
+
* check and `effect` share the global gate, closing the check/delete race.
|
|
194
|
+
* `None` means a backup deliberately fenced the operation.
|
|
195
|
+
*
|
|
196
|
+
* @category combinators
|
|
197
|
+
* @since 1.0.0-rc.0
|
|
198
|
+
*/
|
|
199
|
+
export const unlessActive = <A, E, R, E2>(
|
|
200
|
+
fs: FileSystem.FileSystem,
|
|
201
|
+
directory: string,
|
|
202
|
+
effect: Effect.Effect<A, E, R>,
|
|
203
|
+
failure: (cause: unknown) => E2
|
|
204
|
+
): Effect.Effect<Option.Option<A>, E | E2, R> =>
|
|
205
|
+
gate(
|
|
206
|
+
fs,
|
|
207
|
+
directory,
|
|
208
|
+
Effect.gen(function*() {
|
|
209
|
+
if (yield* activeMarker(fs, directory, failure)) return Option.none<A>()
|
|
210
|
+
return Option.some(yield* effect)
|
|
211
|
+
}),
|
|
212
|
+
failure
|
|
213
|
+
)
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The content-addressed artifact store: bytes addressed by their own SHA-256
|
|
3
|
+
* digest.
|
|
4
|
+
*
|
|
5
|
+
* It is deliberately *not* the step cache: the step cache maps a step key to a
|
|
6
|
+
* recorded result, while large result bytes live here under their digest. The
|
|
7
|
+
* two tiers remain separate because artifacts must be published before a cache
|
|
8
|
+
* record may reference them. See the package README and
|
|
9
|
+
* {@link https://smithers.sh/docs/concepts/content-addressing | step-key documentation}.
|
|
10
|
+
*
|
|
11
|
+
* This module defines the contract: the address schema, the service interface
|
|
12
|
+
* and tag, and one layer per local implementation. The errors, byte helpers,
|
|
13
|
+
* and implementations are defined in the modules named for them and re-exported
|
|
14
|
+
* here unchanged, so this subpath and the root `ArtifactStore` namespace keep
|
|
15
|
+
* their whole surface.
|
|
16
|
+
*
|
|
17
|
+
* @since 1.0.0-rc.0
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { Sha256 } from "@smthrs/crypto"
|
|
21
|
+
import * as Context from "effect/Context"
|
|
22
|
+
import type * as Crypto from "effect/Crypto"
|
|
23
|
+
import * as Effect from "effect/Effect"
|
|
24
|
+
import * as FileSystem from "effect/FileSystem"
|
|
25
|
+
import * as Layer from "effect/Layer"
|
|
26
|
+
import * as Path from "effect/Path"
|
|
27
|
+
import type { ArtifactCorruption, ArtifactMissing, ArtifactStoreError } from "./ArtifactStoreError.ts"
|
|
28
|
+
import { type FileSystemOptions, makeFileSystem } from "./FileSystemArtifactStore.ts"
|
|
29
|
+
import { makeMemory } from "./MemoryArtifactStore.ts"
|
|
30
|
+
import { makeNoop } from "./NoopArtifactStore.ts"
|
|
31
|
+
|
|
32
|
+
export {
|
|
33
|
+
ArtifactCorruption,
|
|
34
|
+
ArtifactMissing,
|
|
35
|
+
ArtifactStoreError,
|
|
36
|
+
ArtifactStoreErrorCode
|
|
37
|
+
} from "./ArtifactStoreError.ts"
|
|
38
|
+
export { defaultDirectory, type FileSystemOptions, makeFileSystem } from "./FileSystemArtifactStore.ts"
|
|
39
|
+
export { measureBytes } from "./measureBytes.ts"
|
|
40
|
+
export { makeMemory } from "./MemoryArtifactStore.ts"
|
|
41
|
+
export { makeNoop } from "./NoopArtifactStore.ts"
|
|
42
|
+
export { snapshotBytes } from "./snapshotBytes.ts"
|
|
43
|
+
export { validateDigest } from "./validateDigest.ts"
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Schema for a content address: exactly 64 lowercase hexadecimal SHA-256
|
|
47
|
+
* characters, branded by `@smthrs/crypto`. Re-exported so a consumer never has
|
|
48
|
+
* to reach past this package for the address type it stores under.
|
|
49
|
+
*
|
|
50
|
+
* @category schemas
|
|
51
|
+
* @since 1.0.0-rc.0
|
|
52
|
+
* @slop
|
|
53
|
+
*/
|
|
54
|
+
export const Digest = Sha256.Digest
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A content address produced by this store.
|
|
58
|
+
*
|
|
59
|
+
* Read operations accept a plain `string` rather than this brand on purpose: a
|
|
60
|
+
* digest read back out of a durable row is untrusted input, so the store
|
|
61
|
+
* validates it (see {@link ArtifactStoreError}'s `invalid_digest` code) instead
|
|
62
|
+
* of asking every caller to re-brand a persisted column. `put` returns the
|
|
63
|
+
* brand, because it measured the bytes itself.
|
|
64
|
+
*
|
|
65
|
+
* @category models
|
|
66
|
+
* @since 1.0.0-rc.0
|
|
67
|
+
* @slop
|
|
68
|
+
*/
|
|
69
|
+
export type Digest = typeof Sha256.Digest.Type
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Content-addressed blob storage.
|
|
73
|
+
*
|
|
74
|
+
* The contract's ergonomics follow Effect's own `KeyValueStore`
|
|
75
|
+
* (`effect/unstable/persistence/KeyValueStore`): a small set of total
|
|
76
|
+
* operations over one address space, with a single typed error family, so a
|
|
77
|
+
* memory, filesystem, or network implementation is the same shape.
|
|
78
|
+
* `findMissing` is Bazel's `MissingDigestsFinder` — one batched round trip
|
|
79
|
+
* whose result is guaranteed to be a subset of its input — because a
|
|
80
|
+
* per-digest existence probe over a network tier is the wrong shape entirely.
|
|
81
|
+
*
|
|
82
|
+
* @category models
|
|
83
|
+
* @since 1.0.0-rc.0
|
|
84
|
+
* @slop
|
|
85
|
+
*/
|
|
86
|
+
export interface Service {
|
|
87
|
+
/**
|
|
88
|
+
* Stores `bytes` under their own SHA-256 digest and returns that address.
|
|
89
|
+
* Storing the same bytes twice is idempotent.
|
|
90
|
+
*/
|
|
91
|
+
readonly put: (bytes: Uint8Array) => Effect.Effect<Digest, ArtifactStoreError, Crypto.Crypto>
|
|
92
|
+
/**
|
|
93
|
+
* Reads the bytes stored at `digest`, verifying that they still hash to it.
|
|
94
|
+
*/
|
|
95
|
+
readonly get: (
|
|
96
|
+
digest: string
|
|
97
|
+
) => Effect.Effect<Uint8Array, ArtifactMissing | ArtifactCorruption | ArtifactStoreError, Crypto.Crypto>
|
|
98
|
+
/** Whether this tier holds an artifact at `digest`. */
|
|
99
|
+
readonly has: (digest: string) => Effect.Effect<boolean, ArtifactStoreError>
|
|
100
|
+
/**
|
|
101
|
+
* Which of `digests` this tier does not hold. The returned array is
|
|
102
|
+
* guaranteed to be a subset of the input and free of duplicates.
|
|
103
|
+
*/
|
|
104
|
+
readonly findMissing: (
|
|
105
|
+
digests: Iterable<string>
|
|
106
|
+
) => Effect.Effect<Array<string>, ArtifactStoreError>
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Service tag for the content-addressed artifact store.
|
|
111
|
+
*
|
|
112
|
+
* The identity string equals this module's package path, per the house rule
|
|
113
|
+
* that an identity is the defining module path.
|
|
114
|
+
*
|
|
115
|
+
* @category services
|
|
116
|
+
* @since 1.0.0-rc.0
|
|
117
|
+
* @slop
|
|
118
|
+
*/
|
|
119
|
+
export class ArtifactStore extends Context.Service<ArtifactStore, Service>()("@smthrs/artifacts/ArtifactStore") {}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Provides the filesystem-backed artifact store.
|
|
123
|
+
*
|
|
124
|
+
* @category layers
|
|
125
|
+
* @since 1.0.0-rc.0
|
|
126
|
+
* @slop
|
|
127
|
+
*/
|
|
128
|
+
export const layerFileSystem = (
|
|
129
|
+
options: FileSystemOptions = {}
|
|
130
|
+
): Layer.Layer<ArtifactStore, never, FileSystem.FileSystem | Path.Path> =>
|
|
131
|
+
Layer.effect(ArtifactStore)(Effect.gen(function*() {
|
|
132
|
+
return makeFileSystem(yield* FileSystem.FileSystem, yield* Path.Path, options)
|
|
133
|
+
}))
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Provides an in-memory artifact store.
|
|
137
|
+
*
|
|
138
|
+
* @category layers
|
|
139
|
+
* @since 1.0.0-rc.0
|
|
140
|
+
* @slop
|
|
141
|
+
*/
|
|
142
|
+
export const layerMemory: Layer.Layer<ArtifactStore> = Layer.effect(ArtifactStore)(Effect.sync(makeMemory))
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Provides a no-op artifact store.
|
|
146
|
+
*
|
|
147
|
+
* @category layers
|
|
148
|
+
* @since 1.0.0-rc.0
|
|
149
|
+
* @slop
|
|
150
|
+
*/
|
|
151
|
+
export const layerNoop = (overrides: Partial<Service> = {}): Layer.Layer<ArtifactStore> =>
|
|
152
|
+
Layer.succeed(ArtifactStore)(makeNoop(overrides))
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The artifact store's typed failures: a refusal of the store itself, the
|
|
3
|
+
* typed miss, and a digest mismatch, with the stable codes a refusal carries.
|
|
4
|
+
*
|
|
5
|
+
* @since 1.0.0-rc.0
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Sha256 } from "@smthrs/crypto"
|
|
9
|
+
import * as Schema from "effect/Schema"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Stable error codes returned by artifact store operations.
|
|
13
|
+
*
|
|
14
|
+
* @category models
|
|
15
|
+
* @since 1.0.0-rc.0
|
|
16
|
+
* @slop
|
|
17
|
+
*/
|
|
18
|
+
export const ArtifactStoreErrorCode = Schema.Literals([
|
|
19
|
+
"digest_failed",
|
|
20
|
+
"invalid_configuration",
|
|
21
|
+
"invalid_digest",
|
|
22
|
+
"unavailable",
|
|
23
|
+
"transport_failed"
|
|
24
|
+
])
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Stable error codes returned by artifact store operations.
|
|
28
|
+
*
|
|
29
|
+
* @category models
|
|
30
|
+
* @since 1.0.0-rc.0
|
|
31
|
+
* @slop
|
|
32
|
+
*/
|
|
33
|
+
export type ArtifactStoreErrorCode = typeof ArtifactStoreErrorCode.Type
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A typed failure of the store itself: the host or crypto provider refused an
|
|
37
|
+
* operation, the remote tier refused a request, or the caller supplied invalid
|
|
38
|
+
* configuration or an invalid content address.
|
|
39
|
+
*
|
|
40
|
+
* Distinct from {@link ArtifactMissing} and {@link ArtifactCorruption} on
|
|
41
|
+
* purpose. A miss is an ordinary, expected outcome that a second tier may
|
|
42
|
+
* still satisfy; corruption is an integrity violation of the store's strongest
|
|
43
|
+
* invariant. `invalid_configuration` and `invalid_digest` are permanent;
|
|
44
|
+
* retryability of host, crypto, and transport failures depends on the cause.
|
|
45
|
+
*
|
|
46
|
+
* @category errors
|
|
47
|
+
* @since 1.0.0-rc.0
|
|
48
|
+
* @slop
|
|
49
|
+
*/
|
|
50
|
+
export class ArtifactStoreError extends Schema.TaggedError<ArtifactStoreError>()(
|
|
51
|
+
"@smthrs/artifacts/ArtifactStoreError",
|
|
52
|
+
{
|
|
53
|
+
code: ArtifactStoreErrorCode,
|
|
54
|
+
message: Schema.String,
|
|
55
|
+
cause: Schema.optional(Schema.Unknown)
|
|
56
|
+
}
|
|
57
|
+
) {}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The typed miss: this tier holds no bytes at the requested address.
|
|
61
|
+
*
|
|
62
|
+
* A miss is not a failure of the store — it is the answer a read-through
|
|
63
|
+
* composition is built to act on, so it is a distinct tag rather than an
|
|
64
|
+
* `unavailable` code that a caller would have to string-match.
|
|
65
|
+
*
|
|
66
|
+
* @category errors
|
|
67
|
+
* @since 1.0.0-rc.0
|
|
68
|
+
* @slop
|
|
69
|
+
*/
|
|
70
|
+
export class ArtifactMissing extends Schema.TaggedError<ArtifactMissing>()(
|
|
71
|
+
"@smthrs/artifacts/ArtifactMissing",
|
|
72
|
+
{
|
|
73
|
+
code: Schema.Literal("artifact_missing"),
|
|
74
|
+
digest: Sha256.Digest
|
|
75
|
+
}
|
|
76
|
+
) {}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Bytes stored at a content address no longer hash to it.
|
|
80
|
+
*
|
|
81
|
+
* Every read is digest-verified, so a truncated blob left by a crashing writer
|
|
82
|
+
* or by disk corruption is refused rather than handed back as if it were the
|
|
83
|
+
* recorded artifact.
|
|
84
|
+
*
|
|
85
|
+
* @category errors
|
|
86
|
+
* @since 1.0.0-rc.0
|
|
87
|
+
* @slop
|
|
88
|
+
*/
|
|
89
|
+
export class ArtifactCorruption extends Schema.TaggedError<ArtifactCorruption>()(
|
|
90
|
+
"@smthrs/artifacts/ArtifactCorruption",
|
|
91
|
+
{
|
|
92
|
+
code: Schema.Literal("artifact_corruption"),
|
|
93
|
+
recordedDigest: Sha256.Digest,
|
|
94
|
+
measuredDigest: Sha256.Digest
|
|
95
|
+
}
|
|
96
|
+
) {}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standard metric definitions for content-addressed artifact storage.
|
|
3
|
+
*
|
|
4
|
+
* This module only defines the metric handles, following the shape of Effect's
|
|
5
|
+
* `ClusterMetrics`. No exporter ships in this package; provide one — for
|
|
6
|
+
* example `@smthrs/observability` — and these counters appear in it.
|
|
7
|
+
*
|
|
8
|
+
* Only the local `ArtifactStore` implementations, filesystem and memory, update
|
|
9
|
+
* them. `RemoteArtifacts` is deliberately uninstrumented, and the counters carry
|
|
10
|
+
* no tier attribute, so read them as *local artifact store traffic* rather than
|
|
11
|
+
* as artifact operations:
|
|
12
|
+
*
|
|
13
|
+
* - a `CombinedArtifacts` read the local tier serves counts one get;
|
|
14
|
+
* - a read the shared tier serves counts NO get, because no local store
|
|
15
|
+
* answered it;
|
|
16
|
+
* - the write-back that materializes such a read counts a put, indistinguishable
|
|
17
|
+
* from a producer publishing new bytes.
|
|
18
|
+
*
|
|
19
|
+
* Attributing operations per tier needs the tier in the metric, which would
|
|
20
|
+
* change the published counter shape; until then this is what the numbers mean.
|
|
21
|
+
*
|
|
22
|
+
* The one shared-tier signal is {@link remoteFailure}: a `CombinedArtifacts`
|
|
23
|
+
* upload or write-back that was refused, or abandoned at its deadline.
|
|
24
|
+
*
|
|
25
|
+
* @since 1.0.0-rc.0
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import * as Metric from "effect/Metric"
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Counter over successful artifact puts. A put that deduplicated against an
|
|
32
|
+
* existing verified blob still counts: the caller stored bytes and received
|
|
33
|
+
* an address either way.
|
|
34
|
+
*
|
|
35
|
+
* @category metrics
|
|
36
|
+
* @since 1.0.0-rc.0
|
|
37
|
+
* @slop
|
|
38
|
+
*/
|
|
39
|
+
export const puts = Metric.counter("flows_artifact_puts", {
|
|
40
|
+
description: "Successful artifact puts, including deduplicated ones"
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Counter over successful artifact gets. Missing and corrupt reads fail with
|
|
45
|
+
* their typed errors and are deliberately not counted here; they are error
|
|
46
|
+
* evidence, not throughput.
|
|
47
|
+
*
|
|
48
|
+
* @category metrics
|
|
49
|
+
* @since 1.0.0-rc.0
|
|
50
|
+
* @slop
|
|
51
|
+
*/
|
|
52
|
+
export const gets = Metric.counter("flows_artifact_gets", {
|
|
53
|
+
description: "Successful artifact gets"
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Counter over opportunistic `CombinedArtifacts` transfers that failed or hit
|
|
58
|
+
* their deadline and were dropped: `put`'s upload to the shared tier and
|
|
59
|
+
* `get`'s write-back into the local tier.
|
|
60
|
+
*
|
|
61
|
+
* Read the attributed views on {@link remoteFailure}: every update carries an
|
|
62
|
+
* `operation` attribute, so this bare handle aggregates nothing and always
|
|
63
|
+
* reads zero.
|
|
64
|
+
*
|
|
65
|
+
* @category metrics
|
|
66
|
+
* @since 1.0.0-rc.1
|
|
67
|
+
*/
|
|
68
|
+
export const remoteFailures = Metric.counter("flows_artifact_remote_failures", {
|
|
69
|
+
description: "Dropped combined-artifact transfers by operation"
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* `remoteFailures` views keyed by the dropped transfer.
|
|
74
|
+
*
|
|
75
|
+
* @category metrics
|
|
76
|
+
* @since 1.0.0-rc.1
|
|
77
|
+
*/
|
|
78
|
+
export const remoteFailure: {
|
|
79
|
+
readonly [Operation in "put" | "write_back"]: Metric.Metric<number, Metric.CounterState<number>>
|
|
80
|
+
} = {
|
|
81
|
+
put: Metric.withAttributes(remoteFailures, { operation: "put" }),
|
|
82
|
+
write_back: Metric.withAttributes(remoteFailures, { operation: "write_back" })
|
|
83
|
+
}
|