Skip to content

Hash SQL message deduplication keys to prevent message_id overflow#6618

Open
tim-smart wants to merge 5 commits into
mainfrom
feat/hash-sql-message-dedupe-keys
Open

Hash SQL message deduplication keys to prevent message_id overflow#6618
tim-smart wants to merge 5 commits into
mainfrom
feat/hash-sql-message-dedupe-keys

Conversation

@tim-smart

@tim-smart tim-smart commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Summary

The composed request deduplication key (entityType/entityId/tag/primaryKey) can legally exceed the 255-character message_id column: entity_type VARCHAR(150) + entity_id VARCHAR(255) + tag VARCHAR(50) alone allow 458 characters of address before the RPC primary key is appended. Any sufficiently long combination overflows the column (or silently violates its purpose via INSERT IGNORE), surfacing as PersistenceErrors from deep inside SQL storage — the failure reported in #6317 via long workflow deferred keys.

SqlMessageStorage now conditionally hashes the composed key at the SQL boundary:

  • Keys that fit in 255 characters are stored as plaintext, byte-compatible with rows written by previous versions — existing deployments keep deduplicating with no transition handling at all.
  • Longer keys are stored as a SHA-256 digest (256 bits, 64 hex chars via Encoding.encodeHex) — fixed-size, fits every dialect's indexable column width, collision probability negligible (birthday bound 2^128). Digests never contain / while composed keys always do, so the two encodings cannot collide.
  • Envelope.primaryKeyByAddress stays synchronous and plaintext; memory storage and the no-op storage are unchanged.
  • No schema change: no new columns, no migration.

Legacy rows with over-length keys (SQLite only)

On the width-enforcing dialects (pg/mysql/mssql), keys longer than 255 characters could never be stored, so no legacy handling is needed. SQLite's message_id is TEXT, so pre-upgrade SQLite deployments can hold plaintext keys longer than 255 characters; for exactly that case (long key, non-width-enforcing dialect), save-time deduplication and requestIdForPrimaryKey fall back to a plaintext read when the digest misses.

Layer requirements

  • SqlMessageStorage.make/layer/layerWith now require Crypto.Crypto; digest failures are mapped into PersistenceError like every other storage failure.
  • SingleRunner.layer propagates the requirement (SqlClient | Crypto.Crypto).
  • NodeClusterHttp/NodeClusterSocket/BunClusterHttp/BunClusterSocket provide NodeCrypto.layer/BunCrypto.layer internally, so their public requirements are unchanged.
  • ClusterWorkflowEngine.layer is untouched.

Tests

