fix(occupancy): address holiday stamping follow-ups #267
No reviewers
Labels
No labels
blocked
bug
enhancement
high-priority
low-priority
needs-info
needs-triage
ready-for-agent
ready-for-human
referenced
research
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
gabogg/hikcentral!267
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/holiday-stamping-followups"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #244, Closes #245, Closes #246
Summary
Resolves the three P3 follow-ups from PR #178 regarding holiday stamping consistency, migration idempotence, and atomic exception writes, incorporating review pass 1 (r57) updates:
get_trusted_calibration_history_conn_async) and spike reference query (get_recent_trusted_cycle_ingress_async) now fall back via hoisted_HOLIDAY_CYCLE_DATES_SQLtooccupancy_holidayswhen a day has no business-day schedule record (WHERE is_holiday = 1 AND holiday_date NOT IN (SELECT day_date FROM occupancy_business_day_schedules)), ensuring all readers agree while preserving the primacy of frozen records. The reconcile auto-stamp was removed fromreconcile_historical_calibrations_asyncbecause the SQL fallback fully resolves the reader gap without mutating draft schedules (preserving CONTEXT.md:131/:134 invariants).20261003_holiday_stamping_backfill) so databases that previously ran20261002_holiday_backfillstill receive stamped records. The migration and runtime readers reuse shared pure helpers inapp.facility_time(schedule_calendar_from_rows,stamped_from_planned) and_BUSINESS_DAY_SCHEDULE_UPSERT_SQLinoccupancy_repository, ensuring zero drift. Unnamed exceptions produceexception_name = None(NULL in DB), matching #222.BEGIN IMMEDIATE) inadd_holiday_asyncandclassify_holiday_asyncthat writes the exception, the STAMPED record (if unrecorded and completed), and the audits together. If stamping raises, fails on lock, or is cancelled, the entire transaction rolls back cleanly without orphan rows or audit noise.Architectural Impact
app/db/database.py): Added migration step20261003_holiday_stamping_backfillreusingschedule_calendar_from_rows,stamped_from_planned, and repository upsert SQL and parameters.app/facility_time.py): Added pure helpersschedule_calendar_from_rowsandstamped_from_planned. Baseline weekly hours useDEFAULT_DAILY_SCHEDULEfrom master.app/db/occupancy_repository.py): Hoisted_HOLIDAY_CYCLE_DATES_SQLfor holiday reader subqueries. Addedstamped_scheduleparameter toadd_holiday_asyncandclassify_holiday_asyncto execute within atomicBEGIN IMMEDIATEtransaction.app/services/occupancy_service.py): Precomputes stamped schedule if completing past/active day and passes to repo methods; removed compensating try/except rollback and removed auto-stamping fromreconcile_historical_calibrations_async.Verification / Test Evidence
test_missed_freeze_holiday_backfilled_all_readers_agree).20261003_holiday_stamping_backfillstamps past holidays on DBs with20261002_holiday_backfillalready present (test_migration_stamps_never_stamped_past_holiday_all_readers_agree).exception_name(test_migration_stamped_rows_match_service_stamped_rows).CancelledError, and lock failure (test_add_schedule_exception_stamp_failure_rolls_back_exception,test_add_schedule_exception_cancelled_error_and_lock_failure,test_classify_holiday_stamp_failure_rolls_back_classification).tests/test_holiday_event_context.pypass.ruff checkandruff formatare clean.python3 scripts/check_docs.pypassed (43 Markdown files and 90 HTTP operations verified).Checklist
20261003_holiday_stamping_backfillreusing pure helpersschedule_calendar_from_rowsandstamped_from_plannedBEGIN IMMEDIATE) atomically writes exception and STAMPED schedule record, withCancelledErrorand lock testsorigin/master(9a608c1) withDEFAULT_DAILY_SCHEDULEbaselineruff), doc checker (scripts/check_docs.py), and test suite (pytest)WIP: fix(occupancy): address holiday stamping follow-upsto fix(occupancy): address holiday stamping follow-upsImplementation complete. Ready for review; requesting pass 1.
Code review, pass 1 (
origin/master...b4c9fd5, spec #244 + #245 + #246)Summary: 0 P1, 3 P2s, a master clash, and P3s. Pass 1: fix everything.
The #246 reader fallback works and is reproduced by tests. The #244 migration is idempotent.
Must fix:
occupancy_service.py:2544-2548). This breaks CONTEXT.md:131 and :134: a day without a record stays unrecorded until an admin stamps it, and an imported day's original schedule comes from its approved draft. For M1, the first startup after a historical import would freeze every imported day's schedule before review. The SQL fallback already fixes #246, so remove the stamp.database.py:600-690) still copies the calendar loader, the STAMPED record construction and the insert/audit SQL, and the copies have already drifted: an empty name gives""in the migration but the ISO date at runtime. Extract shared pure helpers and reuse_BUSINESS_DAY_SCHEDULE_UPSERT_SQL. Note that #222's P2-A changes the runtime side to null, so align on null.CancelledError, it masks the original error when the undo itself hits "database is locked", and it leaves CREATE/DELETE audit noise. Write the exception and the stamp in one repository transaction (BEGIN IMMEDIATE).DEFAULT_WEEKDAY_HOURSindatabase.py, and master removed that import. The merge is textually clean, but ruff reports F821. Merge origin/master and use master's seed defaults.Standards axis
Scope: app/db/database.py, app/db/occupancy_repository.py, app/services/occupancy_service.py, tests/test_holiday_event_context.py.
Checks I ran:
tests/test_holiday_event_context.pyat the PR head: 23 passed.tests/test_holiday_state_reset.pyon the tree merged with master9a608c1(git merge-tree): 25 passed, no conflicts.PR metadata:
ready-for-agent(one triage-role label, correct).P1
None.
P2
P2-1. The #245 rollback is a best-effort compensation, so an orphan exception survives cancellation or a failed rollback. CONFIRMED (probe)
app/services/occupancy_service.py:543-561, and the same pattern in classify at:1022-1037.add_holiday_asynccommits in its own transaction. The stamp then runs in a second transaction, and on failure the code tries to undo the first write with a third one insideexcept Exception. Two paths leave the exception saved with no business-day record, which is exactly the reader split #245 is meant to close:asyncio.CancelledErroris aBaseException, soexcept Exceptionnever runs the rollback. This happens on shutdown, on a timeout, or when the task is cancelled mid-stamp.CancelledError, thenget_holiday_by_date_async("2026-08-12")returns a row and the record isNone.delete_holiday_asyncraises too. The caller sees "database is locked (rollback)" (the original survives only as__context__) and the orphan holiday remains.A crash between the two commits leaves the same state.
Fix: the issue's first option, one transaction.
BusinessDaySchedulein memory withScheduleCalendar, applying the new exception over the loaded calendar.BEGIN IMMEDIATE, asstamp_business_day_schedules_asyncalready does.except BaseException, plus a nested try that logs the rollback failure and re-raises the original error.P2-2. #244's acceptance criterion "rows built with the same helpers as
stamp_business_day_schedules_async" is only half met, and the copies have already drifted. CONFIRMEDapp/db/database.py:623-690.The migration now uses
ScheduleCalendar.planned_for,configured_*andasdict, which is good. It still re-implements three things:get_schedule_calendar_asyncatoccupancy_service.py:1269and_planned_schedule_for_day_asyncat:1307.BusinessDayScheduleconstruction. This is copied verbatim fromoccupancy_service.py:1403-1413._BUSINESS_DAY_SCHEDULE_UPSERT_SQLand_business_day_schedule_params.Drift that already exists:
str(h["name"] or "")and the runtime usesname or day.isoformat(). A holiday with an empty name is stampedexception_name=""by the migration but the ISO date by the runtime path.r["open_time"] or DEFAULTand the runtime usesrow.get("open_time", DEFAULT), which turns NULL into the text"None".These are the "resolver and migration can drift" defects the issue was filed for.
Fix:
facility_time(or a schedule module) and call them from both the migration and the service:schedule_calendar_from_rows(holiday_rows, weekly_rows, cfg)stamped_schedule(planned) -> BusinessDaySchedule(orBusinessDaySchedule.stamped_from(planned))_BUSINESS_DAY_SCHEDULE_UPSERT_SQLand_business_day_schedule_paramsfor the insert.P3
P3-1. Duplicated holiday-identity subquery in the repository (Duplicated Code). CONFIRMED
app/db/occupancy_repository.py:2958-2964and:3281-3287carry the same 6-lineUNION ... NOT INblock. The "holiday identity of a cycle date" rule now lives in two SQL copies plus the service fallback. Decision 1 asks for one source.The repository already uses module SQL constants (
_K_MEASUREMENT_PRIORITY_SQL). Hoist it the same way, e.g._HOLIDAY_CYCLE_DATES_SQL, and use it in both queries.P3-2. The reconcile path now writes STAMPED records for every reconciled day, with a mislabeled audit reason and no test. CONFIRMED
occupancy_service.py:2544-2548.stamp_business_day_schedules_async, which callsget_schedule_calendar_async. That loads every holiday, the weekly rows and every business-day record, once per reconciled day, which is O(N²). Stamping the whole range once before the loop is a single call.stamp_business_day_schedules_asyncjudges "completed" against the wall clock, while reconcile uses itsnow_epochparameter (:2641passes one). With anow_epochahead of the wall clock, the cursor day can be ≥ the real active day, so the stamp raises "Only completed business days can be stamped".test_missed_freeze_holiday_backfilled_all_readers_agreesimulates the backfill withrecord_calibration_log_asyncdirectly, so this branch is never exercised.Either drop the reconcile stamping or test it, and give it its own reason string.
P3-3. The default reason string is repeated three times. CONFIRMED
"Stamp completed business day for schedule exception"is the helper's default (occupancy_service.py:507), and both callers also passreason or "<same string>"(:549,:1027). Let the callers passreasonand keep the default only in the helper.The helper also defaults
actor="SYSTEM", which no caller relies on (Speculative Generality). Its docstring says "completed business day", but it never checks that; it depends onstamp_business_day_schedules_asyncraisingValueError.P3-4. Dead alias and dead guard in
classify_holiday_async. CONFIRMEDoccupancy_service.py:1013and:1031:old_holiday = targetis a pure alias.if old_holiday is not Noneis always true, becausetargetis checked at:1008..get("is_holiday", True)invents a default for a column thatSELECT *always returns. If it were ever missing, the "rollback" would set the day to holiday.Use
bool(target["is_holiday"]).P3-5. Rollback audit noise and actor attribution. CONFIRMED
The compensation writes extra
occupancy_holiday_auditrows:All of them carry the admin's actor and the hardcoded English reason "Rollback after stamp failure". The audit then shows a user action that never logically happened. This goes away with the single transaction from P2-1.
P3-6. Test gaps and nits. CONFIRMED
RuntimeError, neverCancelledError(P2-1).test_add_schedule_exception_stamp_failure_rolls_back_exceptiondeletes the business-day record "if one was stamped". The preceding call isoccupancy_repo.add_holiday_async, which never stamps, so the setup cannot fire. Drop it, or assert the record is absent.test_missed_freeze...asserts1500 not in ingress. That is a value match; asserting on the cycle date or the count is less fragile.isolated_event_dbduplicates conftest'sisolated_repository_db. The new tests do write shared tables but get isolation from it, so this is fine. Optionally switch topytestmark = pytest.mark.usefixtures("isolated_repository_db").P3-7. The PR body has the wrong function name. CONFIRMED
The body says
reconcile_missing_nocturnal_calibrations_async. The function isreconcile_historical_calibrations_async.Note: master compatibility
9a72ff4. Master9a608c1merges cleanly (merge-tree6b6da3a).reset_occupancy_state_in_testsfixture andtests/test_holiday_state_reset.pycoexist with the new tests: 25 passed on the merged tree.Spec axis
Spec: #244, #245, #246 (follow-ups from PR #178). Merge base
9a72ff4, master9a608c1.Probes: git-archive exports
pr267/,base267/(merge base + PR tests),merged267/(merge-tree of master + PR); probe filepr267/tests/test_probe267.py.Targeted runs:
tests/test_holiday_event_context.py+tests/test_calibration_reconciliation.pypass 26/26 on the PR and on the merged tree. On the merge base, the new tests fail as expected: the migration-version assert, the learning-reader assert for #246, and the orphan-exception assert for #245. So the reproductions are real.Acceptance criteria
20261002_holiday_backfillstamp_business_day_schedules_async, no duplicated literalstest_migration_stamps_never_stamped_past_holiday_all_readers_agree)_ensure_completed_day_stamped)Findings
P2-1: Reconciliation now auto-stamps every reconciled past day, not just holidays, which goes against CONTEXT.md. CONFIRMED
app/services/occupancy_service.py:2544-2548(insidereconcile_historical_calibrations_async; the PR body calls itreconcile_missing_nocturnal_calibrations_async, which does not exist).reconcile_and_quarantine_historical_anomalies_async. Result: a record{source: STAMPED, is_exception: 0, is_holiday: 0, 08:00-21:00}plus an audit rowSTAMP / SYSTEM_DAEMON / "Scheduled nocturnal quiet window calibration (reconciled for 2026-09-09)".get_original_business_day_schedule_async. This happens before any draft review, so the reviewed-draft path cannot set the Original Schedule any more. Calibration then follows a schedule nobody approved.occupancy_repository.py:2957-2963,3280-3286) already makes all readers agree without writing anything. The new test passes without reconcile involvement; it never exercises this path.is_holidayexception days and get maintainer sign-off on the CONTEXT change. Also use an honest audit reason. The current reason is a calibration string on a schedule stamp.P2-2: The migration still re-implements the stamping path and has already drifted from it. CONFIRMED
app/db/database.py:600-667.ScheduleCalendarloader, copyingget_schedule_calendar_async. It also hand-builds theplanned -> BusinessDaySchedule(source="STAMPED", recorded=True)block, copyingoccupancy_service.py:1400-1414. #244 asked for "the same helpers … no duplicated literals". Only the leaf helpers (configured_*,asdict,ScheduleCalendar) are shared.exception_namesdefault isstr(h["name"] or "")(database.py:645). The service usesname or day.isoformat()(occupancy_service.py:1286).exception_name=''from the migration, but'2026-08-20'fromstamp_business_day_schedules_async. Same data, two different records depending on which path stamped it. This is exactly the drift #244 warned about.row or DEFAULTand the service usesrow.get(key, DEFAULT).app/facility_time.pyand call them from both sides:schedule_calendar_from_rows(holidays, weekly, cfg)stamped_from_planned(planned)(orreplace(planned, source="STAMPED", recorded=True))P2-3: The #245 "rollback" is a compensating write in a second transaction; a double fault leaves the orphan and hides the cause. CONFIRMED
app/services/occupancy_service.py:543-561(add) and1028-1037(classify).add_holiday_async. On stamp failure, a separatedelete_holiday_asyncoradd_holiday_asyncundoes it.RuntimeError("stamp boom"), then the compensating delete raisesRuntimeError("database is locked"). Result:database is locked; the stamp error survives only as__context__.BEGIN IMMEDIATEcontention, which is the kind of failure that hits both writes.BusinessDayScheduleand runs both under oneBEGIN IMMEDIATE. If compensation is kept, re-raise the original error (raise orig from comp_err) and log the compensation failure.Merge-with-master note (separate from the PR's own correctness)
MC-1: The merged tree fails ruff with F821 undefined
DEFAULT_WEEKDAY_HOURS. CONFIRMED1885b27/ #233 line) removedDEFAULT_WEEKDAY_HOURSfromapp/db/database.pyimports. The PR's migration uses it at database.py:605-606.git merge-treereports no textual conflict, butruff check app/db/database.pyon the merged tree reports F821 at 605:59 and 606:59. CI and pre-commit will fail after merge.open_time/close_time, which is rare because the columns are NOT NULL. So startup normally survives, which is why tests pass.P3
now_epochlater than wall-clock. CONFIRMEDnow_epoch, butstamp_business_day_schedules_asyncchecks against realactive_business_day().now_epoch= 2026-11-12 raiseValueError: Only completed business days can be stampedand abort the whole reconcile.None, so the impact is tests and any future caller. Moot if P2-1 removes the stamp.classify_holiday_asyncrollback has dead and odd code. occupancy_service.py:1016, 1030-1032.old_holiday = targetis always non-None, so theif old_holiday is not Noneguard is dead..get("is_holiday", True)defaults the restore to True. On a missing key that would leave the day marked holiday and unstamped.bool(target["is_holiday"]).if result and "id" in result and result["id"] is not Nonesilently skips the rollback. occupancy_service.py:552-556. If the id is missing, the orphan survives with no log. At minimum, log it.configured_reset_time(current config) instead of the effective-datedResetSchedulethe service uses. database.py:610-613. This is harmless for a one-day cutoff, but it is another divergence from "the stamping path"; worth a comment or the shared helper._ensure_completed_day_stampedcosts a fullget_schedule_calendar_async()per day, throughstamp_business_day_schedules_async. That loads all holidays and all records. In reconcile it runs once per reconciled cycle, which is O(n²) over a long M1 backfill. Moot if P2-1 drops it.reconcile_missing_nocturnal_calibrations_async(the real name isreconcile_historical_calibrations_async). It says the migration builds rows "without hard-coded literals", which P2-2 shows is not fully true.Verdict
The core #246 SQL fallback and the #244 version split are correct and reproduce/fix the reported defects. Changes requested:
exception_name.Pass 1 fixes (
72128f8)All findings from review pass 1 (r57) have been resolved.
origin/master(9a608c1) is merged into the branch, and all 609 tests pass.occupancy_service.py:2544-2548), breaking CONTEXT.md:131 and :134 for M1 draft review._ensure_completed_day_stampedauto-stamp fromreconcile_historical_calibrations_async. The hoisted repository SQL fallback_HOLIDAY_CYCLE_DATES_SQLaligns all readers on unfrozen holidays without mutating draft schedules.database.py:600-690) duplicated calendar loader, STAMPED record construction, and insert SQL with drift (exception_nameempty string vs date).schedule_calendar_from_rowsandstamped_from_plannedinapp/facility_time.py. Routed migration20261003_holiday_stamping_backfillthrough shared helpers, repository_BUSINESS_DAY_SCHEDULE_UPSERT_SQL, and parameters. Unnamed exceptions map toexception_name = None(NULL in DB), matching #222.CancelledError, lock contention error masking, and audit noise.BEGIN IMMEDIATErepository transaction inadd_holiday_asyncandclassify_holiday_asyncthat writes the exception, the STAMPED schedule record (when completed), and audits together.DEFAULT_WEEKDAY_HOURSremoved fromdatabase.pyimports in master (F821).origin/master(9a608c1). Sharedschedule_calendar_from_rowsinfacility_time.pyresolves weekly schedules fromDEFAULT_DAILY_SCHEDULE, removing the unneeded import.now_epochwas later than wall-clock.classify_holiday_asyncrollback had dead and odd code (old_holiday = targetguard dead;.get("is_holiday", True)).if result and "id" in result and result["id"] is not Nonesilently skipped rollback.configured_reset_timeinstead of effective-datedResetSchedule.configured_reset_timecutoff is safe and standard for the one-day active boundary._ensure_completed_day_stampedloaded entire calendar per day (O(n²) in backfill)._ensure_completed_day_stampedentirely.test_add_schedule_exception_cancelled_error_and_lock_failure,test_classify_holiday_stamp_failure_rolls_back_classification, andtest_migration_stamped_rows_match_service_stamped_rowsintests/test_holiday_event_context.py.reconcile_missing_nocturnal_calibrations_asyncand claimed no hardcoded literals.reconcile_historical_calibrations_asyncand accurately describe helper reuse and shared upsert SQL.Ready for review pass 2.
View command line instructions
Manual merge helper
Use this merge commit message when completing the merge manually.
Checkout
From your project repository, check out a new branch and test the changes.Merge
Merge the changes and update on Forgejo.Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.