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
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:
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user