@sanity/ailf 3.0.0 → 3.1.0
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/dist/_vendor/ailf-core/artifact-capture/association.d.ts +37 -0
- package/dist/_vendor/ailf-core/artifact-capture/association.js +19 -0
- package/dist/_vendor/ailf-core/index.d.ts +1 -1
- package/dist/_vendor/ailf-core/index.js +1 -1
- package/dist/_vendor/ailf-core/ports/context.d.ts +8 -0
- package/dist/_vendor/ailf-core/ports/index.d.ts +2 -0
- package/dist/_vendor/ailf-core/ports/index.js +1 -0
- package/dist/_vendor/ailf-core/ports/progress-reporter.d.ts +74 -0
- package/dist/_vendor/ailf-core/ports/progress-reporter.js +26 -0
- package/dist/_vendor/ailf-core/services/slim-report-summary.js +1 -16
- package/dist/adapters/progress/console-progress-reporter.d.ts +35 -0
- package/dist/adapters/progress/console-progress-reporter.js +110 -0
- package/dist/artifact-capture/api-gateway-artifact-writer.d.ts +8 -1
- package/dist/artifact-capture/api-gateway-artifact-writer.js +79 -42
- package/dist/artifact-capture/batching-api-gateway-artifact-writer.d.ts +108 -0
- package/dist/artifact-capture/batching-api-gateway-artifact-writer.js +492 -0
- package/dist/artifact-capture/fanout-artifact-writer.d.ts +14 -2
- package/dist/artifact-capture/fanout-artifact-writer.js +25 -4
- package/dist/artifact-capture/gcs-artifact-writer.d.ts +27 -1
- package/dist/artifact-capture/gcs-artifact-writer.js +168 -38
- package/dist/artifact-capture/instrumented-artifact-writer.d.ts +32 -0
- package/dist/artifact-capture/instrumented-artifact-writer.js +151 -0
- package/dist/artifact-capture/local-fs-artifact-writer.d.ts +8 -1
- package/dist/artifact-capture/local-fs-artifact-writer.js +23 -4
- package/dist/artifact-capture/parallel-emit.d.ts +43 -0
- package/dist/artifact-capture/parallel-emit.js +84 -0
- package/dist/artifact-capture/upload-metrics.d.ts +62 -0
- package/dist/artifact-capture/upload-metrics.js +125 -0
- package/dist/composition-root.d.ts +2 -2
- package/dist/composition-root.js +97 -11
- package/dist/orchestration/pipeline-orchestrator.js +97 -1
- package/dist/orchestration/steps/calculate-scores-step.js +1 -1
- package/dist/orchestration/steps/finalize-run-step.js +33 -2
- package/dist/pipeline/emit-eval-results.js +29 -11
- package/dist/pipeline/upload-test-outputs.d.ts +12 -5
- package/dist/pipeline/upload-test-outputs.js +27 -10
- package/package.json +3 -3
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BatchingApiGatewayArtifactWriter — W0056 prototype B.
|
|
3
|
+
*
|
|
4
|
+
* Drop-in replacement for `ApiGatewayArtifactWriter` that coalesces `emit()`
|
|
5
|
+
* calls into batches, signs many URLs per Vercel round trip via
|
|
6
|
+
* `POST /v1/runs/:runId/artifacts/batch/upload-urls` (W0052), and PUTs to GCS
|
|
7
|
+
* with bounded concurrency.
|
|
8
|
+
*
|
|
9
|
+
* Why: the baseline in `docs/design-docs/artifact-upload-throughput.md` showed
|
|
10
|
+
* that parallel single-URL signs trigger `429 Too Many Requests` on the Vercel
|
|
11
|
+
* signing function. Batch signing eliminates one Vercel round trip per
|
|
12
|
+
* artifact and makes client-side parallelism safe on the API Gateway path.
|
|
13
|
+
*
|
|
14
|
+
* Flushing:
|
|
15
|
+
* - An `emit()` call pushes a `PendingEmit` onto a queue and returns a
|
|
16
|
+
* deferred Promise. The queue self-flushes via `queueMicrotask` when
|
|
17
|
+
* the first entry lands, and again whenever the buffer reaches
|
|
18
|
+
* `batchSize`.
|
|
19
|
+
* - `writeManifest(...)` awaits any in-flight flush and drains the queue
|
|
20
|
+
* before writing the manifest through a single-URL sign (the manifest
|
|
21
|
+
* isn't in the registry's per-entry scheme).
|
|
22
|
+
*
|
|
23
|
+
* NDJSON streaming for `traces` is still not implemented here — the
|
|
24
|
+
* upstream `ApiGatewayArtifactWriter` throws `NotImplementedError` and so
|
|
25
|
+
* does this writer. Traces flow through the GCS-direct writer when ADC
|
|
26
|
+
* credentials are present.
|
|
27
|
+
*/
|
|
28
|
+
import { type ArtifactEntry, type ArtifactRef, type ArtifactType, type ArtifactWriter, type AssociationValues, type RunId, type RunManifest } from "../_vendor/ailf-core/index.d.ts";
|
|
29
|
+
import { type UploadMetricsSink } from "./upload-metrics.js";
|
|
30
|
+
export interface BatchingApiGatewayArtifactWriterOptions {
|
|
31
|
+
/** Base URL of the API gateway (e.g., "https://ailf-api.sanity.build"). */
|
|
32
|
+
apiBaseUrl: string;
|
|
33
|
+
/** AILF API key with the `artifact:write` scope. */
|
|
34
|
+
apiKey: string;
|
|
35
|
+
/** GCS bucket name — included in the returned ArtifactRef. */
|
|
36
|
+
bucket: string;
|
|
37
|
+
/**
|
|
38
|
+
* Maximum entries per `/batch/upload-urls` request. The existing Vercel
|
|
39
|
+
* route signs URLs in parallel inside a single Function invocation, so
|
|
40
|
+
* the cap protects the Function's CPU budget. Defaults to 256 (see
|
|
41
|
+
* `DEFAULT_BATCH_SIZE` for the rationale and the W0058 measurement
|
|
42
|
+
* that motivated the bump from 32).
|
|
43
|
+
*/
|
|
44
|
+
batchSize?: number;
|
|
45
|
+
/** Bounded concurrency for the PUT fan-out after a batch sign resolves. */
|
|
46
|
+
putConcurrency?: number;
|
|
47
|
+
/** Optional metrics sink; defaults to no-op. */
|
|
48
|
+
metrics?: UploadMetricsSink;
|
|
49
|
+
}
|
|
50
|
+
export declare class BatchingApiGatewayArtifactWriter implements ArtifactWriter {
|
|
51
|
+
private readonly options;
|
|
52
|
+
private readonly pending;
|
|
53
|
+
private flushing;
|
|
54
|
+
private microtaskScheduled;
|
|
55
|
+
constructor(options: BatchingApiGatewayArtifactWriterOptions);
|
|
56
|
+
emit<T extends ArtifactType>(type: T, association: AssociationValues, payload: unknown): Promise<ArtifactRef | null>;
|
|
57
|
+
appendNdjson(): Promise<ArtifactRef | null>;
|
|
58
|
+
writeManifest(runId: RunId, manifest: RunManifest): Promise<ArtifactRef | null>;
|
|
59
|
+
/** @deprecated — routes through `emit()` for backward compat. */
|
|
60
|
+
writeBulk(type: ArtifactType, runId: RunId, data: unknown): Promise<ArtifactRef | null>;
|
|
61
|
+
/**
|
|
62
|
+
* @deprecated The legacy `writePerEntry` surface requires back-deriving an
|
|
63
|
+
* `AssociationValues` from a wire entryKey, which the registry no longer
|
|
64
|
+
* exposes. Producers on this writer must use `emit()`. All current producers
|
|
65
|
+
* have already migrated.
|
|
66
|
+
*/
|
|
67
|
+
writePerEntry(type: ArtifactType, _runId: RunId, _entries: readonly ArtifactEntry[]): Promise<ArtifactRef | null>;
|
|
68
|
+
private scheduleFlush;
|
|
69
|
+
/**
|
|
70
|
+
* Drain the pending queue with overlapped sign + PUT.
|
|
71
|
+
*
|
|
72
|
+
* Pre-pipeline behaviour was `sign(N) → put(N) → sign(N+1) → put(N+1)`.
|
|
73
|
+
* After this change the order becomes
|
|
74
|
+
* `sign(N) → (put(N) || sign(N+1)) → put(N+1)`:
|
|
75
|
+
*
|
|
76
|
+
* - We wait for stage N's sign to resolve, then launch stage N+1's sign
|
|
77
|
+
* immediately (before awaiting stage N's PUT phase). This is the moment
|
|
78
|
+
* emits that arrived while stage N's sign was in flight first become
|
|
79
|
+
* visible to `startNextStage`.
|
|
80
|
+
* - Stage N's PUT fan-out then runs concurrently with stage N+1's sign
|
|
81
|
+
* request, removing the per-batch sign-RTT pre-amble that made the
|
|
82
|
+
* W0056 prototype ~10 % slower than the (unsafe) single-URL parallel
|
|
83
|
+
* variant.
|
|
84
|
+
*
|
|
85
|
+
* Invariants preserved from the pre-pipeline implementation:
|
|
86
|
+
* - Emits resolve in batch-commit order within a stage.
|
|
87
|
+
* - All entries in a `Stage` share one `runId` (enforced by `startNextStage`).
|
|
88
|
+
* - A sign failure fails only that stage's group; later stages proceed.
|
|
89
|
+
* - run() keeps looping until `pending` is empty AND no prefetched stage
|
|
90
|
+
* remains — so emits that arrive during a PUT phase are picked up in the
|
|
91
|
+
* next iteration.
|
|
92
|
+
*/
|
|
93
|
+
private drain;
|
|
94
|
+
/**
|
|
95
|
+
* Pop the next same-runId batch (up to `batchSize` entries) off the queue
|
|
96
|
+
* and launch its batch-sign request immediately. Returns a `Stage` whose
|
|
97
|
+
* `signPromise` is already in flight, so the caller can start the sign for
|
|
98
|
+
* the *next* batch while this one's PUTs are still pending.
|
|
99
|
+
*
|
|
100
|
+
* Returning `null` means the queue is empty.
|
|
101
|
+
*/
|
|
102
|
+
private startNextStage;
|
|
103
|
+
private buildSignBody;
|
|
104
|
+
private batchSign;
|
|
105
|
+
private putToSignedUrl;
|
|
106
|
+
/** Single-URL sign + PUT — used for the manifest. */
|
|
107
|
+
private writeSingle;
|
|
108
|
+
}
|
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BatchingApiGatewayArtifactWriter — W0056 prototype B.
|
|
3
|
+
*
|
|
4
|
+
* Drop-in replacement for `ApiGatewayArtifactWriter` that coalesces `emit()`
|
|
5
|
+
* calls into batches, signs many URLs per Vercel round trip via
|
|
6
|
+
* `POST /v1/runs/:runId/artifacts/batch/upload-urls` (W0052), and PUTs to GCS
|
|
7
|
+
* with bounded concurrency.
|
|
8
|
+
*
|
|
9
|
+
* Why: the baseline in `docs/design-docs/artifact-upload-throughput.md` showed
|
|
10
|
+
* that parallel single-URL signs trigger `429 Too Many Requests` on the Vercel
|
|
11
|
+
* signing function. Batch signing eliminates one Vercel round trip per
|
|
12
|
+
* artifact and makes client-side parallelism safe on the API Gateway path.
|
|
13
|
+
*
|
|
14
|
+
* Flushing:
|
|
15
|
+
* - An `emit()` call pushes a `PendingEmit` onto a queue and returns a
|
|
16
|
+
* deferred Promise. The queue self-flushes via `queueMicrotask` when
|
|
17
|
+
* the first entry lands, and again whenever the buffer reaches
|
|
18
|
+
* `batchSize`.
|
|
19
|
+
* - `writeManifest(...)` awaits any in-flight flush and drains the queue
|
|
20
|
+
* before writing the manifest through a single-URL sign (the manifest
|
|
21
|
+
* isn't in the registry's per-entry scheme).
|
|
22
|
+
*
|
|
23
|
+
* NDJSON streaming for `traces` is still not implemented here — the
|
|
24
|
+
* upstream `ApiGatewayArtifactWriter` throws `NotImplementedError` and so
|
|
25
|
+
* does this writer. Traces flow through the GCS-direct writer when ADC
|
|
26
|
+
* credentials are present.
|
|
27
|
+
*/
|
|
28
|
+
import { ARTIFACT_REGISTRY, BULK_ENTRY_KEY, NotImplementedError, } from "../_vendor/ailf-core/index.js";
|
|
29
|
+
import { NO_OP_UPLOAD_METRICS, } from "./upload-metrics.js";
|
|
30
|
+
/**
|
|
31
|
+
* How many entries to bundle into a single `/batch/upload-urls` request.
|
|
32
|
+
*
|
|
33
|
+
* Raised from 32 to 256 after W0058 measurement: full-literacy producers
|
|
34
|
+
* emit ~1,477 artifacts per run. With a tight batch size, the sign-RTT
|
|
35
|
+
* count scales roughly as `artifactCount / batchSize`. At 32 we issued
|
|
36
|
+
* ~420 sign requests and tripped Vercel's per-origin rate limits (71 %
|
|
37
|
+
* sign-failure rate). At 256 we issue ~6 sign requests, well under any
|
|
38
|
+
* per-origin burst cap. The gateway's batch route signs URLs locally
|
|
39
|
+
* with `file.getSignedUrl` in `Promise.all`, so CPU scales sub-linearly
|
|
40
|
+
* in request size — 256 entries fit comfortably in the default Vercel
|
|
41
|
+
* function budget.
|
|
42
|
+
*/
|
|
43
|
+
const DEFAULT_BATCH_SIZE = 256;
|
|
44
|
+
const DEFAULT_PUT_CONCURRENCY = 8;
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Implementation
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
export class BatchingApiGatewayArtifactWriter {
|
|
49
|
+
options;
|
|
50
|
+
pending = [];
|
|
51
|
+
flushing = null;
|
|
52
|
+
microtaskScheduled = false;
|
|
53
|
+
constructor(options) {
|
|
54
|
+
this.options = {
|
|
55
|
+
apiBaseUrl: options.apiBaseUrl,
|
|
56
|
+
apiKey: options.apiKey,
|
|
57
|
+
bucket: options.bucket,
|
|
58
|
+
batchSize: options.batchSize ?? DEFAULT_BATCH_SIZE,
|
|
59
|
+
putConcurrency: options.putConcurrency ?? DEFAULT_PUT_CONCURRENCY,
|
|
60
|
+
metrics: options.metrics ?? NO_OP_UPLOAD_METRICS,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
// ---- ArtifactWriter surface --------------------------------------------
|
|
64
|
+
async emit(type, association, payload) {
|
|
65
|
+
const descriptor = ARTIFACT_REGISTRY[type];
|
|
66
|
+
const runId = association.run;
|
|
67
|
+
if (!runId) {
|
|
68
|
+
console.warn(` ⚠️ emit("${type}"): association.run is required, skipping`);
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
const entryKey = descriptor.layout === "bulk"
|
|
72
|
+
? BULK_ENTRY_KEY
|
|
73
|
+
: descriptor.formatEntryKey(association);
|
|
74
|
+
return new Promise((resolve) => {
|
|
75
|
+
this.pending.push({
|
|
76
|
+
type,
|
|
77
|
+
runId,
|
|
78
|
+
entryKey,
|
|
79
|
+
payload,
|
|
80
|
+
association,
|
|
81
|
+
resolve,
|
|
82
|
+
});
|
|
83
|
+
this.scheduleFlush();
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
async appendNdjson() {
|
|
87
|
+
throw new NotImplementedError("BatchingApiGatewayArtifactWriter.appendNdjson is not supported. " +
|
|
88
|
+
"NDJSON streaming for traces flows through GcsArtifactWriter when " +
|
|
89
|
+
"ADC credentials are present.");
|
|
90
|
+
}
|
|
91
|
+
async writeManifest(runId, manifest) {
|
|
92
|
+
await this.drain();
|
|
93
|
+
return this.writeSingle(`/v1/runs/${encodeURIComponent(runId)}/artifacts/upload-url`, manifest, "manifest");
|
|
94
|
+
}
|
|
95
|
+
/** @deprecated — routes through `emit()` for backward compat. */
|
|
96
|
+
async writeBulk(type, runId, data) {
|
|
97
|
+
return this.emit(type, { run: runId }, data);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* @deprecated The legacy `writePerEntry` surface requires back-deriving an
|
|
101
|
+
* `AssociationValues` from a wire entryKey, which the registry no longer
|
|
102
|
+
* exposes. Producers on this writer must use `emit()`. All current producers
|
|
103
|
+
* have already migrated.
|
|
104
|
+
*/
|
|
105
|
+
async writePerEntry(type, _runId, _entries) {
|
|
106
|
+
console.warn(` ⚠️ BatchingApiGatewayArtifactWriter.writePerEntry("${type}"): ` +
|
|
107
|
+
`deprecated path unsupported on batching writer — callers must use emit()`);
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
// ---- Internals ---------------------------------------------------------
|
|
111
|
+
scheduleFlush() {
|
|
112
|
+
if (this.pending.length >= this.options.batchSize) {
|
|
113
|
+
// Size threshold hit — kick off a drain immediately.
|
|
114
|
+
void this.drain();
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (this.microtaskScheduled)
|
|
118
|
+
return;
|
|
119
|
+
this.microtaskScheduled = true;
|
|
120
|
+
queueMicrotask(() => {
|
|
121
|
+
this.microtaskScheduled = false;
|
|
122
|
+
void this.drain();
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Drain the pending queue with overlapped sign + PUT.
|
|
127
|
+
*
|
|
128
|
+
* Pre-pipeline behaviour was `sign(N) → put(N) → sign(N+1) → put(N+1)`.
|
|
129
|
+
* After this change the order becomes
|
|
130
|
+
* `sign(N) → (put(N) || sign(N+1)) → put(N+1)`:
|
|
131
|
+
*
|
|
132
|
+
* - We wait for stage N's sign to resolve, then launch stage N+1's sign
|
|
133
|
+
* immediately (before awaiting stage N's PUT phase). This is the moment
|
|
134
|
+
* emits that arrived while stage N's sign was in flight first become
|
|
135
|
+
* visible to `startNextStage`.
|
|
136
|
+
* - Stage N's PUT fan-out then runs concurrently with stage N+1's sign
|
|
137
|
+
* request, removing the per-batch sign-RTT pre-amble that made the
|
|
138
|
+
* W0056 prototype ~10 % slower than the (unsafe) single-URL parallel
|
|
139
|
+
* variant.
|
|
140
|
+
*
|
|
141
|
+
* Invariants preserved from the pre-pipeline implementation:
|
|
142
|
+
* - Emits resolve in batch-commit order within a stage.
|
|
143
|
+
* - All entries in a `Stage` share one `runId` (enforced by `startNextStage`).
|
|
144
|
+
* - A sign failure fails only that stage's group; later stages proceed.
|
|
145
|
+
* - run() keeps looping until `pending` is empty AND no prefetched stage
|
|
146
|
+
* remains — so emits that arrive during a PUT phase are picked up in the
|
|
147
|
+
* next iteration.
|
|
148
|
+
*/
|
|
149
|
+
async drain() {
|
|
150
|
+
if (this.flushing) {
|
|
151
|
+
await this.flushing;
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const run = async () => {
|
|
155
|
+
// Holds every `PendingEmit` that run() has already popped but not yet
|
|
156
|
+
// resolved. If anything throws unexpectedly (e.g. an async sink throws
|
|
157
|
+
// inside the PUT loop), the outer finally resolves each one to `null`
|
|
158
|
+
// so the producer's `Promise.all([1500 emits])` can't hang forever.
|
|
159
|
+
const unresolved = new Set();
|
|
160
|
+
const releaseAll = (refIfResolved = null) => {
|
|
161
|
+
for (const p of unresolved)
|
|
162
|
+
p.resolve(refIfResolved);
|
|
163
|
+
unresolved.clear();
|
|
164
|
+
};
|
|
165
|
+
try {
|
|
166
|
+
let prefetched = null;
|
|
167
|
+
while (true) {
|
|
168
|
+
const stage = prefetched ?? this.startNextStage();
|
|
169
|
+
prefetched = null;
|
|
170
|
+
if (!stage)
|
|
171
|
+
return;
|
|
172
|
+
for (const p of stage.group)
|
|
173
|
+
unresolved.add(p);
|
|
174
|
+
const signed = await stage.signPromise;
|
|
175
|
+
// Overlap point: launch the next stage's sign BEFORE awaiting this
|
|
176
|
+
// stage's PUTs. At this point, any emits that arrived while we
|
|
177
|
+
// were awaiting `stage.signPromise` are visible in `pending`.
|
|
178
|
+
prefetched = this.startNextStage();
|
|
179
|
+
if (prefetched) {
|
|
180
|
+
for (const p of prefetched.group)
|
|
181
|
+
unresolved.add(p);
|
|
182
|
+
}
|
|
183
|
+
if (!signed) {
|
|
184
|
+
for (const p of stage.group) {
|
|
185
|
+
p.resolve(null);
|
|
186
|
+
unresolved.delete(p);
|
|
187
|
+
}
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
const pendingPuts = stage.group.map((p) => async () => {
|
|
191
|
+
const typeMap = signed.urls[p.type];
|
|
192
|
+
const signedUrl = typeMap ? typeMap[p.entryKey] : undefined;
|
|
193
|
+
if (!signedUrl) {
|
|
194
|
+
console.warn(` ⚠️ Batch response missing URL for ${p.type}/${p.entryKey || "(bulk)"}`);
|
|
195
|
+
p.resolve(null);
|
|
196
|
+
unresolved.delete(p);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const ref = await this.putToSignedUrl(signedUrl, p);
|
|
200
|
+
p.resolve(ref);
|
|
201
|
+
unresolved.delete(p);
|
|
202
|
+
});
|
|
203
|
+
await runWithConcurrency(pendingPuts, this.options.putConcurrency);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
finally {
|
|
207
|
+
// Any PendingEmit still in `unresolved` reflects a code path that
|
|
208
|
+
// didn't reach its `resolve(ref)`. Resolve to `null` so the producer
|
|
209
|
+
// never hangs; the P5 non-blocking policy is the right default here.
|
|
210
|
+
releaseAll(null);
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
this.flushing = run();
|
|
214
|
+
try {
|
|
215
|
+
await this.flushing;
|
|
216
|
+
}
|
|
217
|
+
finally {
|
|
218
|
+
this.flushing = null;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Pop the next same-runId batch (up to `batchSize` entries) off the queue
|
|
223
|
+
* and launch its batch-sign request immediately. Returns a `Stage` whose
|
|
224
|
+
* `signPromise` is already in flight, so the caller can start the sign for
|
|
225
|
+
* the *next* batch while this one's PUTs are still pending.
|
|
226
|
+
*
|
|
227
|
+
* Returning `null` means the queue is empty.
|
|
228
|
+
*/
|
|
229
|
+
startNextStage() {
|
|
230
|
+
if (this.pending.length === 0)
|
|
231
|
+
return null;
|
|
232
|
+
const runId = this.pending[0].runId;
|
|
233
|
+
const group = [];
|
|
234
|
+
while (this.pending.length > 0 &&
|
|
235
|
+
group.length < this.options.batchSize &&
|
|
236
|
+
this.pending[0].runId === runId) {
|
|
237
|
+
group.push(this.pending.shift());
|
|
238
|
+
}
|
|
239
|
+
const signPromise = this.batchSign(runId, this.buildSignBody(group));
|
|
240
|
+
return { runId, group, signPromise };
|
|
241
|
+
}
|
|
242
|
+
buildSignBody(group) {
|
|
243
|
+
const types = new Set();
|
|
244
|
+
const keys = {};
|
|
245
|
+
for (const p of group) {
|
|
246
|
+
types.add(p.type);
|
|
247
|
+
const descriptor = ARTIFACT_REGISTRY[p.type];
|
|
248
|
+
if (descriptor.layout === "bulk")
|
|
249
|
+
continue;
|
|
250
|
+
let list = keys[p.type];
|
|
251
|
+
if (!list) {
|
|
252
|
+
list = [];
|
|
253
|
+
keys[p.type] = list;
|
|
254
|
+
}
|
|
255
|
+
if (!list.includes(p.entryKey))
|
|
256
|
+
list.push(p.entryKey);
|
|
257
|
+
}
|
|
258
|
+
return { types: Array.from(types), keys };
|
|
259
|
+
}
|
|
260
|
+
async batchSign(runId, body) {
|
|
261
|
+
const url = `${this.options.apiBaseUrl.replace(/\/$/, "")}/v1/runs/${encodeURIComponent(runId)}/artifacts/batch/upload-urls`;
|
|
262
|
+
const start = Date.now();
|
|
263
|
+
let success = false;
|
|
264
|
+
try {
|
|
265
|
+
const res = await fetch(url, {
|
|
266
|
+
method: "POST",
|
|
267
|
+
headers: {
|
|
268
|
+
Authorization: `Bearer ${this.options.apiKey}`,
|
|
269
|
+
"Content-Type": "application/json",
|
|
270
|
+
},
|
|
271
|
+
body: JSON.stringify(body),
|
|
272
|
+
});
|
|
273
|
+
if (!res.ok) {
|
|
274
|
+
console.warn(` ⚠️ Batch sign failed: ${res.status} ${res.statusText}`);
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
// API envelope is flat: { object, apiVersion, createdAt, bucket, urls }.
|
|
278
|
+
const parsed = (await res.json());
|
|
279
|
+
if (parsed.object !== "batch_signed_upload_urls" ||
|
|
280
|
+
typeof parsed.bucket !== "string" ||
|
|
281
|
+
typeof parsed.urls !== "object" ||
|
|
282
|
+
parsed.urls === null) {
|
|
283
|
+
console.warn(` ⚠️ Batch sign response malformed`);
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
success = true;
|
|
287
|
+
return { bucket: parsed.bucket, urls: parsed.urls };
|
|
288
|
+
}
|
|
289
|
+
catch (err) {
|
|
290
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
291
|
+
console.warn(` ⚠️ Batch sign failed: ${message}`);
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
finally {
|
|
295
|
+
// One `sign` event per batch (not per artifact) — a natural
|
|
296
|
+
// amortization signal in the metrics.
|
|
297
|
+
this.options.metrics.record({
|
|
298
|
+
phase: "sign",
|
|
299
|
+
writer: "BatchingApiGatewayArtifactWriter",
|
|
300
|
+
type: `batch(${body.types.length}-types)`,
|
|
301
|
+
ms: Date.now() - start,
|
|
302
|
+
success,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
async putToSignedUrl(signed, pending) {
|
|
307
|
+
const start = Date.now();
|
|
308
|
+
let ok = false;
|
|
309
|
+
// Tracked outside the try so the `finally` metrics event still gets a
|
|
310
|
+
// bytes figure when JSON.stringify itself throws (circular payload,
|
|
311
|
+
// bigint, etc.) — P5 requires we never hang the producer on a
|
|
312
|
+
// pathological payload.
|
|
313
|
+
let bytes = 0;
|
|
314
|
+
try {
|
|
315
|
+
const json = JSON.stringify(pending.payload);
|
|
316
|
+
bytes = Buffer.byteLength(json, "utf-8");
|
|
317
|
+
const res = await fetch(signed.url, {
|
|
318
|
+
method: "PUT",
|
|
319
|
+
body: json,
|
|
320
|
+
headers: signed.requiredHeaders,
|
|
321
|
+
});
|
|
322
|
+
if (!res.ok) {
|
|
323
|
+
console.warn(` ⚠️ Artifact upload failed (non-blocking): ${signed.path} — GCS PUT ${res.status} ${res.statusText}`);
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
ok = true;
|
|
327
|
+
const descriptor = ARTIFACT_REGISTRY[pending.type];
|
|
328
|
+
if (descriptor.layout === "bulk") {
|
|
329
|
+
return {
|
|
330
|
+
store: "gcs",
|
|
331
|
+
bucket: this.options.bucket,
|
|
332
|
+
path: signed.path,
|
|
333
|
+
bytes,
|
|
334
|
+
entryCount: entryCountOf(pending.payload),
|
|
335
|
+
layout: "bulk",
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
return {
|
|
339
|
+
store: "gcs",
|
|
340
|
+
bucket: this.options.bucket,
|
|
341
|
+
path: `runs/${pending.runId}/${descriptor.slug}`,
|
|
342
|
+
bytes,
|
|
343
|
+
entryCount: 1,
|
|
344
|
+
layout: "per-entry",
|
|
345
|
+
entries: [
|
|
346
|
+
{
|
|
347
|
+
key: pending.entryKey,
|
|
348
|
+
bytes,
|
|
349
|
+
association: pending.association,
|
|
350
|
+
},
|
|
351
|
+
],
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
catch (err) {
|
|
355
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
356
|
+
console.warn(` ⚠️ Artifact upload failed (non-blocking): ${signed.path} — ${message}`);
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
finally {
|
|
360
|
+
this.options.metrics.record({
|
|
361
|
+
phase: "put",
|
|
362
|
+
writer: "BatchingApiGatewayArtifactWriter",
|
|
363
|
+
type: pending.type,
|
|
364
|
+
ms: Date.now() - start,
|
|
365
|
+
bytes,
|
|
366
|
+
success: ok,
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
/** Single-URL sign + PUT — used for the manifest. */
|
|
371
|
+
async writeSingle(uploadUrlPath, payload, metricType) {
|
|
372
|
+
const signStart = Date.now();
|
|
373
|
+
let signOk = false;
|
|
374
|
+
let signed = null;
|
|
375
|
+
try {
|
|
376
|
+
const res = await fetch(`${this.options.apiBaseUrl.replace(/\/$/, "")}${uploadUrlPath}`, {
|
|
377
|
+
method: "GET",
|
|
378
|
+
headers: { Authorization: `Bearer ${this.options.apiKey}` },
|
|
379
|
+
});
|
|
380
|
+
if (!res.ok) {
|
|
381
|
+
console.warn(` ⚠️ Signed-URL request failed: ${res.status} ${res.statusText}`);
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
const body = (await res.json());
|
|
385
|
+
if (body.object !== "signed_upload_url" ||
|
|
386
|
+
!body.url ||
|
|
387
|
+
!body.path ||
|
|
388
|
+
!body.bucket ||
|
|
389
|
+
!body.requiredHeaders) {
|
|
390
|
+
console.warn(` ⚠️ Signed-URL response malformed`);
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
signOk = true;
|
|
394
|
+
signed = {
|
|
395
|
+
url: body.url,
|
|
396
|
+
path: body.path,
|
|
397
|
+
bucket: body.bucket,
|
|
398
|
+
requiredHeaders: body.requiredHeaders,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
catch (err) {
|
|
402
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
403
|
+
console.warn(` ⚠️ Signed-URL request failed: ${message}`);
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
finally {
|
|
407
|
+
this.options.metrics.record({
|
|
408
|
+
phase: "sign",
|
|
409
|
+
writer: "BatchingApiGatewayArtifactWriter",
|
|
410
|
+
type: metricType,
|
|
411
|
+
ms: Date.now() - signStart,
|
|
412
|
+
success: signOk,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
if (!signed)
|
|
416
|
+
return null;
|
|
417
|
+
const json = JSON.stringify(payload);
|
|
418
|
+
const bytes = Buffer.byteLength(json, "utf-8");
|
|
419
|
+
const putStart = Date.now();
|
|
420
|
+
let putOk = false;
|
|
421
|
+
try {
|
|
422
|
+
const res = await fetch(signed.url, {
|
|
423
|
+
method: "PUT",
|
|
424
|
+
body: json,
|
|
425
|
+
headers: signed.requiredHeaders,
|
|
426
|
+
});
|
|
427
|
+
if (!res.ok) {
|
|
428
|
+
console.warn(` ⚠️ Artifact upload failed (non-blocking): ${signed.path} — GCS PUT ${res.status} ${res.statusText}`);
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
putOk = true;
|
|
432
|
+
return {
|
|
433
|
+
store: "gcs",
|
|
434
|
+
bucket: signed.bucket,
|
|
435
|
+
path: signed.path,
|
|
436
|
+
bytes,
|
|
437
|
+
layout: "bulk",
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
catch (err) {
|
|
441
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
442
|
+
console.warn(` ⚠️ Artifact upload failed (non-blocking): ${signed.path} — ${message}`);
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
finally {
|
|
446
|
+
this.options.metrics.record({
|
|
447
|
+
phase: "put",
|
|
448
|
+
writer: "BatchingApiGatewayArtifactWriter",
|
|
449
|
+
type: metricType,
|
|
450
|
+
ms: Date.now() - putStart,
|
|
451
|
+
bytes,
|
|
452
|
+
success: putOk,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
// ---------------------------------------------------------------------------
|
|
458
|
+
// Helpers
|
|
459
|
+
// ---------------------------------------------------------------------------
|
|
460
|
+
async function runWithConcurrency(tasks, concurrency) {
|
|
461
|
+
if (tasks.length === 0)
|
|
462
|
+
return [];
|
|
463
|
+
if (concurrency <= 1) {
|
|
464
|
+
const out = [];
|
|
465
|
+
for (const task of tasks)
|
|
466
|
+
out.push(await task());
|
|
467
|
+
return out;
|
|
468
|
+
}
|
|
469
|
+
const results = new Array(tasks.length);
|
|
470
|
+
let cursor = 0;
|
|
471
|
+
async function worker() {
|
|
472
|
+
while (true) {
|
|
473
|
+
const i = cursor++;
|
|
474
|
+
if (i >= tasks.length)
|
|
475
|
+
return;
|
|
476
|
+
results[i] = await tasks[i]();
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
const width = Math.min(concurrency, tasks.length);
|
|
480
|
+
await Promise.all(Array.from({ length: width }, () => worker()));
|
|
481
|
+
return results;
|
|
482
|
+
}
|
|
483
|
+
function entryCountOf(data) {
|
|
484
|
+
if (typeof data === "object" &&
|
|
485
|
+
data !== null &&
|
|
486
|
+
"entries" in data &&
|
|
487
|
+
typeof data.entries === "object") {
|
|
488
|
+
return Object.keys(data.entries)
|
|
489
|
+
.length;
|
|
490
|
+
}
|
|
491
|
+
return undefined;
|
|
492
|
+
}
|
|
@@ -20,10 +20,22 @@
|
|
|
20
20
|
*
|
|
21
21
|
* @see docs/decisions/D0033-unified-run-anchored-artifact-capture.md (§ M4)
|
|
22
22
|
*/
|
|
23
|
-
import type { ArtifactEntry, ArtifactRef, ArtifactType, ArtifactWriter, AssociationValues, RunId, RunManifest } from "../_vendor/ailf-core/index.d.ts";
|
|
23
|
+
import type { ArtifactEntry, ArtifactRef, ArtifactType, ArtifactWriter, ArtifactWriterProgressOptions, AssociationValues, RunId, RunManifest } from "../_vendor/ailf-core/index.d.ts";
|
|
24
|
+
export interface FanoutArtifactWriterOptions {
|
|
25
|
+
/**
|
|
26
|
+
* Optional progress reporter + phaseId (W0053). When set, the fanout
|
|
27
|
+
* publishes `phaseProgress` once per caller-visible write (emit /
|
|
28
|
+
* appendNdjson / writeManifest), attributing one item and the returned
|
|
29
|
+
* ref's bytes. Delegates should NOT also be configured with progress —
|
|
30
|
+
* double-reporting would inflate counters.
|
|
31
|
+
*/
|
|
32
|
+
progress?: ArtifactWriterProgressOptions;
|
|
33
|
+
}
|
|
24
34
|
export declare class FanoutArtifactWriter implements ArtifactWriter {
|
|
25
35
|
private readonly writers;
|
|
26
|
-
|
|
36
|
+
private readonly progress?;
|
|
37
|
+
constructor(writers: readonly ArtifactWriter[], options?: FanoutArtifactWriterOptions);
|
|
38
|
+
private reportProgress;
|
|
27
39
|
emit<T extends ArtifactType>(type: T, association: AssociationValues, payload: unknown): Promise<ArtifactRef | null>;
|
|
28
40
|
appendNdjson<T extends ArtifactType>(type: T, association: AssociationValues, rows: readonly unknown[]): Promise<ArtifactRef | null>;
|
|
29
41
|
writeManifest(runId: RunId, manifest: RunManifest): Promise<ArtifactRef | null>;
|
|
@@ -22,23 +22,44 @@
|
|
|
22
22
|
*/
|
|
23
23
|
export class FanoutArtifactWriter {
|
|
24
24
|
writers;
|
|
25
|
-
|
|
25
|
+
progress;
|
|
26
|
+
constructor(writers, options = {}) {
|
|
26
27
|
if (writers.length === 0) {
|
|
27
28
|
throw new Error("FanoutArtifactWriter requires at least one delegate writer");
|
|
28
29
|
}
|
|
29
30
|
this.writers = writers;
|
|
31
|
+
this.progress = options.progress;
|
|
32
|
+
}
|
|
33
|
+
reportProgress(ref) {
|
|
34
|
+
if (!this.progress)
|
|
35
|
+
return;
|
|
36
|
+
this.progress.reporter.phaseProgress({
|
|
37
|
+
phaseId: this.progress.phaseId,
|
|
38
|
+
items: 1,
|
|
39
|
+
bytes: ref.bytes,
|
|
40
|
+
label: ref.path,
|
|
41
|
+
});
|
|
30
42
|
}
|
|
31
43
|
async emit(type, association, payload) {
|
|
32
44
|
const refs = await this.runAll((w) => w.emit(type, association, payload));
|
|
33
|
-
|
|
45
|
+
const ref = firstNonNull(refs);
|
|
46
|
+
if (ref)
|
|
47
|
+
this.reportProgress(ref);
|
|
48
|
+
return ref;
|
|
34
49
|
}
|
|
35
50
|
async appendNdjson(type, association, rows) {
|
|
36
51
|
const refs = await this.runAll((w) => w.appendNdjson(type, association, rows));
|
|
37
|
-
|
|
52
|
+
const ref = firstNonNull(refs);
|
|
53
|
+
if (ref)
|
|
54
|
+
this.reportProgress(ref);
|
|
55
|
+
return ref;
|
|
38
56
|
}
|
|
39
57
|
async writeManifest(runId, manifest) {
|
|
40
58
|
const refs = await this.runAll((w) => w.writeManifest(runId, manifest));
|
|
41
|
-
|
|
59
|
+
const ref = firstNonNull(refs);
|
|
60
|
+
if (ref)
|
|
61
|
+
this.reportProgress(ref);
|
|
62
|
+
return ref;
|
|
42
63
|
}
|
|
43
64
|
/** @deprecated Use `emit()` instead. */
|
|
44
65
|
async writeBulk(type, runId, data) {
|