deepline 0.3.128 → 0.3.129

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.
Files changed (128) hide show
  1. package/dist/bundling-sources/sdk/src/client.ts +34 -9
  2. package/dist/bundling-sources/sdk/src/http.ts +22 -0
  3. package/dist/bundling-sources/sdk/src/release.ts +1 -1
  4. package/dist/bundling-sources/shared_libs/play-data-plane/r2.ts +22 -0
  5. package/dist/bundling-sources/shared_libs/play-runtime/app-runtime-api.ts +265 -2
  6. package/dist/bundling-sources/shared_libs/play-runtime/bounded-batch-function.ts +682 -0
  7. package/dist/bundling-sources/shared_libs/play-runtime/child-execution-strategy.ts +21 -5
  8. package/dist/bundling-sources/shared_libs/play-runtime/context.ts +5390 -1732
  9. package/dist/bundling-sources/shared_libs/play-runtime/ctx-types.ts +107 -1
  10. package/dist/bundling-sources/shared_libs/play-runtime/deployment/commands/roll-execution-relay.ts +7 -0
  11. package/dist/bundling-sources/shared_libs/play-runtime/deployment/fly-machines.ts +229 -7
  12. package/dist/bundling-sources/shared_libs/play-runtime/deployment/fly-registry.ts +25 -4
  13. package/dist/bundling-sources/shared_libs/play-runtime/deployment/runtime-gateway-capacity.ts +71 -0
  14. package/dist/bundling-sources/shared_libs/play-runtime/deployment/runtime-gateway-drain.ts +1 -1
  15. package/dist/bundling-sources/shared_libs/play-runtime/deployment/runtime-topology.ts +4 -1
  16. package/dist/bundling-sources/shared_libs/play-runtime/durable-receipt-execution.ts +694 -28
  17. package/dist/bundling-sources/shared_libs/play-runtime/egress-bandwidth-probe.ts +167 -0
  18. package/dist/bundling-sources/shared_libs/play-runtime/execute-server-timing.ts +103 -0
  19. package/dist/bundling-sources/shared_libs/play-runtime/execution-gateway-receipt-output-reference.ts +34 -0
  20. package/dist/bundling-sources/shared_libs/play-runtime/execution-gateway-response-size.ts +27 -0
  21. package/dist/bundling-sources/shared_libs/play-runtime/gateway-postgres-admission.ts +66 -1
  22. package/dist/bundling-sources/shared_libs/play-runtime/governor/app-runtime-rate-state-backend.ts +425 -71
  23. package/dist/bundling-sources/shared_libs/play-runtime/governor/governor.ts +210 -32
  24. package/dist/bundling-sources/shared_libs/play-runtime/governor/in-memory-rate-state-backend.ts +27 -13
  25. package/dist/bundling-sources/shared_libs/play-runtime/governor/policy.ts +19 -6
  26. package/dist/bundling-sources/shared_libs/play-runtime/governor/rate-state-backend.ts +61 -2
  27. package/dist/bundling-sources/shared_libs/play-runtime/integration-operation-reader-fence/contracts.ts +33 -1
  28. package/dist/bundling-sources/shared_libs/play-runtime/integration-operation-reader-fence/job.ts +42 -1
  29. package/dist/bundling-sources/shared_libs/play-runtime/launch-source-policy.ts +11 -11
  30. package/dist/bundling-sources/shared_libs/play-runtime/ledger-safe-payload.ts +49 -16
  31. package/dist/bundling-sources/shared_libs/play-runtime/map-memory-limits.ts +1653 -98
  32. package/dist/bundling-sources/shared_libs/play-runtime/memory-ledger.ts +807 -0
  33. package/dist/bundling-sources/shared_libs/play-runtime/memory-pressure-signal.ts +138 -0
  34. package/dist/bundling-sources/shared_libs/play-runtime/output-size-limits.ts +85 -3
  35. package/dist/bundling-sources/shared_libs/play-runtime/persistence-latch.ts +60 -2
  36. package/dist/bundling-sources/shared_libs/play-runtime/play-run-recovery-policy.ts +46 -1
  37. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/terminal/contracts.ts +2 -0
  38. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/terminal/job.ts +1 -0
  39. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/terminal/ports.ts +1 -0
  40. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/terminal/postgres-adapter.ts +1 -0
  41. package/dist/bundling-sources/shared_libs/play-runtime/play-runtime-semantics.ts +70 -27
  42. package/dist/bundling-sources/shared_libs/play-runtime/protocol.ts +56 -0
  43. package/dist/bundling-sources/shared_libs/play-runtime/provider-capacity/adaptive-provider-capacity-controller.ts +192 -0
  44. package/dist/bundling-sources/shared_libs/play-runtime/provider-capacity/epoch-aimd-rate-controller.ts +194 -0
  45. package/dist/bundling-sources/shared_libs/play-runtime/provider-capacity/fixture-capacity-simulator.ts +220 -0
  46. package/dist/bundling-sources/shared_libs/play-runtime/provider-capacity/gradient2-concurrency-controller.ts +214 -0
  47. package/dist/bundling-sources/shared_libs/play-runtime/provider-capacity/provider-capacity-policy.ts +43 -0
  48. package/dist/bundling-sources/shared_libs/play-runtime/provider-capacity/provider-concurrency-policy.ts +30 -0
  49. package/dist/bundling-sources/shared_libs/play-runtime/provider-capacity/provider-rate-policy.ts +95 -0
  50. package/dist/bundling-sources/shared_libs/play-runtime/provider-capacity/provider-throttle-classifier.ts +40 -0
  51. package/dist/bundling-sources/shared_libs/play-runtime/resource-governor.ts +1535 -11
  52. package/dist/bundling-sources/shared_libs/play-runtime/run-failure.ts +49 -2
  53. package/dist/bundling-sources/shared_libs/play-runtime/run-ledger.ts +6 -1
  54. package/dist/bundling-sources/shared_libs/play-runtime/run-log-buffer.ts +55 -0
  55. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/egress-probe.ts +221 -0
  56. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/index.ts +339 -29
  57. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-deadline.ts +28 -1
  58. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-progress-push.ts +192 -165
  59. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-lifecycle.ts +6 -0
  60. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-payload-transport.ts +90 -5
  61. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-runtime-watchdog.ts +281 -6
  62. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona.ts +27 -1
  63. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/local-process.ts +19 -0
  64. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/modal.ts +108 -0
  65. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/runtime-sandbox-reconciliation.ts +40 -0
  66. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/types.ts +11 -5
  67. package/dist/bundling-sources/shared_libs/play-runtime/runtime-actions.ts +30 -0
  68. package/dist/bundling-sources/shared_libs/play-runtime/runtime-api-compression.ts +276 -0
  69. package/dist/bundling-sources/shared_libs/play-runtime/runtime-api.ts +723 -115
  70. package/dist/bundling-sources/shared_libs/play-runtime/runtime-capacity-policy.ts +98 -2
  71. package/dist/bundling-sources/shared_libs/play-runtime/runtime-contract.ts +3 -2
  72. package/dist/bundling-sources/shared_libs/play-runtime/runtime-environment.ts +41 -4
  73. package/dist/bundling-sources/shared_libs/play-runtime/runtime-flow.ts +448 -0
  74. package/dist/bundling-sources/shared_libs/play-runtime/runtime-map-row-feed.ts +215 -0
  75. package/dist/bundling-sources/shared_libs/play-runtime/runtime-operation-contract.ts +11 -0
  76. package/dist/bundling-sources/shared_libs/play-runtime/runtime-receipt-store-adapter.ts +161 -14
  77. package/dist/bundling-sources/shared_libs/play-runtime/runtime-receipt-value.ts +184 -0
  78. package/dist/bundling-sources/shared_libs/play-runtime/runtime-receipt-writer.ts +607 -121
  79. package/dist/bundling-sources/shared_libs/play-runtime/runtime-sandbox-placement-policy.ts +4 -0
  80. package/dist/bundling-sources/shared_libs/play-runtime/runtime-sheet-row-writer.ts +21 -0
  81. package/dist/bundling-sources/shared_libs/play-runtime/sandbox-runs/contract.ts +15 -2
  82. package/dist/bundling-sources/shared_libs/play-runtime/sandbox-runs/runner-backend-adapter.ts +71 -14
  83. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-play-deploy-task.ts +2 -2
  84. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-runtime-worker.ts +288 -119
  85. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-shared.ts +8 -6
  86. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-worker-required-env.ts +28 -3
  87. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/canonical-integration-operation-store.ts +179 -0
  88. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/gateway-heavy-request-admission.ts +80 -2
  89. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/gateway-progress-registry.ts +31 -2
  90. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/integration-operation-store-contract.ts +104 -0
  91. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/integration-operation-value-reference.ts +47 -0
  92. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/integration-operation-value-retirement.ts +193 -0
  93. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/integration-operation-value-storage.ts +420 -0
  94. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres-pool.ts +41 -0
  95. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres-progress.ts +134 -3
  96. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres-rate-state.ts +1657 -95
  97. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres.ts +885 -73
  98. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/receipt-completion-coalescer.ts +452 -0
  99. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/receipt-gateway.ts +601 -42
  100. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/run-terminal-transition.ts +1 -0
  101. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/runtime-receipt-value-storage.ts +371 -0
  102. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/worker-resource-metrics.ts +86 -7
  103. package/dist/bundling-sources/shared_libs/play-runtime/secret-redaction.ts +153 -22
  104. package/dist/bundling-sources/shared_libs/play-runtime/tool-call/index.ts +1 -0
  105. package/dist/bundling-sources/shared_libs/play-runtime/tool-call/receipt-cohort.ts +188 -38
  106. package/dist/bundling-sources/shared_libs/play-runtime/tool-call/runtime-step-receipts-adapter.ts +28 -13
  107. package/dist/bundling-sources/shared_libs/play-runtime/tool-execute-retry-policy.ts +44 -1
  108. package/dist/bundling-sources/shared_libs/play-runtime/tool-result.ts +56 -4
  109. package/dist/bundling-sources/shared_libs/play-runtime/work-receipts.ts +2 -0
  110. package/dist/bundling-sources/shared_libs/plays/authoring-contract.ts +16 -10
  111. package/dist/bundling-sources/shared_libs/plays/dataset.ts +4 -1
  112. package/dist/cli/index.js +250 -38
  113. package/dist/cli/index.mjs +250 -38
  114. package/dist/{compiler-manifest-DfSnbCTW.d.mts → compiler-manifest-BIqRyj5m.d.mts} +1 -1
  115. package/dist/{compiler-manifest-DfSnbCTW.d.ts → compiler-manifest-BIqRyj5m.d.ts} +1 -1
  116. package/dist/index.d.mts +4 -2
  117. package/dist/index.d.ts +4 -2
  118. package/dist/index.js +231 -35
  119. package/dist/index.mjs +231 -35
  120. package/dist/install-integrity.json +31 -2
  121. package/dist/plays/bundle-play-file.d.mts +2 -2
  122. package/dist/plays/bundle-play-file.d.ts +2 -2
  123. package/dist/plays/bundle-play-file.mjs +65 -1
  124. package/dist/release.d.mts +1 -1
  125. package/dist/release.d.ts +1 -1
  126. package/dist/release.js +1 -1
  127. package/dist/release.mjs +1 -1
  128. package/package.json +1 -1
