Kudos accounting reference¶
Topics: accounting, kudos
The maintainer contract for kudos mutation and consumption: the authoritative events, the materialized targets, the producers and consumers, the locks, and the tests that hold each rule. For design rationale, read Kudos accounting, projection, and concurrency. For deployment and incident procedures, follow Kudos ledger operations.
Source of truth by mode¶
| Concern | shadow mode |
ledger mode |
|---|---|---|
| Accepted currency movement | Append kudos_ledger row with applied = true |
Append kudos_ledger row with applied = false |
| Accepted display/counter movement | Append kudos_stat_events row with applied = true |
Append kudos_stat_events row with applied = false |
| Materialized mutation owner | kudos_legacy_projection applies the historical inline change |
apply_pending_kudos folds events asynchronously |
| Spend authorization | Payer lock plus available_kudos and reservations |
Payer lock plus available_kudos and reservations |
| Ordinary balance reads | Materialized column, current after the transaction | Materialized column, eventually consistent |
| Audit/replay role | Forward audit; opening balance predates it | Authoritative post-cutover movement history |
| Default | Yes, for new/migrated installations | No; operator must explicitly cut over |
The existing balance at first deployment is the opening position. The ledger contains deltas from deployment onward, not a fabricated history of older movements.
Vocabulary¶
| Term | Exact meaning |
|---|---|
| Currency | A delta to a user's spendable users.kudos or evaluation users.evaluating_kudos balance. |
| Display kudos | A worker/team attribution total. It describes earned work but is not spendable account currency. |
| Counter | A value denominated in kudos, things, or count and projected into stats, records, or aggregates. |
| Business event | One logical mutation such as a settlement or transfer, correlated by one event_id. |
| Posting | One signed row affecting exactly one target balance or statistic. Direction comes from the amount's sign. |
| Projection | A denormalized current value maintained from postings for inexpensive reads. |
| Hold/reservation | A temporary claim against one payer's available kudos. It is neither a debit nor a credit. |
| Applied | The posting was included in a committed projection transaction, or was projected inline in shadow mode. |
| Available balance | Conservative spend capacity after floor, active reservations, and unreserved queued debits. |
| Effective balance | Materialized balance plus all committed unapplied currency deltas, clamped to the account floor. |
Data model¶
Accounting tables¶
| Model/table | Purpose | Ownership and lifetime | Important constraints |
|---|---|---|---|
KudosLedger / kudos_ledger |
Currency postings, range-partitioned by created |
Required users.id foreign key with ON DELETE RESTRICT; authoritative history must not be orphaned |
One user target; spendable vs escrow selected by escrow; NUMERIC(20,2) amount; NaN rejected; partial unapplied index |
KudosStatEvent / kudos_stat_events |
Display and counter postings, range-partitioned by created |
User, worker, and team IDs are immutable audit references, intentionally not ownership foreign keys because workers/teams may be hard-deleted | Exactly one of user_id and worker_id; typed unit; NaN rejected; invalid events are quarantined outside the drainable queue |
KudosReservation / kudos_reservations |
Payer holds for upfront work and transfers | User ownership foreign key with ON DELETE CASCADE |
Unique business_id; positive original amount; non-negative remaining amount; active-user partial index |
KudosBalanceSnapshot / kudos_balance_snapshots |
User-currency reconciliation baseline | User ownership foreign key with ON DELETE CASCADE |
One row per snapshot/user; records balance, escrow, and visible applied totals |
KudosSnapshotInvalidation / kudos_snapshot_invalidations |
Interval of snapshot creation times whose baselines a checkpoint rebuild made unusable | Operational state | One row per recheckpoint that deleted checkpoints. Reconciliation refuses a snapshot created inside any interval |
KudosLedgerControl / kudos_ledger_control |
Single-row mutation-mode control | Installation state | ID 1; shadow or ledger; non-null change time |
KudosLedgerApplierState / kudos_ledger_applier_state |
Projector heartbeat | Operational state only | ID 1; not a watermark and not used for exactly-once folding |
KudosStatRollup / kudos_stat_rollups |
Statistic events of retired partitions, summed per UTC day and attribution key | Authoritative history. User, worker and team IDs are audit references without foreign keys | Unique over day, attribution columns and source_range, with NULLs not distinct |
KudosStatQuarantine / kudos_stat_quarantine |
Quarantined statistic events moved unchanged out of retired partitions | Audit references without foreign keys. Rows do not expire | id is the original event ID, not a sequence |
KudosLedgerCheckpoint / kudos_ledger_checkpoints |
Per-user cumulative applied balance and escrow totals through a retired partition's upper bound | User foreign key with ON DELETE CASCADE. Authoritative history |
Unique (boundary, user_id). Excludes RECONCILIATION postings |
KudosLedgerDailyStatement / kudos_ledger_daily |
Per-user daily sums of retired currency postings by balance and entry type | User foreign key with ON DELETE CASCADE. Authoritative history |
Unique (user_id, escrow, entry_type, day, source_range) |
KudosIdempotencyKey / kudos_idempotency_keys |
Retry keys of transfers and repairs, with parties, amount and outcome | Deleted after the key lifetime. The party columns are not foreign keys | event_id primary key. created index for expiry |
KudosRetentionUnit / kudos_retention_catalog |
Retention state of each retired partition or legacy sub-range | Operational state | Unique (dataset, partition_name, unit_lower, unit_upper) with NULLs not distinct |
The mapped accounting models use SQLAlchemy 2 Mapped/mapped_column attributes. Runtime dialect operations are
confined to typed helpers: advisory locks and isolation in horde/database/kudos_db.py, counter upserts in
horde/database/kudos_counters.py, and legacy mode behavior in horde/database/kudos_legacy_projection.py. Raw DDL
belongs in the versioned migration file, not request code. The SQLAlchemy mypy plugin checks the accounting model
surface in CI.
KudosLedger fields¶
| Field | Contract |
|---|---|
id |
Database identity used only for bounded claim order; it is not a transaction watermark. |
created |
Audit timestamp, queue-age source and partition key. The primary key is (id, created). |
event_id |
UUID shared by related postings. A deterministic UUID is produced when an idempotency key is supplied. |
entry_type |
Typed business classification from KudosEntryType; never encodes debit/credit direction. |
user_id |
Required currency owner. |
escrow |
false targets spendable balance; true targets evaluation escrow. |
amount |
Signed NUMERIC(20,2) delta. Positive credits and negative debits are separate postings. |
applied |
Per-row work-queue state changed in the same transaction as its projection. |
job_id, wp_type |
Optional correlation dimensions inherited from the active kudos_event. |
detail |
Typed-key audit metadata. The applier only interprets documented keys. |
KudosStatEvent fields¶
event_id, entry_type, job_id, wp_type, amount, detail, and applied have the same correlation/audit
meaning as their currency counterparts. The remaining fields define a counter projection:
| Field | Contract |
|---|---|
user_id |
User counter target; mutually exclusive with worker_id. |
worker_id |
Worker counter target; mutually exclusive with user_id. |
worker_user_id |
Worker owner captured for audit; not itself a projection target. |
team_id |
Team attribution captured when the event is emitted. |
unit |
kudos, things, or count; consumers must not combine unlike units. |
stat_action |
Action/bucket dimension such as generated, usage, contributions, or fulfilments. |
record |
Typed projector discriminator or user-record dimension. |
quarantined, quarantine_reason, quarantined_at |
Durable poison-event isolation. Every claimed statistic row sharing a malformed event's event_id is quarantined together and retained for review. |
Stable enums and audit keys¶
| Enum | Values/role |
|---|---|
KudosLedgerMode |
shadow, ledger |
KudosUnit |
kudos, things, count |
KudosStatRecord |
user_kudos, worker_kudos, last_active |
KudosStatEventQuarantineReason |
Closed reason codes for deterministic statistic-event isolation |
KudosAggregate |
contributions, fulfilments |
KudosAuditDetail |
reason, reservation_id, snapshot_id, touch_last_active, style_author_id, style_surcharge, style_type |
The three style_* keys appear together on a waiting-prompt activation debit whose request runs under someone else's
style. They contain the author's user id, the part of the debit that is the style surcharge (a decimal string), and the
style type (text or image), which is the user-record dimension the author's style count is kept under. They are written and
read through StyleReward.audit_detail and StyleReward.from_audit_detail.
Do not add an untyped string discriminator when one of these axes describes it. Add or extend the enum and teach the projector and tests about the new value together.
Entry-type catalogue¶
An entry type answers “why did this posting exist?” The sign and target answer “what did it do?”
KudosEntryType |
Expected use |
|---|---|
GENERATION |
Image/text/interrogation settlement: requester debit, worker-owner spendable/escrow credit, and worker display event as applicable |
UPTIME_REWARD |
Periodic worker display credit and owner spendable/escrow credit |
EVALUATION_PROMOTION |
Paired escrow debit and spendable credit after trust promotion, plus the user's matching accumulated statistic movement |
TRANSFER |
Paired source debit and destination credit for a user gift |
ADMIN_ADJUSTMENT |
Signed administrator adjustment |
AWARD |
Monthly, rating/aesthetic, and other application award credits |
STYLE_REWARD |
Style-owner currency reward for the part of the style surcharge the requester's activation debit collected. The style object's counters remain separate |
STAT_RECORD |
User record counts or thing totals |
STAT_CONTRIBUTION |
Worker/team contribution and fulfilment aggregates |
STAT_ACTIVITY |
Asynchronous user last_active touch |
FLOOR_ADJUSTMENT |
Explicit currency created when an account-floor rule forgives a debit |
RECONCILIATION |
Deterministic compensating currency posting emitted by an approved repair |
Mutation contract¶
All new or modified kudos code must obey these rules.
- Use the public producer surface. User currency enters through
User.modify_kudosorUser.modify_evaluating_kudos. Derived events enter throughemit_kudos_stat_event. Do not assign a materialized accounting column in a business path. Narrow exceptions are listed under excluded kudos-like state. - Group a logical event. Wrap a settlement, transfer, activation tax, or other multi-posting mutation in
kudos_event. Includejob_idandwp_typewhere available. - Make external retries explicit. If an API mutation can be retried independently of locked business state, accept a stable idempotency key, derive the event ID from it, and reject reuse with different parameters. A random event UUID provides correlation only.
- Commit business state and postings together. Do not commit part of an event, perform network work, and append the rest later. Emission helpers flush by default so the caller owns the transaction boundary.
- One currency row, one user balance. Worker/team IDs and counter units never belong in
KudosLedger. Emit separate debit and credit rows. Do not encode a transfer as an opaque net amount. - Reserve before authorizing eventual spend. Under
acquire_payer_lock, callreserve_kudoswith a stablebusiness_id. Attach that reservation ID to matching debit metadata. Never useeffective_kudosto authorize a spend. - Do not fund from pending credits.
available_kudosdeliberately ignores them. Changing that rule requires a proof covering credit rollback, event ordering, and projector failure. - Preserve mode ownership. Business code emits events in both modes and delegates temporary inline behavior to
kudos_legacy_projection. Do not add mode branches to endpoints or settlement methods. - Record non-linear compatibility behavior. If a rule floors, caps, forgives, or otherwise changes the requested signed delta, emit the difference as its own typed posting so replay matches the projection.
- Capture historical dimensions at emission. Team membership and similar attribution must be stamped on the event. The projector must not infer past ownership from mutable current relationships.
- Use decimal accounting values. Convert through
Decimal(str(value))and round/quantize at the existing two-decimal boundary. Never fold currency through binary-float accumulation. - Keep the archive immutable. Do not update old amounts, retarget postings, delete applied history, or toggle
appliedas a repair. Emit a compensating posting. Only the retention job removes applied history, as described under event retention. - Add producer, fold, retry, and recovery tests. At minimum pin the emitted rows and materialized result. A new spend path also needs concurrent overspend coverage. A new retryable path needs replay/conflict coverage.
Direct materialized assignments are allowed only for initial data/bootstrap before an accounting movement exists, or inside the shadow/projector/reconciliation implementation described here. A test fixture that needs an opening balance should assign it directly and commit rather than creating misleading ledger history.
Producer inventory¶
This is the current inventory of paths that create user currency or kudos-derived projection events.
| Business event | Entry types and targets | Primary code |
|---|---|---|
| Waiting-prompt activation tax | Requester GENERATION debit; user usage/record/activity stats; upfront reservation consumption |
WaitingPrompt._activate, WaitingPrompt.record_usage, User.record_usage |
| Image/text generation settlement or cancellation settlement | Requester GENERATION debit; owner spendable/escrow credit; worker kudos/contribution/fulfilment; user records; team aggregates |
ProcessingGeneration.record, WorkerTemplate.record_contribution, User.record_usage, User.record_contributions |
| Interrogation form settlement/cancellation | Same currency pattern for requester/worker owner plus interrogation worker counters | InterrogationForms.record, InterrogationWorker.record_contribution |
| Worker uptime interval | Owner UPTIME_REWARD spendable/escrow credit and worker display/stat credit |
WorkerTemplate.record_uptime, User.record_uptime |
| Trust threshold crossing | EVALUATION_PROMOTION escrow debit and spendable credit, plus the user accumulated statistic event |
Projector promotion/drain helpers; shadow compatibility projector |
| User transfer | Paired TRANSFER source debit/destination credit, transfer log, payer reservation |
transfer_kudos and username/API-key wrappers |
| Monthly/recurring grant | AWARD user credit |
User.modify_monthly_kudos, User.receive_monthly_kudos |
| Rating/aesthetic reward | AWARD user credit |
Stable API rating/aesthetic endpoints |
| KoboldAI or other application award | AWARD user credit |
Kobold and base API award endpoints |
| Style-owner reward | STYLE_REWARD user credit and user style record, for the part of the surcharge the activation debit collected |
User.record_style, called by _credit_style_authors in ledger mode and project_style_reward in shadow mode |
| Administrator adjustment | ADMIN_ADJUSTMENT signed user delta |
User administration API through User.modify_kudos |
| Minimum-balance forgiveness | Already-applied FLOOR_ADJUSTMENT user credit |
User projection helper in shadow mode; _apply_user_deltas in ledger mode |
| Reconciliation repair | Deterministic RECONCILIATION user spendable/escrow delta and its idempotency key. Refused with ValueError when the snapshot is older than the key lifetime, and with StaleCheckpointsError while a currency retention unit is blocked with late rows or online drift since export or when the snapshot was created inside a kudos_snapshot_invalidations interval |
reconcile_balances(..., apply_repairs=True) |
User.modify_kudos also emits the matching per-action user-kudos statistics event. WorkerTemplate.modify_kudos
emits worker display/stat events, not currency. Producer wrappers should retain that distinction.
Two interrogation contracts in that table correct behaviour the inline implementation had:
- An interrogation uptime crossing grants an untrusted owner exactly one reward, routed wholly to the spendable
balance by the alchemist bypass and minting no escrow, where the inline path credited both the balance and escrow
for the same crossing (pinned by
tests/unit/test_kudos_invariants.py::test_interrogation_uptime_grants_untrusted_owner_exactly_one_reward). - Cancelling an interrogation form a worker is still processing settles it like a submission, crediting the worker
and owner and debiting the requester the form kudos plus burn, where the inline path moved the form to
CANCELLEDahead of the settle check and so settled nothing (pinned bytests/unit/test_kudos_invariants.py::test_cancelling_in_flight_interrogation_form_settles_like_a_submit).
Projection target inventory¶
| Materialized target | Event source | Projector rule |
|---|---|---|
users.kudos |
Non-escrow currency postings | Sum per user, apply in user-ID order, clamp to get_min_kudos, record floor difference |
users.evaluating_kudos |
Escrow currency postings | Sum per user; promotion is evaluated after the fold |
users.last_active |
STAT_ACTIVITY/touch detail |
Keep the latest event timestamp |
user_stats.value |
user_kudos stat record |
Atomic insert-or-increment by (user_id, action) |
user_records.value |
STAT_RECORD |
Atomic insert-or-increment by (user_id, record_type, record) |
workers.kudos |
worker_kudos stat record |
Sum per worker |
worker_stats.value |
worker_kudos stat record |
Atomic insert-or-increment by (worker_id, action) |
workers.contributions |
STAT_CONTRIBUTION / contributions |
Sum thing deltas per worker |
workers.fulfilments |
STAT_CONTRIBUTION / fulfilments |
Sum count deltas per worker |
teams.kudos |
Team-stamped worker kudos events | Sum per captured team ID |
teams.contributions |
Team-stamped contribution events | Sum per captured team ID |
teams.fulfilments |
Team-stamped fulfilment events | Sum per captured team ID |
| Reservation remainder/release time | Currency debit metadata or completed transfer event | Consume request holds by debit; release transfer hold only after every event posting is applied |
| Trusted user role and worker pause state | Projected escrow crossing threshold | Promote eligible mature user, unpause workers, then emit the escrow drain pair |
| Style author credit | Activation debits carrying the style_* detail keys |
After the user fold, attribute each requester's floor difference to their surcharges in id order, each absorbing up to its whole amount. Emit the author's STYLE_REWARD posting and style record for any remainder, folded by a later cycle |
A surcharge the floor forgave is not credited to the style's author, since the requester paid none of it and crediting
it would create currency. The floor difference is computed per user per batch, so a requester's credits in the same
batch reduce what the floor forgives. In shadow mode the same attribution runs inline during activation from the floor
adjustment User.modify_kudos returns. In ledger mode that return is zero and only the applier credits, so a request
never credits its author twice.
Currency rows and statistic rows are each claimed in a bounded batch. A single event may cross a batch boundary. Any event-wide side effect must query for remaining unapplied rows rather than assuming a batch contains the whole event.
Consumer and read-model inventory¶
Choosing the correct read is part of the mutation contract. A blanket replacement of every .kudos access is wrong
because account currency, queue priority, worker attribution, job price, and shared-key quota have different meaning.
User currency consumers¶
| Consumer | Current read | Consistency and rule |
|---|---|---|
| Upfront image/text request admission | reserve_kudos -> available_kudos |
Spend-safe and conservative; includes floor, holds, and queued debits; ignores queued credits |
| Interrogation admission and interrogation-worker check | reserve_kudos / available_kudos |
Spend-safe; retryable form reactivation reuses its business ID |
| User transfer | reserve_kudos -> available_kudos |
Payer-serialized; recipient is not locked; optional API idempotency key protects replay for the key lifetime |
| Admin adjustment “new balance” response | effective_kudos |
Includes the just-committed/unapplied delta; not an authorization value |
| Queue priority for image/text | user.kudos copied to waiting_prompt.extra_priority at activation |
Snapshot of a potentially lagging projection; queue ordering thereafter uses extra_priority |
| Queue priority for interrogation | user.kudos copied to interrogations.extra_priority at construction |
Snapshot of a potentially lagging projection |
| Stable worker's secondary upfront eligibility check | materialized waiting_prompt.user.kudos minus floor |
Eventual legacy recheck; initial admission hold prevents overspend, but lag may transiently alter scheduling eligibility |
| User details, login/welcome, status and ordinary API display | materialized user.kudos/evaluating_kudos |
Eventually consistent in ledger mode |
Award endpoints returning new_kudos |
generally materialized user.kudos |
May return the pre-projection value unless the endpoint explicitly uses effective_kudos |
| User listing/sorting and inactive-account heuristics | materialized User.kudos |
Eventually consistent; intended for administrative/read-model behavior, not spend admission |
| Trust promotion | materialized evaluating_kudos inside the serialized projector |
Evaluated after fold; final qualifying event promotes without another request |
| Snapshot/reconciliation | materialized user columns plus applied ledger totals | Repeatable-read baseline; repair only through compensating postings |
The two legacy materialized reads in worker scheduling are documented rather than hidden. If they are changed, the
maintainer must decide the intended policy: available_kudos is conservative spend capacity. effective_kudos
includes queued income. The materialized value is a cheap, lagging priority/display projection. They are not
interchangeable.
Derived-stat consumers¶
| Projection | Consumers |
|---|---|
workers.kudos, worker_stats |
Worker detail/API reward totals and per-action breakdowns |
workers.contributions, workers.fulfilments |
Worker details, top-worker queries, global totals, performance/leaderboard views |
teams.kudos, teams.contributions, teams.fulfilments |
Team detail/API aggregates and rankings |
user_stats |
User per-action kudos breakdown returned by user detail paths |
user_records |
User usage/contribution/request/fulfilment records, contributor queries, statistics endpoints |
users.last_active |
Account activity and stale-account lifecycle logic |
| Applier heartbeat and queue/reservation ages | Quorum-node metrics, operator status command, cutover gates. Node-local /api/v2/status/heartbeat excludes these shared-database signals |
All of these can lag in ledger mode. Consumers that need transaction-local proof of an accepted event should inspect the event/result they just created, not force the projector or assume a refreshed aggregate.
Excluded kudos-like state¶
The following kudos-adjacent state is not a user currency projection and must not be routed through
KudosLedger without a separate design decision:
| State | Meaning and mutation rule |
|---|---|
UserSharedKey.kudos and utilized |
Per-key quota/budget, including -1 for unlimited; consumed inline with the request, because the clamp at zero is an enforcement read in the request path and a separate endpoint sets the budget absolutely |
WaitingPrompt.kudos, consumed_kudos, generation/form kudos |
Estimated price, accumulated job cost, or reward returned for one job; kept inline because they are per-prompt rows rather than shared aggregates and the same event already emits a currency posting |
WorkerTemplate.kudos |
Worker-attributed display total; projected from KudosStatEvent |
Team.kudos |
Team-attributed display total; projected from worker stat events stamped with team_id |
Style.kudos, Style.contributions, Style.fulfilments |
Style-level popularity/contribution counters; still maintained by the style subsystem |
KudosTransferLog |
Auxiliary transfer history and policy/audit record; does not replace the paired currency postings |
| Test-user bootstrap balance | Absolute fixture/local-login opening state; direct assignment avoids inventing an accounting movement |
workers.last_reward_uptime |
An absolute cursor set to the worker's current uptime at each reward crossing, which a signed-delta fold cannot express |
workers.aborted_jobs |
Resets to zero on hourly rollover and on tripping the drop threshold, so its value depends on event ordering and timing rather than a sum, and the threshold decision is read in the same path that increments it |
Reservation lifecycle¶
| Operation | Stable business ID | Created/reactivated | Consumed/released |
|---|---|---|---|
| Image/text upfront admission | upfront:<waiting-prompt-id> |
Before activation is accepted | Debit projection consumes it; prompt deletion/cancel paths and the expired-prompt cleanup release unused remainder |
| Interrogation upfront admission | interrogation:<form-id> |
When a worker claims a waiting form | Debit projection consumes it; cancellation/failure paths and the expired-interrogation cleanup release it; a retry reactivates the row |
| Transfer | transfer:<source-user-id>:<event-id> |
Before paired postings commit | Released only after no unapplied posting remains for the event |
business_id uniqueness supplies retry stability. Reactivation may not change payer. Reservation mutation uses row
locks only after payer-scoped admission serialization. Release functions are idempotent and return zero when there
is not an active hold. available_kudos reads the balance, active holds, and queued debits in one statement so all terms come
from a single database snapshot. A fold or release committing mid-read cannot hide a debit from every term.
Concurrency and lock reference¶
| Primitive | Scope | Acquired by | Purpose |
|---|---|---|---|
| Applier advisory transaction lock | Installation | Projector and mode transition | At most one database projector; serialize final drain with projection |
| Payer advisory transaction lock | User ID | Reservations/transfers/admission | Prevent concurrent promises from overspending one payer without locking recipients |
| Reconciliation advisory transaction lock | Installation | Repair mode | Prevent concurrent compensation emitters |
| Mode-gate advisory transaction lock (shared) | Mutation transaction | get_kudos_ledger_mode |
Pin mode until the writer commits |
| Mode-gate advisory transaction lock (exclusive) | Installation | set_kudos_ledger_mode |
Wait for every old-mode writer before ownership changes; fair queueing keeps new pins from starving a waiting transition |
Event-row FOR UPDATE SKIP LOCKED |
Bounded batch | Projector; worker deletion for that worker | Claim exact unapplied work without a watermark; serialize worker deletion before child-counter writes |
Projection-target FOR KEY SHARE |
Claimed users/workers | Projector | Prevent target deletion between validation and foreign-key child insertion without blocking ordinary non-key updates |
Reservation-row FOR UPDATE |
Business ID | Consume/release | Serialize hold depletion/release |
| Repeatable-read transaction | Snapshot/reconciliation command | Reconciliation helpers | Observe balances and applied totals from one consistent database snapshot |
| Retention advisory session lock | Installation | run_retention_tick |
At most one retention tick at a time. It is held on a dedicated autocommit connection, so a long export does not keep a transaction open |
Required accounting lock order is applier lock before exclusive mode-gate lock. Projection targets are visited in
stable ID order. Code that needs another accounting lock must document where it fits before it is merged. The legacy
USE_SQLITE runtime mode short-circuits PostgreSQL advisory locks and therefore cannot validate these concurrency
properties.
Failure and recovery invariants¶
- A projector cycle commits both target updates and exact applied flags, or neither.
- Deterministically malformed statistic events are quarantined by business-event ID. Unrelated events in the batch continue, while infrastructure failures still roll back the whole cycle.
- A row is selected by
applied = false, never byid > watermark. - A stopped projector is a lag incident, not lost work. Restore it and drain in ledger mode.
- A transfer hold remains active until both sides of the event are materialized, even when a batch splits the event.
- A trusted user's positive escrow with no in-flight promotion pair is self-healed by a later projector cycle.
- Minimum-balance flooring creates an explicit
FLOOR_ADJUSTMENTso snapshot replay equals projection. - A snapshot records both opening columns and the applied totals visible at that instant.
- Reconciliation excludes prior
RECONCILIATIONentries from its movement baseline and emits one deterministic repair event per snapshot/user. - Ledger-to-shadow transition owns the applier lock, waits for ledger-mode writers, drains the final tail, and changes mode in one transaction.
- Applied currency and statistic history stays online for the retention window. After the window, the retention job removes a partition only once its replacement is committed. See event retention.
Never recover by deleting ledger/stat rows, editing old amounts or targets, changing applied, or directly assigning a
production balance. Preserve evidence and emit compensation. See the operations guide
for the executable procedure.
Event retention¶
kudos_ledger and kudos_stat_events keep exact events online for at least 30 days. After that window, statistic
events are replaced by daily rollups. Currency postings are replaced by per-user checkpoints, daily statements and a
verified archive object. The partition is then dropped. The reasons are recorded in
ADR 42. The procedures for
converting, enabling, unblocking and restoring are in
kudos ledger operations.
Partition layout¶
Both event tables are partitioned by RANGE (created) and have the primary key (id, created). created is naive
UTC.
| Partition | Bounds | Origin |
|---|---|---|
<table>_p_legacy |
MINVALUE to the swap timestamp |
On a converted database, the table as it was before sql_statements/5.1.16.txt. On a fresh database, an empty partition up to the UTC Monday 00:00 on or before the first bootstrap |
<table>_p_<YYYYMMDD> |
From the date in the name for one partition width | On a converted database, the eight weeks from the swap timestamp are created by sql_statements/5.1.16.txt. Later partitions are created ahead of time by ensure_kudos_event_partitions |
sql_statements/5.1.16.txt creates eight weekly partitions because the release before it does not create
partitions. After a rollback to that release, inserts keep finding a partition until eight weeks after the swap
timestamp. Each of these partitions has the storage parameters of KudosPartitioning.AUTOVACUUM_STORAGE_PARAMETERS.
ensure_kudos_event_partitions creates partitions until one covers the current time plus twice the partition width.
It runs in init_db at startup with the default width, and on every retention tick with KUDOS_PARTITION_WIDTH_DAYS.
New partitions continue from the highest existing upper bound, so with the default width every bound is a UTC Monday
00:00. A new partition inherits every partitioned index and, on kudos_ledger, the users foreign key.
Each new partition is created as a standalone table with
LIKE <table> INCLUDING DEFAULTS INCLUDING CONSTRAINTS INCLUDING INDEXES, the storage parameters of
KudosPartitioning.AUTOVACUUM_STORAGE_PARAMETERS and a validated <partition>_bounds CHECK on its range. It is then
attached with ALTER TABLE ... ATTACH PARTITION, and the CHECK is dropped. The attach skips its validation scan
because of the CHECK, and attaches each copied index to the matching partitioned index.
| Lock | Mode | Waits behind |
|---|---|---|
| Parent, from the second bounds listing to the end of the transaction | SHARE UPDATE EXCLUSIVE |
Another partition creation, VACUUM, ANALYZE, a concurrent detach |
users, during a kudos_ledger attach |
SHARE ROW EXCLUSIVE |
Any open transaction that wrote to users |
Reads and inserts through the parent do not wait for these locks. Writes to users queue behind the users request
while it waits. Every wait ends after KudosPartitioning.CREATE_LOCK_TIMEOUT (3 seconds). On a timeout, the partition
is not created, its standalone table is rolled back, and the call logs the warning
Deferred partition <partition> of <table> to the next call, or Deferred the partitions of <table> to the next call
when the parent lock timed out, and returns without raising. The partitions it created
before the timeout are kept, and the next retention tick retries the rest. A range whose partition name already
belongs to a table outside the partitioning, such as a detached partition, is skipped with a warning.
kudos_stat_events_p_legacy also has ix_kudos_stat_events_legacy_created. That index belongs to the legacy partition
alone and is not attached to the parent. The retention job reads the legacy partition by created sub-range through
it.
Retention units¶
A retention unit is one partition, except for the legacy partition, which is split into one unit per partition width
of created. Legacy unit boundaries sit on the same grid as the weekly partitions. The first legacy unit starts at
MINVALUE, and an empty legacy partition is a single unit. Each unit is one kudos_retention_catalog row with
dataset (currency for kudos_ledger, statistics for kudos_stat_events), partition_name, unit_lower (NULL
for MINVALUE), unit_upper, state, object_key, error and these bookkeeping columns:
| Column | Meaning |
|---|---|
row_count, max_id |
Row count and highest id of the unit's range, with no filter. Recorded in the transaction that commits the replacement: the checkpoint for currency, the rollup for statistics. NULL before that |
first_failed_at |
When the current run of failed statistics dumps began. NULL after a successful dump |
consecutive_dump_failures |
Failed statistics dump attempts since the last success |
last_dump_attempt_at |
When the statistics dump was last attempted, by the tick's reference time. Spaces the retries |
detached_at |
The tick's reference time when the partition was detached, set on every unit before the recount that follows the detach. With KUDOS_RETENTION_DROP_GRACE_DAYS above 0 the drop is due once the grace has passed since it. A unit that recount blocks keeps it, so retention reattach finds the partition. NULL while the partition is attached. A dropped unit keeps the value it had |
state_before_detach |
The unit's state before that detach. It stays set while the unit is blocked with reattached by operator, and retention unblock restores it. NULL otherwise |
A currency unit in checkpointed, exported, verified, detached or dropped records the checkpoint boundary at
unit_upper. The checkpoint chain is read from these catalog rows, because an empty unit does not write any
kudos_ledger_checkpoints rows.
A unit's source_range is <partition_name>/<lower>_<upper>. Each bound is formatted as %Y%m%dT%H%M%S, and a
MINVALUE lower bound is written as min. Rollups, quarantine rows and daily statements store the source_range of
the unit they came from. A retried step deletes the rows with its own source_range before inserting them again. A UTC
day can span two units, so readers sum across source_range.
A unit is eligible when its upper bound is older than the dataset's window. A tick creates the catalog rows of a
weekly partition, in state pending, once its upper bound is older than the window, and the rows of the legacy
partition the first time it sees it attached, because the legacy sub-ranges age one by one and the partition's own
upper bound, the swap time, is the bound of its last sub-range only. Units are processed in order of upper bound,
oldest first.
Retention states¶
The states are KudosRetentionState values. Each arrow is one transition, and a tick makes at most one transition.
| Dataset | Path |
|---|---|
statistics |
pending -> rolled_up -> dumped -> dropped, or rolled_up -> dump_failed (retried) -> dumped, or dump_failed -> dump_waived (operator) -> dropped, or without an archive rolled_up -> dump_waived -> dropped. A dump over the statistics budget makes the unit blocked |
currency |
pending -> checkpointed -> exported -> verified -> dropped |
With KUDOS_RETENTION_DROP_GRACE_DAYS above 0, the last arrow of both paths is two transitions: the drop-ready state
-> detached -> dropped. retention reattach moves a detached unit to blocked with reattached by operator,
and retention unblock then returns it to its drop-ready state.
| State | Dataset | Meaning |
|---|---|---|
pending |
Both | Catalogued. A replacement is not committed yet |
rolled_up |
statistics |
Applied, non-quarantined rows are summed into kudos_stat_rollups, and quarantined rows are copied to kudos_stat_quarantine. The rollup totals matched the unit's totals in the same transaction |
dumped |
statistics |
A compressed copy of the unit is in the statistics tier of the archive |
dump_failed |
statistics |
The last dump attempt failed. The dump is retried once DUMP_RETRY_SPACING has passed since the attempt. The unit is not dropped in this state |
dump_waived |
statistics |
The unit is dropped without a dump, because an operator accepted it or no archive is configured (error is then archive not configured). The rollup is the only replacement |
checkpointed |
currency |
Per-user checkpoints at unit_upper and the unit's daily statements are committed in one repeatable-read transaction |
exported |
currency |
The unit's rows and manifest are in the currency tier of the archive |
verified |
currency |
The object was read back and matched its manifest, the online rows and the row count recorded at the checkpoint |
detached |
Both | The partition is detached concurrently and kept as a standalone table, for the drop grace period or until the drop that follows on the same tick. The unit is drop-ready while every unit of the partition that is not dropped is detached, and state_before_detach holds its previous state. Its rows are no longer read through the parent table |
dropped |
Both | The partition is detached concurrently and dropped |
blocked |
Both | The unit is stopped until an operator resets it. The reason is in error. A unit blocked while its partition is detached has detached_at set, and retention unblock refuses it until retention reattach attaches the partition |
Any state except dropped can become blocked. retention unblock returns a unit blocked with reattached by operator to state_before_detach.
Otherwise it returns a blocked currency unit whose checkpoint
is committed (row_count is set) to checkpointed, so export and verification run again against that checkpoint.
Every other blocked unit returns to pending, and its steps run again from the start. Each step replaces what an
earlier attempt wrote. retention unblock --waive-dump moves a dump_failed statistics unit, or one blocked for
dump retries exhausted, statistics tier would exceed budget or archive object missing, to dump_waived. It
refuses a unit of a detached partition until the partition is reattached.
retention unblock --recheckpoint returns a blocked currency unit and every later currency unit to pending, and
clears their error, row_count, max_id and object_key. In the same transaction it deletes the
kudos_ledger_checkpoints rows at the boundary and the kudos_ledger_daily rows of the source_range of each reset
unit with a recorded row_count. The latest checkpoint is then the last valid one, and reconciliation reads every
later posting from kudos_ledger. The next ticks recompute each checkpoint, daily
statement and recorded row count in upper-bound order, and each export replaces the object at its key. A blocked
currency unit stops the currency dataset, so every later unit is still online. The command refuses when a later
currency unit is dropped, because its rows can no longer be recounted, and when the unit's own partition is
detached. Use it when a unit blocks with late rows
or online drift since export because of rows committed after its checkpoint.
retention reattach <unit-id> attaches a detached partition back to its parent with
ALTER TABLE <parent> ATTACH PARTITION <partition> FOR VALUES FROM (<lower>) TO (<upper>). The bounds are the
partition's recorded range: the lowest unit_lower (MINVALUE for the legacy partition) to the highest
unit_upper. Each detached unit becomes blocked with reattached by operator and keeps
state_before_detach, so the partition stays attached until an operator unblocks its units. A unit that the drop
recount blocked after the detach keeps its own reason and loses state_before_detach. A partition whose units are
all blocked that way, with no detached unit, is accepted too. The command clears
detached_at and commits with the attach. It refuses when no unit of the partition has detached_at set, or its
table no longer exists. retention unblock on a unit with state_before_detach set returns it to that state and clears the column,
so the partition is drop-ready again and the next tick detaches it again with a new grace period.
retention unblock --recheckpoint also accepts such a currency unit and clears the column. retention unblock refuses
a unit of a detached partition, and retention unblock --recheckpoint refuses when a later currency unit is detached.
Reattach those first.
All four commands take the retention lock and exit with an error while a tick holds it.
Units are selected by these rules:
- A tick selects the next unit of each dataset. When a step fails or is deferred, the tick runs the other dataset's unit.
- A blocked currency unit stops the currency dataset, because every later checkpoint chains from it. Blocked statistics units are skipped.
- A
dump_failedunit retries only the dump, because its rollup is committed. It is selected again onceDUMP_RETRY_SPACING(15 minutes) has passed sincelast_dump_attempt_at, and skipped until then, so later statistics units proceed. Statistics units do not chain. When a retry fails and the first failure is older thanKUDOS_RETENTION_DUMP_RETRY_DAYS, the unit becomesblockedwithdump retries exhausted. That attempt also counts towardconsecutive_dump_failuresandhorde.kudos.retention.dump_failures. - Without an archive, statistics units go from
rolled_uptodump_waivedwitherrorset toarchive not configured, and are dropped. Currency units staycheckpointedand are skipped, so later units are still checkpointed. A currency partition is not dropped without an archive. - A partition is dropped only when each of its units is
dumped,dump_waivedorverified, or each of them isdetached. Ablockedunit of a detached partition is still selected, so it stops the currency dataset. The drop marks all of themdropped. An interrupted concurrent detach is finished withDETACH PARTITION ... FINALIZE. - With
KUDOS_RETENTION_HOLD_DROPSset totrue, drop-ready units are skipped, so their partitions stay attached and the detach of a grace period is held too. Every other step runs. Once every eligible unit is drop-ready, ticks areidle, and each tick that holds at least one partition logsKudos retention holds <n> partitions. - With
KUDOS_RETENTION_DROP_GRACE_DAYSabove 0, the drop of a partition that was never kept detached recounts, detaches, recounts again and then marks its unitsdetachedwithdetached_atset to the tick's reference time. It does not drop the table. Adetachedunit is skipped until its reference time minus the grace reachesdetached_at, so later units proceed. The drop then recounts the rows once more, because the partition may have been reattached and written in between, and drops the table. Adetachedpartition whose table no longer exists is markeddroppedwhen its drop falls due. - The grace is measured with the tick's reference time, including an
--as-ofrehearsal time. A partition detached by a rehearsal tick is dropped only by a tick whose reference time is at least the grace later, and a tick at the real time does not drop it before the real clock reaches that point. - Before the detach, and again after it, the drop recounts each unit's rows and highest
idand compares them withrow_countandmax_id. A difference, or a unit with no recorded count, blocks that unit withlate rowsand nothing is dropped. Every unit is markeddetachedwithdetached_atbefore the recount that follows the detach, also without a grace period. A difference found then leaves the partition detached, the blocked unit keepsdetached_at, its siblings staydetached, and the partition is not drop-ready. Runretention reattachbefore unblocking. - The drop connection sets
lock_timeouttoDROP_LOCK_TIMEOUT. The connection is discarded afterwards instead of returning to the pool, because the setting lasts for the database session. When another session holds a lock on the partition or its parent in a transaction older thanKUDOS_RETENTION_DROP_DEFER_MINUTES, or the timeout expires, the tick outcome isdeferredwith the pid and transaction age, and the drop is tried again on the next tick. The drop also checks that the connection holding the retention lock is alive, and fails without dropping when it is not. - A tick whose reference time is later than the real clock is
refusedunlessconfirm_live_data_lossis set (retention step --confirm-live-data-loss). - When the archive is configured, each tick aborts multipart uploads under the deployment's prefix that were started
more than
STALE_UPLOAD_AGEago. - A step that raises an error does not make a transition. Examples are an unreachable archive during export or a
rollup whose totals differ from the unit's totals. The tick outcome is
failed, and the step runs again on the next tick.
Blocked reasons¶
The error of a blocked unit is one of the RetentionBlockReason values.
error |
Dataset and state | Condition |
|---|---|---|
unapplied rows |
Both, at pending |
The unit has a row with applied = false. For statistics, only rows with quarantined = false count |
checkpoint chain gap |
currency, at pending |
No catalog unit with a recorded boundary ends at unit_lower, or one with a recorded boundary ends after unit_upper. Only a unit that starts at MINVALUE can start the chain without a previous boundary |
boundary already checkpointed |
currency, at pending |
A unit of another partition already records a boundary at unit_upper. Recomputing it would replace that checkpoint with this unit's rows alone |
currency tier would exceed budget |
currency, at checkpointed |
The stored currency tier, minus an object already stored at the unit's own key, plus the estimated export size is larger than KUDOS_ARCHIVE_BUDGET_BYTES. The estimate is the unit's share of the partition heap size times ARCHIVE_SIZE_RATIO |
statistics tier would exceed budget |
statistics, at rolled_up or dump_failed |
The same test against the statistics tier and KUDOS_ARCHIVE_STATISTICS_BUDGET_BYTES. Nothing is uploaded, and later statistics units proceed |
archive mismatch |
currency, at exported |
The object read back differs from its manifest in digest, size, row count, amount sum or per-user totals |
online drift since export |
currency, at exported |
The manifest's row count differs from row_count, or the row count, amount sum or per-user totals of the online rows differ from the archived object |
late rows |
Both, at the drop | A unit's row count or highest id differs from row_count and max_id, or the unit has no recorded count |
dump retries exhausted |
statistics, at dump_failed |
A dump retry failed more than KUDOS_RETENTION_DUMP_RETRY_DAYS after the first failure |
archive object missing |
currency, at exported, verified or detached, or at the drop. statistics, at detached with a dumped state before the detach, or at the drop |
The tick's archive listing does not hold the unit's object_key, or the drop found a currency unit or a dumped statistics unit whose object_key or object is missing. A unit of a detached partition is reattached first. retention unblock then returns a currency unit to checkpointed, so it is exported and verified again, and a statistics unit to pending, so it is rolled up and dumped again. retention unblock --waive-dump drops a statistics unit without the dump |
reattached by operator |
Both, at detached |
retention reattach attached the unit's partition again. retention unblock returns the unit to state_before_detach |
Reconciliation after retention¶
_applied_totals_subquery sums the latest checkpoint and the applied postings at or above its boundary. Before the
first checkpoint, it sums every applied posting. Snapshots store cumulative applied totals, so a snapshot taken before
a checkpoint stays comparable after it. A boundary without per-user rows occurs only when no contributing posting is
older than it, so the latest boundary with rows, or none, still gives the right tail.
create_balance_snapshot holds the retention lock, so a snapshot never crosses a tick or an operator recovery, and it
raises StaleCheckpointsError while stale_checkpoint_units lists a unit. retention unblock --recheckpoint records,
in the transaction that deletes the stale checkpoints, one kudos_snapshot_invalidations row from the earliest reset
unit's catalog created time to the reset. A snapshot created inside such an interval stored applied totals from
checkpoints that omitted late rows, so reconcile_balances refuses it with StaleCheckpointsError in read-only and
repair modes alike. The snapshot's rows are kept as evidence. The interval is wider than the bad window. On a legacy
sub-range unit it starts at the migration, so one legacy recheckpoint invalidates every snapshot taken since the
cutover. Snapshots outside every interval stay usable, and a fresh snapshot after the recovery is the baseline for
later checks.
Idempotency key lifetime¶
The key lifetime is the shorter of the two online windows (KudosRetentionPolicy.key_lifetime), which is 30 days by
default. transfer_kudos and repair look up kudos_idempotency_keys instead of kudos_ledger, and they write the key
row in the same transaction as their postings. Every tick deletes up to EXPIRE_KEYS_BATCH_SIZE keys older than the
lifetime, whether or not retention is enabled.
- A transfer
Idempotency-Keyis honored for the online retention window (30 days by default). A retry after that creates a new transfer. reconcile_balances(..., apply_repairs=True)refuses a snapshot older than the key lifetime with aValueErrorwhose message says to take a new snapshot. Without the refusal, a repair repeated after its key expired could post a secondRECONCILIATION.reconcile_balances(..., apply_repairs=True)raisesStaleCheckpointsError, aValueError, whilestale_checkpoint_unitslists a currency unit blocked withlate rowsoronline drift since export. That unit holds rows its checkpoint and every later checkpoint omit, so a repair would compensate for drift that does not exist. A read-only run still reports drift and logs a warning. Thereconcilecommand lists the units instale_checkpoint_unitsand setsdrift_reliabletofalse.retention unblock --recheckpointdeletes the stale checkpoints, and repairs are accepted again.sql_statements/5.1.16.txtbackfills the keys ofTRANSFERandRECONCILIATIONevents created in the lastkey_lifetime_daysdays, a psql variable that defaults to 30. When either online window is above 30, the file is run with-v key_lifetime_days=<days>set to the shorter window, so every key inside the lifetime exists. The file prints the window it used.
Retention configuration¶
KUDOS_RETENTION_POLICY is read from the environment at import. An invalid value stops startup with a ValueError
that includes the variable.
| Variable | Default | Constraint | Effect |
|---|---|---|---|
KUDOS_RETENTION_ENABLED |
false |
true, 1, yes, false, 0 or no |
Turns partition removal on. Partitions are created and keys expire either way |
KUDOS_RETENTION_CURRENCY_DAYS |
30 | At least 30 | Online window of kudos_ledger, counted from a partition's upper bound |
KUDOS_RETENTION_STATISTICS_DAYS |
30 | At least 30 | Online window of kudos_stat_events, counted from a partition's upper bound |
KUDOS_PARTITION_WIDTH_DAYS |
7 | 1 to 31 | Width of new partitions and of legacy units. The width adds to the effective retention |
KUDOS_ARCHIVE_BUDGET_BYTES |
Unset, no limit | Positive whole number. Required when the archive is configured | Byte budget of the currency tier (KudosRetentionPolicy.currency_budget_bytes) |
KUDOS_ARCHIVE_STATISTICS_BUDGET_BYTES |
Unset, no limit | Positive whole number | Byte budget of the statistics tier (KudosRetentionPolicy.statistics_budget_bytes) |
KUDOS_RETENTION_DUMP_RETRY_DAYS |
7 | Positive whole number | How long failed statistics dumps are retried, from the first failure, before the unit blocks |
KUDOS_RETENTION_DROP_DEFER_MINUTES |
5 | Positive whole number | Transaction age of another session's lock on the partition or its parent that defers a drop |
KUDOS_RETENTION_HOLD_DROPS |
false |
true, 1, yes, false, 0 or no |
Keeps drop-ready partitions attached. Every other step runs |
KUDOS_RETENTION_DROP_GRACE_DAYS |
7 | Whole number, 0 or more | How long a detached partition is kept before it is dropped. 0 drops it right after the detach and has to be set explicitly, so an environment that lost the variable keeps a reattach window |
KUDOS_ARCHIVE_ACCOUNT |
Unset | Required for the archive | S3 endpoint URL |
KUDOS_ARCHIVE_BUCKET |
Unset | Required for the archive | Archive bucket |
KUDOS_ARCHIVE_AWS_ACCESS_KEY_ID |
Unset | Required for the archive | Access key ID of credentials scoped to the archive bucket |
KUDOS_ARCHIVE_AWS_SECRET_ACCESS_KEY |
Unset | Required for the archive | Secret of those credentials |
KUDOS_ARCHIVE_REGION |
auto |
Signing region | |
KUDOS_ARCHIVE_PREFIX |
Empty | Letters, digits, -, _, / and . |
Key prefix under which this deployment writes and lists its objects, so deployments can share a bucket. A trailing slash is added |
The archive is configured only when the four required KUDOS_ARCHIVE_* variables are all set. When only some are set,
KudosArchive.from_environment logs one warning that lists the unset variables and returns None.
Archive objects¶
The archive is one S3-compatible bucket. Each unit is one compressed CSV object with a header row, produced by COPY
of the unit's rows in id order, and a JSON manifest stored beside it. Objects are written once.
| Item | Layout |
|---|---|
| Object key | <prefix><tier>/<source_range>.csv.<ext>, with each / in source_range replaced by __. prefix is KUDOS_ARCHIVE_PREFIX. tier is currency or statistics. The key has no dataset segment because the tier already names the dataset, and the manifest records it |
| Manifest key | The object key followed by .manifest.json |
| Codec | zstd (zst) for new objects, at ZSTD_COMPRESSION_LEVEL. gzip (gz) objects stay readable. A reader selects the codec by the key's extension |
| Manifest field | Contents |
|---|---|
schema_version |
Manifest format version. New manifests are version 2. A version 1 manifest does not record column types, and restore refuses it. Verification and audit read both versions |
dataset, partition_name |
Where the rows came from |
unit_lower, unit_upper |
The unit's bounds as %Y%m%dT%H%M%S. unit_lower is null for MINVALUE |
row_count |
CSV data rows |
compressed_sha256, compressed_bytes |
Digest and size of the stored bytes |
columns |
The CSV header |
column_types |
Version 2: each column's name and PostgreSQL type in CSV column order, as format_type reports them with modifiers, such as numeric(20,2). The export reads them from a temporary view over its query in the export transaction, and aborts the upload when the CSV header differs from them. Absent in version 1 |
codec |
zstd or gzip |
amount_sum |
Sum of amount, as a decimal string |
per_user_totals |
Currency only: user ID to [balance_sum, escrow_sum] as decimal strings. Null for statistics |
Each tier has its own budget, and a tick does not evict anything. The statistics dump and the currency export each
admit an upload only when the tier's stored bytes, minus an object already stored at the unit's own key, plus the
estimate fit the tier's budget. Otherwise the unit blocks with statistics tier would exceed budget or
currency tier would exceed budget. The estimate is an early admission check. The upload also counts the actual
compressed bytes, including the compressor's final flush, against the same allowance, and an overrun aborts the
multipart upload and blocks the unit with the same reason without replacing an object already at the key. Budgets
count completed data objects and exclude manifests and incomplete multipart uploads. Every writer to a tier holds the
retention lock, including archive evict.
archive evict --tier statistics --budget-bytes <n> runs evict_statistics_dumps, which deletes the oldest
statistics objects and their manifests, ordered by unit upper bound, until the statistics tier fits <n>. The dump of
a statistics unit that is not dropped is kept, because its raw rows are still online. Objects that are not recorded by any
unit are deleted like dropped dumps. Currency objects are never deleted. The deletes go out one batch at a time. When one
fails, ArchiveEvictionError holds the keys deleted before it, and the command prints them. The command sets the
object_key of each evicted unit to NULL, also after a failed batch, so an evicted dump is not reported as lost.
With an archive configured, every tick lists both tiers after its step, while it still holds the retention lock. A
unit whose object_key is not in the listing was removed outside the horde, or by an eviction that stopped before it
updated the catalog. What the tick does depends on the unit:
| Unit | Change | Log line |
|---|---|---|
currency in exported, verified or detached |
blocked with archive object missing, object_key NULL, because a drop would remove the only copy of its postings |
Kudos retention blocked <dataset> unit <source_range>: archive object missing: ... |
statistics in dumped |
dump_failed, object_key NULL, error archive object missing: <key>. The dump retry window starts at that tick, and the next tick dumps the rows again |
Kudos retention dump failed for <source_range>: the archive does not hold <key>, so the unit is dumped again |
statistics in detached, with dumped before the detach |
blocked with archive object missing, object_key NULL |
Kudos retention blocked <dataset> unit <source_range>: archive object missing: ... |
statistics in dropped |
None. The unit keeps its object_key, so archive audit reports the lost object on every run |
None |
| Any other | object_key NULL |
Kudos retention cleared object key <key> of <dataset> unit <id> (<source_range>, <state>) |
Before the detach and again immediately before the drop, retention checks every unit of the partition against the
archive. For a currency unit, the object exists, its manifest is readable, the stored size equals the manifest's
compressed_bytes, and the manifest's row_count equals the unit's recorded count. For a statistics unit in
dumped, or detached with dumped before the detach, the object exists. A missing key or object blocks the unit
with archive object missing, a currency mismatch blocks it with archive mismatch, and an unreachable store or
unreadable manifest fails the tick so that a later tick retries. The check compares metadata only. The digest was
verified at the verified step, and archive audit re-reads objects. The post-step listing does not gate the drop.
The tier sizes from the same listing are the horde.kudos.archive.tier_bytes values.
Retention metrics and log lines¶
kudos_retention_tick runs on the quorum node every 60 seconds and records these metrics after each tick.
| Metric | Type and unit | Attributes | Meaning |
|---|---|---|---|
horde.kudos.retention.steps |
Counter, 1 |
horde.kudos.dataset, horde.kudos.retention.step, horde.kudos.retention.outcome |
One per tick. The step is the RetentionStep that ran, or none. The outcome is a RetentionTickOutcome: skipped_lock, idle, stepped, blocked, failed, refused or deferred. Each tick adds 0 to the blocked and failed series of both datasets, so those series exist before the first event |
horde.kudos.retention.dump_failures |
Counter, {failure} |
horde.kudos.dataset |
One per failed statistics dump attempt, including the one that blocks the unit. Each tick adds 0 for both datasets |
horde.kudos.retention.oldest_eligible_age |
Gauge, s |
horde.kudos.dataset |
How long the oldest unit still online has been past its window, by sub-range for the legacy partition. 0 when every unit is inside its window |
horde.kudos.retention.blocked_units |
Gauge, {unit} |
horde.kudos.block_reason |
Catalog units in blocked, one series per RetentionBlockReason value, 0 when no unit has that reason |
horde.kudos.retention.partition_coverage |
Gauge, 1 |
None | 1 when each event table has a partition containing the real current time and one containing it plus one partition width, else 0 |
horde.kudos.retention.held_partitions |
Gauge, {partition} |
None | held_partitions from retention_health. 0 when KUDOS_RETENTION_HOLD_DROPS is not true |
horde.kudos.retention.pending_units |
Gauge, {unit} |
horde.kudos.dataset |
pending_units_by_dataset from retention_health. The units of the legacy partition are counted until the whole partition is dropped, so the value stays high for a whole catch-up |
horde.kudos.retention.restored_partitions |
Gauge, {partition} |
None | The number of restored_partitions from retention_health. Above 0, the tick does not make a transition in the dataset of each such partition |
horde.kudos.archive.tier_bytes |
Gauge, By |
horde.kudos.archive.tier |
Stored bytes of each tier, excluding manifests, from the tick's listing. Not recorded without an archive, and kept at the last value when the listing fails |
horde.kudos.archive.budget_bytes |
Gauge, By |
horde.kudos.archive.tier |
One series per tier that has a budget: currency from KUDOS_ARCHIVE_BUDGET_BYTES, statistics from KUDOS_ARCHIVE_STATISTICS_BUDGET_BYTES. Not recorded without an archive |
Two warnings are under log contract. Log-based alerting matches their prefixes, and
tests/unit/test_kudos_retention.py::test_log_contract_prefixes_start_the_outcome_warnings tests them.
| Prefix | Constant | Logged when | Full line |
|---|---|---|---|
Kudos retention blocked |
RETENTION_BLOCKED_LOG_PREFIX |
A unit becomes blocked, or a tick does not make a transition in a dataset because of a restored partition |
Kudos retention blocked <dataset> unit <source_range>: <error>, optionally followed by : <detail>, or Kudos retention blocked <dataset> dataset: an attached partition has dropped catalog units: <partitions> |
Kudos retention dump failed |
RETENTION_DUMP_FAILED_LOG_PREFIX |
A statistics dump attempt fails and the unit stays dump_failed, the dump step waives a unit because no archive is configured, or the tick finds the dump of a dumped unit missing |
Kudos retention dump failed for <source_range>: <cause> (<n> consecutive failures since <first failure>), ...: archive not configured, or ...: the archive does not hold <key>, so the unit is dumped again |
The dump-failed warning is logged on every failed attempt. The attempt that exhausts the retry window logs the blocked
warning instead, with dump retries exhausted and the same detail. Without an archive, the dump-failed warning is
logged once per statistics unit, with the cause archive not configured, and horde.kudos.retention.dump_failures
does not count it. Other retention log lines are not under contract.
retention_health returns these fields. retention status prints them with the archive state and the units.
| Field | Meaning |
|---|---|
oldest_eligible_age_seconds |
Per dataset, how long the oldest unit still online has been past its window, by sub-range for the legacy partition, or null |
blocked_units |
Catalog units in blocked |
blocked_units_by_reason |
Catalog units in blocked per RetentionBlockReason value, with every reason present |
pending_units |
Catalog units neither dropped nor blocked, including currency units waiting for an archive and detached units |
pending_units_by_dataset |
pending_units per dataset, with both datasets present |
last_step_age_seconds |
Seconds since the most recent catalog update, or null for an empty catalog |
partition_coverage_ok |
Whether each event table has a partition containing the real current time and one containing it plus one partition width |
hold_drops |
Whether KUDOS_RETENTION_HOLD_DROPS is true |
held_partitions |
Partitions whose units, other than dropped ones, are all drop-ready, while hold_drops is true. 0 otherwise |
drop_grace_days |
The drop grace of the running policy in days, from KUDOS_RETENTION_DROP_GRACE_DAYS or its default |
detached_partitions |
One entry per partition with detached units, oldest detach first: dataset, partition_name, detached_at and drop_due_at (detached_at plus the grace) |
restored_partitions |
One entry per attached partition with dropped catalog units, as dataset and partition_name. Such a partition was restored under its original name. The dataset does not make a transition until the partition is detached |
Retention commands¶
These are subcommands of tools/kudos_ledger_admin.py. Each prints JSON.
| Command | Effect |
|---|---|
retention status |
Prints the health fields, archive_configured, archive_tier_bytes, and every unit not yet dropped with its catalog columns and source_range. Read-only |
retention step [--dataset <dataset>] [--as-of <timestamp>] [--confirm-live-data-loss] |
Runs one tick as the scheduler does. With --dataset currency or --dataset statistics, only that dataset's units are stepped. --as-of is a rehearsal control: every eligibility, key-expiry and partition-creation decision is taken as of that UTC time, which must be no earlier than the current time and no later than the current time plus the longer window, the drop grace and four partition widths, and retention must be enabled. With a drop grace, a partition detached at one --as-of is dropped only by a later tick whose --as-of is at least the grace past it. A time later than now is refused without --confirm-live-data-loss, because against a live database it removes current data. Use that flag on a restored copy only. It can drop a partition |
retention unblock <unit-id> [--waive-dump \| --recheckpoint] |
Returns a blocked unit to state_before_detach when it is set (a unit blocked by retention reattach), else to checkpointed (currency unit with a committed checkpoint) or pending, and clears error. With --waive-dump, moves a dump_failed statistics unit, or one blocked for dump retries exhausted, statistics tier would exceed budget or archive object missing, to dump_waived. With --recheckpoint, returns a blocked currency unit and every later currency unit to pending so their checkpoints are recomputed, and refuses when a later currency unit is dropped or detached. Refuses a unit of a detached partition. Exits with an error for any other unit, or while a tick holds the retention lock |
retention reattach <unit-id> |
Attaches the detached partition of the unit back to its parent over its recorded range and blocks its units with reattached by operator, keeping state_before_detach. retention unblock on each unit returns it to that state. Refuses when the partition is not detached or its table no longer exists. Exits with an error while a tick holds the retention lock |
statement <user-id> --from <YYYY-MM-DD> --to <YYYY-MM-DD> |
Prints the user's lines from kudos_ledger_daily for those UTC days, summed across source_range. Days still online do not have lines yet |
archive restore <key> --into <scratch-table> [--append] |
Copies an archive object into a new scratch table, table or schema.table, created from the manifest's column_types alone, without reference to the live event tables. A version 1 manifest does not record column types and is refused. An existing table is refused, because a repeated restore would hold every row twice and each restore verifies only its own rows. With --append the rows are added to an existing table, and the output has "appended": true and a note. Requires the archive configuration |
archive evict --tier statistics --budget-bytes <n> |
Deletes the oldest statistics objects of dropped units and objects without a unit until the statistics tier fits <n>, sets the object_key of each evicted unit to NULL, and prints the deleted keys and tier_bytes, the stored bytes left. Exits 1 with an error when the dumps of units not yet dropped keep the tier above <n>. On a failed delete it prints the keys deleted before it with the error and exits 1 |
archive audit [--tier currency\|statistics] [--since-key <key>] |
Reads every object of the tier, both tiers by default, back against its manifest: digest, stored bytes, row count, amount sum, columns and per-user totals. Prints one JSON line per object ("kind": "object", matches_manifest, problems), one per catalog unit whose object is missing ("kind": "missing_object"), then a summary line with objects, problems, last_key, noncurrent_bytes and delete_markers. The last two map each audited tier to the summed size of the noncurrent object versions under its prefix, manifests included, and to its count of delete markers. They show storage a versioned bucket still holds for evicted or replaced objects, which the horde.kudos.archive.tier_bytes gauge and retention status do not count. A tier's value is null when the store did not list its versions, which differs from 0. Neither figure counts as a problem. A unit is missing its object when its object_key is not listed, or when it is a currency unit in exported, verified, detached or dropped without an object_key. Exits 1 on any problem, including an unreadable manifest. --since-key audits only objects whose key sorts after <key>, to resume from a summary's last_key. It reads the catalog and changes nothing |
Privacy disclosure¶
AccountingRetentionDisclosure.from_policy derives the two windows, and whether currency is archived, from the same
policy and KUDOS_ARCHIVE_* variables that the retention job reads. The privacy policy renders them under "Retention
of Your Personal Data".
Observability contract¶
kudos_applier_health reports:
| Field | Meaning |
|---|---|
pending_rows |
Combined unapplied currency and drainable, non-quarantined statistic event count |
oldest_pending_seconds |
Age of the oldest drainable unapplied event across both queues |
ledger_pending_rows |
Drainable unapplied currency posting count |
stat_pending_rows |
Drainable unapplied statistic event count |
oldest_ledger_pending_seconds |
Age of the oldest drainable currency posting |
oldest_stat_pending_seconds |
Age of the oldest drainable statistic event |
quarantined_rows |
Total statistic rows retained outside the drainable queue; permanent evidence, not unresolved-incident state |
oldest_quarantined_seconds |
Age of the oldest quarantined statistic row |
newest_quarantined_seconds |
Age of the most recently quarantined statistic row; operationally useful for identifying a new incident |
heartbeat_seconds |
Time since the projector last completed a cycle; may grow even when no rows are pending |
active_reservations |
Count of holds with positive remaining amount and no release time |
oldest_reservation_seconds |
Age of the oldest active hold |
Projector cadence and health sampling¶
The background task runs every three seconds and keeps folding within a tick while full batches drain, up to a bounded number of catch-up cycles, so a backlog clears at many batches per tick while each fold stays a small transaction. Health is sampled once per tick, before the fold loop, and exported as current-value gauges. The combined queue and oldest-age gauges have currency/stat splits, while the quarantine gauges distinguish permanent evidence from the age of the newest incident. Phase histograms, cycles-per-tick and configured batch/catch-up gauges explain capacity limits.
Alerting and node health¶
These metrics are recorded on the quorum node only. The per-node
/api/v2/status/heartbeat endpoint deliberately excludes them, because a load balancer health check fed a
shared-database signal would remove every node from rotation at once. Operators should alert on queue age and
reservation age from the quorum telemetry, not only row count: a steady queue can be healthy under load, while one
old row or hold can indicate a poisoned path.
Code map¶
| Responsibility | File | Symbols |
|---|---|---|
| Models, event context, mode control and event emission | horde/classes/base/kudos.py |
KudosLedger, KudosStatEvent, kudos_event, set_kudos_ledger_mode, emit_kudos_ledger_entry, emit_kudos_stat_event |
| Bounded projection, trust promotion, floor recording and health | horde/database/kudos_ledger.py |
apply_pending_kudos, kudos_applier_health |
| Available/effective balances and hold lifecycle | horde/database/kudos_reservations.py |
available_kudos, effective_kudos, reserve_kudos, consume_reservation, release_reservation |
| Repeatable-read snapshots, drift and compensating repairs | horde/database/kudos_reconciliation.py |
create_balance_snapshot, reconcile_balances |
| Advisory locks and transaction isolation | horde/database/kudos_db.py |
acquire_applier_lock, acquire_payer_lock, acquire_mode_pin, acquire_mode_gate_exclusive, begin_repeatable_read |
| Atomic counter upserts by database dialect | horde/database/kudos_counters.py |
increment_counter, increment_counters |
| Temporary shadow-mode inline projection and cutover deletion boundary | horde/database/kudos_legacy_projection.py |
project_user_balance, project_user_escrow, project_trust_promotion, project_style_reward |
| User currency and statistic producers | horde/classes/base/user.py |
User.modify_kudos, User.record_usage, User.record_contributions, User.record_uptime, User.record_style |
| Worker display/stat producers and contribution orchestration | horde/classes/base/worker.py |
WorkerTemplate.modify_kudos, WorkerTemplate.record_contribution, WorkerTemplate.record_uptime |
| Generation settlement event boundary and reservation cleanup | horde/classes/base/processing_generation.py |
ProcessingGeneration.record |
| Activation tax, usage debit and queue-priority snapshot | horde/classes/base/waiting_prompt.py |
WaitingPrompt._activate, WaitingPrompt.record_usage |
| Interrogation reservation, settlement and cleanup | horde/classes/stable/interrogation.py |
InterrogationForms.record |
| Transfer admission, paired postings and idempotency | horde/database/functions.py |
transfer_kudos |
| Periodic projection and health metrics | horde/database/threads.py |
apply_kudos_ledger |
| Accounting discriminators and metadata keys | horde/enums.py |
KudosEntryType, KudosLedgerMode, KudosAuditDetail |
| Initial schema and counter uniqueness | sql_statements/5.1.0.txt |
SQL migration |
| Quarantine columns and concurrent drainable-queue index replacement | sql_statements/5.1.9.txt |
SQL migration. Run with psql autocommit |
| Retention policy, tick, steps, catalog, health, statements, restore, archive audit and disclosure | horde/database/kudos_retention.py |
KudosRetentionPolicy, KUDOS_RETENTION_POLICY, run_retention_tick, select_next_unit, STEP_TABLE, RetentionBlockReason, retention_health, unblock_unit, reattach_partition, daily_statement, restore_archive_unit, audit_archive, units_without_archive_objects, AccountingRetentionDisclosure |
| Archive client, manifest, codecs, operator eviction, object audit and version usage | horde/kudos_archive.py |
KudosArchive, ArchiveManifest, ArchiveColumn, ArchiveObjectAudit, ArchiveVersionUsage, ArchiveTier, ZstdCodec, GzipCodec, KudosArchiveError, ArchiveEvictionError |
| Partition bootstrap and retention models | horde/classes/base/kudos.py |
ensure_kudos_event_partitions, KudosPartitioning, KudosStatRollup, KudosStatQuarantine, KudosLedgerCheckpoint, KudosLedgerDailyStatement, KudosIdempotencyKey, KudosRetentionUnit |
| Retention lock | horde/database/kudos_db.py |
try_acquire_retention_lock, release_retention_lock |
| Checkpoint plus online tail and repair key lifetime | horde/database/kudos_reconciliation.py |
_applied_totals_subquery, _refuse_snapshot_older_than_key_lifetime |
| Periodic retention tick, metrics and log contract lines | horde/database/threads.py |
kudos_retention_tick |
| Retention tables, key backfill and partition conversion | sql_statements/5.1.16.txt |
SQL migration. Run with psql and ON_ERROR_STOP=1 |
| Status, drain, snapshot, reconciliation, repair, promotion-statistic backfill, mode, retention, statement and archive restore, evict and audit commands | tools/kudos_ledger_admin.py |
main |
See Kudos ledger operations for cutover, rollback and recovery rehearsals.
Regression-test map¶
| Contract | Primary coverage |
|---|---|
| Emission shape, event grouping, currency/stat separation | tests/unit/test_kudos_ledger.py, test_kudos_balance.py, test_kudos_safety.py |
| Existing generation, cancellation, uptime, monthly, interrogation semantics | test_kudos_settlement.py, test_kudos_image_cancel.py, test_kudos_uptime.py, test_kudos_monthly.py, test_kudos_interrogation.py |
| Counter and team fold equivalence | test_kudos_counters.py, test_kudos_counter_fold.py |
| Concurrent projector exclusion and spend reservation | test_kudos_safety.py on PostgreSQL-capable paths |
| Transfer replay and parameter conflict | test_kudos_safety.py, integration kudos endpoint tests |
| Trust promotion on final qualifying posting | test_kudos_safety.py, test_kudos_ledger.py |
| Atomic ledger-to-shadow final drain | test_kudos_safety.py |
| Snapshot drift, idempotent compensation, floor replay | test_kudos_safety.py |
| Style author credited only the collected surcharge | test_kudos_ledger.py (TestStyleRewardAttribution), integration test_text_style_application.py (TestTextStyleAuthorCredit, TestTextStyleAuthorCreditInShadowMode) |
| Remaining activation deadlock retry/failed-session telemetry | test_wp_activate_deadlock.py |
| Image/text/interrogation upfront admission behavior | Integration request-activation tests and test_kudos_interrogation.py |
| Migration idempotency against mapped schema | test_kudos_safety.py |
| Partition conversion, index attachment, sequence continuity, key backfill and ORM parity | Integration test_kudos_retention_migration.py |
| Retention policy, unit selection, state transitions, blocked reasons, rollups, checkpoints, export, drop and log contract | test_kudos_retention.py |
| Archive codecs, manifests, object keys, verification, operator eviction and restore | test_kudos_archive.py (object-store tests use the object_storage marker) |
| Key expiry for transfers, checkpoint plus tail parity, drift after a checkpoint, repair refusal | test_kudos_safety.py |
| Legacy partitions retired with reconciliation, idempotency and projection intact | Integration test_kudos_retention_end_to_end.py |
The unit suite provisions PostgreSQL through testcontainers, so the advisory-lock and concurrent-projector tests
exercise the real primitives. The legacy USE_SQLITE runtime mode short-circuits those locks and is not covered by
the suite. Before cutover or a recovery rehearsal, run the safety and integration suites as required by the
operations guide.
Review checklist for a new kudos change¶
- Is the value currency, a display attribution, a counter, a price, or a quota?
- What is the business event boundary, and which postings share its
event_id? - Is the operation externally retryable? If so, what stable key and parameter-conflict rule make it idempotent?
- Can it authorize spend? If so, where is the payer lock and stable reservation?
- Which projection owns the read, what lag can its consumer tolerate, and should the consumer use materialized, available, or effective balance?
- Does the producer capture mutable attribution such as team membership at event time?
- Does the change introduce another lock? Where does it fit in the documented order?
- Can a batch split the event, and does any event-wide side effect handle that case?
- Is every non-linear adjustment represented by a posting?
- Can the change be rolled back through the supported mode transition without running old code against new holds?
- Do tests cover emission, fold, concurrency/retry behavior, and recovery, not merely the final number?
- Have this reference, the explanation, and the operations guide been updated when the contract changed?