recurring(): schedule profile re-evaluation, and floor epoch days (LYT-391) - #57
Open
junichi-cstk wants to merge 3 commits into
Open
recurring(): schedule profile re-evaluation, and floor epoch days (LYT-391)#57junichi-cstk wants to merge 3 commits into
junichi-cstk wants to merge 3 commits into
Conversation
…(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>
junichi-cstk
force-pushed
the
feat-LYT-391-recurring-recalc-boundary
branch
from
July 29, 2026 23:58
e81b082 to
1d9c566
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two fixes to
recurring(), both found while auditing the lio side of LYT-391.1. Anniversary audiences were never scheduled for re-evaluation
findDateMathFnproduced a boundary fn only for anow±Nstring literal inside a binary orBETWEENnode.recurring()is now-relative without containing any such literal, so it yielded no boundary fns at all —DateConverterreportedHasDateMath=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:
RecurringBoundaryreturns the next UTC midnight at which the expression changes value:n=1past its anchor)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 invalidrecurring()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 forrecurring(), so this is the common case, not an edge.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,
TestRecurringBoundaryIsExactFlipPointwalks 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
RecurringMatchneeds the matching floor change, landing with itsgo.modbump — the two must ship together. No other lio change is required: with this branch, a work segment containingrecurring()reaches full parity with existing datemath (DateMathCalc→ScheduleExitTrigger→segDateCalcs→CalculateRecalcTime→reevalqueue), verified end-to-end against a localreplace.🤖 Generated with Claude Code