mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 06:19:49 +00:00
6f8b6eb958
`storetest.NewPostgres` and its in-package twin `testStorePostgres` ran the
full 87-file migration chain for EVERY test that asked for a store. The schema
is identical for every one of them, so building it per test bought nothing.
Measured on this box, at 8910ad06, before any change:
per store construction CREATE DATABASE 21ms
migrate chain 385ms <- 92% of the toll
DROP DATABASE 13ms
Postgres store leg wall 495.8s / test-binary CPU 93.7s
18% of that leg's wall clock was CPU inside the test binary. The other 82% was
the binary waiting on postgres, and the container's own cgroup accounting put
407.2s of CPU on the other side of the socket — 4.3x what the test process
burned. The per-test distribution is flat (slowest test 3.9%, top twenty 19%),
so there was no fat test to find: ~850 tests were each paying a fixed toll.
`CREATE DATABASE ... TEMPLATE <migrated>` is a file-level copy, and it is the
Postgres analogue of the SQLite template-and-copy this suite has had since
IDEA-1914. Both helpers now build one migrated template per test binary
(sync.Once, RWMutex-guarded against Cleanup) and clone per test.
Results, three repeats per driver on a quiet box:
Postgres wall 495.8s -> 224.4s -54.7%
Postgres container CPU 407.2s -> 167.6s -58.8%
SQLite wall 185.6s -> 186.0s unchanged (the control)
The change DELETES work rather than rearranging it — container CPU is the
figure that says so, and the flat SQLite row is what lets the Postgres delta be
attributed to the template rather than to the environment.
Predicted before building, and recorded on the trail before the measurement
existed: container CPU between 134s and 208s. Actual 167.6s. Rearranged, that
yields 850 store constructions per leg, against a static lower bound of 708 and
a dynamic ceiling of 970 — two instruments sharing no mechanism agreeing on a
number the bracket could only bound.
THE REGRESSION THIS INVITES, and the test that catches it:
A template that silently stopped being used would pass every other test in the
repository. A clone and a per-test migration produce byte-identical databases,
so no assertion about schema or behaviour distinguishes them, and the
build-once counter reads 1 either way — it proves the template was BUILT once,
not that anything was CLONED from it. Drop the TEMPLATE clause and the whole
cost returns silently.
So the template is stamped after migrating with a marker only a file-level copy
can carry, and both helpers assert it on the database they hand out. A table
comment carries it because it lives in the database's own pg_description; a
DATABASE comment would not, since those live in the shared pg_shdescription
keyed by an oid the clone does not inherit. schema_migrations.applied_at was
rejected as the carrier: RFC3339 is second-resolution and a fresh 385ms
migration can land inside the same second as the template's.
Mutation matrix — the class has exactly two members, because the import cycle
forces the helper to be duplicated:
storetest twin, TEMPLATE removed exactly ONE test fails (the marker test);
the other five pass
in-package helper, TEMPLATE removed exactly ONE test fails (its counterpart),
and the run takes 21.2s against 0.9s
Both controls were run only after committing, so restoring from HEAD could not
delete uncommitted work.
No test weakened: per-test outcome sets compared before and after on both
drivers. Zero tests disappeared, zero changed outcome. The only deltas are the
three new mechanism tests, which pass on Postgres and skip on SQLite.
Cleanup() gains the Postgres half; its three existing callers need no change,
because the invariant they rely on — "tears down whatever templates this binary
built" — is what widened. A binary killed mid-run leaks its pad_tmpl_* database,
bounded by the container's lifetime, since the test stack is per-worktree
(TASK-2708) and `make test-pg` tears it down with -v.
Codex round 2 (enumeration-shaped, aimed at the two helpers' divergence) found
one real P2: the in-package helper's FAILURE path dropped its half-built clone
without WITH (FORCE), while the twin's used it. NewPostgres can fail with its
pool already open, since a migration error arrives after the connection does,
and a plain DROP is refused while a session is attached — so the un-forced form
leaked a pad_test_* database on exactly the path that most needs cleaning up.
Pre-existing and asymmetric; corrected here because this change makes the two
helpers a matched pair. Class swept: all three remaining DROP DATABASE sites in
the repository now use WITH (FORCE), which is the whole population.
Round 2 also noted the helpers differ in admin-connection plumbing — the
in-package one goes through newPostgresConn (guarded driver, Ping, pool limits)
and the twin through a raw sql.Open. Accepted rather than unified: it predates
this change, the SQL either side is fixed and parameter-free, and the only
difference is connection validation, which the twin's t.Fatalf surfaces anyway.
Deliverable 3 (t.Parallel on the SQLite leg) follows as a separate package under
the same task, per the lead's split ruling.
Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR
90 lines
3.5 KiB
Go
90 lines
3.5 KiB
Go
package store
|
|
|
|
import (
|
|
"os"
|
|
"sync/atomic"
|
|
"testing"
|
|
)
|
|
|
|
// TASK-2900. The in-package mirror of storetest/postgres_test.go's mechanism
|
|
// tests, and the reason there are two is the reason there are two helpers:
|
|
// internal/store's white-box tests cannot import storetest without an import
|
|
// cycle, so the template machinery is duplicated, and a mechanism test that
|
|
// covered only one copy would leave the copy 700 tests actually use unguarded.
|
|
//
|
|
// This file is the one that matters most by volume. `testStore(t)` routes here
|
|
// under PAD_TEST_POSTGRES_URL, and it is called from 699 sites in this package.
|
|
|
|
// TestTestStorePostgres_DatabasesAreClonedNotMigrated pins the mechanism.
|
|
//
|
|
// Nothing else can. A clone and a per-test migration produce byte-identical
|
|
// databases, so no assertion about schema or behaviour distinguishes them:
|
|
// revert `CREATE DATABASE ... TEMPLATE` to a plain `CREATE DATABASE` and all
|
|
// 902 other tests in this package still pass while the whole per-test
|
|
// migration cost silently returns. Verified by mutation against the storetest
|
|
// twin, where dropping the TEMPLATE clause failed exactly one test — this one's
|
|
// counterpart — and left the other five green.
|
|
//
|
|
// The discriminator is a marker stamped on the template after migrating, which
|
|
// a file-level clone carries and a fresh migration cannot produce.
|
|
func TestTestStorePostgres_DatabasesAreClonedNotMigrated(t *testing.T) {
|
|
baseURL := os.Getenv("PAD_TEST_POSTGRES_URL")
|
|
if baseURL == "" {
|
|
t.Skip("PAD_TEST_POSTGRES_URL not set; this test is about the Postgres fixture")
|
|
}
|
|
s := testStore(t)
|
|
|
|
pgTemplateMu.RLock()
|
|
want := pgTemplateMarker(pgTemplateName)
|
|
pgTemplateMu.RUnlock()
|
|
|
|
var got *string
|
|
if err := s.db.QueryRow(`SELECT obj_description('schema_migrations'::regclass, 'pg_class')`).Scan(&got); err != nil {
|
|
t.Fatalf("read template marker from the handed-out database: %v", err)
|
|
}
|
|
if got == nil {
|
|
t.Fatal("the database handed out carries NO template marker — it was migrated from scratch " +
|
|
"rather than cloned; the per-test migration cost this package's 699 testStore(t) call sites " +
|
|
"used to pay is back")
|
|
}
|
|
if *got != want {
|
|
t.Fatalf("template marker mismatch: got %q, want %q", *got, want)
|
|
}
|
|
}
|
|
|
|
// TestTestStorePostgres_TemplateBuildsOnce asserts the migration chain runs at
|
|
// most once per test binary. It is NOT a substitute for the test above —
|
|
// deliberately noted, because the counter reads 1 whether or not anything was
|
|
// cloned from the template, which is exactly the trap the mutation exposed.
|
|
func TestTestStorePostgres_TemplateBuildsOnce(t *testing.T) {
|
|
if os.Getenv("PAD_TEST_POSTGRES_URL") == "" {
|
|
t.Skip("PAD_TEST_POSTGRES_URL not set")
|
|
}
|
|
_ = testStore(t)
|
|
_ = testStore(t)
|
|
|
|
if got := atomic.LoadInt32(&pgTemplateBuilds); got != 1 {
|
|
t.Fatalf("buildPostgresTemplate ran %d times, want exactly 1 (sync.Once)", got)
|
|
}
|
|
}
|
|
|
|
// TestTestStorePostgres_ClonesAreIsolated proves each caller gets its own
|
|
// writable database rather than a shared one — the failure a template
|
|
// optimisation invites is handing out the template itself.
|
|
func TestTestStorePostgres_ClonesAreIsolated(t *testing.T) {
|
|
if os.Getenv("PAD_TEST_POSTGRES_URL") == "" {
|
|
t.Skip("PAD_TEST_POSTGRES_URL not set")
|
|
}
|
|
sA := testStore(t)
|
|
ws := createTestWorkspace(t, sA, "Clone Isolation A")
|
|
|
|
sB := testStore(t)
|
|
leaked, err := sB.GetWorkspaceBySlug(ws.Slug)
|
|
if err != nil {
|
|
t.Fatalf("query store B: %v", err)
|
|
}
|
|
if leaked != nil {
|
|
t.Fatalf("workspace %q created in store A is visible in store B; clones are not isolated", ws.Slug)
|
|
}
|
|
}
|