"""Seed the ``hris.full_sync`` scheduled job + trigger an immediate first run. Revision ID: 230 Revises: 229 Create Date: 2026-06-01 Why this migration exists ------------------------- The hourly ``hris.incremental_sync`` cron only pulls SF users whose ``lastModifiedDateTime`` is inside a 30-day hard-capped window — anyone who flipped to ``status='f'`` more than 30 days ago lives in a permanent blind spot. On 2026-06-01 wangdi@pingcap.com (P0175, terminated 2026-03-20, 73 days ago) was reported missing from Helix despite being a real PingCAP ex-employee; he is one of 48 such historical leavers currently absent from ``identity_persons``. PR #588 (2026-05-30) fixed the **code** for the manual full-sync path (``preview_full_employee_sync`` now calls ``list_all_users`` and routes ``status='f'`` to offboard). This migration wires the same path into the scheduler so future drift self-heals within 7 days. Schedule ======== Weekly, Sunday 05:00 SGT (= 21:00 UTC Saturday). Sunday low-traffic window matches the existing ``relationships.full_sync`` cadence; SGT aligns with the operator timezone for ``hris.incremental_sync``. Immediate first run trick ========================= The user-reported case (wangdi) is currently missing — we don't want to wait until next Sunday for the cron to surface him. The worker's schedule-attach loop in ``app/worker.py:310-328`` has a "missed beat" branch that fires the cron 90s after worker boot when the next fire time after ``last_run_at`` is already in the past. By seeding ``last_run_at='1970-01-01'`` the next fire after epoch is also in 1970, which is < now, triggering an immediate run on the next worker reload post-deploy. After the first run the handler updates ``last_run_at`` to ``now()`` and the cron settles into its weekly cadence. The 1970 seed leaves no real trace in audit because no actual run row is written for the seed itself — ``scheduled_job_runs`` only records real executions. Idempotency =========== Existing rows with the same ``name`` are not overwritten — preserves any admin customisation (e.g. an operator who disabled the job or changed the cron). A re-run of this migration after the job has fired once is a no-op. """ from __future__ import annotations import uuid import sqlalchemy as sa from alembic import op revision = "230" down_revision = "229" branch_labels = None depends_on = None _JOB_NAME = "hris.full_sync" def upgrade() -> None: bind = op.get_bind() job_exists = bind.execute( sa.text("SELECT 1 FROM scheduled_jobs WHERE name = :n LIMIT 1"), {"n": _JOB_NAME}, ).scalar() if job_exists: return bind.execute( sa.text( """ INSERT INTO scheduled_jobs (id, name, display_name, description, handler_key, trigger_type, interval_seconds, cron_expr, timezone, enabled, config, run_count, failure_count, last_run_at, created_at, updated_at) VALUES (:id, :name, :display_name, :description, :handler_key, 'cron', NULL, :cron_expr, :timezone, 1, '{}', 0, 0, :last_run_at, NOW(), NOW()) """ ), { "id": str(uuid.uuid4()), "name": _JOB_NAME, "display_name": "HRIS 全量同步(历史离职兜底)", "description": ( "每周对每个已启用的 HRIS 实例做一次全量同步(list_all_users:" "active + forfeited),按 SF status 路由为 onboard / offboard" "事件并自动确认。closes the 30-day incremental blind spot —" "see migration 230 docstring for the wangdi P0175 case." ), "handler_key": _JOB_NAME, # Sunday 05:00 in Asia/Singapore — low-traffic window aligned # with other weekly reconciliations. "cron_expr": "0 5 * * 0", "timezone": "Asia/Singapore", # See "Immediate first run trick" in the module docstring. "last_run_at": "1970-01-01 00:00:00", }, ) def downgrade() -> None: bind = op.get_bind() bind.execute( sa.text("DELETE FROM scheduled_jobs WHERE name = :n"), {"n": _JOB_NAME}, )