@airprompter/agent-telemetry 0.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/README.md +32 -0
- package/dist/cjs/index.js +44 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/spool/writer.js +387 -0
- package/dist/cjs/spool/writer.js.map +1 -0
- package/dist/cjs/uploader.js +627 -0
- package/dist/cjs/uploader.js.map +1 -0
- package/dist/esm/.tsbuildinfo +1 -0
- package/dist/esm/index.d.ts +15 -0
- package/dist/esm/index.js +12 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/spool/writer.d.ts +152 -0
- package/dist/esm/spool/writer.js +375 -0
- package/dist/esm/spool/writer.js.map +1 -0
- package/dist/esm/uploader.d.ts +257 -0
- package/dist/esm/uploader.js +617 -0
- package/dist/esm/uploader.js.map +1 -0
- package/package.json +43 -0
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The spool uploader (T26 P4, D52/D66): closed segments from ANY writer in
|
|
3
|
+
* `<store>/spool/telemetry/` are validated line by line against the spool
|
|
4
|
+
* row contract, quarantined when they do not fit, and POSTed straight to S3
|
|
5
|
+
* under the heartbeat's presigned grant — one in flight per host, oldest
|
|
6
|
+
* first, exponential backoff with full jitter (1 s → 5 min), acknowledged
|
|
7
|
+
* segments DELETED (S6: S3 keys are idempotent, a lost response is a
|
|
8
|
+
* replay, nothing needs keeping), `quarantine/` and `exported/` capped in
|
|
9
|
+
* bytes and swept by age, abandoned `.open` files reclaimed, the host
|
|
10
|
+
* budget enforced across writers with the loss written as a `dropped` row.
|
|
11
|
+
* Nothing here reads a row for anything but its shape.
|
|
12
|
+
*
|
|
13
|
+
* S6 — the disk budget is a published invariant (spool-format.md draft 2):
|
|
14
|
+
*
|
|
15
|
+
* tree ≤ budget + (writers × 1 MiB open) + quarantine cap + exported cap
|
|
16
|
+
*
|
|
17
|
+
* Closed unsent segments are the budget; each live writer holds at most one
|
|
18
|
+
* open segment of at most 1 MiB; quarantine/ and exported/ hold at most
|
|
19
|
+
* their caps; nothing else is ever parked under the spool.
|
|
20
|
+
*
|
|
21
|
+
* A grant is per INSTANCE prefix (`org/{org}/agent/{agent}/{target}/{instance}/`)
|
|
22
|
+
* and the ingest processor holds every row to the prefix it arrived under,
|
|
23
|
+
* so a daemon that uploads for several writers holds one grant per writer:
|
|
24
|
+
* `grantFor(instanceId)` is the daemon's heartbeat carrying that writer's
|
|
25
|
+
* instance id. The serverless path uses the same `postSegment` with the
|
|
26
|
+
* runtime's own grant at invocation end.
|
|
27
|
+
*/
|
|
28
|
+
import { nodeFs } from "@airprompter/agent-core";
|
|
29
|
+
import { fsFailureCode } from "@airprompter/agent-core";
|
|
30
|
+
import { join } from "node:path";
|
|
31
|
+
import { HOST_SPOOL_BUDGET_BYTES, LATENCY_BUCKET_EDGES_MS, SEGMENT_MAX_BYTES, epochMinute, segmentName } from "./spool/writer.js";
|
|
32
|
+
export const UPLOAD_BACKOFF_BASE_MS = 1000;
|
|
33
|
+
export const UPLOAD_BACKOFF_CAP_MS = 5 * 60 * 1000;
|
|
34
|
+
export const QUARANTINE_RETENTION_MS = 24 * 60 * 60 * 1000;
|
|
35
|
+
/** S6: `quarantine/` and `exported/` are capped in bytes, oldest first — a buggy third-party writer cannot fill the disk through quarantine. */
|
|
36
|
+
export const QUARANTINE_CAP_BYTES = 10 * 1024 * 1024;
|
|
37
|
+
export const EXPORTED_CAP_BYTES = 10 * 1024 * 1024;
|
|
38
|
+
/** S6: an `.open` segment untouched this long has no writer behind it (a live one closes every minute it has traffic, and its stale windows within one); it is closed and uploaded like any other. */
|
|
39
|
+
export const OPEN_SEGMENT_RECLAIM_MS = 60 * 60 * 1000;
|
|
40
|
+
/** S6: the uploader stamps its last acknowledged upload here (mtime), so `airprompter status` can say it without a daemon. */
|
|
41
|
+
export const LAST_UPLOAD_MARKER = ".last-upload";
|
|
42
|
+
/** A grant is refreshed this long before its `expiresAt`, so an upload never starts on one about to lapse. */
|
|
43
|
+
export const GRANT_REFRESH_MARGIN_MS = 60 * 1000;
|
|
44
|
+
export const SEGMENT_NAME = /^seg-([A-Za-z0-9._~-]{8,64})-(\d+)-(\d+)\.ndjson$/;
|
|
45
|
+
export const OPEN_SEGMENT_NAME = /^seg-([A-Za-z0-9._~-]{8,64})-(\d+)-(\d+)\.ndjson\.open$/;
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Row validation: the spool contract (spool-rows.schema.json + telemetry-window.schema.json), structurally
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
const INSTANCE_ID = /^[A-Za-z0-9._~-]{8,64}$/;
|
|
50
|
+
const TAG = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/;
|
|
51
|
+
const ARM = /^[a-z0-9_-]{1,32}$/;
|
|
52
|
+
const OUTCOME_NAME = /^[a-z][a-zA-Z0-9]{0,31}$/;
|
|
53
|
+
const DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
54
|
+
const ERROR_CLASSES = new Set(["render_missing_variable", "context_length_exceeded", "output_schema_invalid", "truncated", "content_filter", "provider_error", "provider_timeout", "provider_rate_limited"]);
|
|
55
|
+
const REFUSAL_REASONS = new Set(["disabled", "lease_expired", "payload_verification_failed", "forced_downgrade", "model_unavailable", "unlock_refused"]);
|
|
56
|
+
const USAGE_SOURCES = new Set(["reported", "measured", "estimated", "unavailable"]);
|
|
57
|
+
const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
58
|
+
const isNonNegativeInt = (value, min = 0) => typeof value === "number" && Number.isInteger(value) && value >= min;
|
|
59
|
+
const isString = (value, max) => typeof value === "string" && value.length <= max;
|
|
60
|
+
const onlyKeys = (object, allowed) => Object.keys(object).find((key) => !allowed.includes(key)) ?? null;
|
|
61
|
+
/** One parsed line against the contract. The reason names the first field that does not fit — never the value. */
|
|
62
|
+
export function validateSpoolRow(value) {
|
|
63
|
+
if (!isObject(value))
|
|
64
|
+
return { ok: false, reason: "not_an_object" };
|
|
65
|
+
if (value.v !== 1)
|
|
66
|
+
return { ok: false, reason: "v" };
|
|
67
|
+
if (!isString(value.instanceId, 64) || !INSTANCE_ID.test(value.instanceId))
|
|
68
|
+
return { ok: false, reason: "instanceId" };
|
|
69
|
+
switch (value.type) {
|
|
70
|
+
case "window": {
|
|
71
|
+
const extra = onlyKeys(value, ["type", "v", "minute", "instanceId", "instanceClass", "tag", "versionId", "arm", "model", "status", "errorClass", "usageSource", "count", "latencyMs", "tokens", "checks", "outcomes", "sdk"]);
|
|
72
|
+
if (extra)
|
|
73
|
+
return { ok: false, reason: `unknown_field:${extra}` };
|
|
74
|
+
if (!isString(value.minute, 64) || !DATE_TIME.test(value.minute))
|
|
75
|
+
return { ok: false, reason: "minute" };
|
|
76
|
+
if (value.instanceClass !== "resident" && value.instanceClass !== "ephemeral")
|
|
77
|
+
return { ok: false, reason: "instanceClass" };
|
|
78
|
+
if (!isString(value.tag, 128) || !TAG.test(value.tag))
|
|
79
|
+
return { ok: false, reason: "tag" };
|
|
80
|
+
if (!isString(value.versionId, 128))
|
|
81
|
+
return { ok: false, reason: "versionId" };
|
|
82
|
+
if (!isString(value.arm, 32) || !ARM.test(value.arm))
|
|
83
|
+
return { ok: false, reason: "arm" };
|
|
84
|
+
if (!isString(value.model, 128))
|
|
85
|
+
return { ok: false, reason: "model" };
|
|
86
|
+
if (value.status !== "ok" && value.status !== "error" && value.status !== "refused")
|
|
87
|
+
return { ok: false, reason: "status" };
|
|
88
|
+
if (value.errorClass !== undefined && value.errorClass !== null && !(typeof value.errorClass === "string" && ERROR_CLASSES.has(value.errorClass)))
|
|
89
|
+
return { ok: false, reason: "errorClass" };
|
|
90
|
+
if (typeof value.usageSource !== "string" || !USAGE_SOURCES.has(value.usageSource))
|
|
91
|
+
return { ok: false, reason: "usageSource" };
|
|
92
|
+
if (!isNonNegativeInt(value.count))
|
|
93
|
+
return { ok: false, reason: "count" };
|
|
94
|
+
const latency = value.latencyMs;
|
|
95
|
+
if (!isObject(latency) || onlyKeys(latency, ["buckets", "sum"]) || !Array.isArray(latency.buckets) || latency.buckets.length !== LATENCY_BUCKET_EDGES_MS.length || !latency.buckets.every((b) => isNonNegativeInt(b)) || !isNonNegativeInt(latency.sum))
|
|
96
|
+
return { ok: false, reason: "latencyMs" };
|
|
97
|
+
const tokens = value.tokens;
|
|
98
|
+
if (!isObject(tokens) || onlyKeys(tokens, ["input", "cachedInput", "output"]) || !isNonNegativeInt(tokens.input) || !isNonNegativeInt(tokens.output) || (tokens.cachedInput !== undefined && !isNonNegativeInt(tokens.cachedInput)))
|
|
99
|
+
return { ok: false, reason: "tokens" };
|
|
100
|
+
if (value.checks !== undefined) {
|
|
101
|
+
const checks = value.checks;
|
|
102
|
+
if (!isObject(checks) || onlyKeys(checks, ["passed", "failed"]) || (checks.passed !== undefined && !isNonNegativeInt(checks.passed)) || (checks.failed !== undefined && !isNonNegativeInt(checks.failed)))
|
|
103
|
+
return { ok: false, reason: "checks" };
|
|
104
|
+
}
|
|
105
|
+
if (value.outcomes !== undefined) {
|
|
106
|
+
const outcomes = value.outcomes;
|
|
107
|
+
if (!isObject(outcomes))
|
|
108
|
+
return { ok: false, reason: "outcomes" };
|
|
109
|
+
for (const [name, entry] of Object.entries(outcomes)) {
|
|
110
|
+
if (!OUTCOME_NAME.test(name) || !isObject(entry) || onlyKeys(entry, ["n", "sum"]) || !isNonNegativeInt(entry.n) || typeof entry.sum !== "number" || !Number.isFinite(entry.sum))
|
|
111
|
+
return { ok: false, reason: `outcomes:${name}` };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (value.sdk !== undefined && !isString(value.sdk, 64))
|
|
115
|
+
return { ok: false, reason: "sdk" };
|
|
116
|
+
return { ok: true, row: value };
|
|
117
|
+
}
|
|
118
|
+
case "refusal": {
|
|
119
|
+
const extra = onlyKeys(value, ["type", "v", "at", "instanceId", "reason", "generation", "tag"]);
|
|
120
|
+
if (extra)
|
|
121
|
+
return { ok: false, reason: `unknown_field:${extra}` };
|
|
122
|
+
if (!isString(value.at, 64) || !DATE_TIME.test(value.at))
|
|
123
|
+
return { ok: false, reason: "at" };
|
|
124
|
+
if (typeof value.reason !== "string" || !REFUSAL_REASONS.has(value.reason))
|
|
125
|
+
return { ok: false, reason: "reason" };
|
|
126
|
+
if (!isNonNegativeInt(value.generation))
|
|
127
|
+
return { ok: false, reason: "generation" };
|
|
128
|
+
if (!("tag" in value) || !(value.tag === null || (isString(value.tag, 128) && TAG.test(value.tag))))
|
|
129
|
+
return { ok: false, reason: "tag" };
|
|
130
|
+
return { ok: true, row: value };
|
|
131
|
+
}
|
|
132
|
+
case "dropped": {
|
|
133
|
+
const extra = onlyKeys(value, ["type", "v", "at", "instanceId", "segments", "bytes"]);
|
|
134
|
+
if (extra)
|
|
135
|
+
return { ok: false, reason: `unknown_field:${extra}` };
|
|
136
|
+
if (!isString(value.at, 64) || !DATE_TIME.test(value.at))
|
|
137
|
+
return { ok: false, reason: "at" };
|
|
138
|
+
if (!isNonNegativeInt(value.segments, 1))
|
|
139
|
+
return { ok: false, reason: "segments" };
|
|
140
|
+
if (!isNonNegativeInt(value.bytes))
|
|
141
|
+
return { ok: false, reason: "bytes" };
|
|
142
|
+
return { ok: true, row: value };
|
|
143
|
+
}
|
|
144
|
+
default:
|
|
145
|
+
return { ok: false, reason: "unknown_type" };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/** Every line of a segment against the contract; `instanceId` (from the file name) is authoritative for every row. */
|
|
149
|
+
export function inspectSegment(bytes, instanceId) {
|
|
150
|
+
const text = Buffer.from(bytes).toString("utf8");
|
|
151
|
+
const partialTail = text.length > 0 && !text.endsWith("\n");
|
|
152
|
+
const lines = text.split("\n");
|
|
153
|
+
if (partialTail)
|
|
154
|
+
lines.pop();
|
|
155
|
+
else
|
|
156
|
+
lines.pop(); // the empty string after the final newline
|
|
157
|
+
const rows = [];
|
|
158
|
+
const invalid = [];
|
|
159
|
+
lines.forEach((line, index) => {
|
|
160
|
+
if (!line.trim())
|
|
161
|
+
return;
|
|
162
|
+
let parsed;
|
|
163
|
+
try {
|
|
164
|
+
parsed = JSON.parse(line);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
invalid.push({ line: index + 1, reason: "not_json" });
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const verdict = validateSpoolRow(parsed);
|
|
171
|
+
if (!verdict.ok)
|
|
172
|
+
invalid.push({ line: index + 1, reason: verdict.reason });
|
|
173
|
+
else if (verdict.row.instanceId !== instanceId)
|
|
174
|
+
invalid.push({ line: index + 1, reason: "instance_mismatch" });
|
|
175
|
+
else
|
|
176
|
+
rows.push(verdict.row);
|
|
177
|
+
});
|
|
178
|
+
return { rows, invalid, partialTail };
|
|
179
|
+
}
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// The POST: a presigned S3 POST policy, multipart/form-data, fields verbatim then key then file
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
export function multipartBody(boundary, fields, file) {
|
|
184
|
+
const parts = [];
|
|
185
|
+
for (const [name, value] of fields)
|
|
186
|
+
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`, "utf8"));
|
|
187
|
+
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${file.name}"\r\nContent-Type: ${file.contentType}\r\n\r\n`, "utf8"));
|
|
188
|
+
parts.push(Buffer.from(file.bytes));
|
|
189
|
+
parts.push(Buffer.from(`\r\n--${boundary}--\r\n`, "utf8"));
|
|
190
|
+
return Buffer.concat(parts);
|
|
191
|
+
}
|
|
192
|
+
/** One segment under one grant. S3 PUT is idempotent by key, so a replay after a lost response overwrites identically. */
|
|
193
|
+
export async function postSegment(input) {
|
|
194
|
+
const { grant } = input;
|
|
195
|
+
if (input.bytes.length > grant.maxObjectBytes)
|
|
196
|
+
return { status: "too_large", bytes: input.bytes.length };
|
|
197
|
+
const now = input.now?.() ?? Date.now();
|
|
198
|
+
if (Date.parse(grant.expiresAt) <= now)
|
|
199
|
+
return { status: "refused", httpStatus: 403, expired: true };
|
|
200
|
+
const key = `${grant.keyPrefix}${input.segment}`;
|
|
201
|
+
const contentType = grant.contentType ?? "application/x-ndjson";
|
|
202
|
+
// The policy's own fields first, verbatim; `key` and `Content-Type` are what the policy conditions check; `file` last, as S3 requires.
|
|
203
|
+
const fields = [...Object.entries(grant.fields).filter(([name]) => name !== "key" && name.toLowerCase() !== "content-type"), ["key", key], ["Content-Type", contentType]];
|
|
204
|
+
const boundary = input.boundary ?? `----airprompter${Math.random().toString(36).slice(2)}${now.toString(36)}`;
|
|
205
|
+
const body = multipartBody(boundary, fields, { name: input.segment, contentType, bytes: input.bytes });
|
|
206
|
+
try {
|
|
207
|
+
const response = await input.fetch(grant.url, { method: "POST", headers: { "content-type": `multipart/form-data; boundary=${boundary}`, "content-length": String(body.length) }, body });
|
|
208
|
+
if (response.status >= 200 && response.status < 300)
|
|
209
|
+
return { status: "ok", key };
|
|
210
|
+
const text = await response.text().catch(() => "");
|
|
211
|
+
return { status: "refused", httpStatus: response.status, expired: response.status === 403 && /expired|Policy expired|signature/i.test(text) };
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
return { status: "network", reason: error.message };
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/** Full jitter: uniform in [0, min(cap, base × 2^attempt)]. */
|
|
218
|
+
export function backoffDelayMs(attempt, random = Math.random) {
|
|
219
|
+
const ceiling = Math.min(UPLOAD_BACKOFF_CAP_MS, UPLOAD_BACKOFF_BASE_MS * 2 ** Math.max(0, attempt));
|
|
220
|
+
return Math.max(0, Math.round(random() * ceiling));
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* AirPrompter's sink: one grant per writer prefix (the daemon's heartbeat carrying that writer's instance id), a PUT of
|
|
224
|
+
* exactly the whole lines to the customer's own prefix; a grant that lapsed between the check and the bucket's clock is
|
|
225
|
+
* refreshed once. `onGrant` lets the uploader take the grant's cadence.
|
|
226
|
+
*/
|
|
227
|
+
export function airprompterUploadSink(input) {
|
|
228
|
+
const now = input.now ?? (() => Date.now());
|
|
229
|
+
const grants = new Map();
|
|
230
|
+
const grantFor = async (instanceId) => {
|
|
231
|
+
const held = grants.get(instanceId);
|
|
232
|
+
if (held && Date.parse(held.expiresAt) - GRANT_REFRESH_MARGIN_MS > now())
|
|
233
|
+
return { kind: "grant", grant: held };
|
|
234
|
+
grants.delete(instanceId);
|
|
235
|
+
const decision = await input.grantFor(instanceId);
|
|
236
|
+
if (decision.kind === "grant") {
|
|
237
|
+
grants.set(instanceId, decision.grant);
|
|
238
|
+
input.onGrant?.(decision);
|
|
239
|
+
}
|
|
240
|
+
return decision;
|
|
241
|
+
};
|
|
242
|
+
return {
|
|
243
|
+
kind: "airprompter",
|
|
244
|
+
grants,
|
|
245
|
+
status: () => ({ grants: [...grants].map(([instanceId, grant]) => ({ instanceId, expiresAt: grant.expiresAt })) }),
|
|
246
|
+
async ship(segment) {
|
|
247
|
+
const decision = await grantFor(segment.instanceId);
|
|
248
|
+
if (decision.kind === "hold")
|
|
249
|
+
return { status: "hold", retryAfterMs: decision.retryAfterSeconds * 1000, ...(decision.reason !== undefined ? { reason: decision.reason } : {}) };
|
|
250
|
+
if (decision.kind === "unavailable")
|
|
251
|
+
return { status: "failed", reason: `grant:${decision.reason}` };
|
|
252
|
+
let outcome = await postSegment({ grant: decision.grant, segment: segment.segment, bytes: segment.bytes, fetch: input.fetch, now });
|
|
253
|
+
if (outcome.status === "refused" && outcome.expired) {
|
|
254
|
+
// The grant lapsed between the check and the bucket's clock: one fresh grant, one more try.
|
|
255
|
+
grants.delete(segment.instanceId);
|
|
256
|
+
const fresh = await grantFor(segment.instanceId);
|
|
257
|
+
if (fresh.kind === "grant")
|
|
258
|
+
outcome = await postSegment({ grant: fresh.grant, segment: segment.segment, bytes: segment.bytes, fetch: input.fetch, now });
|
|
259
|
+
}
|
|
260
|
+
if (outcome.status === "ok")
|
|
261
|
+
return { status: "ok" };
|
|
262
|
+
if (outcome.status === "too_large")
|
|
263
|
+
return { status: "too_large", bytes: outcome.bytes };
|
|
264
|
+
return { status: "failed", reason: outcome.status === "refused" ? `http_${outcome.httpStatus}` : `network:${outcome.reason}` };
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
export class SpoolUploader {
|
|
269
|
+
options;
|
|
270
|
+
sink;
|
|
271
|
+
lastUploadMs = null;
|
|
272
|
+
lastError = null;
|
|
273
|
+
backoffUntilMs = null;
|
|
274
|
+
attempt = 0;
|
|
275
|
+
inFlight = null;
|
|
276
|
+
intervalSeconds;
|
|
277
|
+
nextPassMs = null;
|
|
278
|
+
sentSegments = 0;
|
|
279
|
+
quarantinedSegments = 0;
|
|
280
|
+
droppedSegments = 0;
|
|
281
|
+
reclaimedSegments = 0;
|
|
282
|
+
capEvictedFiles = 0;
|
|
283
|
+
timer = null;
|
|
284
|
+
stopped = false;
|
|
285
|
+
fs;
|
|
286
|
+
/** Filesystem failures by code — a sweep that could not stat, an evict that found the file gone (S2). */
|
|
287
|
+
fsFaults = {};
|
|
288
|
+
constructor(options) {
|
|
289
|
+
this.options = options;
|
|
290
|
+
this.intervalSeconds = options.intervalSeconds ?? 300;
|
|
291
|
+
this.fs = options.fs ?? nodeFs;
|
|
292
|
+
if (options.sink)
|
|
293
|
+
this.sink = options.sink;
|
|
294
|
+
else {
|
|
295
|
+
if (!options.grantFor || !options.fetch)
|
|
296
|
+
throw new Error("SpoolUploader: a sink, or grantFor + fetch for AirPrompter's, is required");
|
|
297
|
+
this.sink = airprompterUploadSink({
|
|
298
|
+
grantFor: options.grantFor,
|
|
299
|
+
fetch: options.fetch,
|
|
300
|
+
now: () => this.now(),
|
|
301
|
+
onGrant: (decision) => {
|
|
302
|
+
if (decision.uploadIntervalSeconds && decision.uploadIntervalSeconds >= 1)
|
|
303
|
+
this.intervalSeconds = decision.uploadIntervalSeconds;
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
this.fs.mkdirp(join(options.dir, "quarantine"), 0o700);
|
|
308
|
+
this.fs.mkdirp(join(options.dir, "exported"), 0o700);
|
|
309
|
+
}
|
|
310
|
+
/** Run a filesystem step; a failure is counted by code and returns false (a segment a sibling took away is not an error). */
|
|
311
|
+
guard(step, run) {
|
|
312
|
+
try {
|
|
313
|
+
run();
|
|
314
|
+
return true;
|
|
315
|
+
}
|
|
316
|
+
catch (error) {
|
|
317
|
+
const code = fsFailureCode(error);
|
|
318
|
+
this.fsFaults[code] = (this.fsFaults[code] ?? 0) + 1;
|
|
319
|
+
this.log({ event: "fs_fault", step, code });
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
now() {
|
|
324
|
+
return this.options.now?.() ?? Date.now();
|
|
325
|
+
}
|
|
326
|
+
log(event) {
|
|
327
|
+
this.options.logger?.({ component: "uploader", ...event });
|
|
328
|
+
}
|
|
329
|
+
/** Closed, unsent segments, oldest first (by epoch minute, then n, then name). */
|
|
330
|
+
closedSegments() {
|
|
331
|
+
return this.fs
|
|
332
|
+
.list(this.options.dir)
|
|
333
|
+
.filter((name) => SEGMENT_NAME.test(name))
|
|
334
|
+
.sort((a, b) => {
|
|
335
|
+
const [, , ma, na] = SEGMENT_NAME.exec(a);
|
|
336
|
+
const [, , mb, nb] = SEGMENT_NAME.exec(b);
|
|
337
|
+
return Number(ma) - Number(mb) || Number(na) - Number(nb) || (a < b ? -1 : 1);
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
depth() {
|
|
341
|
+
const segments = this.closedSegments();
|
|
342
|
+
let bytes = 0;
|
|
343
|
+
for (const name of segments)
|
|
344
|
+
this.guard("stat_segment", () => void (bytes += this.fs.stat(join(this.options.dir, name)).size));
|
|
345
|
+
return { segments: segments.length, bytes };
|
|
346
|
+
}
|
|
347
|
+
/** Bytes under one subdirectory (files only), oldest-first names beside it. */
|
|
348
|
+
dirBytes(sub) {
|
|
349
|
+
const dir = join(this.options.dir, sub);
|
|
350
|
+
let names = [];
|
|
351
|
+
this.guard("list_dir", () => void (names = this.fs.list(dir).sort()));
|
|
352
|
+
let bytes = 0;
|
|
353
|
+
for (const name of names)
|
|
354
|
+
this.guard("stat_file", () => void (bytes += this.fs.stat(join(dir, name)).size));
|
|
355
|
+
return { names, bytes };
|
|
356
|
+
}
|
|
357
|
+
/** S6: the invariant's terms as they stand — what a host actually has parked under the spool. */
|
|
358
|
+
tree() {
|
|
359
|
+
const closed = this.depth();
|
|
360
|
+
let openSegments = 0;
|
|
361
|
+
let openBytes = 0;
|
|
362
|
+
let names = [];
|
|
363
|
+
this.guard("list_spool", () => void (names = this.fs.list(this.options.dir)));
|
|
364
|
+
for (const name of names) {
|
|
365
|
+
if (!OPEN_SEGMENT_NAME.test(name))
|
|
366
|
+
continue;
|
|
367
|
+
openSegments += 1;
|
|
368
|
+
this.guard("stat_open", () => void (openBytes += this.fs.stat(join(this.options.dir, name)).size));
|
|
369
|
+
}
|
|
370
|
+
const quarantineBytes = this.dirBytes("quarantine").bytes;
|
|
371
|
+
const exportedBytes = this.dirBytes("exported").bytes;
|
|
372
|
+
return { openSegments, openBytes, quarantineBytes, exportedBytes, totalBytes: closed.bytes + openBytes + quarantineBytes + exportedBytes };
|
|
373
|
+
}
|
|
374
|
+
/** S6: the published bound for this uploader's settings — `budget + writers × 1 MiB + quarantine cap + exported cap`. */
|
|
375
|
+
bound(writers) {
|
|
376
|
+
return (this.options.budgetBytes ?? HOST_SPOOL_BUDGET_BYTES) + writers * SEGMENT_MAX_BYTES + (this.options.quarantineCapBytes ?? QUARANTINE_CAP_BYTES) + (this.options.exportedCapBytes ?? EXPORTED_CAP_BYTES);
|
|
377
|
+
}
|
|
378
|
+
/** Attached SDK processes and the daemon both write here; over the host budget the OLDEST unsent segments go and the loss is one `dropped` row under the daemon's own id. */
|
|
379
|
+
enforceBudget() {
|
|
380
|
+
const budget = this.options.budgetBytes ?? HOST_SPOOL_BUDGET_BYTES;
|
|
381
|
+
const segments = [];
|
|
382
|
+
for (const name of this.closedSegments())
|
|
383
|
+
this.guard("stat_segment", () => void segments.push({ name, size: this.fs.stat(join(this.options.dir, name)).size }));
|
|
384
|
+
let total = segments.reduce((sum, s) => sum + s.size, 0);
|
|
385
|
+
let evicted = 0;
|
|
386
|
+
let evictedBytes = 0;
|
|
387
|
+
for (const segment of segments) {
|
|
388
|
+
if (total <= budget)
|
|
389
|
+
break;
|
|
390
|
+
const removed = this.guard("evict_segment", () => this.fs.unlink(join(this.options.dir, segment.name)));
|
|
391
|
+
total -= segment.size;
|
|
392
|
+
if (!removed)
|
|
393
|
+
continue;
|
|
394
|
+
evicted += 1;
|
|
395
|
+
evictedBytes += segment.size;
|
|
396
|
+
}
|
|
397
|
+
if (evicted > 0) {
|
|
398
|
+
const at = this.now();
|
|
399
|
+
const row = { type: "dropped", v: 1, at: new Date(at).toISOString().replace(/\.\d{3}Z$/, "Z"), instanceId: this.options.instanceId, segments: evicted, bytes: evictedBytes };
|
|
400
|
+
let n = 0;
|
|
401
|
+
let name = segmentName(this.options.instanceId, epochMinute(at), n);
|
|
402
|
+
while (this.fs.exists(join(this.options.dir, name)) || this.fs.exists(join(this.options.dir, `${name}.open`)))
|
|
403
|
+
name = segmentName(this.options.instanceId, epochMinute(at), (n += 1));
|
|
404
|
+
this.guard("write_dropped_row", () => this.fs.writeFile(join(this.options.dir, name), Buffer.from(`${JSON.stringify(row)}\n`, "utf8"), 0o600));
|
|
405
|
+
this.droppedSegments += evicted;
|
|
406
|
+
this.log({ event: "spool_evicted", segments: evicted, bytes: evictedBytes });
|
|
407
|
+
}
|
|
408
|
+
return evicted;
|
|
409
|
+
}
|
|
410
|
+
/** The segment's bytes, or null when it is gone (counted as a fault, never thrown). */
|
|
411
|
+
readSegment(path) {
|
|
412
|
+
let bytes = null;
|
|
413
|
+
this.guard("read_segment", () => {
|
|
414
|
+
bytes = Buffer.from(this.fs.readFile(path));
|
|
415
|
+
});
|
|
416
|
+
return bytes;
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* S6: `quarantine/` entries older than their retention are deleted; `quarantine/` and `exported/` are held under their
|
|
420
|
+
* byte caps, oldest first; an `.open` segment untouched past the reclaim age has no writer behind it and is closed so it
|
|
421
|
+
* uploads (a partial last line is skipped at inspection) and counts against the budget like any other.
|
|
422
|
+
*/
|
|
423
|
+
sweep() {
|
|
424
|
+
const at = this.now();
|
|
425
|
+
const quarantine = join(this.options.dir, "quarantine");
|
|
426
|
+
for (const name of this.fs.list(quarantine)) {
|
|
427
|
+
const path = join(quarantine, name);
|
|
428
|
+
this.guard("sweep", () => {
|
|
429
|
+
if (at - this.fs.stat(path).mtimeMs > (this.options.quarantineRetentionMs ?? QUARANTINE_RETENTION_MS))
|
|
430
|
+
this.fs.unlink(path);
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
for (const [sub, cap] of [
|
|
434
|
+
["quarantine", this.options.quarantineCapBytes ?? QUARANTINE_CAP_BYTES],
|
|
435
|
+
["exported", this.options.exportedCapBytes ?? EXPORTED_CAP_BYTES],
|
|
436
|
+
]) {
|
|
437
|
+
const listed = this.dirBytes(sub);
|
|
438
|
+
let total = listed.bytes;
|
|
439
|
+
for (const name of listed.names) {
|
|
440
|
+
if (total <= cap)
|
|
441
|
+
break;
|
|
442
|
+
const path = join(this.options.dir, sub, name);
|
|
443
|
+
let size = 0;
|
|
444
|
+
this.guard("stat_file", () => void (size = this.fs.stat(path).size));
|
|
445
|
+
if (this.guard("cap_evict", () => this.fs.unlink(path))) {
|
|
446
|
+
this.capEvictedFiles += 1;
|
|
447
|
+
this.log({ event: "cap_evicted", dir: sub, file: name, bytes: size });
|
|
448
|
+
}
|
|
449
|
+
total -= size;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
let names = [];
|
|
453
|
+
this.guard("list_spool", () => void (names = this.fs.list(this.options.dir)));
|
|
454
|
+
for (const name of names) {
|
|
455
|
+
if (!OPEN_SEGMENT_NAME.test(name))
|
|
456
|
+
continue;
|
|
457
|
+
const path = join(this.options.dir, name);
|
|
458
|
+
this.guard("reclaim_open", () => {
|
|
459
|
+
if (at - this.fs.stat(path).mtimeMs <= (this.options.openReclaimMs ?? OPEN_SEGMENT_RECLAIM_MS))
|
|
460
|
+
return;
|
|
461
|
+
this.fs.rename(path, path.slice(0, -".open".length));
|
|
462
|
+
this.reclaimedSegments += 1;
|
|
463
|
+
this.log({ event: "open_segment_reclaimed", segment: name.slice(0, -".open".length) });
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
quarantine(name, reason, detail) {
|
|
468
|
+
this.guard("quarantine", () => this.fs.rename(join(this.options.dir, name), join(this.options.dir, "quarantine", name)));
|
|
469
|
+
this.quarantinedSegments += 1;
|
|
470
|
+
this.log({ event: "segment_quarantined", segment: name, reason, ...(detail !== undefined ? { detail } : {}) });
|
|
471
|
+
}
|
|
472
|
+
/** One pass: sweep, budget, then each closed segment oldest first — validate, grant, POST, move — until the spool is empty, a hold, or a failure. Never throws. */
|
|
473
|
+
runOnce() {
|
|
474
|
+
if (this.inFlight)
|
|
475
|
+
return this.inFlight;
|
|
476
|
+
this.inFlight = this.pass().finally(() => {
|
|
477
|
+
this.inFlight = null;
|
|
478
|
+
});
|
|
479
|
+
return this.inFlight;
|
|
480
|
+
}
|
|
481
|
+
async pass() {
|
|
482
|
+
const result = { uploaded: [], quarantined: [], dropped: 0, held: false };
|
|
483
|
+
try {
|
|
484
|
+
this.sweep();
|
|
485
|
+
result.dropped = this.enforceBudget();
|
|
486
|
+
if (this.backoffUntilMs !== null && this.now() < this.backoffUntilMs) {
|
|
487
|
+
result.held = true;
|
|
488
|
+
return result;
|
|
489
|
+
}
|
|
490
|
+
for (const name of this.closedSegments()) {
|
|
491
|
+
const path = join(this.options.dir, name);
|
|
492
|
+
const instanceId = SEGMENT_NAME.exec(name)[1];
|
|
493
|
+
const read = this.readSegment(path);
|
|
494
|
+
// Taken away between the listing and the read (a sibling's eviction): nothing to upload, nothing lost here.
|
|
495
|
+
if (read === null)
|
|
496
|
+
continue;
|
|
497
|
+
const bytes = read;
|
|
498
|
+
if (bytes.length > SEGMENT_MAX_BYTES) {
|
|
499
|
+
this.quarantine(name, "oversize", bytes.length);
|
|
500
|
+
result.quarantined.push(name);
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
const inspection = inspectSegment(bytes, instanceId);
|
|
504
|
+
if (inspection.invalid.length > 0) {
|
|
505
|
+
this.quarantine(name, "invalid_rows", inspection.invalid.slice(0, 5));
|
|
506
|
+
result.quarantined.push(name);
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
if (inspection.rows.length === 0) {
|
|
510
|
+
// Nothing to say (an empty or partial-only segment): acknowledged locally, never uploaded.
|
|
511
|
+
this.guard("ack_segment", () => this.fs.unlink(path));
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
// The partial tail (a crashed writer's last line) is not sent: the bytes shipped are exactly the whole lines.
|
|
515
|
+
const payload = inspection.partialTail ? Buffer.from(bytes.subarray(0, bytes.lastIndexOf(0x0a) + 1)) : bytes;
|
|
516
|
+
const outcome = await this.sink.ship({ instanceId, segment: name, rows: inspection.rows, bytes: payload });
|
|
517
|
+
if (outcome.status === "hold") {
|
|
518
|
+
this.backoffUntilMs = this.now() + outcome.retryAfterMs;
|
|
519
|
+
this.lastError = `hold:${outcome.reason ?? "retry_after"}`;
|
|
520
|
+
this.log({ event: "upload_held", retryAfterSeconds: Math.round(outcome.retryAfterMs / 1000), reason: outcome.reason ?? null });
|
|
521
|
+
result.held = true;
|
|
522
|
+
return result;
|
|
523
|
+
}
|
|
524
|
+
if (outcome.status === "dropped") {
|
|
525
|
+
// S13: the sink gave this segment up for good (the bridge's drop-and-count): deleted, counted, never silent.
|
|
526
|
+
this.guard("drop_segment", () => this.fs.unlink(path));
|
|
527
|
+
this.droppedSegments += 1;
|
|
528
|
+
result.dropped += 1;
|
|
529
|
+
this.log({ event: "segment_dropped_by_sink", segment: name, sink: this.sink.kind, reason: outcome.reason });
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
if (outcome.status === "ok") {
|
|
533
|
+
// S6: delete on ack. The object key is the file name, so a lost response replays to the same key; nothing is kept here.
|
|
534
|
+
this.guard("ack_segment", () => this.fs.unlink(path));
|
|
535
|
+
this.guard("stamp_upload", () => this.fs.writeFile(join(this.options.dir, LAST_UPLOAD_MARKER), Buffer.from(`${new Date(this.now()).toISOString()}\n`, "utf8"), 0o600));
|
|
536
|
+
this.sentSegments += 1;
|
|
537
|
+
this.lastUploadMs = this.now();
|
|
538
|
+
this.lastError = null;
|
|
539
|
+
this.attempt = 0;
|
|
540
|
+
this.backoffUntilMs = null;
|
|
541
|
+
result.uploaded.push(name);
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
if (outcome.status === "too_large") {
|
|
545
|
+
this.quarantine(name, "oversize", outcome.bytes);
|
|
546
|
+
result.quarantined.push(name);
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
this.fail(outcome.reason);
|
|
550
|
+
result.held = true;
|
|
551
|
+
return result;
|
|
552
|
+
}
|
|
553
|
+
return result;
|
|
554
|
+
}
|
|
555
|
+
catch (error) {
|
|
556
|
+
this.fail(`pass:${error.message}`);
|
|
557
|
+
result.held = true;
|
|
558
|
+
return result;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
fail(reason) {
|
|
562
|
+
const delay = backoffDelayMs(this.attempt, this.options.random);
|
|
563
|
+
this.attempt += 1;
|
|
564
|
+
this.backoffUntilMs = this.now() + delay;
|
|
565
|
+
this.lastError = reason;
|
|
566
|
+
this.log({ event: "upload_failed", reason, attempt: this.attempt, backoffMs: delay });
|
|
567
|
+
}
|
|
568
|
+
/** Passes every `intervalSeconds` (the grant's `uploadIntervalSeconds` once one has answered), with a random phase offset so a fleet does not upload together. */
|
|
569
|
+
start() {
|
|
570
|
+
this.stopped = false;
|
|
571
|
+
this.schedule((this.options.random ?? Math.random)() * this.intervalSeconds * 1000);
|
|
572
|
+
}
|
|
573
|
+
schedule(delayMs) {
|
|
574
|
+
if (this.timer)
|
|
575
|
+
clearTimeout(this.timer);
|
|
576
|
+
if (this.stopped)
|
|
577
|
+
return;
|
|
578
|
+
this.nextPassMs = this.now() + delayMs;
|
|
579
|
+
this.timer = setTimeout(() => {
|
|
580
|
+
void this.runOnce().finally(() => {
|
|
581
|
+
const wait = this.backoffUntilMs !== null && this.backoffUntilMs > this.now() ? this.backoffUntilMs - this.now() : this.intervalSeconds * 1000;
|
|
582
|
+
this.schedule(Math.max(250, wait));
|
|
583
|
+
});
|
|
584
|
+
}, delayMs);
|
|
585
|
+
this.timer.unref?.();
|
|
586
|
+
}
|
|
587
|
+
async stop() {
|
|
588
|
+
this.stopped = true;
|
|
589
|
+
if (this.timer)
|
|
590
|
+
clearTimeout(this.timer);
|
|
591
|
+
this.timer = null;
|
|
592
|
+
this.nextPassMs = null;
|
|
593
|
+
if (this.inFlight)
|
|
594
|
+
await this.inFlight;
|
|
595
|
+
}
|
|
596
|
+
status() {
|
|
597
|
+
return {
|
|
598
|
+
lastUploadAt: this.lastUploadMs === null ? null : new Date(this.lastUploadMs).toISOString(),
|
|
599
|
+
lastError: this.lastError,
|
|
600
|
+
backoffUntil: this.backoffUntilMs === null || this.backoffUntilMs <= this.now() ? null : new Date(this.backoffUntilMs).toISOString(),
|
|
601
|
+
attempt: this.attempt,
|
|
602
|
+
inFlight: this.inFlight !== null,
|
|
603
|
+
intervalSeconds: this.intervalSeconds,
|
|
604
|
+
nextPassAt: this.nextPassMs === null || !this.timer ? null : new Date(this.nextPassMs).toISOString(),
|
|
605
|
+
sentSegments: this.sentSegments,
|
|
606
|
+
quarantinedSegments: this.quarantinedSegments,
|
|
607
|
+
droppedSegments: this.droppedSegments,
|
|
608
|
+
sink: this.sink.kind,
|
|
609
|
+
grants: (this.sink.status?.()?.grants ?? []),
|
|
610
|
+
depth: this.depth(),
|
|
611
|
+
tree: this.tree(),
|
|
612
|
+
reclaimedSegments: this.reclaimedSegments,
|
|
613
|
+
capEvictedFiles: this.capEvictedFiles,
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
//# sourceMappingURL=uploader.js.map
|