# chronik

> One primitive: schedule(id, at, payload), and at that instant the fact it is time leaves — never earlier, never lost. The row is written in the same transaction as your state change, which is the only reason to have it rather than cron.

Page: https://kotlin.website/chronik

## 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

```kotlin
repositories { maven("https://reposilite.kotlin.website/snapshots") }

dependencies {
    implementation("io.github.youndie.chronik:chronik-core:0.1.0.11")
    implementation("io.github.youndie.chronik:chronik-postgres:0.1.0.11")
}
```

`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](https://kotlin.website/petich).
