@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/LICENSE +21 -0
- package/README.md +502 -0
- package/index.ts +4 -0
- package/package.json +45 -6
- package/protos/build/bazel/remote/execution/v2/remote_execution.proto +2516 -0
- package/protos/build/bazel/semver/semver.proto +41 -0
- package/protos/google/api/annotations.proto +31 -0
- package/protos/google/api/client.proto +598 -0
- package/protos/google/api/field_behavior.proto +104 -0
- package/protos/google/api/http.proto +370 -0
- package/protos/google/api/launch_stage.proto +72 -0
- package/protos/google/bytestream/bytestream.proto +178 -0
- package/protos/google/longrunning/operations.proto +265 -0
- package/protos/google/rpc/code.proto +186 -0
- package/protos/google/rpc/status.proto +48 -0
- package/src/cache.ts +199 -0
- package/src/executor.ts +1805 -0
- package/src/index.ts +277 -0
- package/src/merkle.ts +867 -0
- package/src/wire.ts +1658 -0
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
|
+
}
|