Documentation
¶
Overview ¶
Package idem makes operations safe to retry: it runs an operation at most once per idempotency key and replays the stored result to every later caller.
A client that times out cannot tell whether its request was lost or whether only the reply was. Retrying is the only way forward, and without protection a retry repeats the side effect: a second charge, a second order, a second email. With an idempotency key the client names the operation, and the server uses the name to recognise a retry.
Model ¶
Every key moves through three states:
absent ──Acquire──▶ in progress ──Complete──▶ completed ──ttl──▶ absent
│
└──Release, or the lease runs out──▶ absent
The caller that acquires a key holds a lease on it. While the operation runs, a heartbeat keeps extending the lease. If the process dies the lease runs out and the key can be acquired again, so a crash blocks retries for one lease, 30 seconds by default, rather than for the lifetime of a fixed lock.
Every acquisition carries a random token, and a store only accepts Complete, Release and Extend from the token that currently holds the key. A worker that stalled past its lease therefore cannot overwrite the record of the worker that took over; it gets ErrLeaseLost instead.
Using it ¶
Create one Engine per store and share it:
engine := idem.New(memstore.New())
Do protects any function, whatever delivered the work: an HTTP handler, a queue consumer, a scheduled job.
receipt, replayed, err := idem.Do(ctx, engine, msg.ID,
func(ctx context.Context) (Receipt, error) {
return charge(ctx, msg)
})
For request handlers, package idemhttp wraps an net/http.Handler and module idemgrpc provides a unary server interceptor. Both are built on Engine.Begin, which is exported for flows that Do does not fit.
Stores ¶
A Store keeps the records. Three ship with this module, and none of them adds a dependency to your build:
- memstore keeps records in process memory, for tests and single instances.
- redisstore runs on Redis through whichever client you already use.
- pgstore runs on PostgreSQL through database/sql, and can write the record in the same transaction as your own changes.
Package storetest checks any Store against the contract.
What is and is not guaranteed ¶
While the lease is held, at most one caller runs the operation for a key. After it succeeds, every caller with that key receives the stored result until the record expires.
A lease is not a transaction. A holder that cannot renew its lease, because its process is frozen or because it cannot reach the store, loses the key when the lease runs out, and a second caller may then start the same work. The first holder's context is cancelled as soon as it can tell, and the store refuses its result once another caller holds the key, but whatever it already did outside the store has happened. Only a store that commits the record together with the side effects closes that gap; see pgstore.DoTx. For side effects in other systems, pass the key on to them so that they deduplicate too.
Do keeps a result only when the function returns a nil error. A function that fails releases its key, and a retry runs it again. The transport packages document which responses they keep.
Index ¶
Examples ¶
Constants ¶
const ( // DefaultLease is how long a key stays claimed without a heartbeat. DefaultLease = 30 * time.Second // DefaultTTL is how long a completed result is kept and replayed. DefaultTTL = 24 * time.Hour )
Variables ¶
var ( // ErrInProgress means another caller holds the key and has not finished. ErrInProgress = errors.New("idem: an operation with this key is already in progress") // ErrFingerprintMismatch means the key was first used with a different // request. Reusing a key for different work is a client bug. ErrFingerprintMismatch = errors.New("idem: key was already used with a different request") // ErrLeaseLost means the caller no longer holds the key: its lease ran // out, or another caller has acquired the key since. ErrLeaseLost = errors.New("idem: lease lost") // ErrInvalidKey means the key was empty. ErrInvalidKey = errors.New("idem: invalid key") // ErrNotRecorded means the operation finished but its result could not be // stored, so a retry with the same key may run the operation again. The // value returned alongside it is the real result of the operation. ErrNotRecorded = errors.New("idem: operation finished but its result was not recorded") )
Errors returned by this package. Test for them with errors.Is.
Functions ¶
func Do ¶
func Do[T any](ctx context.Context, e *Engine, key string, fn func(context.Context) (T, error), opts ...Option) (v T, replayed bool, err error)
Do runs fn at most once for key and returns its result. If the operation already completed, Do does not call fn: it returns the stored result and reports replayed as true.
fn receives a context that is cancelled if the lease on the key is lost; see Attempt.Context.
If fn returns an error or panics, nothing is stored and the key is released, so the next call with the same key runs fn again. Only successful results are replayed.
The result is encoded with the engine's Codec, JSON by default, so T must survive a round trip through it. If fn succeeds but the result cannot be encoded or stored, Do returns the result together with an error that wraps ErrNotRecorded.
Do returns ErrInProgress, ErrFingerprintMismatch and ErrInvalidKey under the conditions described at Engine.Begin.
Example ¶
A client retries a payment three times because its connection keeps dropping. The card is charged once.
package main
import (
"context"
"fmt"
"github.com/udaykishore-resu/idem"
"github.com/udaykishore-resu/idem/memstore"
)
type Receipt struct {
ID string `json:"id"`
Amount int `json:"amount"`
}
func main() {
ctx := context.Background()
engine := idem.New(memstore.New())
charges := 0
charge := func(ctx context.Context) (Receipt, error) {
charges++
return Receipt{ID: fmt.Sprintf("rcpt_%d", charges), Amount: 500}, nil
}
for try := 1; try <= 3; try++ {
receipt, replayed, err := idem.Do(ctx, engine, "payment-42", charge)
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Printf("try %d: %s replayed=%t\n", try, receipt.ID, replayed)
}
fmt.Println("charges made:", charges)
}
Output: try 1: rcpt_1 replayed=false try 2: rcpt_1 replayed=true try 3: rcpt_1 replayed=true charges made: 1
Example (Consumer) ¶
A queue that delivers at least once will sooner or later deliver a message twice. Using the message ID as the key turns the redelivery into a no-op.
package main
import (
"context"
"fmt"
"github.com/udaykishore-resu/idem"
"github.com/udaykishore-resu/idem/memstore"
)
func main() {
ctx := context.Background()
engine := idem.New(memstore.New(), idem.WithNamespace("welcome-email"))
type message struct{ ID, Email string }
sent := 0
handle := func(m message) error {
_, _, err := idem.Do(ctx, engine, m.ID, func(ctx context.Context) (struct{}, error) {
sent++ // send the email
return struct{}{}, nil
})
return err
}
deliveries := []message{
{ID: "msg-1", Email: "ada@example.com"},
{ID: "msg-2", Email: "alan@example.com"},
{ID: "msg-1", Email: "ada@example.com"}, // redelivered
}
for _, m := range deliveries {
if err := handle(m); err != nil {
fmt.Println("error:", err)
}
}
fmt.Println("emails sent:", sent)
}
Output: emails sent: 2
Example (Failure) ¶
A failed operation is not remembered: the key is released and the retry runs it again.
package main
import (
"context"
"errors"
"fmt"
"github.com/udaykishore-resu/idem"
"github.com/udaykishore-resu/idem/memstore"
)
func main() {
ctx := context.Background()
engine := idem.New(memstore.New())
tries := 0
reserve := func(ctx context.Context) (string, error) {
tries++
if tries == 1 {
return "", errors.New("inventory service timed out")
}
return "reservation-7", nil
}
for range 3 {
v, replayed, err := idem.Do(ctx, engine, "cart-9", reserve)
fmt.Printf("%q replayed=%t err=%v\n", v, replayed, err)
}
}
Output: "" replayed=false err=inventory service timed out "reservation-7" replayed=false err=<nil> "reservation-7" replayed=true err=<nil>
func Fingerprint ¶
Fingerprint returns a digest of parts for use with WithFingerprint. Feed it everything that makes two requests different, such as the operation name and the request body. Each part is length-prefixed, so ("ab", "c") and ("a", "bc") produce different fingerprints.
func Provisional ¶
Provisional returns a copy of ctx that marks a call to Attempt.Complete as not final yet: the store will only make the record permanent later, or not at all, as when the write is part of a database transaction that has still to commit. Stores that can do this wrap the context themselves; see pgstore.WithTx.
After a provisional Complete the attempt stays open. A later Attempt.Release still reaches the store, which frees the key if the record did not become permanent and does nothing if it did.
Types ¶
type Attempt ¶
type Attempt struct {
// contains filtered or unexported fields
}
Attempt is one caller's hold on a key. The holder runs the operation and then calls Attempt.Complete or Attempt.Release; until it does, a background goroutine keeps the lease alive, so an Attempt must always be ended. The simplest way is to defer Release, which does nothing once Complete has succeeded:
attempt, stored, err := engine.Begin(ctx, key) ... defer attempt.Release(ctx) ... return attempt.Complete(ctx, result)
func (*Attempt) Complete ¶
Complete stores result as the outcome of the operation and ends the attempt. Later calls to Engine.Begin with the same key return result.
Complete returns ErrLeaseLost if the attempt no longer holds the key; the result is then not stored. If Complete fails, the attempt has not ended and a deferred Attempt.Release still gives the key up. Complete stops the heartbeat in every case.
The values of ctx are passed to the store but its cancellation is not: a client that has gone away must not prevent the outcome from being recorded. The call is bounded by the lease instead, and by five seconds at most.
func (*Attempt) Context ¶
Context returns a context derived from the one passed to Engine.Begin. It is cancelled, with ErrLeaseLost as its cause, as soon as the lease is known to be gone: the store reports that the key is no longer held, or no extension has succeeded for a whole lease. Run the operation with this context so that it stops when it has lost the right to finish. The context is also cancelled when the attempt ends.
func (*Attempt) Release ¶
Release gives the key up without storing a result and ends the attempt, so that a retry can run the operation again straight away. Call it when the operation failed. Like Attempt.Complete, it ignores the cancellation of ctx.
Release does nothing and returns nil if the attempt has already ended, so it is safe to defer. Releasing a key that is no longer held returns ErrLeaseLost, which callers can usually ignore.
type Claim ¶
type Claim struct {
// State says whether the caller acquired the key and, if it did not,
// what holds it.
State State
// Fingerprint is the fingerprint stored with the existing record. It is
// set when State is StateInProgress or StateCompleted.
Fingerprint string
// Result is the stored result. It is set when State is StateCompleted.
Result []byte
}
Claim is the outcome of Store.Acquire.
type Engine ¶
type Engine struct {
// contains filtered or unexported fields
}
Engine runs operations at most once per key against a Store. An Engine is safe for concurrent use; create one per store and share it.
func (*Engine) Begin ¶
Begin claims key. It is the building block under Do and the transport packages; use it directly when the result is not a single Go value.
Exactly one of three things happens:
- The key was free. Begin returns an Attempt; the caller runs the operation and then calls Attempt.Complete or Attempt.Release.
- The operation already completed. Begin returns a nil Attempt and the stored result.
- Begin returns an error: ErrInProgress if another caller holds the key, ErrFingerprintMismatch if the key belongs to a different request, ErrInvalidKey if key is empty, or the store's error.
Example ¶
Begin is for results that are not a single Go value. The caller holds an Attempt while it works and ends it with Complete or Release.
package main
import (
"context"
"fmt"
"github.com/udaykishore-resu/idem"
"github.com/udaykishore-resu/idem/memstore"
)
func main() {
ctx := context.Background()
engine := idem.New(memstore.New())
export := func() {
attempt, stored, err := engine.Begin(ctx, "export-7")
switch {
case err != nil:
fmt.Println("error:", err)
case attempt == nil:
fmt.Printf("already done: %s\n", stored)
default:
// Release frees the key if anything below fails. Once Complete
// has succeeded it does nothing.
defer attempt.Release(ctx)
// Work with attempt.Context(), then record what happened.
if err := attempt.Complete(ctx, []byte("s3://exports/7.csv")); err != nil {
fmt.Println("error:", err)
return
}
fmt.Println("exported")
}
}
export()
export()
}
Output: exported already done: s3://exports/7.csv
type Option ¶
type Option func(*config)
Option configures an Engine. Options passed to New set the engine's defaults; unless noted otherwise, the same options can be passed to Engine.Begin and Do to override a default for a single call.
func WithCodec ¶
WithCodec sets how Do encodes results for storage. The default is JSON. It only has an effect when passed to New.
func WithFingerprint ¶
WithFingerprint attaches a digest of the request to the key. A later call that presents the same key with a different fingerprint fails with ErrFingerprintMismatch instead of receiving the result of other work. An empty fingerprint is never compared. See Fingerprint.
Example ¶
A key identifies one request. Presenting it with a different request is rejected instead of answered with the wrong result.
package main
import (
"context"
"fmt"
"github.com/udaykishore-resu/idem"
"github.com/udaykishore-resu/idem/memstore"
)
func main() {
ctx := context.Background()
engine := idem.New(memstore.New())
transfer := func(ctx context.Context) (string, error) { return "ok", nil }
original := idem.Fingerprint([]byte("transfer"), []byte(`{"to":"alice","amount":100}`))
_, _, err := idem.Do(ctx, engine, "key-1", transfer, idem.WithFingerprint(original))
fmt.Println("original:", err)
changed := idem.Fingerprint([]byte("transfer"), []byte(`{"to":"mallory","amount":100}`))
_, _, err = idem.Do(ctx, engine, "key-1", transfer, idem.WithFingerprint(changed))
fmt.Println("changed: ", err)
}
Output: original: <nil> changed: idem: key was already used with a different request
func WithHeartbeat ¶
WithHeartbeat sets how often the lease is extended while an operation runs. The default is a third of the lease. A value of zero or less turns the heartbeat off; the operation must then finish within one lease. An interval that is not shorter than the lease could never keep it alive and is replaced by the default.
func WithLease ¶
WithLease sets how long a key stays claimed when its holder stops sending heartbeats. It bounds how long a crashed process can block retries. The default is DefaultLease. WithLease panics if d is not positive.
func WithNamespace ¶
WithNamespace prefixes every key with ns and a colon, so that several engines can share one store. It only has an effect when passed to New.
func WithTTL ¶
WithTTL sets how long a completed result is kept and replayed. After it expires the key can be used for new work. The default is DefaultTTL. WithTTL panics if d is not positive.
func WithWait ¶
WithWait makes a call wait up to d for an operation that is in progress under the same key, instead of failing with ErrInProgress at once. If the other operation completes in time, its result is replayed; if it fails and releases the key, this call runs the operation itself. The default is not to wait.
Example ¶
By default a caller that finds the key held gets ErrInProgress at once. With WithWait it waits for the holder and takes over its result.
package main
import (
"context"
"fmt"
"time"
"github.com/udaykishore-resu/idem"
"github.com/udaykishore-resu/idem/memstore"
)
func main() {
ctx := context.Background()
engine := idem.New(memstore.New())
started := make(chan struct{})
go func() {
_, _, _ = idem.Do(ctx, engine, "report-2024", func(ctx context.Context) (string, error) {
close(started)
time.Sleep(50 * time.Millisecond)
return "report built by the first caller", nil
})
}()
<-started
build := func(ctx context.Context) (string, error) { return "report built by the second caller", nil }
_, _, err := idem.Do(ctx, engine, "report-2024", build)
fmt.Println("without waiting:", err)
v, replayed, err := idem.Do(ctx, engine, "report-2024", build, idem.WithWait(5*time.Second))
fmt.Println("with waiting:", v, replayed, err)
}
Output: without waiting: idem: an operation with this key is already in progress with waiting: report built by the first caller true <nil>
type Store ¶
type Store interface {
// Acquire claims key if it has no live record: it stores an in-progress
// record holding token and fingerprint that expires after lease, and
// reports StateAcquired.
//
// If key has a live record, Acquire leaves it unchanged and reports
// StateInProgress or StateCompleted together with the stored fingerprint
// and, for a completed record, the stored result.
//
// When several callers race for a free key, exactly one acquires it.
Acquire(ctx context.Context, key, token, fingerprint string, lease time.Duration) (Claim, error)
// Complete turns the live in-progress record held by token into a
// completed record that stores result and expires after ttl.
//
// Calling Complete again with the same token on the completed record is a
// no-op that returns nil, so the call can be retried. In every other case
// Complete changes nothing and returns ErrLeaseLost.
Complete(ctx context.Context, key, token string, result []byte, ttl time.Duration) error
// Release removes the live in-progress record held by token, so that the
// key can be acquired again. In every other case it changes nothing and
// returns ErrLeaseLost.
Release(ctx context.Context, key, token string) error
// Extend moves the expiry of the live in-progress record held by token to
// lease from now. In every other case it changes nothing and returns
// ErrLeaseLost.
Extend(ctx context.Context, key, token string, lease time.Duration) error
}
Store keeps one record per key. Implementations must be safe for concurrent use, and every method must be atomic with respect to the others, including across processes that share the store.
A record is live until its expiry time. An expired record must behave exactly as if it were absent.
Package storetest verifies an implementation against this contract.
Directories
¶
| Path | Synopsis |
|---|---|
|
examples
|
|
|
orders
command
Command orders is a small HTTP service that shows idem at work.
|
Command orders is a small HTTP service that shows idem at work. |
|
Package idemhttp makes HTTP handlers idempotent with the Idempotency-Key request header.
|
Package idemhttp makes HTTP handlers idempotent with the Idempotency-Key request header. |
|
Package memstore provides an in-memory store for package idem.
|
Package memstore provides an in-memory store for package idem. |
|
Package pgstore provides a PostgreSQL store for package idem, with transactional completion.
|
Package pgstore provides a PostgreSQL store for package idem, with transactional completion. |
|
Package redisstore provides a Redis store for package idem.
|
Package redisstore provides a Redis store for package idem. |
|
Package storetest is a conformance suite for implementations of idem.Store.
|
Package storetest is a conformance suite for implementations of idem.Store. |