Skip to content

Commit ca96956

Browse files
authored
fix(awareness-service): dedupe deliveries by content hash so envelope updates re-deliver (#984) (#988)
1 parent fb427b8 commit ca96956

4 files changed

Lines changed: 107 additions & 5 deletions

File tree

services/awareness-service/api/src/database/entities/Delivery.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,16 @@ export type DeliveryStatus =
1616

1717
/**
1818
* A queued webhook delivery of one packet to one subscription. The unique
19-
* (subscriptionId, packetId) constraint makes ingest idempotent if evault-core
20-
* retries a POST.
19+
* (subscriptionId, packetId, contentHash) constraint dedupes by content: a
20+
* retried POST of the same payload is idempotent, while re-ingesting an updated
21+
* envelope (new contentHash) queues a fresh delivery.
2122
*/
2223
@Entity("deliveries")
23-
@Unique("uq_delivery_subscription_packet", ["subscriptionId", "packetId"])
24+
@Unique("uq_delivery_subscription_packet_content", [
25+
"subscriptionId",
26+
"packetId",
27+
"contentHash",
28+
])
2429
export class Delivery {
2530
@PrimaryGeneratedColumn("uuid")
2631
id!: string;
@@ -32,6 +37,10 @@ export class Delivery {
3237
@Column({ type: "varchar" })
3338
packetId!: string;
3439

40+
/** SHA-256 of the packet payload at ingest; dedupes deliveries by content. */
41+
@Column({ type: "varchar" })
42+
contentHash!: string;
43+
3544
@Column({ type: "varchar", default: "pending" })
3645
status!: DeliveryStatus;
3746

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { MigrationInterface, QueryRunner } from "typeorm";
2+
3+
/**
4+
* Dedupe deliveries by payload content, not packet id alone. Adds a
5+
* `contentHash` column to `deliveries` and swaps the unique key from
6+
* (subscriptionId, packetId) to (subscriptionId, packetId, contentHash) so that
7+
* re-ingesting an updated MetaEnvelope queues a fresh delivery instead of being
8+
* silently dropped by the old constraint.
9+
*/
10+
export class AddDeliveryContentHash1780000000000
11+
implements MigrationInterface
12+
{
13+
name = "AddDeliveryContentHash1780000000000";
14+
15+
public async up(queryRunner: QueryRunner): Promise<void> {
16+
await queryRunner.query(
17+
`ALTER TABLE "deliveries" ADD COLUMN "contentHash" varchar`,
18+
);
19+
// Backfill legacy rows with a single constant: this preserves the old
20+
// one-delivery-per-(subscription, packet) semantics for rows that
21+
// predate content-based dedup.
22+
await queryRunner.query(
23+
`UPDATE "deliveries" SET "contentHash" = '' WHERE "contentHash" IS NULL`,
24+
);
25+
await queryRunner.query(
26+
`ALTER TABLE "deliveries" ALTER COLUMN "contentHash" SET NOT NULL`,
27+
);
28+
await queryRunner.query(
29+
`ALTER TABLE "deliveries" DROP CONSTRAINT "uq_delivery_subscription_packet"`,
30+
);
31+
await queryRunner.query(
32+
`ALTER TABLE "deliveries" ADD CONSTRAINT "uq_delivery_subscription_packet_content" UNIQUE ("subscriptionId", "packetId", "contentHash")`,
33+
);
34+
}
35+
36+
public async down(queryRunner: QueryRunner): Promise<void> {
37+
await queryRunner.query(
38+
`ALTER TABLE "deliveries" DROP CONSTRAINT "uq_delivery_subscription_packet_content"`,
39+
);
40+
await queryRunner.query(
41+
`ALTER TABLE "deliveries" ADD CONSTRAINT "uq_delivery_subscription_packet" UNIQUE ("subscriptionId", "packetId")`,
42+
);
43+
await queryRunner.query(
44+
`ALTER TABLE "deliveries" DROP COLUMN "contentHash"`,
45+
);
46+
}
47+
}

services/awareness-service/api/src/services/IngestService.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { AppDataSource } from "../database/data-source";
22
import { Delivery } from "../database/entities/Delivery";
33
import { Packet } from "../database/entities/Packet";
44
import type { AwarenessPayload } from "../types";
5+
import { contentHash } from "../utils/contentHash";
56
import { SubscriptionMatcher } from "./SubscriptionMatcher";
67

78
/** Returns the normalised origin of a URL, or null if it cannot be parsed. */
@@ -56,19 +57,27 @@ export class IngestService {
5657
return { packetId: packet.id, deliveriesQueued: 0 };
5758
}
5859

60+
// Dedupe deliveries by payload content rather than packet id alone:
61+
// re-ingesting an updated envelope (new hash) must queue a new delivery,
62+
// while a retried POST of the same payload (same hash) must not.
63+
const hash = contentHash(payload.data ?? null);
64+
5965
const deliveryRepo = AppDataSource.getRepository(Delivery);
6066
const rows = subscriptions.map((sub) =>
6167
deliveryRepo.create({
6268
subscriptionId: sub.id,
6369
packetId: packet.id,
70+
contentHash: hash,
6471
status: "pending",
6572
attempts: 0,
6673
nextAttemptAt: new Date(),
6774
}),
6875
);
6976

70-
// orIgnore skips deliveries that already exist for this packet/sub pair,
71-
// so an evault-core retry of POST /ingest does not double-deliver.
77+
// orIgnore skips deliveries that already exist for this
78+
// (subscription, packet, content) triple, so an evault-core retry of
79+
// POST /ingest with unchanged data does not double-deliver. An updated
80+
// payload has a different contentHash and is inserted as a new delivery.
7281
const result = await deliveryRepo
7382
.createQueryBuilder()
7483
.insert()
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import crypto from "crypto";
2+
3+
/**
4+
* Deterministic JSON serialisation: object keys are sorted recursively so that
5+
* two payloads with the same content but different key ordering serialise
6+
* identically. Array order is preserved (it is semantically significant) and
7+
* primitives/null pass through unchanged.
8+
*/
9+
export function stableStringify(value: unknown): string {
10+
if (value === null || typeof value !== "object") {
11+
return JSON.stringify(value) ?? "null";
12+
}
13+
if (Array.isArray(value)) {
14+
return `[${value.map(stableStringify).join(",")}]`;
15+
}
16+
const entries = Object.keys(value as Record<string, unknown>)
17+
.sort()
18+
.map(
19+
(key) =>
20+
`${JSON.stringify(key)}:${stableStringify(
21+
(value as Record<string, unknown>)[key],
22+
)}`,
23+
);
24+
return `{${entries.join(",")}}`;
25+
}
26+
27+
/**
28+
* SHA-256 of a packet's payload, computed over a stable serialisation so the
29+
* hash depends only on content, not key ordering. Used to dedupe deliveries by
30+
* content: identical re-ingests share a hash, a changed payload yields a new one.
31+
*/
32+
export function contentHash(data: unknown): string {
33+
return crypto
34+
.createHash("sha256")
35+
.update(stableStringify(data ?? null))
36+
.digest("hex");
37+
}

0 commit comments

Comments
 (0)