Skip to content

Latest commit

 

History

6 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

once-guard

Every agent guardrail asks may this happen? This one asks did I already?

One customer, one broken order, one refund. It arrives as two support tickets, gets retried after a lost response, and lands on three workers that share nothing. Every "may I?" check says yes to all of them, and the card is charged three times.

from once_guard import idempotent

@idempotent("charge_id", "cents")          # the arguments that ARE the call
def charge(charge_id, cents, note=""):
    return stripe.PaymentIntent.create(...)

One decorator, one directory, no dependencies, no daemon, no broker, no network. Python 3.8+, POSIX, MIT.

dir= is optional. Without it, the claim directory is resolved at call time: CLAIM_DIR when set, then $XDG_STATE_HOME/once-guard/claims, otherwise the durable per-user path ~/.local/state/once-guard/claims. The default directory is created with mode 0700; any directory that already exists, including one selected through CLAIM_DIR, keeps its existing mode. Use an explicit dir= when intentionally coordinating through a shared filesystem.

The numbers

$ python3 demo.py

once-guard · one $42.00 charge, asked for many times

scenario                                        | off                                  | on
------------------------------------------------+--------------------------------------+----------------------------------------------------------------
40 concurrent processes, identical call         | executed 40; replayed 0; in-flight 0 | executed 1 (always); replayed 38 (varies); in-flight 1 (varies)
4 processes, four different note arguments      | executed 4; distinct answers 1       | executed 1; replayed 3; distinct answers 1
the same four, with note wrongly inside the key | executed 4                           | executed 4 (wrong key)
lost response, client retries                   | executed 3; same result 3/3          | executed 1; replayed 2; same result 3/3
the claimer dies before finishing               | immediate retry: executed 1          | before TTL: InFlight; after TTL (2.0s): executed 1
the side effect outlives its TTL                | executed 2                           | executed 2

duplicate charges: 44 (off) -> 0 (on)      across the four scenarios with a correct key
extra money charged: $1,848.00 -> $0.00
control — key names the wrong argument:    4 -> 4   ($126.00 extra in both columns)
limit — the guard cannot make one execution out of two that both really happened

off calls the identical undecorated function; on applies @idempotent to it. In the first row, process_calls(..., concurrent=True) starts all 40 OS processes before joining any of them, so it is a real overlapping process race. Here replayed means “returned a stored result,” so off shows replayed 0 even though all 40 bare calls return normally. executed 1 is fixed; the other 39 callers split by timing between stored-result replay and InFlight, so both values are explicitly marked varies. A separate barrier-synchronised expired-claim race lives in tests/test_expiry_race.py; the regression suite runs it with 16 workers for 40 trials.

Real OS processes, not threads — threads would pass on the GIL alone and prove nothing about what runs in production. The third row shows the configuration failure: put a value the model rewrites into the key and every retry gets a new identity. That wrong-key row is a control and is reported separately from the four correctly keyed treatment scenarios.

The sixth row is a measured limit, not part of the 44 -> 0 headline: after a real side effect has outlived its TTL, the old owner and its legitimate replacement have both already executed.

$ python3 -m pytest -q
71 passed

How it works

The first claimant creates <sig>.gen0.claim using os.open(path, os.O_CREAT | os.O_EXCL). O_EXCL is a compare-and-set on file existence: exactly one process can create that generation. A successful call creates the matching immutable <sig>.gen0.result the same way.

If the claim expires, reclaim does not remove it. Callers find the highest existing generation and compete to create <sig>.gen{N+1}.claim with the same O_EXCL operation.

Result and liveness are separate invariants. For replay, the highest generation with a terminal result wins; higher claims without a result do not hide that stored success. For liveness, only the highest claim's age decides whether a new generation may be created. Thus a late gen0 result cannot overwrite a completed gen1 result, while a dead, empty gen1 cannot erase a valid gen0 replay.

Generation lookup probes <sig>.gen0.claim, <sig>.gen1.claim, and so on with os.stat; it does not scan unrelated keys. The family must therefore remain contiguous. The library never deletes or replaces a claim or result, has no directory-wide lock, and has no steal sentinel or five-second recovery heuristic. Different keys do not share a takeover lock.

Depending on state, a later caller may receive permission to create and run the next generation, the highest terminal stored result, InFlight for a live claim or a result inode still inside its 2.0-second write grace period, or a documented terminal/storage exception. If it becomes the new owner and the wrapped function raises, that function's exception also propagates unchanged. If an old owner records its result after a higher claim exists, it receives SupersededExecution; the result remains on disk for reconciliation, but the caller is not allowed to mistake its private answer for the current one.

Function identity is fn.__module__ + "." + fn.__qualname__, so same-named functions in different modules do not share a key family. The holder token is diagnostic metadata, not authorization; diagnostic messages bound it to 64 printable ASCII characters. Earlier repository revisions used bare fn.__name__; those hashes are not compatible, so reconcile any live old claim directory before upgrading instead of silently treating it as current state.

State growth and lookup cost

A normally completed signature uses two files: one claim and one result. Each completed TTL takeover adds two more; an unfinished generation adds one. There is no automatic sweeper. Lookup probes that signature's contiguous generations in order and does not scan unrelated files.

