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.
- package/dist/resources/DatabaseTransaction.d.ts +5 -1
- package/dist/resources/DatabaseTransaction.js +21 -5
- package/dist/resources/DatabaseTransaction.js.map +1 -1
- package/dist/resources/LMDBTransaction.d.ts +1 -1
- package/dist/resources/LMDBTransaction.js +4 -3
- package/dist/resources/LMDBTransaction.js.map +1 -1
- package/dist/resources/Table.js +85 -36
- package/dist/resources/Table.js.map +1 -1
- package/dist/server/DurableSubscriptionsSession.js +4 -7
- package/dist/server/DurableSubscriptionsSession.js.map +1 -1
- package/dist/server/mqtt.js +21 -9
- package/dist/server/mqtt.js.map +1 -1
- package/dist/server/serverHelpers/contentTypes.js +8 -2
- package/dist/server/serverHelpers/contentTypes.js.map +1 -1
- package/dist/server/serverHelpers/sharedMessageEncoding.js +3 -2
- package/dist/server/serverHelpers/sharedMessageEncoding.js.map +1 -1
- package/package.json +1 -1
- package/resources/DESIGN.md +6 -2
- package/resources/DatabaseTransaction.ts +25 -5
- package/resources/LMDBTransaction.ts +4 -3
- package/resources/Table.ts +99 -42
- package/server/DESIGN.md +7 -1
- package/server/DurableSubscriptionsSession.ts +4 -7
- package/server/mqtt.ts +18 -10
- package/server/serverHelpers/contentTypes.ts +8 -1
- package/server/serverHelpers/sharedMessageEncoding.ts +3 -2
- package/studio/web/assets/{Chat-B-Rd-ft9.js → Chat-DjZNWwwA.js} +1 -1
- package/studio/web/assets/{FloatingChat-CFypO7kh.js → FloatingChat-BWfKK_RX.js} +3 -3
- package/studio/web/assets/{SearchDialog-BRIyPiBl.js → SearchDialog-CdlNxWef.js} +1 -1
- package/studio/web/assets/{apiToken-BraYnzvC.js → apiToken-DyI8IwU3.js} +1 -1
- package/studio/web/assets/{applications-XCds8-ZP.js → applications-DgGopKJq.js} +1 -1
- package/studio/web/assets/{cssMode-BY1w91l8.js → cssMode-4ddO86ey.js} +1 -1
- package/studio/web/assets/{editor-D5mpVs1b.js → editor-DATuu7_v.js} +1 -1
- package/studio/web/assets/{html-DiXKiahz.js → html-c4OKnEF1.js} +1 -1
- package/studio/web/assets/{htmlMode-DitVJ707.js → htmlMode-yXjqUsW9.js} +1 -1
- package/studio/web/assets/{index-D8kyF6dt.js → index-CIK9Nhgo.js} +5 -5
- package/studio/web/assets/{index.lazy-DgbjUxLo.js → index.lazy-DQpKL_QQ.js} +1 -1
- package/studio/web/assets/{javascript-BBVXeMDm.js → javascript-CP1WPkLu.js} +1 -1
- package/studio/web/assets/{jsonMode-D85_j2oD.js → jsonMode-BWsIHqbb.js} +1 -1
- package/studio/web/assets/{languageServices-U3mWhpRL.js → languageServices-Ca72n4Ql.js} +1 -1
- package/studio/web/assets/{lspLanguageFeatures-CoymyM3c.js → lspLanguageFeatures-BQdsSDeh.js} +1 -1
- package/studio/web/assets/{notifications-CPgEC6ZP.js → notifications-BYgO5-yk.js} +1 -1
- package/studio/web/assets/{notifications-D_VqhHnx.js → notifications-DsGk-TnW.js} +1 -1
- package/studio/web/assets/{operations-D8aaKYty.js → operations-Dy69AlTr.js} +1 -1
- package/studio/web/assets/{profile-DM5E6Xls.js → profile-DzQvoYBF.js} +1 -1
- package/studio/web/assets/{regions-WFvXqryO.js → regions-Sv_IN7XA.js} +1 -1
- package/studio/web/assets/{register-B7DvfAGj.js → register-AM2_79FO.js} +2 -2
- package/studio/web/assets/{request-ChoMERRk.js → request-BJj8dbdU.js} +1 -1
- package/studio/web/assets/{setComponentFile-BHk22Rh6.js → setComponentFile-BCnAnxjR.js} +1 -1
- package/studio/web/assets/{setup-wNdpTrVH.js → setup-Ca4RpPzm.js} +2 -2
- package/studio/web/assets/{status-CYoonm9V.js → status-D05eZ5pQ.js} +1 -1
- package/studio/web/assets/{toggleHighContrast-CKEm-P4K.js → toggleHighContrast-JRkLfyfb.js} +1 -1
- package/studio/web/assets/{tsMode-Dv35qHmn.js → tsMode-C_-eSRVR.js} +1 -1
- package/studio/web/assets/{typescript-Bly1EW8o.js → typescript-BYXigM_n.js} +1 -1
- package/studio/web/assets/{useEntityRestURL-Cg3P0MOb.js → useEntityRestURL-HHmjsRm9.js} +1 -1
- package/studio/web/assets/{workers-DVtjrvUa.js → workers-DY0ka4Y7.js} +1 -1
- package/studio/web/assets/{xml-DjAPbgLN.js → xml-CdGINpmN.js} +1 -1
- package/studio/web/assets/{yaml-oTUyoJy1.js → yaml-DYeF07_w.js} +1 -1
- package/studio/web/index.html +1 -1
package/resources/DESIGN.md
CHANGED
|
@@ -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
|
-
- **
|
|
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)
|
|
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();
|
package/resources/Table.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
4652
|
-
// save()
|
|
4653
|
-
//
|
|
4654
|
-
//
|
|
4655
|
-
//
|
|
4656
|
-
//
|
|
4657
|
-
//
|
|
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
|
-
(
|
|
4749
|
-
|
|
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
|
-
|
|
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.
|
|
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 }> =
|
|
4883
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
259
|
+
omitCurrent ||= retainHandling > 0;
|
|
260
260
|
existingSubscription.end();
|
|
261
261
|
this.subscriptions.splice(this.subscriptions.indexOf(existingSubscription), 1);
|
|
262
262
|
} else {
|
|
263
|
-
omitCurrent
|
|
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
|
|
562
|
-
|
|
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
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
|
613
|
-
|
|
614
|
-
|
|
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)
|
|
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
|
|
166
|
-
// caching it retains nothing extra and must not be billed
|
|
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
|