Why it exists
A state change that has to be undone in three days. A confirmation that must expire whether or not anybody comes back. A plan that ends on a date. Written the obvious way, each becomes a row with a timestamp and a job that scans for it — and the scan gets written again in every service, subtly differently, and none of them survives being run twice.
The awkward part is not the scanning. It is that the timer and the state change are two writes. If the state commits and the timer does not, nothing looks wrong: the operation succeeded, the row is correct, and only the thing nobody is watching for never happens.
What you get
- The timer is written in your transaction. Not its own — yours. They commit together or neither does, which is the entire reason this exists rather than a cron job.
- A lease, not a lock. A worker killed holding a timer loses it to another instance once the lease lapses, and no sooner. Several instances compete for rows through
FOR UPDATE SKIP LOCKED, with no leader and no coordinator to fall over. - Lateness is an outcome, not an error. A timer whose moment passed while the process was down fires on start-up, and how late it was is reported — the one number that tells a working worker from a stopped one.
- Cancel and reschedule by id, both first-class. Extending a deadline is one change to one row; expressed as cancel-then-schedule it has a window in which the timer does not exist.
- At-least-once, and it says so. Deduplication belongs to the receiver, on a key in the payload. Exactly-once is not promised anywhere, because it cannot be.
- It runs nothing. Only the fact "it is time" leaves; what to do with it is the receiver's decision. That refusal is what keeps it a primitive instead of somebody's job runner.
What it deliberately does not do
Seconds, not milliseconds — a promise polling cannot keep is worse than no promise. No calendar and no time zones. No catching up: three periods down means one firing, not three, because for side-effecting work catching up is more dangerous than skipping.
Numbers
On one laptop with Postgres in a container beside it — a shape, not a throughput figure. With the index on (state, due_at) the claim does not grow between ten thousand and four hundred thousand waiting rows, holding at ~2 ms. Without it: 2 → 6 → 19 ms, which is a sequential scan drawn in milliseconds. Five thousand timers all due at once drain in under three seconds on one worker.
Both figures came with a defect attached. The benchmark first measured connection setup rather than the query — 12 ms against a plan of 0.072 ms — and once that was fixed it showed that the claim was issuing one UPDATE per row, so the selection the method is named after was 0.3% of its own cost.
Install
repositories { maven("https://reposilite.kotlin.website/snapshots") }
dependencies {
implementation("io.github.youndie:chronik-core:0.1.0.4")
implementation("io.github.youndie:chronik-postgres:0.1.0.4")
}
chronik-postgres ships no driver, no connection pool and no DDL: it takes an Exposed Database and the table describes itself, indexes included, so a schema generator produces something that matches what the queries actually filter on.
chronik-conformance is the corpus of cases every storage implementation has to satisfy — seventeen rules, each named by the rule rather than a number. It was written while there was one implementation on purpose: a corpus written after the second describes the intersection of the two, including whatever both get wrong.
Sagas wake from a fired timer through petich-chronik, in petich.