What it does not do — read this before you use it

  • One host, or a shared filesystem. Two machines with separate disks do not see each other's claims. O_EXCL on older NFS servers is not atomic; test it before trusting it there. This older-NFS warning is operational guidance and is not tested in this repository. Clock skew is handled conservatively: an exact, non-boolean numeric at is used only when it is in the past, and then claim age is the younger of the recorded age and mtime age; otherwise only mtime is used. Editing at alone cannot make a claim old because its mtime caps the age. After reconciling that an owner is dead, manual expiry must move both clocks to the same past instant (os.utime supplies the second clock):

    $ python3 -c 'import json,os,sys,time; from pathlib import Path; p=Path(sys.argv[1]); old=time.time()-3600; record=json.loads(p.read_text(encoding="utf-8")); record["at"]=old; p.write_text(json.dumps(record), encoding="utf-8"); os.utime(str(p),(old,old)); print("record age: %ds; mtime age: %ds" % (round(time.time()-record["at"]), round(time.time()-p.stat().st_mtime)))' /path/to/SIGNATURE.gen0.claim
    record age: 3600s; mtime age: 3600s
    

    Taking the younger age avoids stealing a live worker's claim when its clock jumps backward. The cost is that a dead worker's claim can remain locked longer; shared deployments still need trustworthy filesystem mtimes.

  • TTL is a liveness trade, not an exactly-once proof. A worker that claims and then dies blocks until the TTL. Scenario 5 measures this cost: InFlight before the 2.0-second TTL, then one execution after it expires. If a live side effect exceeds its TTL, a newer generation may also execute; scenario 6 measures two executions with the guard both off and on. The external operation has already happened twice. The late owner gets SupersededExecution, but that notification cannot undo either operation. Set TTL above the slowest real side effect.

  • The local result marker closes only the tested process-retry path. If result-file creation succeeds but its write or fsync fails, the empty or partial result inode is InFlight for a 2.0-second write grace period; after that it becomes a terminal ResultSerializationError, and process retries refuse instead of rearming. The tests cover process retry and process death, not power loss. The result file is fsynced but its parent directory is not, so no directory-entry durability across power loss is claimed here. If the filesystem rejects creation of that marker itself after the external operation has landed (EROFS, exhausted inodes, or some ENOSPC failures), no local record may remain to distinguish success from a dead worker; after TTL a new generation can repeat the operation. Using the provider's own idempotency key for that boundary and manually reconciling ambiguous outcomes are operational recommendations; provider integration is not tested here.

  • A function exception keeps the key locked until TTL. An exception does not prove whether the side effect landed, so release_on_error is not an API. Immediate automatic retry after exceptions is intentionally unavailable.

  • The stored result is your function's return value, not the provider's receipt. The result is JSON, so replay is not type-faithful outside JSON's data model: a Python tuple is returned to the winner but becomes a list for a later caller. Return JSON-native values when winner and replay must compare equal.

  • A result that JSON cannot serialize is not replayable. The side effect has already completed, so once-guard creates a result_unavailable record and raises ResultSerializationError. Later callers receive the same refusal rather than running the side effect again.

  • The key is only as stable as the arguments you name. Key names are checked against the function signature, and positional calls are bound through that signature. Keys use type-tagged canonical encoding: dict keys retain their types, and tuple differs from list. Exact built-in JSON scalar/container types plus tuple are accepted; IntEnum, scalar subclasses, custom objects, Decimal, and non-finite floats raise UnstableKeyError before the side effect. Anything the model rewrites freely — a note, correlation id, or timestamp — must stay out.

  • Storage failure is not an in-flight worker. InFlight means a live claim (or the brief result-write grace period). ClaimContentionError is its retryable subclass for a claim race that did not settle. ClaimStorageError is a sibling under OnceGuardError, so read-only or unavailable storage is not accidentally retried forever as worker contention.

  • Nothing sweeps the claim directory. TTL advances an unfinished call; it does not expire completed results. The retry-horizon and provider-receipt cleanup rules below are unmeasured operational guidance; neither is tested here. The safe cleanup lower bound is the retry horizon: the greatest delay after which the same request can still arrive. Generation files form one signature family (<sig>.gen*.claim plus <sig>.gen*.result). Removing only a .result intentionally re-arms that family after TTL; removing an older claim creates a gap that direct probing cannot cross. Do not remove individual files or use generic find -delete on live state. Keep every generation contiguous through the retry horizon. Remove the entire reconciled family only when its provider receipt proves that late retries can no longer arrive. Removing the whole family re-arms the side effect — in this demo the next identical request is one extra $42.00 charge.

Why it is a separate thing

It came out of answering crewAI #5802"Tool re-execution on task retry has no idempotency guard — duplicate payments, emails, trades possible" — which asked for exactly this design and has no maintainer response and no merged fix. Agent frameworks retry; retry without a claim is how one refund becomes two. This small module keeps the mechanism inspectable without claiming the design was absent from that discussion.

Part of a set

Five more layers, each with an included demo and an MIT license:

They do not compose — separate configs, separate logs, separate ideas of what "denied" means. GuardStack is the composition: it is designed to benchmark the cost of each gate and document the limits shared by the set. It remains separate from these repositories. Assembling the six yourself is a legitimate choice and these repos stay free either way.

MIT © 2026 Jigon Yoo

About

One-file idempotency guard for side effects that must not run twice. No daemon, no dependencies, and a demo that prices every failure.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages