Skip to content

Commit dda01de

Browse files
committed
perf(dump): dump only the collections a change can affect
`app.dump --only COLLECTION` (repeatable) and `--changed-since BASE` restrict the replay to the affected collections and keep the other manifest entries instead of overwriting them. brand changes still mean everything; soc/cpu/gpu add the collections that embed them; scored collections are always dumped whole because scores are partly population-relative. Refs GetTechAPI/TechAPI#350
1 parent 3d63733 commit dda01de

2 files changed

Lines changed: 100 additions & 6 deletions

File tree

‎app/dump.py‎

Lines changed: 73 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from fastapi.testclient import TestClient
2020

2121
from app.categories import COLLECTIONS as CATEGORY_COLLECTIONS
22+
from app.validate import changed_paths
2223

2324
OUTPUT_DIR = Path(__file__).resolve().parent.parent / "dump"
2425

@@ -29,12 +30,21 @@
2930
PAGE_LIMIT = 100 # API max page size (§7.3)
3031

3132

32-
def resolve_collections(exclude: list[str] | None = None) -> list[str]:
33-
"""Return the collections to dump, minus ``exclude``.
33+
def resolve_collections(
34+
exclude: list[str] | None = None, only: list[str] | None = None
35+
) -> list[str]:
36+
"""Return the collections to dump: just ``only`` if given, minus ``exclude``.
3437
3538
Unknown names raise instead of being ignored, so a typo in a workflow fails
3639
loudly rather than silently dumping everything.
3740
"""
41+
if only:
42+
unknown = sorted(set(only) - set(COLLECTIONS))
43+
if unknown:
44+
raise ValueError(
45+
f"unknown collection(s) {unknown}; valid names: {', '.join(COLLECTIONS)}"
46+
)
47+
return [r for r in COLLECTIONS if r in set(only) and r not in set(exclude or [])]
3848
if not exclude:
3949
return list(COLLECTIONS)
4050
unknown = sorted(set(exclude) - set(COLLECTIONS))
@@ -45,6 +55,31 @@ def resolve_collections(exclude: list[str] | None = None) -> list[str]:
4555
return [resource for resource in COLLECTIONS if resource not in set(exclude)]
4656

4757

58+
# Extra collections whose pages embed another category's data. ``None`` = everything.
59+
# Scored collections (smartphones/cpus/gpus/socs) are always re-dumped whole: scores
60+
# are partly relative to the population, so one record can move its neighbours.
61+
DEPENDENTS: dict[str, list[str] | None] = {
62+
"brand": None,
63+
"soc": ["socs", "smartphones", "tablets", "watches", "pdas"],
64+
"cpu": ["cpus", "laptops"],
65+
"gpu": ["gpus", "laptops"],
66+
}
67+
68+
69+
def collections_for_changes(paths: set[str]) -> list[str]:
70+
"""Collections a set of changed seed paths (``data/`` stripped) can affect."""
71+
affected: set[str] = set()
72+
for path in paths:
73+
category = path.split("/", 1)[0]
74+
if category not in CATEGORY_COLLECTIONS:
75+
continue # e.g. _verify/: not part of the dump
76+
deps = DEPENDENTS.get(category, [CATEGORY_COLLECTIONS[category]])
77+
if deps is None:
78+
return list(COLLECTIONS)
79+
affected.update(deps)
80+
return [resource for resource in COLLECTIONS if resource in affected]
81+
82+
4883
def _write_json(path: Path, data: object) -> None:
4984
text = json.dumps(data, indent=2, ensure_ascii=False) + "\n"
5085
# Leave identical pages untouched: rewriting ~1M unchanged files resets
@@ -105,6 +140,14 @@ def generate(
105140
"""Write the full static dump. Returns the number of detail files per collection."""
106141
counts: dict[str, int] = {}
107142
manifest: dict[str, object] = {"version": "v1", "collections": {}}
143+
if collections is not None and set(collections) != set(COLLECTIONS):
144+
# Partial run: keep the entries of collections we are not touching.
145+
try:
146+
previous = json.loads((output_dir / "v1" / "index.json").read_text(encoding="utf-8"))
147+
if isinstance(previous.get("collections"), dict):
148+
manifest["collections"] = previous["collections"]
149+
except (FileNotFoundError, json.JSONDecodeError):
150+
pass
108151

109152
for resource in collections or COLLECTIONS:
110153
count, items = _fetch_all(client, resource)
@@ -142,14 +185,21 @@ def generate(
142185
return counts
143186

144187

145-
def run(output_dir: Path = OUTPUT_DIR, exclude: list[str] | None = None) -> None:
188+
def run(
189+
output_dir: Path = OUTPUT_DIR,
190+
exclude: list[str] | None = None,
191+
only: list[str] | None = None,
192+
) -> None:
146193
from sqlmodel import Session
147194

148195
from app.database import create_db_and_tables, engine
149196
from app.main import app
150197
from app.seed import seed
151198

152-
collections = resolve_collections(exclude)
199+
collections = resolve_collections(exclude, only)
200+
if not collections:
201+
print("Nothing to dump.")
202+
return
153203

154204
create_db_and_tables()
155205
with Session(engine) as session:
@@ -176,5 +226,23 @@ def run(output_dir: Path = OUTPUT_DIR, exclude: list[str] | None = None) -> None
176226
"writing hundreds of thousands of files that are discarded anyway."
177227
),
178228
)
229+
parser.add_argument(
230+
"--only",
231+
action="append",
232+
default=[],
233+
metavar="COLLECTION",
234+
help="dump just this collection, repeatable; other manifest entries are kept",
235+
)
236+
parser.add_argument(
237+
"--changed-since",
238+
metavar="BASE",
239+
help="dump only the collections affected by data changed since BASE",
240+
)
179241
args = parser.parse_args()
180-
run(args.output, args.exclude)
242+
only = args.only
243+
if args.changed_since:
244+
only = only + collections_for_changes(changed_paths(args.changed_since))
245+
if not only:
246+
print("No dump-relevant data changed.")
247+
raise SystemExit(0)
248+
run(args.output, args.exclude, only)

‎tests/integration/test_dump.py‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,13 @@
88
import pytest
99
from fastapi.testclient import TestClient
1010

11-
from app.dump import COLLECTIONS, _prune_orphaned_pages, generate, resolve_collections
11+
from app.dump import (
12+
COLLECTIONS,
13+
_prune_orphaned_pages,
14+
collections_for_changes,
15+
generate,
16+
resolve_collections,
17+
)
1218
from tests.integration.mobile_device_fixtures import ensure_mobile_device_fixtures
1319

1420

@@ -137,3 +143,23 @@ def test_prune_orphaned_pages_leaves_files_and_valid_slugs(tmp_path: Path) -> No
137143

138144
def test_prune_orphaned_pages_noop_when_dir_missing(tmp_path: Path) -> None:
139145
assert _prune_orphaned_pages(tmp_path / "does-not-exist", valid_slugs=set()) == []
146+
147+
148+
def test_partial_dump_keeps_other_manifest_entries(client: TestClient, tmp_path: Path) -> None:
149+
generate(client, output_dir=tmp_path, collections=["cpus", "gpus"])
150+
generate(client, output_dir=tmp_path, collections=["cpus"])
151+
manifest = json.loads((tmp_path / "v1" / "index.json").read_text())
152+
assert set(manifest["collections"]) == {"cpus", "gpus"}
153+
154+
155+
def test_resolve_collections_only() -> None:
156+
assert resolve_collections(only=["laptops", "cpus"]) == ["cpus", "laptops"]
157+
with pytest.raises(ValueError):
158+
resolve_collections(only=["nope"])
159+
160+
161+
def test_collections_for_changes_maps_dependents() -> None:
162+
assert collections_for_changes({"website/a/x.json"}) == ["websites"]
163+
assert collections_for_changes({"cpu/i/1/x.json"}) == ["cpus", "laptops"]
164+
assert collections_for_changes({"_verify/status.json"}) == []
165+
assert collections_for_changes({"brand/us/acme.json"}) == COLLECTIONS

0 commit comments

Comments
 (0)