Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
f685ced
db: dialect-aware Open plus a rebinding DB/Tx wrapper
distronode-com Sep 4, 2026
dead65e
db: PostgreSQL migration set, generated once from the SQLite one
distronode-com Sep 4, 2026
65099c3
db: compare the migrated schemas across both engines
distronode-com Sep 4, 2026
dfd7bd7
db: thread *db.DB / *db.Tx through every call site
distronode-com Sep 4, 2026
62dc81e
db: port the SQL that only SQLite accepts
distronode-com Sep 4, 2026
e675520
booking: hold an advisory lock on the host across the overlap check
distronode-com Sep 4, 2026
c1478c1
test,docs,ci: run the suite against PostgreSQL
distronode-com Sep 4, 2026
78dab82
db: classify constraint violations by code, not by English message
distronode-com Sep 4, 2026
4b86071
db: the last four things only SQLite accepted
distronode-com Sep 4, 2026
9c7792e
db: classify constraint violations by SQLite's codes too, not its prose
distronode-com Sep 4, 2026
26f17b6
docs: record the SQLite error-code correction on the branch log
distronode-com Sep 4, 2026
cf972cf
db: pin every TEXT timestamp column to COLLATE "C"
distronode-com Sep 6, 2026
a1aa94b
handler: verify RETURNING position on both engines
distronode-com Sep 6, 2026
dce3568
db: delete the bare Open, readiness goes through the handle
distronode-com Sep 6, 2026
5297bbf
db,config: make the Postgres pool size configurable
distronode-com Sep 6, 2026
d18bfab
docs: record Boundary 7 on the branch log
distronode-com Sep 6, 2026
5c9e5c8
chore: drop the fork's branch log from the pull request
distronode-com Sep 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,59 @@ jobs:

- name: go test
run: go test ./...

# The same suite against PostgreSQL. Separate job rather than a matrix on `check`
# because only the Go half is engine-dependent: svelte-check and pnpm build would
# run twice for no reason. The frontend build is still needed here, since the Go
# binary go:embeds frontend/build and `go build` fails without it.
postgres:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env:
POSTGRES_PASSWORD: calnode_ci
POSTGRES_DB: calnode
ports:
- 5432:5432
# Without a health check the first connection races the server's startup,
# which fails as "connection refused" and reads like a bad DSN.
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4
with:
version: 10.32.1

- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
cache-dependency-path: frontend/pnpm-lock.yaml

- name: Install frontend deps
working-directory: frontend
run: pnpm install --frozen-lockfile

- name: Build frontend (required by the Go embed)
working-directory: frontend
run: pnpm build

- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true

# CALNODE_TEST_POSTGRES_DSN is what switches internal/dbtest onto Postgres.
# Unset — which is every other job and every local run — the suite uses
# in-memory SQLite exactly as before, so this job is additive: it cannot change
# what a contributor sees.
- name: go test (PostgreSQL)
env:
CALNODE_TEST_POSTGRES_DSN: postgres://postgres:calnode_ci@127.0.0.1:5432/calnode?sslmode=disable
run: go test ./...
4 changes: 3 additions & 1 deletion DEPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ This guide covers a generic Docker deploy and a step-by-step **Railway** deploy
| `CALNODE_RECOVERY_SECRET` | recommended | — | Escrow secret so the data key can be recovered if the encryption key is rotated/lost. Store it somewhere separate. |
| `BASE_URL` | **yes (prod)** | `http://localhost:3000` | Identity host — admin UI, OAuth callbacks, invite links. **Must include the scheme** (`https://booking.example.com`). The `https://` prefix flips the app into production mode (secure cookies, encryption-key enforcement). |
| `PUBLIC_BASE_URL` | no | = `BASE_URL` | Booker-facing host for booking links/emails, if different from the identity host. |
| `DATABASE_URL` | no | `sqlite://./data/calnode.db` | Point at the persistent volume, e.g. `sqlite:///data/calnode.db`. |
| `DATABASE_URL` | no | `sqlite://./data/calnode.db` | Point at the persistent volume, e.g. `sqlite:///data/calnode.db`. A `postgres://user:pass@host:5432/dbname` URL selects PostgreSQL instead; anything else is SQLite. |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Postgres is documented here, but LITESTREAM_REPLICA_URL two rows down is still unqualified “recommended,” and the image entrypoint always runs Litestream against /data/calnode.db. Please spell out that Litestream is SQLite-only and that postgres:// deployments need native Postgres backups (or skip Litestream when the URL scheme is postgres).

Technical details
# Scope Litestream to SQLite in deploy docs

