Fix: indexing wouldn't always kick off, or get deadlocked with multiple tabs open
This commit is contained in:
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user