Activity¶
The interaction firehose and the affinity signal it feeds. Every meaningful thing a user does across surfaces (feed, search, map, profile, picker) is logged as an append-only event; a rollup folds those events into per-user affinity edges that the ranker reads to personalize. This is the data foundation the recommendation work is built on: models, embeddings, and metrics are all downstream of it.
Mental model¶
clients ──POST /activity/events──▶ activity_events (append-only, monthly partitions)
│ cron:activity_rollup (watermark)
▼
interaction_edges (per actor+edge+target, accumulating)
│ read at request time (decayed by age)
▼
the ranker
Two tables, split by growth and access pattern:
| Table | Shape | Read by |
|---|---|---|
activity_events |
append-only firehose, monthly RANGE-partitioned on created_at |
the rollup + offline training only — never online serving |
interaction_edges |
one accumulating row per (actor_id, edge_type, target_type, target_id) |
the ranker, per request (indexed by actor_id) |
The invariant that keeps the firehose from ever hurting a request: online serving
never scans activity_events. It reads interaction_edges (and the Redis
UserContext). The firehose is write-mostly. (The home feed's seen-set is a
separate purpose-built Redis set owned by discovery, not a firehose read.)
Surface area¶
- Ingest —
POST /api/v1/activity/events(JWT). Body:{ "events": [ ... ] }, each{ verb, object_type, object_id, surface?, context? }. The user is taken from the auth context, never the body; server time stamps every event. Returns202with the accepted count. Batched by the client; capped at 100 per call. - Verbs (
models.ActivityVerb) — view, dwell, tap, select, save, rsvp, attend, checkin, share, dismiss, search, react, plan, and impression (shown-but-not- picked, the negative signal). Object types — location, event, moment, user, plan, query. Invalid values are rejected at the handler boundary. - Rollup —
Service.Rollup(driven bycron:activity_rollup) folds the window(watermark, now-lag]into edges via theRollupInteractionEdgesquery, then advances the watermark inactivity_rollup_state. The lag holds the upper bound back fromnow()so in-flight inserts are not skipped. - Read —
Service.ListEdges(actor, edgeType, limit)returns an actor's edges strongest-first (the ranker's affinity lookup).
What the log is for vs what the DB already holds¶
The activity log does not duplicate durable relationships. Most positive
affinity already lives in domain tables and is read directly by UserContext
(when personalization lands): user_affinity (category score), user_saves,
user_dismissals, user_checkins / user_travel_logs (visits),
event_participants (RSVPs), friendships / close_friends (graph + closeness).
The log's unique, non-redundant job is the signals no table can hold:
impressions (shown-but-not-picked, the negatives learning-to-rank needs),
engagement depth (view / dwell), non-converting taps, and the query/rank
context at the moment of action. So the client logs impressions + selection +
sampled view/dwell on ranked surfaces; conversions come from the DB, not the log.
Verb → edge mapping¶
The rollup skips pure-signal verbs (view/dwell/search/…): they stay in the firehose for training but carry no direct affinity. The rest map to a weighted edge:
| Verb | Edge type | Base weight |
|---|---|---|
| save | saved | 3 |
| rsvp / attend | attended | 3 |
| checkin | visited | 2 |
| react | reacted | 1 |
| tap / select | tapped | 0.5 |
| dismiss | dismissed | 1 |
Invariants & gotchas¶
- The rollup is a pure accumulator. Weight only ever adds; decay is applied at
read by age (
last_at), not in the rollup. This keeps the fold idempotent and avoids a global decay pass. - Retention is by partition drop, not row delete.
cron:activity_partition_maintainprovisions upcoming monthly partitions and drops any fully older than 30 days.migrate.RunManualprovisions the hot-window partitions at boot; the DEFAULT partition catches anything outside the monthly bounds. The rolled-upinteraction_edgespersist (the raw events are only a 30-day training corpus). - No FK on
user_id/actor_id. The firehose must never block on a write path; referential integrity is not worth the coupling for an append-only log. - Schema DDL lives in the goose baseline (
db/migrations); the partition provisioning + drop lives ininternal/migrateand runs from boot + the cron.
Where the code lives¶
internal/services/activity/ (store.go, service.go, handler.go, routes.go),
generated data access in activitydb/, models + enums in
internal/models/activity.go, partition maintenance in internal/migrate/manual.go,
and the cron handlers in internal/async/handlers/cron.go.