Skip to content

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

  • IngestPOST /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. Returns 202 with 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.
  • RollupService.Rollup (driven by cron:activity_rollup) folds the window (watermark, now-lag] into edges via the RollupInteractionEdges query, then advances the watermark in activity_rollup_state. The lag holds the upper bound back from now() so in-flight inserts are not skipped.
  • ReadService.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_maintain provisions upcoming monthly partitions and drops any fully older than 30 days. migrate.RunManual provisions the hot-window partitions at boot; the DEFAULT partition catches anything outside the monthly bounds. The rolled-up interaction_edges persist (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 in internal/migrate and 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.