Added to the pg/mysql/sqlite matrix in SqlMessageStorage.test.ts:

  • long composed keys (450-char primary key) save, deduplicate, and resolve via requestIdForPrimaryKey; message_id is verified to be a 64-char hex digest
  • keys within the column width are verified to be stored as byte-identical plaintext (backwards compatibility), and deduplicate/resolve
  • SQLite-specific regression: legacy plaintext rows with keys > 255 chars (possible only on SQLite's TEXT column) still deduplicate via the fallback

All 36 tests pass across the three dialects; memory storage tests are unchanged and pass.

Closes #6317
Closes EFF-48

🤖 Generated with Claude Code

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-project-automation github-project-automation Bot moved this to Discussion Ongoing in PR Backlog Jul 25, 2026
@changeset-bot

changeset-bot Bot commented Jul 25, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a1f96bc

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 28 packages
Name Type
effect Patch
@effect/platform-node Patch
@effect/platform-bun Patch
@effect/opentelemetry Patch
@effect/platform-browser Patch
@effect/platform-node-shared Patch
@effect/vitest Patch
@effect/ai-anthropic Patch
@effect/ai-openai-compat Patch
@effect/ai-openai Patch
@effect/ai-openrouter Patch
@effect/atom-react Patch
@effect/atom-solid Patch
@effect/atom-vue Patch
@effect/sql-clickhouse Patch
@effect/sql-d1 Patch
@effect/sql-libsql Patch
@effect/sql-mssql Patch
@effect/sql-mysql2 Patch
@effect/sql-pg Patch
@effect/sql-pglite Patch
@effect/sql-sqlite-bun Patch
@effect/sql-sqlite-do Patch
@effect/sql-sqlite-node Patch
@effect/sql-sqlite-react-native Patch
@effect/sql-sqlite-wasm Patch
@effect/docgen Patch
@effect/openapi-generator Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions github-actions Bot added the 4.0 label Jul 25, 2026
@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@tim-smart, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 17 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 8e9a319a-2d29-4292-9b75-8ac4b333a36a

📥 Commits

Reviewing files that changed from the base of the PR and between 9ad793b and a1f96bc.

📒 Files selected for processing (4)
  • .changeset/hash-sql-message-dedupe-keys.md
  • packages/effect/src/unstable/cluster/SingleRunner.ts
  • packages/effect/src/unstable/cluster/SqlMessageStorage.ts
  • packages/platform-node/test/cluster/SqlMessageStorage.test.ts
📝 Walkthrough

Walkthrough

SqlMessageStorage now hashes composed deduplication keys into SHA-256 message_id values, stores plaintext keys for diagnostics, and falls back to legacy plaintext lookups. Crypto dependencies are added to storage layers, with Node/Bun layers providing platform implementations. Tests cover long and legacy keys.

Changes

SQL message deduplication

Layer / File(s) Summary
Hashing, persistence, and migration
packages/effect/src/unstable/cluster/SqlMessageStorage.ts, packages/effect/src/unstable/cluster/Envelope.ts, .changeset/hash-sql-message-dedupe-keys.md
Composed primary keys are hashed into message_id, plaintext keys are stored in nullable primary_key, legacy plaintext lookups remain supported, and a migration adds the diagnostic column across supported SQL dialects.
Crypto dependency wiring
packages/effect/src/unstable/cluster/SingleRunner.ts, packages/platform-node/*Cluster*.ts, packages/platform-bun/*Cluster*.ts
Storage and SingleRunner layers require Crypto.Crypto; Node and Bun SQL storage paths provide their platform Crypto layers.
Long-key and legacy-row validation
packages/platform-node/test/cluster/MessageStorageTest.ts, packages/platform-node/test/cluster/SqlMessageStorage.test.ts
Tests verify long-key hashing, duplicate detection, plaintext diagnostic storage, and deduplication of legacy plaintext rows.

Estimated code review effort: 4 (Complex) | ~45 minutes

Suggested labels: bug

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant SqlMessageStorage
  participant Crypto
  participant SQLDatabase
  Client->>SqlMessageStorage: Save envelope with composed primary key
  SqlMessageStorage->>Crypto: Hash primary key with SHA-256
  Crypto-->>SqlMessageStorage: 64-character digest
  SqlMessageStorage->>SQLDatabase: Query or insert hashed message_id
  SqlMessageStorage->>SQLDatabase: Fallback lookup for legacy plaintext message_id
  SQLDatabase-->>SqlMessageStorage: Existing or new message row
  SqlMessageStorage-->>Client: Deduplication result
Loading
🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR hashes long message keys, preserves legacy plaintext lookups, adds migration support, and updates layers/tests to satisfy #6317.
Out of Scope Changes check ✅ Passed The changes stay focused on the overflow fix, crypto wiring, migration, and tests, with no clear unrelated code additions.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot added the bug Something isn't working label Jul 25, 2026
Comment thread packages/effect/src/unstable/cluster/SqlMessageStorage.ts Outdated
@github-actions

Copy link
Copy Markdown
Contributor

Bundle Size Analysis

File Name Current Size Previous Size Difference
basic.ts 6.64 KB 6.64 KB 0.00 KB (0.00%)
batching.ts 9.42 KB 9.42 KB 0.00 KB (0.00%)
brand.ts 6.20 KB 6.20 KB 0.00 KB (0.00%)
cache.ts 10.13 KB 10.13 KB 0.00 KB (0.00%)
config.ts 19.08 KB 19.08 KB 0.00 KB (0.00%)
differ.ts 18.41 KB 18.41 KB 0.00 KB (0.00%)
http-client.ts 20.73 KB 20.73 KB 0.00 KB (0.00%)
logger.ts 10.30 KB 10.30 KB 0.00 KB (0.00%)
metric.ts 8.56 KB 8.56 KB 0.00 KB (0.00%)
optic.ts 7.41 KB 7.41 KB 0.00 KB (0.00%)
pubsub.ts 14.17 KB 14.17 KB 0.00 KB (0.00%)
queue.ts 11.10 KB 11.10 KB 0.00 KB (0.00%)
schedule.ts 10.28 KB 10.28 KB 0.00 KB (0.00%)
schema-class.ts 18.16 KB 18.16 KB 0.00 KB (0.00%)
schema-fromJsonSchemaDocument.ts 27.31 KB 27.31 KB 0.00 KB (0.00%)
schema-representation-roundtrip.ts 24.16 KB 24.16 KB 0.00 KB (0.00%)
schema-string-transformation.ts 12.68 KB 12.68 KB 0.00 KB (0.00%)
schema-string.ts 10.36 KB 10.36 KB 0.00 KB (0.00%)
schema-template-literal.ts 14.38 KB 14.38 KB 0.00 KB (0.00%)
schema-toArbitraryLazy.ts 20.94 KB 20.94 KB 0.00 KB (0.00%)
schema-toCodeDocument.ts 23.42 KB 23.42 KB 0.00 KB (0.00%)
schema-toCodecJson.ts 18.30 KB 18.30 KB 0.00 KB (0.00%)
schema-toEquivalence.ts 18.01 KB 18.01 KB 0.00 KB (0.00%)
schema-toFormatter.ts 17.86 KB 17.86 KB 0.00 KB (0.00%)
schema-toJsonSchemaDocument.ts 21.49 KB 21.49 KB 0.00 KB (0.00%)
schema-toRepresentation.ts 18.55 KB 18.55 KB 0.00 KB (0.00%)
schema.ts 17.43 KB 17.43 KB 0.00 KB (0.00%)
stm.ts 12.07 KB 12.07 KB 0.00 KB (0.00%)
stream.ts 9.36 KB 9.36 KB 0.00 KB (0.00%)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
packages/effect/src/unstable/cluster/SqlMessageStorage.ts (2)

278-369: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated SELECT text across selectByMessageId and insertEnvelope's pg/mysql/orElse branches.

The new selectByMessageId query is identical to the conflict-resolution SELECT already inlined in the pg (Lines 298-303), mysql (Lines 312-318), and orElse/sqlite (Lines 359-364) branches of insertEnvelope. Consider having those branches call selectByMessageId instead of repeating the same SQL literal, to avoid future drift between the copies.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/effect/src/unstable/cluster/SqlMessageStorage.ts` around lines 278 -
369, Reuse the existing selectByMessageId query in the pg, mysql, and orElse
conflict-resolution paths of insertEnvelope instead of repeating the identical
SELECT SQL literals. Pass message_id through the helper while preserving each
branch’s current insert and result-handling behavior.

95-118: 🚀 Performance & Scalability | 🔵 Trivial | ⚖️ Poor tradeoff

Permanent dual-read fallback doubles DB round trips for the common (short-key) case.

mayHaveLegacyRow is true for essentially all keys, forever — there's no way to disable the legacy plaintext fallback once historic rows have aged out. Combined with its usage in saveEnvelope (Lines 454-465) and requestIdForPrimaryKey (Lines 548-556), every short-key save now costs a SELECT before the INSERT (previously a single INSERT ... ON CONFLICT), and every lookup potentially costs two SELECTs. This is a permanent 2x round-trip tax on the hottest path in the storage layer, not a transitional one.

Worth considering a way to opt out of the legacy fallback (e.g., an option threaded through make/layer) once an operator confirms no legacy plaintext message_id rows remain, since this overhead never turns itself off.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/effect/src/unstable/cluster/SqlMessageStorage.ts` around lines 95 -
118, Make the legacy plaintext message_id fallback configurable rather than
unconditionally enabled for short keys. Add an opt-out option to the storage
construction path, including make/layer, and update saveEnvelope and
requestIdForPrimaryKey to skip legacy reads when disabled while preserving the
current dual-read behavior by default.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/effect/src/unstable/cluster/SqlMessageStorage.ts`:
- Around line 278-369: Reuse the existing selectByMessageId query in the pg,
mysql, and orElse conflict-resolution paths of insertEnvelope instead of
repeating the identical SELECT SQL literals. Pass message_id through the helper
while preserving each branch’s current insert and result-handling behavior.
- Around line 95-118: Make the legacy plaintext message_id fallback configurable
rather than unconditionally enabled for short keys. Add an opt-out option to the
storage construction path, including make/layer, and update saveEnvelope and
requestIdForPrimaryKey to skip legacy reads when disabled while preserving the
current dual-read behavior by default.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 72861d84-d62a-4bd7-b9cf-cfc0742da241

📥 Commits

Reviewing files that changed from the base of the PR and between 9d8d85c and 4d0f35e.

📒 Files selected for processing (10)
  • .changeset/hash-sql-message-dedupe-keys.md
  • packages/effect/src/unstable/cluster/Envelope.ts
  • packages/effect/src/unstable/cluster/SingleRunner.ts
  • packages/effect/src/unstable/cluster/SqlMessageStorage.ts
  • packages/platform-bun/src/BunClusterHttp.ts
  • packages/platform-bun/src/BunClusterSocket.ts
  • packages/platform-node/src/NodeClusterHttp.ts
  • packages/platform-node/src/NodeClusterSocket.ts
  • packages/platform-node/test/cluster/MessageStorageTest.ts
  • packages/platform-node/test/cluster/SqlMessageStorage.test.ts

tim-smart and others added 3 commits July 25, 2026 22:55
sqlite does not enforce VARCHAR widths, so legacy plaintext rows there can
hold composed keys longer than 255 characters; only skip the fallback read
on the width-enforcing dialects.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The composed key's components are already readable via the entity_type,
entity_id, tag, and payload columns, so the PR is now a pure code change
with no schema change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread packages/effect/src/unstable/cluster/SqlMessageStorage.ts Outdated
Comment thread packages/effect/src/unstable/cluster/SqlMessageStorage.ts Outdated
Keys within 255 characters stay byte-identical plaintext, keeping
deduplication compatible with rows written by previous versions with no
fallback reads. The plaintext fallback now covers only over-length legacy
keys on sqlite, whose TEXT column stored them before digests existed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

4.0 bug Something isn't working

Projects

Status: Discussion Ongoing

1 participant