@xeplr/actions 1.1.13 → 1.1.15
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/README.md +7 -2
- package/lib/builtins/db/move.js +3 -1
- package/lib/runner.js +2 -0
- package/lib/uploader/index.js +63 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -184,11 +184,16 @@ await actions.uploader.upload({
|
|
|
184
184
|
columns, // optional [{ name, type }] — declared; skips inference
|
|
185
185
|
batchSize: 5000, firstBatchScanRows: 1000,
|
|
186
186
|
metaStore, mtId1, mtId2, mtId3, mtId4, details,
|
|
187
|
-
errorTable, batchDir, keepBatchFiles, onProgress
|
|
187
|
+
errorTable, batchDir, keepBatchFiles, onProgress,
|
|
188
|
+
signal // optional AbortSignal: stop on request
|
|
188
189
|
})
|
|
189
190
|
// → { movementId, tables: { main, errors }, columns, totalRows, totalBatches, completed, dropped, aborted, durationMs }
|
|
190
191
|
```
|
|
191
192
|
|
|
193
|
+
`onProgress({ rowsRead, batches, rowsWritten, phase? })` fires after each batch is read, with `rowsWritten` from the write queue's own count, so a caller can show both and see whether writing keeps up. After the read, while the queue drains, it fires again each time the written count moves (`phase: 'writing'`), never on a bare timer, so a stuck write goes quiet.
|
|
194
|
+
|
|
195
|
+
`signal` (an `AbortSignal`, passed to db-move as `system.signal`) stops a running movement: the writes still queued are dropped at once (in-flight ones settle), the read stops at the next batch, and it ends with an error whose `code` is `'STOPPED'` (the runner keeps `error.code`), neither a failure nor a success. What it wrote stays, under its `movementId`, for the caller to roll back or keep.
|
|
196
|
+
|
|
192
197
|
Rows are spooled to NDJSON files on disk between source and database. From the first batch it creates, if missing: the target table, `<targetTable>_import_errors` (`movement_id`, `row_num`, `error_description`, `underlying_sql`, `raw_row`, `recorded_at`), a `__xeplr_movement_id__` column, and a unique index on `primaryKeys`. Server dialects also add `__xeplr_id__`; DuckDB does not.
|
|
193
198
|
|
|
194
199
|
Logical types and what each driver creates:
|
|
@@ -310,7 +315,7 @@ node --test test/uploader/reconcile.test.js
|
|
|
310
315
|
|
|
311
316
|
| needs nothing | needs a database |
|
|
312
317
|
|---|---|
|
|
313
|
-
| `test/procedure-call.test.js`, `test/streaming/spool.test.js`, `test/uploader/reconcile.test.js`, `test/formats/csv-gnarly.test.js`, `test/drivers/mysql.test.js`, `test/drivers/mssql.test.js`, `test/drivers/query-columns.test.js`, `test/builtins/email.test.js` (loads the package root, so `@xeplr/db` must be resolvable) | Postgres at `localhost:5435` (`PG_PASSWORD`, default `postgres`; database `xeplr_actions_test`): `test/drivers/postgres.test.js`, `test/builtins/db-push.test.js`, `test/builtins/file-upload.test.js`, `test/uploader/upload.test.js`; plus `@xeplr/db` for `test/uploader/meta-store-knex.test.js` |
|
|
318
|
+
| `test/procedure-call.test.js`, `test/streaming/spool.test.js`, `test/uploader/reconcile.test.js`, `test/write-progress.test.js`, `test/stop-movement.test.js`, `test/formats/csv-gnarly.test.js`, `test/drivers/mysql.test.js`, `test/drivers/mssql.test.js`, `test/drivers/query-columns.test.js`, `test/builtins/email.test.js` (loads the package root, so `@xeplr/db` must be resolvable) | Postgres at `localhost:5435` (`PG_PASSWORD`, default `postgres`; database `xeplr_actions_test`): `test/drivers/postgres.test.js`, `test/builtins/db-push.test.js`, `test/builtins/file-upload.test.js`, `test/uploader/upload.test.js`; plus `@xeplr/db` for `test/uploader/meta-store-knex.test.js` |
|
|
314
319
|
| `test/drivers/duckdb.test.js`, `test/drivers/duckdb-values.test.js` (needs `@duckdb/node-api`) | MySQL `localhost:3306` (`MYSQL_HOST/PORT/USER/PASSWORD`): `test/drivers/mysql-integration.test.js` |
|
|
315
320
|
| | SQL Server `localhost:1433` (`MSSQL_HOST/PORT/USER/PASSWORD`): `test/drivers/mssql-integration.test.js` |
|
|
316
321
|
| | Postgres + MySQL + SQL Server (`PG_*`, `MYSQL_*`, `MSSQL_*`): `test/builtins/db-replication.test.js` |
|
package/lib/builtins/db/move.js
CHANGED
|
@@ -304,7 +304,9 @@ module.exports = {
|
|
|
304
304
|
// Passed through from the caller's `system`, not from `input` — a
|
|
305
305
|
// progress reporter is a function, and an action's input is data that
|
|
306
306
|
// has to survive being stored in a job row.
|
|
307
|
-
onProgress: system.onProgress || null
|
|
307
|
+
onProgress: system.onProgress || null,
|
|
308
|
+
// An AbortSignal from the caller: stop reading, drop the queued writes.
|
|
309
|
+
signal: system.signal || null
|
|
308
310
|
});
|
|
309
311
|
|
|
310
312
|
// Every count upload() has, not just the headline one — "read" and
|
package/lib/runner.js
CHANGED
|
@@ -109,6 +109,8 @@ function finish(start, status, output, err) {
|
|
|
109
109
|
if (err.stack) e.stack = err.stack;
|
|
110
110
|
if (err.details) e.details = err.details;
|
|
111
111
|
if (err.actionName) e.actionName = err.actionName;
|
|
112
|
+
// Kept, so a caller can tell a movement STOPPED on request from one that failed.
|
|
113
|
+
if (err.code) e.code = err.code;
|
|
112
114
|
}
|
|
113
115
|
return { status: status, output: output, error: e, durationMs: Date.now() - start };
|
|
114
116
|
}
|
package/lib/uploader/index.js
CHANGED
|
@@ -77,6 +77,14 @@ async function upload(opts) {
|
|
|
77
77
|
var spoolResult = null;
|
|
78
78
|
var runDir = null;
|
|
79
79
|
|
|
80
|
+
// STOPPING ON REQUEST (opts.signal, an AbortSignal). The writes still queued
|
|
81
|
+
// are dropped the moment it fires — in-flight ones settle — and the read
|
|
82
|
+
// stops at the next batch. What was written stays, identified by
|
|
83
|
+
// movementId, for the caller to roll back or keep.
|
|
84
|
+
if (opts.signal && opts.signal.aborted) throw stoppedError();
|
|
85
|
+
var onStop = opts.signal ? function() { opts.queue.abort(opts.movementId, 'stopped'); } : null;
|
|
86
|
+
if (onStop) opts.signal.addEventListener('abort', onStop, { once: true });
|
|
87
|
+
|
|
80
88
|
try {
|
|
81
89
|
var summary = await spool(opts.source, {
|
|
82
90
|
runId: opts.movementId,
|
|
@@ -131,6 +139,7 @@ async function upload(opts) {
|
|
|
131
139
|
// in turn awaits spool's next batch — the whole pipeline throttles
|
|
132
140
|
// correctly, but disk (not memory) is the buffer.
|
|
133
141
|
onBatch: async function(rows, meta) {
|
|
142
|
+
if (opts.signal && opts.signal.aborted) throw stoppedError();
|
|
134
143
|
if (!columns) throw new Error('uploader: onBatch before beforeAll — spool contract violated');
|
|
135
144
|
for (var off = 0; off < rows.length; off += effectiveBatchSize) {
|
|
136
145
|
var chunk = rows.slice(off, off + effectiveBatchSize);
|
|
@@ -154,9 +163,12 @@ async function upload(opts) {
|
|
|
154
163
|
// No total, because for a query source there isn't one to know
|
|
155
164
|
// without running it twice. A caller that wants a percentage has to
|
|
156
165
|
// supply its own denominator.
|
|
166
|
+
//
|
|
167
|
+
// rowsWritten is what HAS landed, from the write queue's own count,
|
|
168
|
+
// so a caller can show both and see whether writing keeps up.
|
|
157
169
|
if (opts.onProgress) {
|
|
158
170
|
try {
|
|
159
|
-
opts.onProgress({ rowsRead: rowsSeen, batches: meta ? meta.batchIndex + 1 : totalBatches });
|
|
171
|
+
opts.onProgress({ rowsRead: rowsSeen, batches: meta ? meta.batchIndex + 1 : totalBatches, rowsWritten: writtenSoFar(opts) });
|
|
160
172
|
} catch (e) { /* a reporting failure must not fail the movement */ }
|
|
161
173
|
}
|
|
162
174
|
}
|
|
@@ -191,7 +203,21 @@ async function upload(opts) {
|
|
|
191
203
|
}
|
|
192
204
|
|
|
193
205
|
// Wait for the DB writes to finish (spool has finished feeding the queue).
|
|
194
|
-
|
|
206
|
+
//
|
|
207
|
+
// The read is over but the queue can take minutes more to drain, and
|
|
208
|
+
// nothing reported progress while it did — so a caller watching for
|
|
209
|
+
// progress saw a busy movement go silent. Reported here whenever the
|
|
210
|
+
// written count MOVES (never on a bare timer: a stuck write must go quiet).
|
|
211
|
+
var drainWatch = opts.onProgress ? watchWrites(opts, function() {
|
|
212
|
+
return { rowsRead: rowsSeen, batches: totalBatches, rowsWritten: writtenSoFar(opts), phase: 'writing' };
|
|
213
|
+
}) : null;
|
|
214
|
+
try {
|
|
215
|
+
await opts.queue.drain();
|
|
216
|
+
} finally {
|
|
217
|
+
if (drainWatch) drainWatch.stop();
|
|
218
|
+
}
|
|
219
|
+
// Stopped while the queue drained: not a finished movement.
|
|
220
|
+
if (opts.signal && opts.signal.aborted) throw stoppedError();
|
|
195
221
|
|
|
196
222
|
var stats = opts.queue.stats(opts.movementId) || {};
|
|
197
223
|
var status = stats.aborted ? 'aborted' : 'completed';
|
|
@@ -221,9 +247,17 @@ async function upload(opts) {
|
|
|
221
247
|
try { await fsp.rm(runDir, { recursive: true, force: true }); } catch (_) {}
|
|
222
248
|
}
|
|
223
249
|
if (!poolIsExternal && pool) await driver.close(pool);
|
|
250
|
+
if (onStop) opts.signal.removeEventListener('abort', onStop);
|
|
224
251
|
}
|
|
225
252
|
}
|
|
226
253
|
|
|
254
|
+
/** The error a stopped movement ends with: code 'STOPPED', so a caller can tell it from a failure. */
|
|
255
|
+
function stoppedError() {
|
|
256
|
+
var err = new Error('Stopped by request');
|
|
257
|
+
err.code = 'STOPPED';
|
|
258
|
+
return err;
|
|
259
|
+
}
|
|
260
|
+
|
|
227
261
|
// Rollback a movement — drop from main + error tables, clear meta row.
|
|
228
262
|
// Graceful queue abort first (in-flight SQLs allowed to settle).
|
|
229
263
|
//
|
|
@@ -332,6 +366,31 @@ async function runBootstrap(driver, pool, targetTable, columns, primaryKeys) {
|
|
|
332
366
|
return reconciled;
|
|
333
367
|
}
|
|
334
368
|
|
|
369
|
+
/** Rows this movement has written so far, from the write queue's own count. */
|
|
370
|
+
function writtenSoFar(opts) {
|
|
371
|
+
try {
|
|
372
|
+
var st = opts.queue && opts.queue.stats ? opts.queue.stats(opts.movementId) : null;
|
|
373
|
+
return st && st.completed != null ? st.completed : null;
|
|
374
|
+
} catch (e) { return null; }
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* While the queue drains, report progress each time the written count moves.
|
|
379
|
+
* Checked every `everyMs`; a count that has not moved reports nothing, so a
|
|
380
|
+
* stuck write goes quiet rather than looking alive.
|
|
381
|
+
*/
|
|
382
|
+
function watchWrites(opts, build, everyMs) {
|
|
383
|
+
var last = writtenSoFar(opts);
|
|
384
|
+
var timer = setInterval(function() {
|
|
385
|
+
var now = writtenSoFar(opts);
|
|
386
|
+
if (now == null || now === last) return;
|
|
387
|
+
last = now;
|
|
388
|
+
try { opts.onProgress(build()); } catch (e) { /* reporting must not fail the movement */ }
|
|
389
|
+
}, everyMs || 2000);
|
|
390
|
+
if (timer.unref) timer.unref();
|
|
391
|
+
return { stop: function() { clearInterval(timer); } };
|
|
392
|
+
}
|
|
393
|
+
|
|
335
394
|
async function enqueueBatch(opts, rows, columns, batchRef) {
|
|
336
395
|
var driver = opts.driver;
|
|
337
396
|
var target = opts.targetTable;
|
|
@@ -365,6 +424,8 @@ var { inferColumnType } = require('./infer');
|
|
|
365
424
|
|
|
366
425
|
module.exports = {
|
|
367
426
|
upload: upload,
|
|
427
|
+
watchWrites: watchWrites,
|
|
428
|
+
stoppedError: stoppedError,
|
|
368
429
|
rollback: rollback,
|
|
369
430
|
inferColumns: inferColumns,
|
|
370
431
|
inferColumnType: inferColumnType,
|
package/package.json
CHANGED