Skip to content

[Bug]: QueueFS list_unacked builds a sort it does not need; an unusable temp dir makes the server refuse to start #6127

Open

Description

@godxxs

Issue Origin

Observed or reproduced in a real environment

Bug Description

SQLiteQueueBackend::list_unacked reads the whole unacknowledged list with

SELECT data FROM queue_messages
 WHERE queue_name = ?1 AND status IN ('pending', 'processing')
 ORDER BY id

(crates/ragfs/src/plugins/queuefs/backend.rs:782-784).

The same file creates idx_queue_status ON queue_messages(queue_name, status, id)
(backend.rs:463) — note the column order. Because the predicate matches two status
values, that index cannot deliver rows in id order, so SQLite adds a sort:

SEARCH queue_messages USING INDEX idx_queue_status (queue_name=? AND status=?)
USE TEMP B-TREE FOR ORDER BY

The sort is unnecessary: rows are already stored in id order, and id is the
INTEGER PRIMARY KEY. The sorter also spills to a temporary file once the queue passes
roughly a megabyte, which turns a cosmetic inefficiency into a hard failure.

Steps to Reproduce

  1. Populate one queue with ~132 messages of 16 KB each (about 8.7 MB unacknowledged).
  2. Make the process temporary directory unusable for SQLite (this is the environment, not
    the trigger — see Additional Context).
  3. Call list_unacked, or start the server, which calls snapshot() for every queue during
    QueueManager.prepare_task_tracking.

Expected Behavior

The unacknowledged list is read without a sort, because the index can serve ORDER BY id
directly. Startup does not depend on a writable temporary directory.

Actual Behavior

internal error: sqlite list unacked row error: unable to open database file

The database is intact (PRAGMA quick_check returns ok) and small queues read fine — only
a queue large enough to force the sorter to disk fails. The message names the wrong cause.

Because task recovery reads every queue before any consumer starts, a large queue prevents
the server from starting at all (Application startup failed). A queue only drains while the
server runs, so the state never heals on its own; the only exit is deleting the queued
messages, which loses unprocessed work.

Minimal Reproducible Example

import json, os, sqlite3

# any directory SQLite cannot write to
os.environ["TEMP"] = r"C:\Temp"

con = sqlite3.connect("queue.db")
con.execute("""CREATE TABLE queue_messages (
    id INTEGER PRIMARY KEY AUTOINCREMENT, queue_name TEXT NOT NULL,
    message_id TEXT NOT NULL, data TEXT NOT NULL, timestamp INTEGER NOT NULL,
    status TEXT NOT NULL DEFAULT 'pending', processing_started_at INTEGER,
    created_at INTEGER DEFAULT (strftime('%s','now')))""")
con.execute("CREATE INDEX idx_queue_status ON queue_messages(queue_name, status, id)")

blob = "x" * 16384
for i in range(132):
    con.execute("INSERT INTO queue_messages (queue_name, message_id, data, timestamp)"
                " VALUES ('Embedding', ?, ?, 0)",
                ("m%d" % i, json.dumps({"id": "m%d" % i, "data": blob})))
con.commit()

q = ("SELECT data FROM queue_messages WHERE queue_name = ?"
     " AND status IN ('pending','processing') ORDER BY id")

print([r[3] for r in con.execute("EXPLAIN QUERY PLAN " + q, ("Embedding",))])
print(len(con.execute(q, ("Embedding",)).fetchall()))   # sqlite3.OperationalError

# Recreating the index in a sort-free column order removes both the sort and the failure:
con.execute("DROP INDEX idx_queue_status")
con.execute("CREATE INDEX idx_queue_status ON queue_messages(queue_name, id, status)")
con.commit()
# plan becomes: SEARCH queue_messages USING INDEX idx_queue_status (queue_name=?)
# and the same query reads all 132 rows (8 674 710 bytes) with the same unusable TEMP

Error Logs

E openviking.service.task: Application startup failed
   internal error: sqlite list unacked row error: unable to open database file

OpenViking Version

0.5.0