@@ -107,6 +107,8 @@ import {
107
107
  PLAY_RUNTIME_SHEET_ATTEMPT_LEASE_TTL_MS,
108
108
  PLAY_RUNTIME_WORK_RECEIPT_LEASE_TTL_MS,
109
109
  } from './lease-policy';
110
+ import { BoundedBatchFunction } from './bounded-batch-function';
111
+ import { jsonByteLengthUpTo } from './output-size-limits';
110
112
  import {
111
113
  parseRuntimeTestFaultCounts,
112
114
  PLAY_RUNTIME_TEST_FAULT_HEADER,
@@ -334,6 +336,49 @@ const RUNTIME_API_RESOLVE_PLAY_RETRY_DELAYS_MS = [
334
336
  ] as const;
335
337
  const RUNTIME_API_DEFAULT_RETRY_AFTER_MS = 2_000;
336
338
  const RUNTIME_API_REQUEST_TIMEOUT_MS = 30_000;
339
+ /**
340
+ * One gateway dataset-read response must fit below the 16 MiB request/response
341
+ * transport ceiling, leaving room for the JSON envelope and proxy framing.
342
+ * This is a decoded-body budget; it is enforced on both sides of the hop.
343
+ */
344
+ export const RUNTIME_SHEET_READ_BATCH_MAX_RESPONSE_BYTES = 12 * 1024 * 1024;
345
+ export const RUNTIME_SHEET_READ_BATCH_MAX_PAGES = 4;
346
+ const RUNTIME_SHEET_READ_BATCH_MAX_IN_FLIGHT = 2;
347
+ const RUNTIME_SHEET_READ_BATCH_MAX_RETAINED_PAGES = 8;
348
+ const RUNTIME_SHEET_READ_BATCH_MAX_RETAINED_REQUEST_BYTES = 64 * 1024;
349
+ const RUNTIME_SHEET_READ_BATCH_FLUSH_MS = 5;
350
+ /**
351
+ * A page queues only until an in-flight batch frees its slot, and the in-flight
352
+ * batches run in parallel, so the longest honest wait is one worst-case runtime
353
+ * API call: its request timeout on every attempt plus the whole retry ladder.
354
+ * Past that the lane is wedged rather than busy, and a wedged lane must fail
355
+ * loudly instead of parking a map row on a promise nothing will settle.
356
+ */
357
+ export const RUNTIME_SHEET_READ_BATCH_MAX_QUEUE_WAIT_MS =
358
+ RUNTIME_API_REQUEST_TIMEOUT_MS * (RUNTIME_API_RETRY_DELAYS_MS.length + 1) +
359
+ RUNTIME_API_RETRY_DELAYS_MS.reduce((total, delay) => total + delay, 0);
360
+
361
+ export type RuntimeSheetDatasetPageInput = {
362
+ limit: number;
363
+ offset: number;
364
+ };
365
+
366
+ export type RuntimeSheetDatasetPage = {
367
+ rows: Record<string, unknown>[];
368
+ limit: number;
369
+ offset: number;
370
+ };
371
+
372
+ export class RuntimeSheetDatasetReadBatchTooLargeError extends Error {
373
+ readonly code = 'runtime_sheet_read_batch_too_large';
374
+
375
+ constructor(readonly observedBytes: number) {
376
+ super(
377
+ `Runtime dataset read batch exceeds the ${RUNTIME_SHEET_READ_BATCH_MAX_RESPONSE_BYTES} byte decoded response budget (observed at least ${observedBytes} bytes).`,
378
+ );
379
+ this.name = 'RuntimeSheetDatasetReadBatchTooLargeError';
380
+ }
381
+ }
337
382
  // A sheet admission can legitimately wait behind two large transactional
338
383
  // writes. Keep the caller alive long enough for the FIFO admission queue to
339
384
  // make progress instead of aborting a healthy queued start after 30 seconds.
@@ -514,6 +559,10 @@ type RuntimeApiActionRequest =
514
559
  action: 'runtime_sheet_read_rows';
515
560
  input: Parameters<typeof readRuntimeSheetDatasetRows>[1];
516
561
  }
