fix(conf): publish_replica resolves symlinked replica path (closes #619)
Some checks failed
CI / lint (push) Successful in 27s
CI / notebooks-smoke (push) Successful in 1m27s
Deploy / notebooks (push) Has been skipped
Deploy / zotero (push) Has been skipped
CI / test (push) Has been cancelled
Deploy / docs (push) Has been cancelled
Deploy / api (push) Has been cancelled
Deploy / llm (push) Has been cancelled
Deploy / mc (push) Has been cancelled
Deploy / report (push) Has been cancelled
Infra CI / zotero (push) Has been cancelled
Infra CI / docs (push) Has been cancelled
Infra CI / api (push) Has been cancelled
Infra CI / llm (push) Has been cancelled
Infra CI / mc (push) Has been cancelled
Infra CI / notebooks (push) Has been cancelled

os.replace(tmp, dst) replaced the symlink itself, silently detaching a
worktree leaf-symlink into a stale standalone copy while the shared
replica went unrefreshed. Resolve dst first so the tmp-write/replace
dance happens next to the real target.
This commit is contained in:
kert
2026-08-14 14:58:43 -04:00
parent f57d77f5f8
commit e91ea1cb7c
2 changed files with 32 additions and 2 deletions

View File

@@ -144,12 +144,15 @@ def publish_replica(name: str = "aco") -> Any:
the copy: no writer can be mid-transaction, and ``CHECKPOINT`` the copy: no writer can be mid-transaction, and ``CHECKPOINT``
flushes the WAL first, so the copied file is a consistent database. flushes the WAL first, so the copied file is a consistent database.
The swap is atomic (``os.replace``); readers already holding the The swap is atomic (``os.replace``); readers already holding the
old snapshot keep a valid file handle until they reconnect. old snapshot keep a valid file handle until they reconnect. The
destination is resolved first so a symlinked replica path (worktree
leaf-symlink pattern, #619) has its shared target updated instead of
the symlink being replaced by a detached copy.
""" """
import shutil import shutil
src = path(f"db.{name}") src = path(f"db.{name}")
dst = _replica_path(name) dst = _replica_path(name).resolve()
tmp = dst.with_suffix(".tmp") tmp = dst.with_suffix(".tmp")
with duckdb_batch(name) as con: with duckdb_batch(name) as con:
con.execute("CHECKPOINT") con.execute("CHECKPOINT")

View File

@@ -335,6 +335,33 @@ class TestReplica:
== 3 == 3
) )
def test_publish_follows_symlinked_replica(self, tmp_path, monkeypatch):
"""A symlinked replica path (worktree leaf-symlink pattern) must be
followed: publish updates the shared target, not the symlink."""
shared = tmp_path / "shared"
shared.mkdir()
db_file = self._make_primary(tmp_path)
self._patch_path(monkeypatch, db_file)
target = shared / "custom.ro.duckdb"
connect.publish_replica("custom") # seed a real file to link to
(tmp_path / "custom.ro.duckdb").rename(target)
(tmp_path / "custom.ro.duckdb").symlink_to(target)
with connect.duckdb_batch("custom") as con:
con.execute("UPDATE t SET x = 7")
dst = connect.publish_replica("custom")
assert (tmp_path / "custom.ro.duckdb").is_symlink(), (
"publish detached the symlink into a standalone copy"
)
import duckdb as _duckdb
con = _duckdb.connect(str(target), read_only=True)
assert con.execute("SELECT x FROM t").fetchone()[0] == 7
con.close()
assert dst == target
def test_republish_refreshes_atomically(self, tmp_path, monkeypatch): def test_republish_refreshes_atomically(self, tmp_path, monkeypatch):
db_file = self._make_primary(tmp_path) db_file = self._make_primary(tmp_path)
self._patch_path(monkeypatch, db_file) self._patch_path(monkeypatch, db_file)