2
from collections import defaultdict
3
from datetime import datetime
4
from datetime import timedelta
5
+from datetime import timezone as dt_timezone
6
from typing import Dict
7
from typing import List
8
from typing import Optional
9
from typing import Tuple
10
+from zoneinfo import ZoneInfo
11
+from zoneinfo import ZoneInfoNotFoundError
12
13
from loguru import logger
14
from sqlalchemy import select
207
return indices_to_snapshot, skipped_write_indices
208
209
210
+# Tolerance window (minutes) used when matching scheduled_minute. Should be
211
+# >= the master poll cadence to avoid missing a slot. Master poll runs every
212
+# 15 minutes (see app/schedulers/scheduler.py initialize_job_metadata).
213
+SCHEDULE_MATCH_TOLERANCE_MINUTES = 15
214
+
215
+
216
+def _is_schedule_due(schedule: SnapshotSchedule, now_utc: Optional[datetime] = None) -> Tuple[bool, str]:
217
+ """
218
+ Determine whether a snapshot schedule is due to run based on its
219
+ scheduled_hour, scheduled_minute, interval_days, and timezone fields.
220
+
221
+ Returns:
222
+ (due, reason). When due is False, reason explains why so it can be
223
+ recorded as last_execution_status (e.g., "DEFERRED: outside window").
224
+ """
225
+ if now_utc is None:
226
+ now_utc = datetime.now(dt_timezone.utc)
227
+ elif now_utc.tzinfo is None:
228
+ now_utc = now_utc.replace(tzinfo=dt_timezone.utc)
229
+
230
+ # Resolve the schedule's timezone (fall back to UTC on any issue).
231
+ try:
232
+ tz = ZoneInfo(schedule.timezone or "UTC")
233
+ except ZoneInfoNotFoundError:
234
+ logger.warning(
235
+ f"Schedule {schedule.name}: invalid timezone '{schedule.timezone}', falling back to UTC",
236
+ )
237
+ tz = ZoneInfo("UTC")
238
+ now_local = now_utc.astimezone(tz)
239
+
240
+ # interval_days gate — compare local calendar dates so a schedule with
241
+ # interval_days=1 fires at most once per local day.
242
+ interval_days = schedule.interval_days or 1
243
+ if schedule.last_execution_time is not None:
244
+ last_utc = schedule.last_execution_time
245
+ if last_utc.tzinfo is None:
246
+ last_utc = last_utc.replace(tzinfo=dt_timezone.utc)
247
+ last_local_date = last_utc.astimezone(tz).date()
248
+ days_elapsed = (now_local.date() - last_local_date).days
249
+ if days_elapsed < interval_days:
250
+ return (
251
+ False,
252
+ f"DEFERRED: interval_days={interval_days}, last run {days_elapsed} day(s) ago",
253
+ )
254
+
255
+ # Day-of-week gate (Python convention: Monday=0 ... Sunday=6).
256
+ # Combines with interval_days for patterns like "every other Sunday".
257
+ if schedule.day_of_week is not None:
258
+ if now_local.weekday() != schedule.day_of_week:
259
+ weekday_names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
260
+ reason = f"DEFERRED: not scheduled day (want={weekday_names[schedule.day_of_week]}, today={weekday_names[now_local.weekday()]} {tz.key})"
261
+ logger.info(reason)
262
+ return False, reason
263
+
264
+ # Time-of-day gate — only enforced when scheduled_hour is set.
265
+ if schedule.scheduled_hour is not None:
266
+ if now_local.hour != schedule.scheduled_hour:
267
+ reason = f"DEFERRED: outside scheduled hour ({schedule.scheduled_hour:02d}:xx {tz.key})"
268
+ logger.info(reason)
269
+ return False, reason
270
+ if schedule.scheduled_minute is not None:
271
+ in_window = schedule.scheduled_minute <= now_local.minute < schedule.scheduled_minute + SCHEDULE_MATCH_TOLERANCE_MINUTES
272
+ if not in_window:
273
+ reason = f"DEFERRED: outside scheduled minute window ({schedule.scheduled_hour:02d}:{schedule.scheduled_minute:02d} +{SCHEDULE_MATCH_TOLERANCE_MINUTES}min {tz.key})"
274
+ logger.info(reason)
275
+ return False, reason
276
+
277
+ return True, ""
278
+
279
+
280
def _schedule_to_response(schedule: SnapshotSchedule) -> SnapshotScheduleResponse:
281
"""Convert a SnapshotSchedule model to a response model."""
282
return SnapshotScheduleResponse(
292
last_execution_time=schedule.last_execution_time.isoformat() if schedule.last_execution_time else None,
293
last_snapshot_name=schedule.last_snapshot_name,
294
last_execution_status=schedule.last_execution_status,
295
+ scheduled_hour=schedule.scheduled_hour,
296
+ scheduled_minute=schedule.scheduled_minute,
297
+ interval_days=schedule.interval_days,
298
+ day_of_week=schedule.day_of_week,
299
+ timezone=schedule.timezone,
300
created_at=schedule.created_at.isoformat(),
301
updated_at=schedule.updated_at.isoformat(),
302
)
724
include_global_state=request.include_global_state if request.include_global_state is not None else False,
725
skip_write_indices=request.skip_write_indices if request.skip_write_indices is not None else True,
726
retention_days=request.retention_days,
727
+ scheduled_hour=request.scheduled_hour,
728
+ scheduled_minute=request.scheduled_minute,
729
+ interval_days=request.interval_days if request.interval_days is not None else 1,
730
+ day_of_week=request.day_of_week,
731
+ timezone=request.timezone or "UTC",
732
)
733
734
session.add(schedule)
884
schedule.skip_write_indices = request.skip_write_indices
885
if request.retention_days is not None:
886
schedule.retention_days = request.retention_days
887
+ # Allow explicit NULL on scheduled_hour/minute so users can clear
888
+ # the time-of-day gate via the API (Pydantic tracks set fields).
889
+ fields_set = request.__fields_set__
890
+ if "scheduled_hour" in fields_set:
891
+ schedule.scheduled_hour = request.scheduled_hour
892
+ if "scheduled_minute" in fields_set:
893
+ schedule.scheduled_minute = request.scheduled_minute
894
+ if request.interval_days is not None:
895
+ schedule.interval_days = request.interval_days
896
+ if "day_of_week" in fields_set:
897
+ schedule.day_of_week = request.day_of_week
898
+ if request.timezone is not None:
899
+ schedule.timezone = request.timezone
900
901
schedule.updated_at = datetime.utcnow()
902
1108
logger.info(f"Executing snapshot schedule: {schedule.name} (ID: {schedule.id})")
1109
1110
try:
1111
+ # Time-of-day / day-interval gate. Cheap check that runs before any
1112
+ # indexer API calls — defers the snapshot until it falls inside the
1113
+ # configured window. Legacy schedules with no scheduling fields set
1114
+ # behave exactly as before (always due, every poll).
1115
+ due, defer_reason = _is_schedule_due(schedule)
1116
+ if not due:
1117
+ logger.info(f"Schedule {schedule.name}: {defer_reason}")
1118
+ schedule.last_execution_status = defer_reason
1119
+ schedule.updated_at = datetime.utcnow()
1120
+ try:
1121
+ await session.commit()
1122
+ except Exception:
1123
+ await session.rollback()
1124
+ return ScheduledSnapshotExecutionResponse(
1125
+ schedule_id=schedule.id,
1126
+ schedule_name=schedule.name,
1127
+ snapshot_name=None,
1128
+ indices_snapshotted=[],
1129
+ skipped_write_indices=[],
1130
+ already_snapshotted_indices=[],
1131
+ success=True,
1132
+ message=defer_reason,
1133
+ )
1134
+
1135
# Determine which indices need to be snapshotted
1136
indices_to_snapshot, skipped_write_indices, already_snapshotted = await get_indices_needing_snapshot(schedule)
1137