Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions backend/app/cron_tz.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"""Zone-aware cron schedules — durable IANA identity, materialized entries.

Cron itself evaluates wall-clock time in the server's local timezone, which
is not a durable way to express "5:00 AM in Europe/Belgrade": the server's
offset to that zone changes at daylight-saving transitions, in either zone.

The durable schedule identity is therefore an IANA timezone name plus a
zone-local daily cron expression, declared in the app's ``init-cron.sh``
(``SCHEDULE_TZ`` / ``SCHEDULE_SOURCE``). The crontab line cron actually runs
is a *materialization* of that identity into the server's current clock,
recomputed at boot and periodically at runtime so an offset change in either
zone reschedules the entry instead of silently drifting the wall time.

Only simple daily expressions (numeric minute + hour, ``* * *`` date fields)
may carry a timezone: shifting an expression across an offset can cross a day
boundary, which is well-defined for a daily job and ambiguous for date-pinned
ones.
"""

from __future__ import annotations

import os
import re
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo

_DAILY_CRON_RE = re.compile(
r"^\s*(\d{1,2})\s+(\d{1,2})\s+\*\s+\*\s+\*\s*$"
)
# Written by init-cron-scaffold.sh; parsed (never executed) from init-cron.sh.
_DECL_TZ_RE = re.compile(r'^SCHEDULE_TZ="([A-Za-z0-9_+/-]+)"\s*$', re.M)
_DECL_SOURCE_RE = re.compile(r'^SCHEDULE_SOURCE="([^"\n]+)"\s*$', re.M)


def valid_timezone(name: str) -> bool:
"""True when ``name`` is a resolvable IANA timezone identifier."""
if not isinstance(name, str) or not name or len(name) > 64:
return False
if not re.fullmatch(r"[A-Za-z0-9_+/-]+", name):
return False
try:
ZoneInfo(name)
except Exception:
return False
return True


def parse_daily_cron(expr: str) -> tuple[int, int] | None:
"""Returns (minute, hour) for a plain daily cron, else None."""
m = _DAILY_CRON_RE.match(expr or "")
if not m:
return None
minute, hour = int(m.group(1)), int(m.group(2))
if minute > 59 or hour > 23:
return None
return minute, hour


def materialize_zone_cron(
zone_cron: str,
tz_name: str,
*,
now: datetime | None = None,
server_tz=None,
) -> str:
"""Expresses a zone-local daily cron in the server's current clock.

``now`` and ``server_tz`` exist for tests; production uses the real clock
and the process-local timezone. The conversion is exact for the current
offset pair — the caller re-materializes when offsets change, which is the
whole durability contract.

Raises ValueError for a non-daily expression or unknown timezone.
"""
parsed = parse_daily_cron(zone_cron)
if parsed is None:
raise ValueError(
"A timezone-owned schedule must be a plain daily cron "
f"('m h * * *'), got: {zone_cron!r}"
)
minute, hour = parsed
if not valid_timezone(tz_name):
raise ValueError(f"Unknown IANA timezone: {tz_name!r}")
tz = ZoneInfo(tz_name)
moment = (now or datetime.now(tz)).astimezone(tz)
local = moment.replace(hour=hour, minute=minute, second=0, microsecond=0)
server = local.astimezone(server_tz)
return f"{server.minute} {server.hour} * * *"


def parse_zone_declaration(init_cron_text: str) -> tuple[str, str] | None:
"""Extracts (timezone, zone_cron) from init-cron.sh text, if declared.

Returns None when either variable is absent or invalid — an app whose
declaration was hand-edited into an inconsistent state falls back to plain
server-local scheduling rather than guessing.
"""
tz_match = _DECL_TZ_RE.search(init_cron_text or "")
source_match = _DECL_SOURCE_RE.search(init_cron_text or "")
if not tz_match or not source_match:
return None
tz_name = tz_match.group(1)
zone_cron = source_match.group(1).strip()
if not valid_timezone(tz_name) or parse_daily_cron(zone_cron) is None:
return None
return tz_name, zone_cron


def server_timezone_name() -> str:
"""The server clock's IANA identity: TZ env, /etc/localtime, else UTC.

Containers conventionally run UTC with neither signal present; "UTC" is a
valid IANA identifier, so the fallback stays truthful for them.
"""
tz_env = os.environ.get("TZ", "").strip()
if tz_env and valid_timezone(tz_env):
return tz_env
try:
target = Path("/etc/localtime").resolve()
parts = target.parts
if "zoneinfo" in parts:
name = "/".join(parts[parts.index("zoneinfo") + 1:])
if valid_timezone(name):
return name
except OSError:
pass
return "UTC"
42 changes: 41 additions & 1 deletion backend/app/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -1127,9 +1127,18 @@ def _process_icon(raw: bytes) -> bytes:


