once

module
v0.3.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 2, 2026 License: BSD-3-Clause

README

once

once is a small persistent log for side effects.

It solves a boring problem: retries are useful, but duplicated side effects are not.

once run --key email:user42:welcome -- ./send-welcome-email user42
once run --key email:user42:welcome -- ./send-welcome-email user42

The first command runs the program and stores its result. The second command does not run it again. It prints the saved stdout and stderr, and exits with the saved exit code.

If the same key is reused with a different command, once returns an error. A key should name one operation, not a family of similar operations.

What it is

once is an idempotency log for commands that perform side effects. Give an operation a stable key, and once records whether the operation is running, succeeded, or failed.

The model is:

  1. reserve a key
  2. run the command
  3. store the result
  4. replay the result when the same key is used again

The store is a SQLite database. By default it uses once.db in the current directory.

Quick start

go install github.com/Baagheera/once/cmd/once@latest

For local development:

go run ./cmd/once run --key demo -- echo hello
go run ./cmd/once run --key demo -- echo hello
go run ./cmd/once list
go run ./cmd/once status demo
go run ./cmd/once get demo

Run the local demo:

sh examples/demo.sh

It shows duplicate side effects, replayed command output, replayed failure results, and the HTTP reserve/commit/replay flow.

Use a different database:

once --store /tmp/once.db run --key payment:order-123 -- ./charge order-123

States

running

The key was reserved, but no result has been stored yet. This can mean that the command is still running, or that the process died before committing the result.

succeeded

The command exited with code 0. Future runs with the same key replay the saved result.

failed

The command exited with a non-zero code, could not be started, or hit a local execution limit such as timeout or maximum stored output. Future runs with the same key replay the saved failure.

Important limitation

once does not provide exactly-once execution against the outside world.

If a process sends an email, charges a card, or calls a webhook, and then dies before storing the result, once cannot know what happened outside the process. It keeps the key in running so the uncertainty is visible instead of hidden.

This is still useful. The common case becomes safe to retry, and the bad case becomes a record you can inspect.

For concrete crash cases, see docs/failure-model.md. For operational repair steps, see docs/repair-cookbook.md.

When not to use once

Do not use once as a job queue, scheduler, broker, workflow engine, or distributed transaction system. It stores one idempotency record; it does not assign work, retry work in the background, or coordinate workers.

Do not use once when you need exactly-once execution against an external system. It can replay a stored result, but it cannot prove whether a crashed process already changed the outside world.

Avoid once run for long-running or interactive jobs. It is intended for finite commands; current command-execution limits are listed below.

Treat the SQLite store as sensitive if command arguments, stdout, stderr, error strings, or keys can contain secrets. once persists those bytes so it can replay the result later. Treat SQLite sidecar files such as once.db-wal and once.db-shm the same way.

Current limitations

once run is intended for finite, non-interactive commands.

Today it buffers stdout and stderr in memory before storing them, so output is not streamed live. Use once run --max-output-bytes N when a command might write too much output to keep. It also does not pass stdin to the child process. Use once run --timeout DURATION when a command should be stopped after a local runtime limit.

The command line is stored to catch accidental key reuse, but once cannot infer changes in environment variables, input files, remote state, or other context outside the command arguments. Put that identity in the key today.

Commands

once run --key KEY [--timeout DURATION] [--max-output-bytes N] -- COMMAND [ARG...]
once serve [--listen ADDR] [--token-file PATH]
once status KEY
once get [--include-output] KEY
once doctor
once list [--state STATE] [--limit N]
once export [--state STATE] [--limit N] [--include-output]
once prune --state STATE --older-than DURATION [--force]
once forget [--force] KEY

Global flags:

--store PATH    SQLite database path. Default: once.db

doctor checks the local store path, file permissions, SQLite sidecar files, schema, and default token-file state. It reports problems and skipped checks; it does not repair the store, print token contents, or print stored command output.

list prints a local summary of records for operators. get prints one record as JSON. export writes JSONL, one record per line, for scripts and audit trails. Both get and export omit stored stdout and stderr by default; use --include-output only when those bytes are safe to handle.

prune finds terminal records updated more than DURATION ago in the local store. It requires --state succeeded or --state failed; durations can use Go syntax such as 24h or day syntax such as 30d. Without --force it only prints the records it would delete.

run --timeout times out the started command after the given duration. A timed-out command is stored as failed with exit code 124, then replayed like any other stored result. Durations use Go syntax such as 30s, 5m, or 1h.

The timeout applies when the command is first executed. If a result already exists for the key and command, once replays that stored result; changing --timeout later does not rerun it. Timeout is not a sandbox or transaction boundary. Commands that spawn or detach child processes can still leave work behind after the parent command is stopped.

run --max-output-bytes limits the total stored stdout and stderr bytes. Output past the limit is discarded while the command continues. If the limit is exceeded, once stores a failed result with exit code 125 and replays the truncated output and stored error on later runs. Changing --max-output-bytes later does not rerun an existing record. Use --max-output-bytes 0 to store no output; omit the flag for no output cap.

For stuck running records and terminal cleanup, see the repair cookbook.

HTTP mode

once serve exposes the same ledger over HTTP:

once serve --listen 127.0.0.1:7410

If no token is provided, once creates once.db.token next to the default database and prints only the token file path. Prefer token files for normal use. Tokens passed through flags or environment variables can be visible to other local processes on shared machines. Explicit --token values must be at least 32 characters.

Every endpoint except /healthz requires:

Authorization: Bearer <token>

JSON request bodies are capped at 1 MiB. Base64 output in commit requests counts toward that limit.

For local testing:

token="$(cat once.db.token)"

Reserve a key:

curl -s http://127.0.0.1:7410/v1/reserve \
  -H "authorization: Bearer $token" \
  -H 'content-type: application/json' \
  -d '{"key":"webhook:event-123","command":["send-webhook"]}'

The response includes an attempt_token for the fresh reservation. Commit the result with that token:

curl -s http://127.0.0.1:7410/v1/commit \
  -H "authorization: Bearer $token" \
  -H 'content-type: application/json' \
  -d '{"key":"webhook:event-123","attempt_token":"...","state":"succeeded","exit_code":0,"stdout_b64":"b2sK"}'

Fetch the record:

curl -s http://127.0.0.1:7410/v1/records/webhook:event-123 \
  -H "authorization: Bearer $token"

Byte fields such as stdout_b64 and stderr_b64 are JSON base64 strings. The HTTP server does not run commands; it only stores and returns idempotency records.

Deleting over HTTP requires the reservation token in X-Once-Attempt-Token. Deleting a running record also requires ?force=1. The local CLI keeps forget --force as an explicit administrative repair path for people with direct access to the SQLite database.

Do not expose once serve to untrusted networks. Non-loopback listeners require --allow-remote, and bearer authentication is still required unless auth is explicitly disabled on a loopback address with --unsafe-no-auth.

Non-goals

once is not a workflow engine, job queue, scheduler, broker, or distributed transaction system.

It is one small primitive: keep a durable idempotency record for a side effect.

Directories

Path Synopsis
cmd
once command
internal
cli

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL