mikser-io 11.10.0 → 11.10.2

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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/journal.js +46 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "11.10.0",
3
+ "version": "11.10.2",
4
4
  "files": [
5
5
  "app.js",
6
6
  "index.js",
package/src/journal.js CHANGED
@@ -235,6 +235,25 @@ export async function* useJournal(name, operations, signal) {
235
235
 
236
236
  // Yield returned — caller's for-body completed for this
237
237
  // iteration. Diff and write back if mutated.
238
+ //
239
+ // WHICH MEANS: a consumer that mutates `entry.entity` MUST finish
240
+ // that mutation before its loop body completes. `for await` does
241
+ // this by construction. Mapping over this generator concurrently
242
+ // does NOT — p-map and friends pull the next item as soon as a
243
+ // worker slot frees, which resumes the generator here, before any
244
+ // body has finished. The diff then sees an untouched entity and
245
+ // writes nothing.
246
+ //
247
+ // Not a race that usually works: measured at concurrency 2 and 4,
248
+ // zero of six mutations survived. Every one, silently, with
249
+ // whatever the work cost already spent.
250
+ //
251
+ // Concurrent mapping over the journal is still fine for a plugin
252
+ // that writes somewhere else — mikser-io-vector embeds into an
253
+ // external store and never touches `entity`. A plugin that
254
+ // mutates entities and wants concurrency has to gather first, do
255
+ // the work off the journal, then apply inside a second walk;
256
+ // mikser-io-ocr does exactly that and says why.
238
257
  if (entry.entity != null) {
239
258
  const currentEntity = JSON.stringify(entry.entity)
240
259
  if (currentEntity !== originalEntity) {
@@ -317,5 +336,31 @@ onFinalized(async () => {
317
336
  })
318
337
 
319
338
  onCancelled(async () => {
320
- clearJournal()
339
+ // NOT cleared. A cancelled cycle's entries are not superseded work —
340
+ // "this entity was created and nothing has processed it" is still true
341
+ // after the restart, and the restarted cycle will not re-journal it: the
342
+ // source gate compares checksums, finds the file unchanged, and emits
343
+ // nothing. So deleting these rows orphaned the entity permanently. It sat
344
+ // in the catalog with empty meta, no output, and no journal entry any
345
+ // future cycle would look at, under a green "Mikser completed" — and
346
+ // touching the file was the only way to get it back.
347
+ //
348
+ // Reported against a 4 MB PDF uploaded over WebDAV: the upload landed
349
+ // mid-cycle, the restart deleted the CREATE entry, and the page was never
350
+ // produced. Nothing about it was specific to the plugin that noticed —
351
+ // any consumer doing real work between a journal read and its write-back
352
+ // had the same hole.
353
+ //
354
+ // The asymmetry was the tell: a CRASH leaves these rows and `--resume`
355
+ // continues from them (see the leftover bootstrap above), while a restart
356
+ // deliberately deleted them. Keeping them makes a restart behave like the
357
+ // case the engine already handles.
358
+ //
359
+ // They are cleared by onFinalized when a cycle actually completes, so a
360
+ // run of cancellations carries its backlog forward and the first cycle to
361
+ // finish clears the lot.
362
+ const carried = stmtCount.get().n
363
+ if (carried > 0) {
364
+ useLogger()?.debug('Restart: carrying %d unprocessed journal entries into the next cycle', carried)
365
+ }
321
366
  })