562
+ | {
563
+ action: 'runtime_sheet_read_rows_batch';
564
+ input: Parameters<typeof readRuntimeSheetDatasetRowsBatch>[1];
565
+ }
517
566
  | {
518
567
  action: 'runtime_sheet_read_row_keys';
519
568
  input: Parameters<typeof readRuntimeSheetDatasetRowKeys>[1];
@@ -632,6 +681,76 @@ function normalizeRuntimeUserEmail(
632
681
  return email ? email : null;
633
682
  }
634
683
 
684
+ class RuntimeApiResponseTooLargeError extends Error {
685
+ constructor(
686
+ readonly action: RuntimeApiActionRequest['action'],
687
+ readonly limitBytes: number,
688
+ readonly observedBytes: number,
689
+ ) {
690
+ super(
691
+ `Runtime API ${action} response exceeds the ${limitBytes} byte decoded-body budget (observed at least ${observedBytes} bytes).`,
692
+ );
693
+ this.name = 'RuntimeApiResponseTooLargeError';
694
+ }
695
+ }
696
+
697
+ function runtimeApiResponseByteLimit(
698
+ action: RuntimeApiActionRequest['action'],
699
+ ): number {
700
+ return action === 'runtime_sheet_read_rows_batch'
701
+ ? RUNTIME_SHEET_READ_BATCH_MAX_RESPONSE_BYTES
702
+ : 16 * 1024 * 1024;
703
+ }
704
+
705
+ async function readRuntimeApiResponseJson(input: {
706
+ response: Response;
707
+ action: RuntimeApiActionRequest['action'];
708
+ }): Promise<unknown> {
709
+ const limitBytes = runtimeApiResponseByteLimit(input.action);
710
+ const declaredLength = Number(
711
+ input.response.headers.get('content-length') ?? 0,
712
+ );
713
+ if (Number.isFinite(declaredLength) && declaredLength > limitBytes) {
714
+ await input.response.body?.cancel().catch(() => undefined);
715
+ throw new RuntimeApiResponseTooLargeError(
716
+ input.action,
717
+ limitBytes,
718
+ declaredLength,
719
+ );
720
+ }
721
+ if (!input.response.body) return null;
722
+ const reader = input.response.body.getReader();
723
+ const chunks: Uint8Array[] = [];
724
+ let bytes = 0;
725
+ try {
726
+ while (true) {
727
+ const { done, value } = await reader.read();
728
+ if (done) break;
729
+ const nextBytes = bytes + value.byteLength;
730
+ if (nextBytes > limitBytes) {
731
+ await reader.cancel().catch(() => undefined);
732
+ throw new RuntimeApiResponseTooLargeError(
733
+ input.action,
734
+ limitBytes,
735
+ nextBytes,
736
+ );
737
+ }
738
+ chunks.push(value);
739
+ bytes = nextBytes;
740
+ }
741
+ } finally {
742
+ reader.releaseLock();
743
+ }
744
+ const body = new Uint8Array(bytes);
745
+ let offset = 0;
746
+ for (const chunk of chunks) {
747
+ body.set(chunk, offset);
748
+ offset += chunk.byteLength;
749
+ }
750
+ if (body.byteLength === 0) return null;
751
+ return JSON.parse(new TextDecoder().decode(body)) as unknown;
752
+ }
753
+
635
754
  async function postRuntimeApi<TResponse>(
636
755
  context: RuntimeApiContext,
637
756
  body: RuntimeApiActionRequest,
@@ -668,8 +787,14 @@ async function postRuntimeApi<TResponse>(
668
787
  });
669
788
  let parsedValue: unknown;
670
789
  try {
671
- parsedValue = await response.json();
790
+ parsedValue = await readRuntimeApiResponseJson({
791
+ response,
792
+ action: body.action,
793
+ });
672
794
  } catch (error) {
795
+ if (error instanceof RuntimeApiResponseTooLargeError) {
796
+ throw error;
797
+ }
673
798
  if (abortController.signal.aborted || context.abortSignal?.aborted) {
674
799
  throw error;
675
800
  }
@@ -683,6 +808,9 @@ async function postRuntimeApi<TResponse>(
683
808
  : null;
684
809
  } catch (error) {
685
810
  clearTimeout(timeout);
811
+ if (error instanceof RuntimeApiResponseTooLargeError) {
812
+ throw error;
813
+ }
686
814
  if (context.abortSignal?.aborted) {
687
815
  throw context.abortSignal.reason ?? error;
688
816
  }
@@ -750,6 +878,18 @@ async function postRuntimeApi<TResponse>(
750
878
  : typeof parsed?.debug_error === 'string'
751
879
  ? parsed.debug_error
752
880
  : null;
881
+ if (
882
+ response.status === 413 &&
883
+ body.action === 'runtime_sheet_read_rows_batch' &&
884
+ parsed?.code === 'runtime_sheet_read_batch_too_large'
885
+ ) {
886
+ throw new RuntimeSheetDatasetReadBatchTooLargeError(
887
+ typeof parsed.observed_bytes === 'number' &&
888
+ Number.isFinite(parsed.observed_bytes)
889
+ ? parsed.observed_bytes
890
+ : RUNTIME_SHEET_READ_BATCH_MAX_RESPONSE_BYTES + 1,
891
+ );
892
+ }
753
893
  if (
754
894
  response.status === 503 &&
755
895
  parsed?.code === 'runtime_postgres_admission_backpressure'
@@ -916,7 +1056,7 @@ function runtimeApiMaxAttempts(
916
1056
  }
917
1057
 
918
1058
  export function isTransientRuntimeApiErrorMessage(message: string): boolean {
919
- return /timeout exceeded when trying to connect|timed out|fetch failed|ECONNRESET|ECONNREFUSED|UND_ERR_CONNECT_TIMEOUT|requested endpoint could not be found, or you don't have access|RUNTIME_SCHEDULER_(?:SATURATED|UNAVAILABLE|SCHEMA_NOT_READY)|Runtime scheduler DB pool is saturated|Runtime scheduler DB circuit breaker is open|Runtime scheduler schema .* is not ready/i.test(
1059
+ return /timeout exceeded when trying to connect|timed out|fetch failed|ECONNRESET|ECONNREFUSED|UND_ERR_CONNECT_TIMEOUT|requested endpoint could not be found, or you don't have access|Neon API request failed \((?:408|429|502|503|504)\)|RUNTIME_SCHEDULER_(?:SATURATED|UNAVAILABLE|SCHEMA_NOT_READY)|RUNTIME_RATE_STATE_UNAVAILABLE|Runtime scheduler DB pool is saturated|Runtime scheduler DB circuit breaker is open|Runtime rate state .* is unavailable/i.test(
920
1060
  message,
921
1061
  );
922
1062
  }
@@ -5355,7 +5495,12 @@ export async function completeRuntimeWorkReceipt(
5355
5495
  input.runId,
5356
5496
  ],
5357
5497
  );
5358
- if (insertedRows[0]) return mapRuntimeWorkReceiptRow(insertedRows[0]);
5498
+ if (insertedRows[0]) {
5499
+ return {
5500
+ ...mapRuntimeWorkReceiptRow(insertedRows[0]),
5501
+ completionDisposition: 'persisted',
5502
+ };
5503
+ }
5359
5504
  const { rows: winnerRows } = await client.query<
5360
5505
  Record<string, unknown>
5361
5506
  >(
@@ -5368,7 +5513,12 @@ export async function completeRuntimeWorkReceipt(
5368
5513
  LIMIT 1`,
5369
5514
  [workReceiptKeyHex(input.key), RECEIPT_STATUS_COMPLETED],
5370
5515
  );
5371
- return winnerRows[0] ? mapRuntimeWorkReceiptRow(winnerRows[0]) : null;
5516
+ return winnerRows[0]
5517
+ ? {
5518
+ ...mapRuntimeWorkReceiptRow(winnerRows[0]),
5519
+ completionDisposition: 'existing',
5520
+ }
5521
+ : null;
5372
5522
  }
5373
5523
  const runAttempt = normalizeRuntimeRunAttempt(input.runAttempt);
5374
5524
  const { rows } = await client.query<Record<string, unknown>>(
@@ -6857,11 +7007,46 @@ type CompleteRuntimeMapRowChunksInput = {
6857
7007
  outputFields: string[];
6858
7008
  };
6859
7009
 
7010
+ /**
7011
+ * Why a terminal row write was rejected as stale. Runtime Sheet rows are fenced
7012
+ * by their schedule-time `_write_version` alone (the legacy attempt-lease
7013
+ * predicates are stubbed to TRUE/FALSE in sheet-attempt-sql.ts), so a stale
7014
+ * versioned write always means the row is owned by a different admission than
7015
+ * the one the writer carries:
7016
+ * - `newer_write_version`: a later admission (rerun, resumed attempt, or a
7017
+ * concurrent run of the same play) re-admitted the row after this writer
7018
+ * admitted it.
7019
+ * - `older_write_version`: the row was admitted under an older version than
7020
+ * the writer sent; the writer's version never admitted this row.
7021
+ * - `already_terminal`: an unversioned writer hit an already-enriched row.
7022
+ * Nothing is being overwritten, so this is the one benign reason.
7023
+ * - `unreported`: the gateway returned stale keys without reasons (predates
7024
+ * this field). Treated as superseded: every stale versioned write is.
7025
+ */
7026
+ export type RuntimeMapRowsStaleReason =
7027
+ | 'newer_write_version'
7028
+ | 'older_write_version'
7029
+ | 'already_terminal'
7030
+ | 'unreported';
7031
+
7032
+ export type RuntimeMapRowsStaleRow = {
7033
+ key: string;
7034
+ reason: RuntimeMapRowsStaleReason;
7035
+ /** The admission version currently stamped on the sheet row. */
7036
+ rowVersionStamp: number | null;
7037
+ };
7038
+
6860
7039
  export type RuntimeMapRowsWriteResult = {
6861
7040
  /** Submitted row identities durably accepted, including idempotent replays. */
6862
7041
  committedKeys: string[];
6863
7042
  /** Submitted row identities rejected because a newer write owns the row. */
6864
7043
  staleKeys: string[];
7044
+ /**
7045
+ * Why each stale key was rejected. Present only when the write produced stale
7046
+ * keys and the classifying side reported reasons; a gateway that predates
7047
+ * this field returns stale keys without it.
7048
+ */
7049
+ staleRows?: RuntimeMapRowsStaleRow[];
6865
7050
  /** Physical row mutations. Idempotent commits do not increment this count. */
6866
7051
  updated: number;
6867
7052
  /** Compatibility alias retained for callers that still call stale rows fenced. */
@@ -6874,16 +7059,67 @@ export type RuntimeMapRowsWriteResult = {
6874
7059
 
6875
7060
  type RuntimeMapRowsMutationResult = {
6876
7061
  updated: number;
6877
- fencedKeys: string[];
7062
+ fencedRows: RuntimeMapRowsStaleRow[];
6878
7063
  conflictKeys: string[];
6879
7064
  };
6880
7065
 
7066
+ const RUNTIME_MAP_ROWS_STALE_REASONS: ReadonlySet<RuntimeMapRowsStaleReason> =
7067
+ new Set([
7068
+ 'newer_write_version',
7069
+ 'older_write_version',
7070
+ 'already_terminal',
7071
+ 'unreported',
7072
+ ]);
7073
+
7074
+ function parseRuntimeMapRowsFencedRows(
7075
+ value: unknown,
7076
+ ): RuntimeMapRowsStaleRow[] {
7077
+ const entries =
7078
+ typeof value === 'string' ? (JSON.parse(value) as unknown) : value;
7079
+ if (entries == null) return [];
7080
+ if (!Array.isArray(entries)) {
7081
+ throw new Error(
7082
+ 'Runtime Sheet terminal write returned a malformed fenced_rows column.',
7083
+ );
7084
+ }
7085
+ return entries.map((entry) => {
7086
+ const row = entry as Partial<RuntimeMapRowsStaleRow> | null;
7087
+ if (
7088
+ !row ||
7089
+ typeof row.key !== 'string' ||
7090
+ typeof row.reason !== 'string' ||
7091
+ !RUNTIME_MAP_ROWS_STALE_REASONS.has(row.reason)
7092
+ ) {
7093
+ throw new Error(
7094
+ `Runtime Sheet terminal write returned a malformed fenced row: ${JSON.stringify(entry)}.`,
7095
+ );
7096
+ }
7097
+ const rowVersionStamp =
7098
+ row.rowVersionStamp == null ? null : Number(row.rowVersionStamp);
7099
+ return {
7100
+ key: row.key,
7101
+ reason: row.reason,
7102
+ rowVersionStamp:
7103
+ rowVersionStamp !== null && Number.isFinite(rowVersionStamp)
7104
+ ? rowVersionStamp
7105
+ : null,
7106
+ };
7107
+ });
7108
+ }
7109
+
6881
7110
  function classifyRuntimeMapRowsWrite(input: {
6882
7111
  submittedKeys: Iterable<string>;
6883
7112
  updated: number;
6884
- fencedKeys: Iterable<string>;
7113
+ fencedKeys?: Iterable<string>;
7114
+ fencedRows?: Iterable<RuntimeMapRowsStaleRow>;
6885
7115
  }): RuntimeMapRowsWriteResult {
6886
- const staleKeys = [...new Set(input.fencedKeys)];
7116
+ const staleRowsByKey = new Map<string, RuntimeMapRowsStaleRow>();
7117
+ for (const row of input.fencedRows ?? []) {
7118
+ if (!staleRowsByKey.has(row.key)) staleRowsByKey.set(row.key, row);
7119
+ }
7120
+ const staleKeys = [
7121
+ ...new Set([...staleRowsByKey.keys(), ...(input.fencedKeys ?? [])]),
7122
+ ];
6887
7123
  const staleSet = new Set(staleKeys);
6888
7124
  const committedKeys = [...new Set(input.submittedKeys)].filter(
6889
7125
  (key) => !staleSet.has(key),
@@ -6891,6 +7127,9 @@ function classifyRuntimeMapRowsWrite(input: {
6891
7127
  return {
6892
7128
  committedKeys,
6893
7129
  staleKeys,
7130
+ ...(staleRowsByKey.size > 0
7131
+ ? { staleRows: [...staleRowsByKey.values()] }
7132
+ : {}),
6894
7133
  updated: input.updated,
6895
7134
  fencedKeys: staleKeys,
6896
7135
  staleDropped: staleKeys.length,
@@ -6898,6 +7137,99 @@ function classifyRuntimeMapRowsWrite(input: {
6898
7137
  };
6899
7138
  }
6900
7139
 
7140
+ /**
7141
+ * The stale rows of a terminal write that prove this writer no longer owns the
7142
+ * sheet: every stale key except the ones the caller verified as idempotent
7143
+ * same-run settlements and the benign unversioned `already_terminal` case. A
7144
+ * result without `staleRows` (a gateway that predates reasons) reports its
7145
+ * stale keys as `unreported`, which counts as superseded: a versioned write can
7146
+ * only be stale when the row carries a different admission version.
7147
+ */
7148
+ export function supersededRuntimeMapRows(
7149
+ result: Pick<RuntimeMapRowsWriteResult, 'staleKeys' | 'staleRows'>,
7150
+ options: { exceptKeys?: Iterable<string> } = {},
7151
+ ): RuntimeMapRowsStaleRow[] {
7152
+ const except = new Set(options.exceptKeys ?? []);
7153
+ const reported = new Map(
7154
+ (result.staleRows ?? []).map((row) => [row.key, row] as const),
7155
+ );
7156
+ const superseded: RuntimeMapRowsStaleRow[] = [];
7157
+ for (const key of new Set(result.staleKeys)) {
7158
+ if (except.has(key)) continue;
7159
+ const row = reported.get(key) ?? {
7160
+ key,
7161
+ reason: 'unreported' as const,
7162
+ rowVersionStamp: null,
7163
+ };
7164
+ if (row.reason === 'already_terminal') continue;
7165
+ superseded.push(row);
7166
+ }
7167
+ return superseded;
7168
+ }
7169
+
7170
+ /**
7171
+ * A terminal write batch was rejected because the Runtime Sheet rows belong to
7172
+ * a different write version than this attempt carries. The attempt has been
7173
+ * superseded: nothing it executes from here on can be persisted, so it must
7174
+ * stop instead of spending provider calls on rows that will be dropped. Thrown
7175
+ * by the runner's terminal-write handler and by the Context when a handler
7176
+ * reports `supersededKeys`; both trip the persistence latch.
7177
+ */
7178
+ export class RuntimeSheetAttemptSupersededError extends Error {
7179
+ readonly tableNamespace: string;
7180
+ readonly runId: string;
7181
+ readonly writeVersion: number | null;
7182
+ readonly submittedRows: number;
7183
+ readonly supersededRows: RuntimeMapRowsStaleRow[];
7184
+
7185
+ constructor(input: {
7186
+ tableNamespace: string;
7187
+ runId: string;
7188
+ writeVersionValue: number | null;
7189
+ submittedRows: number;
7190
+ supersededRows: RuntimeMapRowsStaleRow[];
7191
+ }) {
7192
+ const reasonCounts = new Map<RuntimeMapRowsStaleReason, number>();
7193
+ for (const row of input.supersededRows) {
7194
+ reasonCounts.set(row.reason, (reasonCounts.get(row.reason) ?? 0) + 1);
7195
+ }
7196
+ const reasons = [...reasonCounts.entries()]
7197
+ .map(([reason, count]) => `${reason}=${count}`)
7198
+ .join(', ');
7199
+ const samples = input.supersededRows
7200
+ .slice(0, 5)
7201
+ .map(
7202
+ (row) =>
7203
+ `key=${row.key} row_write_version=${row.rowVersionStamp ?? 'unknown'}`,
7204
+ )
7205
+ .join('; ');
7206
+ super(
7207
+ `RUNTIME_SHEET_ATTEMPT_SUPERSEDED: Runtime Sheet table "${input.tableNamespace}" ` +
7208
+ `rejected ${input.supersededRows.length} of ${input.submittedRows} terminal row write(s) ` +
7209
+ `from run ${input.runId} at write version ${input.writeVersionValue ?? 'none'} ` +
7210
+ `because the rows are owned by a different write version (${reasons || 'unreported'}). ` +
7211
+ `This attempt has been superseded by a newer admission of these rows and must stop ` +
7212
+ `executing: nothing it persists from here on can be accepted. Sample: ${samples}.`,
7213
+ );
7214
+ this.name = 'RuntimeSheetAttemptSupersededError';
7215
+ this.tableNamespace = input.tableNamespace;
7216
+ this.runId = input.runId;
7217
+ this.writeVersion = input.writeVersionValue;
7218
+ this.submittedRows = input.submittedRows;
7219
+ this.supersededRows = input.supersededRows;
7220
+ }
7221
+ }
7222
+
7223
+ export function isRuntimeSheetAttemptSupersededError(
7224
+ error: unknown,
7225
+ ): error is RuntimeSheetAttemptSupersededError {
7226
+ if (error instanceof RuntimeSheetAttemptSupersededError) return true;
7227
+ return (
7228
+ error instanceof Error &&
7229
+ error.name === 'RuntimeSheetAttemptSupersededError'
7230
+ );
7231
+ }
7232
+
6901
7233
  function writableEnrichedRuntimeSheetAttemptSql(
6902
7234
  tableAlias: string,
6903
7235
  attemptOwnerRunIdExpression: string,
@@ -6918,7 +7250,7 @@ async function completeRuntimeMapRowChunks(
6918
7250
  input: CompleteRuntimeMapRowChunksInput,
6919
7251
  ): Promise<RuntimeMapRowsMutationResult> {
6920
7252
  let updated = 0;
6921
- const fencedKeys: string[] = [];
7253
+ const fencedRows: RuntimeMapRowsStaleRow[] = [];
6922
7254
  const conflictKeys: string[] = [];
6923
7255
  for (const chunk of input.chunks) {
6924
7256
  const chunkKeys = chunk.map((row) => row.key);
@@ -6946,7 +7278,7 @@ async function completeRuntimeMapRowChunks(
6946
7278
  const { rows } = await client.query<{
6947
7279
  updated: number;
6948
7280
  matched_keys: string[];
6949
- fenced_keys: string[];
7281
+ fenced_rows: unknown;
6950
7282
  conflict_keys: string[];
6951
7283
  }>(
6952
7284
  `WITH updates AS (
@@ -7062,7 +7394,13 @@ async function completeRuntimeMapRowChunks(
7062
7394
  FROM applied_rows
7063
7395
  ),
7064
7396
  fenced_rows AS (
7065
- SELECT updates.matched_key AS _key
7397
+ SELECT updates.matched_key AS _key,
7398
+ CASE
7399
+ WHEN $13::bigint IS NOT NULL AND target._write_version > $13::bigint THEN 'newer_write_version'
7400
+ WHEN $13::bigint IS NOT NULL AND target._write_version < $13::bigint THEN 'older_write_version'
7401
+ ELSE 'already_terminal'
7402
+ END AS reason,
7403
+ target._write_version AS row_write_version
7066
7404
  FROM matched_updates AS updates
7067
7405
  JOIN ${sheetTable(session)} AS target
7068
7406
  ON target._key = updates.matched_key
@@ -7166,7 +7504,7 @@ async function completeRuntimeMapRowChunks(
7166
7504
  SELECT
7167
7505
  (SELECT count(*)::int FROM applied_rows) AS updated,
7168
7506
  coalesce((SELECT array_agg(matched_key) FROM matched_updates), '{}'::text[]) AS matched_keys,
7169
- coalesce((SELECT array_agg(_key) FROM fenced_rows), '{}'::text[]) AS fenced_keys,
7507
+ coalesce((SELECT jsonb_agg(jsonb_build_object('key', _key, 'reason', reason, 'rowVersionStamp', row_write_version)) FROM fenced_rows), '[]'::jsonb) AS fenced_rows,
7170
7508
  coalesce((SELECT array_agg(_key) FROM same_version_conflicts), '{}'::text[]) AS conflict_keys,
7171
7509
  (SELECT count(*)::int FROM summary_delta) AS summary_delta_count,
7172
7510
  (SELECT count(*)::int FROM column_delta) AS column_delta_count`,
@@ -7187,14 +7525,14 @@ async function completeRuntimeMapRowChunks(
7187
7525
  ],
7188
7526
  );
7189
7527
  const matchedKeys = new Set(rows[0]?.matched_keys ?? []);
7190
- const chunkFencedKeys = rows[0]?.fenced_keys ?? [];
7528
+ const chunkFencedRows = parseRuntimeMapRowsFencedRows(rows[0]?.fenced_rows);
7191
7529
  const chunkConflictKeys = rows[0]?.conflict_keys ?? [];
7192
7530
  // An exact-version replay that already produced the same terminal value is
7193
7531
  // accepted idempotently even when SQL has no physical mutation to apply.
7194
- // Stale versions remain excluded through fenced_keys.
7532
+ // Stale versions remain excluded through fenced_rows.
7195
7533
  const appliedCount = Number(rows[0]?.updated ?? 0);
7196
7534
  updated += appliedCount;
7197
- fencedKeys.push(...chunkFencedKeys);
7535
+ fencedRows.push(...chunkFencedRows);
7198
7536
  conflictKeys.push(...chunkConflictKeys);
7199
7537
 
7200
7538
  if (chunkConflictKeys.length > 0) {
@@ -7217,10 +7555,10 @@ async function completeRuntimeMapRowChunks(
7217
7555
  { ...input, chunks: [repairChunk] },
7218
7556
  );
7219
7557
  updated += repaired.updated;
7220
- fencedKeys.push(...repaired.fencedKeys);
7558
+ fencedRows.push(...repaired.fencedRows);
7221
7559
  conflictKeys.push(...repaired.conflictKeys);
7222
7560
  }
7223
- return { updated, fencedKeys, conflictKeys };
7561
+ return { updated, fencedRows, conflictKeys };
7224
7562
  }
7225
7563
 
7226
7564
  async function completeRuntimeMapRowChunksWithInputIndexRepair(
@@ -7229,7 +7567,7 @@ async function completeRuntimeMapRowChunksWithInputIndexRepair(
7229
7567
  input: CompleteRuntimeMapRowChunksInput,
7230
7568
  ): Promise<RuntimeMapRowsMutationResult> {
7231
7569
  let updated = 0;
7232
- const fencedKeys: string[] = [];
7570
+ const fencedRows: RuntimeMapRowsStaleRow[] = [];
7233
7571
  const conflictKeys: string[] = [];
7234
7572
  for (const chunk of input.chunks) {
7235
7573
  const chunkKeys = chunk.map((row) => row.key);
@@ -7249,7 +7587,7 @@ async function completeRuntimeMapRowChunksWithInputIndexRepair(
7249
7587
  );
7250
7588
  const { rows } = await client.query<{
7251
7589
  _key?: string;
7252
- fenced_keys?: string[];
7590
+ fenced_rows?: unknown;
7253
7591
  conflict_keys?: string[];
7254
7592
  updated?: number;
7255
7593
  }>(
@@ -7362,7 +7700,13 @@ async function completeRuntimeMapRowChunksWithInputIndexRepair(
7362
7700
  FROM applied_rows
7363
7701
  ),
7364
7702
  fenced_rows AS (
7365
- SELECT updates.submitted_key AS _key
7703
+ SELECT updates.submitted_key AS _key,
7704
+ CASE
7705
+ WHEN $13::bigint IS NOT NULL AND target._write_version > $13::bigint THEN 'newer_write_version'
7706
+ WHEN $13::bigint IS NOT NULL AND target._write_version < $13::bigint THEN 'older_write_version'
7707
+ ELSE 'already_terminal'
7708
+ END AS reason,
7709
+ target._write_version AS row_write_version
7366
7710
  FROM matched_updates AS updates
7367
7711
  JOIN ${sheetTable(session)} AS target
7368
7712
  ON target._key = updates.matched_key
@@ -7463,7 +7807,7 @@ async function completeRuntimeMapRowChunksWithInputIndexRepair(
7463
7807
  )
7464
7808
  SELECT
7465
7809
  (SELECT count(*)::int FROM applied_rows) AS updated,
7466
- coalesce((SELECT array_agg(_key) FROM fenced_rows), '{}'::text[]) AS fenced_keys,
7810
+ coalesce((SELECT jsonb_agg(jsonb_build_object('key', _key, 'reason', reason, 'rowVersionStamp', row_write_version)) FROM fenced_rows), '[]'::jsonb) AS fenced_rows,
7467
7811
  coalesce((SELECT array_agg(_key) FROM same_version_conflicts), '{}'::text[]) AS conflict_keys`,
7468
7812
  [
7469
7813
  chunkKeys,
@@ -7482,10 +7826,10 @@ async function completeRuntimeMapRowChunksWithInputIndexRepair(
7482
7826
  ],
7483
7827
  );
7484
7828
  updated += Number(rows[0]?.updated ?? 0);
7485
- fencedKeys.push(...(rows[0]?.fenced_keys ?? []));
7829
+ fencedRows.push(...parseRuntimeMapRowsFencedRows(rows[0]?.fenced_rows));
7486
7830
  conflictKeys.push(...(rows[0]?.conflict_keys ?? []));
7487
7831
  }
7488
- return { updated, fencedKeys, conflictKeys };
7832
+ return { updated, fencedRows, conflictKeys };
7489
7833
  }
7490
7834
 
7491
7835
  async function insertMissingCompletedMapRowChunks(
@@ -7680,7 +8024,7 @@ async function failRuntimeMapRowChunks(
7680
8024
  },
7681
8025
  ): Promise<RuntimeMapRowsMutationResult> {
7682
8026
  let updated = 0;
7683
- const fencedKeys: string[] = [];
8027
+ const fencedRows: RuntimeMapRowsStaleRow[] = [];
7684
8028
  const conflictKeys: string[] = [];
7685
8029
  for (const chunk of input.chunks) {
7686
8030
  const chunkKeys = chunk.map((row) => row.key);
@@ -7713,7 +8057,7 @@ async function failRuntimeMapRowChunks(
7713
8057
  );
7714
8058
  const { rows } = await client.query<{
7715
8059
  updated?: number;
7716
- fenced_keys?: string[];
8060
+ fenced_rows?: unknown;
7717
8061
  conflict_keys?: string[];
7718
8062
  }>(
7719
8063
  `WITH updates AS (
@@ -7829,7 +8173,13 @@ async function failRuntimeMapRowChunks(
7829
8173
  FROM applied_rows
7830
8174
  ),
7831
8175
  fenced_rows AS (
7832
- SELECT updates.matched_key AS _key
8176
+ SELECT updates.matched_key AS _key,
8177
+ CASE
8178
+ WHEN $16::bigint IS NOT NULL AND target._write_version > $16::bigint THEN 'newer_write_version'
8179
+ WHEN $16::bigint IS NOT NULL AND target._write_version < $16::bigint THEN 'older_write_version'
8180
+ ELSE 'already_terminal'
8181
+ END AS reason,
8182
+ target._write_version AS row_write_version
7833
8183
  FROM matched_updates AS updates
7834
8184
  JOIN ${sheetTable(session)} AS target
7835
8185
  ON target._key = updates.matched_key
@@ -7908,7 +8258,7 @@ async function failRuntimeMapRowChunks(
7908
8258
  )
7909
8259
  SELECT
7910
8260
  (SELECT count(*)::int FROM applied_rows) AS updated,
7911
- coalesce((SELECT array_agg(_key) FROM fenced_rows), '{}'::text[]) AS fenced_keys,
8261
+ coalesce((SELECT jsonb_agg(jsonb_build_object('key', _key, 'reason', reason, 'rowVersionStamp', row_write_version)) FROM fenced_rows), '[]'::jsonb) AS fenced_rows,
7912
8262
  coalesce((SELECT array_agg(_key) FROM same_version_conflicts), '{}'::text[]) AS conflict_keys`,
7913
8263
  [
7914
8264
  chunkKeys,
@@ -7930,13 +8280,13 @@ async function failRuntimeMapRowChunks(
7930
8280
  ],
7931
8281
  );
7932
8282
  const appliedCount = Number(rows[0]?.updated ?? 0);
7933
- const chunkFencedKeys = rows[0]?.fenced_keys ?? [];
8283
+ const chunkFencedRows = parseRuntimeMapRowsFencedRows(rows[0]?.fenced_rows);
7934
8284
  const chunkConflictKeys = rows[0]?.conflict_keys ?? [];
7935
8285
  updated += appliedCount;
7936
- fencedKeys.push(...chunkFencedKeys);
8286
+ fencedRows.push(...chunkFencedRows);
7937
8287
  conflictKeys.push(...chunkConflictKeys);
7938
8288
  }
7939
- return { updated, fencedKeys, conflictKeys };
8289
+ return { updated, fencedRows, conflictKeys };
7940
8290
  }
7941
8291
 
7942
8292
  /**
@@ -8006,6 +8356,11 @@ export async function completeRuntimeMapRows(
8006
8356
  submittedKeys,
8007
8357
  updated: result.updated,
8008
8358
  fencedKeys: staleKeys,
8359
+ // A gateway that predates stale reasons omits staleRows; keep it
8360
+ // absent so the caller classifies those keys as unreported.
8361
+ fencedRows: Array.isArray(result.staleRows)
8362
+ ? parseRuntimeMapRowsFencedRows(result.staleRows)
8363
+ : undefined,
8009
8364
  }),
8010
8365
  // Preserve the gateway's compatibility classification during rollout.
8011
8366
  fencedKeys: result.fencedKeys,
@@ -8141,11 +8496,11 @@ export async function completeRuntimeMapRows(
8141
8496
  );
8142
8497
  return {
8143
8498
  updated: updated.updated + inserted.inserted,
8144
- fencedKeys: updated.fencedKeys,
8499
+ fencedRows: updated.fencedRows,
8145
8500
  conflictKeys: updated.conflictKeys,
8146
8501
  };
8147
8502
  })()
8148
- : { updated: 0, fencedKeys: [], conflictKeys: [] };
8503
+ : { updated: 0, fencedRows: [], conflictKeys: [] };
8149
8504
  const failed =
8150
8505
  failedRows.length > 0
8151
8506
  ? await failRuntimeMapRowChunks(client, session, {
@@ -8164,7 +8519,7 @@ export async function completeRuntimeMapRows(
8164
8519
  input.tableNamespace,
8165
8520
  ),
8166
8521
  })
8167
- : { updated: 0, fencedKeys: [], conflictKeys: [] };
8522
+ : { updated: 0, fencedRows: [], conflictKeys: [] };
8168
8523
  const conflictKeys = [...completed.conflictKeys, ...failed.conflictKeys];
8169
8524
  if (conflictKeys.length > 0) {
8170
8525
  throw new Error(
@@ -8178,7 +8533,7 @@ export async function completeRuntimeMapRows(
8178
8533
  return classifyRuntimeMapRowsWrite({
8179
8534
  submittedKeys: uniqueRows.keys(),
8180
8535
  updated: completed.updated + failed.updated,
8181
- fencedKeys: [...completed.fencedKeys, ...failed.fencedKeys],
8536
+ fencedRows: [...completed.fencedRows, ...failed.fencedRows],
8182
8537
  });
8183
8538
  },
8184
8539
  );
@@ -8207,27 +8562,160 @@ export function createCompleteRuntimeMapRowsOperation(
8207
8562
  return () => completeRuntimeMapRows(context, frozenInput);
8208
8563
  }
8209
8564
 
8210
- export async function readRuntimeSheetDatasetRows(
8565
+ export type RuntimeSheetDatasetReadInput = {
8566
+ tableNamespace: string;
8567
+ runId?: string | null;
8568
+ rowMode?: 'output' | 'all';
8569
+ limit: number;
8570
+ offset: number;
8571
+ };
8572
+
8573
+ export type RuntimeSheetDatasetReadBatchInput = Omit<
8574
+ RuntimeSheetDatasetReadInput,
8575
+ 'limit' | 'offset'
8576
+ > & {
8577
+ pages: readonly RuntimeSheetDatasetPageInput[];
8578
+ };
8579
+
8580
+ export type RuntimeSheetDatasetReadBatchResult = {
8581
+ pages: RuntimeSheetDatasetPage[];
8582
+ };
8583
+
8584
+ function normalizeRuntimeSheetDatasetPage(
8585
+ input: RuntimeSheetDatasetPageInput,
8586
+ ): RuntimeSheetDatasetPageInput {
8587
+ if (!input || typeof input !== 'object') {
8588
+ throw new Error('Runtime dataset page input must be an object.');
8589
+ }
8590
+ if (!Number.isFinite(input.limit) || !Number.isFinite(input.offset)) {
8591
+ throw new Error('Runtime dataset page limit and offset must be finite.');
8592
+ }
8593
+ return {
8594
+ limit: Math.max(
8595
+ 1,
8596
+ Math.min(DIRECT_POSTGRES_BATCH_SIZE, Math.floor(input.limit)),
8597
+ ),
8598
+ offset: Math.max(0, Math.floor(input.offset)),
8599
+ };
8600
+ }
8601
+
8602
+ function assertRuntimeSheetDatasetReadBatchResponse(
8603
+ value: unknown,
8604
+ requestedPages: readonly RuntimeSheetDatasetPageInput[],
8605
+ ): asserts value is RuntimeSheetDatasetReadBatchResult {
8606
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
8607
+ throw new Error('Runtime dataset batch response must be an object.');
8608
+ }
8609
+ const pages = (value as { pages?: unknown }).pages;
8610
+ if (!Array.isArray(pages) || pages.length !== requestedPages.length) {
8611
+ throw new Error(
8612
+ `Runtime dataset batch response must contain ${requestedPages.length} correlated page result(s).`,
8613
+ );
8614
+ }
8615
+ for (const [index, requested] of requestedPages.entries()) {
8616
+ const page = pages[index];
8617
+ if (!page || typeof page !== 'object' || Array.isArray(page)) {
8618
+ throw new Error(
8619
+ `Runtime dataset batch response page ${index} must be an object.`,
8620
+ );
8621
+ }
8622
+ const result = page as Partial<RuntimeSheetDatasetPage>;
8623
+ if (
8624
+ result.limit !== requested.limit ||
8625
+ result.offset !== requested.offset ||
8626
+ !Array.isArray(result.rows) ||
8627
+ result.rows.length > requested.limit ||
8628
+ result.rows.some(
8629
+ (row) => !row || typeof row !== 'object' || Array.isArray(row),
8630
+ )
8631
+ ) {
8632
+ throw new Error(
8633
+ `Runtime dataset batch response page ${index} is not correlated to its requested offset and limit.`,
8634
+ );
8635
+ }
8636
+ }
8637
+ }
8638
+
8639
+ function assertRuntimeSheetDatasetReadBatchWithinBudget(
8640
+ value: RuntimeSheetDatasetReadBatchResult,
8641
+ ): void {
8642
+ const measurement = jsonByteLengthUpTo(
8643
+ value,
8644
+ RUNTIME_SHEET_READ_BATCH_MAX_RESPONSE_BYTES,
8645
+ );
8646
+ if (measurement.exceeded) {
8647
+ throw new RuntimeSheetDatasetReadBatchTooLargeError(measurement.bytes);
8648
+ }
8649
+ }
8650
+
8651
+ function isRuntimeSheetDatasetReadBatchUnsupportedError(
8652
+ error: unknown,
8653
+ ): boolean {
8654
+ return (
8655
+ error instanceof Error &&
8656
+ /Unsupported runtime data-plane action/i.test(error.message)
8657
+ );
8658
+ }
8659
+
8660
+ /**
8661
+ * Redeem several ordered dataset pages under one scoped DB session or gateway
8662
+ * request. The response deliberately stays positional: a caller can only
8663
+ * receive the page it asked for, in the order it asked for it.
8664
+ */
8665
+ export async function readRuntimeSheetDatasetRowsBatch(
8211
8666
  context: RuntimeApiContext & { playName: string },
8212
- input: {
8213
- tableNamespace: string;
8214
- runId?: string | null;
8215
- rowMode?: 'output' | 'all';
8216
- limit: number;
8217
- offset: number;
8218
- },
8219
- ): Promise<{ rows: Record<string, unknown>[]; limit: number; offset: number }> {
8667
+ input: RuntimeSheetDatasetReadBatchInput,
8668
+ ): Promise<RuntimeSheetDatasetReadBatchResult> {
8669
+ if (!Array.isArray(input.pages)) {
8670
+ throw new Error('Runtime dataset read batch pages must be an array.');
8671
+ }
8672
+ if (
8673
+ input.pages.length === 0 ||
8674
+ input.pages.length > RUNTIME_SHEET_READ_BATCH_MAX_PAGES
8675
+ ) {
8676
+ throw new Error(
8677
+ `Runtime dataset read batch requires 1-${RUNTIME_SHEET_READ_BATCH_MAX_PAGES} pages.`,
8678
+ );
8679
+ }
8680
+ const pages = input.pages.map(normalizeRuntimeSheetDatasetPage);
8220
8681
  if (context.dbSessionStrategy === 'gateway_only') {
8221
- return await postRuntimeApi(context, {
8222
- action: 'runtime_sheet_read_rows',
8223
- input,
8224
- });
8682
+ try {
8683
+ const result = await postRuntimeApi<RuntimeSheetDatasetReadBatchResult>(
8684
+ context,
8685
+ {
8686
+ action: 'runtime_sheet_read_rows_batch',
8687
+ input: { ...input, pages },
8688
+ },
8689
+ );
8690
+ assertRuntimeSheetDatasetReadBatchResponse(result, pages);
8691
+ return result;
8692
+ } catch (error) {
8693
+ // New runners can cross a rolling gateway fleet. Keep the old
8694
+ // single-page action readable until the batch action is everywhere;
8695
+ // this is a compatibility fallback, never a second read of an accepted
8696
+ // batch response.
8697
+ if (!isRuntimeSheetDatasetReadBatchUnsupportedError(error)) {
8698
+ throw error;
8699
+ }
8700
+ const legacyPages: RuntimeSheetDatasetPage[] = [];
8701
+ for (const page of pages) {
8702
+ legacyPages.push(
8703
+ await postRuntimeApi(context, {
8704
+ action: 'runtime_sheet_read_rows',
8705
+ input: {
8706
+ tableNamespace: input.tableNamespace,
8707
+ runId: input.runId,
8708
+ rowMode: input.rowMode,
8709
+ ...page,
8710
+ },
8711
+ }),
8712
+ );
8713
+ }
8714
+ const result = { pages: legacyPages };
8715
+ assertRuntimeSheetDatasetReadBatchResponse(result, pages);
8716
+ return result;
8717
+ }
8225
8718
  }
8226
- const limit = Math.max(
8227
- 1,
8228
- Math.min(DIRECT_POSTGRES_BATCH_SIZE, Math.floor(input.limit)),
8229
- );
8230
- const offset = Math.max(0, Math.floor(input.offset));
8231
8719
  const session = requireRuntimePostgresSession(
8232
8720
  await getRuntimeDbSession(
8233
8721
  {
@@ -8239,20 +8727,176 @@ export async function readRuntimeSheetDatasetRows(
8239
8727
  tableNamespace: input.tableNamespace,
8240
8728
  logicalTable: 'sheet_rows',
8241
8729
  operations: ['rows.read'],
8242
- limits: { maxRows: runtimeDbSessionRowLimit(limit) },
8730
+ limits: {
8731
+ maxRows: runtimeDbSessionRowLimit(
8732
+ pages.reduce((total, page) => total + page.limit, 0),
8733
+ ),
8734
+ },
8243
8735
  },
8244
8736
  ),
8245
8737
  );
8246
- const rows = await readRuntimeRows(session, {
8247
- limit,
8248
- offset,
8249
- runId: input.runId ?? null,
8738
+ const results: RuntimeSheetDatasetPage[] = [];
8739
+ for (const page of pages) {
8740
+ const rows = await readRuntimeRows(session, {
8741
+ limit: page.limit,
8742
+ offset: page.offset,
8743
+ runId: input.runId ?? null,
8744
+ rowMode: input.rowMode,
8745
+ });
8746
+ results.push({
8747
+ rows: rows.map((row) => row.data),
8748
+ limit: page.limit,
8749
+ offset: page.offset,
8750
+ });
8751
+ // Stop before a later page turns a bounded response into several retained
8752
+ // page bodies. The caller will split this read in offset order.
8753
+ assertRuntimeSheetDatasetReadBatchWithinBudget({ pages: results });
8754
+ }
8755
+ const result = { pages: results };
8756
+ assertRuntimeSheetDatasetReadBatchResponse(result, pages);
8757
+ assertRuntimeSheetDatasetReadBatchWithinBudget(result);
8758
+ return result;
8759
+ }
8760
+
8761
+ export async function readRuntimeSheetDatasetRows(
8762
+ context: RuntimeApiContext & { playName: string },
8763
+ input: RuntimeSheetDatasetReadInput,
8764
+ ): Promise<RuntimeSheetDatasetPage> {
8765
+ const result = await readRuntimeSheetDatasetRowsBatch(context, {
8766
+ tableNamespace: input.tableNamespace,
8767
+ runId: input.runId,
8250
8768
  rowMode: input.rowMode,
8769
+ pages: [{ limit: input.limit, offset: input.offset }],
8770
+ });
8771
+ return result.pages[0]!;
8772
+ }
8773
+
8774
+ async function readRuntimeSheetDatasetPageWithAdaptiveLimit(
8775
+ context: RuntimeApiContext & { playName: string },
8776
+ input: RuntimeSheetDatasetReadInput,
8777
+ knownTooLarge = false,
8778
+ ): Promise<RuntimeSheetDatasetPage> {
8779
+ if (!knownTooLarge) {
8780
+ try {
8781
+ return await readRuntimeSheetDatasetRows(context, input);
8782
+ } catch (error) {
8783
+ if (
8784
+ !(error instanceof RuntimeSheetDatasetReadBatchTooLargeError) ||
8785
+ input.limit <= 1
8786
+ ) {
8787
+ throw error;
8788
+ }
8789
+ }
8790
+ }
8791
+ if (input.limit <= 1) {
8792
+ throw new RuntimeSheetDatasetReadBatchTooLargeError(
8793
+ RUNTIME_SHEET_READ_BATCH_MAX_RESPONSE_BYTES + 1,
8794
+ );
8795
+ }
8796
+ const firstLimit = Math.ceil(input.limit / 2);
8797
+ const first = await readRuntimeSheetDatasetPageWithAdaptiveLimit(context, {
8798
+ ...input,
8799
+ limit: firstLimit,
8800
+ });
8801
+ if (first.rows.length < firstLimit) {
8802
+ return { ...first, limit: input.limit };
8803
+ }
8804
+ const second = await readRuntimeSheetDatasetPageWithAdaptiveLimit(context, {
8805
+ ...input,
8806
+ limit: input.limit - firstLimit,
8807
+ offset: input.offset + first.rows.length,
8251
8808
  });
8252
8809
  return {
8253
- rows: rows.map((row) => row.data),
8254
- limit,
8255
- offset,
8810
+ rows: [...first.rows, ...second.rows],
8811
+ limit: input.limit,
8812
+ offset: input.offset,
8813
+ };
8814
+ }
8815
+
8816
+ export type RuntimeSheetDatasetPageReader = {
8817
+ read(input: RuntimeSheetDatasetPageInput): Promise<RuntimeSheetDatasetPage>;
8818
+ };
8819
+
8820
+ /**
8821
+ * The common lazy-handle reader. It batches only pages that share every
8822
+ * authority fact and has bounded request retention, active requests, and
8823
+ * decoded response size. The rare oversized response is split in order rather
8824
+ * than silently truncating the dataset or increasing the memory ceiling.
8825
+ */
8826
+ export function createRuntimeSheetDatasetPageReader(input: {
8827
+ context: RuntimeApiContext & { playName: string };
8828
+ tableNamespace: string;
8829
+ runId?: string | null;
8830
+ rowMode?: 'output' | 'all';
8831
+ }): RuntimeSheetDatasetPageReader {
8832
+ const tokenDigest = createHash('sha256')
8833
+ .update(input.context.executorToken?.trim() ?? '')
8834
+ .digest('hex')
8835
+ .slice(0, 24);
8836
+ const scope = [
8837
+ input.context.baseUrl?.trim() ?? '',
8838
+ tokenDigest,
8839
+ input.context.orgId?.trim() ?? '',
8840
+ input.context.playName,
8841
+ input.runId?.trim() ?? input.context.runId?.trim() ?? '',
8842
+ input.tableNamespace,
8843
+ input.rowMode ?? 'output',
8844
+ ].join('::');
8845
+ const reads = new BoundedBatchFunction<
8846
+ RuntimeSheetDatasetPageInput,
8847
+ RuntimeSheetDatasetPage,
8848
+ string
8849
+ >({
8850
+ keyId: (key) => key,
8851
+ estimateBytes: (page) =>
8852
+ new TextEncoder().encode(JSON.stringify(page)).byteLength,
8853
+ execute: async ({ values }) => {
8854
+ const read = (pages: readonly RuntimeSheetDatasetPageInput[]) =>
8855
+ readRuntimeSheetDatasetRowsBatch(input.context, {
8856
+ tableNamespace: input.tableNamespace,
8857
+ runId: input.runId,
8858
+ rowMode: input.rowMode,
8859
+ pages,
8860
+ });
8861
+ try {
8862
+ return (await read(values)).pages;
8863
+ } catch (error) {
8864
+ if (!(error instanceof RuntimeSheetDatasetReadBatchTooLargeError)) {
8865
+ throw error;
8866
+ }
8867
+ const results: RuntimeSheetDatasetPage[] = [];
8868
+ for (const [index, page] of values.entries()) {
8869
+ results.push(
8870
+ await readRuntimeSheetDatasetPageWithAdaptiveLimit(
8871
+ input.context,
8872
+ {
8873
+ tableNamespace: input.tableNamespace,
8874
+ runId: input.runId,
8875
+ rowMode: input.rowMode,
8876
+ ...page,
8877
+ },
8878
+ values.length === 1 && index === 0,
8879
+ ),
8880
+ );
8881
+ }
8882
+ return results;
8883
+ }
8884
+ },
8885
+ maxBatchItems: RUNTIME_SHEET_READ_BATCH_MAX_PAGES,
8886
+ maxBatchBytes: 16 * 1024,
8887
+ maxRetainedItems: RUNTIME_SHEET_READ_BATCH_MAX_RETAINED_PAGES,
8888
+ maxRetainedBytes: RUNTIME_SHEET_READ_BATCH_MAX_RETAINED_REQUEST_BYTES,
8889
+ maxInFlightBatches: RUNTIME_SHEET_READ_BATCH_MAX_IN_FLIGHT,
8890
+ flushMs: RUNTIME_SHEET_READ_BATCH_FLUSH_MS,
8891
+ });
8892
+ return {
8893
+ read: (page) =>
8894
+ reads.submit({
8895
+ key: scope,
8896
+ value: normalizeRuntimeSheetDatasetPage(page),
8897
+ signal: input.context.abortSignal ?? undefined,
8898
+ maxWaitMs: RUNTIME_SHEET_READ_BATCH_MAX_QUEUE_WAIT_MS,
8899
+ }),
8256
8900
  };
8257
8901
  }
8258
8902
 
@@ -8622,6 +9266,11 @@ export function createRuntimeBackedPlayDataset(input: {
8622
9266
  ...input.context,
8623
9267
  runId: input.context.runId ?? input.runId,
8624
9268
  };
9269
+ const pageReader = createRuntimeSheetDatasetPageReader({
9270
+ context: runtimeContext,
9271
+ tableNamespace: input.tableNamespace,
9272
+ runId: input.runId ?? null,
9273
+ });
8625
9274
  return createDeferredPlayDataset({
8626
9275
  datasetKind: input.datasetKind,
8627
9276
  datasetId: createRuntimeDatasetId(
@@ -8656,62 +9305,30 @@ export function createRuntimeBackedPlayDataset(input: {
8656
9305
  return Number(summary.stats.total ?? 0);
8657
9306
  },
8658
9307
  peek: async (limit) => {
8659
- const session = await getRuntimeDbSession(runtimeContext, {
8660
- tableNamespace: input.tableNamespace,
8661
- logicalTable: 'sheet_rows',
8662
- operations: ['rows.read'],
8663
- });
8664
- const rows = await readRuntimeRows(
8665
- requireRuntimePostgresSession(session),
8666
- {
8667
- limit,
8668
- offset: 0,
8669
- runId: input.runId ?? null,
8670
- },
8671
- );
8672
- return rows.map((row) => row.data);
9308
+ if (limit <= 0) return [];
9309
+ return (await pageReader.read({ limit, offset: 0 })).rows;
8673
9310
  },
8674
9311
  at: async (index) => {
8675
- const session = await getRuntimeDbSession(runtimeContext, {
8676
- tableNamespace: input.tableNamespace,
8677
- logicalTable: 'sheet_rows',
8678
- operations: ['rows.read'],
8679
- });
8680
- const rows = await readRuntimeRows(
8681
- requireRuntimePostgresSession(session),
8682
- {
8683
- limit: 1,
8684
- offset: index,
8685
- runId: input.runId ?? null,
8686
- },
8687
- );
8688
- return rows[0]?.data;
9312
+ return (await pageReader.read({ limit: 1, offset: index })).rows[0];
8689
9313
  },
8690
9314
  materialize: async (limit) => {
8691
9315
  const pageSize = 1000;
8692
9316
  const materialized: Record<string, unknown>[] = [];
8693
9317
  let offset = 0;
8694
9318
  while (true) {
8695
- const session = await getRuntimeDbSession(runtimeContext, {
8696
- tableNamespace: input.tableNamespace,
8697
- logicalTable: 'sheet_rows',
8698
- operations: ['rows.read'],
8699
- });
8700
- const rows = await readRuntimeRows(
8701
- requireRuntimePostgresSession(session),
8702
- {
9319
+ const rows = (
9320
+ await pageReader.read({
8703
9321
  limit:
8704
9322
  limit !== undefined
8705
9323
  ? Math.min(pageSize, Math.max(0, limit - materialized.length))
8706
9324
  : pageSize,
8707
9325
  offset,
8708
- runId: input.runId ?? null,
8709
- },
8710
- );
9326
+ })
9327
+ ).rows;
8711
9328
  if (rows.length === 0) {
8712
9329
  break;
8713
9330
  }
8714
- materialized.push(...rows.map((row) => row.data));
9331
+ materialized.push(...rows);
8715
9332
  if (limit !== undefined && materialized.length >= limit) {
8716
9333
  return materialized.slice(0, limit);
8717
9334
  }
@@ -8725,26 +9342,17 @@ export function createRuntimeBackedPlayDataset(input: {
8725
9342
  const pageSize = 1000;
8726
9343
  let offset = 0;
8727
9344
  while (true) {
8728
- const session = await getRuntimeDbSession(runtimeContext, {
8729
- tableNamespace: input.tableNamespace,
8730
- logicalTable: 'sheet_rows',
8731
- operations: ['rows.read'],
9345
+ const page = await pageReader.read({
9346
+ limit: pageSize,
9347
+ offset,
8732
9348
  });
8733
- const page = await readRuntimeRows(
8734
- requireRuntimePostgresSession(session),
8735
- {
8736
- limit: pageSize,
8737
- offset,
8738
- runId: input.runId ?? null,
8739
- },
8740
- );
8741
- if (page.length === 0) {
9349
+ if (page.rows.length === 0) {
8742
9350
  return;
8743
9351
  }
8744
- for (const row of page) {
8745
- yield row.data;
9352
+ for (const row of page.rows) {
9353
+ yield row;
8746
9354
  }
8747
- offset += page.length;
9355
+ offset += page.rows.length;
8748
9356
  }
8749
9357
  },
8750
9358
  }) as AsyncIterable<Record<string, unknown>>,