Documentation
¶
Overview ¶
Package idempotency makes a write that had to be sent twice mean one write.
A client that never gets an answer cannot tell a request that was lost on the way out from one that was applied and whose response was lost on the way back. Retrying is right for the first and wrong for the second, and nothing the client holds tells them apart — so the server has to remember, and Idempotency-Key is the name the client gives to what it wants remembered.
Run is the whole of it. The record of the write and the write itself commit together, which is the property everything else here follows from: a claim stored separately from the effect it claims can outlive a rolled-back write, and then a key that wrote nothing replays a success forever.
The table is rig_idempotency, created by github.com/simonjanss/rig/runtime/foundation.
Index ¶
Constants ¶
const DefaultRetention = 24 * time.Hour
DefaultRetention is how long a record is kept, and so how long after a write its key still replays.
A day, because the thing a key protects against is a retry, and a retry that arrives a day later is not a retry — it is a second request that happens to reuse an identifier. Keeping them longer would turn a client's stale key into a write that silently does nothing.
const LockTimeout = 5 * time.Second
LockTimeout bounds how long a request waits for another holding the same key.
Two requests with one key is a client that retried before the first attempt finished, so the second one is usually about to be thrown away by a caller who has already stopped listening. Waiting is still the right default — the answer it gets is the answer it wanted — but waiting without a limit would let a slow write pin a connection per retry, and a retry storm is exactly when there are fewest to spare.
It is a constant rather than configuration because it is bounded on both sides by things a project does not choose: shorter than the server's write timeout, longer than any write worth deduplicating.
const Table = "rig_idempotency"
Table is where records are kept. Named here as well as in the migration because the queries below are the only code that reads it, and a constant is what makes that greppable.
Variables ¶
This section is empty.
Functions ¶
func Fingerprint ¶
Fingerprint is what a request body reduces to for the purpose of noticing that a key has been reused for something else.
For a multipart write it is given the fields and not the file bytes. Hashing an upload would mean buffering a file that may be larger than memory, which is the one thing the whole file path is built to avoid — so the guarantee here is narrower than it looks: two uploads that differ only in their bytes have the same fingerprint. That is the trade, and it is the right way round, because the failure it admits is a client reusing a key across two different uploads, which is a client bug, and the failure it avoids is an out-of-memory, which is an outage.
func Prune ¶
Prune deletes records past their retention, and reports how many.
A subcommand rather than a goroutine, like the file sweep and the notification dispatch: a cron job is one thing running, and a goroutine in every replica is as many as there are replicas, all racing to delete the same rows.
Types ¶
type Request ¶
type Request struct {
// TenantID scopes the key. Two tenants choosing the same string is not a
// collision, and one tenant's key can never replay another's response.
TenantID uuid.UUID
// Key is what the client sent. Empty means the client asked for none of
// this, and [Run] does nothing but call the write.
Key string
// Endpoint is the route pattern, not the path. Part of the identity so that
// one client-side identifier reused across two endpoints is two records
// rather than a replay of the wrong one.
Endpoint string
// Fingerprint is what [Fingerprint] made of the request body. A second
// request with the same key and a different body is refused rather than
// answered.
Fingerprint []byte
// RequestID is this request's own identifier, recorded so that a replay can
// name the request that did the work.
RequestID string
}
Request is what identifies one write for this purpose.
type Result ¶
type Result struct {
// Status is the HTTP status to answer with.
Status int
// Body is the response, ready to write. Nil where there was none, which is
// what a 204 answers with.
Body json.RawMessage
// Replayed says this is a record of an earlier write rather than a new one.
// The caller has nothing it must do about it; it is worth a response header
// and worth a log line.
Replayed bool
}
Result is what a write answered with, whether it just ran or is a replay.
func Run ¶
Run performs a write once, however many times it is asked for.
With no key it calls the write and nothing else — no transaction, no row, no second round trip. That is the common path and it costs what it always cost, which is what makes this safe to have on every write endpoint rather than on the ones somebody remembered to configure.
With a key it opens a transaction, claims the key, runs the write, and records what it answered — all before committing, so the record and the effect are one fact. A second request holding the same key blocks on the claim until this one ends, and then either replays what it committed or, if it rolled back, does the work itself.
A write that fails leaves no record. The transaction rolls back and the key is free again, which is the deliberate reading of what a failed write means: it wrote nothing, so there is nothing to be idempotent about, and a caller who fixes their body and reuses the key gets the write they wanted rather than a cached complaint about the old one.
type Write ¶
Write is the work Run guards: the handler's own call into its service, answering with the status it would have written and the value it would have encoded.
The context it is given is the one carrying the transaction, and passing it on is what puts the service's write in the same transaction as the record of it.