Skip to content

Commit f17b3b2

Browse files
committed
Serialize SAV export publication
1 parent c566aaa commit f17b3b2

2 files changed

Lines changed: 82 additions & 1 deletion

File tree

‎src/openstatspec/spss/sav.py‎

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,15 @@
1212
import math
1313
from contextlib import ExitStack, contextmanager
1414
from datetime import UTC, datetime
15+
from functools import wraps
1516
from pathlib import Path
1617
from tempfile import mkstemp, TemporaryDirectory
17-
from typing import Any
18+
from typing import Any, Callable
19+
20+
try: # POSIX locks let independent export processes share one publication gate.
21+
import fcntl
22+
except ImportError: # pragma: no cover - the supported CI/runtime is POSIX.
23+
fcntl = None
1824

1925
import pandas as pd
2026
import pyspssio
@@ -356,6 +362,46 @@ def _publish_staged_destination(
356362
staged.unlink()
357363

358364

365+
366+
367+
@contextmanager
368+
def _export_destination_lock(destination: Path):
369+
"""Serialize export publication and recovery for one destination directory.
370+
371+
The lock covers the existence observation, backup move, publication, and
372+
compensating restore. Without it, two exporters could each observe the
373+
old destination, and the later exporter could move or delete the newer
374+
export while attempting to restore its own failure.
375+
"""
376+
if fcntl is None: # pragma: no cover - see import guard above.
377+
raise UnsupportedOperationError(
378+
"SAV export publication requires POSIX advisory file locking."
379+
)
380+
descriptor = os.open(destination.parent, os.O_RDONLY)
381+
try:
382+
fcntl.flock(descriptor, fcntl.LOCK_EX)
383+
yield
384+
finally:
385+
try:
386+
fcntl.flock(descriptor, fcntl.LOCK_UN)
387+
finally:
388+
os.close(descriptor)
389+
390+
391+
def _serialize_export_publication(
392+
export: Callable[..., dict[str, Any]],
393+
) -> Callable[..., dict[str, Any]]:
394+
"""Guard a complete export, including any post-publication recovery."""
395+
@wraps(export)
396+
def guarded(*args: Any, **kwargs: Any) -> dict[str, Any]:
397+
destination = kwargs.get("destination")
398+
if destination is None:
399+
raise TypeError("export requires a destination keyword argument")
400+
with _export_destination_lock(Path(destination)):
401+
return export(*args, **kwargs)
402+
return guarded
403+
404+
359405
def _restore_export_destination(
360406
*, destination: Path, backup: Path, had_previous: bool,
361407
expected_identity: tuple[int, int] | None | object = (
@@ -568,6 +614,7 @@ def _export_staging_directory(
568614
raise
569615

570616

617+
@_serialize_export_publication
571618
def export_sav_dataset(
572619
*, database_url: str, dataset_id: str, destination: str | Path,
573620
allow_loss: tuple[str, ...] = (), legacy_locale: str | None = None,

‎tests/test_sav_sqlite.py‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import hashlib
22
import json
33
import sqlite3
4+
import threading
45

56
import pandas as pd
67
import pyspssio
@@ -15,6 +16,39 @@
1516

1617
_COMPAT_NAME_LOSS = _REQUIRED_ENGINE_LOSS
1718

19+
20+
21+
def test_export_destination_lock_serializes_observation_and_publication(tmp_path) -> None:
22+
"""A second exporter cannot replace a destination seen by the first."""
23+
destination = tmp_path / "shared-output.sav"
24+
first_entered = threading.Event()
25+
release_first = threading.Event()
26+
second_entered = threading.Event()
27+
28+
def first_export() -> None:
29+
with sav_module._export_destination_lock(destination):
30+
first_entered.set()
31+
release_first.wait(timeout=2)
32+
33+
def second_export() -> None:
34+
first_entered.wait(timeout=2)
35+
with sav_module._export_destination_lock(destination):
36+
second_entered.set()
37+
38+
first = threading.Thread(target=first_export)
39+
second = threading.Thread(target=second_export)
40+
first.start()
41+
assert first_entered.wait(timeout=2)
42+
second.start()
43+
assert not second_entered.wait(timeout=0.1)
44+
release_first.set()
45+
first.join(timeout=2)
46+
second.join(timeout=2)
47+
assert not first.is_alive()
48+
assert not second.is_alive()
49+
assert second_entered.is_set()
50+
51+
1852
def test_pyspssio_round_trip_uses_one_wide_table_and_catalog(tmp_path) -> None:
1953
source = tmp_path / "tiny.sav"
2054
database_path = tmp_path / "dataset.sqlite"

0 commit comments

Comments
 (0)