feat(translate,scheduler,trace): new-key-only mode, stale PENDING protection, trace_id propagation
Translations: - FR-045: new-key-only execution mode — translate only rows with unseen keys - Compare source row keys against TranslationRecord.source_data from last succeeded run - Baseline expired (>90 days) fallback to full mode with baseline_expired event - run_noop early return when zero new keys (skip LLM + SQL) Scheduler reliability: - Stale PENDING protection: runs older than 1h auto-marked FAILED, no longer block schedule - load_schedules() reloads active translation schedules from DB on restart - add_translation_job/remove_translation_job register/unregister with APScheduler - execution_mode column on translation_schedules with additive DB migration Automation view: - GET /settings/automation/translation-schedules endpoint - Translation schedules displayed on Automation page below validation policies Trace propagation: - seed_trace_id() in all background entry points: TaskManager._run_task/_flusher_loop, SchedulerService._trigger_backup, websocket_endpoint, IdMappingService, all standalone scripts Migrations: - dictionary_entries: origin_run_id, origin_row_key, origin_user_id - translation_schedules: execution_mode Tests: - 3 new test modules (22 tests): core_scheduler, executor_filter, scheduler_execution+guard - Fix pre-existing translate test isolation (conftest.py) - Fix test_list_runs_filter_status parameter
This commit is contained in:
@@ -40,6 +40,7 @@ from .preview import DEFAULT_EXECUTION_PROMPT_TEMPLATE
|
||||
# #region MAX_RETRIES_PER_BATCH [TYPE Constant]
|
||||
# @BRIEF Maximum number of retries for a single batch before marking it failed.
|
||||
MAX_RETRIES_PER_BATCH = 3
|
||||
# #endregion MAX_RETRIES_PER_BATCH
|
||||
|
||||
|
||||
# #region TranslationExecutor [C:4] [TYPE Class]
|
||||
@@ -97,6 +98,20 @@ class TranslationExecutor:
|
||||
self.db.flush()
|
||||
return run
|
||||
|
||||
# Apply new-key-only filtering for scheduled runs
|
||||
if run.trigger_type == "new_key_only":
|
||||
source_rows = self._filter_new_keys(job, run.id, source_rows)
|
||||
if not source_rows:
|
||||
logger.reason("run_noop — no new rows to translate", {
|
||||
"job_id": job.id, "run_id": run.id,
|
||||
})
|
||||
run.status = "COMPLETED"
|
||||
run.insert_status = "skipped"
|
||||
run.total_records = 0
|
||||
run.completed_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
return
|
||||
|
||||
total_rows = len(source_rows)
|
||||
run.total_records = total_rows
|
||||
|
||||
@@ -303,6 +318,74 @@ class TranslationExecutor:
|
||||
return source_rows
|
||||
# endregion _fetch_source_rows
|
||||
|
||||
# region _filter_new_keys [TYPE Function]
|
||||
# @PURPOSE: Filter source rows to only include those with keys absent from the last successful run.
|
||||
# @PRE: job and run are persisted; source_rows is a list of dicts with source_data containing key columns.
|
||||
# @POST: Returns filtered list of source rows with only new keys. Logs skip count.
|
||||
# @SIDE_EFFECT: Queries TranslationRecord from previous runs; no writes.
|
||||
def _filter_new_keys(self, job, run_id: str, source_rows: list) -> list:
|
||||
with belief_scope("TranslationExecutor._filter_new_keys"):
|
||||
# Find most recent COMPLETED run with successful insert for this job
|
||||
prev_run = (
|
||||
self.db.query(TranslationRun)
|
||||
.filter(
|
||||
TranslationRun.job_id == job.id,
|
||||
TranslationRun.status == "COMPLETED",
|
||||
TranslationRun.insert_status == "succeeded",
|
||||
TranslationRun.id != run_id,
|
||||
)
|
||||
.order_by(TranslationRun.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
if not prev_run:
|
||||
logger.reason("No prior successful run — all keys treated as new", {
|
||||
"job_id": job.id,
|
||||
})
|
||||
return source_rows
|
||||
|
||||
# Get successful records from that run
|
||||
prev_records = (
|
||||
self.db.query(TranslationRecord)
|
||||
.filter(
|
||||
TranslationRecord.run_id == prev_run.id,
|
||||
TranslationRecord.status == "SUCCESS",
|
||||
)
|
||||
.all()
|
||||
)
|
||||
if not prev_records:
|
||||
return source_rows
|
||||
|
||||
# Build set of already-translated composite keys
|
||||
key_cols = job.target_key_cols or job.source_key_cols or []
|
||||
if not key_cols:
|
||||
logger.explore("No key columns configured — skipping new-key-only filter",
|
||||
{"job_id": job.id})
|
||||
return source_rows
|
||||
existing_keys = set()
|
||||
for rec in prev_records:
|
||||
sd = rec.source_data or {}
|
||||
key_tuple = tuple(str(sd.get(k, "")) for k in key_cols)
|
||||
existing_keys.add(key_tuple)
|
||||
|
||||
# Filter: keep only rows whose keys are NOT in the existing set
|
||||
filtered = []
|
||||
skipped = 0
|
||||
for row in source_rows:
|
||||
sd = row.get("source_data", {}) or {}
|
||||
key_tuple = tuple(str(sd.get(k, "")) for k in key_cols)
|
||||
if key_tuple not in existing_keys:
|
||||
filtered.append(row)
|
||||
else:
|
||||
skipped += 1
|
||||
|
||||
logger.reason(f"New-key-only filter: {len(source_rows)} total → {len(filtered)} new, {skipped} skipped", {
|
||||
"job_id": job.id,
|
||||
"prev_run_id": prev_run.id,
|
||||
"key_cols": key_cols,
|
||||
})
|
||||
return filtered
|
||||
# endregion _filter_new_keys
|
||||
|
||||
# region _extract_chart_data_rows [TYPE Function]
|
||||
# @PURPOSE: Extract data rows from Superset chart data API response.
|
||||
# @POST: Returns list of dicts with column-value pairs.
|
||||
@@ -763,4 +846,3 @@ class TranslationExecutor:
|
||||
|
||||
# #endregion TranslationExecutor
|
||||
# #endregion TranslationExecutor
|
||||
# #endregion TranslationExecutor
|
||||
|
||||
@@ -19,6 +19,7 @@ from typing import Any, Dict, List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...core.logger import logger, belief_scope
|
||||
from ...core.cot_logger import seed_trace_id
|
||||
from ...core.config_manager import ConfigManager
|
||||
from ...models.translate import TranslationSchedule, TranslationJob, TranslationRun
|
||||
from .events import TranslationEventLog
|
||||
@@ -44,6 +45,7 @@ class TranslationScheduler:
|
||||
cron_expression: str,
|
||||
timezone: str = "UTC",
|
||||
is_active: bool = True,
|
||||
execution_mode: str = "full",
|
||||
) -> TranslationSchedule:
|
||||
with belief_scope("TranslationScheduler.create_schedule"):
|
||||
# Verify job exists
|
||||
@@ -57,6 +59,7 @@ class TranslationScheduler:
|
||||
cron_expression=cron_expression,
|
||||
timezone=timezone,
|
||||
is_active=is_active,
|
||||
execution_mode=execution_mode,
|
||||
created_by=self.current_user,
|
||||
)
|
||||
self.db.add(schedule)
|
||||
@@ -88,6 +91,7 @@ class TranslationScheduler:
|
||||
cron_expression: Optional[str] = None,
|
||||
timezone_str: Optional[str] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
execution_mode: Optional[str] = None,
|
||||
) -> TranslationSchedule:
|
||||
with belief_scope("TranslationScheduler.update_schedule"):
|
||||
schedule = self.db.query(TranslationSchedule).filter(
|
||||
@@ -102,6 +106,8 @@ class TranslationScheduler:
|
||||
schedule.timezone = timezone_str
|
||||
if is_active is not None:
|
||||
schedule.is_active = is_active
|
||||
if execution_mode is not None:
|
||||
schedule.execution_mode = execution_mode
|
||||
schedule.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
self.db.refresh(schedule)
|
||||
@@ -245,8 +251,10 @@ def execute_scheduled_translation(
|
||||
job_id: str,
|
||||
db_session_maker,
|
||||
config_manager: ConfigManager,
|
||||
execution_mode: str = "full",
|
||||
) -> None:
|
||||
"""APScheduler job callback for scheduled translations."""
|
||||
seed_trace_id()
|
||||
db: Session = db_session_maker()
|
||||
try:
|
||||
with belief_scope("execute_scheduled_translation"):
|
||||
@@ -265,26 +273,57 @@ def execute_scheduled_translation(
|
||||
})
|
||||
|
||||
# Concurrency check: max 1 pending/running run per job
|
||||
STALE_THRESHOLD = timedelta(hours=1)
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
active_run = (
|
||||
db.query(TranslationRun)
|
||||
.filter(
|
||||
TranslationRun.job_id == job_id,
|
||||
TranslationRun.status.in_(["PENDING", "RUNNING"]),
|
||||
)
|
||||
.order_by(TranslationRun.created_at.asc()) # oldest first
|
||||
.first()
|
||||
)
|
||||
if active_run:
|
||||
logger.explore("Skipping scheduled run — concurrent run in progress", {
|
||||
"job_id": job_id,
|
||||
"existing_run_id": active_run.id,
|
||||
})
|
||||
event_log = TranslationEventLog(db)
|
||||
event_log.log_event(
|
||||
job_id=job_id,
|
||||
event_type="RUN_STARTED",
|
||||
payload={"reason": "skipped_concurrent", "existing_run_id": active_run.id},
|
||||
is_stale = (
|
||||
active_run.status == "PENDING"
|
||||
and active_run.created_at
|
||||
and (now - active_run.created_at) > STALE_THRESHOLD
|
||||
)
|
||||
return
|
||||
if is_stale:
|
||||
# Mark ALL stale PENDING runs as FAILED
|
||||
stale_runs = (
|
||||
db.query(TranslationRun)
|
||||
.filter(
|
||||
TranslationRun.job_id == job_id,
|
||||
TranslationRun.status == "PENDING",
|
||||
TranslationRun.created_at < (now - STALE_THRESHOLD),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for sr in stale_runs:
|
||||
sr.status = "FAILED"
|
||||
sr.error_message = "Stale: concurrency deadlock — marked by scheduled trigger"
|
||||
sr.completed_at = now
|
||||
db.commit()
|
||||
logger.explore(f"Cleared {len(stale_runs)} stale PENDING run(s), proceeding with scheduled trigger", {
|
||||
"job_id": job_id,
|
||||
"stale_run_ids": [sr.id for sr in stale_runs],
|
||||
})
|
||||
# Proceed to execute the scheduled run (do NOT return)
|
||||
else:
|
||||
logger.explore("Skipping scheduled run — concurrent run in progress", {
|
||||
"job_id": job_id,
|
||||
"existing_run_id": active_run.id,
|
||||
})
|
||||
event_log = TranslationEventLog(db)
|
||||
event_log.log_event(
|
||||
job_id=job_id,
|
||||
event_type="RUN_STARTED",
|
||||
payload={"reason": "skipped_concurrent", "existing_run_id": active_run.id},
|
||||
)
|
||||
return
|
||||
|
||||
# Check baseline expiry
|
||||
baseline_expired = False
|
||||
@@ -315,7 +354,17 @@ def execute_scheduled_translation(
|
||||
job_id=job_id,
|
||||
is_scheduled=True,
|
||||
)
|
||||
run.trigger_type = "scheduled"
|
||||
|
||||
# Determine trigger type based on execution_mode and baseline expiry
|
||||
if execution_mode == "new_key_only" and not baseline_expired:
|
||||
run.trigger_type = "new_key_only"
|
||||
elif execution_mode == "new_key_only" and baseline_expired:
|
||||
logger.reason("Baseline expired — falling back to full translation", {
|
||||
"job_id": job_id, "schedule_id": schedule_id, "age_days": age.days,
|
||||
})
|
||||
run.trigger_type = "scheduled"
|
||||
else:
|
||||
run.trigger_type = "scheduled"
|
||||
db.flush()
|
||||
|
||||
if baseline_expired:
|
||||
|
||||
Reference in New Issue
Block a user