Python Version

3.13.15

Operating System

Windows

Model Backend

Other

Additional Context

Model Backend
Other — local llama-server (llama.cpp) behind an OpenAI-compatible endpoint

Impact measured: 132 unacknowledged messages, 7 946 417 bytes; the server failed to
start, and search returned HTTP 500 for the duration.

Failure threshold: between 564 630 and 2 186 646 bytes of queued data, consistent with
SQLITE_SORTER_PMASZ (250 pages x 4096-byte pages).

Suggested fix — three parts.

  1. Reorder the index so ORDER BY id is satisfied by it; status remains a residual filter:
-            "CREATE INDEX IF NOT EXISTS idx_queue_status ON queue_messages(queue_name, status, id)",
+            "CREATE INDEX IF NOT EXISTS idx_queue_status ON queue_messages(queue_name, id, status)",
  1. Migrate existing databases. CREATE INDEX IF NOT EXISTS leaves the old definition in
    place, so the change above does not repair an installed instance. Something like this in
    SQLiteQueueBackend::open, after the schema batch:
let stale: i64 = conn.query_row(
    "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index'"
      " AND name = 'idx_queue_status' AND sql NOT LIKE '%queue_name, id, status%'",
    [], |row| row.get(0),
)?;
if stale > 0 {
    conn.execute_batch(
        "DROP INDEX IF EXISTS idx_queue_status;
         CREATE INDEX idx_queue_status ON queue_messages(queue_name, id, status);",
    )?;
}
  1. Do not let this path depend on a writable temporary directory: either set
    PRAGMA temp_store = MEMORY at connection setup, or keep the query sort-free as above.

Environment note worth knowing: SQLITE_TMPDIR is not honoured on this path. Setting it
while TEMP/TMP point at an unusable directory still fails, so an operator cannot work
around this from the environment.

Diagnostic note: Error::internal(format!("sqlite list unacked row error: {}", e))
(backend.rs:794) hides which file operation failed. Including the SQLite extended result
code — SQLITE_CANTOPEN here — would have turned a long diagnosis into a one-minute one.

Suggested area / owners (from CONTRIBUTING.md): Storage → RAGFS, PathLock, QueueFS,
crates/ragfs* → @baojun-zhang.

Activity

  1. added
    kernelCore server, runtime, storage, retrieval, SDK, and CLI.
    on Oct 16, 2026
  2. moved this from Backlog to In progress in OpenViking projecton Oct 16, 2026
  3. Tong-bit-art commented on Oct 16, 2026

    @Tong-bit-art
    Contributor

    Claiming this with a PR so it is not picked up twice.

    One thing worth recording here, because it shaped the fix and is easy to get wrong:
    the new index has to have its own name.

    run_migrations uses CREATE INDEX IF NOT EXISTS, so changing the column order of
    idx_queue_status would be a no-op on any database that already has that index — which is
    exactly the population hitting this failure. Verified against SQLite:

    existing database (old schema), then re-issuing
      CREATE INDEX IF NOT EXISTS idx_queue_status ON queue_messages(queue_name, id)
    still resolves to:
      CREATE INDEX idx_queue_status ON queue_messages(queue_name, status, id)
    and USE TEMP B-TREE FOR ORDER BY remains
    

    So the PR adds idx_queue_unacked (queue_name, id) rather than reordering the old index.
    That also leaves the four single-status queries (dequeue next, peek, count pending,
    last enqueue) on idx_queue_status, whose plans are unchanged.

    /explain for both a fresh database and one migrated from the old schema is in the PR
    description. I could not compile Rust in my environment (no cargo, no C++/CMake here) —
    the SQL behaviour is verified empirically, the Rust edit is a string literal plus test
    assertions, and the build job should cover compilation. Saying that up front rather than
    implying a run I did not do.

    I cannot set the assignee field myself (no write access on this repository), so recording
    the link here instead. Happy to adjust the approach if you would rather resolve this
    differently.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingkernelCore server, runtime, storage, retrieval, SDK, and CLI.

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions