bv.age
Milliseconds since
first_seen, computed at read time. Server processing-time perproject_redis_shaped_no_event_time_ever.
Signature
bv.age(
*,
where: bv.Col | None = None,
) -> AggDescriptor
Description
bv.age returns the elapsed milliseconds between the entity's
first_seen timestamp and the query time (now_ms()
at the moment app.get(...) resolves). Read it as "how long has this
account existed in our system?", "how many ms since this card's first
transaction?", or "what's the lifetime age of this device?".
The interesting property of bv.age is that it changes between reads
without any new events — because the right-hand side of
now_ms() - first_ms is captured at query time, not at apply time. State
on the apply path is identical to first_seen (the first arrival ms is
recorded once and never overwritten); the subtraction happens server-side
when the read fans out from the registry to the entity. This makes age
a "time-travel" feature: it grows with wall-clock seconds even on
quiescent entities.
Both timestamps are server processing-time: first_ms is server now_ms()
at the original arrival; the read-side now_ms() is the server clock when
app.get(...) reaches the entity. beava intentionally does not consult
any event-time field — see project_redis_shaped_no_event_time_ever
(locked 2026-04-30).
bv.age belongs to the recency family. Per-event update is two
Option<i64> writes (the same SeenState shared with first_seen,
last_seen, has_seen, time_since). Memory per entity is O(1).
There is no window= kwarg — bv.age is lifetime-only by definition.
Parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
where |
bv.Col |
No | None |
Boolean expression on event fields; only matching events count toward first_seen. Without where=, every event is a candidate. |
Returns
A single i64 value: the number of milliseconds between first_seen and
the query-time now_ms(). When the entity has seen zero matching events,
the result is null (Python None). The value is clamped to a non-negative
range — clock skew that would produce a negative age returns 0 instead.
Complexity
| Resource | Bound |
|---|---|
| CPU per event | Tier 1 (~8 ns floor / ~30 ns measured) — see cost-class.md |
| Memory per entity | O(1) — single Option<i64> slot in the shared SeenState per Phase 12.8 V0-MEM-GOV-02 |
| Lifetime mode | Required — bv.age has no window= kwarg; lifetime is the only mode |
Examples
Example 1: Account age in milliseconds per user
import beava as bv
@bv.event
class Login:
user_id: str
@bv.table(key="user_id")
def UserAccountAge(logins) -> bv.Table:
return (
logins.group_by("user_id")
.agg(account_age_ms=bv.age())
)
# Push first event at server time t=1700000000000
app.push("Login", {"user_id": "alice"})
# Query at server time t=1700000060000 (1 minute later)
result = app.get("UserAccountAge", "alice")
# result == {"account_age_ms": 60000}
# Query again at t=1700003600000 (1 hour later) — same state, different age
result = app.get("UserAccountAge", "alice")
# result == {"account_age_ms": 3600000}
Example 2: Age computed only against successful logins
@bv.table(key="user_id")
def UserSuccessAge(logins) -> bv.Table:
return (
logins.group_by("user_id")
.agg(success_age_ms=bv.age(where=bv.col("status") == "ok"))
)
Wire
JSON wire form in a register payload:
{
"kind": "derivation",
"name": "UserAccountAge",
"output_kind": "table",
"key": ["user_id"],
"agg": {
"account_age_ms": {
"op": "age",
"params": {}
}
}
}
See examples/wire/register-fraud-team.request.json for a full payload example.
Edge cases
- Empty stream / cold-start: result is
null.bv.agerequires at least one matching event to anchorfirst_seen. where=filter excludes everything: result isnulluntil a matching event arrives.- Reads grow without new events: the subtraction
now_ms() - first_mshappens at query time, so the returned value increases between reads even if no events arrived. This is intentional — it is what makesagea useful "time-since-creation" feature. - Clock-skew safety: age is clamped to
>= 0. Ifquery_time_ms < first_ms(only possible under clock-skew or replay scenarios), the result is0, not a negative number. - Server-time, NOT event-time: both endpoints are server-side per
project_redis_shaped_no_event_time_ever. Producers cannot influence the capturedfirst_msor the read-timenow_ms()via payload fields. - Cold-entity eviction: if
@bv.event(cold_after=...)evicts the entity,ageresets to "ms since the next post-eviction arrival" — the entity is treated as fresh per the Redis-TTL pattern (V0-MEM-GOV-01). window=kwarg attempted: raisesTypeErrorat SDK-helper-call time.ageis "since first observation", which is inherently lifetime; for windowed-recency seebv.first_seen_in_window.- Lifetime mode: the only mode. Footprint is
O(1)per Phase 12.8 V0-MEM-GOV-02.
See also
- cost-class.md — performance tier (Tier 1)
- bv.first_seen — the absolute timestamp
ageis measured from - bv.time_since — sibling: ms since
last_seen(the most recent match), notfirst_seen(the earliest) - bv.has_seen — boolean variant: ever-matched, no duration
- pipeline-dsl/compilation-rules.md — chain compilation rules