Last updated 2026-05-28
Conformance Suite
store/conformance.RunConformance is the driver-agnostic
spec for notify.Store. Memory is the differential
reference; every other driver passes the same 24 leaf subtests
across six categories or its CI check goes red and the merge is
blocked.
When you'd use this
- You are adding a new store driver and need to verify it.
- You are debugging an existing driver — a failure path like
TestConformance/postgres/Concurrency/ConcurrentCreate_SameKey_SingleWinnertells you exactly which semantic broke. - You want to know which bug classes the suite probes before you ship code.
Wiring a driver into the suite
package mydriver_test
import ( "testing" "github.com/elloloop/notify" "github.com/elloloop/notify/store/conformance" "github.com/elloloop/notify/store/mydriver")
func TestConformance(t *testing.T) { conformance.RunConformance(t, conformance.Driver{ Name: "mydriver", New: func(t *testing.T) notify.Store { // Build a fresh, empty Store. Register cleanup with t.Cleanup // if the driver needs per-test teardown so one subtest never // leaks state into the next. return mydriver.New(...) }, })} RunConformance wraps everything in
t.Run(d.Name, …), so the test path on failure is
TestConformance/<driver>/<Category>/<Subtest>.
The 24 subtests
| Category | Subtest | What it catches |
|---|---|---|
| Core CRUD | CreateGet | basic round-trip; id is assigned by Store, not caller |
GetNotFound | unknown id returns ErrNotFound (not a typed driver error) | |
Idempotency | second create with same (tenant, user, notification_id) returns created=false, stamps existing id, leaves stored row unchanged | |
StatusTransitions | each status stamps its matching *_at_ms field; unknown id returns ErrNotFound | |
CursorWalk_ThreePages | multi-page traversal with cursors; last page returns empty cursor | |
UnreadFilterAndCount | unreadCount is independent of the page window and UnreadOnly filter | |
DeviceUpsertRotation | re-registering the same (tenant, user, device_type) rotates token in place; doesn't create a new row | |
UserIsolation | cross-user and cross-tenant reads surface as ErrNotFound | |
| Pagination | QueryUserNotifications_AllPagesReturnEveryRow | full-set traversal across many pages — no row dropped, no row duplicated |
StrictLessThanCutoff | cursor semantics are strict <, never <= (otherwise boundary rows repeat) | |
| FreshTenant | QueryUserNotifications_Empty | query against a never-seen tenant returns empty, not error |
GetNotification_NotFound | same for a single Get | |
ListDevices_Empty | same for device listing | |
| RoundTrip | StringFields_OnCreate | adversarial values — emoji, unicode, SQL-shaped, leading/trailing whitespace, 10k chars — survive byte-for-byte |
Int64_Fidelity_Timestamps | extreme int64 values (max_int64, 2^53+1) round-trip exactly — proves the driver does not silently coerce to float64 | |
LargePayload_Body | 64 KiB and 512 KiB bodies round-trip unmodified | |
| Concurrency | ConcurrentCreate_DistinctKeys_NoLostWrites | 16 concurrent creates with distinct keys land 16 rows — the WAL/applier serializes correctly |
ConcurrentCreate_SameKey_SingleWinner | The headline test. 16 concurrent creates with the same key land exactly one row; all callers see the same canonical id | |
ConcurrentUpsertDevice_SameKey_SingleRow | concurrent device upserts on the same (tenant, user, device_type) land exactly one row; token reflects one of the writers | |
ConcurrentUpdateStatus_NoError | concurrent status transitions don't error; the final state matches one of the racers | |
ConcurrentReadYourWrites_QueryAfterCreate | a writer's very next query sees its own row (no stale-read window) | |
| KeyEdge | NotificationID_LongValue | 256-char notification id round-trips; re-create is idempotent |
NotificationID_SeparatorBytesDoNotCollide | distinct triples with separator-byte payloads do not collide on the composite key (length-prefix encoding canary) | |
DeviceType_CaseSensitive_SeparateRows | "android" and "Android" are distinct composite keys (byte-exact equality) |
A representative subtest, walked through
Concurrency/ConcurrentCreate_SameKey_SingleWinner is the
subtest that catches the broadest class of bugs. It launches 16
goroutines that all call CreateNotification with the
same (tenant, user, notification_id):
var wg sync.WaitGroupids := make([]string, 16)wons := make([]bool, 16)
for i := 0; i < 16; i++ { i := i wg.Add(1) go func() { defer wg.Done() n := mkNotif("acme", "u1", "same-key", int64(1000+i)) won, err := s.CreateNotification(ctx, n) if err != nil { t.Errorf("create %d: %v", i, err) } ids[i] = n.ID wons[i] = won }()}wg.Wait()
// Exactly one true in wons.trueCount := 0for _, w := range wons { if w { trueCount++ }}if trueCount != 1 { t.Fatalf("expected exactly 1 winner, got %d", trueCount)}
// Every reported ID is the canonical id.canonical := ids[0]for i, id := range ids { if id != canonical { t.Fatalf("racer %d saw %q, want %q", i, id, canonical) }}This catches:
- Missing composite-unique constraint — drivers that rely on a query-then-create pattern without server-side enforcement let multiple rows land. (This is precisely what tripped EntDB v1.32.1 before v2's schema-aware enforcement.)
- Wrong loser path — drivers that survive the constraint but return a non-canonical id to the loser.
- Lost-update bugs — drivers that silently overwrite the original row's title / body / status with a racer's values.
CONFORMANCE.md per driver
Each non-memory driver ships a CONFORMANCE.md with the
full per-subtest table, status, and a one-line root-cause note for
anything red. Templates to follow:
- store/postgres/CONFORMANCE.md — 24/24, design notes on idempotency / pagination / migrations
- store/entdb/CONFORMANCE.md — 24/24 on v2.0.5, the v1.32.1 → v2 flip story, upstream items #U1–#U4
CI gate
.github/workflows/conformance.yml declares one job per
driver in a matrix. The check name is
Conformance / <driver>, and that is what branch
protection pins. A new driver landing PR must add itself to the
matrix and turn the new check green.
matrix: include: - driver: memory package: ./store/memory/... - driver: postgres package: ./store/postgres/... need_postgres: true - driver: entdb package: ./store/entdb/... build_tag: realentdb need_entdb: true