Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions doc/web-server-compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,38 @@ server, or whether a client upgrade can help resets dismissal; a confirmed
compatible response clears it. Reloading the tab starts a new session. An old
web client offers a reload action; contradictory requirements ask the
administrator to update both components.

## Database page loading

The authenticated view loader requests
`GET /api/workspace/{workspace_id}/page-view/{view_id}?include_rows=false`.
It applies only the page's `encoded_collab`; database row snapshots are loaded by
the existing seed/realtime pipeline. The response retains the full database
metadata, including all view row orders, and returns an empty `row_data` map.
View authorization and database identity resolution remain on the page-view
endpoint. This transport option does not change the loader's view ownership or
sync binding behavior.

Omitting the parameter keeps the full row response for other callers. Request
deduplication includes the normalized row option so a legacy caller cannot
receive an empty-row response from a simultaneous optimized load. Older servers
that ignore the query continue returning rows, which this loader already ignores.
Published pages keep their separate static row payloads. Writable database pages
still walk blob-diff pages (256 items / 16 MiB per page) before exposing a complete
seed set for filtering, sorting, grouping and row counts; only visible rows acquire
realtime subscriptions. This change removes duplicate page-view reads and does
not introduce partial database semantics or change read-only/guest fallback behavior.

The blob-diff binary endpoint can also return JSON application errors, including
HTTP 200 with code 1079 when the server's admission queue is full. Web detects the
JSON envelope before protobuf decoding and retries admission overload (1079 or
HTTP 429) at most three times per page request. Backoff bases are 1/2/4 seconds,
with 100–200% jitter and a server retry hint as a minimum. Hints over 30 seconds
surface the retry action immediately rather than retaining the walk or retrying
early. Cursor, original RID and provisional pages stay unchanged across retries;
seeds and the RID become visible only after the terminal Ready page. Concurrent
views of the same database share the prefetch, and the last owner's release
cancels unfinished network/backoff work. After overload exhaustion, the database
shows a retry action and keeps its row seed gate closed, avoiding a second wave
of individual row-sync requests. Permission and protocol failures retain their
existing handling.
31 changes: 29 additions & 2 deletions src/application/__tests__/view-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ describe('view-loader database cache identity', () => {
expect(mockOpenCollabDB).not.toHaveBeenCalledWith(viewId);
});

