fix(sec): warn when deferred imports stay stale

This commit is contained in:
2026-07-31 13:27:31 +02:00
parent f58f8b0818
commit c8c660e63d
8 changed files with 123 additions and 17 deletions
+1
View File
@@ -40,4 +40,5 @@ class DataImportRun(Base):
completed_at: Mapped[datetime | None] = mapped_column( completed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True DateTime(timezone=True), nullable=True
) )
# Failure detail, or the non-error reason when status is deferred.
error_details: Mapped[str | None] = mapped_column(Text, nullable=True) error_details: Mapped[str | None] = mapped_column(Text, nullable=True)
+1 -1
View File
@@ -950,7 +950,7 @@ async def _run_shadow_import(job_name: str, importer: SourceImporter) -> bool:
"""Run an importer and return whether its scheduled job was enabled. """Run an importer and return whether its scheduled job was enabled.
The SEC wrapper uses the return value to run its activated local cache step The SEC wrapper uses the return value to run its activated local cache step
after failed, no-op, promoted, or source-locked attempts while still honoring after deferred, failed, no-op, promoted, or source-locked attempts while honoring
the job-level disable switch. the job-level disable switch.
""" """
_log_event(logging.INFO, "job_start", job=job_name) _log_event(logging.INFO, "job_start", job=job_name)
+49 -6
View File
@@ -30,7 +30,7 @@ import hashlib
import json import json
import logging import logging
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import date, datetime, timezone from datetime import date, datetime, timedelta, timezone
from typing import Any, Protocol, runtime_checkable from typing import Any, Protocol, runtime_checkable
from sqlalchemy import select, text from sqlalchemy import select, text
@@ -69,9 +69,11 @@ class ValidationResult:
summary: dict[str, Any] = field(default_factory=dict) summary: dict[str, Any] = field(default_factory=dict)
source_max_date: date | None = None source_max_date: date | None = None
messages: list[str] = field(default_factory=list) messages: list[str] = field(default_factory=list)
# Expected source-side lag: retry next run without an operational alert. # Expected source-side lag: retry without an immediate error alert. Sources
# This is only meaningful when ok=False. # can bound the quiet period with deferred_alert_after_days. Only meaningful
# when ok=False.
retryable: bool = False retryable: bool = False
deferred_alert_after_days: int | None = None
@runtime_checkable @runtime_checkable
@@ -127,19 +129,41 @@ async def _last_promoted_revision(db: AsyncSession, source: str) -> str | None:
return row.scalar_one_or_none() return row.scalar_one_or_none()
async def _has_promoted_since(db: AsyncSession, source: str, cutoff: datetime) -> bool:
row = await db.execute(
select(DataImportRun.id)
.where(
DataImportRun.source == source,
DataImportRun.status == STATUS_PROMOTED,
DataImportRun.started_at >= cutoff,
)
.limit(1)
)
return row.scalar_one_or_none() is not None
def _now() -> datetime: def _now() -> datetime:
return datetime.now(timezone.utc) return datetime.now(timezone.utc)
async def _alert(db: AsyncSession, source: str, code: str, messages: list[str]) -> None: async def _alert(
db: AsyncSession,
source: str,
code: str,
messages: list[str],
*,
severity: str = "error",
dedup_hours: int = 24,
) -> None:
try: try:
await system_event_service.log_event( await system_event_service.log_event(
db, db,
severity="error", severity=severity,
source="data_import", source="data_import",
code=f"{source}_{code}", code=f"{source}_{code}",
message=(("; ".join(messages)) or code)[:_MAX_ERROR_LEN], message=(("; ".join(messages)) or code)[:_MAX_ERROR_LEN],
dedup_key=f"data_import:{source}:{code}", dedup_key=f"data_import:{source}:{code}",
dedup_hours=dedup_hours,
) )
except Exception: # noqa: BLE001 — alerting must never mask the real outcome except Exception: # noqa: BLE001 — alerting must never mask the real outcome
logger.exception("Failed to emit data_import alert %s/%s", source, code) logger.exception("Failed to emit data_import alert %s/%s", source, code)
@@ -153,7 +177,8 @@ async def run_import(
) -> DataImportRun | None: ) -> DataImportRun | None:
"""Run one import for ``importer``. """Run one import for ``importer``.
Returns the recorded ``DataImportRun`` (promoted / no_op / failed), or None Returns the recorded ``DataImportRun`` (promoted / no_op / deferred /
failed), or None
when the per-source advisory lock is already held (another run is active). when the per-source advisory lock is already held (another run is active).
``force`` runs even when the revision is unchanged. The revision tracks the ``force`` runs even when the revision is unchanged. The revision tracks the
@@ -217,6 +242,24 @@ async def run_import(
if result.retryable: if result.retryable:
run.status = STATUS_DEFERRED run.status = STATUS_DEFERRED
await session.commit() await session.commit()
alert_days = result.deferred_alert_after_days
if alert_days is not None:
alert_days = max(1, alert_days)
cutoff = run.started_at - timedelta(days=alert_days)
if not await _has_promoted_since(session, source, cutoff):
await _alert(
session,
source,
"deferred_stale",
[
f"No successful promotion for at least "
f"{alert_days} day(s); deferred source lag may "
f"now hide aged-out unresolved items: "
f"{run.error_details or 'validation deferred'}"
],
severity="warning",
dedup_hours=alert_days * 24,
)
logger.info( logger.info(
"data_import %s: deferred for retry: %s", "data_import %s: deferred for retry: %s",
source, source,
+4 -3
View File
@@ -413,14 +413,15 @@ class SecFundamentalsImporter:
summary=summary, summary=summary,
source_max_date=self._latest_index_date, source_max_date=self._latest_index_date,
messages=messages, messages=messages,
# Pure Company-Facts publication lag is expected and self-healing: # Company-Facts absence is usually publication lag, but can also be a
# keep the cursor in place and retry, but do not page operators. Any # permanent co-registrant misfile that the daily index did not expose.
# other missing reason or validation message remains a real failure. # Defer quietly at first; the framework warns if promotions stay stale.
retryable=( retryable=(
len(messages) == 1 len(messages) == 1
and bool(blocking) and bool(blocking)
and all(m.get("reason") == "not_in_companyfacts" for m in blocking) and all(m.get("reason") == "not_in_companyfacts" for m in blocking)
), ),
deferred_alert_after_days=MISSING_XBRL_RETRY_DAYS,
) )
async def promote(self, db, staged: StagedFundamentals, run_id: int) -> dict[str, int]: async def promote(self, db, staged: StagedFundamentals, run_id: int) -> dict[str, int]:
@@ -25,7 +25,7 @@ function formatAgo(iso: string | null | undefined): string {
function lastRunColor(status: string | null | undefined): string { function lastRunColor(status: string | null | undefined): string {
if (status === 'error') return 'text-red-300'; if (status === 'error') return 'text-red-300';
if (status === 'rate_limited') return 'text-amber-300'; if (status === 'rate_limited' || status === 'deferred') return 'text-amber-300';
return 'text-gray-500'; return 'text-gray-500';
} }
@@ -127,7 +127,7 @@ export function JobControls() {
className={`text-[11px] font-medium ${ className={`text-[11px] font-medium ${
job.running job.running
? 'text-blue-300' ? 'text-blue-300'
: job.runtime_status === 'rate_limited' : job.runtime_status === 'rate_limited' || job.runtime_status === 'deferred'
? 'text-amber-300' ? 'text-amber-300'
: job.runtime_status === 'error' : job.runtime_status === 'error'
? 'text-red-300' ? 'text-red-300'
@@ -140,6 +140,8 @@ export function JobControls() {
? 'Running' ? 'Running'
: job.runtime_status === 'rate_limited' : job.runtime_status === 'rate_limited'
? 'Paused (rate-limited)' ? 'Paused (rate-limited)'
: job.runtime_status === 'deferred'
? 'Deferred (retrying)'
: job.runtime_status === 'error' : job.runtime_status === 'error'
? 'Last run error' ? 'Last run error'
: job.enabled : job.enabled
+30 -2
View File
@@ -17,7 +17,7 @@ from __future__ import annotations
import asyncio import asyncio
import os import os
import tempfile import tempfile
from datetime import date, datetime, timezone from datetime import date, datetime, timedelta, timezone
import pytest import pytest
from sqlalchemy import func, select from sqlalchemy import func, select
@@ -68,10 +68,14 @@ class FakeImporter:
source = "sec_facts" source = "sec_facts"
def __init__(self, revision, *, ok=True, retryable=False, n_rows=3, raise_in="none"): def __init__(
self, revision, *, ok=True, retryable=False, alert_days=None,
n_rows=3, raise_in="none",
):
self.revision = revision self.revision = revision
self.ok = ok self.ok = ok
self.retryable = retryable self.retryable = retryable
self.alert_days = alert_days
self.n_rows = n_rows self.n_rows = n_rows
self.raise_in = raise_in self.raise_in = raise_in
self.staged_called = False self.staged_called = False
@@ -97,6 +101,7 @@ class FakeImporter:
source_max_date=date(2026, 7, 21), source_max_date=date(2026, 7, 21),
messages=[] if self.ok else ["coverage below threshold"], messages=[] if self.ok else ["coverage below threshold"],
retryable=self.retryable, retryable=self.retryable,
deferred_alert_after_days=self.alert_days,
) )
async def promote(self, db, staged, run_id): async def promote(self, db, staged, run_id):
@@ -217,6 +222,29 @@ async def test_retryable_validation_defers_without_alerting(engine):
assert await _count(factory, SystemEvent) == 0 # expected retry does not alert assert await _count(factory, SystemEvent) == 0 # expected retry does not alert
async def test_stale_deferred_validation_emits_deduplicated_warning(engine):
factory = _factory(engine)
promoted = await run_import(FakeImporter("rev1", n_rows=3), engine=engine)
async with factory() as s:
promoted.started_at = datetime.now(timezone.utc) - timedelta(days=4)
await s.merge(promoted)
await s.commit()
importer = FakeImporter(
"rev2", ok=False, retryable=True, alert_days=3, n_rows=5
)
first = await run_import(importer, engine=engine)
second = await run_import(importer, engine=engine)
assert first is not None and first.status == STATUS_DEFERRED
assert second is not None and second.status == STATUS_DEFERRED
async with factory() as s:
events = (await s.execute(select(SystemEvent))).scalars().all()
assert len(events) == 1
assert events[0].severity == "warning"
assert events[0].code == "sec_facts_deferred_stale"
async def test_exception_in_promote_rolls_back(engine): async def test_exception_in_promote_rolls_back(engine):
factory = _factory(engine) factory = _factory(engine)
await run_import(FakeImporter("rev1", n_rows=3), engine=engine) # baseline await run_import(FakeImporter("rev1", n_rows=3), engine=engine) # baseline
+3 -2
View File
@@ -20,6 +20,7 @@ from app.scheduler import (
queue_backtest_target_model, queue_backtest_target_model,
scheduler, scheduler,
) )
from app.services.data_import import STATUS_DEFERRED
def test_manual_backtest_target_model_is_one_shot(): def test_manual_backtest_target_model_is_one_shot():
@@ -256,7 +257,7 @@ class TestShadowImportJobs:
async def imported(importer): async def imported(importer):
return SimpleNamespace( return SimpleNamespace(
status="deferred", status=STATUS_DEFERRED,
revision="abcdef1234567890", revision="abcdef1234567890",
error_details="Company Facts publication lag; retrying", error_details="Company Facts publication lag; retrying",
) )
@@ -268,7 +269,7 @@ class TestShadowImportJobs:
await _run_shadow_import("sec_fundamentals_import", object()) await _run_shadow_import("sec_fundamentals_import", object())
runtime = get_job_runtime_snapshot("sec_fundamentals_import") runtime = get_job_runtime_snapshot("sec_fundamentals_import")
assert runtime["status"] == "deferred" assert runtime["status"] == STATUS_DEFERRED
assert runtime["processed"] == 0 assert runtime["processed"] == 0
assert runtime["message"] == "Company Facts publication lag; retrying" assert runtime["message"] == "Company Facts publication lag; retrying"
+31 -1
View File
@@ -23,7 +23,11 @@ from app.services.data_import import (
STATUS_PROMOTED, STATUS_PROMOTED,
run_import, run_import,
) )
from app.services.sec_fundamentals_importer import SecFundamentalsImporter from app.services.sec_fundamentals_importer import (
SecFundamentalsImporter,
StagedFundamentals,
)
from app.services.sec_universe import ResolvedUniverse
@pytest.fixture @pytest.fixture
@@ -232,6 +236,32 @@ async def test_consistency_gate_defers_without_alert_when_facts_lag_index(engine
assert await _count(factory, SystemEvent) == 0 # expected SEC lag does not alert assert await _count(factory, SystemEvent) == 0 # expected SEC lag does not alert
async def test_companyfacts_lag_does_not_mask_second_validation_failure():
importer = SecFundamentalsImporter(today=date(2026, 5, 3))
importer._latest_index_date = date(2026, 5, 2)
staged = StagedFundamentals(
resolved=ResolvedUniverse(),
missing_xbrl=[{
"cik": "0000320193",
"accession": "GHOST",
"form": "10-Q",
"index_date": date(2026, 5, 1),
"age_days": 2,
"reason": "not_in_companyfacts",
}],
invalid_payloads=[{
"cik": "0000789019",
"reason": "missing facts structure",
}],
)
result = await importer.validate(None, staged)
assert not result.ok
assert not result.retryable
assert len(result.messages) == 2
async def test_gate_separates_missing_submissions_from_missing_facts(engine): async def test_gate_separates_missing_submissions_from_missing_facts(engine):
"""An index row the issuer's own filing list does not carry is a different """An index row the issuer's own filing list does not carry is a different
failure from a Company-Facts lag, and must not be reported as one.""" failure from a Company-Facts lag, and must not be reported as one."""