Karma is a small TCP service for fast, day-bucketed limit usage accounting.
Use it when an application needs fresh usage totals by limit, subject, and UTC day on the hot path. Karma keeps counters in memory, persists accepted writes with snapshots and a write-ahead log (WAL), and speaks newline-delimited JSON over TCP.
Russian documentation: README.ru.md.
Karma is built for this shape of problem:
application receives a request
-> records usage for a subject
-> reads the current usage total for the same limit
-> decides whether the subject is still within the limit
Good fits:
- API request, email, export, job, or storage usage limits;
- many subjects with small day-bucketed counters;
- fresh totals that are cheaper to read from a focused read model than from an analytical database;
- at-least-once producers that need idempotent writes.
Not a fit:
- arbitrary time-series tags or analytical queries;
- multi-master replication;
- automatic leader election or quorum writes;
- strong cross-node read-after-write guarantees from replicas.
Requirements:
- Crystal 1.17.1
- Shards
Build and run a local node:
shards build --release
bin/karma \
--bind=127.0.0.1 \
--port=8080 \
--directory=.karma-data \
--restore=true \
--wal=trueWrite one usage event:
printf '{"v":2,"op":"counter.increment","series":"api_requests","key":42,"value":1}\n' \
| nc 127.0.0.1 8080Read the current total:
printf '{"v":2,"op":"counter.sum","series":"api_requests","key":42}\n' \
| nc 127.0.0.1 8080Successful responses use the v2 envelope:
{"protocol_version":2,"success":true,"response":1,"error_code":null}Run with Docker:
docker build -t karma:local .
docker run --rm \
-p 8080:8080 \
-v karma-data:/data \
karma:local \
--bind=0.0.0.0 \
--port=8080 \
--directory=/data \
--restore=true \
--wal=true \
--wal-fsync=trueFor production, use a persistent volume, keep WAL enabled, keep
--wal-fsync=true unless you have a measured reason not to, scrape metrics, and
create regular snapshots with snapshot.create_all or SIGUSR1.
| Term | Meaning |
|---|---|
series |
Limit name, for example api_requests or emails_sent. |
key |
Unsigned 64-bit subject id inside a limit, for example account, user, workspace, or project id. |
bucket |
UTC day in YYYYMMDD format. If omitted on writes, Karma uses the current UTC day. |
value |
Unsigned 64-bit usage amount. Counters never go below zero. |
The public v2 protocol still contains some historical tree.* operation names.
For new examples and clients, use limit-usage language: series, key,
bucket, and value.
Read commands do not create missing series. A missing series returns
not_found; a missing key inside an existing series returns zero or an empty
result.
Karma 1.0 accepts protocol v2 only.
- One request is one JSON object followed by
\n. - One response is one JSON object followed by
\r\n. - Every request must include
"v": 2andop. - If
--auth-tokenis set, requests must includetoken. - If
--read-auth-tokenis set, that token can run only read-only commands.
Example request:
{"v":2,"op":"counter.increment","series":"api_requests","key":42,"bucket":20260620,"value":1}Example error:
{
"protocol_version": 2,
"success": false,
"response": "Field tree or series is required",
"error_code": "validation_error"
}Stable error codes include invalid_json, unsupported_protocol,
unknown_command, validation_error, not_found, unauthorized, forbidden,
request_too_large, response_too_large, query_timeout,
idempotency_conflict, replication_gap, replication_error, and
internal_error.
Create a limit explicitly when you want setup to be visible:
{"v":2,"op":"tree.create","series":"api_requests"}Record and read usage:
{"v":2,"op":"counter.increment","series":"api_requests","key":42,"value":1}
{"v":2,"op":"counter.increment","series":"api_requests","key":42,"bucket":20260620,"value":10}
{"v":2,"op":"counter.decrement","series":"api_requests","key":42,"bucket":20260620,"value":1}
{"v":2,"op":"counter.sum","series":"api_requests","key":42}
{"v":2,"op":"counter.sum","series":"api_requests","key":42,"range":{"from":20260601,"to":20260620}}
{"v":2,"op":"counter.series","series":"api_requests","key":42,"range":{"from":20260601,"to":20260620}}Use batches when the application can collapse events before sending them:
{"v":2,"op":"series.batch_add","series":"api_requests","items":[[42,20260620,37],[43,20260620,12]]}
{"v":2,"op":"series.batch_set","series":"api_requests","items":[[42,20260620,100],[43,20260620,0]]}
{"v":2,"op":"counter.batch_sum","series":"api_requests","keys":[41,42,43]}
{"v":2,"op":"counter.multi_sum","items":[{"series":"api_requests","key":101},{"series":"emails_sent","key":101}]}series.batch_set writes exact bucket values. A zero value deletes that bucket.
Large requests must fit --max-request-bytes.
Inspect and maintain a limit:
{"v":2,"op":"tree.list"}
{"v":2,"op":"tree.info","series":"api_requests"}
{"v":2,"op":"tree.keys","series":"api_requests","limit":1000,"cursor":0}
{"v":2,"op":"tree.summary","series":"api_requests","range":{"from":20260601,"to":20260620}}
{"v":2,"op":"tree.top","series":"api_requests","limit":100}
{"v":2,"op":"series.delete_before","series":"api_requests","before":20260601}
{"v":2,"op":"series.compact","series":"api_requests"}Reset or delete usage when an application needs to remove stored buckets:
{"v":2,"op":"counter.reset","series":"api_requests","key":42}
{"v":2,"op":"counter.delete_range","series":"api_requests","key":42,"range":{"from":20260601,"to":20260620}}
{"v":2,"op":"tree.reset","series":"api_requests"}Mutating commands can include idempotency_key. Karma stores the first
successful response for that key. Repeating the same payload returns the saved
response with "idempotent": true; reusing the key with a different payload
returns idempotency_conflict.
{"v":2,"op":"series.batch_add","series":"api_requests","items":[[42,20260620,37]],"idempotency_key":"usage-offsets-1000-1499"}Use idempotency for writes that may be retried by a queue, job runner, import, or at-least-once event source. Do not retry mutating writes blindly.
Idempotency retention is controlled by --idempotency-max-records,
--idempotency-max-age-seconds, and manual pruning:
{"v":2,"op":"idempotency.prune","before":"2026-06-20T00:00:00Z","limit":10000}Streaming ingest is for rebuilds, backfills, and large imports. It supports:
| Mode | Behavior |
|---|---|
add |
Add item values to the live series. |
set |
Set exact item bucket values in the live series. |
replace_series |
Build a staged series and atomically replace the live series on commit. |
{"v":2,"op":"ingest.begin","stream_id":"import-20260620","mode":"add","granularity":"day"}
{"v":2,"op":"ingest.chunk","stream_id":"import-20260620","series":"api_requests","chunk_seq":1,"items":[[42,20260620,10]]}
{"v":2,"op":"ingest.commit","stream_id":"import-20260620"}Duplicate chunks are skipped. Out-of-order chunks are rejected before they are applied. A committed stream is remembered durably, including after restart, snapshot restore, or replication bootstrap.
Karma stores live counters in memory and persists data through:
- snapshots: MessagePack
.treefiles, one per series; - WAL: newline-delimited JSON entries in
karma.wal.
With --restore=true, startup loads the latest snapshot per series and replays
WAL entries after the snapshot LSN.
Snapshot commands:
{"v":2,"op":"snapshot.create","series":"api_requests"}
{"v":2,"op":"snapshot.create_all"}
{"v":2,"op":"snapshot.list"}
{"v":2,"op":"snapshot.info"}
{"v":2,"op":"snapshot.verify"}snapshot.create_all writes atomic snapshots, fsyncs them, truncates WAL after
successful snapshotting, and prunes old snapshots according to
--dump-retention-per-tree.
Recovery markers for external pipelines:
{"v":2,"op":"recovery.checkpoint","source":"usage-export","offset":"export-2026-06-20","event_id":"batch-42"}
{"v":2,"op":"recovery.status"}
{"v":2,"op":"reconciliation.report","checked_points":1000,"mismatch_count":2,"absolute_drift":15,"max_abs_delta":10}Karma supports asynchronous master-to-slave replication. A slave bootstraps from master snapshots and then polls WAL entries.
bin/karma \
--role=slave \
--port=8081 \
--directory=/var/lib/karma-slave \
--restore=true \
--replication-source-host=127.0.0.1 \
--replication-source-port=8080 \
--replication-token=read-secretUseful checks:
{"v":2,"op":"replication.status"}
{"v":2,"op":"replication.entries","after_lsn":120,"limit":1000}Replication boundaries:
- replication is asynchronous;
- slave nodes reject direct mutating client commands;
- failover is manual;
- the old master must be stopped before promoting a slave.
Runbook: docs/replication-operations-runbook.md.
Health and metrics:
{"v":2,"op":"system.ping"}
{"v":2,"op":"system.health"}
{"v":2,"op":"system.stats"}
{"v":2,"op":"system.metrics"}Production runbook: docs/runbook.md.
Signals:
SIGINT: stop accepting clients, snapshot all series, truncate WAL after successful snapshots, and exit with status 0.SIGUSR1: snapshot all series, truncate WAL after successful snapshots, and keep running.
Common startup options:
| Option | Environment | Default | Meaning |
|---|---|---|---|
--bind=host |
KARMA_HOST |
0.0.0.0 |
Address to listen on. |
--port=port |
KARMA_PORT |
8080 |
TCP port. |
--directory=path |
KARMA_DUMP_DIR |
. |
Directory for snapshots, WAL, and metadata. |
--role=master|slave |
KARMA_ROLE |
master |
Node role. |
--restore=true|false |
KARMA_RESTORE |
true |
Restore snapshots and replay WAL on startup. |
--wal=true|false |
KARMA_WAL |
true |
Persist mutating commands to WAL. |
--wal-fsync=true|false |
KARMA_WAL_FSYNC |
true |
Fsync WAL writes and truncation. |
--auth-token=token |
KARMA_AUTH_TOKEN |
unset | Token required for all commands. |
--read-auth-token=token |
KARMA_READ_AUTH_TOKEN |
unset | Token allowed only for read-only commands. |
--max-request-bytes=bytes |
KARMA_MAX_REQUEST_BYTES |
4096 |
Maximum JSON request line size. |
--max-response-bytes=bytes |
KARMA_MAX_RESPONSE_BYTES |
1048576 |
Maximum JSON response size; 0 disables the limit. |
--query-timeout-ms=ms |
KARMA_QUERY_TIMEOUT_MS |
1000 |
Timeout for large reads; 0 disables it. |
Run bin/karma --help for the full flag list.
Crystal:
dependencies:
karma_client:
path: clients/crystalKarmaClient.with_client do |karma|
karma.record_usage("api_requests", subject_id: 42, amount: 1, day: Time.utc)
karma.usage("api_requests", subject_id: 42)
endSee clients/crystal.
Ruby/Rails:
gem "karma_client", path: "clients/ruby"KarmaClient.with_client do |karma|
karma.increment(series: "api_requests", key: 42, bucket: Date.current, value: 1)
karma.sum(series: "api_requests", key: 42, from: 7.days.ago.to_date, to: Date.current)
endSee clients/ruby.
Local performance depends on CPU, disk, filesystem, runtime, network, and workload mix. Treat the included results as regression checks, not universal promises.
Recent local checks showed:
- in-process single reads and writes in the hundreds of thousands of ops/sec with WAL off;
- batched add and batched sum paths above one million items or keys per second in local profiles;
- persisted single increments becoming WAL-bound when WAL is enabled;
- 10M-key scalability checks using about 5 GiB of reserved heap for 70M daily data points in one local run.
Charts:
Developer guide: docs/development.ru.md.
Baseline checks:
crystal spec
shards build --releaseFocused suites:
crystal spec spec/command_spec.cr
crystal spec spec/wal_spec.cr
crystal spec spec/replication_spec.cr
crystal spec spec/idempotency_spec.cr
crystal spec spec/bucketed_counter
crystal spec clients/crystal/spec
ruby clients/ruby/test/karma_client_test.rbDo not commit generated runtime data such as .crystal-cache-*, .karma-data,
.spec_*, bin/, snapshots, WAL files, or temporary files.
Current shard version: 1.0.1.
The supported public API is protocol v2. Legacy/v1 requests are intentionally not supported in Karma 1.0.
For a bug report, include the Karma version, startup flags, request JSON, response envelope, and relevant logs. For production triage, start with docs/runbook.md and, for replicas, docs/replication-operations-runbook.md.
MIT