Running the duplicate check¶
Cairn looks for duplicate charts after the fact, so that a busy front desk never has to. A small
standalone program, the commit-time worker (cairn-matcher watch), does the looking. This page is
for the person who operates a node: what the worker does, how to run it, how to see whether it is
keeping up, and how to keep it alive.
What the worker does, and never does¶
Whenever a chart's identity evidence changes (a name, a demographic or an identifier), the database
appends a notice to a queue (match_pending, db/056). The worker drains that queue: for each chart
it finds the other charts that might be the same person, scores each pair with the advisory matcher,
and writes the result as match_proposal rows.
- It never links anyone. A proposal is a suggestion; only a human decision (R2's gesture) links charts. Identity is a claim, never a fact.
- It never makes a clinical write wait on it. The queue hook cannot raise on its own and never
waits on the worker; it adds one small insert and a brief commit-time serialisation (PostgreSQL's
NOTIFYtakes a short cluster-wide lock while the writing transaction commits). The worker is a separate process that reads the identity projections and writes only the matcher's own tables (match_proposal,match_pending,match_worker_state). - It never claims "no duplicates" falsely. A chart stays "not yet checked" for as long as one of its notices is still in the queue, and a worker that has never run is reported as such.
The worker is a standalone process you run on the node (or on a host that can reach its database). It
is deliberately not started by cairn-sync, so the safety-critical daemon is never tied to the
advisory tier.
Run exactly one worker per node. A second one repeats the same checks, and two running different matcher versions re-queue every chart each time either of them restarts (each sees "the version changed"). A node that runs no worker keeps queuing: one notice per identity change, about five per chart after a rebuild or restore, and nothing ever clears them.
The database role¶
The worker connects as a role that holds cairn_agent. Grant it to the login role you use:
GRANT cairn_agent TO matcher_login; -- the login role the worker connects as
cairn_agent is the advisory-actor role (ADR-0030): db/056 grants it the queue and worker-state
tables the worker writes, alongside the matcher's own proposal tables.
If you give the login role a statement_timeout, make it comfortably longer than the worker's longest
query: at least 30 s, and more on a Raspberry Pi until it has been measured there (#728). The largest
single query that has been timed is the sweep's birth-year-range candidate query, about 3 s at 10 000
charts on the development machine, and it varied about twofold between runs; the sweep's other queries
(the whole-population name grouping, the alias and trust reads) have not been timed on their own.
Finding one chart's candidates took under a second there since #725. A timeout shorter than a sweep
query fails every sweep. Since #727 the worker survives that: it logs the failed sweep with its
traceback (bulk sweep of N waiting charts failed …), holds the sweep for the retry backoff (5 minutes
by default), and checks the backlog chart by chart meanwhile. That is slower than a sweep but makes
progress, and a chart that fails on its own is held without stopping the rest. A recurring
bulk sweep … failed line in the log means the timeout (or the data in the traceback) needs attention.
Running it: cairn-matcher watch¶
From the repository root:
uv run --project matcher --extra pipeline cairn-matcher watch
With no --dsn the standard libpq environment is used (PGHOST, PGPORT, PGUSER, PGDATABASE,
PGPASSWORD, ...). Flags:
| Flag | Default | Meaning |
|---|---|---|
--dsn |
empty (use PG*) |
libpq connection string |
--once |
off | drain the queue once and exit: 0 = clean; 1 = a chart failed, or an uncaught non-database error (a traceback); 2 = a database error (unreachable, or e.g. a statement timeout — the log names its class) |
--poll-seconds |
60 | how long to wait for a notification before looking again (a backstop for a missed NOTIFY) |
--bulk-threshold |
250 | more than this many charts waiting: do one full sweep instead of per-chart checks (250 comes from the measured break-even; see below) |
--max-block-size |
1000 | the largest block of look-alike charts the per-chart check will pair up (bigger blocks are skipped and logged as a warning, never silently truncated) |
--pace-ms |
0 | pause between charts, to be gentle on a busy node |
While running, the worker listens for the database's NOTIFY, drains newest change first, then
sleeps. It re-reads the queue after every chart, so a fresh registration is checked next, ahead of older
changes; at 10 000 charts one check takes ~0.8 s (p95 1.4 s) on the development machine, and a sweep already
running finishes first. If the connection drops it reconnects with a backoff that doubles up to 60 s. A
chart whose check raises is held for five minutes and then retried; its notices stay, so it keeps reading
as "not yet checked".
Very large look-alike blocks. A block is a group of charts that share one blocking value (a name token, a date of birth, an identifier). The two modes cap it differently:
- a per-chart check pairs the chart with every member of a block of up to
--max-block-size(default 1000,DEFAULT_TARGETED_CAP) charts; - a bulk sweep pairs every member with every other, so it keeps only blocks of up to 100 charts
(
sweep_block_size).
A bigger block is skipped, never silently: the per-chart check logs one warning per chart naming each
skipped block (its pass, value and size), and the sweep logs how many blocks it skipped. So after a
first run, a restore, a rebuild or a matcher update (swept whenever more than --bulk-threshold
charts wait), a chart whose look-alike block has
101–1000 members is cleared with fewer comparisons than a per-chart check would have made. It still
reads "up to date"; whether it should stay "not yet checked" is an open design question.
At start the worker compares its matcher configuration with the one recorded in the database. If they differ (or it has never run), every chart is queued for a re-check, because a changed scorer may judge old pairs differently.
Is it keeping up? cairn-node duplicate-check¶
cairn-node duplicate-check # node-wide status
cairn-node duplicate-check --patient <uuid> # also this one chart
It only reads. It does not load or upgrade the schema (that is the node's own start-up), so it is cheap to run from cron, and on a node whose schema predates db/056 it fails with an error rather than printing a status. The node-wide line is one of four states:
| State | Line |
|---|---|
| Never run | Duplicate check has never run on this node. — or, when notices are queued, Duplicate check has never run on this node — 1 chart waiting. / … — N charts waiting. |
| Behind | Duplicate check is behind — last ran HH:MM; N charts waiting. (or — it has not completed a check yet; when there is no last-ran time) |
| Running | Duplicate check running — N charts waiting. (with (re-checking every chart: first run or matcher update) during a full re-check) |
| Up to date | Duplicate check up to date — last ran HH:MM. (or Duplicate check up to date. when the worker has no last-ran time yet) |
"Behind" means a change has been waiting for more than five minutes and the worker has finished no
work in those five minutes — measured from the oldest waiting notice or the worker's last finished
work, whichever is later. Only finished work counts: the worker records it after every chart it checks
and about every 30 seconds while a sweep is successfully scoring pairs. Starting a sweep does not count,
and neither the sweep's first phase (finding the candidate pairs), a single slow pair, nor a pair that
fails records anything. So "behind" means the worker is stopped, keeps failing and restarting, fails on
every pair it tries (a missing grant, say — check its log), or is stuck in one piece of work that
long. New changes arriving do not reset it: on a busy node a stopped worker still reads "behind"
five minutes after the first change it failed to check. A healthy worker working through a restore's
backlog reads "running", however old the queued notices are, because it keeps finishing work; a shrinking N charts waiting between two runs of the command (and, after a sweep, the worker's
swept a backlog of … log line) shows it working. "Last ran HH:MM" is the worker's last finished work,
not its last sign of life: a worker that keeps failing and restarting is active, but its time stays put.
The time is the database's local time, and it carries the date (last ran 2026-10-03 09:41) whenever
that work was not today, so a worker that stopped days ago never reads as this morning.
With --patient a second line says either
This chart: duplicate check not yet run since its identity details last changed. or
This chart: duplicate check up to date. A chart this node does not hold (a mistyped id, or a linked
chart held only on another node) always reads "not yet run": nothing here has checked it.
What a restore or rebuild does¶
reproject --rebuild, a restore, or a new node's first pull rewrites the projections, so every chart
queues a notice. That is correct: the inputs changed. The worker sees a backlog above
--bulk-threshold, runs one full sweep, clears the notices the sweep covered, and then works through
anything left newest-first. It re-counts the backlog before every step, so a rebuild or restore that
starts while the worker is already checking charts one by one is swept next, not worked chart by chart
(#729). It sweeps once per crossing of the threshold: not again until the backlog has fallen to the
threshold or below. One sweep is the cheaper way through a large backlog: measured on an Apple M3
Max with 10 000 records, one sweep took about 2.5 minutes, while one per-chart check took about 0.8
seconds, so the sweep cost the same as about 200 charts checked singly (about 330 at 2 000 records). The
default threshold of 250 sits between those break-evens. While the worker makes progress the status line reads
"running"; it reads "up to date" once the queue is empty.
Keeping it alive¶
Both examples run the worker from a checkout at /opt/cairn-ehr and read the database settings from the
environment. Adjust the paths, host and role; keep the password out of the unit (use a ~/.pgpass file
or the service manager's secret mechanism).
Two things to adjust before you load either example:
- The
uvpath. Service managers start programs with a minimalPATH, so giveuv's full path:/opt/homebrew/bin/uvon Apple Silicon with Homebrew,/usr/local/bin/uvon Intel Homebrew, often~/.local/bin/uvfor a per-user install (command -v uvtells you). - Logs and first start. The
/tmplog path is an example only (/tmpis cleared on reboot). Under systemd the service user needs a home directory, becauseuvkeeps its cache there, and the first start needs network access to fetch the dependencies unless that user's cache has been warmed (run the sameuv run …once by hand as that user).
macOS: launchd¶
Save as ~/Library/LaunchAgents/org.cairn.matcher-watch.plist, then
launchctl load ~/Library/LaunchAgents/org.cairn.matcher-watch.plist.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>org.cairn.matcher-watch</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/uv</string>
<string>run</string>
<string>--project</string><string>/opt/cairn-ehr/matcher</string>
<string>--extra</string><string>pipeline</string>
<string>cairn-matcher</string><string>watch</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>PGHOST</key><string>127.0.0.1</string>
<key>PGPORT</key><string>5432</string>
<key>PGUSER</key><string>matcher_login</string>
<key>PGDATABASE</key><string>cairn</string>
</dict>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>StandardErrorPath</key><string>/tmp/cairn-matcher-watch.log</string>
</dict>
</plist>
Linux: systemd¶
Save as /etc/systemd/system/cairn-matcher-watch.service, then
systemctl enable --now cairn-matcher-watch.
[Unit]
Description=Cairn commit-time duplicate check (advisory; never links)
After=network.target postgresql.service
[Service]
User=cairn
Environment=PGHOST=127.0.0.1
Environment=PGPORT=5432
Environment=PGUSER=matcher_login
Environment=PGDATABASE=cairn
ExecStart=/usr/local/bin/uv run --project /opt/cairn-ehr/matcher --extra pipeline cairn-matcher watch
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
The worker has no monitoring exit code or metric yet; supervise it with cairn-node duplicate-check
(for example from a cron job that alerts on the "behind" line).