def _register_cron(slug: str, schedule_expr: str, job_path: Path,
app_id: int | None = None) -> None:
app_id: int | None = None,
timezone: str | None = None,
zone_cron: str | None = None) -> None:
"""Runs init-cron-scaffold.sh to install the crontab entry.

``timezone``/``zone_cron`` (given together) declare the schedule's durable
zone-aware identity: ``schedule_expr`` is then its server-local
materialization, and the identity is appended to ``init-cron.sh`` after the
scaffold rewrites it, so reconciliation can re-materialize the entry when
either zone's UTC offset changes. The scaffold's own contract stays
unchanged; the platform owns the declaration lines (see app.cron_tz).

The scaffold writes both the durable ``init-cron.sh`` declaration and the
live crontab entry. On restart, lifespan parses that declaration and rewrites
it through the supervised runner; app-owned shell is never executed merely
Expand Down Expand Up @@ -1180,6 +1189,37 @@ def _register_cron(slug: str, schedule_expr: str, job_path: Path,
500,
f"Cron registration failed: {result.stderr.strip()[:400]}",
)
if (timezone is None) != (zone_cron is None):
raise HTTPException(
500, "Cron registration bug: timezone and zone_cron must be paired.",
)
if timezone is not None:
from app import cron_tz

if not cron_tz.valid_timezone(timezone):
raise HTTPException(500, f"Unknown IANA timezone: {timezone!r}")
if cron_tz.parse_daily_cron(zone_cron) is None:
raise HTTPException(
500, f"Zone-owned schedule must be a plain daily cron: {zone_cron!r}",
)
init_path = job_path.parent / "init-cron.sh"
try:
# The scaffold just rewrote init-cron.sh from its template, so a plain
# append is deterministic and repeat-registration cannot duplicate.
with init_path.open("a", encoding="utf-8") as fh:
fh.write(
"\n# --- Zone-aware schedule identity "
"(platform-managed; parsed, never executed).\n"
"# ENTRY above is the server-local materialization of this durable\n"
"# identity, recomputed at boot and periodically so DST transitions\n"
"# reschedule the entry instead of drifting its wall time.\n"
f'SCHEDULE_TZ="{timezone}"\n'
f'SCHEDULE_SOURCE="{zone_cron}"\n'
)
except OSError as exc:
raise HTTPException(
500, f"Could not persist schedule timezone: {exc}",
) from exc


def _reconcile_cron_after_install_rollback() -> None:
Expand Down
46 changes: 46 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,50 @@ async def lifespan(app):
_cron_ready.write_text(f"{_BOOT_ID}\n", encoding="utf-8")
except Exception as exc:
_log.error("app cron supervision wiring failed: %s", exc, exc_info=True)
# Periodic re-materialization for timezone-owned schedules (app.cron_tz):
# a schedule declared as "wall time in an IANA zone" must be rescheduled
# when either that zone's or the server's UTC offset changes (DST). Boot
# reconciliation covers restarts; this hourly pass covers long-running
# containers crossing a transition. It reuses the same idempotent
# reconciler, and only runs while any zone declaration actually exists.
_cron_tz_task = None
try:
from app.routes.apps import (
_app_zone_declaration as _zone_decl,
reconcile_app_cron_supervision as _cron_reconcile,
)
from app.database import SessionLocal as _CronTzSession

async def _cron_tz_loop():
while True:
await _asyncio.sleep(3600)
try:
def _pass():
from app import models as _tz_models
_db = _CronTzSession()
try:
_live = _db.query(_tz_models.App).filter(
_tz_models.App.deleted_at.is_(None)
).all()
if not any(_zone_decl(_a) for _a in _live):
return None
return _cron_reconcile(_db)
finally:
_db.close()
_result = await _asyncio.to_thread(_pass)
if _result is not None:
for _warning in _result[1]:
_log.warning("cron tz re-materialization: %s", _warning)
except _asyncio.CancelledError:
raise
except Exception as _exc:
_log.error(
"cron tz re-materialization failed: %s", _exc, exc_info=True,
)

_cron_tz_task = _asyncio.create_task(_cron_tz_loop())
except Exception as exc:
_log.error("cron tz loop wiring failed: %s", exc, exc_info=True)
record_memory_checkpoint("startup_metadata_reconciled")
# Route provider model-registry and memory diagnostics to the durable
# rotating chat.log handler. These loggers otherwise land only on
Expand Down Expand Up @@ -665,6 +709,8 @@ async def _browser_profile_loop():
_reset_park_task.cancel()
if _browser_profile_task is not None:
_browser_profile_task.cancel()
if _cron_tz_task is not None:
_cron_tz_task.cancel()
if _writer_supervisor_task is not None:
_writer_supervisor_task.cancel()
# Drain + join the chat-writer actor so any in-flight persistence
Expand Down
78 changes: 74 additions & 4 deletions backend/app/routes/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,26 @@ def _app_schedule(app: models.App, live_crontab: str) -> tuple[str, str] | None:
return _manifest_schedule(source_dir)


def _app_zone_declaration(app: models.App) -> tuple[str, str] | None:
"""The schedule's durable (timezone, zone_cron) identity, if declared.

Read from the init-cron.sh declaration lines the platform appends when a
schedule is owned in an IANA zone (see app.cron_tz / _register_cron).
"""
from app import cron_tz

if not app.source_dir:
return None
source_dir = Path(app.source_dir)
for replay_dir in _cron_replay_dirs_for_app(app, source_dir):
declaration = cron_tz.parse_zone_declaration(
_read_init_cron_text(replay_dir),
)
if declaration is not None:
return declaration
return None


def reconcile_app_cron_supervision(db: Session) -> tuple[int, list[str]]:
"""Converge every live managed schedule through the common job runner.

Expand All @@ -600,7 +620,13 @@ def reconcile_app_cron_supervision(db: Session) -> tuple[int, list[str]]:
crontab entry and its durable declaration via the current
scaffold. Tombstoned apps are excluded and source trees must be ordinary,
non-symlink direct children of ``/data/apps``.

A schedule owned in an IANA timezone (see ``app.cron_tz``) is
re-materialized here from its durable (timezone, zone_cron) identity
rather than preserved verbatim — this pass, run at boot and periodically
at runtime, is what reschedules the entry across DST transitions.
"""
from app import cron_tz
from app.install import _register_cron

settings = get_settings()
Expand Down Expand Up @@ -632,9 +658,19 @@ def reconcile_app_cron_supervision(db: Session) -> tuple[int, list[str]]:
try:
if job_path.is_symlink() or not job_path.is_file():
raise ValueError(f"job is missing or a symlink: {job_name}")
_register_cron(
resolved_source.name, cron, job_path, app.id,
)
declaration = _app_zone_declaration(app)
if declaration is not None:
timezone, zone_cron = declaration
_register_cron(
resolved_source.name,
cron_tz.materialize_zone_cron(zone_cron, timezone),
job_path, app.id,
timezone=timezone, zone_cron=zone_cron,
)
else:
_register_cron(
resolved_source.name, cron, job_path, app.id,
)
except Exception as exc:
warnings.append(f"app {app.id}: {exc}")
continue
Expand Down Expand Up @@ -723,7 +759,10 @@ def list_app_schedules(
_: models.Owner = Depends(get_current_owner_or_app),
):
"""Returns read-only recurring app schedules visible to owners and apps."""
from app import cron_tz

live_crontab = _read_live_crontab()
server_timezone = cron_tz.server_timezone_name()
rows = []
apps = (
db.query(models.App)
Expand All @@ -736,12 +775,16 @@ def list_app_schedules(
if schedule is None:
continue
cron, job = schedule
declaration = _app_zone_declaration(app)
rows.append(schemas.AppScheduleOut(
id=app.id,
name=app.name,
slug=app.slug,
cron=cron,
job=job,
timezone=declaration[0] if declaration else None,
zone_cron=declaration[1] if declaration else None,
server_timezone=server_timezone,
))
return rows

Expand Down Expand Up @@ -2863,11 +2906,27 @@ def update_app_schedule(
raise HTTPException(
status_code=400, detail="App has no source_dir; cannot locate job.",
)
from app import cron_tz
from app.install import _register_cron
try:
validate_cron_expr(body.cron)
except ManifestContractError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
timezone = (body.timezone or "").strip() or None
if timezone is not None:
# A zone-owned schedule is durable data: body.cron is the daily wall
# time in that zone, and the crontab entry is its server-local
# materialization, re-materialized when either zone's offset changes.
if not cron_tz.valid_timezone(timezone):
raise HTTPException(
status_code=400, detail=f"Unknown IANA timezone: {timezone!r}",
)
if cron_tz.parse_daily_cron(body.cron) is None:
raise HTTPException(
status_code=400,
detail="A timezone-owned schedule must be a plain daily cron "
"('m h * * *').",
)
source_dir = Path(app.source_dir)
job_name = body.job or "fetch.sh"
if "/" in job_name or "\\" in job_name or not job_name.strip():
Expand All @@ -2876,8 +2935,19 @@ def update_app_schedule(
if not job_path.is_file():
raise HTTPException(status_code=400, detail="Job script not found.")
slug = app.slug or _slugify_for_source_dir(app.name)
if timezone is not None:
materialized = cron_tz.materialize_zone_cron(body.cron, timezone)
_register_cron(
slug, materialized, job_path, app_id,
timezone=timezone, zone_cron=body.cron,
)
return {
"cron": materialized, "job": job_name,
"timezone": timezone, "zone_cron": body.cron,
}
_register_cron(slug, body.cron, job_path, app_id)
return {"cron": body.cron, "job": job_name}
return {"cron": body.cron, "job": job_name, "timezone": None,
"zone_cron": None}


def _etag_for_app(app: models.App) -> str | None:
Expand Down
Loading