Skip to content

Commit 6c704ce

Browse files
committed
fix: harden schema probe cache against concurrency and stale code versions
Two latent issues, both surfaced while populating new fields: - The schema-file and probe-cache writers used a shared "<path>.tmp" and the schema-file writer did not catch errors. Concurrent extraction workers that all cache-miss and re-probe raced on that temp and one worker's rename failed with FileNotFoundError, aborting the run. Each writer now uses a unique temp file (tempfile.mkstemp) and atomically renames; failures are logged, not raised. As the proper fix, convert_relationships now builds the canonical write schemas once in the parent before forking, so workers inherit the cache and never re-probe concurrently. - A probe cache is keyed by the source-file set, which is unchanged across a code change, so a stale cache silently shadowed updated extraction logic (e.g. the new affiliation `years` field). The cache now records and validates the schema version on load and is rejected on mismatch; bumped to v2. Tests in test_probe_cache.py cover version rejection and concurrency-safe writes (blocking the legacy shared temp name to deterministically distinguish the fix).
1 parent 70a0e1d commit 6c704ce

3 files changed

Lines changed: 148 additions & 9 deletions

File tree

sync/extract.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1318,6 +1318,12 @@ def convert_relationships(
13181318
" + ".join(all_pending_types),
13191319
)
13201320

1321+
# Build the canonical write schemas once, here in the parent, before forking
1322+
# the pool. Workers inherit the populated lru cache across fork, so they
1323+
# never re-probe (which would have every worker sample the source and race
1324+
# to rewrite the shared schema-probe cache).
1325+
_entity_arrow_schemas(entity_type)
1326+
13211327
# Distribute files across workers — each worker processes all types
13221328
actual_workers = min(n_workers, len(files_to_process))
13231329
actual_workers = max(1, actual_workers)

sync/schema.py

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import json
1717
import logging
1818
import os
19+
import tempfile
1920
from dataclasses import dataclass, field
2021
from functools import lru_cache
2122
from pathlib import Path
@@ -1767,7 +1768,14 @@ def probe_schema_multi(
17671768

17681769
# ── Committed schema file ──────────────────────────────────────────────
17691770

1770-
_SCHEMA_FILE_VERSION = 1
1771+
# Bump this whenever the probe/extraction logic changes what columns a schema
1772+
# produces. Both the committed schema file and the on-disk probe cache record
1773+
# the version they were written with and are rejected on load if it no longer
1774+
# matches, so a logic change automatically invalidates stale caches instead of
1775+
# silently shadowing the new behaviour (the source-file hash alone can't catch
1776+
# a code change). v2: locations capture all scalar leaves; relationship probes
1777+
# carry lists-of-scalars as native list columns.
1778+
_SCHEMA_FILE_VERSION = 2
17711779
_SCHEMA_FILE = Path(__file__).resolve().parent.parent / "openalex.schema.json"
17721780
_PROBE_SAMPLE_SIZE = 100 # records sampled per file
17731781
_PROBE_SAMPLE_FILES = 25 # files sampled, evenly spaced across the date range
@@ -1805,9 +1813,27 @@ def _write_schema_file(cache: dict[str, dict[str, Any]]) -> None:
18051813
"version": _SCHEMA_FILE_VERSION,
18061814
"entities": cache,
18071815
}
1808-
tmp_path = path.with_suffix(".tmp")
1809-
tmp_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
1810-
tmp_path.replace(path)
1816+
# Unique temp file per writer (not a shared "<path>.tmp") so concurrent
1817+
# workers that all cache-miss and re-probe at once can't clobber each
1818+
# other's temp and fail the rename. Failures are logged, not raised, so a
1819+
# best-effort cache update never aborts the run.
1820+
tmp_path: str | None = None
1821+
try:
1822+
fd, tmp_path = tempfile.mkstemp(
1823+
dir=str(path.parent), prefix=".openalex.schema.", suffix=".tmp",
1824+
)
1825+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
1826+
json.dump(payload, fh, indent=2, sort_keys=True)
1827+
os.replace(tmp_path, path)
1828+
tmp_path = None
1829+
except OSError as exc:
1830+
log.warning("Cannot write schema file %s: %s", path, exc)
1831+
finally:
1832+
if tmp_path is not None:
1833+
try:
1834+
os.unlink(tmp_path)
1835+
except OSError:
1836+
pass
18111837
_load_schema_file.cache_clear()
18121838

18131839

