@hue-run/sdk 0.18.2 → 0.19.1
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/CLI.md +16 -12
- package/EVALUATIONS.md +1 -1
- package/README.md +110 -68
- package/dist/blobs.d.ts +116 -36
- package/dist/blobs.js +359 -162
- package/dist/cli/langfuse.d.ts +21 -5
- package/dist/cli/langfuse.js +123 -16
- package/dist/client.d.ts +11 -7
- package/dist/client.js +35 -26
- package/dist/inline-files.d.ts +21 -4
- package/dist/inline-files.js +100 -14
- package/dist/large-media.d.ts +15 -2
- package/dist/large-media.js +27 -16
- package/dist/transport.d.ts +38 -7
- package/dist/transport.js +240 -63
- package/dist/types.d.ts +5 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/blobs.js
CHANGED
|
@@ -8,9 +8,11 @@ import { capLogBody, TRUNCATED_KEY, truncatedMarker, withTruncatedKeys } from ".
|
|
|
8
8
|
import { truncateUtf8 } from "./safety.js";
|
|
9
9
|
/**
|
|
10
10
|
* Large values and binary media travel apart from their span through reservation, presigned PUT,
|
|
11
|
-
* and completion.
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* and completion. Export hashes each value, writes its reference (text keeps a 16 KiB prefix,
|
|
12
|
+
* binary media digest metadata on its part) and its `hue.blobs` entry, and sends the record; the
|
|
13
|
+
* upload runs afterwards in the background, as Langfuse uploads media. A value refused when its
|
|
14
|
+
* record is exported keeps its inline fallback or file metadata; an upload that fails after export
|
|
15
|
+
* leaves its entry, which Hue reads as missing. Both are reported as warnings and counted.
|
|
14
16
|
*/
|
|
15
17
|
/** The span attribute listing the values the SDK uploaded apart from the span: one compact JSON
|
|
16
18
|
* object per value, `{"key", "sha256", "size", "content_type"}`, with an optional `name`. */
|
|
@@ -23,30 +25,42 @@ export const BLOB_PREFIX_BYTES = 16 * 1024;
|
|
|
23
25
|
export const MAX_BLOB_ENTRIES = 64;
|
|
24
26
|
const MAX_BLOB_KEY_LENGTH = 256;
|
|
25
27
|
/**
|
|
26
|
-
* The values over the inline limit that queued
|
|
27
|
-
* apart from `maxQueueBytes` (a string as two bytes a code unit, bytes as
|
|
28
|
-
*
|
|
29
|
-
*
|
|
28
|
+
* The values over the inline limit that queued records and unsettled uploads may hold whole at
|
|
29
|
+
* once, charged apart from `maxQueueBytes` (a string as two bytes a code unit, bytes as
|
|
30
|
+
* themselves): each is held until its record is exported and its upload settles. A value the
|
|
31
|
+
* budget cannot hold is cut when it is queued, as before uploads existed; a single value larger
|
|
32
|
+
* than the whole budget is held while nothing else is.
|
|
30
33
|
*/
|
|
31
34
|
export const MAX_HELD_BLOB_BYTES = 128 * 1024 * 1024;
|
|
32
35
|
/** Uploads in flight at once, across both signals' exports. */
|
|
33
36
|
const UPLOAD_CONCURRENCY = 4;
|
|
34
|
-
/** How long a receiver without the upload route is believed to stay without it
|
|
37
|
+
/** How long a receiver without the upload route is believed to stay without it: an answer misread
|
|
38
|
+
* as one (a proxy's page during a deploy) turns uploads off for this long, never for good. */
|
|
35
39
|
const UNSUPPORTED_PAUSE_MILLIS = 10 * 60_000;
|
|
36
|
-
/**
|
|
37
|
-
*
|
|
38
|
-
const
|
|
40
|
+
/** Values hashed at once for export, apart from the uploads' places: export never waits for an
|
|
41
|
+
* upload to hash. */
|
|
42
|
+
const HASH_CONCURRENCY = 4;
|
|
43
|
+
/** Uploads queued and not yet settled; past it a value keeps its fallback. */
|
|
44
|
+
const MAX_QUEUED_UPLOADS = 4096;
|
|
45
|
+
/** How many times a reservation or a PUT is tried. */
|
|
46
|
+
const UPLOAD_ATTEMPTS = 3;
|
|
39
47
|
const MAX_JSON_RESPONSE_BYTES = 64 * 1024;
|
|
40
48
|
const MAX_ERROR_RESPONSE_BYTES = 4096;
|
|
41
49
|
/** Text is encoded to UTF-8 a slice at a time to hash and upload it: no full copy is held. */
|
|
42
50
|
const TEXT_CHUNK_UNITS = 1 << 20;
|
|
43
51
|
const RETRY_DELAY_MILLIS = 1000;
|
|
44
|
-
/** The longest
|
|
45
|
-
const
|
|
46
|
-
/** How long one export may spend
|
|
52
|
+
/** The longest wait before an upload's next attempt, a `Retry-After` included. */
|
|
53
|
+
export const MAX_RETRY_AFTER_MILLIS = 60_000;
|
|
54
|
+
/** How long one export may spend hashing its values: past it a value keeps its fallback. Also
|
|
55
|
+
* the import's least upload deadline, and part of an upload's lifetime. */
|
|
47
56
|
export function uploadBudgetMillis(timeoutMillis) {
|
|
48
57
|
return timeoutMillis * 6;
|
|
49
58
|
}
|
|
59
|
+
/** How long one upload may take from when its record's export queued it: six export timeouts for
|
|
60
|
+
* its requests and up to a minute of Retry-After of its own. */
|
|
61
|
+
export function uploadLifetimeMillis(timeoutMillis) {
|
|
62
|
+
return uploadBudgetMillis(timeoutMillis) + MAX_RETRY_AFTER_MILLIS;
|
|
63
|
+
}
|
|
50
64
|
/** The fixed description of each fallback, as an export issue reports it. */
|
|
51
65
|
export const FALLBACK_MESSAGES = {
|
|
52
66
|
unsupported: "This Hue server does not accept uploaded values; large values were cut inline and media kept digest metadata",
|
|
@@ -54,6 +68,14 @@ export const FALLBACK_MESSAGES = {
|
|
|
54
68
|
tooLarge: "Content over its upload limit kept inline fallback or file metadata",
|
|
55
69
|
budget: "Content did not fit the SDK's upload budget; large values were cut inline and media kept digest metadata",
|
|
56
70
|
};
|
|
71
|
+
/** The fixed description of each failure of an upload that ran after its record was exported:
|
|
72
|
+
* the record lists the value under `hue.blobs`, and Hue reads it as missing. */
|
|
73
|
+
export const LATE_FALLBACK_MESSAGES = {
|
|
74
|
+
unsupported: "This Hue server does not accept uploaded values; values exported before it answered list hue.blobs entries that it does not store",
|
|
75
|
+
failed: "Content could not be uploaded after its record was exported; its hue.blobs entry names a value Hue does not store",
|
|
76
|
+
tooLarge: "Content over its upload limit could not be uploaded after its record was exported; its hue.blobs entry names a value Hue does not store",
|
|
77
|
+
budget: "Content did not fit the SDK's upload budget after its record was exported; its hue.blobs entry names a value Hue does not store",
|
|
78
|
+
};
|
|
57
79
|
/** The `hue.blobs` key of a staged file: its part's for an attachment placed in a value, else
|
|
58
80
|
* its `hue.file` event's. */
|
|
59
81
|
export function mediaKey(file) {
|
|
@@ -68,6 +90,32 @@ export function countFallback(tally, reason, traceId) {
|
|
|
68
90
|
entry.traceIds.add(traceId);
|
|
69
91
|
tally.fallbacks.set(reason, entry);
|
|
70
92
|
}
|
|
93
|
+
/** Adds one tally's outcomes to another's. */
|
|
94
|
+
export function mergeTally(into, from) {
|
|
95
|
+
into.uploaded += from.uploaded;
|
|
96
|
+
for (const [reason, { count, traceIds }] of from.fallbacks) {
|
|
97
|
+
const entry = into.fallbacks.get(reason) ?? { count: 0, traceIds: new Set() };
|
|
98
|
+
entry.count += count;
|
|
99
|
+
for (const traceId of traceIds)
|
|
100
|
+
entry.traceIds.add(traceId);
|
|
101
|
+
into.fallbacks.set(reason, entry);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** Adds `from` to `into` once `settled` all settle. Built apart from the step that queued the
|
|
105
|
+
* uploads, so a pending upload keeps alive the two tallies alone, never that step's record. */
|
|
106
|
+
export function mergeWhenSettled(into, from, settled) {
|
|
107
|
+
return Promise.all(settled).then(() => mergeTally(into, from));
|
|
108
|
+
}
|
|
109
|
+
/** Counts an upload's outcome in `tally` once it settles. Built apart from the placement that
|
|
110
|
+
* queued it, so the upload keeps alive its own bytes alone, never the record placed around it. */
|
|
111
|
+
function countWhenSettled(done, tally, traceId) {
|
|
112
|
+
return done.then((outcome) => {
|
|
113
|
+
if (outcome.ok)
|
|
114
|
+
tally.uploaded++;
|
|
115
|
+
else
|
|
116
|
+
countFallback(tally, outcome.reason, traceId);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
71
119
|
/** A media type accepted by the upload contract. */
|
|
72
120
|
export function isBlobContentType(value) {
|
|
73
121
|
return isMediaContentType(value);
|
|
@@ -109,6 +157,13 @@ export function sizeOf(value) {
|
|
|
109
157
|
export function heldBytesOf(value) {
|
|
110
158
|
return isByteSource(value) ? 0 : value.byteLength;
|
|
111
159
|
}
|
|
160
|
+
/** The memory a queued upload keeps alive until it settles: a string as two bytes a code unit,
|
|
161
|
+
* bytes as themselves, a source the strings it reads from, each whole. */
|
|
162
|
+
function heldBy(value) {
|
|
163
|
+
if (isByteSource(value))
|
|
164
|
+
return value.held ?? [];
|
|
165
|
+
return [{ bytes: typeof value === "string" ? value.length * 2 : value.byteLength }];
|
|
166
|
+
}
|
|
112
167
|
/** A string's UTF-8 encoding a slice at a time, never splitting a surrogate pair. */
|
|
113
168
|
function* utf8Chunks(text) {
|
|
114
169
|
// One buffer, reused: a reader is done with each slice before it asks for the next.
|
|
@@ -180,26 +235,111 @@ function parseJson(body) {
|
|
|
180
235
|
function isObject(value) {
|
|
181
236
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
182
237
|
}
|
|
238
|
+
/** Places for work of one kind: a finishing run hands its place to the next one waiting, so none
|
|
239
|
+
* is ever taken twice. */
|
|
240
|
+
class Slots {
|
|
241
|
+
size;
|
|
242
|
+
running = 0;
|
|
243
|
+
waiting = [];
|
|
244
|
+
released = false;
|
|
245
|
+
constructor(size) {
|
|
246
|
+
this.size = size;
|
|
247
|
+
}
|
|
248
|
+
/** Runs `work` once fewer than `size` runs are in progress. */
|
|
249
|
+
async run(work) {
|
|
250
|
+
await this.take();
|
|
251
|
+
try {
|
|
252
|
+
return await work();
|
|
253
|
+
}
|
|
254
|
+
finally {
|
|
255
|
+
this.give();
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
/** Runs `work` (a wait) from within a run without its place, and takes a place again after:
|
|
259
|
+
* a run that waits never holds the place another could use. Once `deadline` passes it stops
|
|
260
|
+
* waiting for a place and goes on with one over the size, to end without more work. */
|
|
261
|
+
async away(work, deadline = Infinity) {
|
|
262
|
+
this.give();
|
|
263
|
+
try {
|
|
264
|
+
return await work();
|
|
265
|
+
}
|
|
266
|
+
finally {
|
|
267
|
+
await this.take(deadline);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
async take(deadline = Infinity) {
|
|
271
|
+
if (this.running < this.size || this.released) {
|
|
272
|
+
this.running++;
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
await new Promise((resolve) => {
|
|
276
|
+
const start = () => {
|
|
277
|
+
clearTimeout(timer);
|
|
278
|
+
resolve();
|
|
279
|
+
};
|
|
280
|
+
const timer = deadline < Infinity
|
|
281
|
+
? setTimeout(() => {
|
|
282
|
+
const at = this.waiting.indexOf(start);
|
|
283
|
+
if (at >= 0)
|
|
284
|
+
this.waiting.splice(at, 1);
|
|
285
|
+
this.running++;
|
|
286
|
+
resolve();
|
|
287
|
+
}, deadline - Date.now())
|
|
288
|
+
: undefined;
|
|
289
|
+
this.waiting.push(start);
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
give() {
|
|
293
|
+
// A place taken over the size, past a deadline, is given back rather than handed on.
|
|
294
|
+
const next = this.running > this.size ? undefined : this.waiting.shift();
|
|
295
|
+
if (next)
|
|
296
|
+
next();
|
|
297
|
+
else
|
|
298
|
+
this.running--;
|
|
299
|
+
}
|
|
300
|
+
/** Starts every waiting run now; none waits again. */
|
|
301
|
+
release() {
|
|
302
|
+
this.released = true;
|
|
303
|
+
for (const run of this.waiting.splice(0)) {
|
|
304
|
+
this.running++;
|
|
305
|
+
run();
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
183
309
|
/**
|
|
184
310
|
* Uploads values to Hue: reserves each with `POST /api/v1/otlp/blobs`, PUTs its bytes to the
|
|
185
|
-
* presigned URL Hue answers (skipped when Hue already stores the value), and completes it.
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
* {@link
|
|
311
|
+
* presigned URL Hue answers (skipped when Hue already stores the value), and completes it. An
|
|
312
|
+
* export queues its values ({@link enqueue}) and sends its records without waiting for them; the
|
|
313
|
+
* import stores each value before it sends its span ({@link store}). At most
|
|
314
|
+
* {@link UPLOAD_CONCURRENCY} uploads run at once, and {@link HASH_CONCURRENCY} hashes apart from
|
|
315
|
+
* them. A reservation or PUT that gets no answer, a 408, a 429 or a 5xx is tried
|
|
316
|
+
* {@link UPLOAD_ATTEMPTS} times; every request has a deadline and none follows a redirect. A
|
|
317
|
+
* receiver without the route (a 404 without Hue's JSON error, a 405 or 501, or a 200 that is no
|
|
318
|
+
* reservation) is remembered for {@link UNSUPPORTED_PAUSE_MILLIS}: values exported meanwhile keep
|
|
319
|
+
* their fallback, never an entry the receiver cannot resolve.
|
|
191
320
|
*/
|
|
192
321
|
export class BlobUploader {
|
|
193
322
|
options;
|
|
323
|
+
/** Until when the receiver is taken to lack the upload route. */
|
|
194
324
|
unsupportedUntil = 0;
|
|
195
|
-
pausedUntil = 0;
|
|
196
325
|
closed = false;
|
|
197
|
-
|
|
198
|
-
|
|
326
|
+
uploads = new Slots(UPLOAD_CONCURRENCY);
|
|
327
|
+
hashes = new Slots(HASH_CONCURRENCY);
|
|
328
|
+
queued = 0;
|
|
329
|
+
queuedBytes = 0;
|
|
330
|
+
/** What queued uploads keep alive, each charged once, from the first upload holding it until
|
|
331
|
+
* the last settles: two files read in place from one recorded string charge it once, whole. */
|
|
332
|
+
holding = new Map();
|
|
333
|
+
sleeps = new Set();
|
|
199
334
|
requests = new Set();
|
|
200
335
|
outcomes = new Map();
|
|
336
|
+
/** The first wait before another attempt, doubled each time; tests shorten it. */
|
|
337
|
+
retryDelayMillis = RETRY_DELAY_MILLIS;
|
|
338
|
+
/** How long a queued upload may take; tests shorten it. */
|
|
339
|
+
lifetimeMillis;
|
|
201
340
|
constructor(options) {
|
|
202
341
|
this.options = options;
|
|
342
|
+
this.lifetimeMillis = uploadLifetimeMillis(options.timeoutMillis);
|
|
203
343
|
}
|
|
204
344
|
/** Whether a value over the inline limit is worth holding whole for upload now. */
|
|
205
345
|
available() {
|
|
@@ -209,35 +349,31 @@ export class BlobUploader {
|
|
|
209
349
|
unsupported() {
|
|
210
350
|
return Date.now() < this.unsupportedUntil;
|
|
211
351
|
}
|
|
212
|
-
/** Ends uploads in flight and refuses new ones:
|
|
352
|
+
/** Ends uploads in flight, queued uploads and their waits, and refuses new ones: values not
|
|
353
|
+
* yet exported fall back to the inline cut, and uploads queued by exported records fail. */
|
|
213
354
|
close() {
|
|
214
355
|
this.closed = true;
|
|
215
356
|
for (const request of this.requests)
|
|
216
357
|
request.destroy();
|
|
217
358
|
this.requests.clear();
|
|
218
359
|
this.outcomes.clear();
|
|
219
|
-
for (const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
360
|
+
for (const cancel of [...this.sleeps])
|
|
361
|
+
cancel();
|
|
362
|
+
this.uploads.release();
|
|
363
|
+
this.hashes.release();
|
|
223
364
|
}
|
|
224
365
|
/** Runs `work` once fewer than {@link UPLOAD_CONCURRENCY} uploads are in flight. */
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
if (next)
|
|
237
|
-
next();
|
|
238
|
-
else
|
|
239
|
-
this.running--;
|
|
240
|
-
}
|
|
366
|
+
limit(work) {
|
|
367
|
+
return this.uploads.run(work);
|
|
368
|
+
}
|
|
369
|
+
/** The SHA-256 and size of `value` (see {@link digestOf}), hashed in a place of its own: an
|
|
370
|
+
* export never waits for an upload to hash its values. */
|
|
371
|
+
digest(value, deadline) {
|
|
372
|
+
return this.hashes.run(() => digestOf(value, deadline));
|
|
373
|
+
}
|
|
374
|
+
/** The bytes queued uploads keep alive until they settle. */
|
|
375
|
+
pendingBytes() {
|
|
376
|
+
return this.queuedBytes;
|
|
241
377
|
}
|
|
242
378
|
/** Why a value cannot be uploaded right now without trying, if it cannot. */
|
|
243
379
|
refusal() {
|
|
@@ -245,21 +381,10 @@ export class BlobUploader {
|
|
|
245
381
|
return "failed";
|
|
246
382
|
if (this.unsupported())
|
|
247
383
|
return "unsupported";
|
|
248
|
-
if (Date.now() < this.pausedUntil)
|
|
249
|
-
return "failed";
|
|
250
384
|
return undefined;
|
|
251
385
|
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
}
|
|
255
|
-
/**
|
|
256
|
-
* Stores `value` under `key` of the trace: reserved, uploaded unless Hue already holds it, and
|
|
257
|
-
* completed. Only a fresh upload takes a {@link limit} slot: another reference to bytes the
|
|
258
|
-
* trace is already storing shares that upload's outcome without one, so repeated files never
|
|
259
|
-
* hold the slots new files need. The caller must not hold a slot.
|
|
260
|
-
*/
|
|
261
|
-
async store(traceId, key, value, contentType, digest, upload) {
|
|
262
|
-
// Every occurrence is validated even when a previous occurrence already uploaded the bytes.
|
|
386
|
+
/** Why the value cannot be uploaded at all, whatever Hue would answer. */
|
|
387
|
+
invalid(traceId, key, value, contentType, digest) {
|
|
263
388
|
if (!isBlobKey(key) ||
|
|
264
389
|
!isBlobContentType(contentType) ||
|
|
265
390
|
!/^[0-9a-f]{32}$/.test(traceId) ||
|
|
@@ -268,9 +393,95 @@ export class BlobUploader {
|
|
|
268
393
|
!Number.isSafeInteger(digest.size) ||
|
|
269
394
|
digest.size <= 0 ||
|
|
270
395
|
digest.size !== sizeOf(value))
|
|
271
|
-
return
|
|
396
|
+
return "failed";
|
|
272
397
|
if (digest.size > MAX_BLOB_BYTES)
|
|
273
|
-
return
|
|
398
|
+
return "tooLarge";
|
|
399
|
+
return undefined;
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Queues `value`'s upload for an exported record, deciding now what the record lists: a value
|
|
403
|
+
* refused without trying (see {@link refusal}), invalid, or past the queue's
|
|
404
|
+
* {@link MAX_QUEUED_UPLOADS} uploads or {@link MAX_HELD_BLOB_BYTES} keeps its fallback. A queued
|
|
405
|
+
* upload runs within {@link uploadLifetimeMillis} of now, holding `value` until it settles;
|
|
406
|
+
* `done` never rejects. `bytes` is what it adds to {@link pendingBytes}: the strings it keeps
|
|
407
|
+
* alive that no other queued upload holds, charged until `released`, once the last upload
|
|
408
|
+
* holding them settles.
|
|
409
|
+
*/
|
|
410
|
+
enqueue(traceId, key, value, contentType, digest) {
|
|
411
|
+
const refused = this.refusal() ?? this.invalid(traceId, key, value, contentType, digest);
|
|
412
|
+
if (refused)
|
|
413
|
+
return { ok: false, reason: refused };
|
|
414
|
+
const held = heldBy(value);
|
|
415
|
+
const fresh = held.filter((text) => !this.holding.has(text));
|
|
416
|
+
const bytes = fresh.reduce((total, text) => total + text.bytes, 0);
|
|
417
|
+
// An upload whose strings are all held already adds no memory, so only the job cap applies.
|
|
418
|
+
if (this.queued >= MAX_QUEUED_UPLOADS ||
|
|
419
|
+
(bytes > 0 && this.queuedBytes > 0 && this.queuedBytes + bytes > MAX_HELD_BLOB_BYTES))
|
|
420
|
+
return { ok: false, reason: "budget" };
|
|
421
|
+
this.queued++;
|
|
422
|
+
this.queuedBytes += bytes;
|
|
423
|
+
const released = fresh.map((text) => new Promise((release) => {
|
|
424
|
+
this.holding.set(text, { uploads: 0, release });
|
|
425
|
+
}));
|
|
426
|
+
for (const text of held)
|
|
427
|
+
this.holding.get(text).uploads++;
|
|
428
|
+
const upload = {
|
|
429
|
+
deadline: Date.now() + this.lifetimeMillis,
|
|
430
|
+
wait: (millis) => this.sleep(millis),
|
|
431
|
+
};
|
|
432
|
+
// store() never rejects; the job holds `value` only until it settles.
|
|
433
|
+
const done = this.store(traceId, key, value, contentType, digest, upload).finally(() => {
|
|
434
|
+
this.queued--;
|
|
435
|
+
for (const text of held) {
|
|
436
|
+
const holding = this.holding.get(text);
|
|
437
|
+
if (--holding.uploads > 0)
|
|
438
|
+
continue;
|
|
439
|
+
this.holding.delete(text);
|
|
440
|
+
this.queuedBytes -= text.bytes;
|
|
441
|
+
holding.release();
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
return { ok: true, done, bytes, released: Promise.all(released).then(() => { }) };
|
|
445
|
+
}
|
|
446
|
+
/** Waits `millis`; false when {@link close} ended the wait first. The timer keeps the process
|
|
447
|
+
* running, as an export in progress does, within the upload's lifetime. */
|
|
448
|
+
sleep(millis) {
|
|
449
|
+
if (this.closed)
|
|
450
|
+
return Promise.resolve(false);
|
|
451
|
+
return new Promise((resolve) => {
|
|
452
|
+
const done = (ran) => {
|
|
453
|
+
clearTimeout(timer);
|
|
454
|
+
this.sleeps.delete(cancel);
|
|
455
|
+
resolve(ran && !this.closed);
|
|
456
|
+
};
|
|
457
|
+
const cancel = () => done(false);
|
|
458
|
+
const timer = setTimeout(() => done(true), millis);
|
|
459
|
+
this.sleeps.add(cancel);
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
/** Waits before attempt `attempt + 1`: `Retry-After` when the answer gave one, otherwise a
|
|
463
|
+
* second doubled each attempt with jitter, at most a minute. The upload's place is given up
|
|
464
|
+
* meanwhile, so uploads queued behind it run. False when the wait would pass the upload's
|
|
465
|
+
* deadline, was ended, or no place came free again before the deadline. */
|
|
466
|
+
async retryWait(attempt, retryAfter, upload) {
|
|
467
|
+
const millis = Math.min(retryAfter ?? this.retryDelayMillis * (2 ** attempt + Math.random()), MAX_RETRY_AFTER_MILLIS);
|
|
468
|
+
if (this.closed || Date.now() + millis >= upload.deadline)
|
|
469
|
+
return false;
|
|
470
|
+
return ((await this.uploads.away(() => upload.wait(millis), upload.deadline)) &&
|
|
471
|
+
!this.closed &&
|
|
472
|
+
Date.now() < upload.deadline);
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Stores `value` under `key` of the trace: reserved, uploaded unless Hue already holds it, and
|
|
476
|
+
* completed. Only a fresh upload takes a {@link limit} slot: another reference to bytes the
|
|
477
|
+
* trace is already storing shares that upload's outcome without one, so repeated files never
|
|
478
|
+
* hold the slots new files need. The caller must not hold a slot. Never rejects.
|
|
479
|
+
*/
|
|
480
|
+
async store(traceId, key, value, contentType, digest, upload) {
|
|
481
|
+
// Every occurrence is validated even when a previous occurrence already uploaded the bytes.
|
|
482
|
+
const invalid = this.invalid(traceId, key, value, contentType, digest);
|
|
483
|
+
if (invalid)
|
|
484
|
+
return { ok: false, reason: invalid };
|
|
274
485
|
const identity = `${traceId}:${digest.sha256}`;
|
|
275
486
|
for (let known = this.outcomes.get(identity); known; known = this.outcomes.get(identity)) {
|
|
276
487
|
const shared = await known.promise;
|
|
@@ -303,6 +514,9 @@ export class BlobUploader {
|
|
|
303
514
|
const refused = this.refusal();
|
|
304
515
|
if (refused)
|
|
305
516
|
return { ok: false, reason: refused };
|
|
517
|
+
// Queued past its lifetime: not tried. Failed, not budget: the import holds its span back.
|
|
518
|
+
if (Date.now() >= upload.deadline)
|
|
519
|
+
return { ok: false, reason: "failed" };
|
|
306
520
|
if (digest.size > MAX_BLOB_BYTES)
|
|
307
521
|
return { ok: false, reason: "tooLarge" };
|
|
308
522
|
if (!isBlobKey(key) ||
|
|
@@ -319,8 +533,7 @@ export class BlobUploader {
|
|
|
319
533
|
});
|
|
320
534
|
let reservation = await this.reserve(body, digest.size, upload);
|
|
321
535
|
let presigns = 1;
|
|
322
|
-
let
|
|
323
|
-
while (!("fallback" in reservation) && !reservation.exists) {
|
|
536
|
+
for (let attempt = 0; !("fallback" in reservation) && !reservation.exists;) {
|
|
324
537
|
const { grant } = reservation;
|
|
325
538
|
const put = await this.put(grant, value, digest.size, upload.deadline);
|
|
326
539
|
if (put === "stored")
|
|
@@ -329,19 +542,14 @@ export class BlobUploader {
|
|
|
329
542
|
return { ok: false, reason: "failed", changed: true };
|
|
330
543
|
if (this.closed)
|
|
331
544
|
return { ok: false, reason: "failed" };
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MILLIS));
|
|
545
|
+
if (typeof put === "object") {
|
|
546
|
+
if (++attempt >= UPLOAD_ATTEMPTS ||
|
|
547
|
+
!(await this.retryWait(attempt - 1, put.retryAfter, upload)))
|
|
548
|
+
return { ok: false, reason: "failed" };
|
|
337
549
|
if (Date.now() < grant.expiresAt)
|
|
338
550
|
continue;
|
|
339
551
|
}
|
|
340
|
-
else if (put
|
|
341
|
-
this.pause();
|
|
342
|
-
return { ok: false, reason: "failed" };
|
|
343
|
-
}
|
|
344
|
-
else if (!expired)
|
|
552
|
+
else if (put !== "expired" && Date.now() < grant.expiresAt)
|
|
345
553
|
return { ok: false, reason: "failed" };
|
|
346
554
|
// The URL expired: reserved again once.
|
|
347
555
|
if (presigns++ >= 2)
|
|
@@ -350,13 +558,10 @@ export class BlobUploader {
|
|
|
350
558
|
}
|
|
351
559
|
if ("fallback" in reservation)
|
|
352
560
|
return { ok: false, reason: reservation.fallback };
|
|
353
|
-
if (!reservation.exists)
|
|
354
|
-
// For Hue's accounting only: a value is read whether or not it was completed,
|
|
355
|
-
// completion is not
|
|
356
|
-
|
|
357
|
-
if (!completion || completion.status >= 500 || completion.status === 429)
|
|
358
|
-
this.pause();
|
|
359
|
-
}
|
|
561
|
+
if (!reservation.exists)
|
|
562
|
+
// For Hue's accounting only: a value is read whether or not it was completed, and Hue
|
|
563
|
+
// stamps a missed completion on the next reservation, so its outcome is not read.
|
|
564
|
+
await this.request("POST", new URL("/api/v1/otlp/blobs/complete", this.options.baseUrl), this.jsonHeaders(), JSON.stringify({ traceId, sha256: digest.sha256 }), Math.min(upload.deadline, Date.now() + this.options.timeoutMillis), MAX_JSON_RESPONSE_BYTES);
|
|
360
565
|
return { ok: true, contentType: reservation.contentType };
|
|
361
566
|
}
|
|
362
567
|
jsonHeaders() {
|
|
@@ -366,71 +571,46 @@ export class BlobUploader {
|
|
|
366
571
|
"User-Agent": this.options.userAgent,
|
|
367
572
|
};
|
|
368
573
|
}
|
|
369
|
-
/** Reserves the value: Hue answers that it holds it already, or a presigned PUT.
|
|
370
|
-
*
|
|
574
|
+
/** Reserves the value: Hue answers that it holds it already, or a presigned PUT. No answer, a
|
|
575
|
+
* 408, a 429 or a 5xx other than 501 is tried again, after its `Retry-After` when it gave one,
|
|
576
|
+
* within the upload's deadline. */
|
|
371
577
|
async reserve(body, size, upload) {
|
|
372
578
|
const url = new URL("/api/v1/otlp/blobs", this.options.baseUrl);
|
|
373
|
-
for (let attempt = 0
|
|
579
|
+
for (let attempt = 0;; attempt++) {
|
|
374
580
|
if (this.closed)
|
|
375
581
|
return { fallback: "failed" };
|
|
376
582
|
const reply = await this.request("POST", url, this.jsonHeaders(), body, Math.min(upload.deadline, Date.now() + this.options.timeoutMillis), MAX_JSON_RESPONSE_BYTES);
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
return this.reservation(answer, size);
|
|
405
|
-
if (status === 413)
|
|
406
|
-
return { fallback: "tooLarge" };
|
|
407
|
-
if (status === 429) {
|
|
408
|
-
const wait = retryAfterMillis(reply.headers) ?? RETRY_DELAY_MILLIS;
|
|
409
|
-
if (Date.now() + wait < upload.deadline && (await upload.wait(wait)))
|
|
410
|
-
continue;
|
|
411
|
-
this.pause();
|
|
412
|
-
return { fallback: "failed" };
|
|
583
|
+
let retryAfter;
|
|
584
|
+
if (reply) {
|
|
585
|
+
const { status } = reply;
|
|
586
|
+
const answer = status === 200 || status === 404 || status === 409 ? parseJson(reply.body) : undefined;
|
|
587
|
+
// A receiver without the route answers its framework's not-found (Hue's own 404, an
|
|
588
|
+
// archived project, is JSON with an `error`), refuses the method, or answers something
|
|
589
|
+
// that is no reservation at all: it is not asked again for a while, since the values
|
|
590
|
+
// exported while it answers are listed for bytes it cannot store.
|
|
591
|
+
if (status === 405 ||
|
|
592
|
+
status === 501 ||
|
|
593
|
+
(status === 404 && !(isObject(answer) && typeof answer.error === "string")) ||
|
|
594
|
+
(status === 200 &&
|
|
595
|
+
!(isObject(answer) && (answer.status === "exists" || answer.status === "upload")))) {
|
|
596
|
+
this.unsupportedUntil = Date.now() + UNSUPPORTED_PAUSE_MILLIS;
|
|
597
|
+
return { fallback: "unsupported" };
|
|
598
|
+
}
|
|
599
|
+
// A reservation that cannot be used fails its value.
|
|
600
|
+
if (status === 200)
|
|
601
|
+
return this.reservation(answer, size);
|
|
602
|
+
if (status === 409 && isObject(answer) && answer.status === "exists")
|
|
603
|
+
return this.reservation(answer, size);
|
|
604
|
+
if (status === 413)
|
|
605
|
+
return { fallback: "tooLarge" };
|
|
606
|
+
// Refusals of the value or the key (400, 401, 403, Hue's own 404, a conflict) are final.
|
|
607
|
+
if (status !== 408 && status !== 429 && status < 500)
|
|
608
|
+
return { fallback: "failed" };
|
|
609
|
+
retryAfter = retryAfterMillis(reply.headers);
|
|
413
610
|
}
|
|
414
|
-
if (
|
|
415
|
-
const wait = retryAfterMillis(reply.headers) ?? RETRY_DELAY_MILLIS;
|
|
416
|
-
if (attempt === 0 && wait <= MAX_TRANSIENT_WAIT_MILLIS && (await this.delay(wait, upload)))
|
|
417
|
-
continue;
|
|
418
|
-
this.pause();
|
|
611
|
+
if (attempt + 1 >= UPLOAD_ATTEMPTS || !(await this.retryWait(attempt, retryAfter, upload)))
|
|
419
612
|
return { fallback: "failed" };
|
|
420
|
-
}
|
|
421
|
-
// Bad values and conflicts refuse only this value. Transient service failures pause uploads.
|
|
422
|
-
if (status !== 400 && status !== 409)
|
|
423
|
-
this.pause();
|
|
424
|
-
return { fallback: "failed" };
|
|
425
613
|
}
|
|
426
|
-
this.pause();
|
|
427
|
-
return { fallback: "failed" };
|
|
428
|
-
}
|
|
429
|
-
async delay(millis, upload) {
|
|
430
|
-
if (Date.now() + millis >= upload.deadline)
|
|
431
|
-
return false;
|
|
432
|
-
await new Promise((resolve) => setTimeout(resolve, millis));
|
|
433
|
-
return !this.closed;
|
|
434
614
|
}
|
|
435
615
|
/** A 200 answer read: `exists`, or an upload whose URL and headers are usable as given. */
|
|
436
616
|
reservation(answer, size) {
|
|
@@ -483,7 +663,8 @@ export class BlobUploader {
|
|
|
483
663
|
grant: { url, headers, expiresAt: Number.isFinite(expiresAt) ? expiresAt : Infinity },
|
|
484
664
|
};
|
|
485
665
|
}
|
|
486
|
-
/** The PUT of the value's bytes, with exactly the headers Hue signed.
|
|
666
|
+
/** The PUT of the value's bytes, with exactly the headers Hue signed. No answer, a 408, a 429 or
|
|
667
|
+
* a 5xx is transient, with the answer's `Retry-After` when it gave one. A source that no longer
|
|
487
668
|
* holds its bytes (a file changed since it was recorded) ends the request before its body is
|
|
488
669
|
* complete, and fails the value without a retry. */
|
|
489
670
|
async put(grant, value, size, deadline) {
|
|
@@ -491,7 +672,7 @@ export class BlobUploader {
|
|
|
491
672
|
if (isByteSource(value) && value.changed)
|
|
492
673
|
return "changed";
|
|
493
674
|
if (!reply)
|
|
494
|
-
return
|
|
675
|
+
return { retryAfter: undefined };
|
|
495
676
|
// 412: the object exists already (another upload of the same value won). A success answered
|
|
496
677
|
// before the whole body was written stores nothing that was sent.
|
|
497
678
|
if (reply.status === 412)
|
|
@@ -501,7 +682,7 @@ export class BlobUploader {
|
|
|
501
682
|
if (reply.status === 403)
|
|
502
683
|
return reply.body.toString("utf8").includes("Request has expired") ? "expired" : "failed";
|
|
503
684
|
if (reply.status >= 500 || reply.status === 408 || reply.status === 429)
|
|
504
|
-
return
|
|
685
|
+
return { retryAfter: retryAfterMillis(reply.headers) };
|
|
505
686
|
return "failed";
|
|
506
687
|
}
|
|
507
688
|
/**
|
|
@@ -626,9 +807,9 @@ export class BlobUploader {
|
|
|
626
807
|
}
|
|
627
808
|
/**
|
|
628
809
|
* A span's own attributes with each candidate placed: binary media becomes a digest, while
|
|
629
|
-
* text media over 64 KiB retains a 16 KiB prefix
|
|
630
|
-
* A value still over the inline limit is uploaded and replaced by its first
|
|
631
|
-
*
|
|
810
|
+
* text media over 64 KiB retains a 16 KiB prefix once uploaded or queued, or a digest when
|
|
811
|
+
* refused. A value still over the inline limit is uploaded and replaced by its first 16 KiB (or
|
|
812
|
+
* cut to the limit when refused), each upload listed under `hue.blobs`, each whole value cut or
|
|
632
813
|
* uploaded listed under `hue.truncated`. Every fallback is counted in the tally.
|
|
633
814
|
*/
|
|
634
815
|
export async function offloadAttributes(attributes, collector, options) {
|
|
@@ -636,12 +817,12 @@ export async function offloadAttributes(attributes, collector, options) {
|
|
|
636
817
|
}
|
|
637
818
|
/**
|
|
638
819
|
* A record's own attributes, and a log record's body, with each candidate placed (see
|
|
639
|
-
* {@link offloadAttributes}). A file is uploaded
|
|
640
|
-
* provider part keeps a 16 KiB prefix), its `hue.blobs` entry keyed
|
|
641
|
-
* a log record's body, `body#<pointer>`. A file
|
|
642
|
-
*
|
|
643
|
-
*
|
|
644
|
-
* receiver's marker, as before.
|
|
820
|
+
* {@link offloadAttributes}). A file is uploaded (or, for an export, queued) and placed in its
|
|
821
|
+
* digest form (a text file in a provider part keeps a 16 KiB prefix), its `hue.blobs` entry keyed
|
|
822
|
+
* `<attribute>#<pointer>` or, in a log record's body, `body#<pointer>`. A file refused or not
|
|
823
|
+
* uploaded before it is placed keeps its bytes inline, except in a span's own recorded messages,
|
|
824
|
+
* where it keeps its digest as before. A log record's values over the inline limit are cut, never
|
|
825
|
+
* uploaded whole, and a body still over it is replaced by the receiver's marker, as before.
|
|
645
826
|
*/
|
|
646
827
|
export async function offloadRecord(attributes, collector, options) {
|
|
647
828
|
const { uploader, traceId, valueBytes, upload, tally } = options;
|
|
@@ -655,14 +836,33 @@ export async function offloadRecord(attributes, collector, options) {
|
|
|
655
836
|
const entries = [];
|
|
656
837
|
const listed = [];
|
|
657
838
|
const fallback = (reason) => countFallback(tally, reason, traceId);
|
|
839
|
+
/** Stores a value (the import), or queues its upload (an export): what the record lists. A
|
|
840
|
+
* queued value's entry names the type its own reference declared. `shared` when the value is
|
|
841
|
+
* memory its record holds (not decoded or rewritten from it). */
|
|
842
|
+
const submit = async (key, value, contentType, digest, shared) => {
|
|
843
|
+
const late = options.background;
|
|
844
|
+
if (!late) {
|
|
845
|
+
const outcome = await uploader.store(traceId, key, value, contentType, digest, upload);
|
|
846
|
+
if (outcome.ok)
|
|
847
|
+
tally.uploaded++;
|
|
848
|
+
return outcome;
|
|
849
|
+
}
|
|
850
|
+
const queued = uploader.enqueue(traceId, key, value, contentType, digest);
|
|
851
|
+
if (!queued.ok)
|
|
852
|
+
return queued;
|
|
853
|
+
const settled = countWhenSettled(queued.done, late.tally, traceId);
|
|
854
|
+
late.settled.push(settled);
|
|
855
|
+
late.queued?.(settled, shared ? queued.bytes : 0, queued.released);
|
|
856
|
+
return { ok: true, contentType };
|
|
857
|
+
};
|
|
658
858
|
// A value the queue cut when it was admitted was never held whole to upload.
|
|
659
859
|
for (let index = 0; index < collector.cut.length; index++)
|
|
660
860
|
fallback(uploader.unsupported() ? "unsupported" : "budget");
|
|
661
861
|
let inspected = 0;
|
|
662
|
-
/** Uploads each file of a value and places it; whether any was placed. A file
|
|
663
|
-
* uploaded keeps its bytes inline, except in a recorded message: a span's own
|
|
664
|
-
* digest, and a log record's keeps the digest of a file over the inline limit,
|
|
665
|
-
* messages kept them before log records uploaded. */
|
|
862
|
+
/** Uploads (or queues) each file of a value and places it; whether any was placed. A file
|
|
863
|
+
* refused or not uploaded keeps its bytes inline, except in a recorded message: a span's own
|
|
864
|
+
* message keeps its digest, and a log record's keeps the digest of a file over the inline limit,
|
|
865
|
+
* as nested messages kept them before log records uploaded. */
|
|
666
866
|
const placeFiles = async (index, key, files, long) => {
|
|
667
867
|
const message = isMessageKey(key);
|
|
668
868
|
let changed = false;
|
|
@@ -683,12 +883,12 @@ export async function offloadRecord(attributes, collector, options) {
|
|
|
683
883
|
const partKey = `${key}#${prefix ? file.pointer : file.partPointer}`;
|
|
684
884
|
const slot = slots > 0 && isBlobKey(partKey) ? (slots--, true) : false;
|
|
685
885
|
return (async () => {
|
|
686
|
-
//
|
|
687
|
-
//
|
|
886
|
+
// A long file is hashed in a hashing place, as a large value is; one the export's
|
|
887
|
+
// hashing deadline cannot hash stays as recorded and is not uploaded.
|
|
688
888
|
let digest;
|
|
689
889
|
try {
|
|
690
890
|
digest = isByteSource(bytes)
|
|
691
|
-
? await uploader.
|
|
891
|
+
? await uploader.digest(bytes, upload.deadline)
|
|
692
892
|
: options.memo
|
|
693
893
|
? options.memo.digest(file, decoded, traceId)
|
|
694
894
|
: await digestOf(bytes);
|
|
@@ -698,7 +898,7 @@ export async function offloadRecord(attributes, collector, options) {
|
|
|
698
898
|
return;
|
|
699
899
|
}
|
|
700
900
|
const outcome = slot
|
|
701
|
-
? await
|
|
901
|
+
? await submit(partKey, bytes, contentType, digest, isByteSource(bytes))
|
|
702
902
|
: { ok: false, reason: "budget" };
|
|
703
903
|
if (outcome.ok) {
|
|
704
904
|
if (prefix)
|
|
@@ -710,7 +910,6 @@ export async function offloadRecord(attributes, collector, options) {
|
|
|
710
910
|
order: [index, position],
|
|
711
911
|
entry: blobEntry(partKey, digest.sha256, digest.size, outcome.contentType, file.name),
|
|
712
912
|
});
|
|
713
|
-
tally.uploaded++;
|
|
714
913
|
}
|
|
715
914
|
else {
|
|
716
915
|
if (message && (!collector.log || size > INLINE_FILE_LIMIT)) {
|
|
@@ -833,10 +1032,10 @@ export async function offloadRecord(attributes, collector, options) {
|
|
|
833
1032
|
const stored = await (async () => {
|
|
834
1033
|
if (size > MAX_BLOB_BYTES)
|
|
835
1034
|
return { ok: false, reason: "tooLarge" };
|
|
836
|
-
//
|
|
1035
|
+
// A large value is hashed in a hashing place; its upload takes an upload place.
|
|
837
1036
|
let digest;
|
|
838
1037
|
try {
|
|
839
|
-
digest = await uploader.
|
|
1038
|
+
digest = await uploader.digest(placed, upload.deadline);
|
|
840
1039
|
}
|
|
841
1040
|
catch {
|
|
842
1041
|
return { ok: false, reason: "failed" };
|
|
@@ -846,7 +1045,7 @@ export async function offloadRecord(attributes, collector, options) {
|
|
|
846
1045
|
: typeof placed === "string"
|
|
847
1046
|
? textContentType(placed)
|
|
848
1047
|
: "application/octet-stream";
|
|
849
|
-
const outcome = await
|
|
1048
|
+
const outcome = await submit(key, placed, contentType, digest, !!joined || read === value);
|
|
850
1049
|
return outcome.ok ? { ...outcome, digest } : outcome;
|
|
851
1050
|
})();
|
|
852
1051
|
if (stored.ok) {
|
|
@@ -859,7 +1058,6 @@ export async function offloadRecord(attributes, collector, options) {
|
|
|
859
1058
|
order: [index, Infinity],
|
|
860
1059
|
entry: blobEntry(key, stored.digest.sha256, stored.digest.size, stored.contentType),
|
|
861
1060
|
});
|
|
862
|
-
tally.uploaded++;
|
|
863
1061
|
}
|
|
864
1062
|
else {
|
|
865
1063
|
result[key] = cut();
|
|
@@ -875,14 +1073,13 @@ export async function offloadRecord(attributes, collector, options) {
|
|
|
875
1073
|
return (async () => {
|
|
876
1074
|
const key = mediaKey(file);
|
|
877
1075
|
const outcome = slot
|
|
878
|
-
? await
|
|
1076
|
+
? await submit(key, file.bytes, file.contentType, file.digest, true)
|
|
879
1077
|
: { ok: false, reason: "budget" };
|
|
880
1078
|
if (outcome.ok) {
|
|
881
1079
|
entries.push({
|
|
882
1080
|
order: [collector.candidates.length, position],
|
|
883
1081
|
entry: blobEntry(key, file.digest.sha256, file.digest.size, outcome.contentType, file.name),
|
|
884
1082
|
});
|
|
885
|
-
tally.uploaded++;
|
|
886
1083
|
}
|
|
887
1084
|
else
|
|
888
1085
|
fallback(outcome.reason);
|