harper 5.3.3 → 5.3.4

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 (59) hide show
  1. package/dist/resources/DatabaseTransaction.d.ts +5 -1
  2. package/dist/resources/DatabaseTransaction.js +21 -5
  3. package/dist/resources/DatabaseTransaction.js.map +1 -1
  4. package/dist/resources/LMDBTransaction.d.ts +1 -1
  5. package/dist/resources/LMDBTransaction.js +4 -3
  6. package/dist/resources/LMDBTransaction.js.map +1 -1
  7. package/dist/resources/Table.js +85 -36
  8. package/dist/resources/Table.js.map +1 -1
  9. package/dist/server/DurableSubscriptionsSession.js +4 -7
  10. package/dist/server/DurableSubscriptionsSession.js.map +1 -1
  11. package/dist/server/mqtt.js +21 -9
  12. package/dist/server/mqtt.js.map +1 -1
  13. package/dist/server/serverHelpers/contentTypes.js +8 -2
  14. package/dist/server/serverHelpers/contentTypes.js.map +1 -1
  15. package/dist/server/serverHelpers/sharedMessageEncoding.js +3 -2
  16. package/dist/server/serverHelpers/sharedMessageEncoding.js.map +1 -1
  17. package/package.json +1 -1
  18. package/resources/DESIGN.md +6 -2
  19. package/resources/DatabaseTransaction.ts +25 -5
  20. package/resources/LMDBTransaction.ts +4 -3
  21. package/resources/Table.ts +99 -42
  22. package/server/DESIGN.md +7 -1
  23. package/server/DurableSubscriptionsSession.ts +4 -7
  24. package/server/mqtt.ts +18 -10
  25. package/server/serverHelpers/contentTypes.ts +8 -1
  26. package/server/serverHelpers/sharedMessageEncoding.ts +3 -2
  27. package/studio/web/assets/{Chat-B-Rd-ft9.js → Chat-DjZNWwwA.js} +1 -1
  28. package/studio/web/assets/{FloatingChat-CFypO7kh.js → FloatingChat-BWfKK_RX.js} +3 -3
  29. package/studio/web/assets/{SearchDialog-BRIyPiBl.js → SearchDialog-CdlNxWef.js} +1 -1
  30. package/studio/web/assets/{apiToken-BraYnzvC.js → apiToken-DyI8IwU3.js} +1 -1
  31. package/studio/web/assets/{applications-XCds8-ZP.js → applications-DgGopKJq.js} +1 -1
  32. package/studio/web/assets/{cssMode-BY1w91l8.js → cssMode-4ddO86ey.js} +1 -1
  33. package/studio/web/assets/{editor-D5mpVs1b.js → editor-DATuu7_v.js} +1 -1
  34. package/studio/web/assets/{html-DiXKiahz.js → html-c4OKnEF1.js} +1 -1
  35. package/studio/web/assets/{htmlMode-DitVJ707.js → htmlMode-yXjqUsW9.js} +1 -1
  36. package/studio/web/assets/{index-D8kyF6dt.js → index-CIK9Nhgo.js} +5 -5
  37. package/studio/web/assets/{index.lazy-DgbjUxLo.js → index.lazy-DQpKL_QQ.js} +1 -1
  38. package/studio/web/assets/{javascript-BBVXeMDm.js → javascript-CP1WPkLu.js} +1 -1
  39. package/studio/web/assets/{jsonMode-D85_j2oD.js → jsonMode-BWsIHqbb.js} +1 -1
  40. package/studio/web/assets/{languageServices-U3mWhpRL.js → languageServices-Ca72n4Ql.js} +1 -1
  41. package/studio/web/assets/{lspLanguageFeatures-CoymyM3c.js → lspLanguageFeatures-BQdsSDeh.js} +1 -1
  42. package/studio/web/assets/{notifications-CPgEC6ZP.js → notifications-BYgO5-yk.js} +1 -1
  43. package/studio/web/assets/{notifications-D_VqhHnx.js → notifications-DsGk-TnW.js} +1 -1
  44. package/studio/web/assets/{operations-D8aaKYty.js → operations-Dy69AlTr.js} +1 -1
  45. package/studio/web/assets/{profile-DM5E6Xls.js → profile-DzQvoYBF.js} +1 -1
  46. package/studio/web/assets/{regions-WFvXqryO.js → regions-Sv_IN7XA.js} +1 -1
  47. package/studio/web/assets/{register-B7DvfAGj.js → register-AM2_79FO.js} +2 -2
  48. package/studio/web/assets/{request-ChoMERRk.js → request-BJj8dbdU.js} +1 -1
  49. package/studio/web/assets/{setComponentFile-BHk22Rh6.js → setComponentFile-BCnAnxjR.js} +1 -1
  50. package/studio/web/assets/{setup-wNdpTrVH.js → setup-Ca4RpPzm.js} +2 -2
  51. package/studio/web/assets/{status-CYoonm9V.js → status-D05eZ5pQ.js} +1 -1
  52. package/studio/web/assets/{toggleHighContrast-CKEm-P4K.js → toggleHighContrast-JRkLfyfb.js} +1 -1
  53. package/studio/web/assets/{tsMode-Dv35qHmn.js → tsMode-C_-eSRVR.js} +1 -1
  54. package/studio/web/assets/{typescript-Bly1EW8o.js → typescript-BYXigM_n.js} +1 -1
  55. package/studio/web/assets/{useEntityRestURL-Cg3P0MOb.js → useEntityRestURL-HHmjsRm9.js} +1 -1
  56. package/studio/web/assets/{workers-DVtjrvUa.js → workers-DY0ka4Y7.js} +1 -1
  57. package/studio/web/assets/{xml-DjAPbgLN.js → xml-CdGINpmN.js} +1 -1
  58. package/studio/web/assets/{yaml-oTUyoJy1.js → yaml-DYeF07_w.js} +1 -1
  59. package/studio/web/index.html +1 -1
@@ -153,7 +153,7 @@ Consequences worth knowing:
153
153
  branches and, when a RocksDB record version differs from its log key, the record's own audit head.
154
154
  Under stage 2 the stored reference field can be renamed; until then, "fixing" it to the record
155
155
  version silently unaddresses the entry it points at.