it('fetches by viewId into the canonical databaseId cache when local cache is empty', async () => {
it('fetches metadata by viewId into the canonical database cache without requesting row snapshots', async () => {
const viewId = '00000000-0000-4000-8000-000000000003';
const databaseId = '00000000-0000-4000-8000-000000000004';
const canonicalDoc = createEmptyDoc(databaseId);
Expand Down Expand Up @@ -193,7 +193,34 @@ describe('view-loader database cache identity', () => {
expect(result.doc).toBe(canonicalDoc);
expect(result.fromCache).toBe(false);
expect(getDatabaseIdFromDoc(canonicalDoc)).toBe(databaseId);
expect(mockFetchPageCollab).toHaveBeenCalledWith('workspace-id', viewId);
expect(mockFetchPageCollab).toHaveBeenCalledWith('workspace-id', viewId, { includeRows: false });
expect(mockFetchDatabaseCollab).not.toHaveBeenCalled();
});

it('loads complete database row orders without row snapshots before the layout or database ID is known', async () => {
const viewId = 'unknown-database-view';
const databaseId = 'canonical-database';
const doc = createEmptyDoc(viewId);
const serverDoc = createCompleteDatabaseDoc(databaseId, databaseId, viewId);
const database = serverDoc.getMap(YjsEditorKey.data_section).get(YjsEditorKey.database) as Y.Map<unknown>;
const views = database.get(YjsDatabaseKey.views) as Y.Map<Y.Map<unknown>>;
const rowOrders = new Y.Array();

rowOrders.push([{ id: 'row-1' }, { id: 'row-2' }]);
views.get(viewId)?.set(YjsDatabaseKey.row_orders, rowOrders);
mockOpenCollabDB.mockResolvedValue(doc);
mockFetchPageCollab.mockResolvedValue({ data: Y.encodeStateAsUpdate(serverDoc), rows: {} });

const result = await openView('workspace-id', viewId);
const loadedDatabase = result.doc.getMap(YjsEditorKey.data_section).get(YjsEditorKey.database) as Y.Map<unknown>;
const loadedViews = loadedDatabase.get(YjsDatabaseKey.views) as Y.Map<Y.Map<unknown>>;
const loadedRowOrders = loadedViews.get(viewId)?.get(YjsDatabaseKey.row_orders) as Y.Array<unknown>;

expect(result.collabType).toBe(Types.Database);
expect(getDatabaseIdFromDoc(result.doc)).toBe(databaseId);
expect(loadedRowOrders.toJSON()).toEqual([{ id: 'row-1' }, { id: 'row-2' }]);
expect(mockFetchPageCollab).toHaveBeenCalledWith('workspace-id', viewId, { includeRows: false });
expect(mockFetchDatabaseCollab).not.toHaveBeenCalled();
});

it('fetches only the canonical database collab for metadata-only relation loads', async () => {
Expand Down
160 changes: 160 additions & 0 deletions src/application/database-blob/__tests__/prefetch-dedup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import {
prefetchDatabaseBlobDiff,
clearDatabaseRowDocSeedCache,
invalidateDatabaseRowDocSeed,
retainDatabaseRowDocSeedCache,
releaseDatabaseRowDocSeedCache,
takeDatabaseRowDocSeed,
} from '@/application/database-blob';
import * as pageStageModule from '@/application/database-blob/page-stage';
Expand Down Expand Up @@ -359,6 +361,164 @@ describe('database blob prefetch deduplication', () => {
});
});

it('shares an admission retry and commits no seeds until the unchanged continuation succeeds', async () => {
jest.useFakeTimers();
jest.spyOn(Math, 'random').mockReturnValue(0.5);
const workspaceId = 'workspace-overloaded';
const databaseId = 'database-overloaded';
const nextCursor = new Uint8Array([4, 5]);
const originalRid = { timestamp: 10, seqNo: 1 };
const onSeedsReady = jest.fn();

databaseIds.add(databaseId);
localStorage.setItem(`af_database_blob_rid:${databaseId}`, JSON.stringify(originalRid));
mockedDatabaseBlobDiff
.mockResolvedValueOnce(persistablePage({ timestamp: 20, seqNo: 1 }, { hasMore: true, nextCursor }))
.mockRejectedValueOnce({ code: 1079, httpStatus: 200, message: 'Busy', retryAfterSecs: 2 })
.mockResolvedValueOnce(readyDiff());
const first = prefetchDatabaseBlobDiff(workspaceId, databaseId, { onSeedsReady });
const second = prefetchDatabaseBlobDiff(workspaceId, databaseId);

await jest.advanceTimersByTimeAsync(2999);
expect(mockedDatabaseBlobDiff).toHaveBeenCalledTimes(2);
expect(onSeedsReady).not.toHaveBeenCalled();
expect(mockedOpenRowCollabDB).not.toHaveBeenCalled();
expect(takeDatabaseRowDocSeed(`${databaseId}_rows_${VALID_ROW_ID}`)).toBeNull();
expect(JSON.parse(localStorage.getItem(`af_database_blob_rid:${databaseId}`) ?? 'null')).toEqual(originalRid);
await jest.advanceTimersByTimeAsync(1);
await Promise.all([first, second]);
expect(mockedDatabaseBlobDiff).toHaveBeenCalledTimes(3);
expect(mockedDatabaseBlobDiff.mock.calls[2][2]).toBe(mockedDatabaseBlobDiff.mock.calls[1][2]);
expect(mockedDatabaseBlobDiff.mock.calls[2][2].maxKnownRid).toMatchObject(originalRid);
expect(onSeedsReady).toHaveBeenCalledTimes(1);
expect(mockedOpenRowCollabDB).toHaveBeenCalledTimes(1);
expect(JSON.parse(localStorage.getItem(`af_database_blob_rid:${databaseId}`) ?? 'null')).toEqual({
timestamp: 20,
seqNo: 1,
});
});

it('bounds admission retries and discards provisional pages without signaling row fallback', async () => {
jest.useFakeTimers();
jest.spyOn(Math, 'random').mockReturnValue(0);
const databaseId = 'database-overload-exhausted';
const onSeedsReady = jest.fn();
const overloaded = { code: 1079, message: 'Busy' };
const { stage, clear } = createMockPageStage();

jest.spyOn(pageStageModule, 'createDatabaseBlobDiffPageStage').mockReturnValueOnce(stage);
databaseIds.add(databaseId);
mockedDatabaseBlobDiff
.mockResolvedValueOnce(
persistablePage({ timestamp: 20, seqNo: 1 }, { hasMore: true, nextCursor: new Uint8Array([1]) })
)
.mockRejectedValue(overloaded);
const result = prefetchDatabaseBlobDiff('workspace', databaseId, { onSeedsReady }).catch((error) => error);

await jest.runAllTimersAsync();
expect(await result).toBe(overloaded);
expect(mockedDatabaseBlobDiff).toHaveBeenCalledTimes(5);
expect(onSeedsReady).not.toHaveBeenCalled();
expect(mockedOpenRowCollabDB).not.toHaveBeenCalled();
expect(localStorage.getItem(`af_database_blob_rid:${databaseId}`)).toBeNull();
expect(takeDatabaseRowDocSeed(`${databaseId}_rows_${VALID_ROW_ID}`)).toBeNull();
expect(clear).toHaveBeenCalledTimes(1);
});

it('cancels a backpressure wait only after the last database owner releases it', async () => {
jest.useFakeTimers();
const databaseId = 'database-overload-cancel';
const onSeedsReady = jest.fn();

databaseIds.add(databaseId);
retainDatabaseRowDocSeedCache(databaseId);
retainDatabaseRowDocSeedCache(databaseId);
mockedDatabaseBlobDiff.mockRejectedValue({ code: 1079, message: 'Busy' });
const result = prefetchDatabaseBlobDiff('workspace', databaseId, { onSeedsReady }).catch((error) => error);

await jest.advanceTimersByTimeAsync(0);
const signal = mockedDatabaseBlobDiff.mock.calls[0][3]?.signal;

releaseDatabaseRowDocSeedCache(databaseId);
expect(signal?.aborted).toBe(false);
releaseDatabaseRowDocSeedCache(databaseId);
expect(signal?.aborted).toBe(true);
expect(await result).toMatchObject({ name: 'AbortError' });
await jest.runAllTimersAsync();
expect(mockedDatabaseBlobDiff).toHaveBeenCalledTimes(1);
expect(onSeedsReady).not.toHaveBeenCalled();
expect(mockedOpenRowCollabDB).not.toHaveBeenCalled();

mockedDatabaseBlobDiff.mockResolvedValueOnce(readyDiff());
await prefetchDatabaseBlobDiff('workspace', databaseId);
expect(mockedDatabaseBlobDiff).toHaveBeenCalledTimes(2);
});

it.each([
{ code: 1012, message: 'Denied' },
{ code: 1079, message: 'Busy', retryAfterSecs: 60 },
])('does not retry permanent errors or violate a long server cooldown: %p', async (error) => {
const databaseId = `database-no-retry-${error.code}`;

databaseIds.add(databaseId);
mockedDatabaseBlobDiff.mockRejectedValue(error);
await expect(prefetchDatabaseBlobDiff('workspace', databaseId)).rejects.toBe(error);
expect(mockedDatabaseBlobDiff).toHaveBeenCalledTimes(1);
});

it('reuses a terminal walk during close and reopen while staged seeds are being committed', async () => {
const databaseId = 'database-reopen-during-commit';
const diff = persistablePage({ timestamp: 20, seqNo: 1 });
const stagedRead = createDeferred<Uint8Array>();
const { stage, read } = createMockPageStage();
const reopenedSeedsReady = jest.fn();

jest.spyOn(pageStageModule, 'createDatabaseBlobDiffPageStage').mockReturnValueOnce(stage);
read.mockReturnValueOnce(stagedRead.promise);
databaseIds.add(databaseId);
retainDatabaseRowDocSeedCache(databaseId);
mockedDatabaseBlobDiff.mockResolvedValueOnce(diff);
const first = prefetchDatabaseBlobDiff('workspace', databaseId);

await flushPendingWork();
expect(read).toHaveBeenCalledTimes(1);
const signal = mockedDatabaseBlobDiff.mock.calls[0][3]?.signal;

releaseDatabaseRowDocSeedCache(databaseId);
expect(signal?.aborted).toBe(false);
retainDatabaseRowDocSeedCache(databaseId);
const reopened = prefetchDatabaseBlobDiff('workspace', databaseId, { onSeedsReady: reopenedSeedsReady });

expect(mockedDatabaseBlobDiff).toHaveBeenCalledTimes(1);
stagedRead.resolve(database_blob.DatabaseBlobDiffResponse.encode(diff).finish());
await Promise.all([first, reopened]);
expect(reopenedSeedsReady).toHaveBeenCalledTimes(1);
expect(mockedOpenRowCollabDB).toHaveBeenCalledTimes(1);
expect(JSON.parse(localStorage.getItem(`af_database_blob_rid:${databaseId}`) ?? 'null')).toEqual({
timestamp: 20,
seqNo: 1,
});
releaseDatabaseRowDocSeedCache(databaseId);
});

it('discards a late response after its last owner cancels the in-flight request', async () => {
const databaseId = 'database-cancel-in-flight';
const onSeedsReady = jest.fn();
const response = createDeferred<database_blob.DatabaseBlobDiffResponse>();

databaseIds.add(databaseId);
retainDatabaseRowDocSeedCache(databaseId);
mockedDatabaseBlobDiff.mockReturnValueOnce(response.promise);
const result = prefetchDatabaseBlobDiff('workspace', databaseId, { onSeedsReady }).catch((error) => error);

releaseDatabaseRowDocSeedCache(databaseId);
response.resolve(persistablePage({ timestamp: 20, seqNo: 1 }));
expect(await result).toMatchObject({ name: 'AbortError' });
expect(onSeedsReady).not.toHaveBeenCalled();
expect(mockedOpenRowCollabDB).not.toHaveBeenCalled();
expect(localStorage.getItem(`af_database_blob_rid:${databaseId}`)).toBeNull();
});

it('retries a Pending page with the same cursor', async () => {
jest.useFakeTimers();

Expand Down
40 changes: 33 additions & 7 deletions src/application/database-blob/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ import { database_blob } from '@/proto/database_blob';
import { Log } from '@/utils/log';

import { createDatabaseBlobDiffPageStage, type DatabaseBlobDiffPageStage } from './page-stage';
import {
throwIfDatabaseBlobAborted,
waitForDatabaseBlobRetry,
withDatabaseBlobBackpressureRetry,
} from './request-retry';
import {
createDatabaseRowDocSeed,
invalidateDatabaseRowDocSeedGeneration,
Expand All @@ -43,6 +48,9 @@ type PrefetchOptions = {
};

type SharedPrefetchEntry = {
abortController: AbortController;
/** A terminal page walk must finish committing before a replacement starts. */
fetching: boolean;
priorityRowIds: Set<string>;
/** Rows reset after this prefetch started must not consume its stale snapshot. */
invalidatedRowIds: Set<string>;
Expand Down Expand Up @@ -127,10 +135,6 @@ function sharedPrefetchEntryMatchesDatabase(sharedKey: string, databaseId: strin
return sharedKey.includes(`:${databaseId}:`) || sharedKey.endsWith(`:${databaseId}`);
}

function sleep(ms: number): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}

function retryDelayMs(retryAfterSecs?: number | null): number {
if (!retryAfterSecs || retryAfterSecs <= 0) return BLOB_DIFF_DEFAULT_RETRY_MS;
return Math.min(retryAfterSecs * 1000, BLOB_DIFF_MAX_RETRY_MS);
Expand Down Expand Up @@ -423,6 +427,12 @@ export function clearDatabaseRowDocSeedCache(databaseId: string) {
entry.onSeedsReadyCallbacks.clear();

if (entry.promise && !entry.settled) {
// A terminal walk may still persist its committed seeds. Cancel only
// provisional network work when no view retains this database.
if (entry.fetching && (rowDocSeedCacheRetainCounts.get(databaseId) ?? 0) === 0) {
entry.abortController.abort();
}

clearSharedPrefetchEntryAfterSettle(databaseId, key, entry);
hasUnsettledPrefetch = true;
}
Expand Down Expand Up @@ -1060,6 +1070,7 @@ async function fetchReadyDiff(
options: {
cachedRid: DatabaseBlobRowRid | null;
forceFullSync?: boolean;
signal: AbortSignal;
}
): Promise<FetchDiffResult> {
const cachedRid = options.cachedRid;
Expand Down Expand Up @@ -1098,7 +1109,10 @@ async function fetchReadyDiff(

for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const attemptStartedAt = Date.now();
const diff = await databaseBlobDiff(workspaceId, databaseId, request);
const diff = await withDatabaseBlobBackpressureRetry(
() => databaseBlobDiff(workspaceId, databaseId, request, { signal: options.signal }),
options.signal
);

Log.debug('[Database] blob diff page response', {
databaseId,
Expand Down Expand Up @@ -1187,6 +1201,7 @@ async function fetchReadyDiff(
}

if (!page.hasMore) {
throwIfDatabaseBlobAborted(options.signal);
return { diff, ready: true, stagedPages };
}

Expand Down Expand Up @@ -1225,7 +1240,7 @@ async function fetchReadyDiff(
message: diff.message ?? null,
});

await sleep(delayMs);
await waitForDatabaseBlobRetry(delayMs, options.signal);
}
}
} catch (error) {
Expand All @@ -1245,7 +1260,10 @@ export async function prefetchDatabaseBlobDiff(workspaceId: string, databaseId:
existingEntry.reuseSettled && existingEntry.settled && existingEntry.hasCompleteSeedSet
);

if (!existingEntry.settled || canReuseSettledFullSeed || canReuseSettledSeed) {
if (
!existingEntry.abortController.signal.aborted &&
(!existingEntry.settled || canReuseSettledFullSeed || canReuseSettledSeed)
) {
applyPrefetchOptions(existingEntry, options);

if (canReuseSettledSeed && !options?.reuseSettled) {
Expand All @@ -1267,6 +1285,8 @@ export async function prefetchDatabaseBlobDiff(workspaceId: string, databaseId:
const outboxSession = getCurrentOutboxSession(workspaceId);
const cachedRid = options?.forceFullSync ? null : readCachedRid(databaseId);
const entry: SharedPrefetchEntry = {
abortController: new AbortController(),
fetching: true,
priorityRowIds: new Set(),
invalidatedRowIds: new Set(),
onSeedsReadyCallbacks: new Set(),
Expand Down Expand Up @@ -1300,9 +1320,12 @@ export async function prefetchDatabaseBlobDiff(workspaceId: string, databaseId:
const { diff, ready, stagedPages } = await fetchReadyDiff(workspaceId, databaseId, {
cachedRid,
forceFullSync: options?.forceFullSync,
signal: entry.abortController.signal,
});

entry.fetching = false;
if (!ready) {
throwIfDatabaseBlobAborted(entry.abortController.signal);
notifySeedsReady(entry);
return diff;
}
Expand All @@ -1316,6 +1339,9 @@ export async function prefetchDatabaseBlobDiff(workspaceId: string, databaseId:
}

try {
// Cancellation can race the terminal response's promise continuation.
// Once replay starts, retain this entry until its seeds and RID commit.
throwIfDatabaseBlobAborted(entry.abortController.signal);
if (pageCount === 0) {
throw new Error('database blob diff paging protocol error: Ready walk did not stage any pages');
}
Expand Down
Loading
Loading