@hue-run/sdk 0.18.2 → 0.19.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/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. Text keeps a 16 KiB prefix. Binary media keeps digest metadata on its part.
12
- * Successful uploads are listed in `hue.blobs`; failures keep inline fallback or file metadata,
13
- * are reported as warnings, and are counted.
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 spans may hold whole for upload at once, charged
27
- * apart from `maxQueueBytes` (a string as two bytes a code unit, bytes as themselves). A value
28
- * the budget cannot hold is cut when it is queued, as before uploads existed; a single value
29
- * larger than the whole budget is held while nothing else is.
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
- /** How long uploads pause after one failed for a transient reason (a 5xx, the network, a
37
- * timeout), so an unavailable store meets no retry storm: values fall back to the inline cut. */
38
- const FAILURE_PAUSE_MILLIS = 30_000;
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 `Retry-After` a transient refusal of a reservation is waited for. */
45
- const MAX_TRANSIENT_WAIT_MILLIS = 5000;
46
- /** How long one export may spend uploading its values, on top of its requests. */
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. At
186
- * most {@link UPLOAD_CONCURRENCY} uploads run at once; every request has a deadline and none
187
- * follows a redirect. A receiver without the route (a 404 without Hue's JSON error, a 405 or
188
- * 501, or a 200 that is no reservation) is remembered for {@link UNSUPPORTED_PAUSE_MILLIS}, and a
189
- * transient failure or a refusal that is not about the value alone pauses uploads for
190
- * {@link FAILURE_PAUSE_MILLIS}, so values fall back to the inline cut instead of retrying.
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
- running = 0;
198
- waiting = [];
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: their values fall back to the inline cut. */
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 run of this.waiting.splice(0)) {
220
- this.running++;
221
- run();
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
- async limit(work) {
226
- // A finishing upload hands its place to the next one waiting, so none is ever taken twice.
227
- if (this.running >= UPLOAD_CONCURRENCY && !this.closed)
228
- await new Promise((resolve) => this.waiting.push(resolve));
229
- else
230
- this.running++;
231
- try {
232
- return await work();
233
- }
234
- finally {
235
- const next = this.waiting.shift();
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
- pause() {
253
- this.pausedUntil = Date.now() + FAILURE_PAUSE_MILLIS;
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 { ok: false, reason: "failed" };
396
+ return "failed";
272
397
  if (digest.size > MAX_BLOB_BYTES)
273
- return { ok: false, reason: "tooLarge" };
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 attempts = 0;
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,13 @@ 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
- const expired = put === "expired" || Date.now() >= grant.expiresAt;
333
- if (put === "transient" &&
334
- ++attempts < 2 &&
335
- Date.now() + RETRY_DELAY_MILLIS < upload.deadline) {
336
- await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MILLIS));
545
+ if (put === "transient") {
546
+ if (++attempt >= UPLOAD_ATTEMPTS || !(await this.retryWait(attempt - 1, undefined, upload)))
547
+ return { ok: false, reason: "failed" };
337
548
  if (Date.now() < grant.expiresAt)
338
549
  continue;
339
550
  }
340
- else if (put === "transient") {
341
- this.pause();
342
- return { ok: false, reason: "failed" };
343
- }
344
- else if (!expired)
551
+ else if (put !== "expired" && Date.now() < grant.expiresAt)
345
552
  return { ok: false, reason: "failed" };
346
553
  // The URL expired: reserved again once.
347
554
  if (presigns++ >= 2)
@@ -350,13 +557,10 @@ export class BlobUploader {
350
557
  }
351
558
  if ("fallback" in reservation)
352
559
  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, so a failed
355
- // completion is not retried.
356
- const completion = 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);
357
- if (!completion || completion.status >= 500 || completion.status === 429)
358
- this.pause();
359
- }
560
+ if (!reservation.exists)
561
+ // For Hue's accounting only: a value is read whether or not it was completed, and Hue
562
+ // stamps a missed completion on the next reservation, so its outcome is not read.
563
+ 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
564
  return { ok: true, contentType: reservation.contentType };
361
565
  }
362
566
  jsonHeaders() {
@@ -366,71 +570,46 @@ export class BlobUploader {
366
570
  "User-Agent": this.options.userAgent,
367
571
  };
368
572
  }
