@elaraai/e3-types 1.0.84 → 1.0.86

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 (67) hide show
  1. package/dist/src/api.d.ts +523 -17
  2. package/dist/src/api.d.ts.map +1 -1
  3. package/dist/src/api.js +208 -22
  4. package/dist/src/api.js.map +1 -1
  5. package/dist/src/dataflow.d.ts +115 -12
  6. package/dist/src/dataflow.d.ts.map +1 -1
  7. package/dist/src/dataflow.js +151 -15
  8. package/dist/src/dataflow.js.map +1 -1
  9. package/dist/src/dataflow.spec.js +41 -7
  10. package/dist/src/dataflow.spec.js.map +1 -1
  11. package/dist/src/dataset-blob.d.ts +19 -2
  12. package/dist/src/dataset-blob.d.ts.map +1 -1
  13. package/dist/src/dataset-blob.js +100 -21
  14. package/dist/src/dataset-blob.js.map +1 -1
  15. package/dist/src/dataset-blob.spec.js +132 -0
  16. package/dist/src/dataset-blob.spec.js.map +1 -1
  17. package/dist/src/execution.d.ts +68 -8
  18. package/dist/src/execution.d.ts.map +1 -1
  19. package/dist/src/execution.js +49 -8
  20. package/dist/src/execution.js.map +1 -1
  21. package/dist/src/execution.spec.js +31 -9
  22. package/dist/src/execution.spec.js.map +1 -1
  23. package/dist/src/index.d.ts +8 -7
  24. package/dist/src/index.d.ts.map +1 -1
  25. package/dist/src/index.js +14 -11
  26. package/dist/src/index.js.map +1 -1
  27. package/dist/src/names.d.ts +9 -0
  28. package/dist/src/names.d.ts.map +1 -1
  29. package/dist/src/names.js.map +1 -1
  30. package/dist/src/pieces.d.ts +47 -0
  31. package/dist/src/pieces.d.ts.map +1 -0
  32. package/dist/src/pieces.js +17 -0
  33. package/dist/src/pieces.js.map +1 -0
  34. package/dist/src/pieces.spec.d.ts +6 -0
  35. package/dist/src/pieces.spec.d.ts.map +1 -0
  36. package/dist/src/pieces.spec.js +20 -0
  37. package/dist/src/pieces.spec.js.map +1 -0
  38. package/dist/src/release.d.ts +1 -1
  39. package/dist/src/release.js +1 -1
  40. package/dist/src/repository.d.ts +16 -0
  41. package/dist/src/repository.d.ts.map +1 -1
  42. package/dist/src/repository.js +15 -0
  43. package/dist/src/repository.js.map +1 -1
  44. package/dist/src/structure.d.ts +27 -0
  45. package/dist/src/structure.d.ts.map +1 -1
  46. package/dist/src/structure.js +59 -18
  47. package/dist/src/structure.js.map +1 -1
  48. package/dist/src/structure.spec.d.ts +6 -0
  49. package/dist/src/structure.spec.d.ts.map +1 -0
  50. package/dist/src/structure.spec.js +57 -0
  51. package/dist/src/structure.spec.js.map +1 -0
  52. package/package.json +3 -2
  53. package/src/api.ts +225 -21
  54. package/src/dataflow.spec.ts +49 -7
  55. package/src/dataflow.ts +160 -14
  56. package/src/dataset-blob.spec.ts +147 -0
  57. package/src/dataset-blob.ts +97 -20
  58. package/src/execution.spec.ts +31 -9
  59. package/src/execution.ts +52 -7
  60. package/src/index.ts +38 -2
  61. package/src/names.ts +12 -0
  62. package/src/pieces.spec.ts +23 -0
  63. package/src/pieces.ts +49 -0
  64. package/src/release.ts +1 -1
  65. package/src/repository.ts +18 -0
  66. package/src/structure.spec.ts +65 -0
  67. package/src/structure.ts +64 -21
@@ -12,8 +12,8 @@
12
12
 
13
13
  import { describe, it } from 'node:test';
14
14
  import assert from 'node:assert/strict';
15
- import { ArrayType, DictType, StringType, StructType, encodeBeast2For, none, some, variant } from '@elaraai/east';
16
- import { decodeDataflowExecutionState } from './dataflow.js';
15
+ import { ArrayType, DictType, StringType, StructType, encodeBeast2For, equalFor, none, printFor, some, variant } from '@elaraai/east';
16
+ import { DataflowForceType, decodeDataflowExecutionState } from './dataflow.js';
17
17
  import { E3_RELEASE } from './release.js';
18
18
 
