From 74a3617e7ea7ad9be21fe0973b0901f75d1df6b5 Mon Sep 17 00:00:00 2001 From: Dan Stillman Date: Fri, 22 May 2026 10:54:00 -0400 Subject: [PATCH] Fix full-text indexing failing silently after periodic vacuum or backup 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 70c4cadab17d7d4b12f100b7252bb092ef6d0216) --- chrome/content/zotero/xpcom/db.js | 29 +++++++++++++++++++++++-- chrome/content/zotero/xpcom/fulltext.js | 12 +++++++--- test/tests/dbTest.js | 12 ++++++++++ test/tests/fulltextTest.js | 10 +++++++++ 4 files changed, 58 insertions(+), 5 deletions(-) diff --git a/chrome/content/zotero/xpcom/db.js b/chrome/content/zotero/xpcom/db.js index 4553876936..31078fb4e2 100644 --- a/chrome/content/zotero/xpcom/db.js +++ b/chrome/content/zotero/xpcom/db.js @@ -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; }; diff --git a/chrome/content/zotero/xpcom/fulltext.js b/chrome/content/zotero/xpcom/fulltext.js index 52ee6428a7..4fcefe2b61 100644 --- a/chrome/content/zotero/xpcom/fulltext.js +++ b/chrome/content/zotero/xpcom/fulltext.js @@ -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"; diff --git a/test/tests/dbTest.js b/test/tests/dbTest.js index 63bd7afcf0..3876d17ebd 100644 --- a/test/tests/dbTest.js +++ b/test/tests/dbTest.js @@ -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); + }); + }); }); diff --git a/test/tests/fulltextTest.js b/test/tests/fulltextTest.js index 8a0a565277..29e2b9b435 100644 --- a/test/tests/fulltextTest.js +++ b/test/tests/fulltextTest.js @@ -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