fix(frontend): park uploads that cannot succeed, and stop two false signals
The upload queue gains `parkedFor`, so a photo rejected for a reason that cannot change on its own stops re-pushing itself. A ban used to come back as a generic `forbidden`, which purged the blob and moved the row to `blocked` — a terminal state with no retry button — so lifting a ban restored everything except the photo actually in flight. Ban and release are now distinct codes that keep the blob, charge no attempt, and tell the guest what has to happen. `releaseResolvedParks` drains them at boot from /me/context, because the live `user-shown` / `event-opened` events only reach a tab that was open when the host acted, and the usual sequence is the other way round. Two signals were firing on nothing. A filtered feed set `feedStale` on EVERY delta without deduping — and the delta cursor boundary is inclusive while sse.ts deliberately rewinds `lastEventTime`, so deltas routinely re-return rows already delivered. With the backstop polling every 60-120s, a guest who tapped a hashtag got a "Neue Beiträge" pill they could never clear, each tap costing a full filtered refetch. It now dedupes in both branches. The SSE liveness backstop had the mirror problem: `noteDelivered` harvested id, upload_id AND user_id from every payload, so by the time anything was deleted or anyone banned, their ids were already marked delivered from ordinary traffic about live content. The `deleted_ids` and `hidden_user_ids` clauses were false essentially always, leaving a half-open socket undetected while a host moderated into a feed nobody was listening to. Each event now records only the id its own clause tests. Also: /admin no longer bounces to /join on a cleared session — AUTH_ROUTES had the `/admin` prefix, which suppressed clearAuth() on the dashboard and let the login guard bounce back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -61,6 +61,9 @@ const KNOWN_EVENTS = [
|
||||
'new-comment',
|
||||
'comment-deleted',
|
||||
'user-hidden',
|
||||
// The mirror of `user-hidden`: a host lifted a ban, so the guest's uploads return to every
|
||||
// feed and the projector, and their own parked upload queue resumes.
|
||||
'user-shown',
|
||||
'event-closed',
|
||||
'event-opened',
|
||||
'event-updated',
|
||||
@@ -144,7 +147,9 @@ export function connectSse(): void {
|
||||
for (const eventName of KNOWN_EVENTS) {
|
||||
eventSource.addEventListener(eventName, (e) => {
|
||||
noteStreamActivity();
|
||||
dispatch(eventName, (e as MessageEvent).data);
|
||||
const data = (e as MessageEvent).data;
|
||||
noteDelivered(eventName, data);
|
||||
dispatch(eventName, data);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -191,6 +196,10 @@ export function disconnectSse(): void {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
// A new stream has delivered nothing, so anything the next delta returns is genuinely
|
||||
// undelivered as far as THIS connection is concerned. Keeping the old set would suppress the
|
||||
// liveness signal for content that arrived during the gap — the opposite failure to H1.
|
||||
forgetDelivered();
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
@@ -305,6 +314,78 @@ function dispatch(eventType: string, data: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ids the LIVE STREAM has actually delivered to us — the evidence the liveness backstop needs.
|
||||
*
|
||||
* The backstop asks "did the poll find something the stream never pushed?", and the old answer was
|
||||
* simply "did the poll return any rows?". Those are not the same question, and on a live event they
|
||||
* diverge constantly: `dispatch` advances the cursor to an upload's `created_at`, which is always
|
||||
* EARLIER than the `server_time` the previous delta stored, so the cursor rewinds onto
|
||||
* `feed_delta`'s deliberately inclusive `>=` boundary and the poll re-returns an upload the stream
|
||||
* had already delivered a moment ago. Row count > 0, so the backstop concluded the socket was dead
|
||||
* and tore down a perfectly healthy stream — then reset `reconnectAttempt` to 0, bypassing the
|
||||
* jittered backoff. At 100 guests that is roughly one reconnect per second, sustained, all evening,
|
||||
* each costing ~10 queries.
|
||||
*
|
||||
* Bounded FIFO: an event runs for hours and this must not grow without limit. The cap only needs to
|
||||
* exceed what one delta window can return (`DELTA_LIMIT` server-side), because anything older than
|
||||
* the current window cannot be re-returned as "new".
|
||||
*/
|
||||
const DELIVERED_MEMORY = 500;
|
||||
const deliveredIds: string[] = [];
|
||||
const deliveredSet = new Set<string>();
|
||||
|
||||
function rememberDelivered(id: string): void {
|
||||
if (deliveredSet.has(id)) return;
|
||||
deliveredSet.add(id);
|
||||
deliveredIds.push(id);
|
||||
if (deliveredIds.length > DELIVERED_MEMORY) {
|
||||
const evicted = deliveredIds.shift();
|
||||
if (evicted !== undefined) deliveredSet.delete(evicted);
|
||||
}
|
||||
}
|
||||
|
||||
/** Record whatever ids a stream payload carried, so a later delta can be recognised as a repeat.
|
||||
*
|
||||
* Only the id that IS the thing the liveness check tests, per event — not every id field present.
|
||||
*
|
||||
* This used to harvest `id`, `upload_id` and `user_id` from every payload, which quietly disarmed
|
||||
* two thirds of the backstop. `new-upload` carries `id` + `user_id`, and `like-update` /
|
||||
* `new-comment` carry `upload_id` + `user_id`, so by the time anything was deleted or anyone was
|
||||
* banned their ids were already in `deliveredSet` — recorded from ordinary traffic about content
|
||||
* that was still perfectly live. `carried`'s `deleted_ids` and `hidden_user_ids` clauses were then
|
||||
* false essentially always.
|
||||
*
|
||||
* The cost: a socket that goes half-open (a phone roaming APs leaves `readyState === OPEN`, so the
|
||||
* cheap check misses it) is only noticed once a genuinely NEW upload appears. A host moderating
|
||||
* three photos, or banning a guest, produced a delta whose every id was "already delivered" — so
|
||||
* the stream stayed dead and the host kept moderating into a feed nobody's app was listening to.
|
||||
*/
|
||||
function noteDelivered(eventName: string, data: string): void {
|
||||
try {
|
||||
const p = JSON.parse(data) as { id?: unknown; upload_id?: unknown; user_id?: unknown };
|
||||
// Mirrors the three clauses in `carried`: uploads by upload id, deletions by upload id,
|
||||
// ban-hides by user id.
|
||||
const relevant =
|
||||
eventName === 'new-upload' || eventName === 'upload-processed'
|
||||
? p.id
|
||||
: eventName === 'upload-deleted'
|
||||
? (p.upload_id ?? p.id)
|
||||
: eventName === 'user-hidden' || eventName === 'user-shown'
|
||||
? p.user_id
|
||||
: undefined;
|
||||
if (typeof relevant === 'string') rememberDelivered(relevant);
|
||||
} catch {
|
||||
// non-JSON payload — nothing to record
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset on disconnect: a fresh stream has delivered nothing yet. */
|
||||
function forgetDelivered(): void {
|
||||
deliveredIds.length = 0;
|
||||
deliveredSet.clear();
|
||||
}
|
||||
|
||||
/** Pull an ISO `created_at` out of an event payload if it has one, else undefined. */
|
||||
function extractCreatedAt(data: string): string | undefined {
|
||||
try {
|
||||
@@ -333,10 +414,18 @@ async function deltaFetchAndFan(since: string, attempt = 0): Promise<boolean> {
|
||||
// reconnect resumes exactly where the server left off (no browser-clock skew).
|
||||
lastEventTime = response.server_time;
|
||||
dispatch('feed-delta', JSON.stringify(response));
|
||||
// "Did the poll find something the STREAM never delivered?" — not "did it return rows?".
|
||||
// See `deliveredIds`: on a live event the cursor rewinds onto the inclusive `>=` boundary
|
||||
// and re-returns uploads the stream already pushed, so a row count made every healthy
|
||||
// stream look dead and produced a sustained reconnect storm.
|
||||
//
|
||||
// Ids the stream delivered while we were connected are excluded. Anything genuinely new —
|
||||
// including everything that arrived while the socket was half-open — still counts, which is
|
||||
// the signal this backstop exists for.
|
||||
return (
|
||||
response.uploads.length > 0 ||
|
||||
response.deleted_ids.length > 0 ||
|
||||
response.hidden_user_ids.length > 0
|
||||
response.uploads.some((u) => !deliveredSet.has(u.id)) ||
|
||||
response.deleted_ids.some((id) => !deliveredSet.has(id)) ||
|
||||
response.hidden_user_ids.some((id) => !deliveredSet.has(id))
|
||||
);
|
||||
} catch (e) {
|
||||
// A throttled delta (429) must NOT be silently dropped: live events keep advancing
|
||||
|
||||
Reference in New Issue
Block a user