19
19
  /** Version 1, written by the released e3: task `double` completed, `sum`
@@ -150,8 +150,9 @@ const STATE_V5 = Buffer.from(
150
150
  );
151
151
 
152
152
  /** The version 5 state, as release 1.0.79 writes it: the release in place of
153
- * the version. */
154
- const STATE = Buffer.from(
153
+ * the version. Its `force` is a Boolean, which a repository upgrade carries
154
+ * forward (`dataflow-force-tasks`). */
155
+ const STATE_1_0_79 = Buffer.from(
155
156
  'iUVhc3QNCgUAngsoKQEFBAAIAgRub25lAwRzb21lAAoACQUEbmFtZQAEaGFzaAAGaW5wdXRzBQZvdXRwdXQACWRlcGVuZHNP' +
156
157
  'bgUKBgkBBXRhc2tzBwgCBG5vbmUDBHNvbWUICAIEbm9uZQMEc29tZQICCAIEbm9uZQMEc29tZQsIAgRub25lAwRzb21lAQkC' +
157
158
  'CmlucHV0c0hhc2gAC2V4ZWN1dGlvbklkAAgCBG5vbmUDBHNvbWUOCQsEbmFtZQAGc3RhdHVzAAZjYWNoZWQKCm91dHB1dEhh' +
@@ -180,6 +181,37 @@ const STATE = Buffer.from(
180
181
  'base64',
181
182
  );
182
183
 
184
+ /** The release 1.0.79 state as release 1.0.85 writes it: `force` names the
185
+ * tasks the run forces, `double`. */
186
+ const STATE = Buffer.from(
187
+ 'iUVhc3QNCgUAsgspKgEFAAoACAMDYWxsAgRub25lAgV0YXNrcwMIAgRub25lAgRzb21lAAkFBG5hbWUABGhhc2gABmlucHV0' +
188
+ 'cwMGb3V0cHV0AAlkZXBlbmRzT24DCgYJAQV0YXNrcwcIAgRub25lAgRzb21lCAQIAgRub25lAgRzb21lCgIIAgRub25lAgRz' +
189
+ 'b21lDAgCBG5vbmUCBHNvbWUBCQIKaW5wdXRzSGFzaAALZXhlY3V0aW9uSWQACAIEbm9uZQIEc29tZQ8JCwRuYW1lAAZzdGF0' +
190
+ 'dXMABmNhY2hlZAsKb3V0cHV0SGFzaAUFZXJyb3IFCGV4aXRDb2RlDQlzdGFydGVkQXQOC2NvbXBsZXRlZEF0DghkdXJhdGlv' +
191
+ 'bg0EcGxhbgUJZXhlY3V0aW9uEAsAEQsAAAsAEwkDA3NlcQwJdGltZXN0YW1wAQZyZWFzb24FCQgDc2VxDAl0aW1lc3RhbXAB' +
192
+ 'B3N1Y2Nlc3MKCGV4ZWN1dGVkDAZjYWNoZWQMBmZhaWxlZAwHc2tpcHBlZAwIZHVyYXRpb24MCQQDc2VxDAl0aW1lc3RhbXAB' +
193
+ 'C2V4ZWN1dGlvbklkAAp0b3RhbFRhc2tzDAkFA3NlcQwJdGltZXN0YW1wAQRwYXRoAAxwcmV2aW91c0hhc2gAB25ld0hhc2gA' +
194
+ 'CQcDc2VxDAl0aW1lc3RhbXABBHRhc2sABmNhY2hlZAoKb3V0cHV0SGFzaAAIZHVyYXRpb24MCXBlYWtCeXRlcw0JBANzZXEM' +
195
+ 'CXRpbWVzdGFtcAEEdGFzawAMY29uZmxpY3RQYXRoAAkGA3NlcQwJdGltZXN0YW1wAQR0YXNrAAVlcnJvcgUIZXhpdENvZGUN' +
196
+ 'CGR1cmF0aW9uDAkEA3NlcQwJdGltZXN0YW1wAQR0YXNrAAZyZWFzb24ACQUDc2VxDAl0aW1lc3RhbXABBHRhc2sABWxldmVs' +
197
+ 'DAZsZXZlbHMMCQYDc2VxDAl0aW1lc3RhbXABBHRhc2sABWxldmVsDAZsZXZlbHMMBXVuaXRzDAkDA3NlcQwJdGltZXN0YW1w' +
198
+ 'AQR0YXNrAAkEA3NlcQwJdGltZXN0YW1wAQR0YXNrAAVjYXVzZQAJBANzZXEMCXRpbWVzdGFtcAEEdGFzawAGcGllY2VzDAkC' +
199
+ 'BWxldmVsDAZsZXZlbHMMCAIEbm9uZQIEc29tZSIJAwVtZXJnZSMFaW5kZXgMBXVuaXRzDAgDBmJ1ZGdldAIDY2FwAgdtYWNo' +
200
+ 'aW5lAgkHA3NlcQwJdGltZXN0YW1wAQR0YXNrAAR1bml0JAZyZWFzb24lBHBlYWsMCHJlc2VydmVzDAgPE2V4ZWN1dGlvbl9j' +
201
+ 'YW5jZWxsZWQVE2V4ZWN1dGlvbl9jb21wbGV0ZWQWEWV4ZWN1dGlvbl9zdGFydGVkFw1pbnB1dF9jaGFuZ2VkGA50YXNrX2Nv' +
202
+ 'bXBsZXRlZBkNdGFza19kZWZlcnJlZBoLdGFza19mYWlsZWQbEHRhc2tfaW52YWxpZGF0ZWQcFHRhc2tfbWVyZ2VfY29tcGxl' +
203
+ 'dGVkHRJ0YXNrX21lcmdlX3N0YXJ0ZWQeCnRhc2tfcmVhZHkfDHRhc2tfc2tpcHBlZCAKdGFza19zcGxpdCEMdGFza19zdGFy' +
204
+ 'dGVkHw11bml0X3JlcXVldWVkJgonCRcHcmVsZWFzZQACaWQABHJlcG8ACXdvcmtzcGFjZQAJc3RhcnRlZEF0AQVmb3JjZQQG' +
205
+ 'ZmlsdGVyBQVncmFwaAkJZ3JhcGhIYXNoBQV0YXNrcxIIZXhlY3V0ZWQMBmNhY2hlZAwGZmFpbGVkDAdza2lwcGVkDAZzdGF0' +
206
+ 'dXMAC2NvbXBsZXRlZEF0DgVlcnJvcgUOdmVyc2lvblZlY3RvcnMUDWlucHV0U25hcHNob3QTD3Rhc2tPdXRwdXRQYXRocwMK' +
207
+ 'cmVleGVjdXRlZAwGZXZlbnRzKAhldmVudFNlcQwBAAHvBpkCYzPUM9CzMFUxMLQ0SDRIMtC1AAJdcwMDIAtEADEcsBSlFuQz' +
208
+ 'lRc3PDl96n0aEwMjW0p+aVJOKgMDIwMTlO2QSCFgYOTUy8wrKC0p1qtg4NUrSSzOLtaD2cNcXJrrkEIhYGBEM5YLygUajuQn' +
209
+ 'BrifoBRncn5uQU5qSWoKIwOjQzKFABhoFz6DwpFxQfMZECUBNDSJQkBMNBqCAhGEuTPz4guK8tOLUouLQd5l/DAJ5BAggynV' +
210
+ 'FOh7oBB7UWleXmZeOkgaOWIc0igEQONRIwE5DoD2MjFBEhlx6ZKXBRKU0IhiYYMEKSwqKY4rCcaGhoYEXg5IAIHCjocLwWbh' +
211
+ '4zmwCMZhYGJhACqeBtIxjZGTb8ImmAwTExMDHwA=',
212
+ 'base64',
213
+ );
214
+
183
215
  const NEW_FORM = 'a change to the execution state\'s type is a new form: ship a repository upgrade step that carries a repository\'s states into it, move this state to the refusals, and add one the new form writes';
184
216
 
185
217
  describe('decodeDataflowExecutionState', () => {
@@ -195,7 +227,9 @@ describe('decodeDataflowExecutionState', () => {
195
227
  it('reads a state in this release\'s form', () => {
196
228
  assert.doesNotThrow(() => decodeDataflowExecutionState(STATE), NEW_FORM);
197
229
  const state = decodeDataflowExecutionState(STATE);
198
- assert.equal(state.release, '1.0.79');
230
+ assert.equal(state.release, '1.0.85');
231
+ const forced = variant('tasks', ['double']);
232
+ assert.ok(equalFor(DataflowForceType)(state.force, forced), `it forces double, not ${printFor(DataflowForceType)(state.force)}`);
199
233
  assert.deepEqual(state.tasks.get('sum')!.plan, some('e5'));
200
234
  assert.deepEqual(state.tasks.get('sum')!.execution, none);
201
235
  assert.deepEqual(state.tasks.get('double')!.execution, some({ inputsHash: 'b'.repeat(64), executionId: '0190a0b0-8888-7000-8000-000000000001' }));
@@ -211,6 +245,13 @@ describe('decodeDataflowExecutionState', () => {
211
245
  });
212
246
  });
213
247
 
248
+ it('refuses a state an earlier release wrote, which a repository upgrade carries forward: release 1.0.79\'s Boolean force', () => {
249
+ assert.throws(
250
+ () => decodeDataflowExecutionState(STATE_1_0_79),
251
+ { message: `the execution state was written by e3 1.0.79, in a form this e3, ${E3_RELEASE}, does not read — re-create the repository: deploy again and import its data again` },
252
+ );
253
+ });
254
+
214
255
  it('refuses a state a newer release wrote, naming it', () => {
215
256
  const newer = encodeBeast2For(StructType({ release: StringType, id: StringType }))({ release: '999.0.0', id: '7' });
216
257
  assert.throws(
@@ -229,8 +270,9 @@ describe('decodeDataflowExecutionState', () => {
229
270
  );
230
271
  });
231
272
 
232
- it('refuses data that is not an execution state', () => {
273
+ it('refuses data that is not an execution state: a value of another type, or bytes that are not beast2', () => {
233
274
  const other = encodeBeast2For(StructType({ id: StringType }))({ id: '7' });
234
- assert.throws(() => decodeDataflowExecutionState(other), /not an execution state/);
275
+ assert.throws(() => decodeDataflowExecutionState(other), { message: 'the data is not an execution state: its type is not the execution state\'s' });
276
+ assert.throws(() => decodeDataflowExecutionState(new TextEncoder().encode('not a run')), /^Error: the data is not an execution state: it is not beast2 \(/);
235
277
  });
236
278
  });
package/src/dataflow.ts CHANGED
@@ -11,7 +11,8 @@
11
11
  *
12
12
  * Key design decisions:
13
13
  * - Dates are Date objects (via DateTimeType), not strings
14
- * - Events stored inline as array (single file, not separate JSONL)
14
+ * - A run's events are kept apart from its state, so a write of the state
15
+ * costs what the run holds, not what it has done
15
16
  * - Tasks stored as Dict (not Map) for beast2 compatibility
16
17
  */
17
18
 
@@ -30,8 +31,11 @@ import {
30
31
  decodeBeast2,
31
32
  decodeBeast2For,
32
33
  isTypeValueEqual,
34
+ lessFor,
33
35
  readBeast2Type,
34
36
  toEastTypeValue,
37
+ variant,
38
+ type EastTypeValue,
35
39
  } from '@elaraai/east';
36
40
  import { E3_RELEASE, compareReleases } from './release.js';
37
41
 
@@ -157,12 +161,13 @@ export type RequeueReason = ValueTypeOf<typeof RequeueReasonType>;
157
161
  /**
158
162
  * Execution events (VariantType for discriminated union).
159
163
  *
160
- * Events track the progress of a dataflow execution and are stored
161
- * inline in the execution state (not as a separate JSONL file).
164
+ * Events track the progress of a dataflow execution. A store keeps a run's
165
+ * events apart from its state, each numbered by its `seq`, and a poll reads
166
+ * those past its cursor.
162
167
  *
163
168
  * @remarks
164
- * Part of the execution state's wire: a reader decodes a state against the
165
- * whole type it was written with, so a new event is a new form of the state,
169
+ * A stored form, as the state is: a reader decodes events against the whole
170
+ * type they were written with, so a new event is a new form of a run's events,
166
171
  * which the release that makes it carries repositories into with an upgrade
167
172
  * step. Each unit's progress is a callback, {@link PartitionProgress}, and is
168
173
  * not persisted.
@@ -393,6 +398,59 @@ export interface PartitionProgress {
393
398
  duration?: number;
394
399
  }
395
400
 
401
+ // =============================================================================
402
+ // Forced Tasks
403
+ // =============================================================================
404
+
405
+ /**
406
+ * The tasks a dataflow run forces: re-executes even where the execution cache
407
+ * holds their results.
408
+ *
409
+ * - `none`: no task; each is served from the cache when it can be
410
+ * - `all`: every task the run launches — under a filter, the filter's task
411
+ * - `tasks`: the tasks named, and no other
412
+ *
413
+ * @remarks
414
+ * A forced task is forced each time the run launches it, so one invalidated
415
+ * mid-run and launched again is forced again. Its dependents are not forced:
416
+ * they re-run when its output changes. A run that names a task the graph does
417
+ * not have, or one its filter's run set leaves out, is refused before anything
418
+ * runs.
419
+ */
420
+ export const DataflowForceType = VariantType({
421
+ none: NullType,
422
+ all: NullType,
423
+ tasks: ArrayType(StringType),
424
+ });
425
+ export type DataflowForce = ValueTypeOf<typeof DataflowForceType>;
426
+
427
+ /**
428
+ * The tasks a run forces, from the option a caller passes it.
429
+ *
430
+ * @param force - `true` for every task, the names of the tasks, or `false` or
431
+ * nothing for none
432
+ * @returns What the run forces
433
+ */
434
+ export function dataflowForce(force: boolean | readonly string[] | undefined): DataflowForce {
435
+ if (force === undefined || force === false) return variant('none', null);
436
+ if (force === true) return variant('all', null);
437
+ return variant('tasks', [...force]);
438
+ }
439
+
440
+ /**
441
+ * The option a run's forced tasks are passed to an orchestrator as.
442
+ *
443
+ * @param force - What the run forces
444
+ * @returns `true` for every task, the names of the tasks, or `false` for none
445
+ */
446
+ export function dataflowForceOption(force: DataflowForce): boolean | string[] {
447
+ switch (force.type) {
448
+ case 'none': return false;
449
+ case 'all': return true;
450
+ case 'tasks': return [...force.value];
451
+ }
452
+ }
453
+
396
454
  // =============================================================================
397
455
  // Main Execution State
398
456
  // =============================================================================
@@ -404,7 +462,9 @@ export interface PartitionProgress {
404
462
  *
405
463
  * @remarks
406
464
  * - Tasks are stored as a Dict (serializes as object, not array of tuples)
407
- * - Events are stored inline (not as separate JSONL file)
465
+ * - A run's events are kept apart from it: `events` holds those the run has
466
+ * added since its state was last written, which the write hands its store,
467
+ * so a state a store holds, or reads back, holds none
408
468
  * - Dates are Date objects (via DateTimeType)
409
469
  * - Read it back with {@link decodeDataflowExecutionState}, which reads this
410
470
  * form alone. A release that changes it ships a repository upgrade step,
@@ -426,9 +486,10 @@ export const DataflowExecutionStateType = StructType({
426
486
  startedAt: DateTimeType,
427
487
 
428
488
  // Config (immutable after initialization)
429
- /** Force re-execution even if cached */
430
- force: BooleanType,
431
- /** Filter to run only specific task(s) by exact name */
489
+ /** The tasks the run re-executes even where the cache holds their results
490
+ * ({@link DataflowForceType}) */
491
+ force: DataflowForceType,
492
+ /** One task's exact name: the run runs it and its dependency closure */
432
493
  filter: OptionType(StringType),
433
494
 
434
495
  // Graph (inline or by reference)
@@ -469,10 +530,13 @@ export const DataflowExecutionStateType = StructType({
469
530
  /** Number of tasks re-executed due to input changes */
470
531
  reexecuted: IntegerType,
471
532
 
472
- // Events (inline array)
473
- /** All events for this execution */
533
+ // Events, kept apart
534
+ /** The events the run has added since its state was last written, in
535
+ * order: a write of the state hands them to its store, which keeps a run's
536
+ * events apart from its state, so a stored state holds none */
474
537
  events: ArrayType(ExecutionEventType),
475
- /** Sequence number for next event (auto-increment) */
538
+ /** The sequence number of the run's last event, which the next event's
539
+ * follows: 0 while it has none */
476
540
  eventSeq: IntegerType,
477
541
  });
478
542
  export type DataflowExecutionState = ValueTypeOf<typeof DataflowExecutionStateType>;
@@ -493,10 +557,16 @@ const decodeState = decodeBeast2For(DataflowExecutionStateType);
493
557
  * @param data - the stored state
494
558
  * @returns the state
495
559
  * @throws {Error} When the state is of another form, naming the release that
496
- * wrote it, or the data is not an execution state.
560
+ * wrote it, or the data is not an execution state — of another type, or not
561
+ * beast2 at all.
497
562
  */
498
563
  export function decodeDataflowExecutionState(data: Uint8Array): DataflowExecutionState {
499
- const type = readBeast2Type(data);
564
+ let type: EastTypeValue;
565
+ try {
566
+ type = readBeast2Type(data);
567
+ } catch (err) {
568
+ throw new Error(`the data is not an execution state: it is not beast2 (${err instanceof Error ? err.message : `${err as string}`})`, { cause: err });
569
+ }
500
570
  if (isTypeValueEqual(type, STATE_TYPE)) return decodeState(data);
501
571
  const { value } = decodeBeast2(data);
502
572
  const state = typeof value === 'object' && value !== null ? value as Record<string, unknown> : null;
@@ -515,6 +585,82 @@ export function decodeDataflowExecutionState(data: Uint8Array): DataflowExecutio
515
585
  throw new Error('the data is not an execution state: its type is not the execution state\'s');
516
586
  }
517
587
 
588
+ /**
589
+ * A dataflow run's state but its graph, its tasks, its reactive tracking and
590
+ * its events: what a poll of the run serves but the events, and the sequence
591
+ * number of its last event, past which a poll reads the events it has not
592
+ * served.
593
+ *
594
+ * @remarks
595
+ * An execution state store answers it for a workspace's latest run
596
+ * (`ExecutionStateStore.readLatestSummary`), so a poll reads it rather than
597
+ * the whole state, which grows with the dataflow and with every event of the
598
+ * run. A store may keep it beside the state, written with each change of the
599
+ * state; one that reads the whole state anyway derives it
600
+ * ({@link executionStateSummary}).
601
+ */
602
+ export const ExecutionStateSummaryType = StructType({
603
+ /** The run's id, a UUIDv7 */
604
+ id: StringType,
605
+ /** When the run started */
606
+ startedAt: DateTimeType,
607
+ /** Number of tasks executed (not from cache) */
608
+ executed: IntegerType,
609
+ /** Number of tasks served from cache */
610
+ cached: IntegerType,
611
+ /** Number of tasks that failed */
612
+ failed: IntegerType,
613
+ /** Number of tasks skipped due to upstream failure */
614
+ skipped: IntegerType,
615
+ /** The run's status ({@link DataflowExecutionStatus}) */
616
+ status: StringType,
617
+ /** When the run ended */
618
+ completedAt: OptionType(DateTimeType),
619
+ /** Why the run failed, when it did */
620
+ error: OptionType(StringType),
621
+ /** The sequence number of the run's last event: 0 while it has none */
622
+ lastSeq: IntegerType,
623
+ });
624
+ export type ExecutionStateSummary = ValueTypeOf<typeof ExecutionStateSummaryType>;
625
+
626
+ /** Whether one event sequence number comes before another. */
627
+ const seqBefore = lessFor(IntegerType);
628
+
629
+ /**
630
+ * The sequence number of a run's last event, as its state counts it: the
631
+ * state's own count, or the last event it holds, which a write has not yet
632
+ * handed its store, when that is later.
633
+ *
634
+ * @param state - The run's state
635
+ * @returns The sequence number: 0 while the run has no event
636
+ */
637
+ export function lastEventSeq(state: Pick<DataflowExecutionState, 'events' | 'eventSeq'>): bigint {
638
+ const held = state.events.at(-1)?.value.seq;
639
+ return held !== undefined && seqBefore(state.eventSeq, held) ? held : state.eventSeq;
640
+ }
641
+
642
+ /**
643
+ * The summary of a run's state ({@link ExecutionStateSummaryType}).
644
+ *
645
+ * @param state - The run's state
646
+ * @returns Its summary: its last event's sequence number is the last event's
647
+ * own ({@link lastEventSeq}), however the event was recorded
648
+ */
649
+ export function executionStateSummary(state: DataflowExecutionState): ExecutionStateSummary {
650
+ return {
651
+ id: state.id,
652
+ startedAt: state.startedAt,
653
+ executed: state.executed,
654
+ cached: state.cached,
655
+ failed: state.failed,
656
+ skipped: state.skipped,
657
+ status: state.status,
658
+ completedAt: state.completedAt,
659
+ error: state.error,
660
+ lastSeq: lastEventSeq(state),
661
+ };
662
+ }
663
+
518
664
  // =============================================================================
519
665
  // Dataflow Run History
520
666
  // =============================================================================
@@ -29,6 +29,54 @@ import { encodeDatasetBlob, writeCollectionManifest, type CollectionSegmentRef }
29
29
 
30
30
  const TableType = DictType(StringType, IntegerType);
31
31
 
32
+ /** `n` rows, in canonical order: 20,000 span sixteen segments. */
33
+ function rowsOf(n: number): SortedMap<string, bigint> {
34
+ return new SortedMap(
35
+ Array.from({ length: n }, (_, i): [string, bigint] => [`k${String(i).padStart(6, '0')}`, BigInt(i)]),
36
+ compareFor(StringType));
37
+ }
38
+
39
+ /** A write the test ends, as the store would: stored, or failed. */
40
+ interface HeldWrite {
41
+ hash: string;
42
+ end: (failure?: Error) => void;
43
+ }
44
+
45
+ /** A sink whose writes are held until the test ends each one: the writes in
46
+ * flight, and the hash of every object it was handed, in order. */
47
+ function heldSink(): { sink: (bytes: Uint8Array) => Promise<string>; open: HeldWrite[]; calls: string[] } {
48
+ const open: HeldWrite[] = [];
49
+ const calls: string[] = [];
50
+ const sink = (bytes: Uint8Array): Promise<string> => new Promise((resolve, reject) => {
51
+ const hash = sha256Hex(bytes);
52
+ calls.push(hash);
53
+ const write: HeldWrite = {
54
+ hash,
55
+ end: (failure) => {
56
+ open.splice(open.indexOf(write), 1);
57
+ if (failure === undefined) resolve(hash);
58
+ else reject(failure);
59
+ },
60
+ };
61
+ open.push(write);
62
+ });
63
+ return { sink, open, calls };
64
+ }
65
+
66
+ /** Lets `turns` rounds of the event loop pass. */
67
+ async function turns(turns: number): Promise<void> {
68
+ for (let i = 0; i < turns; i++) await new Promise((resolve) => setImmediate(resolve));
69
+ }
70
+
71
+ /** Lets the event loop run until `done` holds. */
72
+ async function until(done: () => boolean, what: string): Promise<void> {
73
+ for (let i = 0; i < 10_000; i++) {
74
+ if (done()) return;
75
+ await turns(1);
76
+ }
77
+ assert.fail(`never: ${what}`);
78
+ }
79
+
32
80
  describe('writeCollectionManifest', () => {
33
81
  it("writes the manifest writer's manifest for the value, byte for byte", async () => {
34
82
  const value = new SortedMap(
@@ -96,6 +144,105 @@ describe('writeCollectionManifest', () => {
96
144
  assert.deepEqual(reads, [], 'the seam is decided by the fence that followed');
97
145
  for (const entry of entries.slice(0, half)) assert.ok(!written.has(entry.hash), `segment ${entry.hash.slice(0, 8)} is carried`);
98
146
  });
147
+
148
+ it('has the sink write the objects at once, as many as it is given, in order, and returns once every one is stored', async () => {
149
+ const value = rowsOf(20_000);
150
+ const expected = await writeCollectionManifest(TableType, [{ elements: value.entries() }], async (bytes) => sha256Hex(bytes));
151
+ const entries = decodeCollectionManifest(expected).entries;
152
+ const objects = entries.length + 1;
153
+ const { sink, open, calls } = heldSink();
154
+ let manifest: Uint8Array | undefined;
155
+ const writing = writeCollectionManifest(TableType, [{ elements: value.entries() }], sink, { inFlight: 3 })
156
+ .then((bytes) => {
157
+ manifest = bytes;
158
+ });
159
+
160
+ // Each write ends only when the test ends it, the last begun first, so the
161
+ // writes end in another order than they began. Between ends, the writer
162
+ // has as many in flight as it may: three, or the objects left.
163
+ while (manifest === undefined) {
164
+ const inFlight = (): number => Math.min(3, objects - (calls.length - open.length));
165
+ await until(() => manifest !== undefined || open.length === inFlight(), 'as many writes in flight as may be');
166
+ await turns(20);
167
+ if (manifest !== undefined) break;
168
+ assert.equal(open.length, inFlight(), `${open.length} writes in flight, of ${calls.length} begun`);
169
+ open.at(-1)!.end();
170
+ }
171
+ await writing;
172
+
173
+ assert.deepEqual(manifest, expected, 'the manifest a write at a time writes, each entry in its place');
174
+ assert.deepEqual(calls, [...entries.map((entry) => entry.hash), decodeCollectionManifest(expected).header],
175
+ 'the segments, in order, and then the header');
176
+ });
177
+
178
+ /** A writer's failure, once it has failed: read through a function, which
179
+ * no assertion narrows. */
180
+ function failureOf(writing: Promise<unknown>): () => { failure: unknown } | undefined {
181
+ let settled: { failure: unknown } | undefined;
182
+ writing.then(() => {
183
+ settled = { failure: new Error('the writer did not fail') };
184
+ }, (failure: unknown) => {
185
+ settled = { failure };
186
+ });
187
+ return () => settled;
188
+ }
189
+
190
+ it('starts no write after one fails, and throws its failure once the writes in flight have ended', async () => {
191
+ const { sink, open, calls } = heldSink();
192
+ const writing = writeCollectionManifest(TableType, [{ elements: rowsOf(20_000).entries() }], sink, { inFlight: 3 });
193
+ const settled = failureOf(writing);
194
+ await until(() => open.length === 3, 'three writes in flight');
195
+ const failure = new Error('the store is gone');
196
+ open[1]!.end(failure);
197
+ await turns(20);
198
+ assert.equal(calls.length, 3, 'nothing is begun after a write fails');
199
+ assert.equal(settled(), undefined, 'the writer waits for the writes still in flight');
200
+
201
+ for (const write of [...open]) write.end();
202
+ await writing.catch(() => {});
203
+ assert.equal(settled()?.failure, failure);
204
+ });
205
+
206
+ it('throws a failure among the last writes, which no write after it meets', async () => {
207
+ const value = rowsOf(20_000);
208
+ const { header } = decodeCollectionManifest(
209
+ await writeCollectionManifest(TableType, [{ elements: value.entries() }], async (bytes) => sha256Hex(bytes)));
210
+ const { sink, open } = heldSink();
211
+ const writing = writeCollectionManifest(TableType, [{ elements: value.entries() }], sink, { inFlight: 3 });
212
+ const settled = failureOf(writing);
213
+ // Every object is stored but the header, the last begun.
214
+ const failure = new Error('the store is gone');
215
+ for (;;) {
216
+ await until(() => settled() !== undefined || open.length > 0, 'a write in flight, or the writer\'s end');
217
+ if (settled() !== undefined) break;
218
+ const write = open[0]!;
219
+ write.end(write.hash === header ? failure : undefined);
220
+ }
221
+ assert.equal(settled()?.failure, failure);
222
+ });
223
+
224
+ it('throws a piece\'s failure once the writes in flight have ended', async () => {
225
+ const { sink, open } = heldSink();
226
+ // Rows in order, then one out of it.
227
+ let disordered = false;
228
+ function* rows(): Generator<[string, bigint]> {
229
+ yield* rowsOf(20_000).entries();
230
+ disordered = true;
231
+ yield ['a', 0n];
232
+ }
233
+ const writing = writeCollectionManifest(TableType, [{ elements: rows() }], sink, { inFlight: 64 });
234
+ const settled = failureOf(writing);
235
+ await until(() => disordered, 'the row out of order');
236
+ await turns(20);
237
+ assert.ok(open.length > 0, 'segments are in flight');
238
+ assert.equal(settled(), undefined, 'the writer waits for the writes still in flight');
239
+
240
+ for (const write of [...open]) write.end();
241
+ await writing.catch(() => {});
242
+ const failure = settled()?.failure;
243
+ assert.ok(failure instanceof Error, 'the piece\'s failure');
244
+ assert.match(failure.message, /ascend|order/);
245
+ });
99
246
  });
100
247
 
101
248
  describe('encodeDatasetBlob', () => {
@@ -59,6 +59,63 @@ import {
59
59
  */
60
60
  export type SegmentSink = (bytes: Uint8Array) => Promise<string>;
61
61
 
62
+ /**
63
+ * How many objects {@link writeCollectionManifest} has a sink write at once,
64
+ * unless its caller says otherwise: a store whose writes are round trips is
65
+ * waited on once for this many segments, rather than once for each.
66
+ */
67
+ export const SEGMENT_WRITES_IN_FLIGHT = 16;
68
+
69
+ /**
70
+ * The writes of one manifest's objects: each started in the order it is
71
+ * handed over, at most `width` in flight, and none left running once the
72
+ * manifest's writer returns.
73
+ *
74
+ * @internal
75
+ */
76
+ class SinkWrites {
77
+ private readonly inFlight = new Set<Promise<void>>();
78
+ private failure: { error: unknown } | undefined;
79
+
80
+ constructor(private readonly sink: SegmentSink, private readonly width: number) {}
81
+
82
+ /**
83
+ * Starts writing `bytes` once fewer than `width` writes are in flight.
84
+ *
85
+ * @param bytes - The object
86
+ * @param named - Told the object's hash once it is stored
87
+ * @throws The first failed write's error, once one has failed: nothing more
88
+ * is started after it.
89
+ */
90
+ async start(bytes: Uint8Array, named: (hash: string) => void): Promise<void> {
91
+ while (this.failure === undefined && this.inFlight.size >= this.width) await Promise.race(this.inFlight);
92
+ if (this.failure !== undefined) throw this.failure.error;
93
+ const write: Promise<void> = this.sink(bytes)
94
+ .then(named, (error: unknown) => {
95
+ this.failure ??= { error };
96
+ })
97
+ .finally(() => {
98
+ this.inFlight.delete(write);
99
+ });
100
+ this.inFlight.add(write);
101
+ }
102
+
103
+ /** Waits for every write started, whatever its outcome. */
104
+ async settled(): Promise<void> {
105
+ await Promise.all(this.inFlight);
106
+ }
107
+
108
+ /**
109
+ * Waits for every write started.
110
+ *
111
+ * @throws The first failed write's error.
112
+ */
113
+ async stored(): Promise<void> {
114
+ await this.settled();
115
+ if (this.failure !== undefined) throw this.failure.error;
116
+ }
117
+ }
118
+
62
119
  /**
63
120
  * Whether a dataset root type is a collection — the kinds stored segmented +
64
121
  * indexed so the paged read API can seek.
@@ -106,49 +163,69 @@ export type CollectionPiece = Beast2RecutPiece<EastType, CollectionSegmentRef>;
106
163
  * Writer's own: a piece's segments are carried over only when that is the
107
164
  * header they are under, which is the caller's to establish.
108
165
  *
166
+ * The objects are written at once, `inFlight` at a time — the segments in
167
+ * order, then the header — and the manifest is returned once every one is
168
+ * stored, so the caller stores the manifest after the objects it names. The
169
+ * sink is called in segment order, and each segment's blob is held until its
170
+ * write ends.
171
+ *
109
172
  * @param type - The collection type (Array / Set / Dict)
110
173
  * @param pieces - The collection's pieces, in order
111
174
  * @param sink - Writes one object and returns its hash
175
+ * @param options - `inFlight`, the most objects the sink writes at once
176
+ * (default {@link SEGMENT_WRITES_IN_FLIGHT})
112
177
  * @returns The manifest's bytes — the caller stores them, and its hash is the
113
178
  * dataset's content address
114
179
  * @throws {Error} When a piece's elements do not ascend (Set / Dict), a
115
180
  * segment's blob is not the one segment its reference describes, or the sink
116
- * rejects.
181
+ * rejects: once the writes in flight have ended, so none runs on after the
182
+ * writer returns.
117
183
  */
118
184
  export async function writeCollectionManifest(
119
185
  type: EastType | EastTypeValue,
120
186
  pieces: Iterable<CollectionPiece> | AsyncIterable<CollectionPiece>,
121
187
  sink: SegmentSink,
188
+ options: { inFlight?: number } = {},
122
189
  ): Promise<Uint8Array> {
123
190
  const typeValue = asTypeValue(type);
124
191
  const entries: CollectionManifestEntry[] = [];
192
+ const writes = new SinkWrites(sink, options.inFlight ?? SEGMENT_WRITES_IN_FLIGHT);
193
+ /** Writes a segment the manifest names at the place it takes now. */
194
+ const write = (blob: Uint8Array, fence: Uint8Array, count: number): Promise<void> => {
195
+ const at = entries.push({ hash: '', fence, count: BigInt(count), bytes: BigInt(blob.byteLength) }) - 1;
196
+ return writes.start(blob, (hash) => {
197
+ entries[at] = { ...entries[at]!, hash };
198
+ });
199
+ };
125
200
  // Frames deflate on the worker pool once the value is large enough to be
126
201
  // worth it; the pool holds the frames in flight and nothing more.
127
202
  const recut = recutBeast2For<EastType, CollectionSegmentRef>(typeValue, { parallel: true });
128
- const stats = await recut(pieces, {
129
- written: async (segment) => {
130
- entries.push({
131
- hash: await sink(segment.blob),
132
- fence: segment.fence,
133
- count: BigInt(segment.count),
134
- bytes: BigInt(segment.blob.byteLength),
135
- });
136
- },
137
- carried: async (ref) => {
138
- if (ref.entry !== undefined) {
139
- entries.push(ref.entry);
140
- return;
141
- }
142
- const blob = await ref.read();
143
- entries.push({ hash: await sink(blob), fence: ref.fence, count: BigInt(ref.count), bytes: BigInt(blob.byteLength) });
144
- },
145
- });
203
+ let header = '';
204
+ try {
205
+ const stats = await recut(pieces, {
206
+ written: (segment) => write(segment.blob, segment.fence, segment.count),
207
+ carried: async (ref) => {
208
+ if (ref.entry !== undefined) {
209
+ entries.push(ref.entry);
210
+ return;
211
+ }
212
+ await write(await ref.read(), ref.fence, ref.count);
213
+ },
214
+ });
215
+ await writes.start(stats.header, (hash) => {
216
+ header = hash;
217
+ });
218
+ } catch (error) {
219
+ await writes.settled();
220
+ throw error;
221
+ }
222
+ await writes.stored();
146
223
  return encodeCollectionManifest({
147
224
  kind: COLLECTION_MANIFEST_KIND,
148
225
  level: 0n,
149
226
  type: typeValue,
150
227
  rule: segmentRuleFor(typeValue),
151
- header: await sink(stats.header),
228
+ header,
152
229
  entries,
153
230
  });
154
231
  }