@vzn/vx-reapi 0.0.0 → 0.0.485

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/src/cache.ts ADDED
@@ -0,0 +1,199 @@
1
+ // vx's `RemoteCacheLayer` (has / get / put) over REAPI's ActionCache + CAS.
2
+ //
3
+ // The mapping, and why it needs the AC at all: a CAS digest is the sha256 of
4
+ // the CONTENT, so it cannot be derived from a vx cache key before the bytes
5
+ // exist — `has(key)` could never answer. The ActionCache supplies exactly the
6
+ // missing indirection: a SYNTHETIC action digest derived from the vx key
7
+ // addresses an ActionResult whose single output file points at the artifact
8
+ // blob in CAS. This is the convention Gradle and sccache use to reuse an AC
9
+ // as a general key/value cache.
10
+
11
+ import { createHash } from 'node:crypto'
12
+ import { type ActionResult, type Digest, ReapiClient, type ReapiOptions } from './wire.js'
13
+
14
+ /** The artifact's `output_files` path. Constant: the AC entry holds exactly one. */
15
+ const ARTIFACT_PATH = 'vx-artifact.tar.zst'
16
+
17
+ /**
18
+ * Namespaced so a vx key can never collide with a real Bazel action digest
19
+ * on a server shared with Bazel itself. The version prefix is what lets a
20
+ * future change of this scheme miss cleanly rather than read a stale entry
21
+ * under the same address.
22
+ */
23
+ export function actionDigestFor(vxKey: string): Digest {
24
+ const payload = Buffer.from(`vx-reapi-v1\0${vxKey}`, 'utf8')
25
+ return { hash: createHash('sha256').update(payload).digest('hex'), size_bytes: payload.length }
26
+ }
27
+
28
+ /**
29
+ * The EXECUTION record's address for a vx key — distinct from the artifact
30
+ * mapping above. Where `actionDigestFor` points at one tarred artifact blob
31
+ * (the vx cache entry), this points at an ActionResult listing the task's
32
+ * outputs FILE BY FILE with workspace-relative paths: what a dependent's
33
+ * input tree grafts by reference, so upstream outputs flow worker→CAS→worker
34
+ * without ever landing on the submitter's disk.
35
+ */
36
+ export function execDigestFor(vxKey: string): Digest {
37
+ const payload = Buffer.from(`vx-reapi-exec-v1\0${vxKey}`, 'utf8')
38
+ return { hash: createHash('sha256').update(payload).digest('hex'), size_bytes: payload.length }
39
+ }
40
+
41
+ export function digestOf(body: Uint8Array): Digest {
42
+ return { hash: createHash('sha256').update(body).digest('hex'), size_bytes: body.length }
43
+ }
44
+
45
+ /** `digestOf` in one pass over a Blob's stream, so a file-backed artifact is never held. */
46
+ async function streamedDigestOf(body: Blob): Promise<Digest> {
47
+ const hash = createHash('sha256')
48
+ for await (const chunk of body.stream()) hash.update(chunk)
49
+ return { hash: hash.digest('hex'), size_bytes: body.size }
50
+ }
51
+
52
+ /**
53
+ * `durationMs` rides the AC entry so a restored hit can report what the task
54
+ * originally cost. REAPI models a build action, not a cache entry, so it has
55
+ * no field for it — this uses `stdout_raw`, the one place whose BYTES a server
56
+ * must round-trip verbatim and which nothing else in this mapping needs.
57
+ * bazel-remote normalises inline stdout into a CAS blob and returns
58
+ * `stdout_digest` instead, so the read path accepts either form.
59
+ */
60
+ function encodeDuration(durationMs: number): Uint8Array {
61
+ return new TextEncoder().encode(JSON.stringify({ durationMs }))
62
+ }
63
+
64
+ function decodeDuration(raw: Uint8Array | undefined): number | undefined {
65
+ if (raw === undefined || raw.length === 0) return undefined
66
+ try {
67
+ const parsed = JSON.parse(new TextDecoder().decode(raw)) as { durationMs?: number }
68
+ return typeof parsed.durationMs === 'number' ? parsed.durationMs : undefined
69
+ } catch {
70
+ return undefined
71
+ }
72
+ }
73
+
74
+ /** Artifacts up to this size are batch-uploaded without a FindMissingBlobs probe. */
75
+ const SMALL_PUT_BYTES = 256 * 1024
76
+
77
+ export class ReapiRemoteCache {
78
+ private readonly client: ReapiClient
79
+ /** Named in core's degrade line: a gRPC status carries no server. */
80
+ readonly endpoint: string
81
+
82
+ constructor(opts: ReapiOptions) {
83
+ this.client = new ReapiClient(opts)
84
+ this.endpoint = opts.endpoint
85
+ }
86
+
87
+ /**
88
+ * Existence probe — no artifact bytes, but it does confirm the artifact
89
+ * still EXISTS rather than trusting the entry that names it.
90
+ *
91
+ * Servers disagree here, measured both ways: bazel-remote validates an
92
+ * ActionResult's referenced blobs and hides a dangling entry, NativeLink
93
+ * serves it. Without the second call, `has` on a NativeLink-style server
94
+ * promises a hit that `get` then cannot honour — and the only consumer of
95
+ * `has` is the `--dry` / `--graph` plan, whose entire job is predicting
96
+ * hit vs miss. Costs one extra round trip, and only for a PREDICTED HIT:
97
+ * a miss still answers in one call.
98
+ */
99
+ async has(hash: string): Promise<boolean> {
100
+ const result = await this.client.getActionResult(actionDigestFor(hash))
101
+ if (result === null) return false
102
+ const file = result.output_files?.find((f) => f.path === ARTIFACT_PATH)
103
+ if (file === undefined) return false
104
+ return (await this.client.findMissingBlobs([file.digest])).length === 0
105
+ }
106
+
107
+ /**
108
+ * The artifact streams from the ByteStream read into core's ingest. The
109
+ * duration (which a server may have moved into CAS) is read alongside the
110
+ * artifact's open, not before it: one round trip less per remote hit
111
+ * (130 → 99 ms at 15 ms one-way against bazel-remote, F-24). The open
112
+ * waits only for its first message, so the call is not left paused.
113
+ */
114
+ async get(hash: string): Promise<{ body: Response; durationMs: number | undefined } | null> {
115
+ // Inline: a server that honours it answers a small hit in this one round
116
+ // trip, duration and artifact both (bazel-remote inlines up to ~1 MiB);
117
+ // one that declines costs nothing (F-25).
118
+ const result = await this.client.getActionResult(actionDigestFor(hash), {
119
+ stdout: true,
120
+ files: [ARTIFACT_PATH],
121
+ })
122
+ if (result === null) return null
123
+ const file = result.output_files?.find((f) => f.path === ARTIFACT_PATH)
124
+ // An AC entry whose blob has been evicted from CAS is a MISS, not an
125
+ // error: the two stores are pruned independently and a dangling entry is
126
+ // an ordinary state, not a fault.
127
+ if (file === undefined) return null
128
+ // Inline bytes are held to the digest they ride with, as fetched ones
129
+ // are: a mismatch streams the blob instead (F-8's rule).
130
+ const inline =
131
+ file.contents !== undefined &&
132
+ file.contents.length > 0 &&
133
+ digestOf(file.contents).hash === file.digest.hash
134
+ ? file.contents
135
+ : undefined
136
+ const [durationMs, body] = await Promise.all([
137
+ this.durationOf(result),
138
+ inline === undefined ? this.client.readBlobStream(file.digest) : inline,
139
+ ])
140
+ if (body === null) return null
141
+ return { body: new Response(body), durationMs }
142
+ }
143
+
144
+ private async durationOf(result: ActionResult): Promise<number | undefined> {
145
+ if (result.stdout_raw !== undefined && result.stdout_raw.length > 0) {
146
+ return decodeDuration(result.stdout_raw)
147
+ }
148
+ // The server normalised our inline bytes into CAS (bazel-remote does).
149
+ // An absent digest arrives as `null` on this path (proto-loader's
150
+ // message default, as `this_readStream` in executor.ts records).
151
+ // The duration is metadata: a read of it that fails (retries spent, a
152
+ // blob failing its digest) leaves it unknown, where it made core drop a
153
+ // valid hit as a miss (F-21).
154
+ if ((result.stdout_digest?.size_bytes ?? 0) > 0) {
155
+ const raw = await this.client.readBlob(result.stdout_digest!).catch(() => null)
156
+ return decodeDuration(raw ?? undefined)
157
+ }
158
+ return undefined
159
+ }
160
+
161
+ /**
162
+ * A large artifact takes two passes over `body`, never one in memory: the
163
+ * digest first (the CAS address must be known before the server is asked),
164
+ * then the upload from a second read of the stream.
165
+ */
166
+ async put(hash: string, body: Blob, meta: { durationMs: number }): Promise<void> {
167
+ let digest: Digest
168
+ if (body.size <= SMALL_PUT_BYTES) {
169
+ // A small artifact is sent in one batch without asking first: the
170
+ // probe was a round trip of its own to save an upload no larger than
171
+ // it (102 → 71 ms per save at 15 ms one-way, F-26). Content-addressed,
172
+ // so a re-send is harmless.
173
+ // Read once, and those bytes hashed and sent: a second writer of the
174
+ // key renames its artifact over the file, and two reads sent its bytes
175
+ // under this one's digest (F-50).
176
+ // A server whose batch limit is smaller refuses it; stream instead.
177
+ const data = await body.bytes()
178
+ digest = digestOf(data)
179
+ await this.client
180
+ .batchUpdateBlobs([{ digest, data }])
181
+ .catch(() => this.client.writeBlob(digest, data))
182
+ } else {
183
+ digest = await streamedDigestOf(body)
184
+ // Upload only what the server lacks: for a large artifact the probe is
185
+ // cheap next to the bytes it can skip.
186
+ const missing = await this.client.findMissingBlobs([digest])
187
+ if (missing.length > 0) await this.client.writeBlob(digest, body)
188
+ }
189
+ await this.client.updateActionResult(actionDigestFor(hash), {
190
+ exit_code: 0,
191
+ output_files: [{ path: ARTIFACT_PATH, digest, is_executable: false }],
192
+ stdout_raw: encodeDuration(meta.durationMs),
193
+ })
194
+ }
195
+
196
+ close(): void {
197
+ this.client.close()
198
+ }
199
+ }