Repository navigation
Skip a replicated publish the receiving node has already logged, so a restart or reconnect doesn't deliver it twice #4339
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
58bfda0
b92deea
ee5a9f9
6a06693
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
|
@@ -251,6 +251,7 @@ import { | |
| auditHistoryAbortMarked, | ||
| type AuditRecord, | ||
| } from './auditStore.ts'; | ||
| import { abortMarkersEnabled } from './transactionLogAbortMarkers.ts'; | ||
| import { | ||
| acquireFullTextClearFence, | ||
| acquireFullTextRetirementFence, | ||
|
|
@@ -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']); | ||
|
|
@@ -11351,10 +11356,30 @@ export function makeTable(options): TableResourceClass { | |
| (this.constructor as any).source?.publish && !(context as any)?.source | ||
| ? (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) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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 |
||
| : precedes <= 0) && | ||
| isMessageLogged(committedBy, (context as any)?.timestamp, id, options?.nodeId, options?.version ?? txnTime) | ||
| ) { | ||
| write.skipped = true; | ||
| return; | ||
| } | ||
| if (existingEntry === undefined && trackDeletes && !audit) { | ||
| scheduleCleanup(); | ||
| } | ||
|
|
@@ -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) | ||
|
|
@@ -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) { | ||
|
|
||
There was a problem hiding this comment.
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:11158is not part of this PR's diff β anchored to the nearest line this PR's diff can hold)