Architecture
How HTTP click writes and Quack remote reads share one in-process DuckDB writer. Mermaid diagrams render via goldmark.
Architecture
Single Go process. libduckdb 1.5.3 is compiled in via
duckdb-go-bindings/v2, and the Quack extension is INSTALL’d / LOAD’d
at startup. The same in-process DuckDB serves both sides — HTTP click
writes and Quack remote reads share one writer, which is what DuckDB
needs (single-writer constraint).
flowchart LR
subgraph readers["Newsletter readers"]
B["Browser"]
end
subgraph laptop["You (laptop)"]
M["make survey-create<br/>make survey-result<br/>make survey-reset"]
D["local duckdb CLI<br/>+ quack extension"]
end
subgraph rly["Railway"]
Edge["HTTPS edge · auto-TLS"]
TCP["TCP Proxy · plaintext + token"]
subgraph cont["pollmd container · single Go process (CGO)"]
HTTP["net/http :8080<br/>vote / landing / result / thanks / style"]
QSrv["Quack listener :9494<br/>started by CALL quack_serve"]
DB[("libduckdb 1.5.3<br/>single writer · in-process")]
Salt["32-byte salt in memory<br/>rotates @ UTC midnight"]
end
V[("Persistent volume<br/>votes.duckdb")]
end
B -->|"GET /{id}/{answer}"| Edge
Edge --> HTTP
HTTP -->|"RecordVote, TallyBySurvey, GetAllowedAnswers"| DB
HTTP -.->|"voter.Hash"| Salt
M -->|"survey-create writes via Quack"| TCP
D -->|"quack_query over HTTP"| TCP
TCP --> QSrv
QSrv -->|"reads/writes in same process"| DB
DB --> V
Write path (newsletter click): browser → Railway HTTPS edge → Go
net/http mux → handleSurvey → store.RecordVote → in-process
libduckdb → votes.duckdb on the persistent volume. Go and DuckDB
are not separate services; CGO links the two.
Read path (your laptop queries): local duckdb CLI →
quack_query(…) over HTTP → Railway TCP Proxy (plaintext, token
authenticates) → Quack listener on container port 9494 → same
libduckdb in the same process → same file. Quack is never used for
recording votes; it’s the read-side / admin channel.
The only thing make survey-create / survey-reset / survey-result
use Quack for is admin SQL (INSERT into surveys, DELETE from
votes, SELECT for the bar-chart tally). Vote recording always goes
through HTTP.
One vote, end to end
flowchart TD
Click["Browser clicks<br/>https://q.ssp.sh/2026-06-04/awesome"]
Click --> Edge["Railway HTTPS edge"]
Edge --> H["Go handleSurvey()"]
H --> Method{"HTTP method?"}
Method -->|"HEAD (Safe Links prefetch)"| OK1["200 · no record"]
Method -->|"GET"| Slug{"slug regex<br/>matches id + answer?"}
Slug -->|"no"| Err["400 Bad Request"]
Slug -->|"yes"| Bot{"User-Agent<br/>looks like a bot?"}
Bot -->|"TwitterBot, etc."| OK2["200 · log bot-skip"]
Bot -->|"browser"| Reg{"survey_id<br/>in surveys table?"}
Reg -->|"no (open mode)"| Hash["voter = sha256(ip + ua + daily_salt + survey_id)[:16]"]
Reg -->|"yes and answer allowed"| Hash
Reg -->|"yes and answer NOT allowed"| OK3["200 · log answer-reject"]
Hash --> Up[("INSERT INTO votes<br/>ON CONFLICT (survey_id, voter)<br/>DO UPDATE — last vote wins")]
Up --> Redir["302 → /thanks · log vote"]
Key consequences of this shape:
- No second writer. Quack runs inside the same Go process as the
HTTP server, so there’s exactly one thing writing to
votes.duckdb— required by DuckDB. - One click = at most one row per (survey_id, voter) per day. Re-clicks upsert. The daily salt rotation is the dedup window — after midnight UTC, the salt regenerates and the same reader produces a different hash for the same survey, so they could vote again. That’s a feature for multi-day polls and the price of not persisting any identifier.
- The landing page (
/{id}) and the markdown links resolve to the same handler. Clicking a button on the landing page hits the same/{id}/{answer}route as the newsletter link → same voter hash → same row → same upsert.
How votes are deduplicated
voter = sha256(ip || ua || daily_salt || survey_id)[:16] (hex).
- The daily salt is 32 random bytes generated in memory at startup, rotated every midnight UTC, and regenerated on every process restart. It is never written to disk.
- After rotation, yesterday’s hashes can no longer be reproduced from logs.
- Including
survey_idin the hash means the same reader produces different hashes for different newsletters, so cross-issue tracking is impossible.
If the same reader clicks twice on the same newsletter (e.g. Awesome, then Good), the second click replaces the first — last vote wins.