# A failed SQLite integrity check on Windows that reported itself as a POSIX lock conflict

> Source: <https://dev.to/spranab/a-failed-sqlite-integrity-check-on-windows-that-reported-itself-as-a-posix-lock-conflict-obn>
> Published: 2026-10-08 15:11:10+00:00

For several hours on 2026-09-30, every memory write from a Hermes Agent install on a Windows 11 machine failed with this:

YantrikDB unavailable: remember failed: refusing to write: another SQLite library has C:/Users/<user>/AppData/Local/hermes/yantrikdb-memory-ml.db open in this process (issue #225 - POSIX locks are per process, so its unlock releases the engine's and the two writers would interleave WAL commits). Close that connection, check integrity and reopen the engine (its close may have unlinked the shared-memory file); use the engine API / a separate process for raw SQL.

The engine refusing those writes was [YantrikDB](https://github.com/yantrikos/yantrikdb) 0.23.1, a persistent memory engine for AI agents that I maintain (Rust core, Python bindings on PyPI). @pawelsu8 filed [issue #247](https://github.com/yantrikos/yantrikdb/issues/247) the same day, from Windows 11 build 26200, CPython 3.14.7 and stdlib sqlite3 3.53.1. The title is long, and every clause in it turned out to be accurate: "ForeignSqliteInstance latches writes off on Windows, where the foreign-instance detector is inert: a failed quick_check taints the store but reports as an in-process foreign SQLite library, with no in-process recovery."

The error is about POSIX advisory locks. They belong to the whole process, so if another SQLite library in the same process closes the same file, its unlock releases the engine's locks as well. That's the scenario the engine's in-process foreign-SQLite detector, `ForeignSqliteGuard`, looks for.

On Windows the detector does nothing. `ForeignSqliteGuard::new` sets `shm_path = None` on any target that isn't Linux or macOS, so `supported()` returns `false` and `scan()` returns immediately. The module's doc comment says so: "Windows locks are per handle and does not have the problem; there the detector reports `supported = false` and the mode is inert."

@pawelsu8 checked that against the running system. He dumped `stats()` on the affected machine and got `foreign_sqlite_supported: false`, `foreign_sqlite_active: false`, `foreign_sqlite_tainted: false`. Then he opened the same store with Python's stdlib `sqlite3`, read-only and read-write, with and without commits, and none of it ever set `active` or `tainted`. The detector was inert exactly as documented, which meant something else was refusing his writes.

`check_write()` doesn't look at the `active` flag the detector sets. It refuses on `tainted`, and `tainted` has a second writer with no platform gate: `note_integrity()`. The background materializer tracks SQLite's `PRAGMA data_version`, and when it notices a commit from outside the engine it runs `PRAGMA quick_check(1)` on a read connection and hands the result to `note_integrity()`. A failed check sets `tainted`.

On Windows that's the only thing that can set it, so every refusal of this kind on that platform was a failed integrity check. Both causes raised the same error type, `ForeignSqliteInstance`, with the same fixed message. That message described the cause that was impossible on Windows and told him to close a connection that couldn't exist. What had actually happened, a check saying the database might be corrupt, wasn't mentioned anywhere. He called that "a far more serious thing to be told about," and he was right.

The Python object exposes `close()`, but there was no `reopen()` or `recover()`. Opening the store from a second process got "database is locked." So the message's own advice, reopen the engine, couldn't be followed from inside the running host or from outside it. Restarting the whole application was the only thing that worked.

Reads kept succeeding the whole time, so nothing looked broken. Refused writes meant no commits, no commits meant no checkpoint, and the WAL file grew to 6,344,832 bytes next to a 6,307,840-byte database.

There was one more exit, which he found by reading the source: `set_foreign_sqlite_mode("warn")`. It resumes writes without re-checking integrity, and the setting persists, so every future open of that store runs with both refusal mechanisms disarmed. He used it cautiously and flagged it in the issue as worse than the docs make it look.

I confirmed the trace and marked it as a bug. My reply opened with: "Thanks for this one — tracing `check_write` down to `decide()` and pulling the `stats()` flags dump to confirm `supported=false` empirically is the kind of report that gets acted on fast."

The fix is commit `33d0be9`, 15 files changed, +404/-45, released as v0.23.2 (tagged 2026-10-02T07:04:44Z; `pip install -U yantrikdb`). Most of it maps directly onto the numbered requests at the end of his issue:

`IntegrityCheckFailed` error, whose message carries the actual `quick_check` result. `ForeignSqliteInstance` is now raised only by the in-process detector, so the Windows message above can't be produced at all anymore.`stats()` shows the cause too. There are new `integrity_tainted` and `integrity_checks_unconfirmed_since_boot` keys, and `foreign_sqlite_tainted` now reflects the detector alone.`ok` resumes writes with no restart. A repair made from another process is itself an outside commit, so the engine notices it and re-checks on its own.
The release note ends: "Reported by @pawelsu8 with a source-level trace, stats() evidence from the affected Windows machine, and test-coverage requests that shaped the fix. Thank you."

Testing the fix turned up a second bug. What I wrote to him: "Testing it turned up a second problem that explains your 'can't open it from another process' symptom."

While writes were refused, the materializer kept retrying its pending work every 100ms, on every worker, forever. Each attempt took the store's write lock and was then aborted at commit. Seen from any other process, the file was write-locked almost continuously, so his attempt to open the store separately got "database is locked" even with a 30-second busy timeout. So would any attempt to repair it.

That changes how the rest of the report reads. The refusal existed to protect the database until someone worked out what had gone wrong, and the engine's own retry loop was locking out every process that could have done the work. The materializer now pauses while writes are refused and picks back up once a check comes back clean.

Two new Python tests damage a store for real from a second process, by writing a row that violates a CHECK constraint (which `quick_check` reports), and assert that the engine recovers. One of them failed with exactly "database is locked" before the materializer change went in.

The engine now re-runs a failed `quick_check` once, refuses only if it fails again, and resumes by itself the next time a check passes. Someone could fairly argue that's too permissive. An integrity failure means possible corruption, and possible corruption should stop writes until a person has looked.

I think one retry plus auto-resume is the right setting for this engine. Its job is to be an agent's memory, and #247 is a detailed record of what a refusal that only a human can clear looks like in practice: hours of dropped writes, reads that make everything look fine, a remedy the user can't reach, and finally a workaround that switches the protection off permanently. The one person who hit this found `"warn"` by reading Rust source, and he's more careful than most users will be. A refusal that names the failure in the error and in `stats()`, and lifts when a check passes, is one people will leave turned on.

The cost is that the engine now treats a failure that doesn't repeat as a blip. If the storage underneath is bad enough to produce damage that only shows up some of the time, a clean check can resume writes on top of it. And the best evidence for the stricter view is still open on the issue.

Nobody knows why his process got a bad `quick_check` result in the first place. His offline copy of the same database checks clean. My last note on the issue says: "Still unknown: why your process got a bad quick_check result when your offline copy checks clean. The original result string was lost with the restart. If it happens again on 0.23.2, the error message and stats()['last_integrity_check'] will contain it, and integrity_checks_unconfirmed_since_boot will show whether it was a one-off. We'd be glad to see either."

I also asked him to switch the store from `"warn"` back to `"refuse"`. Nothing needs the workaround on 0.23.2, and if it stays set, both refusal paths stay disarmed on every open of that file, including the next time a check fails for a reason none of us can explain yet.

Pranab Sarkar, Independent Researcher
