Skip to content

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

Open
junichi-cstk wants to merge 3 commits into
masterfrom
feat-LYT-391-recurring-recalc-boundary
Open

recurring(): schedule profile re-evaluation, and floor epoch days (LYT-391)#57
junichi-cstk wants to merge 3 commits into
masterfrom
feat-LYT-391-recurring-recalc-boundary

Conversation

@junichi-cstk

Copy link
Copy Markdown
Contributor

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 - Math.floorDiv(doc['f'].value.toInstant().getEpochSecond(), 86400L) - params.offset;

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 (DateMathCalcScheduleExitTriggersegDateCalcsCalculateRecalcTimereevalqueue), verified end-to-end against a local replace.

🤖 Generated with Claude Code

Junichi Furukawa and others added 3 commits July 28, 2026 15:29
…(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
junichi-cstk force-pushed the feat-LYT-391-recurring-recalc-boundary branch from e81b082 to 1d9c566 Compare July 29, 2026 23:58
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.

1 participant