@dudousxd/nestjs-catalog 0.22.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/catalog.environment.d.ts +12 -1
- package/dist/catalog.environment.js +12 -1
- package/dist/catalog.pipeline.d.ts +548 -3
- package/dist/catalog.pipeline.js +570 -1
- package/dist/catalog.stage-encoding.d.ts +72 -0
- package/dist/catalog.stage-encoding.js +100 -0
- package/dist/client.d.ts +3 -3
- package/dist/client.js +31 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +21 -4
- package/dist/transform-runner.d.ts +42 -1
- package/dist/transform-runner.js +677 -28
- package/package.json +1 -1
package/dist/transform-runner.js
CHANGED
|
@@ -23,6 +23,51 @@ const catalog_pipeline_1 = require("./catalog.pipeline");
|
|
|
23
23
|
const transform_shape_1 = require("./transform-shape");
|
|
24
24
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
25
25
|
const MAX_OUTPUT_BYTES = 32 * 1024 * 1024;
|
|
26
|
+
/**
|
|
27
|
+
* How many records ride on one line of the wire into the child.
|
|
28
|
+
*
|
|
29
|
+
* **Framing only.** The child calls the author's function once per record
|
|
30
|
+
* whatever this is; nothing about the contract, the memory bound or what a
|
|
31
|
+
* transform can see changes with the number. What changes is how many times
|
|
32
|
+
* `JSON.parse` is entered, and that turned out to be worth measuring rather than
|
|
33
|
+
* assuming: `bench/transform-stream.mjs` runs the identical rename at one record
|
|
34
|
+
* per line and at five hundred, and over the real 102,520-row `af_fleet.csv` the
|
|
35
|
+
* batched wire is about 7% faster end to end and holds the same memory.
|
|
36
|
+
*
|
|
37
|
+
* Five hundred, not five thousand, because this is the one buffer on the write
|
|
38
|
+
* side that scales with nothing else: it is the *only* thing standing between a
|
|
39
|
+
* streamed source and a bounded parent, so it is kept at the same order as the
|
|
40
|
+
* `BATCH_SIZE` the pipeline's own writers use. At five columns that is roughly
|
|
41
|
+
* 40 KB in flight, which is not a memory decision anybody has to think about.
|
|
42
|
+
*/
|
|
43
|
+
const RECORDS_PER_LINE = 500;
|
|
44
|
+
/**
|
|
45
|
+
* When the child stops accumulating rows and puts a line on the wire.
|
|
46
|
+
*
|
|
47
|
+
* Two triggers rather than one, and each covers a case the other cannot.
|
|
48
|
+
*
|
|
49
|
+
* The **byte** trigger is the memory bound: a transform that fans one record out
|
|
50
|
+
* into thousands of rows would otherwise hold all of them until the record after
|
|
51
|
+
* it, and the child's heap would be a property of somebody's data.
|
|
52
|
+
*
|
|
53
|
+
* The **record** trigger is the liveness bound, and it is why the flush happens
|
|
54
|
+
* even when there is nothing to say. A per-record transform that drops most of
|
|
55
|
+
* what it sees — a normaliser that returns `[]` for a blank row — emits no bytes
|
|
56
|
+
* for a long time, and a parent watching only the byte stream cannot tell that
|
|
57
|
+
* from a child hung on record 60,000. The empty line carries `at`, so the stall
|
|
58
|
+
* clock has something to reset on. See {@link SubprocessTransformRunner.runStream}.
|
|
59
|
+
*
|
|
60
|
+
* It is also the **attribution window**, which is what fixes its size at 200
|
|
61
|
+
* rather than at a round thousand. A child killed mid-record cannot report where
|
|
62
|
+
* it got to, so the finest a failure can be located is "after the last `at` the
|
|
63
|
+
* child sent, and before the last record the parent sent" — and this number is
|
|
64
|
+
* the first half of that. Two hundred costs about five hundred extra lines over a
|
|
65
|
+
* hundred-thousand-record load, which is nothing measurable, and buys a window
|
|
66
|
+
* five times narrower for the person reading the failure. {@link stallError}
|
|
67
|
+
* states the window rather than picking a record inside it.
|
|
68
|
+
*/
|
|
69
|
+
const FLUSH_BYTES = 256 * 1024;
|
|
70
|
+
const FLUSH_RECORDS = 200;
|
|
26
71
|
/**
|
|
27
72
|
* How much of the child's stderr is held, and why it is a different number from
|
|
28
73
|
* {@link MAX_OUTPUT_BYTES} with a different consequence.
|
|
@@ -62,6 +107,41 @@ const MAX_CAPTURED_STDERR_BYTES = 64 * 1024;
|
|
|
62
107
|
* would throw on every timeout.
|
|
63
108
|
*/
|
|
64
109
|
const KILL_PROCESS_GROUP = process.platform !== 'win32';
|
|
110
|
+
/**
|
|
111
|
+
* The whole of the isolation, in one object, spawned identically by every path
|
|
112
|
+
* in this file.
|
|
113
|
+
*
|
|
114
|
+
* One object rather than a literal per call site, and that is the point rather
|
|
115
|
+
* than tidiness. The class docblock spends four paragraphs being exact about
|
|
116
|
+
* what a transform can and cannot reach, and every sentence of it is a claim
|
|
117
|
+
* about *these three fields*. A second literal somewhere else in the file would
|
|
118
|
+
* be a second answer to "what environment does user code get", and the one that
|
|
119
|
+
* drifted would be discovered by a transform reading something the docblock says
|
|
120
|
+
* it cannot. The streaming path added below reuses this untouched, which is why
|
|
121
|
+
* "a streamed transform is isolated exactly as a batched one is" is a property of
|
|
122
|
+
* the code rather than a promise in a comment.
|
|
123
|
+
*
|
|
124
|
+
* - **`env`** is `{PATH, NODE_ENV}` and not the parent's. A transform has no
|
|
125
|
+
* business reading the database password, and inheriting env is how it would.
|
|
126
|
+
* Read the class docblock before treating this as containment: the same values
|
|
127
|
+
* are a `/proc/<ppid>/environ` read away, and this is a guard rail against the
|
|
128
|
+
* accidental read rather than a boundary.
|
|
129
|
+
* - **`cwd`** is not the parent's, which is a running service's directory and
|
|
130
|
+
* holds the `.env` the allowlist exists to withhold — a transform whose first
|
|
131
|
+
* line is `readFileSync(".env")` was reading the host application's
|
|
132
|
+
* configuration by relative path. A temporary directory keeps the file writes a
|
|
133
|
+
* transform may legitimately want working while making the one path it can name
|
|
134
|
+
* without knowing anything about the deployment uninteresting. Absolute paths
|
|
135
|
+
* are unaffected, and cannot be.
|
|
136
|
+
* - **`detached`** puts the child in its own process group, so a timeout can
|
|
137
|
+
* reach a grandchild. See {@link KILL_PROCESS_GROUP} and {@link stop}.
|
|
138
|
+
*/
|
|
139
|
+
const CHILD_PROCESS_OPTIONS = {
|
|
140
|
+
env: { PATH: process.env.PATH ?? '', NODE_ENV: 'production' },
|
|
141
|
+
cwd: (0, node_os_1.tmpdir)(),
|
|
142
|
+
detached: KILL_PROCESS_GROUP,
|
|
143
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
144
|
+
};
|
|
65
145
|
/**
|
|
66
146
|
* How much of what a transform logged is carried back, on both axes.
|
|
67
147
|
*
|
|
@@ -259,6 +339,116 @@ let SubprocessTransformRunner = SubprocessTransformRunner_1 = class SubprocessTr
|
|
|
259
339
|
await (0, promises_1.rm)(modulePath, { force: true });
|
|
260
340
|
}
|
|
261
341
|
}
|
|
342
|
+
/**
|
|
343
|
+
* Run a `'record'`-mode transform over a stream, and hand the rows back as a
|
|
344
|
+
* stream.
|
|
345
|
+
*
|
|
346
|
+
* ## What is and is not different from {@link run}
|
|
347
|
+
*
|
|
348
|
+
* **The isolation is not different, at all.** Same interpreter, same
|
|
349
|
+
* {@link CHILD_PROCESS_OPTIONS} — the same `{PATH, NODE_ENV}`, the same
|
|
350
|
+
* temporary cwd, the same process group — and the context still travels beside
|
|
351
|
+
* the records on stdin rather than in the environment. It is worth being blunt
|
|
352
|
+
* about why that is easy to say: the child here is **not longer-lived than the
|
|
353
|
+
* one `run` spawns.** Both live for exactly one node run and are gone when it
|
|
354
|
+
* ends. `run` was never a spawn per batch; it was a spawn per node with the
|
|
355
|
+
* whole dataset in one blob. So there is no new window in which state could
|
|
356
|
+
* leak between batches or between runs, because there was never a window to
|
|
357
|
+
* widen. What a transform may retain *within* one run is stated exactly on
|
|
358
|
+
* {@link javascriptRecordHarness}.
|
|
359
|
+
*
|
|
360
|
+
* **The timeout is different, and it has to be.** See {@link stallError}.
|
|
361
|
+
*
|
|
362
|
+
* **Failure names a record.** A batch call can only report that the transform
|
|
363
|
+
* threw; here the child counts what it has consumed and puts that number on
|
|
364
|
+
* every line, so a stream that dies names the record it died on. What was
|
|
365
|
+
* already staged is a matter for the caller — for the connector runner it sits
|
|
366
|
+
* in an uncommitted snapshot, for a workflow node it is overwritten by the next
|
|
367
|
+
* attempt — and in neither case does a watermark move, because nothing here
|
|
368
|
+
* reaches a commit.
|
|
369
|
+
*
|
|
370
|
+
* ## The one shape that would deadlock, and how it is avoided
|
|
371
|
+
*
|
|
372
|
+
* The writer runs as a floating loop and the reader is driven by the consumer
|
|
373
|
+
* pulling rows. Awaiting the writer before yielding the first row is the one
|
|
374
|
+
* arrangement that hangs: the child fills its stdout buffer with rows nobody is
|
|
375
|
+
* draining, stops reading stdin, and this side waits forever for a `drain` that
|
|
376
|
+
* requires the reader that has not started. Written the way it is, a slow
|
|
377
|
+
* consumer simply back-pressures the whole chain, which is the point.
|
|
378
|
+
*/
|
|
379
|
+
async runStream(transform, records, options = {}) {
|
|
380
|
+
const mode = (0, catalog_pipeline_1.transformMode)(transform);
|
|
381
|
+
if (mode === 'batch') {
|
|
382
|
+
throw new Error('This transform is a function over the whole batch, so it cannot be streamed a record at a time — that would call it once per record and return one partial answer per record, which is exactly the silent wrong result TRANSFORM_MODES exists to prevent. Run it through `run`, or set the transform to per-record mode.');
|
|
383
|
+
}
|
|
384
|
+
if (mode !== 'record')
|
|
385
|
+
return (0, catalog_pipeline_1.unreachableTransformMode)(mode, 'SubprocessTransformRunner.runStream');
|
|
386
|
+
// Asked here as well as at the controller, because a transform row can reach
|
|
387
|
+
// a runner without ever passing through this build's controller — promoted
|
|
388
|
+
// from another environment, restored, written by an older version — and the
|
|
389
|
+
// failure this must never have is the quiet one.
|
|
390
|
+
const refusal = (0, catalog_pipeline_1.recordModeRefusal)(transform);
|
|
391
|
+
if (refusal)
|
|
392
|
+
throw new Error(refusal);
|
|
393
|
+
const started = Date.now();
|
|
394
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
395
|
+
// `rowCount` is 0 rather than a guess: a stream does not know how many
|
|
396
|
+
// records there are, and `contextlessRun` is only reached by a caller that
|
|
397
|
+
// gave no context at all. A caller with a real run supplies the count it
|
|
398
|
+
// knows, exactly as it does for a batch.
|
|
399
|
+
const context = options.context ?? contextlessRun(0);
|
|
400
|
+
// A record-mode transform is always a module — `recordModeRefusal` has just
|
|
401
|
+
// refused anything else — so the file is unconditional here where `run` has
|
|
402
|
+
// to decide. Removed in the generator's `finally` rather than in one here,
|
|
403
|
+
// because the run is not over when this method returns: it is over when the
|
|
404
|
+
// rows have been drained, thrown, or abandoned by a `break`.
|
|
405
|
+
const modulePath = (0, node_path_1.join)((0, node_os_1.tmpdir)(), `catalog-transform-${(0, node_crypto_1.randomUUID)()}.${transform.language === 'typescript' ? 'mts' : 'mjs'}`);
|
|
406
|
+
await (0, promises_1.writeFile)(modulePath, transform.code, 'utf8');
|
|
407
|
+
const child = (0, node_child_process_1.spawn)(process.execPath, ['--input-type', 'module', '-e', javascriptRecordHarness((0, node_url_1.pathToFileURL)(modulePath).href)], CHILD_PROCESS_OPTIONS);
|
|
408
|
+
const pump = new RecordStreamPump(child, timeoutMs);
|
|
409
|
+
pump.feed(records, context);
|
|
410
|
+
let summary;
|
|
411
|
+
const logger = this.logger;
|
|
412
|
+
async function* rows() {
|
|
413
|
+
try {
|
|
414
|
+
for await (const message of pump.messages()) {
|
|
415
|
+
if (message.done) {
|
|
416
|
+
summary = { ...message.done, elapsedMs: Date.now() - started };
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
for (const row of message.rows) {
|
|
420
|
+
// The same filter `run` applies before it returns, through the same
|
|
421
|
+
// predicate: anything that is not a plain object cannot be written
|
|
422
|
+
// as a row, and dropping it silently downstream is how a load comes
|
|
423
|
+
// out short with nothing to explain it.
|
|
424
|
+
if (isRowObject(row))
|
|
425
|
+
yield row;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
finally {
|
|
430
|
+
// Every exit: drained, thrown, or a `break` in the consumer's loop. The
|
|
431
|
+
// last one is why this cannot be a `finally` around the spawn — a
|
|
432
|
+
// consumer that stops early leaves a child holding a pipe, and the kill
|
|
433
|
+
// has to happen where the abandonment is observable.
|
|
434
|
+
pump.close();
|
|
435
|
+
await (0, promises_1.rm)(modulePath, { force: true }).catch(() => {
|
|
436
|
+
// A temporary file that will not unlink is not worth failing a load
|
|
437
|
+
// that produced correct rows. It is logged rather than thrown.
|
|
438
|
+
logger.warn(`Could not remove the transform's temporary module at ${modulePath}.`);
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return {
|
|
443
|
+
rows: rows(),
|
|
444
|
+
summary: () => {
|
|
445
|
+
if (!summary) {
|
|
446
|
+
throw new Error('The transform stream has not finished, so there is no summary yet. `summary()` is answered only once `rows` is exhausted — a running total asked for early is exactly the number somebody would go on to record as `fetched`.');
|
|
447
|
+
}
|
|
448
|
+
return summary;
|
|
449
|
+
},
|
|
450
|
+
};
|
|
451
|
+
}
|
|
262
452
|
async execute(interpreter, args, records, context, timeoutMs, shape, started) {
|
|
263
453
|
// An envelope rather than the bare array stdin used to carry. The context
|
|
264
454
|
// travels beside the records rather than in the child's `env`, and that is
|
|
@@ -282,33 +472,14 @@ let SubprocessTransformRunner = SubprocessTransformRunner_1 = class SubprocessTr
|
|
|
282
472
|
throw new Error('The transform must return an array of rows. Returning anything else would leave the load ambiguous.');
|
|
283
473
|
}
|
|
284
474
|
return {
|
|
285
|
-
rows: parsed.rows.filter(
|
|
475
|
+
rows: parsed.rows.filter(isRowObject),
|
|
286
476
|
logs,
|
|
287
477
|
elapsedMs: Date.now() - started,
|
|
288
478
|
};
|
|
289
479
|
}
|
|
290
480
|
spawn(command, args, input, timeoutMs, shape = 'body') {
|
|
291
481
|
return new Promise((resolve, reject) => {
|
|
292
|
-
const child = (0, node_child_process_1.spawn)(command, args,
|
|
293
|
-
// An empty environment, not the parent's. A transform has no business
|
|
294
|
-
// reading the database password, and inheriting env is how it would.
|
|
295
|
-
// Read the class docblock before treating this as containment: the same
|
|
296
|
-
// values are a `/proc/<ppid>/environ` read away, and this is a guard
|
|
297
|
-
// rail against the accidental read rather than a boundary.
|
|
298
|
-
env: { PATH: process.env.PATH ?? '', NODE_ENV: 'production' },
|
|
299
|
-
// Not the parent's, which is a running service's directory and holds
|
|
300
|
-
// the `.env` the allowlist above exists to withhold — a transform whose
|
|
301
|
-
// first line is `readFileSync(".env")` was reading the host application's
|
|
302
|
-
// configuration by relative path. A temporary directory keeps the file
|
|
303
|
-
// writes a transform may legitimately want working while making the one
|
|
304
|
-
// path it can name without knowing anything about the deployment
|
|
305
|
-
// uninteresting. Absolute paths are unaffected, and cannot be.
|
|
306
|
-
cwd: (0, node_os_1.tmpdir)(),
|
|
307
|
-
// Its own process group, so the timeout below can reach a grandchild.
|
|
308
|
-
// See {@link KILL_PROCESS_GROUP}.
|
|
309
|
-
detached: KILL_PROCESS_GROUP,
|
|
310
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
311
|
-
});
|
|
482
|
+
const child = (0, node_child_process_1.spawn)(command, args, CHILD_PROCESS_OPTIONS);
|
|
312
483
|
let stdout = '';
|
|
313
484
|
let stderr = '';
|
|
314
485
|
let settled = false;
|
|
@@ -398,6 +569,313 @@ exports.SubprocessTransformRunner = SubprocessTransformRunner = SubprocessTransf
|
|
|
398
569
|
(0, common_1.Injectable)(),
|
|
399
570
|
__metadata("design:paramtypes", [Object])
|
|
400
571
|
], SubprocessTransformRunner);
|
|
572
|
+
/**
|
|
573
|
+
* Whether this is something that can be stored as a row.
|
|
574
|
+
*
|
|
575
|
+
* One predicate for both paths rather than the same three clauses written twice.
|
|
576
|
+
* "A row is a plain object" is a rule the publish side already depends on, and
|
|
577
|
+
* two copies of it are two places for an array or a `null` to start counting as
|
|
578
|
+
* a row in one mode and not the other.
|
|
579
|
+
*/
|
|
580
|
+
function isRowObject(value) {
|
|
581
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* Both halves of the pipe to a record-mode child: records in, rows out, and the
|
|
585
|
+
* clock that decides the child has stopped.
|
|
586
|
+
*
|
|
587
|
+
* A class rather than closures inside `runStream` because the two halves have to
|
|
588
|
+
* see each other. The stall clock cannot be armed by the reader alone — whether
|
|
589
|
+
* the child *owes* an answer is a fact about what the writer has sent — and the
|
|
590
|
+
* writer cannot arm it either, because whether anybody is *waiting* is a fact
|
|
591
|
+
* about the reader. Separating them into two functions would mean passing a
|
|
592
|
+
* mutable cell between them, which is this object with the name taken off.
|
|
593
|
+
*
|
|
594
|
+
* ## The stall clock, and why it is not the batch timeout
|
|
595
|
+
*
|
|
596
|
+
* `run` bounds total wall clock: the whole payload is written at once, so
|
|
597
|
+
* "elapsed" is time the child spent working and nothing else. That bound cannot
|
|
598
|
+
* be carried over unchanged, and this is the one genuine semantic change in the
|
|
599
|
+
* streaming path, so it is worth stating rather than discovering. A streamed
|
|
600
|
+
* transform's clock would include **time waiting for the source** — a SQL cursor
|
|
601
|
+
* paging over ten million rows, an S3 prefix listing — which the batch path
|
|
602
|
+
* finished before it spawned anything. Thirty seconds of total wall clock would
|
|
603
|
+
* therefore fail loads that work today, for reasons that have nothing to do with
|
|
604
|
+
* the transform.
|
|
605
|
+
*
|
|
606
|
+
* So what is bounded is a **stall**: `timeoutMs` with the child owing an answer
|
|
607
|
+
* and nobody hearing one. The three states are kept apart deliberately —
|
|
608
|
+
*
|
|
609
|
+
* - the child is hung on a record → the reader is waiting, records are
|
|
610
|
+
* outstanding, the clock runs, and at `timeoutMs` the process group is killed.
|
|
611
|
+
* This is the failure the timeout exists for and it is caught *sooner* than the
|
|
612
|
+
* old bound caught it on a long load;
|
|
613
|
+
* - the source is slow → the child has answered everything it was given, nothing
|
|
614
|
+
* is outstanding, the clock is not running. A load that spends an hour waiting
|
|
615
|
+
* on a database is not a stalled transform;
|
|
616
|
+
* - the consumer is slow → the reader is not waiting on the child at all, so the
|
|
617
|
+
* clock is not running. A sink writing to a busy warehouse back-pressures the
|
|
618
|
+
* whole chain, which is the design, and must not read as a hang.
|
|
619
|
+
*
|
|
620
|
+
* What is given up is a bound on **total** time, and that is deliberate: a
|
|
621
|
+
* stream's total time is a property of how much data there is, so a total bound
|
|
622
|
+
* is a bound on dataset size wearing a clock's clothes. The outer bound has not
|
|
623
|
+
* disappeared — a node runs inside a durable step, and `abandoned-runs.ts` is
|
|
624
|
+
* what closes a run whose worker went away.
|
|
625
|
+
*
|
|
626
|
+
* On the kill, rows already yielded have already been written by whoever was
|
|
627
|
+
* consuming them. Nothing promotes a watermark or commits a snapshot on this
|
|
628
|
+
* path, so "staged but not committed" is where they stay — the same place every
|
|
629
|
+
* other mid-run failure leaves its work.
|
|
630
|
+
*/
|
|
631
|
+
class RecordStreamPump {
|
|
632
|
+
child;
|
|
633
|
+
timeoutMs;
|
|
634
|
+
chunks = [];
|
|
635
|
+
carry = '';
|
|
636
|
+
stderr = '';
|
|
637
|
+
/** How many records the writer has put on the wire. */
|
|
638
|
+
sent = 0;
|
|
639
|
+
/** How many the child has said it consumed, from the last line it wrote. */
|
|
640
|
+
answered = 0;
|
|
641
|
+
waiting = false;
|
|
642
|
+
timer;
|
|
643
|
+
wake;
|
|
644
|
+
fail;
|
|
645
|
+
ended = false;
|
|
646
|
+
failure;
|
|
647
|
+
/** Set once, and preferred over any later error: the reason we killed it. */
|
|
648
|
+
stalled = false;
|
|
649
|
+
constructor(child, timeoutMs) {
|
|
650
|
+
this.child = child;
|
|
651
|
+
this.timeoutMs = timeoutMs;
|
|
652
|
+
child.stdout.setEncoding('utf8');
|
|
653
|
+
child.stdout.on('data', (chunk) => {
|
|
654
|
+
this.chunks.push(chunk);
|
|
655
|
+
this.notify();
|
|
656
|
+
});
|
|
657
|
+
child.stderr.on('data', (chunk) => {
|
|
658
|
+
// Bounded exactly as the batch path bounds it, and for the reason
|
|
659
|
+
// {@link MAX_CAPTURED_STDERR_BYTES} gives at length: a transform looping on
|
|
660
|
+
// writes to fd 2 grows the *parent's* heap, and a stream gives it longer to
|
|
661
|
+
// do so.
|
|
662
|
+
if (this.stderr.length >= MAX_CAPTURED_STDERR_BYTES)
|
|
663
|
+
return;
|
|
664
|
+
this.stderr += chunk.toString().slice(0, MAX_CAPTURED_STDERR_BYTES - this.stderr.length);
|
|
665
|
+
});
|
|
666
|
+
child.on('error', (error) => this.abort(error));
|
|
667
|
+
child.on('close', (code) => {
|
|
668
|
+
this.ended = true;
|
|
669
|
+
if (code !== 0 && !this.failure) {
|
|
670
|
+
this.failure = new Error(`The transform exited with code ${code} after ${this.answered} record(s), before it finished the stream. ${this.stderr.slice(0, 500)}`);
|
|
671
|
+
}
|
|
672
|
+
this.notify();
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Write the context, then the records, in lines of
|
|
677
|
+
* {@link RECORDS_PER_LINE}.
|
|
678
|
+
*
|
|
679
|
+
* Floating on purpose — see {@link SubprocessTransformRunner.runStream} on the
|
|
680
|
+
* deadlock this shape avoids. Its failure is recorded rather than thrown into
|
|
681
|
+
* nowhere: a source that dies mid-read must surface on the row stream, not as
|
|
682
|
+
* an unhandled rejection with the run reporting a short but successful load.
|
|
683
|
+
*/
|
|
684
|
+
feed(records, context) {
|
|
685
|
+
void (async () => {
|
|
686
|
+
const stdin = this.child.stdin;
|
|
687
|
+
await write(stdin, `${JSON.stringify(context)}\n`);
|
|
688
|
+
let line = [];
|
|
689
|
+
for await (const record of records) {
|
|
690
|
+
line.push(record);
|
|
691
|
+
if (line.length < RECORDS_PER_LINE)
|
|
692
|
+
continue;
|
|
693
|
+
await write(stdin, `${JSON.stringify(line)}\n`);
|
|
694
|
+
this.sent += line.length;
|
|
695
|
+
line = [];
|
|
696
|
+
// The clock may have been idle while the child had nothing outstanding.
|
|
697
|
+
// Sending work is one of the two events that can start it running.
|
|
698
|
+
this.rearm();
|
|
699
|
+
}
|
|
700
|
+
if (line.length > 0) {
|
|
701
|
+
await write(stdin, `${JSON.stringify(line)}\n`);
|
|
702
|
+
this.sent += line.length;
|
|
703
|
+
this.rearm();
|
|
704
|
+
}
|
|
705
|
+
stdin.end();
|
|
706
|
+
})().catch((error) => {
|
|
707
|
+
this.abort(error instanceof Error ? error : new Error(String(error)));
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
/** Every line the child wrote, in order, until it says it is done or it fails. */
|
|
711
|
+
async *messages() {
|
|
712
|
+
for (;;) {
|
|
713
|
+
const line = await this.nextLine();
|
|
714
|
+
if (line === undefined) {
|
|
715
|
+
if (this.failure)
|
|
716
|
+
throw this.failure;
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
// `parse` throws on the child's failure line, so that the record number is
|
|
720
|
+
// in the sentence a consumer sees rather than in a field nobody reads.
|
|
721
|
+
const message = this.parse(line);
|
|
722
|
+
this.answered = message.at;
|
|
723
|
+
yield message;
|
|
724
|
+
if (message.done)
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
/** Kill the child and everything it started. Idempotent. */
|
|
729
|
+
close() {
|
|
730
|
+
this.clearTimer();
|
|
731
|
+
if (!this.ended)
|
|
732
|
+
stop(this.child);
|
|
733
|
+
}
|
|
734
|
+
parse(line) {
|
|
735
|
+
let parsed;
|
|
736
|
+
try {
|
|
737
|
+
parsed = JSON.parse(line);
|
|
738
|
+
}
|
|
739
|
+
catch {
|
|
740
|
+
throw new Error(`The transform wrote a line this runner could not read after ${this.answered} record(s). That is a bug in the catalog's harness rather than in the transform. stderr: ${this.stderr.slice(0, 500)}`);
|
|
741
|
+
}
|
|
742
|
+
if (parsed.failed) {
|
|
743
|
+
const at = typeof parsed.failed.at === 'number' ? parsed.failed.at : this.answered + 1;
|
|
744
|
+
const logs = Array.isArray(parsed.failed.logs) ? parsed.failed.logs.map(String) : [];
|
|
745
|
+
throw new Error(withFinalLogs(`The transform failed on record ${at}: ${String(parsed.failed.error)}`, logs));
|
|
746
|
+
}
|
|
747
|
+
if (isDoneLine(parsed.done)) {
|
|
748
|
+
return { at: parsed.done.recordsIn, rows: [], done: parsed.done };
|
|
749
|
+
}
|
|
750
|
+
return {
|
|
751
|
+
at: typeof parsed.at === 'number' ? parsed.at : this.answered,
|
|
752
|
+
rows: Array.isArray(parsed.rows) ? parsed.rows : [],
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
/** One complete line, or `undefined` when the child has closed for good. */
|
|
756
|
+
async nextLine() {
|
|
757
|
+
for (;;) {
|
|
758
|
+
const nl = this.carry.indexOf('\n');
|
|
759
|
+
if (nl !== -1) {
|
|
760
|
+
const line = this.carry.slice(0, nl);
|
|
761
|
+
this.carry = this.carry.slice(nl + 1);
|
|
762
|
+
if (line.length > 0)
|
|
763
|
+
return line;
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
if (this.chunks.length > 0) {
|
|
767
|
+
this.carry += this.chunks.shift();
|
|
768
|
+
if (this.carry.length > MAX_OUTPUT_BYTES) {
|
|
769
|
+
this.abort(new Error(`The transform wrote more than ${MAX_OUTPUT_BYTES} bytes without a line break, at record ${this.answered}. One record cannot fan out to more rows than the runner can hold.`));
|
|
770
|
+
}
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
if (this.ended)
|
|
774
|
+
return undefined;
|
|
775
|
+
if (this.failure)
|
|
776
|
+
throw this.failure;
|
|
777
|
+
await this.awaitChunk();
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* Wait for the child to say something, with the clock running only if it owes
|
|
782
|
+
* us an answer.
|
|
783
|
+
*
|
|
784
|
+
* This method *is* the arming rule: the clock runs while, and only while,
|
|
785
|
+
* control is inside it and {@link owes} holds.
|
|
786
|
+
*/
|
|
787
|
+
awaitChunk() {
|
|
788
|
+
return new Promise((resolve, reject) => {
|
|
789
|
+
this.waiting = true;
|
|
790
|
+
this.wake = () => {
|
|
791
|
+
this.waiting = false;
|
|
792
|
+
this.wake = undefined;
|
|
793
|
+
this.fail = undefined;
|
|
794
|
+
this.clearTimer();
|
|
795
|
+
resolve();
|
|
796
|
+
};
|
|
797
|
+
this.fail = (error) => {
|
|
798
|
+
this.waiting = false;
|
|
799
|
+
this.wake = undefined;
|
|
800
|
+
this.fail = undefined;
|
|
801
|
+
this.clearTimer();
|
|
802
|
+
reject(error);
|
|
803
|
+
};
|
|
804
|
+
this.rearm();
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
/** Any output at all is progress: the clock stops, and whoever waits wakes. */
|
|
808
|
+
notify() {
|
|
809
|
+
this.clearTimer();
|
|
810
|
+
this.wake?.();
|
|
811
|
+
}
|
|
812
|
+
owes() {
|
|
813
|
+
return this.sent > this.answered && !this.ended;
|
|
814
|
+
}
|
|
815
|
+
rearm() {
|
|
816
|
+
this.clearTimer();
|
|
817
|
+
if (!this.waiting || !this.owes())
|
|
818
|
+
return;
|
|
819
|
+
this.timer = setTimeout(() => {
|
|
820
|
+
this.stalled = true;
|
|
821
|
+
this.abort(stallError(this.timeoutMs, this.answered, this.sent));
|
|
822
|
+
}, this.timeoutMs);
|
|
823
|
+
}
|
|
824
|
+
clearTimer() {
|
|
825
|
+
if (this.timer === undefined)
|
|
826
|
+
return;
|
|
827
|
+
clearTimeout(this.timer);
|
|
828
|
+
this.timer = undefined;
|
|
829
|
+
}
|
|
830
|
+
abort(error) {
|
|
831
|
+
// The first failure wins, except that a stall always does: a killed child
|
|
832
|
+
// then closes with a signal, and reporting "exited with code null" instead of
|
|
833
|
+
// "made no progress for 30000ms" would name the symptom rather than the
|
|
834
|
+
// cause.
|
|
835
|
+
if (!this.failure || this.stalled)
|
|
836
|
+
this.failure = error;
|
|
837
|
+
this.close();
|
|
838
|
+
this.ended = true;
|
|
839
|
+
if (this.fail)
|
|
840
|
+
this.fail(this.failure);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
/** Whether the child's last line is the summary, with the fields it promises. */
|
|
844
|
+
function isDoneLine(value) {
|
|
845
|
+
if (typeof value !== 'object' || value === null)
|
|
846
|
+
return false;
|
|
847
|
+
const done = value;
|
|
848
|
+
return typeof done.recordsIn === 'number' && typeof done.rowsOut === 'number';
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* The sentence a stalled stream fails with.
|
|
852
|
+
*
|
|
853
|
+
* It states a **window** rather than a record, and the difference is the whole
|
|
854
|
+
* honesty of the message. A child killed with SIGKILL cannot report where it
|
|
855
|
+
* got to, so what the parent knows is the last `at` the child sent and the
|
|
856
|
+
* number of records it has been given since; the record that hung is somewhere
|
|
857
|
+
* between them. Naming `answered + 1` would read as a precise answer and would
|
|
858
|
+
* be wrong by up to {@link FLUSH_RECORDS} — and wrong in the direction that
|
|
859
|
+
* sends somebody to look at a row that transformed perfectly well.
|
|
860
|
+
*
|
|
861
|
+
* The window collapses to one record when it can, because "somewhere in records
|
|
862
|
+
* 618 to 618" is a sentence nobody should have to read.
|
|
863
|
+
*
|
|
864
|
+
* It also says what became of the rows, which is the second question anybody
|
|
865
|
+
* asks and the one #96 established the rule for: rows already yielded were
|
|
866
|
+
* already written by whoever consumed them, and they sit in an uncommitted
|
|
867
|
+
* snapshot because nothing on this path reaches a commit or moves a watermark.
|
|
868
|
+
*/
|
|
869
|
+
function stallError(timeoutMs, answered, sent) {
|
|
870
|
+
const where = sent <= answered + 1 ? `on record ${sent}` : `somewhere in records ${answered + 1} to ${sent}`;
|
|
871
|
+
return new Error(`The transform stopped making progress for ${timeoutMs}ms and was stopped. It had finished ${answered} record(s) and been given ${sent}, so it stopped ${where}. Rows it had already produced were passed on and are in an uncommitted snapshot; nothing was committed and no watermark moved.`);
|
|
872
|
+
}
|
|
873
|
+
/** Write, and wait for the pipe to drain if it asked us to. */
|
|
874
|
+
function write(stream, text) {
|
|
875
|
+
if (stream.write(text))
|
|
876
|
+
return undefined;
|
|
877
|
+
return new Promise((resolve) => stream.once('drain', resolve));
|
|
878
|
+
}
|
|
401
879
|
/**
|
|
402
880
|
* What the interpreter is invoked with, and which harness it is handed.
|
|
403
881
|
*
|
|
@@ -569,6 +1047,168 @@ function javascriptModuleHarness(moduleUrl) {
|
|
|
569
1047
|
return `${JAVASCRIPT_PRELUDE}
|
|
570
1048
|
try {
|
|
571
1049
|
${JAVASCRIPT_PAYLOAD}
|
|
1050
|
+
${javascriptExportedFunction(moduleUrl)}
|
|
1051
|
+
const rows = await exported({ records, context });
|
|
1052
|
+
process.stdout.write(JSON.stringify({ rows: rows ?? [], logs: captured() }));
|
|
1053
|
+
} catch (error) {
|
|
1054
|
+
${JAVASCRIPT_FAILURE}
|
|
1055
|
+
}
|
|
1056
|
+
`;
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* The harness for `'record'` mode: import the author's module, call it once per
|
|
1060
|
+
* record, and put the rows on the wire as they are produced.
|
|
1061
|
+
*
|
|
1062
|
+
* Everything about the *code* is the same as {@link javascriptModuleHarness} —
|
|
1063
|
+
* the same six console channels, the same two caps, the same frozen `context`,
|
|
1064
|
+
* the same two accepted export spellings, the same refusal by name when there is
|
|
1065
|
+
* neither. What differs is only the argument and the transport, and keeping the
|
|
1066
|
+
* rest identical is what makes "the same transform, called differently" true
|
|
1067
|
+
* rather than approximately true.
|
|
1068
|
+
*
|
|
1069
|
+
* ## What a per-record transform can and cannot retain
|
|
1070
|
+
*
|
|
1071
|
+
* The question is worth answering exactly, because "it cannot see the batch" is
|
|
1072
|
+
* a claim about a contract and people will build on it.
|
|
1073
|
+
*
|
|
1074
|
+
* **It cannot see other records.** The function is handed one `record` and there
|
|
1075
|
+
* is no array anywhere in scope. That is enforced by the shape of the call, not
|
|
1076
|
+
* by convention.
|
|
1077
|
+
*
|
|
1078
|
+
* **It cannot emit at the end.** There is no finish hook, no flush callback, no
|
|
1079
|
+
* second export this harness looks for. A transform that accumulates into a
|
|
1080
|
+
* module-scope `Map` intending to return the totals afterwards has nowhere to
|
|
1081
|
+
* return them *to*, so it emits nothing and the node's row count is zero — which
|
|
1082
|
+
* is loud, immediate and visible on the run, rather than a partial aggregate
|
|
1083
|
+
* committed as though it were the answer. This is the enforcement that matters,
|
|
1084
|
+
* because it is the one that turns "you have used the wrong mode" from silently
|
|
1085
|
+
* wrong data into an obvious failure.
|
|
1086
|
+
*
|
|
1087
|
+
* **It cannot retain anything past the node.** The process is spawned for one
|
|
1088
|
+
* node run and killed at the end of it, so nothing carries to the next run, the
|
|
1089
|
+
* next node, or another connector sharing the same transform. That is enforced
|
|
1090
|
+
* by process lifetime and is the identical guarantee the whole-batch path has
|
|
1091
|
+
* always had — the child was never reused there either.
|
|
1092
|
+
*
|
|
1093
|
+
* **It can retain state in module scope for the length of one run**, and no
|
|
1094
|
+
* honest harness can stop it: a module may close over a `let`, and preventing
|
|
1095
|
+
* that means either re-importing per record (which would cost more than the
|
|
1096
|
+
* subprocess this change exists to make cheaper) or forbidding modules, which is
|
|
1097
|
+
* the only shape the mode accepts. So it is stated rather than pretended
|
|
1098
|
+
* otherwise. What that buys an author is a memo table or a compiled regex, which
|
|
1099
|
+
* is legitimate and useful; what it does not buy is an aggregate, for the reason
|
|
1100
|
+
* directly above. Records arrive in source order, so such a transform is
|
|
1101
|
+
* deterministic — it is simply not what the mode is for.
|
|
1102
|
+
*
|
|
1103
|
+
* ## The wire, and why the child talks in lines
|
|
1104
|
+
*
|
|
1105
|
+
* Records arrive as JSON arrays, one per line, {@link RECORDS_PER_LINE} at a
|
|
1106
|
+
* time; the first line is the context. Rows leave as `{"at":N,"rows":[…]}`
|
|
1107
|
+
* lines, then one `{"done":…}` or `{"failed":…}`. `at` is how many records have
|
|
1108
|
+
* been *consumed*, and it is on every line for two separate consumers: it is
|
|
1109
|
+
* what a failure names so a stack trace can be tied to a row, and it is what the
|
|
1110
|
+
* parent's stall clock resets on. See {@link FLUSH_BYTES} and
|
|
1111
|
+
* {@link FLUSH_RECORDS} for why a line is written even when it carries no rows.
|
|
1112
|
+
*
|
|
1113
|
+
* `process.stdout.write` is awaited through its `drain`, which is the child's
|
|
1114
|
+
* half of the back-pressure: a parent that stops reading stops this loop, which
|
|
1115
|
+
* stops it reading stdin, which stops the source. Without it the child would
|
|
1116
|
+
* happily buffer the whole output in its own heap while congratulating itself on
|
|
1117
|
+
* streaming.
|
|
1118
|
+
*/
|
|
1119
|
+
function javascriptRecordHarness(moduleUrl) {
|
|
1120
|
+
return `${JAVASCRIPT_CONSOLE}
|
|
1121
|
+
let at = 0;
|
|
1122
|
+
let rowsOut = 0;
|
|
1123
|
+
let pending = [];
|
|
1124
|
+
let pendingBytes = 0;
|
|
1125
|
+
let sinceFlush = 0;
|
|
1126
|
+
const send = async (line) => {
|
|
1127
|
+
if (!process.stdout.write(line)) await new Promise((r) => process.stdout.once("drain", r));
|
|
1128
|
+
};
|
|
1129
|
+
const flush = async () => {
|
|
1130
|
+
const line = '{"at":' + at + ',"rows":[' + pending.join(",") + ']}\\n';
|
|
1131
|
+
pending = [];
|
|
1132
|
+
pendingBytes = 0;
|
|
1133
|
+
sinceFlush = 0;
|
|
1134
|
+
await send(line);
|
|
1135
|
+
};
|
|
1136
|
+
// One rule for four return shapes: an object is a row, an array is those rows,
|
|
1137
|
+
// and null or undefined is none. See CatalogRecordTransformFunction, which is
|
|
1138
|
+
// where the argument for that rule lives.
|
|
1139
|
+
const collect = (value) => {
|
|
1140
|
+
if (value === null || value === undefined) return;
|
|
1141
|
+
const rows = Array.isArray(value) ? value : [value];
|
|
1142
|
+
for (const row of rows) {
|
|
1143
|
+
const json = JSON.stringify(row);
|
|
1144
|
+
pending.push(json);
|
|
1145
|
+
pendingBytes += json.length;
|
|
1146
|
+
rowsOut += 1;
|
|
1147
|
+
}
|
|
1148
|
+
};
|
|
1149
|
+
try {
|
|
1150
|
+
${javascriptExportedFunction(moduleUrl)}
|
|
1151
|
+
let context = null;
|
|
1152
|
+
let carry = "";
|
|
1153
|
+
process.stdin.setEncoding("utf8");
|
|
1154
|
+
for await (const chunk of process.stdin) {
|
|
1155
|
+
carry += chunk;
|
|
1156
|
+
let nl = carry.indexOf("\\n");
|
|
1157
|
+
while (nl !== -1) {
|
|
1158
|
+
const line = carry.slice(0, nl);
|
|
1159
|
+
carry = carry.slice(nl + 1);
|
|
1160
|
+
nl = carry.indexOf("\\n");
|
|
1161
|
+
if (line.length === 0) continue;
|
|
1162
|
+
if (context === null) {
|
|
1163
|
+
const sent = JSON.parse(line);
|
|
1164
|
+
// Frozen one level down, exactly as the batch harnesses freeze it, so
|
|
1165
|
+
// assigning to context.env.TOKEN fails loudly here rather than appearing
|
|
1166
|
+
// to work. Frozen ONCE, outside the record loop: it is the same object
|
|
1167
|
+
// for every record by construction, so there is nothing a transform
|
|
1168
|
+
// could write on it that would reach the next one.
|
|
1169
|
+
context = Object.freeze({ ...sent, env: Object.freeze({ ...sent.env }) });
|
|
1170
|
+
continue;
|
|
1171
|
+
}
|
|
1172
|
+
for (const record of JSON.parse(line)) {
|
|
1173
|
+
at += 1;
|
|
1174
|
+
sinceFlush += 1;
|
|
1175
|
+
collect(await exported({ record, context }));
|
|
1176
|
+
if (pendingBytes >= ${FLUSH_BYTES} || sinceFlush >= ${FLUSH_RECORDS}) await flush();
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
await flush();
|
|
1181
|
+
await send(JSON.stringify({ done: { recordsIn: at, rowsOut, logs: captured() } }) + "\\n");
|
|
1182
|
+
} catch (error) {
|
|
1183
|
+
// Whatever was pending is deliberately NOT flushed. The node has failed, so
|
|
1184
|
+
// nothing downstream will read this stage; sending the rows anyway would put
|
|
1185
|
+
// work on the wire that exists only to be discarded, and would make the last
|
|
1186
|
+
// "at" a reader sees larger than the record that actually threw.
|
|
1187
|
+
await send(JSON.stringify({
|
|
1188
|
+
failed: {
|
|
1189
|
+
at,
|
|
1190
|
+
error: error instanceof Error ? \`\${error.name}: \${error.message}\` : String(error),
|
|
1191
|
+
logs: captured(),
|
|
1192
|
+
},
|
|
1193
|
+
}) + "\\n");
|
|
1194
|
+
}
|
|
1195
|
+
`;
|
|
1196
|
+
}
|
|
1197
|
+
/**
|
|
1198
|
+
* Find the function the author exported, or refuse by name.
|
|
1199
|
+
*
|
|
1200
|
+
* Shared verbatim by the module harness and the record harness rather than
|
|
1201
|
+
* copied into each, for the reason {@link JAVASCRIPT_CONSOLE} is shared: two
|
|
1202
|
+
* copies of "which exports are accepted" are two answers, and the one that
|
|
1203
|
+
* drifts is discovered by an author whose perfectly good `export default` works
|
|
1204
|
+
* in one mode and not the other.
|
|
1205
|
+
*
|
|
1206
|
+
* The sentence it throws deliberately does not name a mode. Both modes accept
|
|
1207
|
+
* exactly the same two spellings, and what differs — the argument — is already
|
|
1208
|
+
* on the type the author is writing against.
|
|
1209
|
+
*/
|
|
1210
|
+
function javascriptExportedFunction(moduleUrl) {
|
|
1211
|
+
return `
|
|
572
1212
|
const mod = await import(${JSON.stringify(moduleUrl)});
|
|
573
1213
|
const exported = typeof mod.default === "function"
|
|
574
1214
|
? mod.default
|
|
@@ -583,11 +1223,6 @@ try {
|
|
|
583
1223
|
" Export the function as \`export default\`, or name it \`transform\`."
|
|
584
1224
|
);
|
|
585
1225
|
}
|
|
586
|
-
const rows = await exported({ records, context });
|
|
587
|
-
process.stdout.write(JSON.stringify({ rows: rows ?? [], logs: captured() }));
|
|
588
|
-
} catch (error) {
|
|
589
|
-
${JAVASCRIPT_FAILURE}
|
|
590
|
-
}
|
|
591
1226
|
`;
|
|
592
1227
|
}
|
|
593
1228
|
/**
|
|
@@ -597,7 +1232,7 @@ try {
|
|
|
597
1232
|
* copies of a log cap are two numbers that drift, and the one that drifts is
|
|
598
1233
|
* discovered by a run record nobody can explain.
|
|
599
1234
|
*/
|
|
600
|
-
const
|
|
1235
|
+
const JAVASCRIPT_CONSOLE = `
|
|
601
1236
|
const logs = [];
|
|
602
1237
|
let dropped = 0;
|
|
603
1238
|
const keep = (line) => {
|
|
@@ -614,11 +1249,25 @@ console.debug = write; console.trace = write;
|
|
|
614
1249
|
const captured = () => dropped === 0
|
|
615
1250
|
? logs
|
|
616
1251
|
: logs.concat(["… " + dropped + " more line(s) were logged and dropped: a transform keeps its first ${MAX_LOG_LINES}."]);
|
|
617
|
-
|
|
1252
|
+
`;
|
|
1253
|
+
/**
|
|
1254
|
+
* The whole of stdin, as one string.
|
|
1255
|
+
*
|
|
1256
|
+
* Split out of the prelude when the streaming harness arrived, because that one
|
|
1257
|
+
* must **not** do this: slurping the input is precisely the thing a per-record
|
|
1258
|
+
* run exists to avoid, and a harness that inherited it by sharing a constant
|
|
1259
|
+
* would have held the dataset in the child while the parent was carefully not
|
|
1260
|
+
* holding it in itself. The console capture above is shared by all three
|
|
1261
|
+
* harnesses and this is shared by the two that read a finished batch, which is
|
|
1262
|
+
* the line the split follows.
|
|
1263
|
+
*/
|
|
1264
|
+
const JAVASCRIPT_SLURP_STDIN = `
|
|
618
1265
|
let input = "";
|
|
619
1266
|
process.stdin.setEncoding("utf8");
|
|
620
1267
|
for await (const chunk of process.stdin) input += chunk;
|
|
621
1268
|
`;
|
|
1269
|
+
/** What the two whole-batch harnesses open with: capture the console, read stdin. */
|
|
1270
|
+
const JAVASCRIPT_PRELUDE = `${JAVASCRIPT_CONSOLE}${JAVASCRIPT_SLURP_STDIN}`;
|
|
622
1271
|
/** Unpack the envelope. `context` is frozen one level down — see below. */
|
|
623
1272
|
const JAVASCRIPT_PAYLOAD = `
|
|
624
1273
|
const payload = JSON.parse(input || "{}");
|