369
- /** Reserves the value: Hue answers that it holds it already, or a presigned PUT. A rate limit
370
- * is waited out within the export's hold, a transient refusal retried once. */
573
+ /** Reserves the value: Hue answers that it holds it already, or a presigned PUT. No answer, a
574
+ * 408, a 429 or a 5xx other than 501 is tried again, after its `Retry-After` when it gave one,
575
+ * within the upload's deadline. */
371
576
  async reserve(body, size, upload) {
372
577
  const url = new URL("/api/v1/otlp/blobs", this.options.baseUrl);
373
- for (let attempt = 0; attempt < 3; attempt++) {
578
+ for (let attempt = 0;; attempt++) {
374
579
  if (this.closed)
375
580
  return { fallback: "failed" };
376
581
  const reply = await this.request("POST", url, this.jsonHeaders(), body, Math.min(upload.deadline, Date.now() + this.options.timeoutMillis), MAX_JSON_RESPONSE_BYTES);
377
- if (!reply) {
378
- if (attempt === 0 && (await this.delay(RETRY_DELAY_MILLIS, upload)))
379
- continue;
380
- this.pause();
381
- return { fallback: "failed" };
382
- }
383
- const { status } = reply;
384
- const answer = status === 200 || status === 404 || status === 409 ? parseJson(reply.body) : undefined;
385
- // A receiver without the route answers its framework's not-found (Hue's own 404, an
386
- // archived project, is JSON with an `error`), refuses the method, or answers something
387
- // that is no reservation at all: it is not asked again for a while.
388
- if (status === 405 ||
389
- status === 501 ||
390
- (status === 404 && !(isObject(answer) && typeof answer.error === "string")) ||
391
- (status === 200 &&
392
- !(isObject(answer) && (answer.status === "exists" || answer.status === "upload")))) {
393
- this.unsupportedUntil = Date.now() + UNSUPPORTED_PAUSE_MILLIS;
394
- return { fallback: "unsupported" };
395
- }
396
- if (status === 200) {
397
- const reservation = this.reservation(answer, size);
398
- // A reservation that cannot be used would fail every value alike.
399
- if ("fallback" in reservation)
400
- this.pause();
401
- return reservation;
402
- }
403
- if (status === 409 && isObject(answer) && answer.status === "exists")
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" };
582
+ let retryAfter;
583
+ if (reply) {
584
+ const { status } = reply;
585
+ const answer = status === 200 || status === 404 || status === 409 ? parseJson(reply.body) : undefined;
586
+ // A receiver without the route answers its framework's not-found (Hue's own 404, an
587
+ // archived project, is JSON with an `error`), refuses the method, or answers something
588
+ // that is no reservation at all: it is not asked again for a while, since the values
589
+ // exported while it answers are listed for bytes it cannot store.
590
+ if (status === 405 ||
591
+ status === 501 ||
592
+ (status === 404 && !(isObject(answer) && typeof answer.error === "string")) ||
593
+ (status === 200 &&
594
+ !(isObject(answer) && (answer.status === "exists" || answer.status === "upload")))) {
595
+ this.unsupportedUntil = Date.now() + UNSUPPORTED_PAUSE_MILLIS;
596
+ return { fallback: "unsupported" };
597
+ }
598
+ // A reservation that cannot be used fails its value.
599
+ if (status === 200)
600
+ return this.reservation(answer, size);
601
+ if (status === 409 && isObject(answer) && answer.status === "exists")
602
+ return this.reservation(answer, size);
603
+ if (status === 413)
604
+ return { fallback: "tooLarge" };
605
+ // Refusals of the value or the key (400, 401, 403, Hue's own 404, a conflict) are final.
606
+ if (status !== 408 && status !== 429 && status < 500)
607
+ return { fallback: "failed" };
608
+ retryAfter = retryAfterMillis(reply.headers);
413
609
  }
414
- if (status >= 500) {
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();
610
+ if (attempt + 1 >= UPLOAD_ATTEMPTS || !(await this.retryWait(attempt, retryAfter, upload)))
419
611
  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
612
  }
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
613
  }
435
614
  /** A 200 answer read: `exists`, or an upload whose URL and headers are usable as given. */
436
615
  reservation(answer, size) {
@@ -626,9 +805,9 @@ export class BlobUploader {
626
805
  }
627
806
  /**
628
807
  * 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 after upload or a digest on failure.
630
- * A value still over the inline limit is uploaded and replaced by its first
631
- * 16 KiB (or cut to the limit), each upload listed under `hue.blobs`, each whole value cut or
808
+ * text media over 64 KiB retains a 16 KiB prefix once uploaded or queued, or a digest when
809
+ * refused. A value still over the inline limit is uploaded and replaced by its first 16 KiB (or
810
+ * cut to the limit when refused), each upload listed under `hue.blobs`, each whole value cut or
632
811
  * uploaded listed under `hue.truncated`. Every fallback is counted in the tally.
633
812
  */
634
813
  export async function offloadAttributes(attributes, collector, options) {
@@ -636,12 +815,12 @@ export async function offloadAttributes(attributes, collector, options) {
636
815
  }
637
816
  /**
638
817
  * A record's own attributes, and a log record's body, with each candidate placed (see
639
- * {@link offloadAttributes}). A file is uploaded and placed in its digest form (a text file in a
640
- * provider part keeps a 16 KiB prefix), its `hue.blobs` entry keyed `<attribute>#<pointer>` or, in
641
- * a log record's body, `body#<pointer>`. A file that is not uploaded keeps its bytes inline, except
642
- * in a span's own recorded messages, where it keeps its digest as before. A log record's values
643
- * over the inline limit are cut, never uploaded whole, and a body still over it is replaced by the
644
- * receiver's marker, as before.
818
+ * {@link offloadAttributes}). A file is uploaded (or, for an export, queued) and placed in its
819
+ * digest form (a text file in a provider part keeps a 16 KiB prefix), its `hue.blobs` entry keyed
820
+ * `<attribute>#<pointer>` or, in a log record's body, `body#<pointer>`. A file refused or not
821
+ * uploaded before it is placed keeps its bytes inline, except in a span's own recorded messages,
822
+ * where it keeps its digest as before. A log record's values over the inline limit are cut, never
823
+ * uploaded whole, and a body still over it is replaced by the receiver's marker, as before.
645
824
  */
646
825
  export async function offloadRecord(attributes, collector, options) {
647
826
  const { uploader, traceId, valueBytes, upload, tally } = options;
@@ -655,14 +834,33 @@ export async function offloadRecord(attributes, collector, options) {
655
834
  const entries = [];
656
835
  const listed = [];
657
836
  const fallback = (reason) => countFallback(tally, reason, traceId);
837
+ /** Stores a value (the import), or queues its upload (an export): what the record lists. A
838
+ * queued value's entry names the type its own reference declared. `shared` when the value is
839
+ * memory its record holds (not decoded or rewritten from it). */
840
+ const submit = async (key, value, contentType, digest, shared) => {
841
+ const late = options.background;
842
+ if (!late) {
843
+ const outcome = await uploader.store(traceId, key, value, contentType, digest, upload);
844
+ if (outcome.ok)
845
+ tally.uploaded++;
846
+ return outcome;
847
+ }
848
+ const queued = uploader.enqueue(traceId, key, value, contentType, digest);
849
+ if (!queued.ok)
850
+ return queued;
851
+ const settled = countWhenSettled(queued.done, late.tally, traceId);
852
+ late.settled.push(settled);
853
+ late.queued?.(settled, shared ? queued.bytes : 0, queued.released);
854
+ return { ok: true, contentType };
855
+ };
658
856
  // A value the queue cut when it was admitted was never held whole to upload.
659
857
  for (let index = 0; index < collector.cut.length; index++)
660
858
  fallback(uploader.unsupported() ? "unsupported" : "budget");
661
859
  let inspected = 0;
662
- /** Uploads each file of a value and places it; whether any was placed. A file that is not
663
- * uploaded keeps its bytes inline, except in a recorded message: a span's own message keeps its
664
- * digest, and a log record's keeps the digest of a file over the inline limit, as nested
665
- * messages kept them before log records uploaded. */
860
+ /** Uploads (or queues) each file of a value and places it; whether any was placed. A file
861
+ * refused or not uploaded keeps its bytes inline, except in a recorded message: a span's own
862
+ * message keeps its digest, and a log record's keeps the digest of a file over the inline limit,
863
+ * as nested messages kept them before log records uploaded. */
666
864
  const placeFiles = async (index, key, files, long) => {
667
865
  const message = isMessageKey(key);
668
866
  let changed = false;
@@ -683,12 +881,12 @@ export async function offloadRecord(attributes, collector, options) {
683
881
  const partKey = `${key}#${prefix ? file.pointer : file.partPointer}`;
684
882
  const slot = slots > 0 && isBlobKey(partKey) ? (slots--, true) : false;
685
883
  return (async () => {
686
- // Hashing a long file takes an upload place, as hashing a large value does; one the
687
- // export's upload budget cannot hash stays as recorded and is not uploaded.
884
+ // A long file is hashed in a hashing place, as a large value is; one the export's
885
+ // hashing deadline cannot hash stays as recorded and is not uploaded.
688
886
  let digest;
689
887
  try {
690
888
  digest = isByteSource(bytes)
691
- ? await uploader.limit(() => digestOf(bytes, upload.deadline))
889
+ ? await uploader.digest(bytes, upload.deadline)
692
890
  : options.memo
693
891
  ? options.memo.digest(file, decoded, traceId)
694
892
  : await digestOf(bytes);
@@ -698,7 +896,7 @@ export async function offloadRecord(attributes, collector, options) {
698
896
  return;
699
897
  }
700
898
  const outcome = slot
701
- ? await uploader.store(traceId, partKey, bytes, contentType, digest, upload)
899
+ ? await submit(partKey, bytes, contentType, digest, isByteSource(bytes))
702
900
  : { ok: false, reason: "budget" };
703
901
  if (outcome.ok) {
704
902
  if (prefix)
@@ -710,7 +908,6 @@ export async function offloadRecord(attributes, collector, options) {
710
908
  order: [index, position],
711
909
  entry: blobEntry(partKey, digest.sha256, digest.size, outcome.contentType, file.name),
712
910
  });
713
- tally.uploaded++;
714
911
  }
715
912
  else {
716
913
  if (message && (!collector.log || size > INLINE_FILE_LIMIT)) {
@@ -833,10 +1030,10 @@ export async function offloadRecord(attributes, collector, options) {
833
1030
  const stored = await (async () => {
834
1031
  if (size > MAX_BLOB_BYTES)
835
1032
  return { ok: false, reason: "tooLarge" };
836
- // Hashing a large value takes a slot as its upload does; the upload takes its own.
1033
+ // A large value is hashed in a hashing place; its upload takes an upload place.
837
1034
  let digest;
838
1035
  try {
839
- digest = await uploader.limit(() => digestOf(placed, upload.deadline));
1036
+ digest = await uploader.digest(placed, upload.deadline);
840
1037
  }
841
1038
  catch {
842
1039
  return { ok: false, reason: "failed" };
@@ -846,7 +1043,7 @@ export async function offloadRecord(attributes, collector, options) {
846
1043
  : typeof placed === "string"
847
1044
  ? textContentType(placed)
848
1045
  : "application/octet-stream";
849
- const outcome = await uploader.store(traceId, key, placed, contentType, digest, upload);
1046
+ const outcome = await submit(key, placed, contentType, digest, !!joined || read === value);
850
1047
  return outcome.ok ? { ...outcome, digest } : outcome;
851
1048
  })();
852
1049
  if (stored.ok) {
@@ -859,7 +1056,6 @@ export async function offloadRecord(attributes, collector, options) {
859
1056
  order: [index, Infinity],
860
1057
  entry: blobEntry(key, stored.digest.sha256, stored.digest.size, stored.contentType),
861
1058
  });
862
- tally.uploaded++;
863
1059
  }
864
1060
  else {
865
1061
  result[key] = cut();
@@ -875,14 +1071,13 @@ export async function offloadRecord(attributes, collector, options) {
875
1071
  return (async () => {
876
1072
  const key = mediaKey(file);
877
1073
  const outcome = slot
878
- ? await uploader.store(traceId, key, file.bytes, file.contentType, file.digest, upload)
1074
+ ? await submit(key, file.bytes, file.contentType, file.digest, true)
879
1075
  : { ok: false, reason: "budget" };
880
1076
  if (outcome.ok) {
881
1077
  entries.push({
882
1078
  order: [collector.candidates.length, position],
883
1079
  entry: blobEntry(key, file.digest.sha256, file.digest.size, outcome.contentType, file.name),
884
1080
  });
885
- tally.uploaded++;
886
1081
  }
887
1082
  else
888
1083
  fallback(outcome.reason);