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_SingleWinner tells 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

CategorySubtestWhat it catches
Core CRUDCreateGetbasic round-trip; id is assigned by Store, not caller
GetNotFoundunknown id returns ErrNotFound (not a typed driver error)
Idempotencysecond create with same (tenant, user, notification_id) returns created=false, stamps existing id, leaves stored row unchanged
StatusTransitionseach status stamps its matching *_at_ms field; unknown id returns ErrNotFound
CursorWalk_ThreePagesmulti-page traversal with cursors; last page returns empty cursor
UnreadFilterAndCountunreadCount is independent of the page window and UnreadOnly filter
DeviceUpsertRotationre-registering the same (tenant, user, device_type) rotates token in place; doesn't create a new row
UserIsolationcross-user and cross-tenant reads surface as ErrNotFound
PaginationQueryUserNotifications_AllPagesReturnEveryRowfull-set traversal across many pages — no row dropped, no row duplicated
StrictLessThanCutoffcursor semantics are strict <, never <= (otherwise boundary rows repeat)
FreshTenantQueryUserNotifications_Emptyquery against a never-seen tenant returns empty, not error
GetNotification_NotFoundsame for a single Get
ListDevices_Emptysame for device listing
RoundTripStringFields_OnCreateadversarial values — emoji, unicode, SQL-shaped, leading/trailing whitespace, 10k chars — survive byte-for-byte
Int64_Fidelity_Timestampsextreme int64 values (max_int64, 2^53+1) round-trip exactly — proves the driver does not silently coerce to float64
LargePayload_Body64 KiB and 512 KiB bodies round-trip unmodified
ConcurrencyConcurrentCreate_DistinctKeys_NoLostWrites16 concurrent creates with distinct keys land 16 rows — the WAL/applier serializes correctly
ConcurrentCreate_SameKey_SingleWinnerThe headline test. 16 concurrent creates with the same key land exactly one row; all callers see the same canonical id
ConcurrentUpsertDevice_SameKey_SingleRowconcurrent device upserts on the same (tenant, user, device_type) land exactly one row; token reflects one of the writers
ConcurrentUpdateStatus_NoErrorconcurrent status transitions don't error; the final state matches one of the racers
ConcurrentReadYourWrites_QueryAfterCreatea writer's very next query sees its own row (no stale-read window)
KeyEdgeNotificationID_LongValue256-char notification id round-trips; re-create is idempotent
NotificationID_SeparatorBytesDoNotCollidedistinct 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.WaitGroup
ids := 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 := 0
for _, 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:

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.

.github/workflows/conformance.yml — abridged
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

Related