Skip to content

driver-sql (MySQL): the #11627 hash shadow drops the NULL-safe organization key part — a shadow-carried org-scoped UNIQUE reintroduces #5030's NULL-org zero-constraint #12998

Description

@os-elon

Found while measuring #12978 (see the measurement comment there); filed separately because it is #11627's seam, not service-messaging's.

The gap

When MySQL refuses a declared UNIQUE index directly, syncDeclaredIndexes's catch hands the shadow route the bare column list: createHashShadowUniqueIndex(tableName, name, columns) (packages/drivers/driver-sql/src/sql-driver.ts). norm.nullSafeColumns — the ADR-0120 D3 marker saying which parts must materialize as COALESCE(organization_id, '__global__') — is not passed and not consulted. The shadow's generation expression hashes the RAW columns: UNHEX(SHA2(CONCAT(col1, 0x1f, ..., organization_id), 256)).

CONCAT returns NULL when any argument is NULL — deliberately, and pinned as CORRECT for a plain composite ("hashes a composite tuple, keeps any-NULL tuples non-conflicting", sql-driver-11627-hash-shadow-key.test.ts): a tuple containing NULL conflicts with nothing, matching MySQL's own composite-UNIQUE semantics.

But for an ORG-SCOPED unique that is exactly the semantics ADR-0120 D3 exists to replace: the declared key coalesces a NULL organization into the '__global__' bucket so NULL-org rows DO collide with each other (#5030's fix — "NULL-distinct means field-level unique: true is silently zero constraint on a single-tenant stack"). Under the shadow, every NULL-organization row hashes to NULL and is unconstrained. The shadow route therefore silently reintroduces #5030's defect shape, precisely on the deployments (single-tenant / global-default rows) where organization_id is NULL.

Live members today

sys_notification_preference (user_id, topic, channel, unique: 'organization') and sys_notification_subscription (topic, principal, unique: 'organization') — both currently over unbounded TEXT columns on MySQL, so their org-uniques already take the shadow route (ER_BLOB_KEY_WITHOUT_LENGTH → shadow). After #12978's bounds land they stay shadow-carried, because their bounded key widths (774 and 975 chars at 4 bytes/char) still exceed InnoDB's 3072-byte key budget (ER_TOO_LONG_KEY → shadow). The rows most exposed are the admin-global defaults these objects are designed to hold (user_id: '*' / topic '*' rows written without an organization).

Directions (not prescribing)

  • Hash the DECLARED key rather than the raw columns: for a nullSafeColumns part, put COALESCE(col, '__global__') inside the CONCAT so the shadow enforces the same key the direct index would have. Note the ADR-0120 D4 duplicate pre-flight probes the COALESCE'd key too, so plan-time probing stays consistent.
  • Or refuse the shadow for NULL-safe uniques and keep the named refusal — weaker, but honest until the expression form lands.

Either way the choice should be explicit; today the weakening is silent and the boot log reports the constraint as carried.

Generated by Claude Code · session_01CPrUz21stTFhJRUirdc4yw · found on #12978

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingdomain:enginepriority:p1High: required for production / M2

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions