Skip to content

recurring(): schedule profile re-evaluation, and floor epoch days (LYT-391) - #62

Merged
junichi-cstk merged 4 commits into
masterfrom
feat/LYT-391-recurring-recalc-boundary-v2
Oct 12, 2026
Merged

junichi-cstk merged 4 commits into
masterfrom
feat/LYT-391-recurring-recalc-boundary-v2

Conversation

@junichi-cstk

@junichi-cstk junichi-cstk commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Supersedes #57 — same three commits, rebased onto current master (e6ce933, #61), which is the qlbridge commit lio develop already pins. #57 was 3 commits behind master. Rebased cleanly; go test ./vm/ ./expr/... pass.

Two fixes to recurring(), both found while auditing the lio side of LYT-391.

1. Anniversary audiences were never scheduled for re-evaluation

findDateMathFn produced a boundary fn only for a now±N string literal inside a binary or BETWEEN node. recurring() is now-relative without containing any such literal, so it yielded no boundary fns at all — DateConverter reported HasDateMath=false, callers never flagged the segment as needing recalculation, and nothing scheduled the profile to be re-evaluated on its anniversary.

The practical effect: an anniversary audience only picked up a profile when some unrelated event happened to re-evaluate it. A dormant profile never entered on its birthday, and — worse for a "send on birthday" trigger — never exited the day after. That is precisely the failure mode the original feature request (lytics/lio#34919) was filed to escape:

Since profile evaluation only happens when there are events or scoring type processes, a subset of profiles would be inadvertently excluded from the audience.

RecurringBoundary returns the next UTC midnight at which the expression changes value:

State Boundary
Currently false start of the next recurrence day
Matching today start of tomorrow (the exit edge)
Never changes again (n=1 past its anchor) zero — not scheduled

It mirrors the evaluators rather than idealizing them: anchors with no counterpart in a period are skipped, not clamped — Feb 29 recurs only in leap years, the 31st only in months that have one. Shapes the evaluators reject (unknown period, non-positive or fractional n, non-literal period or offset) return no boundary fn, so an invalid recurring() isn't flagged for recalculation either.

2. Pre-1970 anchors recurred a day late

The every-n-days path divided epoch seconds with / on both sides — Go in the boundary calculation, Java in the generated Painless. Both round toward zero, so an anchor before 1970 whose time-of-day wasn't midnight bucketed one day too high. Pre-1970 birth dates are the headline input for recurring(), so this is the common case, not an edge.

- long d = params.todayDay - (doc['f'].value.toInstant().getEpochSecond() / 86400) - params.offset;
+ long d = params.todayDay - doc['f'].value.toLocalDate().toEpochDay() - params.offset;

Math.floorDiv was the first attempt, but Painless doesn't allow it: Elasticsearch rejects the script at compile time (static method [java.lang.Math, floorDiv/2] not found), and lio's size endpoint then silently reports 0. I found this end-to-end against a real ES 9 in lio's minikube. toLocalDate().toEpochDay() on the UTC doc value floors the same way and is already what the field-to-field compare uses. Verified in that ES:

  • the old script fails to compile and the new one runs
  • the new script matches seconds // 86400 on every indexed doc
  • Java's toEpochDay matches floor division on synthetic pre-1970 non-midnight timestamps
  • the yearly, monthly and weekly scripts compile too

Both sides move together — flooring one alone would make an Elasticsearch scan and in-process evaluation disagree for exactly these profiles. Post-1970 values are unaffected (truncation and flooring agree for non-negative seconds), so no audience of recent dates changes behavior.

Verification

Full suite green: 19 packages, 0 failures.

Beyond exact-value cases per period, TestRecurringBoundaryIsExactFlipPoint walks 550 days of "now" per configuration and asserts the returned boundary is the precise instant the predicate flips — checked against an independent copy of the predicate rather than against the implementation. That oracle caught a wrong expectation of mine while writing these tests.

Downstream

lio's RecurringMatch needs the matching floor change, landing with its go.mod bump — the two must ship together. No other lio change is required: with this branch, a work segment containing recurring() reaches full parity with existing datemath (DateMathCalc → ScheduleExitTrigger → segDateCalcs → CalculateRecalcTime → reevalqueue), verified end-to-end against a local replace.

🤖 Generated with Claude Code

Junichi Furukawa and others added 3 commits October 7, 2026 14:17
…(LYT-391)

findDateMathFn only produced a boundary fn for a "now±N" string literal inside a
binary or BETWEEN node. recurring() is now-relative without containing any such
literal, so it yielded no boundary fns at all -- DateConverter reported
HasDateMath=false, callers never flagged the segment as needing recalculation,
and nothing scheduled the profile to be re-evaluated on its anniversary. An
anniversary audience then only picked up a profile when some unrelated event
happened to re-evaluate it, which is the exact failure mode the feature request
(lytics/lio#34919) was filed to escape.

Adds a recurring case to findDateMathFn and RecurringBoundary, which returns the
next UTC midnight at which the expression changes value: the start of the next
recurrence day when it's currently false, or the start of tomorrow when it's
matching today. Zero time when it never changes again (every-1-day past its
anchor), so those don't get scheduled pointlessly.

Mirrors the evaluators rather than idealizing them: the n-day path divides epoch
seconds toward zero, and anchors with no counterpart in a period are skipped, not
clamped -- Feb 29 recurs only in leap years, the 31st only in months that have
one. Shapes the evaluators reject (unknown period, non-positive or fractional n,
non-literal period or offset) return no boundary fn, so they aren't flagged for
recalculation either.

Tests cover exact boundaries per period, the DateConverter contract callers rely
on, and a walk over 550 days per configuration asserting the boundary is the
precise instant the predicate flips.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…YT-391)

The every-n-days path divided epoch seconds with `/` on both sides -- Go in the
boundary calculation, Java in the generated Painless. Both round toward zero, so
an anchor before 1970 whose time-of-day was not midnight bucketed one day too
high and its recurrences landed a day late. Pre-1970 birth dates are the headline
input for recurring(), so this is the common case for the feature, not an edge.

Switches the script to Math.floorDiv(..., 86400L) and the Go side to a matching
floorDivInt64. The two must move together: flooring one side alone would make an
Elasticsearch scan and in-process evaluation disagree for exactly these profiles.
lio's RecurringMatch needs the same change, landing with its go.mod bump.

Post-1970 values are unaffected -- truncation and flooring agree for
non-negative seconds -- so no existing audience of recent dates changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-391)

