diff --git a/client/content_manager.ts b/client/content_manager.ts index b7ef9aae..1c214dc9 100644 --- a/client/content_manager.ts +++ b/client/content_manager.ts @@ -342,9 +342,14 @@ export class ContentManager { // This could create an invalid editor state, but that doesn't matter, we'll update it later this.switchToPageEditor(); - await this.client.pageMetaAugmenter.setAugmentation(pageName, { - lastOpened: Date.now(), - }); + // Record last-opened time best-effort and non-blocking: this is a write + // to the shared index store, and awaiting it would stall navigation + // behind an in-progress reindex for no user-visible benefit. + void this.client.pageMetaAugmenter + .setAugmentation(pageName, { lastOpened: Date.now() }) + .catch((e) => + console.warn("Could not record lastOpened for", pageName, e), + ); this.client.ui.viewDispatch({ type: "page-loaded", diff --git a/client/data/object_index.ts b/client/data/object_index.ts index 55de8fd1..e044ded2 100644 --- a/client/data/object_index.ts +++ b/client/data/object_index.ts @@ -29,8 +29,14 @@ const pageKey = "ridx"; const indexVersionKey = ["$indexVersion"]; +// Set while a full reindex is running and cleared on completion. Survives +// the version-key deletion a reindex performs at its start, so an +// `undefined` index version can be disambiguated: a fresh install has no +// marker, an interrupted reindex left one behind. +const reindexInProgressKey = ["$reindexInProgress"]; + // Bump this one every time a full reindex is needed -const desiredIndexVersion = 11; +const desiredIndexVersion = 12; type TagDefinition = { tagPage?: string; @@ -337,12 +343,27 @@ export class ObjectIndex { async ensureFullIndex(space: Space) { const currentIndexVersion = await this.getCurrentIndexVersion(); - if (currentIndexVersion === undefined) { - console.log("No index version found, assuming fresh install"); + // Fast path: the index is present and already at the desired version. + if ( + currentIndexVersion !== undefined && + currentIndexVersion >= desiredIndexVersion + ) { return; } - if (currentIndexVersion >= desiredIndexVersion) { + // An `undefined` version is ambiguous. A genuinely fresh install builds + // its index lazily as sync streams files in (handled by the + // constructor's one-shot empty-queue handler), so there's nothing to do + // here. But a reindex *also* deletes the version key at its start, so an + // `undefined` version that still carries the in-progress marker means a + // prior reindex was interrupted (e.g. the window was closed mid-reindex) + // and must be resumed — otherwise the index stays permanently empty and + // `ensureFullIndex` would keep mistaking it for a fresh install. + if ( + currentIndexVersion === undefined && + !(await this.isReindexInProgress()) + ) { + console.log("No index version found, assuming fresh install"); return; } @@ -358,30 +379,54 @@ export class ObjectIndex { // being indexed) so we don't fight the worker over the queue. await this.mq.awaitEmptyQueue("indexQueue"); - // Re-read the version in case a concurrent path already bumped it - // while we were waiting. - const versionNow = await this.getCurrentIndexVersion(); - if (versionNow !== undefined && versionNow >= desiredIndexVersion) { - return; - } + // Serialize the reindex across every open window/tab: the whole + // space lives in a single shared IndexedDB object store, and two + // windows clearing + rewriting it concurrently serialize on that + // one store and can wedge each other (one window's `get` blocks + // forever behind the other's bulk delete). + await this.withLock(async () => { + // Re-read the version now that we hold the lock — another window + // (or a concurrent path here) may have already completed the bump + // while we were queued behind it. + const versionNow = await this.getCurrentIndexVersion(); + if (versionNow !== undefined && versionNow >= desiredIndexVersion) { + return; + } - console.info( - "[index]", - "Performing a full space reindex, this could take a while...", - currentIndexVersion, - desiredIndexVersion, - ); + console.info( + "[index]", + "Performing a full space reindex, this could take a while...", + currentIndexVersion, + desiredIndexVersion, + ); - await this.reindexSpace(space); + await this.reindexSpaceUnlocked(space); - // Dispatch an editor:reloadState event to reload the editor state (render widgets etc.) - void this.eventHook.dispatchEvent("editor:reloadState"); + // Dispatch an editor:reloadState event to reload the editor state (render widgets etc.) + void this.eventHook.dispatchEvent("editor:reloadState"); + }); } finally { this.reindexingForVersionBump = false; } } - async reindexSpace(space: Space) { + /** + * Full space reindex. Public entry point (used by the manual + * "Space: Reindex" command and the `index.reindexSpace` syscall) — takes + * the cross-window lock so it can't race a concurrent reindex (auto or + * manual) running in another window. The auto path (`ensureFullIndex`) + * already holds the lock, so it calls `reindexSpaceUnlocked` directly to + * avoid re-entering the (non-reentrant) lock and deadlocking on itself. + */ + reindexSpace(space: Space): Promise { + return this.withLock(() => this.reindexSpaceUnlocked(space)); + } + + private async reindexSpaceUnlocked(space: Space) { + // Record that a reindex is underway *before* we delete the version key, + // so an interruption between here and `markFullIndexComplete` is + // recoverable (see `ensureFullIndex`). + await this.markReindexInProgress(); console.log("Clearing page index..."); await this.clearIndex(); await this.markFullIndexInComplete(); @@ -400,6 +445,18 @@ export class ObjectIndex { console.log("Full index completed after", Date.now() - startTime, "ms"); } + /** + * Cross-window mutual exclusion for global reindex work. + */ + private withLock(fn: () => Promise): Promise { + const locks = (navigator as any)?.locks; + if (!locks?.request) { + return fn(); + } + const dbName = (this.ds.kv as any).dbName ?? "default"; + return locks.request(`sb-reindex:${dbName}`, fn); + } + public async hasFullIndexCompleted() { return (await this.ds.get(indexVersionKey)) >= desiredIndexVersion; } @@ -423,12 +480,22 @@ export class ObjectIndex { async markFullIndexComplete() { await this.ds.set(indexVersionKey, desiredIndexVersion); + // The index is whole again — drop the interrupted-reindex marker. + await this.ds.delete(reindexInProgressKey); } async markFullIndexInComplete() { await this.ds.delete(indexVersionKey); } + private markReindexInProgress(): Promise { + return this.ds.set(reindexInProgressKey, true); + } + + private async isReindexInProgress(): Promise { + return (await this.ds.get(reindexInProgressKey)) === true; + } + cleanKey(ref: string, page: string) { if (ref.startsWith(`${page}@`)) { return ref.substring(page.length + 1); @@ -529,7 +596,11 @@ export class ObjectIndex { for await (const { key } of this.ds.query({ prefix: [pageKey] })) { allKeys.push(key); } - await this.ds.batchDelete(allKeys); + // Delete in chunks rather than as one giant transaction. + const deleteChunkSize = 500; + for (let i = 0; i < allKeys.length; i += deleteChunkSize) { + await this.ds.batchDelete(allKeys.slice(i, i + deleteChunkSize)); + } console.log("Deleted", allKeys.length, "keys from the index"); } diff --git a/website/CHANGELOG.md b/website/CHANGELOG.md index 996c6d86..2037e986 100644 --- a/website/CHANGELOG.md +++ b/website/CHANGELOG.md @@ -22,6 +22,8 @@ Whenever a commit is pushed to the `main` branch, within ~5 minutes, it will be * Space Lua: added `spacelua.prettyPrintBlock` / `spacelua.prettyPrintExpression` to pretty-print a parsed Lua AST back to formatted source. Supports `indentWidth`, `quote` and `trailingComma` options. In preparation of future functionality that will manipulate existing Lua code. * `index.contentPages` now accepts an optional `tag` argument to filter content pages by an additional tag, matching the other type-specific [[API/index]] helpers. * Fix: forced space reindex handling +* Fix: with two windows/tabs open on the same space, a full reindex (e.g. after an index-version bump) could deadlock IndexedDB. +* Fix: an interrupted full reindex (e.g. the window closed mid-reindex) no longer leaves the space permanently un-indexed. * Fix: Clicking a wiki link to a page now places the cursor just after the page's frontmatter on first visit (matching fresh-load behavior), instead of at position 0. Pages already visited in the session still restore their previously saved cursor position. * Fix: `$`-anchor refs now resolve through the index from every navigation path * Fix: Ctrl/Cmd-clicking a link inside rendered widgets (query/template results) now navigates in a new window via the normal navigation path