Fix full-text indexing failing silently after periodic vacuum or backup
Some checks failed
CI / Build, Upload, Test (push) Has been cancelled

vacuum() and the APFS-cloning offline backup path both close and
reopen the SQLite connection, which drops all ATTACHed databases --
including the in-memory "indexing" alias used for the fulltextWords
scratch table set up in Fulltext.init(). Once the connection is
reopened, indexing queries fail with "no such table:
indexing.fulltextWords", and indexItems()'s ignoreErrors path
routes the error to logError(), so indexing silently stops working
for the rest of the session.

Add an onConnect() hook on Zotero.DBConnection for per-connection
state that doesn't persist across reopens, and use it from
Fulltext.init() to re-attach the indexing DB on each reconnect.

Regression sources:

- 67288047f3 ("Use APFS cloning for file copies on macOS") flips
  online idle backups to offline-with-clone on APFS, so every idle
  backup interval (24h default) closes and reopens the connection.
  Affects Mac users on APFS.
- b27c4cb023 ("Enable SQLite WAL mode and add periodic VACUUM
  INTO") adds the vacuum path, which closes and reopens on the
  first idle period in a session that passes the freelist/time
  gates. Affects all users with sufficient DB churn, roughly once
  per 14 days.

https://forums.zotero.org/discussion/131576/debug-id-d848621212-indexing-of-pdfs-fail-zotero-9-0-3
https://forums.zotero.org/discussion/131718/possible-bug-regression-report-search-unusable-on-macos-zotero-9-0-3-with-large-library
(cherry picked from commit 70c4cadab1)
This commit is contained in:
Dan Stillman 2026-05-22 10:54:00 -04:00
parent 6881089b1b
commit 74a3617e7e
4 changed files with 58 additions and 5 deletions

View file

@ -105,7 +105,8 @@ Zotero.DBConnection = function (dbNameOrPath) {
};
this._dbIsCorrupt = null
this._onlineBackupInProgress = false;
this._onConnectCallbacks = [];
this._transactionPromise = null;
if (dbNameOrPath == 'zotero') {
@ -987,6 +988,21 @@ Zotero.DBConnection.prototype.closeDatabase = async function (permanent) {
};
/**
* Register a callback to run after the SQLite connection is opened, including after it has
* been closed and reopened (e.g., after vacuum()). Use this for per-connection state that
* doesn't persist across reopens, such as ATTACHed databases or temp tables.
*
* The callback is not invoked for the connection that's already open at registration time --
* the caller is responsible for any initial setup.
*
* @param {Function} callback - Async function called with no arguments after each (re)open
*/
Zotero.DBConnection.prototype.onConnect = function (callback) {
this._onConnectCallbacks.push(callback);
};
/**
* @deprecated
*/
@ -1302,7 +1318,16 @@ Zotero.DBConnection.prototype._getConnectionAsync = async function () {
idleService.addIdleObserver(this, 300);
});
}
for (let callback of this._onConnectCallbacks) {
try {
await callback();
}
catch (e) {
Zotero.logError(e);
}
}
return this._connection;
};

View file

@ -61,9 +61,15 @@ Zotero.Fulltext = Zotero.FullText = new function () {
var _syncLibraryVersion = 0;
this.init = async function () {
await Zotero.DB.queryAsync("ATTACH ':memory:' AS 'indexing'");
await Zotero.DB.queryAsync('CREATE TABLE indexing.fulltextWords (word NOT NULL)');
let setUpIndexingDB = async () => {
await Zotero.DB.queryAsync("ATTACH ':memory:' AS 'indexing'");
await Zotero.DB.queryAsync('CREATE TABLE indexing.fulltextWords (word NOT NULL)');
};
await setUpIndexingDB();
// ATTACHed databases don't survive a connection reopen (e.g., after vacuum), so
// re-run the setup on every reconnect
Zotero.DB.onConnect(setUpIndexingDB);
let pdfConverterFileName = "pdftotext";
let pdfInfoFileName = "pdfinfo";

View file

@ -453,4 +453,16 @@ describe("Zotero.DB", function () {
assert.isTrue(await IOUtils.exists(bakFile));
});
});
describe("#onConnect()", function () {
it("should run registered callbacks after the connection is reopened", async function () {
let count = 0;
Zotero.DB.onConnect(async () => {
count++;
});
await Zotero.DB.closeDatabase();
await Zotero.DB.valueQueryAsync("SELECT 1");
assert.equal(count, 1);
});
});
});

View file

@ -62,6 +62,16 @@ describe("Zotero.FullText", function () {
);
});
it("should still work after the DB connection is reopened", async function () {
var item = await importFileAttachment('test.txt');
await Zotero.DB.vacuum({ force: true });
await Zotero.Fulltext.indexItems([item.id]);
assert.equal(
((await Zotero.Fulltext.getIndexedState(item))),
Zotero.Fulltext.INDEX_STATE_INDEXED
);
});
describe("Indexing with HiddenBrowser", () => {
it("should index attachment as its attachmentContentType when supported", async function () {
// Firefox would normally load this as text/x-shellscript, but we detect text/plain