perf(feed): rewrite v_feed, bound feed_delta, server-time SSE cursor (H6,H7,M9)
H6: migration 006 replaces v_feed's double LEFT JOIN + COUNT(DISTINCT) (which materialized a likes×comments Cartesian per upload) with correlated scalar subqueries that each use their own index. Output columns are unchanged, so feed + hashtag-filtered paths both benefit. H7: feed_delta now applies the feed rate limit (keyed per user), caps results at 200 rows, and clamps how far back a client `since` may reach (7 days). When clamped or capped it returns reload_required=true; the SSE client turns that into a full feed reload instead of streaming the whole gallery through the view on every tab refocus. M9: the SSE reconnect cursor is now advanced only from server timestamps (an upload's created_at, seeded from the feed and updated on new-upload events and delta responses), never the client clock — so a skewed phone clock can't drop events missed while backgrounded. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -50,7 +50,18 @@ const KNOWN_EVENTS = [
|
||||
* Synthetic event types — not emitted by the server, dispatched locally to fan out
|
||||
* cross-cutting state changes (e.g. delta-fetch results after a reconnect).
|
||||
*/
|
||||
export type SyntheticEvent = 'feed-delta';
|
||||
export type SyntheticEvent = 'feed-delta' | 'feed-reload';
|
||||
|
||||
/**
|
||||
* Advance the reconnect cursor using a **server** timestamp (an upload's
|
||||
* `created_at`), never the client clock. A phone clock skewed ahead would
|
||||
* otherwise make the cursor jump past events that happened while the tab was
|
||||
* backgrounded. ISO-8601 UTC strings compare correctly lexicographically.
|
||||
*/
|
||||
function noteServerTime(ts: string | null | undefined): void {
|
||||
if (!ts) return;
|
||||
if (!lastEventTime || ts > lastEventTime) lastEventTime = ts;
|
||||
}
|
||||
|
||||
export function onSseEvent(eventType: string, handler: EventHandler): () => void {
|
||||
if (!handlers.has(eventType)) {
|
||||
@@ -93,12 +104,13 @@ export function connectSse(): void {
|
||||
eventSource.onopen = () => {
|
||||
// Successful connection — reset the backoff counter.
|
||||
reconnectAttempt = 0;
|
||||
// If we have a previous timestamp this is a reconnect — fetch the gap.
|
||||
// If we have a previous (server-derived) timestamp this is a reconnect
|
||||
// — fetch the gap. The cursor is only ever advanced from server
|
||||
// timestamps (noteServerTime), so client clock skew can't drop events.
|
||||
const since = lastEventTime;
|
||||
if (since) {
|
||||
void deltaFetchAndFan(since);
|
||||
}
|
||||
lastEventTime = new Date().toISOString();
|
||||
};
|
||||
|
||||
for (const eventName of KNOWN_EVENTS) {
|
||||
@@ -146,7 +158,15 @@ export function setLastEventTime(time: string): void {
|
||||
}
|
||||
|
||||
function dispatch(eventType: string, data: string): void {
|
||||
lastEventTime = new Date().toISOString();
|
||||
// Advance the cursor from the server timestamp carried by a new upload.
|
||||
// Other event types don't carry one and must not bump it off the client clock.
|
||||
if (eventType === 'new-upload') {
|
||||
try {
|
||||
noteServerTime((JSON.parse(data) as { created_at?: string }).created_at);
|
||||
} catch {
|
||||
// payload not JSON — ignore
|
||||
}
|
||||
}
|
||||
const list = handlers.get(eventType);
|
||||
if (list) {
|
||||
for (const handler of list) {
|
||||
@@ -166,6 +186,14 @@ async function deltaFetchAndFan(since: string): Promise<void> {
|
||||
const response = await api.get<DeltaResponse>(
|
||||
`/feed/delta?since=${encodeURIComponent(since)}`
|
||||
);
|
||||
// Advance the cursor from the newest server timestamp in the delta.
|
||||
for (const u of response.uploads) noteServerTime(u.created_at);
|
||||
// The server clamped the window or hit the row cap — the partial delta
|
||||
// can't be trusted, so ask the page to do a full reload instead.
|
||||
if (response.reload_required) {
|
||||
dispatch('feed-reload', '{}');
|
||||
return;
|
||||
}
|
||||
dispatch('feed-delta', JSON.stringify(response));
|
||||
} catch {
|
||||
// non-fatal
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface FeedResponse {
|
||||
export interface DeltaResponse {
|
||||
uploads: FeedUpload[];
|
||||
deleted_ids: string[];
|
||||
reload_required: boolean;
|
||||
}
|
||||
|
||||
// mirrors backend/src/handlers/feed.rs::HashtagCount
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { getToken, getUserId } from '$lib/auth';
|
||||
import { api } from '$lib/api';
|
||||
import { connectSse, disconnectSse, onSseEvent } from '$lib/sse';
|
||||
import { connectSse, disconnectSse, onSseEvent, setLastEventTime } from '$lib/sse';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import FeedGrid from '$lib/components/FeedGrid.svelte';
|
||||
import FeedListCard from '$lib/components/FeedListCard.svelte';
|
||||
@@ -200,6 +200,9 @@
|
||||
}),
|
||||
onSseEvent('like-update', () => loadFeed(true)),
|
||||
onSseEvent('new-comment', () => loadFeed(true)),
|
||||
// Delta was clamped/capped server-side (H7) — do a full reload instead
|
||||
// of trusting a partial set.
|
||||
onSseEvent('feed-reload', () => loadFeed(true)),
|
||||
// Synthetic event from the SSE client after a foreground reconnect — merge
|
||||
// any uploads + deletions we missed while the tab was hidden.
|
||||
onSseEvent('feed-delta', (data) => {
|
||||
@@ -243,6 +246,10 @@
|
||||
const res = await api.get<FeedResponse>(`/feed?${params}`);
|
||||
uploads = res.uploads;
|
||||
nextCursor = res.next_cursor;
|
||||
// Seed the SSE reconnect cursor from the newest server timestamp so the
|
||||
// delta on the next reconnect is based on server time, not the client
|
||||
// clock (M9).
|
||||
if (uploads.length) setLastEventTime(uploads[0].created_at);
|
||||
} catch (e) {
|
||||
// Initial / user-triggered refresh is worth surfacing — background SSE refetches are noisier and silenced below.
|
||||
if (!refresh) toastError(e);
|
||||
|
||||
Reference in New Issue
Block a user