Files
pad/docker-compose.yml
xarmian ea139272ce fix(server,watchevents): shared session presence + honest push acceptance (BUG-2698, BUG-2699) (#1175)
Two coupled defects in the push path, fixed as one unit because 2699's honest-acceptance signature is the substrate 2698's fix reports through.

BUG-2699 — Bus.Publish reports acceptance. The endpoint returned 200 pushed:true
for a publish that was dropped, because Publish returned nothing and swallowed
every failure. An error is two outcomes and they are kept apart: ErrBusClosed
proves nothing was published (503 unavailable, safe to resend), while any other
error means UNCONFIRMED — go-redis retries a command whose reply was lost, which
is why the publish script already carries a dedupe token — and gets 502
push_unconfirmed, deliberately off the web client's safe-to-resend list.
MemoryBus was the worse case, not the exempt one: neither implementation checked
`closed`, and the in-process one dropped silently with no log at all. Seven
production call sites, not the six the item named; the six best-effort producers
discard through one named helper, and an AST-based test fails when a new
producer publishes directly.

BUG-2698 — RedisSessionPresence. A session-targeted push was resolved against
the answering replica's presence registry, and the handler skips the publish
when the target is absent, so a POST landing on A for a session held on B
dropped the instruction and answered delivered_sessions:0. Fixed at the REGISTRY
rather than the gate: a shared registry makes the snapshot right, which makes
the picker complete and restores the gate's original premise, so the existing
skip becomes correct for the reason it was written. Entry and index are written
atomically under a TTL renewed by a goroutine that lives exactly as long as the
connection; a crashed process stops renewing and Redis clears it. Staleness is
unchanged and now stated in full: ~30s for a dropped client, ~90s for a dead
instance.

delivered_sessions becomes nullable — null means published-but-uncountable,
never zero — documented as three states at every consumer.

35 Codex review rounds. Notable: a per-user registry cap was added and then
removed after three consecutive rounds found defects inside it and a fourth was
asked whether it belonged in this PR at all; a context bound was documented,
disproved by its own test (go-redis does not apply a command context to
connection establishment — 5.0s measured against a 150ms ctx), and rewritten to
say what is true. Every fix was mutation-checked; one instrument was deleted for
passing on broken code and one for not asserting its own premise.

Filed rather than folded in: BUG-2724 (Redis keyspace namespacing + Cluster),
BUG-2725 (delivered_sessions is an estimate with error in both directions),
BUG-2726 (no concurrent-connection limit on the watch stream), BUG-2727 (Redis
absent from readiness/metrics; silent subscriber loss), BUG-2728 (epoch-reset
resume lead).

Gates: build · make lint 0 issues · go test ./... (25 pkgs) · svelte-check 0
errors · vitest 1738 passed · CI 7/7 including Go (PostgreSQL) and Nix.
2026-08-21 20:43:20 -04:00

122 lines
5.3 KiB
YAML

# Pad — local production setup with PostgreSQL + Redis
# Usage:
# 1. cp .env.example .env # generates a POSTGRES_PASSWORD if missing
# 2. docker compose up -d
#
# Starts Pad with PostgreSQL for storage and Redis for real-time events,
# notifications, and the shared session-presence registry.
# The web UI binds to 127.0.0.1:7777 on the host by default — LAN/internet
# access requires explicit opt-in (see PAD_BIND_ADDR below or the prod override).
# First-time setup: visit the UI or run `pad auth setup` from a local CLI.
services:
pad:
build:
context: .
dockerfile: Dockerfile
ports:
# Bind to loopback only by default. A fresh install exposes the bootstrap
# endpoint until the first admin is created, so defaulting to 0.0.0.0
# would hand control of the instance to anyone who can route to the host.
# Override by setting PAD_BIND_ADDR=0.0.0.0 (or a specific LAN IP) in .env.
- "${PAD_BIND_ADDR:-127.0.0.1}:7777:7777"
environment:
# Container UID/GID for the pad process. Default to 1000/1000 for
# backward compat with existing compose deploys whose volumes were
# created under the previous USER pad (uid 1000) image. Override to
# match your host volume's ownership if different.
#
# ${VAR-default} (no colon), not ${VAR:-default}. Compose follows
# shell-like semantics — colon form would silently convert PUID=
# or PGID= in .env to 1000, masking operator typos. Colon-less lets
# explicit-empty values reach the entrypoint, which rejects them
# with a clear error (matches docker-entrypoint.sh's own
# ${VAR-99} convention).
PUID: "${PUID-1000}"
PGID: "${PGID-1000}"
PAD_HOST: "0.0.0.0"
PAD_PORT: "7777"
PAD_DB_DRIVER: "postgres"
# POSTGRES_PASSWORD is REQUIRED — docker compose refuses to start when unset.
# Use libpq's keyword=value DSN (not the URI form) so passwords with
# reserved URI characters like '/', '+', ':', '@' — common in
# `openssl rand -base64` output — don't need percent-encoding and
# can't break the connection string by accident.
PAD_DATABASE_URL: "host=postgres port=5432 user=pad password=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required — see .env.example} dbname=pad sslmode=disable"
# PAD_ENCRYPTION_KEY is REQUIRED on Postgres — Pad won't auto-generate
# one because a multi-replica deploy would generate a different key
# per replica and fail cross-instance decryption. Generate with
# `openssl rand -hex 32` and keep it stable across restarts.
PAD_ENCRYPTION_KEY: "${PAD_ENCRYPTION_KEY:?PAD_ENCRYPTION_KEY is required — see .env.example}"
PAD_REDIS_URL: "redis://redis:6379"
PAD_DATA_DIR: "/data"
PAD_LOG_LEVEL: "info"
# Cloud-mode wiring — safe to keep enabled on self-host deploys
# because every var defaults to empty and pad treats empty as
# "not in cloud mode". Populate from .env when co-deploying with
# pad-cloud so the reverse sidecar can cascade Stripe cancels on
# account delete (TASK-690).
PAD_CLOUD: "${PAD_CLOUD:-}"
PAD_CLOUD_SECRET: "${PAD_CLOUD_SECRET:-}"
PAD_CLOUD_SIDECAR_URL: "${PAD_CLOUD_SIDECAR_URL:-}"
PAD_CLOUD_OUTBOUND_SECRET: "${PAD_CLOUD_OUTBOUND_SECRET:-}"
volumes:
- pad-data:/data
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped
healthcheck:
# Conditional: root → su-exec to pad; non-root → direct wget.
# Mirrors the Dockerfile HEALTHCHECK so `docker run --user 1234`
# pass-through still reports healthy.
#
# NOTE: $$(id -u), not $(id -u) — Compose performs $-interpolation
# on YAML strings BEFORE the value reaches the container. Single
# `$` would be parsed as a Compose variable reference; `$$` escapes
# to a literal `$` so the shell inside the container sees `$(id -u)`.
test: ["CMD-SHELL", "if [ \"$$(id -u)\" = \"0\" ]; then exec su-exec pad wget -q --spider http://localhost:7777/api/v1/health; fi; exec wget -q --spider http://localhost:7777/api/v1/health"]
interval: 10s
timeout: 5s
retries: 3
# Bumped 10s → 60s. The always-chown-R policy in the entrypoint
# means a user restoring a backup with a large attachment store
# can spend tens of seconds before pad starts listening — 10s
# would mark the container unhealthy mid-startup.
start_period: 60s
postgres:
image: postgres:17-alpine
environment:
POSTGRES_USER: pad
# Required. Fail-fast with the helpful error below when unset.
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required — see .env.example}"
POSTGRES_DB: pad
volumes:
- pg-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U pad"]
interval: 5s
timeout: 3s
retries: 5
restart: unless-stopped
redis:
image: redis:7-alpine
command: redis-server --maxmemory 64mb --maxmemory-policy allkeys-lru
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
restart: unless-stopped
volumes:
pad-data:
pg-data:
redis-data: