Fix: indexing wouldn't always kick off, or get deadlocked with multiple tabs open

This commit is contained in:
Zef Hemel
2026-06-11 08:35:45 +02:00
parent a220e1b0a7
commit 2e4e850020
3 changed files with 102 additions and 24 deletions
+8 -3
View File
@@ -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",
+92 -21
View File
@@ -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<void> {
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<T>(fn: () => Promise<T>): Promise<T> {
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<void> {
return this.ds.set(reindexInProgressKey, true);
}
private async isReindexInProgress(): Promise<boolean> {
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");
}
+2
View File
@@ -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