Skip to content

Commit 772ea03

Browse files
committed
fix: dedupe byte-identical durable event appends without consuming seq
1 parent 9cebd98 commit 772ea03

9 files changed

Lines changed: 317 additions & 23 deletions

File tree

.specgit.yaml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
version: 1
2-
delivery: summary-diff-continue
2+
delivery: event-idempotency-gate
33
context:
44
kind: branch
5-
branch: fix/525-summary-diff-continue
5+
branch: fix/523-event-idempotency-gate
66
issues:
7-
- 525
8-
pr: 526
7+
- 523
8+
pr: 527

packages/core/schema.json

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
{
22
"version": "7",
33
"dialect": "sqlite",
4-
"id": "abadf28b-1770-46c6-bbf6-b7800a9ca874",
4+
"id": "7a2e2a70-584a-4604-bf73-4c6e116c20a3",
55
"prevIds": [
6-
"874d8e74-d354-4dcb-b98c-c893660c9371"
6+
"abadf28b-1770-46c6-bbf6-b7800a9ca874"
77
],
88
"ddl": [
99
{
@@ -1052,6 +1052,16 @@
10521052
"entityType": "columns",
10531053
"table": "event"
10541054
},
1055+
{
1056+
"type": "text",
1057+
"notNull": false,
1058+
"autoincrement": false,
1059+
"default": null,
1060+
"generated": null,
1061+
"name": "data_hash",
1062+
"entityType": "columns",
1063+
"table": "event"
1064+
},
10551065
{
10561066
"type": "text",
10571067
"notNull": false,

packages/core/src/database/migration.gen.ts

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Effect } from "effect"
2+
import type { DatabaseMigration } from "../migration"
3+
4+
export default {
5+
id: "20260903062324_add_event_data_hash",
6+
up(tx) {
7+
return Effect.gen(function* () {
8+
yield* tx.run(`ALTER TABLE \`event\` ADD \`data_hash\` text;`)
9+
})
10+
},
11+
} satisfies DatabaseMigration.Migration

packages/core/src/database/schema.gen.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ export default {
149149
\`seq\` integer NOT NULL,
150150
\`type\` text NOT NULL,
151151
\`data\` text NOT NULL,
152+
\`data_hash\` text,
152153
CONSTRAINT \`fk_event_aggregate_id_event_sequence_aggregate_id_fk\` FOREIGN KEY (\`aggregate_id\`) REFERENCES \`event_sequence\`(\`aggregate_id\`) ON DELETE CASCADE
153154
);
154155
`)

packages/core/src/event.ts

Lines changed: 43 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@ export * as EventV2 from "./event"
33
import { Cause, Context, Effect, FiberSet, Layer, Option, PubSub, Schema, Stream } from "effect"
44
import { Event } from "@opencode-ai/schema/event"
55
import type { Data, Definition, Payload } from "@opencode-ai/schema/event"
6-
import { and, asc, eq, gt } from "drizzle-orm"
6+
import { and, asc, desc, eq, gt } from "drizzle-orm"
77
import { Database } from "./database/database"
88
import { EventSequenceTable, EventTable } from "./event/sql"
99
import { Location } from "./location"
1010
import { LayerNode } from "./effect/layer-node"
1111
import { isDeepStrictEqual } from "node:util"
1212
import { Durable } from "@opencode-ai/schema/durable-event-manifest"
13+
import { Hash } from "./util/hash"
1314

1415
export const ID = Event.ID
1516
export type ID = import("@opencode-ai/schema/event").ID
@@ -227,6 +228,30 @@ export const layerWith = (options?: LayerOptions) =>
227228
if (input && row?.ownerID && row.ownerID !== input.ownerID) {
228229
return undefined
229230
}
231+
const dataHash = Hash.sha256(JSON.stringify(encoded))
232+
if (!input) {
233+
// Idempotency gate (#523): a fresh append that byte-for-byte repeats
234+
// this aggregate's latest same-type event carries zero information
235+
// delta. Skip it entirely — no seq consumed, no projectors, no commit
236+
// hook, no durable wake — so the persisted sequence stays dense and
237+
// both the replayAll contiguity check and gt(seq, after) readers are
238+
// unaffected. Replay appends (input) keep their exact-seq contract,
239+
// and legacy rows carry a NULL hash so they never match.
240+
const previous = yield* db
241+
.select({ dataHash: EventTable.data_hash })
242+
.from(EventTable)
243+
.where(
244+
and(
245+
eq(EventTable.aggregate_id, aggregateID),
246+
eq(EventTable.type, versionedType(definition.type, durable.version)),
247+
),
248+
)
249+
.orderBy(desc(EventTable.seq))
250+
.limit(1)
251+
.get()
252+
.pipe(Effect.orDie)
253+
if (previous && previous.dataHash === dataHash) return undefined
254+
}
230255
const seq = input?.seq ?? latest + 1
231256
if (input && seq !== latest + 1) {
232257
yield* Effect.die(
@@ -278,6 +303,7 @@ export const layerWith = (options?: LayerOptions) =>
278303
seq,
279304
type: versionedType(definition.type, durable.version),
280305
data: encoded,
306+
data_hash: dataHash,
281307
},
282308
])
283309
.run()
@@ -479,7 +505,9 @@ export const layerWith = (options?: LayerOptions) =>
479505
.transaction(
480506
() =>
481507
Effect.gen(function* () {
482-
const results = new Array<{ aggregateID: string; seq: number }>()
508+
// Aligned with entries by index: a deduped entry yields
509+
// undefined so the payload pairing below stays positional.
510+
const results = new Array<{ aggregateID: string; seq: number } | undefined>()
483511
for (const entry of entries) {
484512
// No replay input: seq is allocated contiguously from the latest sequence inside the transaction.
485513
const result = yield* commitDurableEventInner(
@@ -488,7 +516,7 @@ export const layerWith = (options?: LayerOptions) =>
488516
undefined,
489517
entry.commit,
490518
)
491-
if (result) results.push(result)
519+
results.push(result)
492520
}
493521
return results
494522
}),
@@ -499,19 +527,19 @@ export const layerWith = (options?: LayerOptions) =>
499527
return results
500528
}),
501529
)
502-
const payloads = entries.flatMap((entry, index) => {
530+
const payloads = entries.map((entry, index) => {
503531
const result = committed[index]
504-
if (!result) return []
505-
return [
506-
{
507-
...entry.event,
508-
durable: {
509-
aggregateID: result.aggregateID,
510-
seq: result.seq,
511-
version: entry.durable.version,
512-
},
513-
} as Payload,
514-
]
532+
// A deduped entry is still notified (mirrors the single-publish
533+
// path) but stays unstamped: it occupies no sequence position.
534+
if (!result) return entry.event
535+
return {
536+
...entry.event,
537+
durable: {
538+
aggregateID: result.aggregateID,
539+
seq: result.seq,
540+
version: entry.durable.version,
541+
},
542+
} as Payload
515543
})
516544
for (const payload of payloads) {
517545
yield* notify(payload)

packages/core/src/event/sql.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ export const EventTable = sqliteTable(
1717
seq: integer().notNull(),
1818
type: text().notNull(),
1919
data: text({ mode: "json" }).$type<Record<string, unknown>>().notNull(),
20+
// sha256 of the serialized payload, written once at append time. The
21+
// idempotency gate compares against the latest same-type row via
22+
// event_aggregate_type_seq_idx instead of re-hashing MiB-scale payloads.
23+
// Nullable: legacy rows predate the column and never match the gate.
24+
data_hash: text(),
2025
},
2126
(table) => [
2227
uniqueIndex("event_aggregate_seq_idx").on(table.aggregate_id, table.seq),

0 commit comments

Comments
 (0)