## Affected sites
- `DEPLOY.md` — this row + `LITESTREAM_REPLICA_URL`
- `entrypoint.sh` — restore/replicate of `/data/calnode.db` whenever replica URL is set

## Required outcome
- Operators choosing Postgres know not to rely on Litestream for the app DB
- Optional runtime guard so both env vars together do not silently mislead

| `DB_MAX_OPEN_CONNS` | no | `10` | **PostgreSQL only.** Size of the connection pool. It has to fit inside the server's own `max_connections`, shared with every other client — raise it for a busy instance on a well-sized server, lower it behind PgBouncer or on a shared one. Must be a positive integer; anything else is ignored (with a warning) and the default stands. **Ignored on SQLite, which is always 1**: the single connection is what serialises write transactions, not a tuning choice. |
| `DB_MAX_IDLE_CONNS` | no | `5` | **PostgreSQL only.** How many idle connections the pool keeps rather than closing. Positive integer, and capped at `DB_MAX_OPEN_CONNS` (a larger value is clamped, since `database/sql` would silently do the same). |
| `PORT` | no | `3000` | The app listens on `$PORT`. Many platforms inject their own (Railway injects `8080`) — let them. |
| `EMAIL_SMTP_HOST` / `_PORT` / `_USER` / `_PASS` | no¹ | — / `587` | SMTP. Can also be set later in Settings → Email (DB-stored, encrypted). |
| `EMAIL_SMTP_TLS` / `_STARTTLS` | no | `false` | `STARTTLS` for 587, implicit `TLS` for 465. |
Expand Down
4 changes: 2 additions & 2 deletions cmd/calnode/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,14 @@ func main() {
slog.Warn("Google OAuth NOT configured — GOOGLE_CLIENT_ID is empty")
}

database, err := db.Open(cfg.DatabaseURL)
database, err := db.OpenDB(cfg.DatabaseURL)
if err != nil {
logger.Error("failed to open database", "error", err)
os.Exit(1)
}
defer database.Close()

if err := db.Migrate(database); err != nil {
if err := database.Migrate(); err != nil {
logger.Error("failed to run migrations", "error", err)
os.Exit(1)
}
Expand Down
4 changes: 2 additions & 2 deletions cmd/calnode/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,14 @@ func runMCPStdio(_ []string) {
logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: cfg.LogLevel}))
slog.SetDefault(logger)

database, err := db.Open(cfg.DatabaseURL)
database, err := db.OpenDB(cfg.DatabaseURL)
if err != nil {
logger.Error("mcp: failed to open database", "error", err)
os.Exit(1)
}
defer database.Close()

