Configuration storage¶
Doppel keeps its configuration in one of two places, chosen by --store: a
YAML file, or a PostgreSQL database.
Everything above the store is written against one trait, so the choice changes where a configuration lives and nothing about what the proxy or the admin API does with it. One conformance suite runs against both, which is what keeps that true rather than merely intended.
Choosing a store¶
| Setting | Argument | Environment | Default |
|---|---|---|---|
| Store | --store file\|postgres |
DOPPEL_CONFIG_STORE |
file |
| YAML path | --config |
DOPPEL_CONFIG_PATH |
./main.yaml |
| Database | --database-url |
DOPPEL_DATABASE_URL |
none |
| Configuration name | --config-name |
DOPPEL_CONFIG_NAME |
default |
| Worker threads | --workers |
DOPPEL_WORKERS |
available parallelism |
Precedence is argument, then environment, then default.
None of these can come from the configuration document. Settings needed to
reach the store cannot live inside it, or reading the configuration would
require the configuration. That is also why --workers is here rather than
under server: the tokio runtime has to exist before a database store can be
opened, and the store is where the document is.
--database-url is required with --store postgres. There is no local
default, deliberately: a mistyped environment would otherwise connect to
whatever database happened to be at hand.
A database URL is masked wherever it can surface -- an error message, a log line, a debug formatting of the arguments.
The file store¶
One YAML document, and a templates/<proxy>/ directory beside it.
Writes go through a temporary file and a rename, so a reader sees either the
old configuration or the new one and never half of either. Concurrent writers
are serialised by an advisory lock on a sibling <config>.lock file -- a
separate name because rename replaces the inode, and a lock held on the
config path itself would stop guarding it the moment a write landed.
The PostgreSQL store¶
Getting a database¶
docker compose up -d --wait
export DOPPEL_DATABASE_URL=postgres://doppel:doppel@127.0.0.1:55432/doppel
doppel config migrate
docker-compose.yml at the repository root runs one PostgreSQL on port
55432 -- not 5432, so it cannot collide with a database you already run,
or quietly become one these tools write into.
Migrations¶
Built into the binary. A deployment ships one file and needs no migrations/
directory beside it.
They are applied only by doppel config migrate, never at startup. A process
that silently alters a shared schema when it boots turns a rollback into data
loss, and the operator who rolled back is the one least expecting it. Starting
against a schema that has not been migrated is refused with a message naming
the command.
Running config migrate twice is safe; the second run reports that there was
nothing to do.
Checking what is applied¶
doppel config migrate --status
Changes nothing, and exits 0 only when every migration this binary carries
is applied, complete, and unchanged since it was applied -- so a deploy gate
can branch on the code without parsing the output. Otherwise it exits 1 and
says which of those three is false.
There is deliberately no separate table holding a single revision number.
It would be the same fact written twice, and two copies of one fact disagree
eventually. sqlx's table already carries what a number cannot: a row per
migration, with a checksum, so it can tell "version 1 is applied" apart from
"version 1 is applied and the file that produced it has since been edited".
--status reports the highest applied version, which is the revision number a
single-row table would have held.
sqlx records a checksum per applied migration and refuses to start against a database whose applied file no longer matches. Until the first release that protection is being spent deliberately: schema changes are merged into the initial migration rather than appended, because nothing is deployed and a history of one file is easier to read than a history of corrections. After the release, migrations are append-only.
The schema¶
Three tables. Anything SQL actually uses is a column; everything else is the document, as JSON.
| Table | Holds |
|---|---|
configurations |
One row per named configuration: name, revision, and settings -- the whole document except its proxies |
proxies |
One row per proxy: name, ordinal, and document -- the proxy with its mocks inside |
templates |
One row per template file, content included |
It was five tables with a column per configuration field, and the change is worth explaining because it looks like a step backwards.
Nothing ever queried admin_host or latency_min in SQL. What that
normalisation bought was a migration for every field the configuration format
gained -- three of them between 0.3.0 and 0.4.1, each one ADD COLUMN and
nothing else, plus a matching edit in the loader, the writer, and two
hand-written statements that a test existed solely to keep in step. A field
added to the format now reaches the database with no schema change at all.
What did not change is the part that matters: a proxy is still its own row, addressed by name and carrying its own revision, so the per-proxy concurrency the admin API is built on is untouched. A single blob per configuration would have thrown that away.
The rows are parsed by the same code that parses main.yaml, so a row edited by
hand is held to exactly the rules a configuration file is held to -- a value the
format refuses is refused here too, reported against the field and the proxy that
carry it.
ordinal preserves document order. That is not decoration: mock patterns are
unanchored, so a general one placed before a specific one shadows it, and a store
that reordered them would change which mock answers a request.
One thing moved rather than disappeared. The old schema had a unique index on
(config, token), making duplicate token values impossible in the database; rule
V26 refuses them on every write instead, and every write goes through validation.
templates has no cascading foreign key on purpose. Deleting a proxy's files
is a decision the store makes explicitly and after the configuration write --
the write is what authorises dropping them -- and a cascade would move that
ordering into the schema where the reasoning behind it is invisible.
Templates¶
The database holds them; the directory named by templates.dir is a cache of
it. The render path reads a file at request time, and turning that into a
database round trip would put a query on the hot path of every mocked
response.
Every write touches both, row first. A failure to write the file then leaves the row for the next reload to replay; the other order would report success for a template the database never received.
Concurrency¶
A save is one transaction. When the caller supplies the revision it built from,
the write becomes UPDATE ... WHERE revision = ..., and a revision that has
moved matches nothing and rolls the transaction back. The database serialises
the transaction, so the file store's advisory lock has no analogue here.
No explicit SELECT ... FOR UPDATE is needed for that, and adding one would
not make it safer. The conditional UPDATE takes the row lock itself, and a
second writer that blocks on it re-evaluates its own WHERE against the row
as the winner left it -- finds the revision moved, matches nothing, and is
told it lost.
A load is also one transaction, at REPEATABLE READ. A configuration spans two
tables, and each query used to take its own connection and its own snapshot,
so a save committing in between produced a configuration assembled from two of
them. The revision check at the end of a load caught it and reported "the rows
and the revision column have diverged" -- which accuses the database of
corruption for what is ordinary concurrency, and sends an operator looking for
a hand-edited row that does not exist. Measured at 37 failures in 600 loads
against one concurrent writer; zero with one snapshot.
Template writes are single statements, so they need nothing further.
materialize reads across statements deliberately: a template added between
two of them appears on the next reload, which is the propagation model
described below rather than a gap in it.
Two instances, one database¶
Both instances see the same configuration and agree on its revision, because a revision is derived from content rather than counted.
What they do not get is automatic propagation. An instance keeps serving
the configuration it compiled until it is told to reload, by
doppel config reload or POST /api/v1/config/reload. A change one instance
writes reaches the other on that other's next reload -- including any template
the first uploaded, which is materialised onto disk as part of the same
sequence.
So: a rolling change is a write followed by a reload on each instance. There
is no coordination between them, and nothing stops two instances running
different revisions in between; GET /api/v1/status on each reports which one it is
holding.
Moving between the stores¶
# file -> database
doppel config push --config main.yaml --database-url "$DOPPEL_DATABASE_URL"
# database -> file
doppel config pull --database-url "$DOPPEL_DATABASE_URL" --output main.yaml
pull renders the same canonical form the revision is computed over, so a
pulled document pushed straight back produces the revision it left with.
Comments and layout are not preserved: they are no part of what the database
stores.
push is unconditional by default, which is what provisioning wants. Add
--if-revision <hex> to make it the same compare-and-swap the admin API uses,
so a scripted push cannot overwrite a change made in between.
An invalid document is refused with every violation listed, and nothing is written -- validation happens inside the same transaction as the write.