The floor-toward-negative-infinity day arithmetic had been copied into vm and
esgen, and lio needs the same function for its in-process evaluators -- three
copies of the rule that has to stay in lockstep with Math.floorDiv in the
generated Painless. That is the drift that produced the pre-1970 bug in the first
place.

Exports it once as vm.EpochDay and points esgen at it (esgen already depends on
vm, and vm does not depend on esgen, so no new cycle). lio calls the same function
rather than keeping its own copy.

The boundary test's oracle keeps its own hand-rolled flooring on purpose, with a
comment saying so: an oracle that shares code with what it checks cannot catch a
bug in that code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@snyk-io

snyk-io Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

✅ Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
✅ Open Source Security 0 0 0 0 0 issues
✅ Licenses 0 0 0 0 0 issues
✅ Code Security 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

Painless does not allowlist Math.floorDiv, so Elasticsearch rejected every
every-N-days recurring() script at compile time ("static method
[java.lang.Math, floorDiv/2] not found") and sizes/scans came back empty.
ZonedDateTime.toLocalDate().toEpochDay() on a UTC doc value floors the same
way as vm.EpochDay and is already what field-to-field time compares use.

Verified against a real ES 9 (lio minikube): the old script fails to
compile; the new one runs, matches seconds//86400 on every indexed doc,
and Java's toEpochDay matches floor division on synthetic pre-1970
non-midnight timestamps. Yearly/monthly/weekly scripts also compile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@sumankarki sumankarki left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — recurring(): schedule profile re-evaluation, and floor epoch days (LYT-391)

Reviewed (logic + mutation lens) in an isolated worktree. CI green; 1 prior approval.

Verified clean — the date math is correct in every branch.

  • EpochDay floors toward -inf to match ES Painless toLocalDate().toEpochDay() (Go / truncates toward zero, putting a pre-1970 non-midnight anchor a day late). Exhaustively checked: every second across ±100 days plus the exact boundaries {0, 86399, 86400, -1, -86400, -86401}. The generated Painless now uses toLocalDate().toEpochDay() + vm.EpochDay for todayDay.
  • Boundary == evaluator alignment (the critical re-eval property): brute-forced ~20 period/n-days/offset configs × past/future/leap/month-end/pre-1970 anchors, stepping now 1500 days each — the value is unchanged in [now, boundary), flips exactly at boundary, and every zero boundary genuinely never flips again. No stale-membership shape (no zero/late boundary where the value still changes), including negative offsets, future anchors, and n>1 with offset. Pinned by TestRecurringBoundaryIsExactFlipPoint, which walks 550 days against an independent oracle (hand-rolling the floor so it can catch a bug in EpochDay).
  • Feb-29 / month-end: the yearly 8-year loop bound is exactly sufficient and tight (anchor 2096-02-29 across the non-leap 2100 → 2104-02-29; a +7 bound would return zero → stale). Monthly 14-iteration bound is ample. Non-existent dates are skipped, not clamped — matching the evaluator.
  • Arg guards (count 2-3, int offset, positive int n, period in yearly/monthly/weekly) match esgen; daily and unsupported shapes correctly yield a nil boundary (never-flips / never-compiles), not a missed boundary.

🟡 Suggestions (non-blocking)

  1. The yearly 8-year span is only test-pinned down to +3 — the value is correct, but a config/row with a Feb-29 anchor and now in 2097-2099 (expect 2104-02-29) would pin the full span against an accidental shrink.
  2. [LOW/doc] RecurringBoundary's n-days path reads the anchor via .UTC().Unix() while ES reads toLocalDate().toEpochDay(); these agree only because ES date doc values are UTC — worth a one-line note in the doc comment.

Approving. (Note: lio#39549 pins this branch's pseudo-version — re-pin to merged master once this lands.)

🤖 reviewed with Claude Code

@junichi-cstk
junichi-cstk merged commit 51283f2 into master Oct 12, 2026
5 checks passed
@junichi-cstk
junichi-cstk deleted the feat/LYT-391-recurring-recalc-boundary-v2 branch October 12, 2026 22:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants