fetch details incrementally on listing changes or weekly refresh
This commit is contained in:
parent
533b3e7e7b
commit
c9ee4bde30
@ -183,6 +183,8 @@ COLLECT_FAMILY=saab-900
|
|||||||
COLLECT_MAX_PAGES=1
|
COLLECT_MAX_PAGES=1
|
||||||
COLLECT_MAX_DETAILS=all
|
COLLECT_MAX_DETAILS=all
|
||||||
COLLECT_DELAY=5
|
COLLECT_DELAY=5
|
||||||
|
COLLECT_DETAIL_POLICY=incremental
|
||||||
|
COLLECT_DETAIL_REFRESH_DAYS=7
|
||||||
```
|
```
|
||||||
|
|
||||||
The SSH wrapper reads resolved Compose settings and supplies a validated, fixed
|
The SSH wrapper reads resolved Compose settings and supplies a validated, fixed
|
||||||
@ -202,6 +204,45 @@ increase runtime; the Actions timeout remains 45 minutes. The pipeline and local
|
|||||||
state still retain exit 1 for partial coverage; the SSH wrapper converts verified
|
state still retain exit 1 for partial coverage; the SSH wrapper converts verified
|
||||||
limit-only outcomes into an Actions warning/success. No run is launched by editing app.env.
|
limit-only outcomes into an Actions warning/success. No run is launched by editing app.env.
|
||||||
|
|
||||||
|
### Incremental details
|
||||||
|
|
||||||
|
`COLLECT_DETAIL_POLICY=incremental` fetches detail pages only when:
|
||||||
|
|
||||||
|
1. There is no successfully imported detail observation for the ad.
|
||||||
|
2. The search-visible listing data changed since the search accompanying its last
|
||||||
|
successful detail fetch (price, source update timestamp, title/description,
|
||||||
|
vehicle attributes, location, status/category and publication/expiration dates).
|
||||||
|
3. Its last imported detail is at least `COLLECT_DETAIL_REFRESH_DAYS` old (default 7).
|
||||||
|
|
||||||
|
Counters, promotion/boost flags, attribute order and excluded personal attributes
|
||||||
|
do not trigger a refresh. Detail-only changes invisible in search results are
|
||||||
|
detected only on the age-based refresh. A weekly refresh is an eligibility rule
|
||||||
|
for the next manually requested run, not a weekly schedule.
|
||||||
|
|
||||||
|
History comes from accepted observations in committed PostgreSQL imports. The
|
||||||
|
baseline search is the latest search at/before the last successful detail, not
|
||||||
|
the most recent search overall: failed enrichment cannot acknowledge a change.
|
||||||
|
Missing baselines conservatively trigger a request. Cache skips are recorded as
|
||||||
|
`not_attempted` with reason `fresh_unchanged`; they do not create a detail observation,
|
||||||
|
advance freshness, consume max-details, or claim a new detail was fetched. Such
|
||||||
|
intentional skips may leave raw coverage partial but produce the existing Actions
|
||||||
|
warning/success when there are no actual failures or rejected records.
|
||||||
|
|
||||||
|
The pipeline loads a read-only history snapshot before collection. Failure to load
|
||||||
|
it prevents collection rather than silently falling back to fetching everything.
|
||||||
|
Standalone `car-tracker` collection rejects incremental mode; use the database-backed
|
||||||
|
`car-tracker-pipeline`. No schema migration is required, and existing import/replay
|
||||||
|
semantics are unchanged. For this homelab-sized dataset the snapshot contains the
|
||||||
|
latest accepted detail and preceding search per listing.
|
||||||
|
|
||||||
|
**Activate only after a new image supporting these flags is published.** Install
|
||||||
|
the updated host_run.py and compose.runtime.yaml, then add the two policy variables
|
||||||
|
to host app.env and select that new release. For older images use
|
||||||
|
`COLLECT_DETAIL_POLICY=always`; defaults remain always for backward compatibility.
|
||||||
|
COLLECT_MAX_DETAILS=all still means all eligible ads, not all discovered ads.
|
||||||
|
Existing details older than seven days will be eligible on the first incremental
|
||||||
|
run, so this does not guarantee fewer requests immediately or eliminate 403s.
|
||||||
|
|
||||||
### Execution outcomes
|
### Execution outcomes
|
||||||
|
|
||||||
Local outcomes: `~/containers/car-tracker/state/requests/<uuid>.json`.
|
Local outcomes: `~/containers/car-tracker/state/requests/<uuid>.json`.
|
||||||
|
|||||||
@ -34,6 +34,8 @@ services:
|
|||||||
COLLECT_MAX_PAGES: "${COLLECT_MAX_PAGES:-1}"
|
COLLECT_MAX_PAGES: "${COLLECT_MAX_PAGES:-1}"
|
||||||
COLLECT_MAX_DETAILS: "${COLLECT_MAX_DETAILS:-1}"
|
COLLECT_MAX_DETAILS: "${COLLECT_MAX_DETAILS:-1}"
|
||||||
COLLECT_DELAY: "${COLLECT_DELAY:-5}"
|
COLLECT_DELAY: "${COLLECT_DELAY:-5}"
|
||||||
|
COLLECT_DETAIL_POLICY: "${COLLECT_DETAIL_POLICY:-always}"
|
||||||
|
COLLECT_DETAIL_REFRESH_DAYS: "${COLLECT_DETAIL_REFRESH_DAYS:-7}"
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@ -38,9 +38,9 @@ def limit_reasons(manifest, pipeline):
|
|||||||
for item in details.get("listings", {}).values():
|
for item in details.get("listings", {}).values():
|
||||||
if item.get("status") == "complete":
|
if item.get("status") == "complete":
|
||||||
continue
|
continue
|
||||||
if item.get("status") != "not_attempted" or item.get("reason") != "detail_limit":
|
if item.get("status") != "not_attempted" or item.get("reason") not in ("detail_limit", "fresh_unchanged"):
|
||||||
return []
|
return []
|
||||||
reasons.add("detail_limit")
|
reasons.add(item["reason"])
|
||||||
return sorted(reasons)
|
return sorted(reasons)
|
||||||
|
|
||||||
|
|
||||||
@ -59,12 +59,13 @@ def outcome_summary(manifest, pipeline):
|
|||||||
failed = sum(item.get("status") == "failed" for item in listings)
|
failed = sum(item.get("status") == "failed" for item in listings)
|
||||||
skipped = sum(item.get("status") == "not_attempted" for item in listings)
|
skipped = sum(item.get("status") == "not_attempted" for item in listings)
|
||||||
stopped = sum(item.get("reason") == "run_stopped" for item in listings)
|
stopped = sum(item.get("reason") == "run_stopped" for item in listings)
|
||||||
|
cached = sum(item.get("reason") == "fresh_unchanged" for item in listings)
|
||||||
lines = [
|
lines = [
|
||||||
f"Collection: {status(manifest.get('status'))}. Import: {status(pipeline.get('import_status'))}.",
|
f"Collection: {status(manifest.get('status'))}. Import: {status(pipeline.get('import_status'))}.",
|
||||||
f"Import records: accepted={count(summary.get('accepted'))}, rejected={count(summary.get('rejected'))}, "
|
f"Import records: accepted={count(summary.get('accepted'))}, rejected={count(summary.get('rejected'))}, "
|
||||||
f"field issues={count(summary.get('field_issues'))}.",
|
f"field issues={count(summary.get('field_issues'))}.",
|
||||||
f"Details: completed={completed}, failed={failed}, not attempted={skipped} "
|
f"Details: completed={completed}, failed={failed}, not attempted={skipped} "
|
||||||
f"(collection stopped early={stopped}).",
|
f"(fresh unchanged details reused={cached}, collection stopped early={stopped}).",
|
||||||
]
|
]
|
||||||
items = [*manifest.get("searches", []), *listings]
|
items = [*manifest.get("searches", []), *listings]
|
||||||
http = sorted({item["http_status"] for item in items
|
http = sorted({item["http_status"] for item in items
|
||||||
@ -132,7 +133,8 @@ def actions_outcome(code, request, state, raw_root):
|
|||||||
print("::error::" + " ".join(lines) + " See private host logs/manifest; no automatic retry.", flush=True)
|
print("::error::" + " ".join(lines) + " See private host logs/manifest; no automatic retry.", flush=True)
|
||||||
return code
|
return code
|
||||||
explanations = {"page_limit": "the configured page limit left additional search pages unfetched",
|
explanations = {"page_limit": "the configured page limit left additional search pages unfetched",
|
||||||
"detail_limit": "the configured detail limit left some discovered ads without detail attempts"}
|
"detail_limit": "the configured detail limit left some discovered ads without detail attempts",
|
||||||
|
"fresh_unchanged": "unchanged ads with fresh previously imported details were intentionally not fetched again"}
|
||||||
message = "; ".join(explanations[reason] for reason in reasons)
|
message = "; ".join(explanations[reason] for reason in reasons)
|
||||||
print(f"::warning::Import succeeded with no rejected records, but coverage is intentionally partial: {message}. "
|
print(f"::warning::Import succeeded with no rejected records, but coverage is intentionally partial: {message}. "
|
||||||
"This is not full source coverage. Host state retains pipeline exit 1.", flush=True)
|
"This is not full source coverage. Host state retains pipeline exit 1.", flush=True)
|
||||||
@ -147,6 +149,12 @@ def collection_command(settings):
|
|||||||
pages = settings.get("COLLECT_MAX_PAGES", "1")
|
pages = settings.get("COLLECT_MAX_PAGES", "1")
|
||||||
details = settings.get("COLLECT_MAX_DETAILS", "1")
|
details = settings.get("COLLECT_MAX_DETAILS", "1")
|
||||||
delay = settings.get("COLLECT_DELAY", "5")
|
delay = settings.get("COLLECT_DELAY", "5")
|
||||||
|
policy = settings.get("COLLECT_DETAIL_POLICY", "always")
|
||||||
|
refresh = settings.get("COLLECT_DETAIL_REFRESH_DAYS", "7")
|
||||||
|
if policy not in ("always", "incremental"):
|
||||||
|
raise ValueError("Detail policy must be always or incremental")
|
||||||
|
if not isinstance(refresh, str) or not refresh.isdecimal() or int(refresh) < 1:
|
||||||
|
raise ValueError("Detail refresh days must be positive")
|
||||||
if not isinstance(family, str) or not family.strip():
|
if not isinstance(family, str) or not family.strip():
|
||||||
raise ValueError("A collection family or all is required")
|
raise ValueError("A collection family or all is required")
|
||||||
if not isinstance(pages, str) or not pages.isdecimal() or int(pages) < 1:
|
if not isinstance(pages, str) or not pages.isdecimal() or int(pages) < 1:
|
||||||
@ -158,6 +166,8 @@ def collection_command(settings):
|
|||||||
raise ValueError("Delay must be finite and at least one second")
|
raise ValueError("Delay must be finite and at least one second")
|
||||||
args = ["car-tracker-pipeline", "--headed", "--config", "/app/searches.toml",
|
args = ["car-tracker-pipeline", "--headed", "--config", "/app/searches.toml",
|
||||||
"--output", "/data", "--max-pages", pages, "--delay", delay]
|
"--output", "/data", "--max-pages", pages, "--delay", delay]
|
||||||
|
if policy == "incremental":
|
||||||
|
args.extend(["--detail-policy", policy, "--detail-refresh-days", refresh])
|
||||||
if family != "all":
|
if family != "all":
|
||||||
args.extend(["--family", family])
|
args.extend(["--family", family])
|
||||||
if details != "all":
|
if details != "all":
|
||||||
|
|||||||
@ -114,7 +114,7 @@ def write_records(path, records):
|
|||||||
temporary.replace(path)
|
temporary.replace(path)
|
||||||
|
|
||||||
|
|
||||||
def collect(fetch, searches, output, max_pages=10, delay=5, search_only=False, max_details=None):
|
def collect(fetch, searches, output, max_pages=10, delay=5, search_only=False, max_details=None, detail_policy=None):
|
||||||
run_id = str(uuid.uuid4())
|
run_id = str(uuid.uuid4())
|
||||||
directory = output / datetime.now(timezone.utc).strftime("%Y-%m-%d") / run_id
|
directory = output / datetime.now(timezone.utc).strftime("%Y-%m-%d") / run_id
|
||||||
directory.mkdir(mode=0o700, parents=True, exist_ok=False)
|
directory.mkdir(mode=0o700, parents=True, exist_ok=False)
|
||||||
@ -126,6 +126,8 @@ def collect(fetch, searches, output, max_pages=10, delay=5, search_only=False, m
|
|||||||
"planned_searches": searches,
|
"planned_searches": searches,
|
||||||
"details": {
|
"details": {
|
||||||
"enabled": not search_only, "max_details": max_details,
|
"enabled": not search_only, "max_details": max_details,
|
||||||
|
"policy": "incremental" if detail_policy is not None else "always",
|
||||||
|
"refresh_days": getattr(detail_policy, "refresh_days", None),
|
||||||
"status": "disabled" if search_only else "running", "listings": {},
|
"status": "disabled" if search_only else "running", "listings": {},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@ -192,6 +194,12 @@ def collect(fetch, searches, output, max_pages=10, delay=5, search_only=False, m
|
|||||||
item = details[identifier]
|
item = details[identifier]
|
||||||
if item["status"] != "pending":
|
if item["status"] != "pending":
|
||||||
continue
|
continue
|
||||||
|
if detail_policy is not None:
|
||||||
|
decision = detail_policy(ad)
|
||||||
|
item["detail_decision"] = decision
|
||||||
|
if decision == "fresh_unchanged":
|
||||||
|
item.update(status="not_attempted", reason="fresh_unchanged")
|
||||||
|
continue
|
||||||
if max_details is not None and detail_attempts >= max_details:
|
if max_details is not None and detail_attempts >= max_details:
|
||||||
item.update(status="not_attempted", reason="detail_limit")
|
item.update(status="not_attempted", reason="detail_limit")
|
||||||
continue
|
continue
|
||||||
@ -280,11 +288,15 @@ def parse_collection_args(argv=None, description=None):
|
|||||||
parser.add_argument("--headed", action="store_true")
|
parser.add_argument("--headed", action="store_true")
|
||||||
parser.add_argument("--search-only", action="store_true", help="Skip individual listing pages")
|
parser.add_argument("--search-only", action="store_true", help="Skip individual listing pages")
|
||||||
parser.add_argument("--max-details", type=int, help="Maximum unique detail requests per run")
|
parser.add_argument("--max-details", type=int, help="Maximum unique detail requests per run")
|
||||||
|
parser.add_argument("--detail-policy", choices=("always", "incremental"), default="always")
|
||||||
|
parser.add_argument("--detail-refresh-days", type=int, default=7)
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
if args.max_pages < 1 or not math.isfinite(args.delay) or args.delay < 1:
|
if args.max_pages < 1 or not math.isfinite(args.delay) or args.delay < 1:
|
||||||
parser.error("max-pages must be positive and delay must be finite and >= 1 second")
|
parser.error("max-pages must be positive and delay must be finite and >= 1 second")
|
||||||
if args.max_details is not None and args.max_details < 1:
|
if args.max_details is not None and args.max_details < 1:
|
||||||
parser.error("max-details must be positive")
|
parser.error("max-details must be positive")
|
||||||
|
if args.detail_refresh_days < 1:
|
||||||
|
parser.error("detail-refresh-days must be positive")
|
||||||
with args.config.open("rb") as stream:
|
with args.config.open("rb") as stream:
|
||||||
searches = tomllib.load(stream).get("searches", [])
|
searches = tomllib.load(stream).get("searches", [])
|
||||||
if not isinstance(searches, list) or not searches or any(
|
if not isinstance(searches, list) or not searches or any(
|
||||||
@ -330,6 +342,7 @@ def collect_browser(args):
|
|||||||
result = collect(
|
result = collect(
|
||||||
fetch, args.searches, args.output, args.max_pages, args.delay,
|
fetch, args.searches, args.output, args.max_pages, args.delay,
|
||||||
args.search_only, args.max_details,
|
args.search_only, args.max_details,
|
||||||
|
detail_policy=getattr(args, "detail_decider", None),
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
browser.close()
|
browser.close()
|
||||||
@ -350,6 +363,10 @@ def collect_browser(args):
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
path, status = collect_browser(parse_collection_args())
|
args = parse_collection_args()
|
||||||
|
if args.detail_policy == "incremental":
|
||||||
|
print("Incremental details require car-tracker-pipeline and imported database history.", file=sys.stderr)
|
||||||
|
return 3
|
||||||
|
path, status = collect_browser(args)
|
||||||
print(f"{status}: {path}")
|
print(f"{status}: {path}")
|
||||||
return 0 if status == "complete" else 1
|
return 0 if status == "complete" else 1
|
||||||
|
|||||||
67
src/car_tracker/detail_policy.py
Normal file
67
src/car_tracker/detail_policy.py
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
"""Choose detail requests from successfully imported history, not failed attempts."""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import json
|
||||||
|
|
||||||
|
from .collect import minimize
|
||||||
|
|
||||||
|
|
||||||
|
CHANGE_FIELDS = {
|
||||||
|
"subject", "body", "price", "price_cents", "brand", "status", "ad_type",
|
||||||
|
"category_id", "category_name", "attributes", "location", "index_date",
|
||||||
|
"first_publication_date", "expiration_date",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def signature(ad):
|
||||||
|
value = {key: item for key, item in minimize(ad).items() if key in CHANGE_FIELDS}
|
||||||
|
if "attributes" in value:
|
||||||
|
value["attributes"] = sorted(value["attributes"], key=lambda item: json.dumps(item, sort_keys=True))
|
||||||
|
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
class DetailPolicy:
|
||||||
|
def __init__(self, history, refresh_days=7, clock=None):
|
||||||
|
self.history = history
|
||||||
|
self.refresh_days = refresh_days
|
||||||
|
self.clock = clock or (lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
def __call__(self, ad):
|
||||||
|
previous = self.history.get(str(ad["list_id"]))
|
||||||
|
if previous is None:
|
||||||
|
return "never_detailed"
|
||||||
|
observed_at, search = previous
|
||||||
|
current = self.clock()
|
||||||
|
if observed_at > current:
|
||||||
|
return "invalid_history_time"
|
||||||
|
if current - observed_at >= timedelta(days=self.refresh_days):
|
||||||
|
return "weekly_refresh"
|
||||||
|
if search is None:
|
||||||
|
return "missing_search_baseline"
|
||||||
|
if signature(ad) != signature(search):
|
||||||
|
return "search_changed"
|
||||||
|
return "fresh_unchanged"
|
||||||
|
|
||||||
|
|
||||||
|
def load_policy(conn, refresh_days=7):
|
||||||
|
# A detail must have an accepted observation from a committed import. Pair it
|
||||||
|
# with the search observed before that detail, NOT the latest search overall:
|
||||||
|
# otherwise a failed enrichment could incorrectly acknowledge a change.
|
||||||
|
rows = conn.execute("""
|
||||||
|
WITH details AS (
|
||||||
|
SELECT DISTINCT ON (source_listing_id)
|
||||||
|
source_listing_id, observed_at, observation_id
|
||||||
|
FROM silver.observations
|
||||||
|
WHERE source = 'leboncoin' AND record_type = 'detail'
|
||||||
|
ORDER BY source_listing_id, observed_at DESC, observation_id DESC
|
||||||
|
)
|
||||||
|
SELECT d.source_listing_id, d.observed_at, s.raw_payload
|
||||||
|
FROM details d
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT raw_payload FROM silver.observations
|
||||||
|
WHERE source = 'leboncoin' AND source_listing_id = d.source_listing_id
|
||||||
|
AND record_type = 'search' AND observed_at <= d.observed_at
|
||||||
|
ORDER BY observed_at DESC, observation_id DESC LIMIT 1
|
||||||
|
) s ON true
|
||||||
|
""").fetchall()
|
||||||
|
return DetailPolicy({identifier: (observed_at, search) for identifier, observed_at, search in rows}, refresh_days)
|
||||||
@ -12,6 +12,7 @@ import psycopg
|
|||||||
|
|
||||||
from .collect import collect_browser, parse_collection_args, write_json
|
from .collect import collect_browser, parse_collection_args, write_json
|
||||||
from .database import InputError, database_error_hint, import_run
|
from .database import InputError, database_error_hint, import_run
|
||||||
|
from .detail_policy import load_policy
|
||||||
|
|
||||||
|
|
||||||
def run_pipeline(args):
|
def run_pipeline(args):
|
||||||
@ -35,6 +36,8 @@ def run_pipeline(args):
|
|||||||
try:
|
try:
|
||||||
with psycopg.connect(conninfo, connect_timeout=10, autocommit=True) as conn:
|
with psycopg.connect(conninfo, connect_timeout=10, autocommit=True) as conn:
|
||||||
conn.execute("SELECT 1 FROM silver.collection_runs LIMIT 0")
|
conn.execute("SELECT 1 FROM silver.collection_runs LIMIT 0")
|
||||||
|
if getattr(args, "detail_policy", "always") == "incremental":
|
||||||
|
args.detail_decider = load_policy(conn, args.detail_refresh_days)
|
||||||
path, status = collect_browser(args)
|
path, status = collect_browser(args)
|
||||||
print(f"Manifest: {path}", flush=True)
|
print(f"Manifest: {path}", flush=True)
|
||||||
sidecar = path.parent / "pipeline.json"
|
sidecar = path.parent / "pipeline.json"
|
||||||
|
|||||||
@ -150,6 +150,20 @@ class CollectorTests(unittest.TestCase):
|
|||||||
self.run_collection([SourceAccessError(429, capture=capture)])
|
self.run_collection([SourceAccessError(429, capture=capture)])
|
||||||
capture.assert_not_called()
|
capture.assert_not_called()
|
||||||
|
|
||||||
|
def test_fresh_details_are_intentionally_skipped_without_requests(self):
|
||||||
|
path, manifest = self.run_collection([payload([123, 456], total=2)], search_only=False,
|
||||||
|
detail_policy=lambda ad: "fresh_unchanged")
|
||||||
|
self.assertEqual(manifest["details"]["attempted"], 0)
|
||||||
|
self.assertEqual(manifest["details"]["listings"]["123"]["reason"], "fresh_unchanged")
|
||||||
|
self.assertEqual(len(list(path.parent.glob("*.jsonl.gz"))), 1)
|
||||||
|
|
||||||
|
def test_fresh_skips_do_not_consume_detail_request_limit(self):
|
||||||
|
_, manifest = self.run_collection([payload([123, 456], total=2), detail_payload(456)],
|
||||||
|
search_only=False, max_details=1,
|
||||||
|
detail_policy=lambda ad: "fresh_unchanged" if ad["list_id"] == 123 else "never_detailed")
|
||||||
|
self.assertEqual(manifest["details"]["attempted"], 1)
|
||||||
|
self.assertEqual(manifest["details"]["listings"]["456"]["status"], "complete")
|
||||||
|
|
||||||
def test_discovery_failure_does_not_claim_detail_completion(self):
|
def test_discovery_failure_does_not_claim_detail_completion(self):
|
||||||
_, manifest = self.run_collection([SourceAccessError(403)], search_only=False)
|
_, manifest = self.run_collection([SourceAccessError(403)], search_only=False)
|
||||||
self.assertEqual(manifest["details"]["status"], "not_attempted")
|
self.assertEqual(manifest["details"]["status"], "not_attempted")
|
||||||
|
|||||||
@ -21,6 +21,7 @@ from psycopg.conninfo import conninfo_to_dict
|
|||||||
|
|
||||||
from car_tracker import database
|
from car_tracker import database
|
||||||
from car_tracker.collect import search_url, write_json, write_records
|
from car_tracker.collect import search_url, write_json, write_records
|
||||||
|
from car_tracker.detail_policy import load_policy
|
||||||
|
|
||||||
|
|
||||||
@unittest.skipUnless(os.environ.get("TEST_DATABASE_URL"), "TEST_DATABASE_URL is not set")
|
@unittest.skipUnless(os.environ.get("TEST_DATABASE_URL"), "TEST_DATABASE_URL is not set")
|
||||||
@ -128,6 +129,36 @@ class DatabaseIntegrationTests(unittest.TestCase):
|
|||||||
database.migrate(self.conn)
|
database.migrate(self.conn)
|
||||||
self.assertEqual(self.state(), before)
|
self.assertEqual(self.state(), before)
|
||||||
|
|
||||||
|
def test_incremental_policy_pairs_search_with_last_successful_detail(self):
|
||||||
|
path, _ = self.fixture(detail={"list_id": 123, "price": ["12500"]}, day=1)
|
||||||
|
database.import_run(self.conn, path)
|
||||||
|
later, _ = self.fixture(pages=[[{"list_id": 123, "price": ["11000"]}]], day=2)
|
||||||
|
database.import_run(self.conn, later)
|
||||||
|
before = self.state()
|
||||||
|
policy = load_policy(self.conn)
|
||||||
|
policy.clock = lambda: datetime(2026, 1, 3, tzinfo=timezone.utc)
|
||||||
|
self.assertEqual(policy({"list_id": 123, "price": ["12000"]}), "fresh_unchanged")
|
||||||
|
self.assertEqual(policy({"list_id": 123, "price": ["11000"]}), "search_changed")
|
||||||
|
self.assertEqual(policy({"list_id": 456, "price": ["10000"]}), "never_detailed")
|
||||||
|
policy.clock = lambda: datetime(2026, 1, 9, tzinfo=timezone.utc)
|
||||||
|
self.assertEqual(policy({"list_id": 123, "price": ["12000"]}), "weekly_refresh")
|
||||||
|
self.assertEqual(self.state(), before)
|
||||||
|
|
||||||
|
def test_cached_skip_import_preserves_detail_age_and_reason(self):
|
||||||
|
first, _ = self.fixture(detail={"list_id": 123, "price": ["12000"]}, day=1)
|
||||||
|
database.import_run(self.conn, first)
|
||||||
|
path, manifest = self.fixture(day=2)
|
||||||
|
manifest["status"] = "partial"
|
||||||
|
manifest["details"] = {"enabled": True, "status": "partial", "listings": {
|
||||||
|
"123": {"status": "not_attempted", "reason": "fresh_unchanged"}}}
|
||||||
|
write_json(path, manifest)
|
||||||
|
database.import_run(self.conn, path)
|
||||||
|
self.assertEqual(self.conn.execute(
|
||||||
|
"SELECT reason FROM silver.detail_coverage WHERE run_id = %s", (manifest["run_id"],)
|
||||||
|
).fetchone()[0], "fresh_unchanged")
|
||||||
|
policy = load_policy(self.conn)
|
||||||
|
self.assertEqual(policy.history["123"][0].day, 1)
|
||||||
|
|
||||||
def test_applied_migration_checksum_mismatch_rolls_back(self):
|
def test_applied_migration_checksum_mismatch_rolls_back(self):
|
||||||
before = self.state()
|
before = self.state()
|
||||||
# The outer transaction also rolls back the deliberate checksum corruption.
|
# The outer transaction also rolls back the deliberate checksum corruption.
|
||||||
|
|||||||
53
tests/test_detail_policy.py
Normal file
53
tests/test_detail_policy.py
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
from car_tracker.detail_policy import DetailPolicy, load_policy, signature
|
||||||
|
|
||||||
|
|
||||||
|
class DetailPolicyTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.current = datetime(2026, 10, 12, 12, tzinfo=timezone.utc)
|
||||||
|
self.ad = {"list_id": 123, "price": [12000], "subject": "Synthetic car",
|
||||||
|
"attributes": [{"key": "mileage", "value": "100000"}]}
|
||||||
|
|
||||||
|
def policy(self, age=1, baseline=True):
|
||||||
|
return DetailPolicy({"123": (self.current - timedelta(days=age), self.ad if baseline else None)},
|
||||||
|
clock=lambda: self.current)
|
||||||
|
|
||||||
|
def test_no_successful_detail_is_eligible(self):
|
||||||
|
self.assertEqual(DetailPolicy({}, clock=lambda: self.current)(self.ad), "never_detailed")
|
||||||
|
|
||||||
|
def test_unchanged_fresh_ad_is_skipped(self):
|
||||||
|
self.assertEqual(self.policy()(self.ad), "fresh_unchanged")
|
||||||
|
|
||||||
|
def test_exact_week_boundary_is_eligible(self):
|
||||||
|
self.assertEqual(self.policy(age=7)(self.ad), "weekly_refresh")
|
||||||
|
self.assertEqual(self.policy(age=6)(self.ad), "fresh_unchanged")
|
||||||
|
|
||||||
|
def test_price_and_source_timestamp_changes_are_eligible(self):
|
||||||
|
self.assertEqual(self.policy()({**self.ad, "price": [11000]}), "search_changed")
|
||||||
|
self.assertEqual(self.policy()({**self.ad, "index_date": "2026-10-12"}), "search_changed")
|
||||||
|
|
||||||
|
def test_unknown_baseline_and_future_timestamp_are_conservative(self):
|
||||||
|
self.assertEqual(self.policy(baseline=False)(self.ad), "missing_search_baseline")
|
||||||
|
self.assertEqual(self.policy(age=-1)(self.ad), "invalid_history_time")
|
||||||
|
|
||||||
|
def test_counters_and_promotions_do_not_trigger_enrichment(self):
|
||||||
|
other = {**self.ad, "counters": {"views": 123}, "is_boosted": True,
|
||||||
|
"options": {"highlight": True}, "owner": {"name": "private"}}
|
||||||
|
self.assertEqual(self.policy()(other), "fresh_unchanged")
|
||||||
|
|
||||||
|
def test_attribute_order_and_sensitive_fields_do_not_trigger_changes(self):
|
||||||
|
a = {**self.ad, "attributes": [*self.ad["attributes"], {"key": "fuel", "value": "petrol"}]}
|
||||||
|
b = {**a, "attributes": [*reversed(a["attributes"]), {"key": "phone", "value": "secret"}]}
|
||||||
|
self.assertEqual(signature(a), signature(b))
|
||||||
|
|
||||||
|
def test_loads_only_imported_history(self):
|
||||||
|
conn = Mock()
|
||||||
|
conn.execute.return_value.fetchall.return_value = [("123", self.current, self.ad)]
|
||||||
|
policy = load_policy(conn)
|
||||||
|
self.assertEqual(policy.history["123"], (self.current, self.ad))
|
||||||
|
query = conn.execute.call_args.args[0]
|
||||||
|
self.assertIn("observed_at <= d.observed_at", query)
|
||||||
|
self.assertIn("record_type = 'detail'", query)
|
||||||
@ -103,6 +103,21 @@ class PipelineTests(unittest.TestCase):
|
|||||||
self.preflight.__exit__.assert_called_once()
|
self.preflight.__exit__.assert_called_once()
|
||||||
self.assertNotIn("TOP_SECRET", self.stderr.getvalue())
|
self.assertNotIn("TOP_SECRET", self.stderr.getvalue())
|
||||||
|
|
||||||
|
def test_incremental_history_is_loaded_before_collection(self):
|
||||||
|
self.args.detail_policy = "incremental"
|
||||||
|
self.args.detail_refresh_days = 7
|
||||||
|
with patch.object(pipeline, "load_policy", return_value="synthetic-policy") as load:
|
||||||
|
self.assertEqual(pipeline.run_pipeline(self.args), 0)
|
||||||
|
load.assert_called_once_with(self.preflight, 7)
|
||||||
|
self.assertEqual(self.args.detail_decider, "synthetic-policy")
|
||||||
|
|
||||||
|
def test_history_failure_does_not_start_collection(self):
|
||||||
|
self.args.detail_policy = "incremental"
|
||||||
|
self.args.detail_refresh_days = 7
|
||||||
|
with patch.object(pipeline, "load_policy", side_effect=pipeline.psycopg.OperationalError("secret")):
|
||||||
|
self.assertEqual(pipeline.run_pipeline(self.args), 3)
|
||||||
|
self.collect.assert_not_called()
|
||||||
|
|
||||||
def test_finalized_interruption_is_not_imported(self):
|
def test_finalized_interruption_is_not_imported(self):
|
||||||
self.collect.side_effect = lambda args: self.fixture(args, "partial", "KeyboardInterrupt")
|
self.collect.side_effect = lambda args: self.fixture(args, "partial", "KeyboardInterrupt")
|
||||||
self.assertEqual(pipeline.run_pipeline(self.args), 130)
|
self.assertEqual(pipeline.run_pipeline(self.args), 130)
|
||||||
|
|||||||
@ -97,6 +97,22 @@ class SSHRunTests(unittest.TestCase):
|
|||||||
self.assertEqual(args[args.index("--max-details") + 1], "20")
|
self.assertEqual(args[args.index("--max-details") + 1], "20")
|
||||||
self.assertEqual(args[args.index("--max-pages") + 1], "3")
|
self.assertEqual(args[args.index("--max-pages") + 1], "3")
|
||||||
|
|
||||||
|
def test_incremental_policy_is_host_configurable_and_validated(self):
|
||||||
|
args = host.collection_command({"COLLECT_DETAIL_POLICY": "incremental", "COLLECT_DETAIL_REFRESH_DAYS": "7"})
|
||||||
|
self.assertEqual(args[args.index("--detail-policy") + 1], "incremental")
|
||||||
|
self.assertEqual(args[args.index("--detail-refresh-days") + 1], "7")
|
||||||
|
self.assertNotIn("--detail-policy", host.collection_command({}))
|
||||||
|
for settings in ({"COLLECT_DETAIL_POLICY": "unknown"}, {"COLLECT_DETAIL_REFRESH_DAYS": "0"}):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
host.collection_command(settings)
|
||||||
|
|
||||||
|
def test_fresh_unchanged_skips_are_not_source_failures(self):
|
||||||
|
manifest, pipeline = self.bounded_outcome()
|
||||||
|
manifest["searches"][0] = {"status": "complete"}
|
||||||
|
manifest["details"]["listings"]["1"] = {"status": "not_attempted", "reason": "fresh_unchanged"}
|
||||||
|
self.assertEqual(host.limit_reasons(manifest, pipeline), ["fresh_unchanged"])
|
||||||
|
self.assertIn("fresh unchanged details reused=1", " ".join(host.outcome_summary(manifest, pipeline)))
|
||||||
|
|
||||||
def test_invalid_settings_are_refused(self):
|
def test_invalid_settings_are_refused(self):
|
||||||
for key, value in [("COLLECT_MAX_DETAILS", "0"), ("COLLECT_MAX_DETAILS", "-1"),
|
for key, value in [("COLLECT_MAX_DETAILS", "0"), ("COLLECT_MAX_DETAILS", "-1"),
|
||||||
("COLLECT_MAX_DETAILS", "all; id"), ("COLLECT_MAX_PAGES", "all"),
|
("COLLECT_MAX_DETAILS", "all; id"), ("COLLECT_MAX_PAGES", "all"),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user