|
12 | 12 | import math |
13 | 13 | from contextlib import ExitStack, contextmanager |
14 | 14 | from datetime import UTC, datetime |
| 15 | +from functools import wraps |
15 | 16 | from pathlib import Path |
16 | 17 | 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 |
18 | 24 |
|
19 | 25 | import pandas as pd |
20 | 26 | import pyspssio |
@@ -356,6 +362,46 @@ def _publish_staged_destination( |
356 | 362 | staged.unlink() |
357 | 363 |
|
358 | 364 |
|
| 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 | + |
359 | 405 | def _restore_export_destination( |
360 | 406 | *, destination: Path, backup: Path, had_previous: bool, |
361 | 407 | expected_identity: tuple[int, int] | None | object = ( |
@@ -568,6 +614,7 @@ def _export_staging_directory( |
568 | 614 | raise |
569 | 615 |
|
570 | 616 |
|
| 617 | +@_serialize_export_publication |
571 | 618 | def export_sav_dataset( |
572 | 619 | *, database_url: str, dataset_id: str, destination: str | Path, |
573 | 620 | allow_loss: tuple[str, ...] = (), legacy_locale: str | None = None, |
|
0 commit comments