Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
section: 'Replication'
---

### Replicated Messages Arrive Once After a Restart or Reconnect

After a node restarted, restarted its HTTP workers or reconnected replication, its subscribers could receive a burst of messages published on another node a second time. A resuming node asks its peer to re-send the part of the log just before what it had applied, so that a transaction committed late is not missed; record writes in that stretch were already dropped as duplicates, but published messages were applied and delivered again. On RocksDB, a replicated message the node's transaction log still holds is now skipped, and a message it never received is still delivered once; only a message at or below its topic's record version is looked up (or, on a table that evicts or expires records, one whose record is gone), so in-order messages cost nothing extra. Nothing needs to change in applications or configuration. A message is still applied again when it is re-sent after audit retention purged its entry, on a node configured without transaction-log abort markers, or when its topic's record was moved back by an earlier version before upgrading.
1 change: 1 addition & 0 deletions resources/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Declarations compile to `fullTextIndexes`, never `attributes` (no stored value).
- `receivedIdentityRefs` holds received identities, never log keys: unaudited, the only re-delivery proof ([receipt rules](receipt-rules.md)). **Never "fix" a log key to the version** (`auditRefKinds.test.js`).
- LMDB merges keep the older version (`noAuditOutOfOrderPatch.test.js`). Duplicates use committed identity; only minted keys mark a retry's own staging (`readsOwnStaging`, `replicatedRetryDuplicate.test.js`).
- RocksDB's watermark stalls at uncommitted entries, so ref walks pass `followingCommittedRef`. Gap: history walks follow one chain, plus late messages (#3977).
- A replicated publish is skipped only on its logged entry (`isMessageLogged`), at or below the record's version, with abort markers on; in history older than the process only a record vouches for it (`replicatedPublishRedelivery.test.js`).
- **A record's version never moves back**: a message older than it keeps the version and refs its entry, which history walks reach by `isLateEntry` (`latePublishVersionRegression.test.js`).
- `FULL_WRITE_HEAD`: the head replaced the record and is unchanged since; any other write clears it (`fullWriteHead.test.js`).
- Named-log misses never fall back to `local`; `local` reads filter by `nodeId` (`auditLog.test.js`).
Expand Down
65 changes: 60 additions & 5 deletions resources/Table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ import {
GET_TRACKED_WRITE_GENERATION,
} from './tracked.ts';
import { transaction, contextStorage } from './transaction.ts';
import { MAXIMUM_KEY, writeKey, compareKeys } from 'ordered-binary';
import { MAXIMUM_KEY, writeKey, compareKeys, toBufferKey } from 'ordered-binary';
import {
getWorkerIndex,
applicationWorkerIndex,
Expand Down Expand Up @@ -251,6 +251,7 @@ import {
auditHistoryAbortMarked,
type AuditRecord,
} from './auditStore.ts';
import { abortMarkersEnabled } from './transactionLogAbortMarkers.ts';
import {
acquireFullTextClearFence,
acquireFullTextRetirementFence,
Expand Down Expand Up @@ -389,6 +390,10 @@ type MaybePromise<T> = T | Promise<T>;
const NULL_WITH_TIMESTAMP = new Uint8Array(9);
NULL_WITH_TIMESTAMP[8] = 0xc0; // null
const sourceWriteTypes = new Set(['put', 'patch', 'delete', 'publish', 'message', 'invalidate', 'relocate']);
const loggedMessagesByTransaction = new WeakMap<object, Map<string, Set<string> | null>>();
// the storage-key encoding, so ids the store treats as one key match
const messageIdentity = (tableId: number, id: Id, version: number) =>
`${tableId}:${version}:${toBufferKey(id).toString('latin1')}`;
const isSourceWriteType = (type: string) => sourceWriteTypes.has(type);
// declared types `validate` holds to one value on validated writes: `ID` admits an array of strings
const SCALAR_TYPES = new Set(['String', 'Int', 'Long', 'Float', 'Boolean', 'Date', 'BigInt']);
Expand Down Expand Up @@ -11351,10 +11356,30 @@ export function makeTable(options): TableResourceClass {
(this.constructor as any).source?.publish && !(context as any)?.source

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] Newly committed messages can still be delivered twice after restart

(resources/Table.ts:11158 is not part of this PR's diff β€” anchored to the nearest line this PR's diff can hold)

? (this.constructor as any).source.publish.bind((this.constructor as any).source, id, message, context)
: undefined,
commit: (txnTime, existingEntry, _retry, transaction: any) => {
commit: (txnTime, existingEntry, retry, transaction: any, committedBy?: DatabaseTransaction) => {
write.skipped = false;
write.stagedEntry = undefined;
write.completeResidencyRecord = undefined;
write.superseded = false;
const precedes =
existingEntry?.version !== undefined ? precedesExistingVersion(txnTime, existingEntry, options?.nodeId) : 1;
// The record's version never moves back, so only a message at or below it, or one to a record the table can
// drop, can have been logged here, and the logged entry is the proof. A retry recommits the batch its first
// attempt staged; an earlier process's file can hold an unmarked aborted batch, so there only a record vouches.
if (
committedBy?.sourceApply &&
!committedBy.isReplay &&
isRocksDB &&
abortMarkersEnabled() &&
!(retry && write.appendedAuditEntry === true) &&
(existingEntry === undefined
? (evictsRecords() || !audit) && auditHistoryAbortMarked(auditStore, options?.nodeId)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] Newly committed messages can still be delivered twice after restart

The missing-topic trigger from the prior finding remains. On an expiring or evicting audited table, retain an origin's pre-restart file, roll into a new file, commit message A after restart onto a populated topic, then evict that record without purging A's log entry. Re-send A: this branch refuses the lookup because auditHistoryAbortMarked() remains false while the older file survives (resources/auditStore.ts:1913-1922). A is logged and delivered again even though its own batch was written with abort markers and committed in the newer file.

Preserving record versions fixes the late-publish trigger, but cannot supply evidence after eviction removes the record. Scope trust to the matched frame's file/sequence, potentially through a rocksdb-js committed-identity primitive. The discriminating check is that restart/rollover/apply/evict/re-send sequence, expecting one delivery of A. The simulated old-history case at unitTests/resources/replicatedPublishRedelivery.test.js:283-302 retains the topic record, so it does not exercise this branch. This remaining outcome is supported by code tracing; I could not run a database repro in this read-only checkout.

: precedes <= 0) &&
isMessageLogged(committedBy, (context as any)?.timestamp, id, options?.nodeId, options?.version ?? txnTime)
) {
write.skipped = true;
return;
}
if (existingEntry === undefined && trackDeletes && !audit) {
scheduleCleanup();
}
Expand All @@ -11365,9 +11390,7 @@ export function makeTable(options): TableResourceClass {
const base = priorStagedOp?.stagedEntry ?? existingEntry;
const value = base?.value ?? null;
const metadataFlags = (base?.metadataFlags ?? 0) & (LOCAL_ONLY | INVALIDATED | REPLICA);
const late =
existingEntry?.version !== undefined &&
precedesExistingVersion(txnTime, existingEntry, options?.nodeId) < 0;
const late = precedes < 0;
const ownRefs = late
? isRocksDB
? withAuditRef(existingEntry.additionalAuditRefs, appendedLogKey, options?.nodeId)
Expand Down Expand Up @@ -14393,6 +14416,38 @@ export function makeTable(options): TableResourceClass {
return Boolean(entry?.metadataFlags & INVALIDATED) && isAuditEntryWrite(entry, { txnLogKey, nodeId });
}

/**
* An origin transaction commits here all at once, so one read answers for all its messages for the applying
* transaction; no write that staged its entry asks again on a retry.
*/
function isMessageLogged(
applying: DatabaseTransaction,
txnLogKey: number | undefined,
id: Id,
nodeId: number | undefined,
version: number
): boolean {
if (txnLogKey === undefined || !nodeId) return false;
// an unnamed origin's entries sit in `local` under minted keys, never at its received key
if (!auditStore.logById(nodeId)) return false;
let byOriginKey = loggedMessagesByTransaction.get(applying);
if (!byOriginKey) loggedMessagesByTransaction.set(applying, (byOriginKey = new Map()));
const originKey = `${nodeId}:${txnLogKey}`;
let logged = byOriginKey.get(originKey);
if (logged === undefined) byOriginKey.set(originKey, (logged = loggedMessagesAt(txnLogKey, nodeId)));
return logged !== null && logged.has(messageIdentity(tableId, id, version));
}

function loggedMessagesAt(txnLogKey: number, nodeId: number): Set<string> | null {
let logged: Set<string> | null = null;
for (const entry of auditStore.getRange({ start: txnLogKey, exactStart: true, log: nodeId })) {
if (entry.txnLogKey !== txnLogKey) break;
if (entry.type === 'message')
(logged ??= new Set()).add(messageIdentity(entry.tableId, entry.recordId, entry.version));
}
return logged;
}

// Not stagedEntry: a relocate restamps the record without staging it, and a publish's restamp counts too.
function followsAppliedWrite(write: any): boolean {
for (let prior = write.priorWrite; prior; prior = prior.priorWrite) {
Expand Down
14 changes: 10 additions & 4 deletions unitTests/resources/relayedHeadTieDuplicate.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -237,16 +237,22 @@ describe('Relayed head-tie duplicates (harper#3467)', function () {
],
});
await apply(transaction());
const puts = async () => (await T.getHistoryOfRecord('r')).filter((entry) => entry.type === 'put').length;
const putsBefore = await puts();
const logged = async (type) => (await T.getHistoryOfRecord('r')).filter((entry) => entry.type === type).length;
const putsBefore = await logged('put');
const messagesBefore = await logged('message');
const spy = spyRecordLogReads(T, version, origin);
try {
await apply(transaction());
} finally {
spy.restore();
}
assert.deepEqual(spy.reads, [], 'the publish runs after the put, so the put reads no committed base');
assert.equal(await puts(), putsBefore, 'the re-delivered put must not be logged again');
assert.deepEqual(
spy.reads,
[{ getRange: version, log: origin }],
'the publish runs after the put, so the put reads no committed base; only the publish looks up its entry'
);
assert.equal(await logged('put'), putsBefore, 'the re-delivered put must not be logged again');
assert.equal(await logged('message'), messagesBefore, 'the re-delivered publish must not be logged again');
assert.equal((await T.get('r')).name, 'first');
});

Expand Down
Loading
Loading