mirror of
https://github.com/mattermost/mattermost-mobile.git
synced 2026-11-09 04:30:14 +00:00
Re-verification of the 11 Phase-4 review fixes surfaced 3 that were only partially closed: #2 (busy loop): drainOutbox skipped a mis-scoped teamId='' row with a bare `continue`, leaving it Pending-and-due. scheduleWork kept counting it as due work and re-armed a zero-delay drain that skipped it again — a CPU spin. Now the skip backs off next_attempt_at (via computeNextAttemptAt), so the row leaves the due set until its channel/membership may hydrate; a valid drain resets the count. #1 (torn-down DB write): the GET/reconcile path wrote the snapshot after the HTTP await with no post-HTTP guard — only the POST/DELETE workers had one. reconcileTeamDrafts now takes ReconcileOpts.shouldAbort and re-checks shouldAbortPostHttpWrite before every per-draft write, failing closed if the server was torn down mid-GET. #6 (observation fence too late): the in-flight observation ordinal was bumped only after reconcileTeamDrafts fully returned (after the whole apply loop), leaving a window where a concurrent POST ack cleared the outbox first. The bump now runs synchronously via ReconcileOpts.onSnapshot the moment the GET resolves, before any apply-loop await. Adds regression tests for each; all affected suites green (tsc + eslint clean). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
872 lines
39 KiB
TypeScript
872 lines
39 KiB
TypeScript
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
import {Q, type Database, type Model} from '@nozbe/watermelondb';
|
|
|
|
import {General} from '@constants';
|
|
import {MM_TABLES} from '@constants/database';
|
|
import {DRAFT_SYNC_RETRY_BASE_MS, DRAFT_SYNC_RETRY_JITTER, DRAFT_SYNC_RETRY_MAX_MS, DraftOutboxOperation, DraftOutboxStatus} from '@constants/draft';
|
|
import DatabaseManager from '@database/manager';
|
|
import NetworkManager from '@managers/network_manager';
|
|
import websocketManager from '@managers/websocket_manager';
|
|
import {getChannelById, getMyChannel} from '@queries/servers/channel';
|
|
import {adoptLegacyDrafts, buildDraftOutboxId, getDraft, getDraftOutbox, mutateDraftAndOutbox, prepareDraftOutbox, queryDraftsForTeam, type PrepareDraftAndOutbox} from '@queries/servers/drafts';
|
|
import {getConfigValue} from '@queries/servers/system';
|
|
import {buildDraftUpsertRequest, draftContentFingerprint, normalizeServerDraft, type NormalizedDraft} from '@utils/draft/sync';
|
|
import {getFullErrorMessage, isErrorWithStatusCode} from '@utils/errors';
|
|
import {logDebug, logError} from '@utils/log';
|
|
|
|
import {forceLogoutIfNecessary} from './session';
|
|
|
|
import type DraftModel from '@typings/database/models/servers/draft';
|
|
import type DraftOutboxModel from '@typings/database/models/servers/draft_outbox';
|
|
|
|
const {SERVER: {DRAFT, DRAFT_OUTBOX}} = MM_TABLES;
|
|
|
|
/**
|
|
* fetchDraftsForTeam: fetch the server's authoritative draft snapshot for a team. Returns the raw
|
|
* DraftApi list on success. On failure it triggers a forced logout when the error warrants it,
|
|
* logs a non-PII error, and returns {error} so callers can fail closed (apply nothing).
|
|
*/
|
|
export async function fetchDraftsForTeam(serverUrl: string, teamId: string, groupLabel?: RequestGroupLabel): Promise<{drafts?: DraftApi[]; error?: unknown}> {
|
|
try {
|
|
const client = NetworkManager.getClient(serverUrl);
|
|
const drafts = await client.getDrafts(teamId, groupLabel);
|
|
return {drafts};
|
|
} catch (error) {
|
|
logError('fetchDraftsForTeam', getFullErrorMessage(error));
|
|
forceLogoutIfNecessary(serverUrl, error);
|
|
return {error};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* applyServerMetadata: produce the metadata to persist for a remote update. Server-owned fields
|
|
* (priority and the normalized burn-on-read borConfig) come from the snapshot; only genuinely
|
|
* device-local metadata (markdown image dimensions) is preserved from the existing row. This keeps
|
|
* type and borConfig consistent: a normal->BoR update carries the reconstructed borConfig (or none
|
|
* when it fails closed), and a BoR->normal update drops a stale enabled borConfig so it can never
|
|
* be sent as burn-on-read. `sd.metadata.borConfig` is already reconstructed fail-closed upstream.
|
|
*/
|
|
const applyServerMetadata = (existing: PostMetadata | undefined, sd: NormalizedDraft): PostMetadata => {
|
|
const next: PostMetadata = {};
|
|
|
|
// Device-local, non-portable metadata is preserved.
|
|
if (existing?.images) {
|
|
next.images = existing.images;
|
|
}
|
|
|
|
// Server-owned metadata replaces whatever was local.
|
|
if (sd.metadata?.priority) {
|
|
next.priority = sd.metadata.priority;
|
|
}
|
|
if (sd.metadata?.borConfig) {
|
|
next.borConfig = sd.metadata.borConfig;
|
|
}
|
|
|
|
return next;
|
|
};
|
|
|
|
/**
|
|
* prepareIncomingDraft: the additive reconciliation matrix for a single draft key. Given the current
|
|
* local Draft/DraftOutbox rows (fetched inside the serialized writer) and the normalized server draft
|
|
* `sd`, it returns the prepared records to commit. It NEVER deletes a Draft for being absent from the
|
|
* server and NEVER performs a network operation — this is a READ-path snapshot applier only.
|
|
*/
|
|
const prepareIncomingDraft = (sd: NormalizedDraft): PrepareDraftAndOutbox => ({database, channelId, rootId, draft, outbox}) => {
|
|
const createCleanDraft = (): Model => database.collections.get<DraftModel>(DRAFT).prepareCreate((d) => {
|
|
d.channelId = channelId;
|
|
d.rootId = rootId;
|
|
d.message = sd.message;
|
|
d.type = sd.type;
|
|
d.props = sd.props;
|
|
d.fileIds = sd.fileIds;
|
|
d.files = sd.files;
|
|
d.metadata = sd.metadata;
|
|
d.serverUpdateAt = sd.serverUpdateAt;
|
|
d.updateAt = Date.now();
|
|
});
|
|
|
|
const updateCleanDraft = (existing: DraftModel): Model => existing.prepareUpdate((d) => {
|
|
d.message = sd.message;
|
|
d.type = sd.type;
|
|
d.props = sd.props;
|
|
d.fileIds = sd.fileIds;
|
|
d.files = sd.files;
|
|
d.metadata = applyServerMetadata(d.metadata, sd);
|
|
d.serverUpdateAt = sd.serverUpdateAt;
|
|
d.updateAt = Date.now();
|
|
});
|
|
|
|
// A queued DELETE is authoritative local intent: the fingerprint distinguishes a stale replica
|
|
// echo of the very content we are deleting from genuinely new content written by another client.
|
|
if (outbox && outbox.operation === DraftOutboxOperation.Delete) {
|
|
const fp = draftContentFingerprint({
|
|
message: sd.message,
|
|
type: sd.type,
|
|
props: sd.props,
|
|
fileIds: sd.fileIds,
|
|
priority: sd.metadata?.priority,
|
|
});
|
|
|
|
if (fp === outbox.deletedFingerprint) {
|
|
// The server still holds the exact content we are deleting. For a Pending delete (not yet
|
|
// sent) that is expected — keep it pending; the drain will DELETE it. For a ConfirmingDelete
|
|
// (we already got a 200 but the content is still present) the delete did NOT take, so reset
|
|
// to Pending and let the drain retry the DELETE (naturally spaced by the confirmation GETs).
|
|
if (outbox.status === DraftOutboxStatus.ConfirmingDelete) {
|
|
return [outbox.prepareUpdate((o) => {
|
|
o.status = DraftOutboxStatus.Pending;
|
|
o.attemptCount = 0;
|
|
o.nextAttemptAt = 0;
|
|
})];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
// The server content differs from what we enqueued for deletion — another client wrote new
|
|
// content after our delete.
|
|
if (outbox.keepLocal) {
|
|
// keep_local: the retained empty/attachment-only local Draft must be neither resurrected
|
|
// nor replaced with remote text. Abandon the DELETE by parking it as unsyncable_empty so
|
|
// later reconciliations preserve the local Draft (and it never POSTs). Do NOT touch the Draft.
|
|
return prepareDraftOutbox(database, channelId, rootId, outbox.teamId, outbox, {type: 'park'});
|
|
}
|
|
|
|
// Ordinary delete: abandon it and adopt the new server content as a clean local draft.
|
|
return [
|
|
outbox.prepareDestroyPermanently(),
|
|
draft ? updateCleanDraft(draft) : createCleanDraft(),
|
|
];
|
|
}
|
|
|
|
// A pending UPSERT is unsynced local intent that wins: keep local message/files/type/priority and
|
|
// only merge the server-owned passthrough props while recording the observed server_update_at.
|
|
if (outbox && outbox.operation === DraftOutboxOperation.Upsert && outbox.status === DraftOutboxStatus.Pending) {
|
|
if (!draft) {
|
|
return [];
|
|
}
|
|
|
|
return [draft.prepareUpdate((d) => {
|
|
d.props = sd.props;
|
|
d.serverUpdateAt = sd.serverUpdateAt;
|
|
})];
|
|
}
|
|
|
|
// An in-progress/failed upload: apply the remote portable fields but preserve the local `files`
|
|
// array (which carries upload progress/failure state) untouched.
|
|
if (outbox && (outbox.status === DraftOutboxStatus.WaitingForUpload || outbox.status === DraftOutboxStatus.BlockedUpload)) {
|
|
if (!draft) {
|
|
return [];
|
|
}
|
|
|
|
return [draft.prepareUpdate((d) => {
|
|
d.message = sd.message;
|
|
d.type = sd.type;
|
|
d.props = sd.props;
|
|
d.fileIds = sd.fileIds;
|
|
d.metadata = applyServerMetadata(d.metadata, sd);
|
|
d.serverUpdateAt = sd.serverUpdateAt;
|
|
d.updateAt = Date.now();
|
|
})];
|
|
}
|
|
|
|
// A parked unsyncable-empty draft: preserve it locally, ignore the server content.
|
|
if (outbox && outbox.status === DraftOutboxStatus.Blocked) {
|
|
return [];
|
|
}
|
|
|
|
// No outbox and no local draft: create the clean draft from the server snapshot.
|
|
if (!draft) {
|
|
return [createCleanDraft()];
|
|
}
|
|
|
|
// No outbox and a clean existing draft: the server snapshot is authoritative. Apply only when the
|
|
// portable content actually differs (idempotent otherwise); an equal server_update_at with a
|
|
// differing payload still applies.
|
|
const localFingerprint = draftContentFingerprint({
|
|
message: draft.message,
|
|
type: draft.type,
|
|
props: draft.props,
|
|
fileIds: draft.fileIds ?? [],
|
|
priority: draft.metadata?.priority,
|
|
});
|
|
const serverFingerprint = draftContentFingerprint({
|
|
message: sd.message,
|
|
type: sd.type,
|
|
props: sd.props,
|
|
fileIds: sd.fileIds,
|
|
priority: sd.metadata?.priority,
|
|
});
|
|
|
|
if (localFingerprint === serverFingerprint) {
|
|
return [];
|
|
}
|
|
|
|
return [updateCleanDraft(draft)];
|
|
};
|
|
|
|
/**
|
|
* reconcileTeamDrafts: apply the server's authoritative draft snapshot to local state additively.
|
|
* It adopts legacy drafts first (so local intent wins below), fetches the snapshot, and applies the
|
|
* incoming matrix per in-scope key. It NEVER deletes a Draft for being absent from the response, and
|
|
* it applies nothing when the GET fails (no baseline, no deletion). Returns the applied write count
|
|
* and the in-scope normalized drafts so a later absence-detection sub-step can track candidates.
|
|
*/
|
|
export async function reconcileTeamDrafts(serverUrl: string, teamId: string, opts?: ReconcileOpts): Promise<{applied?: number; drafts?: NormalizedDraft[]; missingDeps?: boolean; error?: unknown}> {
|
|
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
|
|
if (!database) {
|
|
return {error: `${serverUrl} database not found`};
|
|
}
|
|
|
|
try {
|
|
// Adopt pre-sync drafts FIRST so their local intent wins over the incoming snapshot below.
|
|
await adoptLegacyDrafts(database, teamId);
|
|
|
|
const res = await fetchDraftsForTeam(serverUrl, teamId);
|
|
if (res.error || !res.drafts) {
|
|
// GET failure: apply nothing. Without a baseline we must not create/update/delete anything.
|
|
return {error: res.error};
|
|
}
|
|
|
|
// Post-dispatch observation fence: announce every observed key SYNCHRONOUSLY, in the same tick
|
|
// the GET resolved and before any apply-loop await, so an in-flight POST's ack (which may run
|
|
// during that loop) sees the bumped ordinal and does not clobber content this GET observed.
|
|
opts?.onSnapshot?.(res.drafts.map((d) => ({channelId: d.channel_id, rootId: d.root_id})));
|
|
|
|
// BoR durations live in server config, not in draft props. Read them once; fail closed to
|
|
// undefined when unavailable so burn-on-read reconstruction never fabricates durations.
|
|
const borDurationSeconds = parseInt(await getConfigValue(database, 'BurnOnReadDurationSeconds') ?? '', 10);
|
|
const borMaximumTimeToLiveSeconds = parseInt(await getConfigValue(database, 'BurnOnReadMaximumTimeToLiveSeconds') ?? '', 10);
|
|
const durations = (borDurationSeconds > 0 && borMaximumTimeToLiveSeconds > 0) ? {borDurationSeconds, borMaximumTimeToLiveSeconds} : undefined;
|
|
|
|
const inScope: NormalizedDraft[] = [];
|
|
let applied = 0;
|
|
let missingChannelCount = 0;
|
|
|
|
for (const serverDraft of res.drafts) {
|
|
// The GET awaited above may have outlived the server: a logout/wipe/persistence-mode change
|
|
// can have destroyed or recreated the database mid-flight. Re-check before every snapshot
|
|
// write so a late completion never writes to a stale/destroyed database (the manager's own
|
|
// epoch check only runs AFTER this function returns — too late for these in-loop writes).
|
|
if (shouldAbortPostHttpWrite(serverUrl, database, opts)) {
|
|
return {error: 'reconcileTeamDrafts: server torn down during reconcile'};
|
|
}
|
|
|
|
const sd = normalizeServerDraft(serverDraft, durations);
|
|
|
|
// eslint-disable-next-line no-await-in-loop
|
|
const channel = await getChannelById(database, sd.channelId);
|
|
if (!channel) {
|
|
// Missing channel row: skip this key without aborting the others and signal a
|
|
// guaranteed retry (missingDeps) so a later reschedule reconciles it once the channel
|
|
// is hydrated. NEVER log the channel id — DM/GM channel ids embed usernames (PII).
|
|
missingChannelCount += 1;
|
|
continue;
|
|
}
|
|
|
|
const isDmGm = channel.type === General.DM_CHANNEL || channel.type === General.GM_CHANNEL;
|
|
|
|
// Authoritative scope: a confirmed membership row is required in every case (a DM/GM the
|
|
// user is not a member of is not authoritative). A non-DM/GM channel must additionally
|
|
// belong to the reconciled team — a channel mis-scoped to another team is not this run's
|
|
// authority — so it is skipped.
|
|
// eslint-disable-next-line no-await-in-loop
|
|
const isMember = Boolean(await getMyChannel(database, sd.channelId));
|
|
const inScopeKey = isDmGm ? isMember : (isMember && channel.teamId === teamId);
|
|
|
|
if (!inScopeKey) {
|
|
continue;
|
|
}
|
|
|
|
inScope.push(sd);
|
|
|
|
// eslint-disable-next-line no-await-in-loop
|
|
const models = await mutateDraftAndOutbox(database, sd.channelId, sd.rootId, prepareIncomingDraft(sd));
|
|
if (models.length > 0) {
|
|
applied += 1;
|
|
}
|
|
}
|
|
|
|
if (missingChannelCount > 0) {
|
|
// Count only, no ids — the skip stays traceable without logging PII.
|
|
logDebug('reconcileTeamDrafts: skipped drafts for missing channels', missingChannelCount);
|
|
}
|
|
|
|
return {applied, drafts: inScope, missingDeps: missingChannelCount > 0};
|
|
} catch (error) {
|
|
logError('reconcileTeamDrafts', getFullErrorMessage(error));
|
|
forceLogoutIfNecessary(serverUrl, error);
|
|
return {error};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* ReconcileKey: the classification payload for a single in-scope draft key considered by the
|
|
* manager's absence pass. `kind` distinguishes a plain Draft row from a delete tombstone (which
|
|
* drives the decision when both a Draft and a delete outbox exist for the key). `authoritative`
|
|
* is false when the channel membership cannot be confirmed for this scope (membership lost), in
|
|
* which case the key must be preserved (never deleted).
|
|
*/
|
|
export type ReconcileKey = {
|
|
channelId: string;
|
|
rootId: string;
|
|
kind: 'draft' | 'tombstone';
|
|
serverUpdateAt: number;
|
|
hasOutbox: boolean;
|
|
outboxOperation?: DraftOutboxOperation;
|
|
outboxStatus?: DraftOutboxStatus;
|
|
keepLocal?: boolean;
|
|
authoritative: boolean;
|
|
};
|
|
|
|
/**
|
|
* isChannelAuthoritative: a channel is authoritative for absence decisions when it is a DM/GM
|
|
* (always in scope) or the current user has a confirmed membership row for it. A non-DM/GM channel
|
|
* whose membership row is gone is NOT authoritative — its keys are preserved, never deleted.
|
|
*/
|
|
const isChannelAuthoritative = async (database: Database, channelId: string): Promise<boolean> => {
|
|
const channel = await getChannelById(database, channelId);
|
|
if (channel?.type === General.DM_CHANNEL || channel?.type === General.GM_CHANNEL) {
|
|
return true;
|
|
}
|
|
return Boolean(await getMyChannel(database, channelId));
|
|
};
|
|
|
|
/**
|
|
* getReconcilableKeys: enumerate every in-scope draft key (a Draft row and/or a delete tombstone)
|
|
* that the manager's absence pass may need to classify for `teamId`. Delete tombstones drive the
|
|
* decision, so a key that has both a Draft and a delete tombstone appears once as a `tombstone`.
|
|
* Only tombstones within this run's authority are returned: those whose stored teamId matches, or
|
|
* DM/GM ('' team) tombstones whose channel is a confirmed DM/GM channel. This function performs no
|
|
* network activity and never mutates state.
|
|
*/
|
|
export async function getReconcilableKeys(database: Database, teamId: string): Promise<ReconcileKey[]> {
|
|
const keys: ReconcileKey[] = [];
|
|
const tombstoneKeys = new Set<string>();
|
|
|
|
const deleteOutboxes = await database.collections.get<DraftOutboxModel>(DRAFT_OUTBOX).query(
|
|
Q.where('operation', DraftOutboxOperation.Delete),
|
|
).fetch();
|
|
|
|
for (const outbox of deleteOutboxes) {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
const channel = await getChannelById(database, outbox.channelId);
|
|
const isDmGm = channel?.type === General.DM_CHANNEL || channel?.type === General.GM_CHANNEL;
|
|
|
|
// Scope gate: this run only has authority over tombstones stored under its team, or DM/GM
|
|
// ('' team) tombstones whose channel is a confirmed DM/GM channel. Skip everything else.
|
|
if (outbox.teamId !== teamId && !(outbox.teamId === '' && isDmGm)) {
|
|
continue;
|
|
}
|
|
|
|
// eslint-disable-next-line no-await-in-loop
|
|
const authoritative = isDmGm || Boolean(await getMyChannel(database, outbox.channelId));
|
|
|
|
tombstoneKeys.add(buildDraftOutboxId(outbox.channelId, outbox.rootId));
|
|
keys.push({
|
|
channelId: outbox.channelId,
|
|
rootId: outbox.rootId,
|
|
kind: 'tombstone',
|
|
serverUpdateAt: 0,
|
|
hasOutbox: true,
|
|
outboxOperation: outbox.operation,
|
|
outboxStatus: outbox.status,
|
|
keepLocal: outbox.keepLocal,
|
|
authoritative,
|
|
});
|
|
}
|
|
|
|
const drafts = await queryDraftsForTeam(database, teamId).fetch();
|
|
for (const draft of drafts) {
|
|
const key = buildDraftOutboxId(draft.channelId, draft.rootId);
|
|
if (tombstoneKeys.has(key)) {
|
|
// A delete tombstone already represents this key (a keepLocal=true delete retains a Draft).
|
|
continue;
|
|
}
|
|
|
|
// eslint-disable-next-line no-await-in-loop
|
|
const outbox = await getDraftOutbox(database, draft.channelId, draft.rootId);
|
|
|
|
// eslint-disable-next-line no-await-in-loop
|
|
const authoritative = await isChannelAuthoritative(database, draft.channelId);
|
|
|
|
keys.push({
|
|
channelId: draft.channelId,
|
|
rootId: draft.rootId,
|
|
kind: 'draft',
|
|
serverUpdateAt: draft.serverUpdateAt ?? 0,
|
|
hasOutbox: Boolean(outbox),
|
|
outboxOperation: outbox?.operation,
|
|
outboxStatus: outbox?.status,
|
|
keepLocal: outbox?.keepLocal,
|
|
authoritative,
|
|
});
|
|
}
|
|
|
|
return keys;
|
|
}
|
|
|
|
/**
|
|
* deleteAbsentCleanDraft: CONFIRMED-absence deletion of a clean, server-backed Draft (Rule A). It
|
|
* re-reads the key inside the serialized writer and only destroys the Draft when it is STILL clean
|
|
* (serverUpdateAt > 0 AND no outbox) — otherwise the state changed since the decision and it no-ops.
|
|
* Never performs a network operation.
|
|
*/
|
|
export async function deleteAbsentCleanDraft(serverUrl: string, channelId: string, rootId: string): Promise<void> {
|
|
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
|
|
if (!database) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await mutateDraftAndOutbox(database, channelId, rootId, ({draft, outbox}) => {
|
|
if (draft && !outbox && (draft.serverUpdateAt ?? 0) > 0) {
|
|
return [draft.prepareDestroyPermanently()];
|
|
}
|
|
return [];
|
|
});
|
|
} catch (error) {
|
|
logError('deleteAbsentCleanDraft', getFullErrorMessage(error));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* confirmDeleteTombstone: CONFIRMED-absence resolution of a delete tombstone (Rule B). It re-reads
|
|
* the key inside the serialized writer; if the outbox is no longer a Delete (a genuine edit flipped
|
|
* it) it no-ops. For an ordinary delete (keepLocal=false) it removes the tombstone row (the visible
|
|
* Draft was already removed at enqueue time). For a keepLocal=true delete it detaches the retained
|
|
* Draft from the server (serverUpdateAt=0) and parks the outbox as unsyncable_empty. Never networks.
|
|
*/
|
|
export async function confirmDeleteTombstone(serverUrl: string, channelId: string, rootId: string): Promise<void> {
|
|
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
|
|
if (!database) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await mutateDraftAndOutbox(database, channelId, rootId, ({database: db, draft, outbox}) => {
|
|
if (!outbox || outbox.operation !== DraftOutboxOperation.Delete) {
|
|
return [];
|
|
}
|
|
if (outbox.status !== DraftOutboxStatus.Pending && outbox.status !== DraftOutboxStatus.ConfirmingDelete) {
|
|
return [];
|
|
}
|
|
|
|
if (!outbox.keepLocal) {
|
|
// Ordinary delete: the visible Draft was already removed at enqueue time. If a Draft
|
|
// unexpectedly exists, leave it — only drop the tombstone row.
|
|
return prepareDraftOutbox(db, channelId, rootId, outbox.teamId, outbox, {type: 'remove'});
|
|
}
|
|
|
|
const models: Model[] = [];
|
|
if (draft) {
|
|
models.push(draft.prepareUpdate((d) => {
|
|
d.serverUpdateAt = 0;
|
|
}));
|
|
}
|
|
models.push(...prepareDraftOutbox(db, channelId, rootId, outbox.teamId, outbox, {type: 'park'}));
|
|
return models;
|
|
});
|
|
} catch (error) {
|
|
logError('confirmDeleteTombstone', getFullErrorMessage(error));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* WorkerOutcome: the result of a single per-key outbox worker run.
|
|
* - done: nothing left to do for this generation (removed / acked / no-op).
|
|
* - retry: transient failure or a newer generation remains; the row stays time-driven.
|
|
* - blocked: parked awaiting an external signal (edit/unblock); not time-driven.
|
|
* - suspend: a server-wide failure (sync disabled / unsupported route); stop this session.
|
|
* - converted: the upsert was rewritten into a delete; drain will pick the delete up next.
|
|
*/
|
|
export type WorkerOutcome = {
|
|
outcome: 'done' | 'retry' | 'blocked' | 'suspend' | 'converted';
|
|
retryAfterMs?: number;
|
|
};
|
|
|
|
/**
|
|
* OutboxWorkerOpts: caller-supplied guards a worker consults ONLY after its HTTP call resolves and
|
|
* immediately before any post-HTTP database write.
|
|
* - shouldAbort: true when the server lifecycle changed under the request (epoch bumped / server
|
|
* invalidated); the worker must not write.
|
|
* - captureObservation / observationChanged: the per-key observation ordinal fence. captureObservation
|
|
* is read right before the POST dispatch; observationChanged(captured) is true in the ack when a
|
|
* reconcile GET observed newer content for this key while the POST was in flight, so the ack must
|
|
* not clobber it (see the manager's observationOrdinals).
|
|
*/
|
|
export type OutboxWorkerOpts = {
|
|
shouldAbort?: () => boolean;
|
|
captureObservation?: () => number;
|
|
observationChanged?: (captured: number) => boolean;
|
|
};
|
|
|
|
/**
|
|
* ReconcileOpts: caller-supplied guards for the GET/reconcile path (see reconcileTeamDrafts).
|
|
* - shouldAbort: same semantics as OutboxWorkerOpts.shouldAbort — true when the server lifecycle
|
|
* changed under the in-flight GET, so no snapshot may be written to the (now stale) database.
|
|
* - onSnapshot: invoked SYNCHRONOUSLY the moment the GET response is received (before any apply-loop
|
|
* await), with every observed key. The manager uses it to bump the per-key observation ordinal for
|
|
* in-flight POSTs, so a concurrent ack cannot clobber content this GET already observed.
|
|
*/
|
|
export type ReconcileOpts = {
|
|
shouldAbort?: () => boolean;
|
|
onSnapshot?: (keys: Array<{channelId: string; rootId: string}>) => void;
|
|
};
|
|
|
|
/**
|
|
* shouldAbortPostHttpWrite: guards every post-HTTP database write. Returns true (abort, write nothing)
|
|
* when the caller signals staleness OR the server database is no longer the SAME instance captured at
|
|
* the top of the worker (recreated by a logout/wipe/re-login) or is now absent. A late HTTP completion
|
|
* must never write to a destroyed/recreated database.
|
|
*/
|
|
const shouldAbortPostHttpWrite = (serverUrl: string, database: Database, opts?: {shouldAbort?: () => boolean}): boolean => {
|
|
if (opts?.shouldAbort?.()) {
|
|
return true;
|
|
}
|
|
return DatabaseManager.serverDatabases[serverUrl]?.database !== database;
|
|
};
|
|
|
|
// Non-sensitive outbox error classifications persisted to last_error_code.
|
|
const OUTBOX_ERROR = {
|
|
MissingLocalDraft: 'missing_local_draft',
|
|
Forbidden: 'forbidden',
|
|
UnsupportedRoute: 'unsupported_route',
|
|
SyncDisabled: 'sync_disabled',
|
|
Invalid: 'invalid',
|
|
ScopeUnverifiable: 'scope_unverifiable',
|
|
} as const;
|
|
|
|
/**
|
|
* computeNextAttemptAt: the next absolute retry timestamp for a failed outbox row. When the server
|
|
* supplies a Retry-After (retryAfterMs) it is honored verbatim. Otherwise it is capped exponential
|
|
* backoff (DRAFT_SYNC_RETRY_BASE_MS * 2^attemptCount, ceilinged at DRAFT_SYNC_RETRY_MAX_MS) with
|
|
* +/-DRAFT_SYNC_RETRY_JITTER proportional jitter. The result is never negative.
|
|
*/
|
|
export function computeNextAttemptAt(attemptCount: number, now: number, retryAfterMs?: number): number {
|
|
if (retryAfterMs != null) {
|
|
return Math.max(0, now + retryAfterMs);
|
|
}
|
|
|
|
const base = Math.min(DRAFT_SYNC_RETRY_BASE_MS * (2 ** attemptCount), DRAFT_SYNC_RETRY_MAX_MS);
|
|
const jitter = base * DRAFT_SYNC_RETRY_JITTER * ((Math.random() * 2) - 1);
|
|
return Math.max(0, Math.round(now + base + jitter));
|
|
}
|
|
|
|
// isErrorWithHeaders: narrow guard for a ClientError-shaped object carrying response headers.
|
|
const isErrorWithHeaders = (error: unknown): error is {headers: Record<string, string>} => {
|
|
return typeof error === 'object' && error !== null && 'headers' in error &&
|
|
typeof (error as {headers: unknown}).headers === 'object' && (error as {headers: unknown}).headers !== null;
|
|
};
|
|
|
|
// readRetryAfterMs: parse a Retry-After (seconds) response header into milliseconds, or undefined.
|
|
const readRetryAfterMs = (error: unknown): number | undefined => {
|
|
if (!isErrorWithHeaders(error)) {
|
|
return undefined;
|
|
}
|
|
const raw = error.headers['Retry-After'] ?? error.headers['retry-after'];
|
|
if (!raw) {
|
|
return undefined;
|
|
}
|
|
const seconds = parseInt(raw, 10);
|
|
if (Number.isNaN(seconds) || seconds < 0) {
|
|
return undefined;
|
|
}
|
|
return seconds * 1000;
|
|
};
|
|
|
|
/**
|
|
* classifyOutboxError: map a caught worker error to a WorkerOutcome and persist the matching outbox
|
|
* transition (guarded by generation so a newer local edit during the request is never clobbered).
|
|
* See the error-classification table in the drafts-sync spec. Never logs message/props/file/ids.
|
|
*/
|
|
const classifyOutboxError = async (
|
|
serverUrl: string,
|
|
database: Database,
|
|
channelId: string,
|
|
rootId: string,
|
|
generation: number,
|
|
error: unknown,
|
|
logPrefix: string,
|
|
isDelete: boolean,
|
|
): Promise<WorkerOutcome> => {
|
|
const status = isErrorWithStatusCode(error) ? error.status_code : undefined;
|
|
logDebug(`${logPrefix}: request failed`, `status=${status ?? 'none'}`, getFullErrorMessage(error));
|
|
|
|
// Persist an outbox transition only while the generation still matches the one we sent.
|
|
const applyOutbox = (mutate: (o: DraftOutboxModel) => void) => mutateDraftAndOutbox(database, channelId, rootId, ({outbox}) => {
|
|
if (!outbox || outbox.generation !== generation) {
|
|
return [];
|
|
}
|
|
return [outbox.prepareUpdate(mutate)];
|
|
});
|
|
|
|
switch (status) {
|
|
case 401:
|
|
// The forced-logout flow owns recovery; leave the row pending for after re-auth.
|
|
forceLogoutIfNecessary(serverUrl, error);
|
|
return {outcome: 'retry'};
|
|
case 403:
|
|
await applyOutbox((o) => {
|
|
o.status = DraftOutboxStatus.Blocked;
|
|
o.lastErrorCode = OUTBOX_ERROR.Forbidden;
|
|
});
|
|
return {outcome: 'blocked'};
|
|
case 400:
|
|
await applyOutbox((o) => {
|
|
o.status = DraftOutboxStatus.Blocked;
|
|
o.lastErrorCode = OUTBOX_ERROR.Invalid;
|
|
});
|
|
return {outcome: 'blocked'};
|
|
case 429: {
|
|
const retryAfterMs = readRetryAfterMs(error);
|
|
await applyOutbox((o) => {
|
|
o.attemptCount += 1;
|
|
o.nextAttemptAt = computeNextAttemptAt(o.attemptCount, Date.now(), retryAfterMs);
|
|
});
|
|
return {outcome: 'retry', retryAfterMs};
|
|
}
|
|
case 501:
|
|
await applyOutbox((o) => {
|
|
o.status = DraftOutboxStatus.Blocked;
|
|
o.lastErrorCode = OUTBOX_ERROR.SyncDisabled;
|
|
});
|
|
return {outcome: 'suspend'};
|
|
case 404:
|
|
if (isDelete) {
|
|
// The supported delete route returns 200 even for an already-absent row, so a 404 means
|
|
// the route/capability is unavailable: block and suspend this session.
|
|
await applyOutbox((o) => {
|
|
o.status = DraftOutboxStatus.Blocked;
|
|
o.lastErrorCode = OUTBOX_ERROR.UnsupportedRoute;
|
|
});
|
|
return {outcome: 'suspend'};
|
|
}
|
|
|
|
// A 404 on upsert is not otherwise expected; treat as transient with capped backoff.
|
|
await applyOutbox((o) => {
|
|
o.attemptCount += 1;
|
|
o.nextAttemptAt = computeNextAttemptAt(o.attemptCount, Date.now());
|
|
});
|
|
return {outcome: 'retry'};
|
|
default:
|
|
// 5xx / network / timeout / malformed response: keep pending with capped backoff.
|
|
await applyOutbox((o) => {
|
|
o.attemptCount += 1;
|
|
o.nextAttemptAt = computeNextAttemptAt(o.attemptCount, Date.now());
|
|
});
|
|
return {outcome: 'retry'};
|
|
}
|
|
};
|
|
|
|
/**
|
|
* processOutboxUpsert: drain a single Pending upsert outbox row to the server. It is DB-focused: it
|
|
* captures the row generation, POSTs outside any writer, then re-checks the generation inside the ack
|
|
* writer so a newer local edit during the POST is never clobbered. Epoch checks are the caller's job.
|
|
*/
|
|
export async function processOutboxUpsert(serverUrl: string, channelId: string, rootId: string, opts?: OutboxWorkerOpts): Promise<WorkerOutcome> {
|
|
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
|
|
if (!database) {
|
|
return {outcome: 'done'};
|
|
}
|
|
|
|
const outbox = await getDraftOutbox(database, channelId, rootId);
|
|
if (!outbox || outbox.operation !== DraftOutboxOperation.Upsert || outbox.status !== DraftOutboxStatus.Pending) {
|
|
return {outcome: 'done'};
|
|
}
|
|
|
|
const draft = await getDraft(database, channelId, rootId);
|
|
|
|
// A pending upsert with no local Draft is unresolvable: never fabricate content or POST.
|
|
if (!draft) {
|
|
logDebug('processOutboxUpsert: no local draft for pending upsert');
|
|
await mutateDraftAndOutbox(database, channelId, rootId, ({outbox: o}) => {
|
|
if (!o || o.operation !== DraftOutboxOperation.Upsert || o.status !== DraftOutboxStatus.Pending) {
|
|
return [];
|
|
}
|
|
return [o.prepareUpdate((x) => {
|
|
x.status = DraftOutboxStatus.Blocked;
|
|
x.lastErrorCode = OUTBOX_ERROR.MissingLocalDraft;
|
|
})];
|
|
});
|
|
return {outcome: 'blocked'};
|
|
}
|
|
|
|
const request = buildDraftUpsertRequest({
|
|
channelId: draft.channelId,
|
|
rootId: draft.rootId,
|
|
message: draft.message,
|
|
type: draft.type as PostTypesUserCreatable | null,
|
|
props: draft.props,
|
|
fileIds: draft.fileIds ?? [],
|
|
metadata: draft.metadata,
|
|
});
|
|
if (!request) {
|
|
// Empty message: the server treats an empty upsert as a delete. Convert to an explicit delete
|
|
// when there is remote/attachment state to clear; otherwise park as unsyncable-empty.
|
|
const hasFiles = draft.files.length > 0;
|
|
const wasDispatched = (draft.serverUpdateAt ?? 0) > 0;
|
|
if (hasFiles || wasDispatched) {
|
|
const deletedFingerprint = draftContentFingerprint({
|
|
message: draft.message,
|
|
type: draft.type,
|
|
props: draft.props,
|
|
fileIds: draft.fileIds ?? [],
|
|
priority: draft.metadata?.priority,
|
|
});
|
|
await mutateDraftAndOutbox(database, channelId, rootId, ({database: db, outbox: o}) => {
|
|
if (!o) {
|
|
return [];
|
|
}
|
|
return prepareDraftOutbox(db, channelId, rootId, o.teamId, o, {type: 'delete', keepLocal: hasFiles, deletedFingerprint});
|
|
});
|
|
return {outcome: 'converted'};
|
|
}
|
|
|
|
await mutateDraftAndOutbox(database, channelId, rootId, ({database: db, outbox: o}) => {
|
|
if (!o) {
|
|
return [];
|
|
}
|
|
return prepareDraftOutbox(db, channelId, rootId, o.teamId, o, {type: 'park'});
|
|
});
|
|
return {outcome: 'blocked'};
|
|
}
|
|
|
|
const generation = outbox.generation;
|
|
const connectionId = websocketManager.getClient(serverUrl)?.getConnectionId();
|
|
|
|
// Read the per-key observation ordinal right before dispatch so the ack can detect a reconcile GET
|
|
// that observed newer content for this key while the POST was in flight (see OutboxWorkerOpts).
|
|
const observation = opts?.captureObservation?.() ?? 0;
|
|
|
|
let draftApi: DraftApi;
|
|
try {
|
|
const client = NetworkManager.getClient(serverUrl);
|
|
draftApi = await client.upsertDraft(request, connectionId);
|
|
} catch (error) {
|
|
// Late failure against a destroyed/recreated DB or a torn-down server: write nothing.
|
|
if (shouldAbortPostHttpWrite(serverUrl, database, opts)) {
|
|
return {outcome: 'done'};
|
|
}
|
|
return classifyOutboxError(serverUrl, database, channelId, rootId, generation, error, 'processOutboxUpsert', false);
|
|
}
|
|
|
|
// Guard the success ack write: a logout/wipe during the POST must not write to a stale DB.
|
|
if (shouldAbortPostHttpWrite(serverUrl, database, opts)) {
|
|
return {outcome: 'done'};
|
|
}
|
|
|
|
let outcome: WorkerOutcome = {outcome: 'done'};
|
|
await mutateDraftAndOutbox(database, channelId, rootId, ({database: db, draft: d, outbox: o}) => {
|
|
// A newer local mutation happened during the POST (generation bumped) OR a reconcile GET
|
|
// observed newer content for this key while the POST was in flight (observation ordinal
|
|
// changed): do not clear the outbox or overwrite newer content; only advance the observed
|
|
// server_update_at. A re-POST will capture the new generation/ordinal.
|
|
if (!o || o.generation !== generation || opts?.observationChanged?.(observation)) {
|
|
outcome = {outcome: 'retry'};
|
|
if (d) {
|
|
const observedUpdateAt = Math.max(d.serverUpdateAt ?? 0, draftApi.update_at);
|
|
return [d.prepareUpdate((x) => {
|
|
x.serverUpdateAt = observedUpdateAt;
|
|
})];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
const models: Model[] = [];
|
|
if (d) {
|
|
models.push(d.prepareUpdate((x) => {
|
|
x.serverUpdateAt = draftApi.update_at;
|
|
}));
|
|
}
|
|
|
|
// An in-progress upload (a file with no server id) keeps the row alive as waiting_for_upload so
|
|
// the eventual upload-complete edit re-sends; otherwise the row is fully acked and removed. The
|
|
// waitingForUpload INTENT would no-op on a still-Pending row, so transition the status directly.
|
|
const hasPendingUpload = (d?.files ?? []).some((f) => !f.id);
|
|
if (hasPendingUpload) {
|
|
models.push(o.prepareUpdate((x) => {
|
|
x.status = DraftOutboxStatus.WaitingForUpload;
|
|
x.attemptCount = 0;
|
|
x.nextAttemptAt = 0;
|
|
x.lastErrorCode = null;
|
|
}));
|
|
} else {
|
|
models.push(...prepareDraftOutbox(db, channelId, rootId, o.teamId, o, {type: 'remove'}));
|
|
}
|
|
outcome = {outcome: 'done'};
|
|
return models;
|
|
});
|
|
|
|
return outcome;
|
|
}
|
|
|
|
/**
|
|
* processOutboxDelete: drain a single Pending delete tombstone to the server. A 200 is NOT proof of
|
|
* absence (replica lag), so success hands off to confirming_delete; the reconciliation absence pass
|
|
* clears the tombstone after confirmed GET absences. A 404 means the delete route is unsupported.
|
|
*/
|
|
export async function processOutboxDelete(serverUrl: string, channelId: string, rootId: string, opts?: OutboxWorkerOpts): Promise<WorkerOutcome> {
|
|
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
|
|
if (!database) {
|
|
return {outcome: 'done'};
|
|
}
|
|
|
|
const outbox = await getDraftOutbox(database, channelId, rootId);
|
|
if (!outbox || outbox.operation !== DraftOutboxOperation.Delete || outbox.status !== DraftOutboxStatus.Pending) {
|
|
return {outcome: 'done'};
|
|
}
|
|
|
|
const generation = outbox.generation;
|
|
|
|
// Scope authority: a non-DM/GM channel whose membership row is gone cannot be verified; retain the
|
|
// tombstone and block rather than risk deleting a draft we no longer have authority over.
|
|
const channel = await getChannelById(database, channelId);
|
|
const isDmGm = channel?.type === General.DM_CHANNEL || channel?.type === General.GM_CHANNEL;
|
|
if (!isDmGm) {
|
|
const membership = await getMyChannel(database, channelId);
|
|
if (!membership) {
|
|
await mutateDraftAndOutbox(database, channelId, rootId, ({outbox: o}) => {
|
|
if (!o || o.generation !== generation) {
|
|
return [];
|
|
}
|
|
return [o.prepareUpdate((x) => {
|
|
x.status = DraftOutboxStatus.Blocked;
|
|
x.lastErrorCode = OUTBOX_ERROR.ScopeUnverifiable;
|
|
})];
|
|
});
|
|
return {outcome: 'blocked'};
|
|
}
|
|
}
|
|
|
|
const connectionId = websocketManager.getClient(serverUrl)?.getConnectionId();
|
|
try {
|
|
const client = NetworkManager.getClient(serverUrl);
|
|
await client.deleteDraft(channelId, rootId, connectionId);
|
|
} catch (error) {
|
|
// Late failure against a destroyed/recreated DB or a torn-down server: write nothing.
|
|
if (shouldAbortPostHttpWrite(serverUrl, database, opts)) {
|
|
return {outcome: 'done'};
|
|
}
|
|
return classifyOutboxError(serverUrl, database, channelId, rootId, generation, error, 'processOutboxDelete', true);
|
|
}
|
|
|
|
// Guard the confirming-delete transition: a logout/wipe during the DELETE must not write to a stale DB.
|
|
if (shouldAbortPostHttpWrite(serverUrl, database, opts)) {
|
|
return {outcome: 'done'};
|
|
}
|
|
|
|
// Success: hand off to the absence pass. A genuine edit that flipped the row to a new operation (or
|
|
// bumped the generation) is retained untouched for the drain to pick up next.
|
|
await mutateDraftAndOutbox(database, channelId, rootId, ({outbox: o}) => {
|
|
if (!o || o.operation !== DraftOutboxOperation.Delete || o.generation !== generation) {
|
|
return [];
|
|
}
|
|
return [o.prepareUpdate((x) => {
|
|
x.status = DraftOutboxStatus.ConfirmingDelete;
|
|
})];
|
|
});
|
|
return {outcome: 'done'};
|
|
}
|