Activity · 時間軸
排序、fan-out 與重建
Activity 分開儲存 canonical post 與 viewer timeline。Timeline entry 是衍生投影,可能在 post 寫入後才出現,但重試與修復會讓每組 viewer 與 post 收斂成一筆 entry。
Publish transaction
├─ canonical Post
├─ author self entry
├─ initial PostStats
├─ fan-out job
└─ outbox event
Fan-out worker
└─ follower TimelineEntry rows Private post 不會建立 fan-out job,但作者自己的 self entry 仍會存在。
Timeline 排序
Home timeline 採用反向時間排序。Activity 依照以下欄位排序:
ORDER BY ranked_at DESC, id DESC ranked_at 取自來源 post 的 published_at。多筆 entry 使用相同 timestamp 時,timeline entry UUID 是穩定的 tie-breaker。
這是 materialized chronological order,不是 recommendation score。未明確委派給 Activity 的 ranking policy 仍由 host 負責。
Cursor 分頁
第一頁可以帶選填的 limit:
GET /api/v1/timeline/home?limit=50 公開契約接受 1 到 100,預設值為 50。還有下一頁時,page.next_cursor 會包含 opaque URL-safe value。
{
"data": [],
"page": {
"next_cursor": "eyJhdCI6IjIwMjYtMDctMTRUMDQ6MTA6MDBaIiwiaWQiOiIuLi4ifQ"
}
} 下一個請求必須原樣帶入這個值:
GET /api/v1/timeline/home?limit=50&cursor=<next_cursor> 請根據實際送出的 query string 簽章。不要解碼 cursor、修改 padding,或把它用於不同的分頁契約。
無效 cursor 會回傳 400 invalid_cursor。最後一頁則回傳 "next_cursor": null。
穩定的分頁邊界
Cursor 同時記錄最後一筆 entry 的排序時間與 UUID。下一頁會選取小於該組合的 entry:
ranked_at < cursor time
or
ranked_at = cursor time and id < cursor id 這個 tie-break 可以避免多筆 entry 使用相同 ranked_at 時,在穩定的結果集合內重複或跳過資料。
Timeline read 不是 snapshot。新 post 或 policy change 可能在兩次請求之間改變 materialized set。Consumer 應把一串 cursor 視為一次向後分頁工作階段,不是永久的 archive position。
Fan-out 收斂
Publish transaction 會建立作者的 self entry,並排入 follower fan-out。Publish response 剛回傳時,follower 可能還看不到該 post。
每筆 follower projection 都使用 upsert,並由以下 database identity 保證唯一:
(tenant_id, viewer_id, post_id) 重複 job 可能執行,但只會更新同一筆 timeline entry,不會建立另一筆。公開保證的是 projection uniqueness,不是 job uniqueness。
Fan-out 前,worker 會讀取目前的 post。Post 已經符合以下任一條件時,worker 會略過 materialization:
- 已 soft delete
- Moderation state 不是
visible - Visibility 是
private
Worker 選擇 viewer 時,也會使用目前有效的 follow graph 與 block state。
Rebuild 會還原什麼
內部 repair worker 每次重建一位 viewer。它會在同一個 database transaction 內:
- 刪除該 viewer 的
followingentry。 - 依照目前有效的 follow 讀取 canonical post。
- 排除 private、deleted、hidden、blocked 與 non-visible post。
- 為每筆符合條件的 post 重建一筆
followingentry。
Rebuild 不會刪除 viewer 自己的 self entry。重建的 entry 會使用來源 post 的 published_at 作為 ranked_at,因此能保留正常的讀取順序。
目前的 rebuild primitive 是內部 Oban worker,沒有出現在公開 /api/v1 契約裡。Host 現在無法透過 customer HTTP API 要求重建。
保證矩陣
| 狀況 | 目前行為 |
|---|---|
| Publish 成功 | Canonical post 與作者的 self entry 已 commit |
| Follower 立刻讀取 | 新 entry 可能仍在等待 fan-out |
| Fan-out job 執行兩次 | 只保留一筆 viewer-post entry |
| Fan-out 前 post 已刪除 | Worker 略過 materialization |
| Cursor read 重試 | 重新簽署請求後可安全讀取 |
| Publish timeout | 不保證 retry 不會建立重複 post |
| Viewer projection 發生 drift | 內部 rebuild 可重建 following entry |
驗證邊界
Repository test 涵蓋相同 timestamp 的分頁、重複 fan-out 執行、刪除或內容管理後的 stale fan-out、private post 排除,以及 viewer rebuild。FeedCore TLA+ model 會在其建模範圍與 fairness assumption 內檢查 tenant safety、projection uniqueness、visibility safety、idempotent job effect 與 eventual convergence。
這些 proof 不代表已證明 production fan-out latency、queue capacity、recovery time objective 或 public rebuild operation。這些仍是產品化工作。