@@ -1905,6 +1931,11 @@ def _load_probe_cache(entity: str, key: str) -> EntitySchema | None:
19051931
return None
19061932
if payload.get("cache_key") != key or payload.get("entity") != entity:
19071933
return None
1934+
# Reject caches written by a different probe-logic version — the source-file
1935+
# hash is unchanged across a code change, so without this a stale cache would
1936+
# shadow the updated extraction behaviour.
1937+
if payload.get("version") != _SCHEMA_FILE_VERSION:
1938+
return None
19081939
schema_dict = payload.get("schema")
19091940
if not isinstance(schema_dict, dict):
19101941
return None
@@ -1929,15 +1960,28 @@ def _store_probe_cache(entity: str, key: str, schema: EntitySchema) -> None:
19291960
"cache_key": key,
19301961
"schema": schema.to_dict(),
19311962
}
1932-
tmp_path = path.with_suffix(path.suffix + ".tmp")
1963+
# Write to a unique temp file, then atomically rename onto the final path.
1964+
# A shared "<path>.tmp" would let concurrent writers (e.g. a forked
1965+
# extraction pool that all cache-miss at once) clobber each other's temp and
1966+
# fail the rename with FileNotFoundError. Every writer produces the same
1967+
# schema, so a per-writer temp plus last-writer-wins rename is safe.
1968+
tmp_path: str | None = None
19331969
try:
1934-
tmp_path.write_text(
1935-
json.dumps(payload, indent=2, sort_keys=True),
1936-
encoding="utf-8",
1970+
fd, tmp_path = tempfile.mkstemp(
1971+
dir=str(_PROBE_CACHE_DIR), prefix=f"schema_{entity}_", suffix=".tmp",
19371972
)
1938-
tmp_path.replace(path)
1973+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
1974+
json.dump(payload, fh, indent=2, sort_keys=True)
1975+
os.replace(tmp_path, path)
1976+
tmp_path = None
19391977
except OSError as exc:
19401978
log.warning("Cannot write schema probe cache %s: %s", path, exc)
1979+
finally:
1980+
if tmp_path is not None:
1981+
try:
1982+
os.unlink(tmp_path)
1983+
except OSError:
1984+
pass
19411985

19421986

19431987
# ── Schema probing from JSONL ───────────────────────────────────────────

tests/test_probe_cache.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""Regression tests for the schema probe cache: version invalidation and
2+
concurrency-safe writes."""
3+
from __future__ import annotations
4+
5+
import json
6+
from pathlib import Path
7+
8+
import pytest
9+
10+
import sync.schema as schema
11+
from sync.schema import EntitySchema, FieldSchema
12+
13+
14+
def _tiny_schema(entity: str = "widgets") -> EntitySchema:
15+
return EntitySchema(
16+
entity=entity,
17+
id_col="widget_id",
18+
id_path="id",
19+
id_type="int",
20+
fields=[
21+
FieldSchema(
22+
json_key="cited_by_count", pattern="scalar", rel_name="widget_main",
23+
scalar_cols=[{"col": "cited_by_count", "path": "cited_by_count", "type": "int"}],
24+
)
25+
],
26+
)
27+
28+
29+
@pytest.fixture
30+
def cache_dir(tmp_path, monkeypatch):
31+
d = tmp_path / "probe-cache"
32+
monkeypatch.setattr(schema, "_PROBE_CACHE_DIR", d)
33+
return d
34+
35+
36+
class TestVersionInvalidation:
37+
def test_roundtrip_same_version_loads(self, cache_dir):
38+
schema._store_probe_cache("widgets", "key0", _tiny_schema())
39+
loaded = schema._load_probe_cache("widgets", "key0")
40+
assert loaded is not None
41+
assert loaded.entity == "widgets"
42+
43+
def test_stale_version_is_rejected(self, cache_dir, monkeypatch):
44+
# Written under the current version...
45+
schema._store_probe_cache("widgets", "key0", _tiny_schema())
46+
path = schema._probe_cache_path("widgets", "key0")
47+
assert path.is_file()
48+
# ...then the probe logic version moves on: the cache must be ignored,
49+
# not silently shadow the new behaviour (the cache_key is unchanged).
50+
monkeypatch.setattr(schema, "_SCHEMA_FILE_VERSION", schema._SCHEMA_FILE_VERSION + 1)
51+
assert schema._load_probe_cache("widgets", "key0") is None
52+
53+
def test_cache_payload_records_version(self, cache_dir):
54+
schema._store_probe_cache("widgets", "key0", _tiny_schema())
55+
payload = json.loads(schema._probe_cache_path("widgets", "key0").read_text())
56+
assert payload["version"] == schema._SCHEMA_FILE_VERSION
57+
58+
59+
class TestConcurrentWriteSafe:
60+
"""A unique temp file per writer means a contended/blocked legacy shared
61+
``<path>.tmp`` name can't break a write. Blocking that exact name (with a
62+
directory, so the old shared-tmp write would fail) deterministically
63+
distinguishes the fix from the racy original — no timing luck involved.
64+
"""
65+
66+
def test_write_schema_file_survives_blocked_shared_tmp(self, tmp_path, monkeypatch):
67+
sf = tmp_path / "openalex.schema.json"
68+
monkeypatch.setattr(schema, "_SCHEMA_FILE", sf)
69+
# Occupy the legacy shared temp name; the old code wrote here and would
70+
# raise (and it didn't catch the error, which is what aborted the job).
71+
(tmp_path / "openalex.schema.tmp").mkdir()
72+
schema._write_schema_file({"widgets": _tiny_schema().to_dict()})
73+
assert sf.is_file()
74+
payload = json.loads(sf.read_text())
75+
assert payload["version"] == schema._SCHEMA_FILE_VERSION
76+
assert "widgets" in payload["entities"]
77+
assert not list(tmp_path.glob(".openalex.schema.*.tmp")), "temp not cleaned up"
78+
79+
def test_store_probe_cache_survives_blocked_shared_tmp(self, cache_dir):
80+
cache_dir.mkdir(parents=True, exist_ok=True)
81+
path = schema._probe_cache_path("widgets", "key0")
82+
# Block the legacy shared temp name (old: ``<path>.tmp``).
83+
Path(str(path) + ".tmp").mkdir()
84+
schema._store_probe_cache("widgets", "key0", _tiny_schema())
85+
# New code uses a unique temp, so the cache is written and loadable;
86+
# the old code would have failed to write it (and logged a warning).
87+
loaded = schema._load_probe_cache("widgets", "key0")
88+
assert loaded is not None and loaded.entity == "widgets"
89+
assert not list(cache_dir.glob("schema_widgets_key0.*.tmp")), "temp not cleaned up"

0 commit comments

Comments
 (0)