if err := db.Migrate(database); err != nil {
if err := database.Migrate(); err != nil {
logger.Error("mcp: failed to run migrations", "error", err)
os.Exit(1)
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/calnode/recover_key.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ func runRecoverKey(args []string) {
dbURL = "sqlite://./data/calnode.db"
}

database, err := db.Open(dbURL)
database, err := db.OpenDB(dbURL)
if err != nil {
fmt.Fprintf(os.Stderr, "error: open database: %v\n", err)
os.Exit(1)
Expand Down
2 changes: 1 addition & 1 deletion cmd/calnode/reset_admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ func runResetAdmin(args []string) {

cfg := config.Load()

database, err := db.Open(cfg.DatabaseURL)
database, err := db.OpenDB(cfg.DatabaseURL)
if err != nil {
fmt.Fprintf(os.Stderr, "error: failed to open database: %v\n", err)
os.Exit(1)
Expand Down
2 changes: 1 addition & 1 deletion cmd/calnode/rotate_key.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ func runRotateKey(args []string) {
dbURL = "sqlite://./data/calnode.db"
}

database, err := db.Open(dbURL)
database, err := db.OpenDB(dbURL)
if err != nil {
fmt.Fprintf(os.Stderr, "error: open database: %v\n", err)
os.Exit(1)
Expand Down
70 changes: 56 additions & 14 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,15 +67,32 @@ app you must `pnpm build` in `frontend/` **and** rebuild/restart the Go binary

---

## 4. Persistence (SQLite) — and the single-connection rule

- `internal/db`: opens SQLite with **`SetMaxOpenConns(1)`** + `SetMaxIdleConns(1)`,
**WAL** journal mode, `busy_timeout=5000`. One writer connection by design.
- Migrations: **goose** SQL files in `internal/db/migrations/` (00001→00029). Run
automatically on startup. `ALTER TABLE ADD COLUMN` is reversible-by-convention
only (SQLite can't easily drop columns).

### ⚠️ The single-connection gotcha (bit us once)
## 4. Persistence (SQLite or PostgreSQL) — and the single-connection rule

- `internal/db`: `OpenDB(DATABASE_URL)` picks the engine from the URL scheme. A
`postgres://` URL selects PostgreSQL; everything else (`sqlite://./rel`,
`sqlite:///abs`, `:memory:`, a bare path) selects SQLite, so every configuration
that worked before still works unchanged.
- SQLite: **`SetMaxOpenConns(1)`** + `SetMaxIdleConns(1)`, **WAL** journal mode,
`busy_timeout=5000`. One writer connection by design.
- PostgreSQL: a normal pool (10 open / 5 idle). The single connection above is a
SQLite constraint, not a Calnode design choice, and carrying it over would
serialise the whole instance on an engine with its own concurrency control.
- The handle rebinds placeholders: Calnode's SQL is written once with `?` and
rewritten to `$1…$n` on PostgreSQL. ⚠️ `db.DB` embeds `*sql.DB` and the field is
exported, so `h.DB.Query(...)` compiles and **skips rebinding** — it passes on
SQLite and fails on PostgreSQL with a syntax error far from the edit. Call the
wrapper's own methods. The handful of statements no single spelling covers use
`Dialect.SQL(sqlite, postgres)`.
- Timestamps are computed in Go (`internal/dbtime`) rather than by the engine;
`datetime('now')` and `strftime` do not exist in PostgreSQL. `dbtime` keeps the
two layouts the schema already stores, byte-identical to what SQLite wrote.
- Migrations: **goose** SQL files in `internal/db/migrations/{sqlite,postgres}/` —
one schema, two spellings, same version numbers. Run automatically on startup.
`ALTER TABLE ADD COLUMN` is reversible-by-convention only (SQLite can't easily
drop columns).

### ⚠️ The single-connection gotcha (bit us once) — SQLite only

With one connection, **never run a query while a `rows` cursor from the same pool
is still open** (i.e. inside a `for rows.Next()` loop). The open cursor holds the
Expand All @@ -85,9 +102,32 @@ exceeded` (not "database is locked"). **Pattern:** read the cursor fully into a
slice, close it, then loop. See `Handler.assignedHosts`, the calendar reconciler,
`Reschedule`. (Memory: `sqlite-single-connection`.)

Bonus property: because booking transactions serialize on the single connection,
the app-level overlap check reliably guards **all** hosts (not just the one the
partial unique index covers) — no TOCTOU between concurrent bookings.
This is a consequence of the single connection, so it does not apply on
PostgreSQL — but the materialise-first pattern must stay, because it is the only
thing keeping those three paths working on SQLite.

### The double-booking guarantee, per engine

The app-level overlap check reads "is this host free?" and then writes on the
answer. What keeps that free of TOCTOU races differs:

- **SQLite** — booking transactions serialize on the single connection, so the
check reliably guards **all** hosts, not just the one the partial unique index
covers.
- **PostgreSQL** — the pool has many connections, so two overlapping bookings could
both clear the check. `booking.lockHosts` takes **`pg_advisory_xact_lock`** on
each host id before the first overlap read, in `Create`, `Reschedule` and
`ReassignHost`. The key is SHA-256 of `"calnode:booking:host:" + hostID`, first
eight bytes as an int64; ids are locked in sorted order so two transactions
needing the same pair cannot deadlock. The lock ends with the transaction, so
there is nothing to release. On SQLite it is a no-op.

Both engines also carry the partial unique index
`idx_bookings_no_double (host_id, start_at) WHERE status != 'cancelled'`. It catches
an **identical** start time and nothing else — a partial overlap is two distinct
keys — which is why the app-level check and the lock exist and why all three stay.
Measured on PostgreSQL, 40 races between overlapping-but-not-identical slots: with
the lock, 0 double bookings; without it, 39, of which the index caught one.

### Data model (key tables)

Expand Down Expand Up @@ -264,7 +304,7 @@ members.
mode (role-tagged), then loads each host's availability **concurrently**
(goroutines) — the slow part is one Google free/busy round-trip per host, so
parallelizing turns N sequential calls into ~one call's latency. DB queries
serialize on the single connection (fast); only the network overlaps. Response
serialize on SQLite's single connection (fast); only the network overlaps. Response
includes a `hosts` metadata map (id→name/avatar) for rendering faces.
- **Busy** for a host = every non-cancelled booking they attend, via
`booking_hosts` (NOT just `bookings.host_id`) — so a non-primary Group/fixed seat
Expand Down Expand Up @@ -704,7 +744,9 @@ as the desired state:
## 17. Cross-cutting gotchas (read before editing)

1. **SQLite single connection** — never query inside an open cursor; materialize
first (§4).
first (§4). Harmless on PostgreSQL, but the pattern stays either way.
On PostgreSQL the same single connection is also what the booking overlap check
loses, which `pg_advisory_xact_lock` replaces (§4).
2. **All times UTC** in storage; convert at the edges. The slot busy-window must be
widened for tz boundaries.
3. **Frontend is embedded at compile time** — `pnpm build` + rebuild Go to see
Expand Down
4 changes: 4 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ toolchain go1.26.6

require (
github.com/disintegration/imaging v1.6.2
github.com/jackc/pgx/v5 v5.10.0
github.com/joho/godotenv v1.5.1
github.com/modelcontextprotocol/go-sdk v1.6.1
github.com/pressly/goose/v3 v3.27.1
Expand All @@ -22,6 +23,9 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/jsonschema-go v0.4.3 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
Expand Down
14 changes: 14 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
Expand All @@ -18,6 +19,14 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
Expand All @@ -40,6 +49,9 @@ github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfv
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
Expand All @@ -66,6 +78,8 @@ golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.28.1 h1:XpLbkYVQ24E8tX5u8+yWGvaxerxkR/S4zqxI8ZoSBuc=
Expand Down
93 changes: 93 additions & 0 deletions internal/booking/concurrency_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package booking_test

import (
"context"
"errors"
"sync"
"testing"
"time"

"github.com/calnode/calnode/internal/booking"
"github.com/calnode/calnode/internal/dbtest"
)

// TestCreate_concurrentPartialOverlap_postgres is the test for the guarantee
// pg_advisory_xact_lock replaces.
//
// The two slots deliberately OVERLAP without SHARING a start time. That is the case
// no index catches: idx_bookings_no_double is UNIQUE(host_id, start_at), so 10:00
// and 10:15 are two distinct keys and both inserts satisfy it. The only thing
// standing between them is hostBusy's read, and on a multi-connection pool two
// transactions can both take that read before either writes. If the lock is not
// held, this test double-books.
//
// It is skipped on SQLite, where the race is not reachable: db.SetMaxOpenConns(1)
// means the second transaction cannot begin until the first has committed.
func TestCreate_concurrentPartialOverlap_postgres(t *testing.T) {
database := dbtest.RequirePostgres(t)
svc := booking.New(database)
hostID := seedHost(t, database)
etID := seedEventType(t, database, hostID)

// Enough rounds that a lost race is very unlikely to go unseen. One round is
// not evidence: two goroutines miss each other's window often enough that an
// unlocked build passes a single round most of the time.
const rounds = 40

for round := 0; round < rounds; round++ {
// A fresh, non-overlapping window per round, so a round is independent of
// every earlier round's surviving booking.
base := slot(0, 0).Add(time.Duration(round) * time.Hour)
first := [2]time.Time{base, base.Add(30 * time.Minute)}
second := [2]time.Time{base.Add(15 * time.Minute), base.Add(45 * time.Minute)}

var wg sync.WaitGroup
errs := make([]error, 2)
start := make(chan struct{})
for i, window := range [2][2]time.Time{first, second} {
wg.Add(1)
go func(i int, from, to time.Time) {
defer wg.Done()
<-start // line both up so the transactions truly overlap
_, errs[i] = svc.Create(context.Background(), booking.CreateParams{
EventTypeID: etID,
HostIDs: []string{hostID},
StartAt: from,
EndAt: to,
Organizer: booking.Attendee{
Name: "Alice",
Email: "alice@example.com",
},
})
}(i, window[0], window[1])
}
close(start)
wg.Wait()

var created int
for i, err := range errs {
switch {
case err == nil:
created++
case errors.Is(err, booking.ErrDoubleBooked):
// the expected loser
default:
t.Fatalf("round %d: booking %d: unexpected error: %v", round, i, err)
}
}
if created != 1 {
t.Fatalf("round %d: %d of 2 overlapping bookings were created; want exactly 1 (errors: %v, %v)",
round, created, errs[0], errs[1])
}
}

// And the database agrees: one booking per round, never two in a window.
var n int
if err := database.QueryRow(
`SELECT COUNT(*) FROM bookings WHERE host_id = ? AND status != 'cancelled'`, hostID).Scan(&n); err != nil {
t.Fatalf("count bookings: %v", err)
}
if n != rounds {
t.Errorf("bookings for host = %d; want %d (one per round)", n, rounds)
}
}
Loading
Loading