Define reducers
An IViewReducer defines one deterministic state transition. It declares a stable name and one or more history-stream suffixes, produces an initial dictionary, and applies one retained message at a time.
from servercheetah.views import IViewReducer
class SuccessCounter(IViewReducer):
@property
def name(self) -> str:
return "success-counter-v1"
@property
def source_streams(self) -> list[str]:
return ["jobs"]
def initial_state(self) -> dict:
return {"completed": 0}
def apply(self, event: dict, state: dict) -> dict:
if event.get("kind") != "result":
return state
return {**state, "completed": state.get("completed", 0) + 1}
apply() is synchronous and runs once per consumed history message. Keep it fast, deterministic, and free of I/O or external effects. It must not send mail, charge an account, issue another command, or update unrelated storage: a message can be replayed before a checkpoint is saved.
Treat the input event as read-only. A reducer may mutate the state dictionary or return a new one, but returned state must remain deep-copyable. It must also be JSON-serializable when the Redis store may be used. State size is the application's responsibility; frozen v1 enforces no byte, key-count, or reduction-time quota.
Five supplied state shapes
| Reducer | Messages used | Maintained answer | Primary bound | Replay behavior |
|---|---|---|---|---|
ListCollectorReducer | result, plus error count | recent result payloads, current list length, cumulative error count | max_items result payloads | recent message IDs are suppressed; an older replay can increment counters again |
LatestPerKeyReducer | result | newest payload for each selected key | no entry-count bound | updates only when timestamp_ms is strictly newer, so an equal or older replay is ignored |
ScreenshotCollectorReducer | result and error | recent screenshot records and error summaries | max_items screenshots; newest 100 error records | recent message IDs are suppressed; an older replay can increment the error count again |
ErrorAggregatorReducer | error | total, per-code buckets, recent errors, and affected clients | max_recent; max_clients_per_code; 20 recent IDs per code | recent message IDs are suppressed; older replay makes totals approximate |
PartialUpdateMergerReducer | keyed result | composite data and update metadata per key | max_entries keys | recent message IDs are suppressed; older replay can merge and increment again |
All five reject an empty source_streams list. Bounded reducers reject zero or negative limits. PartialUpdateMergerReducer accepts only replace, deep_merge, or concatenate. Misconfiguration raises ValueError during construction rather than becoming silently unbounded.
Collect a bounded result list
from servercheetah.views.builtins import ListCollectorReducer
reducer = ListCollectorReducer(
source_streams=["scrape-results"],
max_items=500,
)
The state contains items, count, error_count, and reserved _seen_ids. items retains the newest max_items result payloads. count is the current retained list length, while error_count is cumulative within the replay behavior described below.
Keep the latest payload per key
from servercheetah.views.builtins import LatestPerKeyReducer
reducer = LatestPerKeyReducer(
source_streams=["device-status"],
key_field="device_id",
)
For every result whose payload contains key_field, the reducer stores entries[str(key)] = {"payload": ..., "timestamp_ms": ...}. It replaces an entry only when the new event timestamp is strictly greater. Equal timestamps keep the existing value. The state also contains last_updated_ms.
This reducer has no entry-count bound. Choose it only when the key population is already bounded or add a product reducer with an explicit eviction policy.
Collect screenshot evidence
ScreenshotCollectorReducer(source_streams, max_items=500) retains screenshot records keyed by their reported client and browser context information. It recognizes tab_id or context_id, dataUrl or data_url, screenshot_b64, and url in result payloads.
The screenshot list is bounded by max_items; the detailed error list retains the newest 100 records. error_count remains cumulative apart from duplicate handling, so the snapshot can grow in meaning even when detailed records have rolled out.
Aggregate protocol errors
ErrorAggregatorReducer(source_streams, max_recent=200, max_clients_per_code=50) reads canonical Cheetah error details from the top-level error object:
{
"kind": "error",
"error": {
"code": "tab_not_found",
"message": "Tab does not exist"
}
}
It groups counts by code, records last time and message, keeps a bounded affected-client list, and retains bounded recent errors. Direct legacy events that put the error dictionary in payload remain accepted as a compatibility fallback; that fallback is not an alternative wire format.
Merge partial keyed updates
from servercheetah.views.builtins import PartialUpdateMergerReducer
reducer = PartialUpdateMergerReducer(
source_streams=["inventory-updates"],
key_field="device_id",
strategy="deep_merge",
max_entries=5_000,
)
The three strategies operate on each key's data dictionary:
| Strategy | Existing and new values |
|---|---|
replace | the complete new payload replaces the old data |
deep_merge | nested dictionaries merge recursively; new scalar or non-dictionary values win |
concatenate | list values concatenate when both sides are lists; other values replace |
When the entry bound is exceeded, the reducer removes entries with the oldest last_updated_ms. The state also exposes total_updates, last_updated_ms, per-entry update_count, and reserved _seen_ids.
Duplicate suppression is deliberately bounded
Four collection reducers keep recent message IDs in _seen_ids. The remembered window is derived from max_items, max_recent, or max_entries; it prevents ordinary recent replay but eventually evicts old IDs. Replaying an event outside that window can collect, merge, or count it again.
Do not call those reducers permanently idempotent and do not remove _seen_ids from a saved state. The field is visible reserved bookkeeping and uses linear membership checks. If an application needs all-history deduplication, exact counters, or a different performance bound, enforce that identity contract before history ingestion or implement a product reducer and durable index.
LatestPerKeyReducer is different: timestamp comparison makes an equal or older replay harmless for the retained entry. It still depends on trustworthy timestamps and does not resolve ties.