Fix startup delay and direct write file naming in CronTriggeringPolicy - #4226
Fix startup delay and direct write file naming in CronTriggeringPolicy#4226tupelo-schneck wants to merge 6 commits into
Conversation
`CronTriggeringPolicy.initialize()` looks up the previous fire time relative to `RollingFileManager#getFileTime()`. That value is 0 whenever there is no current file yet, which is the case on every startup of an appender configured without a `fileName`. The resulting `getPrevFireTime(new Date(0))` call took roughly three seconds per appender. `CronExpression.getTimeBefore()` walks backwards from the target date one `findMinIncrement()` step at a time, calling `getTimeAfter()` until the result precedes the target. `getTimeAfter()` clamps its result to 1970, so for a target at the epoch that exit condition can never be satisfied and the search instead grinds back through several millennia of candidate dates before `getTimeAfter()` finally gives up and the method returns `null`. Bound the backward search at `MIN_DATE`, and skip the lookup entirely when there is no current file. Both paths already returned `null` for this input, so behaviour is unchanged; only the cost differs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
@tupelo-schneck Thanks for the contribution. I seen the #4227 that's also relate to this, can You close the follow up Pr and work it out here?
| // `RollingFileManager` reports a file time of 0 when there is no current file yet, which | ||
| // happens on every startup of an appender configured without a `fileName`. There is no | ||
| // previous roll to look up in that case, so skip the lookup entirely. | ||
| final long fileTime = this.manager.getFileTime(); |
There was a problem hiding this comment.
testBuilderWithoutFileNameInitializesPromptly passes with either fix alone (0.134 s / 0.143 s) and fails only when both are reverted, so nothing pins this guard — is it load-bearing, or documentation like TimeBasedTriggeringPolicy.initialize()'s getFileTime() == 0?
There was a problem hiding this comment.
Documentation, not load-bearing — your second reading is right, and thank you for checking it rather than assuming.
I confirmed it: with the epoch bound in CronExpression in place and this guard reverted, CronTriggeringPolicyTest and CronExpressionTest pass in 0.18 s. Once the search is bounded, getPrevFireTime(new Date(0)) returns null in microseconds for any expression, so nothing here depends on the guard for speed.
I have kept it, for the reason you name: it mirrors TimeBasedTriggeringPolicy.initialize()'s getFileTime() == 0 check, and it makes lastRollForFile null by construction rather than by a lookup that happens to return null for a meaningless input. Happy to drop it if you would rather keep the diff minimal — no functional difference either way.
…onExpression.java Co-authored-by: Ramanathan <ramanathanbscmca@gmail.com>
The bound that stops `getTimeBefore()` running away must not swallow fire times that exist just after the epoch. Bounding on `MIN_DATE` did: it carries the JVM's start time of day, since `MIN_CAL.set(1970, 0, 1)` leaves the time fields alone, so whether a fire time was found depended on when the JVM started. Add a test covering a target shortly after the epoch, in a fixed zone so the result does not depend on the CI host's default. It fails with a `MIN_DATE` bound and passes with the epoch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An appender configured without a `fileName` writes directly to the file its
pattern resolves to. `CronTriggeringPolicy.initialize()` records the start of
the current rollover period as the pattern processor's current file time, but
`DirectWriteRolloverStrategy.getCurrentFileName()` overwrote it with the
current time, so the file was named after the moment the appender started
rather than after the period it covers.
For a weekly schedule and a `%d{yyyyMMdd}` pattern that means every restart on
a new day opens another file, leaving a week with as many files as there were
restart days rather than the single file the schedule implies.
That override (LOG4J2-3339) compensated for `rollover()` passing the previous
roll date as the new file's time, which named each new file after the file just
rolled. Pass the roll time instead, so the replacement file is named after the
period it opens, and let `getCurrentFileName()` use the recorded period start.
Policies that do not track a period, such as `TimeBasedTriggeringPolicy`, leave
the current file time at 0, and `PatternProcessor.formatFileName()` already
falls back to the current time in that case, so their behaviour is unchanged.
Note that this changes direct write file names for cron based appenders.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two defects in
CronTriggeringPolicy, both hit by an appender configured without afileName. Originally split across this PR and #4227; consolidated here at @ramanathan1504's request, and #4227 is closed in favour of this one.Reproduced with:
1. Roughly three seconds of startup delay per appender
initialize()looks up the previous fire time relative toRollingFileManager#getFileTime():That value is 0 when there is no current file yet (
file == null || !file.exists() ? 0 : initialFileTime(file)), which is the case on every startup of an appender that writes directly to the file its pattern resolves to. The lookup therefore runs against the epoch.CronExpression.getTimeBefore()walks backwards onefindMinIncrement()step at a time, callinggetTimeAfter()until the result precedes the target.getTimeAfter()clamps its result to 1970, because an expression without a year field gets a year set starting there. For a target at the epoch the exit condition can therefore never be satisfied: every iteration returns the same 1970 fire time, which is neithernull, nor beforeMIN_DATE, nor before the target. The search grinds back through several millennia of candidate dates until the calendar's era flips andgetTimeAfter()bails out at itsYEAR > 2999guard, returningnull.A stack sample, taken once a second, with the main thread pinned at 100% CPU throughout:
Note that
evaluateOnStartup="false"does not avoid the cost: the lookup runs unconditionally and only its result is guarded by that flag.Fix. Bound the backward search at the epoch, and skip the lookup when there is no current file. Both paths already returned
nullhere, so behaviour is unchanged and only the cost differs.getPrevFireTime(new Date(0))for0 0 0 ? * SUN:The two-appender configuration above goes from ~6.0 s to ~0.2 s.
The bound is the epoch rather than
MIN_DATE, per @ramanathan1504's review.MIN_CAL.set(1970, 0, 1)leaves the time fields alone, soMIN_DATEcarries the JVM's start time of day — two JVMs seconds apart print09:40:09and09:40:17. Bounding the candidate on it would make results depend on when the process started, and would lose real fire times just after the epoch: for0 0 0 * * ?at1970-01-02 06:00,2.xreturns1970-01-02 00:00where aMIN_DATEbound returnsnull. A bound on the local 1970 instant in the expression's own zone was also tried, sincegetTime() < 0is a UTC-instant test whilegetTimeAfter()floors at local 1970; it gave identical results acrossAmerica/New_York,UTCandAustralia/Sydney, so the simpler form is used.2. The direct write file is named after process start, not the rollover period
The file an appender writes to covers a whole rollover period, but it was named after the moment the appender started. Starting on Tuesday 2026-07-28 under the weekly schedule above writes to
error.log-20260728, though the period began on Sunday 2026-07-26, so a restart on Wednesday openserror.log-20260729instead of continuing the same file. A week with daily restarts leaves seven files where the schedule implies one, and rollover only ever closes out whichever fragment is current.initialize()records the period start correctly:DirectWriteRolloverStrategy.getCurrentFileName()then discarded it:That override compensated for a different problem:
rollover()passedlastRollDate, the previous period's start, as the new file's time, so each replacement file was named after the file just rolled. Substituting the current time hid that, and works only because at the instant of a rollover "now" is approximately the new period's start. At startup "now" is an arbitrary point inside the period, which is where it breaks.Fix. Pass the roll time rather than the previous roll date, so the replacement file is named after the period it opens, and let
getCurrentFileName()use the period start already recorded.Blast radius is limited to policies that track a period.
PatternProcessor.formatFileName()already falls back to the current time when the current file time is 0, andupdateTime()resets it to 0, soTimeBasedTriggeringPolicy, which never sets it, is unchanged. Appenders configured with afileNameare unaffected, sincegetCurrentFileName()is only consulted on the direct write path.Compatibility. This changes the names of direct write files for cron based appenders, from the process start time to the start of the rollover period —
error.log-20260726rather thanerror.log-20260728above. Existing files are untouched, neither read nor renamed; a restart after upgrading simply begins writing to the period's file. The changelog entry calls this out.RollingAppenderDirectCronTest, the regression test added with LOG4J2-3339, still passes: it asserts that a rolled file's name matches the timestamp of its first line, which this change preserves.Testing
Four tests, each confirmed to fail without its corresponding fix and pass with it:
CronExpressionTest#testPrevFireTimeAtEpochReturnsNullPromptly— assertsnullwithin a 1 s timeout, deliberately well below the ~2.9 s the unfixed path takes, since a generous timeout would pass with the defect present.CronExpressionTest#testPrevFireTimeJustAfterEpochIsUnaffectedByBound— pins the near-epoch behaviour above, in a fixed zone so it does not depend on the CI host.CronTriggeringPolicyTest#testBuilderWithoutFileNameInitializesPromptly— builds an appender without afileNameon a weekly schedule.CronTriggeringPolicyTest#testDirectWriteFileNameUsesPeriodStart— asserts the manager's file name matches the period start computed independently from the same expression. Without the fix:expected: <target/testcmd5.log-20260726> but was: <target/testcmd5.log-20260728>../mvnw verifypasses across all 41 modules.Checklist
2.x./mvnw verifysucceedssrc/changelog/.2.x.x