156
- - **Retried duplicates use committed identity before audit I/O ([#3361](https://github.com/HarperFast/harper/issues/3361)).** `Table._writeUpdate` recognizes an implicit head only when origin, version and received log key match and no explicit refs exist; `replicatedRetryDuplicate.test.js` covers real conflicts, including multiple writes to the same key.
156
+ - **Replicated duplicates use committed identity before audit I/O ([#3361](https://github.com/HarperFast/harper/issues/3361), [#3467](https://github.com/HarperFast/harper/issues/3467)).** `Table._writeUpdate` recognizes an implicit head only when origin, version and received log key match and no explicit refs exist, on first delivery and on retry alike. Identity comes from the commit base unless another write to the key in the transaction has saved without being skipped in the current retry round (a retry re-saves writes in order, so a later sibling's flags are stale until it re-stages); then it is read from the committed record, outside the transaction, because a publish or relocate saves when it is added and restamps the base, before or after this write, which would make a write that never applied look like its own head. The out-of-order ref check reads its refs from the same record, and a source-applied relocate that ties with the committed head is skipped too: re-applied, it would restamp the head, which can be its own transaction's put. The head's own log entry is not required: a base-copied record has none, and looking for it costs an end-of-log scan and, on a miss, re-appends the duplicate for every peer to re-receive. On retry, standalone keyed checks are shielded and walk skips require known committed-origin provenance; an ambiguous chain can double-apply. Fresh-delivery orphan handling remains out of scope pending native conflict-before-log validation (#3253). A retry's pinned log watermark can still hide a duplicate flushed afterward. Retained-read-snapshot replay clears audit flags for writes staged on a never-submitted handle. `replicatedRetryDuplicate.test.js` covers real conflicts, including multiple writes to the same key, same-key restamps, terminal-abort orphans, committed controls, a retried same-key batch whose duplicate survives only in audit history, and a retry whose own entry is on another handle; `relayedHeadTieDuplicate.test.js` covers a relayed duplicate of a base-copied record, a put before or after a same-key publish or relocate, a competing delivery committing while a publish is staged, a log key that differs from the version, and a re-delivered non-resident relocate.
157
157
  - **Crash replay uses both.** `replayLogs` replays each write at its stored `version`; stamping a
158
158
  replayed record at its log key would move its version forward and make a later legitimate write
159
159
  look stale. Its transactions follow native commits: one ends at a commit's last-entry `endTxn`
@@ -387,7 +387,7 @@ Opt-in deflate compression for file-backed blobs (harper#2443) has three load-be
387
387
 
388
388
  **Extending the budget for one known-long write:** `DatabaseTransaction.timeoutBudget` is a per-transaction RocksDB floor applied whenever the transaction is re-armed (initial reads, writes, and active multi-store-chain propagation); the effective timeout is `Math.max(txnExpiration, timeoutBudget)`. This makes the budget sticky across a write's pre-commit existing-entry read and later writes, while never shortening a larger global `STORAGE_MAXTRANSACTIONOPENTIME`; RocksDB links added for another store inherit the same floor. Reads after a pending write do not re-arm the transaction: that preserves the idle-limit invariant for orphaned write-holding requests. Also, `resources/transaction.ts`'s `transaction(callback)` (no explicit context) joins whatever transaction is already open on the ambient AsyncLocalStorage context rather than guaranteeing a fresh one. `components/deploymentRecorder.ts`'s `withIsolatedTransaction` builds a new context from only the ambient audit/session fields — not the request `signal`, so a deploy client that stops waiting cannot cancel the tracking writes and strand an `hdb_deployment` row mid-phase (see the disconnect note below) — and every recorder write commits independently without inheriting transaction controls. It uses the sticky budget to give `ingestPayload`'s blob-gated writes a size-appropriate limit instead of the generic default, while coalesced progress flushes are drained and suppressed until ingest settles to avoid same-row transaction conflicts. The ingest helper deliberately floors the shared `deployment_timeout` at ten minutes because `0` means “poll once” for peer waits; consequently an ingest can pin its system-database snapshot for that minimum. Known gap (harper#2057): the extension only reaches RocksDB transactions — on `HARPER_STORAGE_ENGINE=lmdb`, `Table.txnForContext()` chains a separate `LMDBTransaction` (`txn.next`) with its own independently-reset timeout that the LMDB engine's monitor tracks instead. **A commit's conflict retries have their own deadline, separate from the open-transaction limit** (issue #2450). rocksdb-js ≥2.8 wakes a commit parked on another transaction's write intent after `ROCKSDB_JS_PARK_TIMEOUT_MS` (5s) and returns `RETRY_NOW_VALUE` even when the holder never releases, so a wedged intent presents as a stream of transient conflicts rather than one hung commit — and the `MAX_RETRIES` cap alone then keeps the request pending for ~40 park timeouts, minutes past the configured queue limit. `DatabaseTransaction.commitStartedAt` is stamped on the **chain root** (or on the `ImmediateTransaction` instance while a single handle is active, migrating onto the per-write native `RocksTransaction` handle when concurrent commits overlap or a late immediate commit runs on a closed request scope) at its first native submission and read at both retry decisions (the coordinated `RETRY_NOW_VALUE` resolve path and the `ERR_BUSY`/`ERR_TRY_AGAIN` rejection path); past `Math.max(STORAGE_MAXTRANSACTIONQUEUETIME, timeoutBudget)` an unlogged commit takes the existing `abortChainAfterRetries()` cleanup and throws a 503 `TransactionCommitConflictTimeoutError`. One clock per _logical_ commit, deliberately not per attempt: the per-attempt clock is `trackOutstandingCommit()`'s, which measures native liveness and drives `checkOverloaded()`'s thread-wide shedding, so back-dating it would let one uncapped `sourceApply` retry shed every unrelated request on the thread. The clock is released through the promise `commit()` returns (which settles only after the chained stores' commits), so a reused transaction's next batch starts fresh. `retryable` is true only on a chain root that has rotated through no mid-scope commit — anywhere else an earlier store already wrote durable audit entries and ran its hooks that a replayed request would repeat. `sourceApply` is exempt, as it is from the attempt cap, for the harper-pro#348 divergence reason above.
389
389
 
390
- - **Once a transaction has a durable transaction-log entry, it is never aborted on retry** (harper#3236). In rocksdb-js, `Transaction::Commit` flushes staged `TransactionLog` batches (`executeLogWork`, assigning `committedPosition`) _before_ `txn->Commit()` checks optimistic conflicts; on conflict the handle resets onto a fresh snapshot while keeping `committedPosition > 0` so the log is write-once (rocksdb-js#668), and aborting it throws `ERR_TRANSACTION_ABANDONED` and orphans a durable log entry that live reads miss while subscriptions and `replayLogs()` apply.
390
+ - **Once a transaction has a durable transaction-log entry, it is never aborted on retry** (harper#3236). In rocksdb-js, `Transaction::Commit` flushes staged `TransactionLog` batches (`executeLogWork`, assigning `committedPosition`) _before_ `txn->Commit()` checks optimistic conflicts; on conflict the handle resets onto a fresh snapshot while keeping `committedPosition > 0` so the log is write-once (rocksdb-js#668). Aborting then throws `ERR_TRANSACTION_ABANDONED` and publishes a durable audit entry even though the primary record never changed; keyed audit reads, subscriptions and `replayLogs()` can observe that orphan, so audit replay may diverge from the primary store. Fresh-delivery handling of this orphan remains out of scope pending native conflict-before-log validation (harper#3253).
391
391
  - **Per-handle invariant:** once a handle's first native commit returns a conflict (`hasDurableLog(transaction)` = `isRetry && hasStagedLog`, evaluated per native handle so an unlogged link in a multi-store chain still respects `suspendDatabaseCommits` and retry bounds), `DatabaseTransaction` treats it like `sourceApply`: the caller stays awaiting an uncapped retry (so the #2450 deadline and 40-attempt cap apply only before the first log flush or on `audit: false` tables), record-lock lease-expiry aborts are skipped while post-expiry/release native unlock is deferred up to the conflict budget, `deferForCommitInFlight` reclaims separate read snapshots without poisoning or logging the unknown-outcome restart error, and `ImmediateTransaction` stages only writes whose `save()` has been invoked while isolating per-commit state on the native handle when concurrent commits overlap.
392
392
  - **Operational cost:** while a logged retry waits on a conflicting write intent (which `abortDueToTimeout` clears after ~2 ticks of `STORAGE_MAXTRANSACTIONOPENTIME` if held by an unsubmitted open transaction), its position in `uncommittedPositions` pins the database's `'local'` transaction-log read watermark for live subscriptions and replication (reported by `noteOverdueLoggedCommit`), the caller awaits convergence without a 503-pending or detached background commit, and `waitForDrain()` holds across retry backoff delays up to its 120s deadline.
393
393
  - **Native follow-up (harper#3253):** validating conflicts before `executeLogWork` so a conflicting commit never flushes a log entry or pins the read watermark, exposing `committedPosition`, and reconciling non-conflict or post-drain aborts live in rocksdb-js.
@@ -405,6 +405,10 @@ Opt-in deflate compression for file-backed blobs (harper#2443) has three load-be
405
405
 
406
406
  RocksDB 2.9.0 binds ranges to their supplied transaction and invalidates them when that transaction ends. An abandoned range is still bounded by the idle monitor. Resuming after its snapshot has been released throws an `ReadSnapshotExpiredError` (503) before accessing the native iterator; retry only the read, without replaying previously committed writes. A poisoned write-bearing transaction retains its existing 422 error and rollback behavior. Early iterator return remains a cleanup operation, including after expiration.
407
407
 
408
+ ## A read reference belongs to the handle acquisition it was taken on (`DatabaseTransaction.useReadTxn`/`doneReadTxn`, harper#3308)
409
+
410
+ `readTxnsUsed` is never positive without a read handle, and `doneReadTxn(generation)` ignores a return from an earlier acquisition (`readTxnGeneration`, bumped per acquisition, because lmdb-js renews the same `Txn` object for a new snapshot). Enforced by `unitTests/resources/detachedReadRefs.test.js`.
411
+
408
412
  ## Replicated apply failure listeners (`resources/replicatedApplyFailure.ts`)
409
413
 
410
414
  `registerReplicatedApplyFailureListener(database, listener)` and
@@ -606,6 +606,7 @@ export type TransactionWrite = {
606
606
  stagedIn?: DatabaseTransaction;
607
607
  // the preceding write to the same store and key in this transaction, if any (linked in addWrite)
608
608
  priorWrite?: TransactionWrite;
609
+ keyId?: unknown;
609
610
  // set only by a write that BOTH reads priorStagedWrite() and publishes stagedEntry; addWrite orders
610
611
  // those against earlier same-key writes. A write that does one or neither would be ordered against a
611
612
  // basis it cannot consume.
@@ -646,6 +647,10 @@ export type TransactionWrite = {
646
647
  // save() must reload that base through the committing transaction's snapshot
647
648
  reloadCommitBase?: boolean;
648
649
  stagedOn?: RocksTransaction;
650
+ // the handle and retry round this write last ran its commit handler in: a retry re-saves writes in order,
651
+ // so a sibling's saved/skipped flags describe the current snapshot only once it has staged in this round
652
+ committedOn?: RocksTransaction;
653
+ committedRound?: number;
649
654
  baseReadTxn?: any;
650
655
  writeGeneration?: WriteGeneration;
651
656
  instanceClosed?: boolean;
@@ -764,6 +769,10 @@ export class DatabaseTransaction implements Transaction {
764
769
  readTxn: ReadTransaction;
765
770
  readTxnRefCount: number;
766
771
  readTxnsUsed: number;
772
+ // Bumped each time this instance acquires a read handle, so a reference can be returned to the
773
+ // acquisition it came from (doneReadTxn()). Not the handle's identity: lmdb-js hands back the same
774
+ // read Txn object for a new snapshot.
775
+ readTxnGeneration = 0;
767
776
  timeout: number;
768
777
  // Write recency, tracked separately from `timeout`: set only by addWrite, never by a read. `timeout`
769
778
  // is re-armed by reads too (on a link with no pending writes of its own, see the fast path in
@@ -1006,6 +1015,7 @@ export class DatabaseTransaction implements Transaction {
1006
1015
  readTransactionOwners.set(transaction, this);
1007
1016
  this.rangeReadActive = false;
1008
1017
  this.readTxnsUsed = 1;
1018
+ this.readTxnGeneration++;
1009
1019
  this.baseReadRefConsumed = false;
1010
1020
  this.handleOpenedAt = performance.now();
1011
1021
  }
@@ -1106,12 +1116,12 @@ export class DatabaseTransaction implements Transaction {
1106
1116
  // the array it never got threw (issue #2222). Capturing an Error per read that nothing can dump
1107
1117
  // is not worth doing either, so those reads stay untraced.
1108
1118
  if (DEBUG_LONG_TXNS && this.stackTraces) this.stackTraces.push(new StartedTransaction());
1109
- this.readTxnsUsed++;
1119
+ if (readTxn) this.readTxnsUsed++;
1110
1120
  return readTxn;
1111
1121
  }
1112
1122
 
1113
- doneReadTxn() {
1114
- if (!this.transaction) return;
1123
+ doneReadTxn(generation = this.readTxnGeneration) {
1124
+ if (!this.transaction || generation !== this.readTxnGeneration) return;
1115
1125
  if (--this.readTxnsUsed === 0) {
1116
1126
  // The native handle was only being held open for the read iterators: any writes staged
1117
1127
  // through it were already committed at commit() time by replaying them onto a fresh
@@ -1191,7 +1201,7 @@ export class DatabaseTransaction implements Transaction {
1191
1201
  if (operation.key === undefined) return;
1192
1202
  let writesForStore = (this.writesByKey ??= new Map()).get(operation.store);
1193
1203
  if (!writesForStore) this.writesByKey.set(operation.store, (writesForStore = new Map()));
1194
- const keyId = writeKeyId(operation.key);
1204
+ const keyId = (operation.keyId = writeKeyId(operation.key));
1195
1205
  const priorWrite = writesForStore.get(keyId);
1196
1206
  if (priorWrite) operation.priorWrite = priorWrite;
1197
1207
  writesForStore.set(keyId, operation);
@@ -2093,6 +2103,8 @@ export class DatabaseTransaction implements Transaction {
2093
2103
  if (shouldStampStagedOn) operation.stagedOn = transaction;
2094
2104
  if (lockHandle || this.recordLocks) operation.trackRecordVersion = true;
2095
2105
  if (operation.trackRecordVersion) operation.recordVersionApplied = false;
2106
+ operation.committedOn = transaction;
2107
+ operation.committedRound = txnRetries;
2096
2108
  try {
2097
2109
  completion = operation.commit(
2098
2110
  writeVersion,
@@ -2561,10 +2573,18 @@ export class DatabaseTransaction implements Transaction {
2561
2573
  if (transaction) this.releaseActiveCommitHandle(transaction);
2562
2574
  this.trackActiveCommitHandle(replayTransaction);
2563
2575
  if (prevStagedTxnTime) this.setHandleStagedTxnTime(replayTransaction, prevStagedTxnTime);
2576
+ const abandoned = transaction;
2577
+ const abandonedLogged = hasDurableLog(abandoned);
2564
2578
  transaction = replayTransaction;
2565
2579
  try {
2566
2580
  for (const operation of this.writes) {
2567
2581
  if (!operation?.saved) continue;
2582
+ // A non-durable abandoned handle left no readable audit entry; a durable one may
2583
+ // have published its log and must retain the retry shield.
2584
+ if (operation.appendedTo === abandoned && !abandonedLogged) {
2585
+ operation.appendedAuditEntry = false;
2586
+ operation.appendedTo = undefined;
2587
+ }
2568
2588
  this.save(operation, replayTransaction, true, options);
2569
2589
  }
2570
2590
  } catch (error) {
@@ -3218,7 +3238,7 @@ export class DatabaseTransaction implements Transaction {
3218
3238
  this.baseReadRefConsumed = true;
3219
3239
  }
3220
3240
  } else {
3221
- while (this.readTxnsUsed > 0) this.doneReadTxn(); // release the read snapshot when we abort, we assume we don't need it
3241
+ while (this.transaction && this.readTxnsUsed > 0) this.doneReadTxn(); // release the read snapshot when we abort, we assume we don't need it
3222
3242
  }
3223
3243
  // A write-only transaction never took a read reference (getReadTxn was never called), so the
3224
3244
  // loop above releases nothing even though save() created a native handle. Keep an iterator's
@@ -70,6 +70,7 @@ export class LMDBTransaction extends DatabaseTransaction {
70
70
  this.readTxn = (this.db as any).useReadTransaction();
71
71
 
72
72
  this.readTxnsUsed = 1;
73
+ this.readTxnGeneration++;
73
74
  this.baseReadRefConsumed = false;
74
75
  if ((this.readTxn as any).openTimer) (this.readTxn as any).openTimer = 0;
75
76
  trackedTxns.add(this as any);
@@ -89,8 +90,8 @@ export class LMDBTransaction extends DatabaseTransaction {
89
90
  return this.readTxn;
90
91
  }
91
92
 
92
- doneReadTxn() {
93
- if (!this.readTxn) return;
93
+ doneReadTxn(generation = this.readTxnGeneration) {
94
+ if (!this.readTxn || generation !== this.readTxnGeneration) return;
94
95
  if (this.readTxn instanceof RocksTransaction) {
95
96
  // TODO: Implement this for RocksDB
96
97
  } else {
@@ -434,7 +435,7 @@ export class LMDBTransaction extends DatabaseTransaction {
434
435
  this.baseReadRefConsumed = true;
435
436
  }
436
437
  } else {
437
- while (this.readTxnsUsed > 0) this.doneReadTxn(); // release the read snapshot when we abort, we assume we don't need it
438
+ while (this.readTxn && this.readTxnsUsed > 0) this.doneReadTxn(); // release the read snapshot when we abort, we assume we don't need it
438
439
  }
439
440
  this.open = TRANSACTION_STATE.CLOSED;
440
441
  this.drainCompletions();
@@ -261,6 +261,8 @@ const MAX_CONCURRENT_LMDB_HISTORY_REMOVALS = 1000;
261
261
  // Each evict otherwise pays a full transaction commit, so batching amortizes that cost. LMDB already
262
262
  // coalesces async writes per event turn (eventTurnBatching), so it keeps the per-record path.
263
263
  const EVICTION_BATCH_SIZE = 100;
264
+ const COMMITTED_READ = { uncachedRead: true };
265
+ const UNRESOLVED = Symbol('unresolved');
264
266
  // Cap on eviction-batch commits in flight at once, so commit I/O overlaps scan/staging without
265
267
  // letting an unbounded number of open transactions (and their snapshots) accumulate.
266
268
  const MAX_INFLIGHT_EVICTION_BATCHES = 4;
@@ -3796,10 +3798,23 @@ export function makeTable(options): TableResourceClass {
3796
3798
  (this.constructor as any).source?.relocate && !(context as any)?.source
3797
3799
  ? (this.constructor as any).source.relocate.bind((this.constructor as any).source, id, undefined, context)
3798
3800
  : undefined,
3799
- commit: (txnTime, existingEntry, _retry, transaction: any) => {
3801
+ commit: (txnTime, existingEntry, _retry, transaction: any, committedBy?: DatabaseTransaction) => {
3800
3802
  const txnLogKey =
3801
3803
  isRocksDB && options?.version != null ? (transaction?.getTimestamp?.() ?? txnTime) : txnTime;
3802
- if (precedesExistingVersion(txnTime, existingEntry, options?.nodeId) < 0) return;
3804
+ write.skipped = false;
3805
+ const precedesExisting = precedesExistingVersion(txnTime, existingEntry, options?.nodeId);
3806
+ if (precedesExisting < 0) return;
3807
+ if (
3808
+ // a re-delivered relocate would restamp the committed head, which can be another write of its transaction
3809
+ precedesExisting === 0 &&
3810
+ committedBy?.sourceApply &&
3811
+ isRocksDB &&
3812
+ txnLogKey === txnTime &&
3813
+ isHeadIdentity(identityBase(write, existingEntry, committedBy), txnTime, options?.nodeId)
3814
+ ) {
3815
+ write.skipped = true;
3816
+ return;
3817
+ }
3803
3818
  const residency = TableResource.getResidencyRecord(options.residencyId);
3804
3819
  let metadata = 0;
3805
3820
  let newRecord = null;
@@ -4648,15 +4663,13 @@ export function makeTable(options): TableResourceClass {
4648
4663
  transaction: any,
4649
4664
  committedBy?: DatabaseTransaction
4650
4665
  ) => {
4651
- // Whether a prior attempt of THIS write appended its own audit entry (sticky, set in
4652
- // save(); log entries are not part of the aborted rocks transaction, so they survive).
4653
- // Only such a write can find its own orphaned entry in the dedup lookups below and must
4654
- // not treat it as "already applied". Per-write on purpose: the transaction-wide retry
4655
- // flag would also suppress dedup for a genuine re-delivered duplicate co-batched with
4656
- // the conflicting write (double-applying it) and for fresh writes staged through a
4657
- // reused transaction whose retries counter is stale. Sticky on purpose: a proxy read
4658
- // from the last attempt's skipped state launders when a recommit round self-skips
4659
- // (walk identity tie against its own staged record) before a fresh-transaction replay.
4666
+ // Whether a prior attempt of THIS write appended its audit entry (sticky, set in
4667
+ // save()). A retry cannot infer that its record mutation committed from an exact log
4668
+ // match alone, so keyed probes stay shielded and walk skips require committed provenance.
4669
+ // Per-write: a transaction-wide retry flag would also suppress dedup for unrelated
4670
+ // deliveries co-batched with the conflict or fresh writes in a reused transaction.
4671
+ // Sticky: a proxy read from the last attempt's skipped state launders when a recommit
4672
+ // round self-skips (walk identity tie against its own staged record) before a replay.
4660
4673
  const stagedOwnAuditEntry = retry && write.appendedAuditEntry === true;
4661
4674
  write.skipped = false; // reset on each retry; cleanup happens after commit if still true
4662
4675
  write.stagedEntry = undefined; // likewise: only set once this round actually stores a record
@@ -4738,15 +4751,18 @@ export function makeTable(options): TableResourceClass {
4738
4751
  let belowAuditFloor = false;
4739
4752
  let dedupVersionCouldBeRetained: (version: number) => boolean;
4740
4753
  if (audit) {
4754
+ let identityRecord: Partial<Entry> | undefined | typeof UNRESOLVED = UNRESOLVED;
4741
4755
  if (
4742
- stagedOwnAuditEntry &&
4743
4756
  committedBy?.sourceApply &&
4744
4757
  isRocksDB &&
4745
4758
  !replaying &&
4746
4759
  precedesExisting === 0 &&
4747
4760
  txnLogKey === txnTime &&
4748
- (existingEntry.nodeId ?? 0) === (options?.nodeId ?? getThisNodeId(auditStore)) &&
4749
- !existingEntry.additionalAuditRefs?.length
4761
+ isHeadIdentity(
4762
+ (identityRecord = identityBase(write, existingEntry, committedBy)),
4763
+ txnTime,
4764
+ options?.nodeId
4765
+ )
4750
4766
  ) {
4751
4767
  write.skipped = true;
4752
4768
  return;
@@ -4764,7 +4780,10 @@ export function makeTable(options): TableResourceClass {
4764
4780
  // === 0 is the identity tie: same version AND same node (the local node is id 0, so an undefined
4765
4781
  // options?.nodeId resolves to the same 0 the ref stored).
4766
4782
  if (
4767
- existingEntry.additionalAuditRefs?.some(
4783
+ (identityRecord === UNRESOLVED
4784
+ ? identityBase(write, existingEntry, committedBy)
4785
+ : identityRecord
4786
+ )?.additionalAuditRefs?.some(
4768
4787
  (ref) =>
4769
4788
  ref.version === txnLogKey &&
4770
4789
  precedesExistingVersion(
@@ -4822,11 +4841,7 @@ export function makeTable(options): TableResourceClass {
4822
4841
  // RocksDB-only: LMDB audit entries are keyed by local audit time, so this lookup doesn't apply
4823
4842
  // there (LMDB keeps the exact unbounded walk). A miss (the keyed lookup can lag a back-to-back re-delivery — #1137)
4824
4843
  // simply falls through to the walk, so this never changes correctness; the additionalAuditRefs
4825
- // check above remains the read-your-writes guard. Never when this write staged in a prior
4826
- // failed attempt: that attempt already appended this write's own audit entry, so the lookup
4827
- // would find it and skip the write as "already applied" when the record was never committed.
4828
- // A recommit of the same transaction survived that skip only because the old write batch
4829
- // still carried the put; a fresh-transaction replay (ERR_TRY_AGAIN) would drop the write.
4844
+ // check above remains the read-your-writes guard. Not on a same-write retry (see stagedOwnAuditEntry).
4830
4845
  if (isRocksDB && !replaying && !stagedOwnAuditEntry && dedupVersionCouldBeRetained(txnLogKey)) {
4831
4846
  const priorAudit = auditStore.get(txnLogKey, tableId, id, options?.nodeId);
4832
4847
  if (
@@ -4878,10 +4893,21 @@ export function makeTable(options): TableResourceClass {
4878
4893
  // `previousNodeId` is never encoded, so absent there means the log is unknown and only the
4879
4894
  // aggregate lookup can resolve a cross-origin predecessor.
4880
4895
  let nodeId = initialAuditHead.nodeId ?? 0;
4896
+ // A retry can load a base restamped by an earlier saved publish/relocate from this
4897
+ // transaction. Those writes do not set stagedEntry, so priorStaged alone cannot
4898
+ // establish that the base is committed.
4899
+ const retryBaseIsCommitted =
4900
+ !priorStaged && !(stagedOwnAuditEntry && hasSavedSameKeySibling(write, committedBy));
4901
+ let walkOriginKnown = retryBaseIsCommitted;
4881
4902
  const succeedingUpdates = []; // record the "future" updates, as we need to apply the updates in reverse order
4882
- const auditRefsToVisit: Array<{ localTime: number; nodeId: number }> = existingEntry.additionalAuditRefs
4883
- ? existingEntry.additionalAuditRefs.map((ref) => ({ localTime: ref.version, nodeId: ref.nodeId }))
4884
- : [];
4903
+ const auditRefsToVisit: Array<{ localTime: number; nodeId: number; originKnown: boolean }> =
4904
+ existingEntry.additionalAuditRefs
4905
+ ? existingEntry.additionalAuditRefs.map((ref) => ({
4906
+ localTime: ref.version,
4907
+ nodeId: ref.nodeId,
4908
+ originKnown: retryBaseIsCommitted,
4909
+ }))
4910
+ : [];
4885
4911
 
4886
4912
  // Out-of-order merges retain every existing branch head; per-origin log keys are not globally ordered.
4887
4913
  if (existingEntry.additionalAuditRefs) {
@@ -4890,13 +4916,13 @@ export function makeTable(options): TableResourceClass {
4890
4916
  }
4891
4917
  }
4892
4918
  let addedAuditRef = false;
4893
- let nextRef: { localTime: number; nodeId: number };
4919
+ let nextRef: { localTime: number; nodeId: number; originKnown: boolean };
4894
4920
  const visitedAuditRefs = new Set<string>();
4895
- const queuePreviousAuditRefs = (auditRecord) => {
4921
+ const queuePreviousAuditRefs = (auditRecord, originKnown: boolean) => {
4896
4922
  const previousRefs = auditRecord.previousAdditionalAuditRefs;
4897
4923
  if (previousRefs) {
4898
4924
  for (const ref of previousRefs) {
4899
- auditRefsToVisit.push({ localTime: ref.version, nodeId: ref.nodeId });
4925
+ auditRefsToVisit.push({ localTime: ref.version, nodeId: ref.nodeId, originKnown });
4900
4926
  logger.debug?.('Adding audit ref from audit record to visit queue', {
4901
4927
  version: ref.version,
4902
4928
  nodeId: ref.nodeId,
@@ -4912,6 +4938,7 @@ export function makeTable(options): TableResourceClass {
4912
4938
  : { txnLogKey: auditRecord.previousVersion, nodeId: auditRecord.previousNodeId };
4913
4939
  localTime = previousHead.txnLogKey;
4914
4940
  nodeId = previousHead.nodeId;
4941
+ walkOriginKnown = walkOriginKnown && previousHead.nodeId != null;
4915
4942
  };
4916
4943
  let walkSteps = 0;
4917
4944
  let auditWalkCapped = false;
@@ -4927,9 +4954,7 @@ export function makeTable(options): TableResourceClass {
4927
4954
  // A re-delivered write whose exact (version, nodeId) is already in the audit log was already
4928
4955
  // applied; drop it rather than re-applying it (double-applying commutative ops) or writing a
4929
4956
  // duplicate audit-only record. Used by the early-out and the depth-cap block below.
4930
- // Never a duplicate for a write that staged in a prior failed attempt: that attempt already
4931
- // appended this write's own audit entry, so the lookup would match it while the record was
4932
- // never committed (see the up-front keyed dedup above).
4957
+ // Not for a same-write retry (see stagedOwnAuditEntry).
4933
4958
  const isReDeliveredDuplicate = () => {
4934
4959
  if (replaying || stagedOwnAuditEntry) return false;
4935
4960
  if (!dedupVersionCouldBeRetained(txnLogKey)) return false; // pre-retention log key — skip the end-of-log scan (best-effort; see above)
@@ -4959,17 +4984,15 @@ export function makeTable(options): TableResourceClass {
4959
4984
  }
4960
4985
  const auditRecord = auditStore.get(localTime, tableId, id, nodeId);
4961
4986
  if (!auditRecord) break;
4962
- queuePreviousAuditRefs(auditRecord);
4987
+ queuePreviousAuditRefs(auditRecord, walkOriginKnown);
4963
4988
  if (
4964
4989
  isRocksDB &&
4965
4990
  !replaying &&
4966
- !stagedOwnAuditEntry &&
4991
+ // on a retry only a link from committed state proves the write committed: an
4992
+ // orphaned log entry can match the identity but is never linked from a committed record
4993
+ (!stagedOwnAuditEntry || walkOriginKnown) &&
4967
4994
  localTime === txnLogKey &&
4968
- precedesExistingVersion(
4969
- txnTime,
4970
- { version: txnTime, localTime: txnLogKey, key: id, nodeId: auditRecord.nodeId },
4971
- options?.nodeId
4972
- ) === 0
4995
+ (auditRecord.nodeId ?? 0) === (options?.nodeId ?? getThisNodeId(auditStore))
4973
4996
  ) {
4974
4997
  write.skipped = true;
4975
4998
  return;
@@ -4987,9 +5010,8 @@ export function makeTable(options): TableResourceClass {
4987
5010
  // Same origin and record version, but a distinct write. Its per-origin log key
4988
5011
  // orders the otherwise non-unique record clock without comparing keys across origins.
4989
5012
  precedesExisting = txnLogKey > localTime ? 1 : -1;
4990
- } else if (replaying || stagedOwnAuditEntry) {
4991
- // The log entry being replayed (or staged by this write's failed attempt) is
4992
- // the write itself, not proof that its primary-store mutation committed.
5013
+ } else if (replaying || (stagedOwnAuditEntry && !walkOriginKnown)) {
5014
+ // replayed, or reached on a retry without committed provenance: not proof it committed
4993
5015
  precedesExisting = 1;
4994
5016
  } else {
4995
5017
  logger.debug?.(
@@ -5075,6 +5097,7 @@ export function makeTable(options): TableResourceClass {
5075
5097
  if (nextRef) {
5076
5098
  localTime = auditedVersion = nextRef.localTime;
5077
5099
  nodeId = nextRef.nodeId;
5100
+ walkOriginKnown = nextRef.originKnown;
5078
5101
  logger.debug?.('Following additional audit ref to continue scanning', { localTime, nodeId });
5079
5102
  }
5080
5103
  } while (nextRef);
@@ -5968,6 +5991,7 @@ export function makeTable(options): TableResourceClass {
5968
5991
  // scans), the read transaction reads against the latest committed data without pinning a
5969
5992
  // consistent snapshot, so the scan doesn't hold a snapshot that blocks compaction.
5970
5993
  const readTxn = txn.useReadTxn(target.snapshot === false);
5994
+ const readTxnGeneration = txn.readTxnGeneration;
5971
5995
  // The explicit row filter participates in query execution: it is pushed into HNSW
5972
5996
  // traversal for vector sorts, applied after conditions otherwise, and checked again after
5973
5997
  // cache/source materialization below. A policy error aborts the query instead of silently
@@ -6110,7 +6134,7 @@ export function makeTable(options): TableResourceClass {
6110
6134
  } finally {
6111
6135
  // We own the iteration here (no results.onDone consumer), so release the read
6112
6136
  // transaction unconditionally — including when the drain throws — or the snapshot leaks.
6113
- txn.doneReadTxn();
6137
+ if (readTxn) txn.doneReadTxn(readTxnGeneration);
6114
6138
  }
6115
6139
  let total: number | null;
6116
6140
  if (wantExact) {
@@ -6150,7 +6174,7 @@ export function makeTable(options): TableResourceClass {
6150
6174
  results.onDone = () => {
6151
6175
  results.onDone = null; // ensure that it isn't called twice
6152
6176
  txn.unregisterReadIterator(results);
6153
- txn.doneReadTxn();
6177
+ if (readTxn) txn.doneReadTxn(readTxnGeneration);
6154
6178
  };
6155
6179
  // Recorded ownership: if the request dies before anything consumes these results, the
6156
6180
  // transaction closes them itself rather than leaving its read snapshot pinned.
@@ -6159,7 +6183,7 @@ export function makeTable(options): TableResourceClass {
6159
6183
  results.getColumns = getColumns;
6160
6184
  return results;
6161
6185
  } catch (error) {
6162
- txn.doneReadTxn();
6186
+ if (readTxn) txn.doneReadTxn(readTxnGeneration);
6163
6187
  throw error;
6164
6188
  }
6165
6189
  }
@@ -9124,6 +9148,39 @@ export function makeTable(options): TableResourceClass {
9124
9148
  if (!(existingEntry?.metadataFlags & INVALIDATED)) return existingEntry;
9125
9149
  }
9126
9150
 
9151
+ // The commit base is the committed record unless another write to the key already saved without being
9152
+ // skipped this round: a publish or relocate saves when it is added and restamps the base without staging it.
9153
+ function identityBase(write: any, existingEntry: Entry, transaction: DatabaseTransaction | undefined) {
9154
+ if (hasSavedSameKeySibling(write, transaction)) return committedEntry(write.key);
9155
+ return existingEntry;
9156
+ }
9157
+
9158
+ function hasSavedSameKeySibling(write: any, transaction: DatabaseTransaction | undefined): boolean {
9159
+ for (let other = transaction?.writesByKey?.get(write.store)?.get(write.keyId); other; other = other.priorWrite) {
9160
+ if (
9161
+ other !== write &&
9162
+ other.saved &&
9163
+ !other.skipped &&
9164
+ other.committedOn === write.committedOn &&
9165
+ other.committedRound === write.committedRound
9166
+ )
9167
+ return true;
9168
+ }
9169
+ return false;
9170
+ }
9171
+
9172
+ function committedEntry(id: Id): Entry | undefined {
9173
+ return primaryStore.getEntry(id, COMMITTED_READ);
9174
+ }
9175
+
9176
+ function isHeadIdentity(entry: Partial<Entry> | undefined, version: number, nodeId?: number): boolean {
9177
+ return (
9178
+ entry?.version === version &&
9179
+ (entry.nodeId ?? 0) === (nodeId ?? getThisNodeId(auditStore)) &&
9180
+ !entry.additionalAuditRefs?.length
9181
+ );
9182
+ }
9183
+
9127
9184
  function precedesExistingVersion(txnTime: number, existingEntry: Partial<Entry>, nodeId?: number): number {
9128
9185
  if (nodeId === undefined) {
9129
9186
  nodeId = getThisNodeId(auditStore);
package/server/DESIGN.md CHANGED
@@ -73,6 +73,11 @@ A request entering `http.ts` does **not** go through Fastify unless no Harper ha
73
73
  > all. Only mutating an object _without_ changing its version can serve stale bytes, and that is
74
74
  > indistinguishable from re-sending the same message.
75
75
 
76
+ > **Pass-through payloads are `Buffer | string`.** A message that carries its own `{ contentType, data }`
77
+ > is delivered as its `data`, but binary data leaves `serializeMessage` as a `Buffer`: on a worker other
78
+ > than the publisher's, the record is decoded from the transaction log and `data` is a plain `Uint8Array`,
79
+ > which mqtt-packet cannot encode. Pinned by `integrationTests/mqtt/opaque-payload-across-workers.test.ts`.
80
+
76
81
  ### Threads
77
82
 
78
83
  | File | Purpose |
@@ -353,10 +358,11 @@ The `@table(cacheControl:)` value is persisted on the primary-key attribute (lik
353
358
 
354
359
  A persisted QoS 1/2 entry is `{ qos, topic, startTime, databaseGeneration? }`, and the record carries the `incarnation` of the connection that owns it (harper#2448). A QoS 0 entry is `{ qos, topic }`: it is session state with no position, resumed live with retain handling 2 and no acknowledgement tracking.
355
360
 
356
- - **Positions are certified, never clocks.** A new subscription starts at its registration watermark (`registeredThrough`), bound to the database generation when one exists, and otherwise at `getNextMonotonicTime()` unbound until the first certified position replaces it. A SUBSCRIBE that replaces a QoS 1/2 topic's subscription continues that topic's position through the checked replay, since replacing must lose nothing; a pruned-history refusal there resets the session as it would at reconnect. A topic's position is `min(subscription.progress(), previousKey of its oldest unacknowledged delivery)`; a transaction's messages share a key, so one unacked message holds the position before its whole transaction, and while a message is still queued the position stays before the last delivered transaction, which that message may share. Live events arrive in commit order, and a RocksDB transaction takes its key before it commits, so a transaction can arrive after a higher key. Its previousKey is then the position just below its own key, and while such a late delivery is unacknowledged the position takes the lowest previousKey of all of them rather than the oldest's (harper#3101). Scan deliveries (`fromScan`) stay out of this ordering: they are retained state, which a resumed session does not resend.
361
+ - **Positions are certified, never clocks.** A new subscription starts at its registration watermark (`registeredThrough`), bound to the database generation when one exists, and otherwise at `getNextMonotonicTime()` unbound until the first certified position replaces it. A SUBSCRIBE that replaces a QoS 1/2 topic's subscription continues that topic's position through the checked replay, since replacing must lose nothing; a pruned-history refusal there resets the session as it would at reconnect. A topic's position is `min(subscription.progress(), previousKey of its oldest unacknowledged delivery)`; a transaction's messages share a key, so one unacked message holds the position before its whole transaction, and while a message is still queued the position stays before the last delivered transaction, which that message may share. Live events arrive in commit order, and a RocksDB transaction takes its key before it commits, so a transaction can arrive after a higher key. Its previousKey is then the position just below its own key, and while such a late delivery is unacknowledged the position takes the lowest previousKey of all of them rather than the oldest's (harper#3101). Scan deliveries (`fromScan`) stay out of this ordering: they are retained state, which a resumed session does not resend: `resumeTopics` subscribes with retain handling 2 (`addSubscription` omits current values for that or an explicit `omitCurrent`), since a record topic whose saved position covers its current version would otherwise send that version again.
357
362
  - **Checkpoints:** coalesced after acknowledgements, on an unref'd timer (`min(auditRetention / 10, 1h)`), and at disconnect, where positions are taken before subscriptions end because `close()` clears a queue and would read as idle. Nothing past a resumed position is saved until that subscription's `resumeVerified` resolves true. Out-of-order acks are not persisted: a partly acked transaction is redelivered. Legacy `acks` lists are ignored.
358
363
  - **Before CONNACK,** a collection entry bound to its database's current generation is checked against the floor (a record entry's own walk decides after CONNACK); a failure deletes the record and reports the session as not present. The connection then writes its incarnation (`takeOver`), and every later write first checks the stored incarnation: a connection that finds another's stops writing and closes (0x8E), and one resumed from a record never creates one. Any newer authenticated connection for the client id on the same thread, a clean start included, closes the older durable connection the same way before it reads or deletes the record, once that connection has saved its positions, and waits for that save to settle; a connection that has already closed counts until its last save settles. A save that failed leaves the record at the last one that landed, so the newer connection resumes from there and may redeliver, never skip. Simultaneous CONNECTs for one client id take their turn through a per-thread claim, so each sees the one before it; one that gives up waiting keeps its place until the one before it finishes. The CONNECT's pending session is assigned before it waits, so packets sent right behind the CONNECT wait with it rather than being refused; the wait is bounded (10 s, then CONNACK 0x88, or 0x03 on v3.1.1), and a CONNECT whose client closes while it waits takes nothing over. A session that has ended, by a disconnect or by yielding to a takeover, saves nothing after its final save, so a SUBSCRIBE still in flight cannot write over the record a newer connection claimed.
359
364
  - **Subscription changes are serial.** Packets are handled concurrently, so a durable session applies SUBSCRIBE, UNSUBSCRIBE and `resume()` one at a time: a topic's state belongs to one subscription. A SUBSCRIBE of any QoS is saved before SUBACK. A failed SUBSCRIBE leaves its topic unsubscribed and saves that. It ends the subscription it started, and drops the state of the one it replaced, which replacing ended. An UNSUBSCRIBE whose save fails is reported failed again on a retry until the removal is saved, and any failed save starts the checkpoint timer, so a session with only QoS 0 topics retries it too.
365
+ - **A delivery that cannot be sent ends the connection, not just the session.** The listener's `catch` in `mqtt.ts` aborts the transport (`destroy`/`terminate`, as for a parse error) and `onClose` ends the session, so the client sees the drop and reconnects. Ending only the session, as before, left the client connected and receiving nothing. An abort rather than a graceful end, because teardown on a graceful TCP end waits for the peer to close its side. A delivery that fails every time therefore repeats on each reconnect (a durable QoS≥1 session replays the unacknowledged message).
360
366
  - **Wills are kept per connection,** keyed `[clientId, incarnation]`: a connection closing after a takeover publishes and deletes only its own will (MQTT 5 §3.1.4 publishes a taken-over client's will), never the will of the connection that replaced it.
361
367
  - **Only a will's publish is authorized** (`settleWill`, harper#1874). The `hdb_session_will` row is the session's bookkeeping: it is read and deleted on contexts of its own, not the session's `authorize: true` context, which spends its one-shot check on the first call (roles are not granted system tables, so a non-super user's will never published and its row leaked). The publish then runs on a fresh session context, as the connection's user or a scoped will's embedded role, and the row is deleted after any outcome, a refused or failed publish included, so a will that fails for a transient reason is lost: the boot sweep, the only retry path, would replay a kept row only at the next restart, with no liveness check (harper#3034). The publish commits before the delete, so a crash between them leaves the row for that sweep and the will may be published twice (by design; untested). `unitTests/server/sessionWill.test.js` and `integrationTests/mqtt/will-authorization.test.ts` enforce that only the publish is authorized and that the row is deleted after a publish, a refusal and a clean DISCONNECT.
362
368
  - **One terminal path** (`subscriptionFailed`): a refused resume subscribe, a final `Error` value (observed through the subscription's `close` as well as the consumer loop, which may be parked on socket back-pressure), or an iterator failure. Only a 410 (pruned history) discards the session — writers fenced, siblings ended, a v5 DISCONNECT (0x83, the client-error message when problem information is allowed) and a socket close that never waits on storage, then a deletion only if the record still carries this incarnation. Anything else, a 409 from a restored or copied database included, saves positions and closes the connection so the client resumes.
@@ -254,13 +254,13 @@ class SubscriptionsSession {
254
254
  if (path.indexOf('.') > -1) throw new Error('Dots are not allowed in topic names');
255
255
  // might be faster to somehow modify existing subscription and re-get the retained record, but this should work for now
256
256
  const existingSubscription = this.subscriptions.find((subscription) => subscription.topic === topic);
257
- let omitCurrent;
257
+ let omitCurrent = subscriptionRequest.omitCurrent === true;
258
258
  if (existingSubscription) {
259
- omitCurrent = retainHandling > 0;
259
+ omitCurrent ||= retainHandling > 0;
260
260
  existingSubscription.end();
261
261
  this.subscriptions.splice(this.subscriptions.indexOf(existingSubscription), 1);
262
262
  } else {
263
- omitCurrent = retainHandling === 2;
263
+ omitCurrent ||= retainHandling === 2;
264
264
  }
265
265
  if (startTime) trace('Resuming subscription from', topic, 'from', startTime);
266
266
  const entry = resources.getMatch(path, 'mqtt');
@@ -672,11 +672,8 @@ export class DurableSubscriptionsSession extends SubscriptionsSession {
672
672
  const { qos, topic, startTime, databaseGeneration } = state.entry;
673
673
  const durable = qos > 0;
674
674
  try {
675
- // retain handling, not omitCurrent, decides whether a subscription starts with current values
676
675
  await this.resumeSubscription(
677
- durable
678
- ? { omitCurrent: true, topic, qos, startTime, databaseGeneration, reportProgress: true }
679
- : { topic, qos, rh: 2 },
676
+ durable ? { topic, qos, rh: 2, startTime, databaseGeneration, reportProgress: true } : { topic, qos, rh: 2 },
680
677
  durable
681
678
  );
682
679
  } catch (error) {
package/server/mqtt.ts CHANGED
@@ -329,6 +329,8 @@ function onSocket(socket, send, request, user, mqttSettings) {
329
329
  }
330
330
  }
331
331
  let disconnected;
332
+ // set by a failed delivery, so sibling subscriptions stop writing before the aborted socket's 'close' arrives
333
+ let aborted = false;
332
334
  numberOfConnections++;
333
335
  const connection = {
334
336
  protocolVersion: () => mqttOptions.protocolVersion,
@@ -557,9 +559,12 @@ function onSocket(socket, send, request, user, mqttSettings) {
557
559
  });
558
560
  const listener = async (topic, message, messageId, subscription, version) => {
559
561
  try {
560
- if (disconnected) {
561
- // a delivery already past the subscription when the client left; the disconnect has ended the session
562
- mqttLog.debug?.(`Session ${session?.sessionId} disconnected while trying to send message to ${topic}`);
562
+ if (disconnected || aborted) {
563
+ // a delivery already past the subscription when the client left or a failed delivery aborted
564
+ // the connection; onClose ends the session
565
+ mqttLog.debug?.(
566
+ `Session ${session?.sessionId} ${aborted ? 'aborted' : 'disconnected'} while trying to send message to ${topic}`
567
+ );
563
568
  return false;
564
569
  }
565
570
  const slashIndex = topic.indexOf('/', 1);
@@ -569,11 +574,12 @@ function onSocket(socket, send, request, user, mqttSettings) {
569
574
  // payload is encoded once per (message, content type) and reused across all of them.
570
575
  const encoding = getSharedMessageEncoding(message, request, version);
571
576
  const encoded = encoding.payload;
577
+ let payload: Buffer | string;
572
578
  // only pay for a microtask when the serialization is genuinely still pending
573
- const payload =
574
- typeof (encoded as any)?.then === 'function'
575
- ? await resolveSharedPayload(encoding, message, request, version)
576
- : (encoded as Buffer | string);
579
+ if (typeof (encoded as any)?.then === 'function') {
580
+ payload = await resolveSharedPayload(encoding, message, request, version);
581
+ if (disconnected || aborted) return false;
582
+ } else payload = encoded as Buffer | string;
577
583
  if (qos > 0) {
578
584
  // mqtt-packet requires a numeric message identifier once qos is non-zero
579
585
  const packetData: any = {
@@ -609,9 +615,11 @@ function onSocket(socket, send, request, user, mqttSettings) {
609
615
  return !rawSocket.closed;
610
616
  } catch (error) {
611
617
  mqttLog.error?.(error);
612
- session?.disconnect(false);
613
- mqttSettings.sessions.delete(session);
614
- releaseClientSession(session);
618
+ // Ending only the session would leave the client connected and receiving nothing. An abort,
619
+ // unlike a graceful end, closes without waiting on the peer, and onClose ends the session.
620
+ aborted = true;
621
+ if (socket.destroy) socket.destroy();
622
+ else socket.terminate();
615
623
  return false;
616
624
  }
617
625
  };
@@ -536,7 +536,14 @@ export function serializeMessage(
536
536
  request?: Request,
537
537
  inAsyncContinuation?: boolean
538
538
  ): Buffer | string | Promise<Buffer | string> {
539
- if (message?.contentType != null && message.data != null) return message.data;
539
+ if (message?.contentType != null && message.data != null) {
540
+ const data = message.data;
541
+ // a record read on another thread decodes binary as a plain Uint8Array view, which protocol
542
+ // encoders (mqtt-packet) reject; wrap the same bytes rather than copy them
543
+ return ArrayBuffer.isView(data) && !Buffer.isBuffer(data)
544
+ ? Buffer.from(data.buffer, data.byteOffset, data.byteLength)
545
+ : data;
546
+ }
540
547
  asyncSerializations = inAsyncContinuation ? undefined : [];
541
548
  try {
542
549
  const serialized: Buffer | string = getMessageSerializer(request)(message);
@@ -162,8 +162,9 @@ function fill(encoding: SharedMessageEncoding, message: any, request: any) {
162
162
  const payload = serializeMessage(message, request);
163
163
  encoding.payload = payload;
164
164
  encoding.failed = false;
165
- // A pass-through payload IS the message's own data, already reachable from the WeakMap key, so
166
- // caching it retains nothing extra and must not be billed against the budget.
165
+ // A pass-through payload IS the message's own data (or a Buffer over the same memory), already
166
+ // reachable from the WeakMap key, so caching it retains nothing extra and must not be billed
167
+ // against the budget.
167
168
  if (encoding.serializer === PASS_THROUGH) return;
168
169
  if (typeof (payload as any)?.then === 'function')
169
170
  // collapse an async serialization in place so later subscribers, and frame sharing, see the