Repository navigation
fix: Use monotonic clocks for intervals, durations, and deadlines - #459
Merged
Merged
Conversation
Wall-clock steps (NTP corrections, manual clock sets, VM/container time resync) could cause immediate re-polls or hour-long silences in RepeatingTask, stale or mass-evicted ExpiringCache entries, skewed or silently dropped migration latencies, wrong FDv2 fallback/recovery verdicts, and negative stream-init durations. Every interval, duration, and deadline now reads Util.monotonic_seconds; wall clocks remain only for values reported outside the process or compared against server-supplied absolute dates. Note for applications that use Timecop to drive SDK-internal timing: these measurements no longer follow Time.now. Timecop's opt-in mock_process_clock with the offset forms (e.g. Timecop.travel(3600)) still works. SDK-3104
kinyoklion
approved these changes
Oct 2, 2026
keelerm84
approved these changes
Oct 6, 2026
tanderson-ld
added a commit
that referenced
this pull request
Oct 7, 2026
🤖 I have created a release *beep* *boop* --- ## [8.18.2](8.18.1...8.18.2) (2026-10-06) ### Bug Fixes * Use monotonic clocks for intervals, durations, and deadlines ([#459](#459)) ([5016129](5016129)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Overview** > **Release 8.18.2** — bumps the SDK version from `8.18.1` to `8.18.2` in `lib/ldclient-rb/version.rb`, `.release-please-manifest.json`, and `PROVENANCE.md`, and adds the corresponding `CHANGELOG.md` section. > > The changelog records one shipped fix: **intervals, durations, and deadlines now use monotonic clocks** ([#459](#459)), so timing for things like data-source status duration is not skewed by wall-clock adjustments (NTP, DST, manual changes). This PR contains no runtime code changes—only release bookkeeping from Release Please. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 40431a6. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
This was referenced Oct 8, 2026
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.
Summary
Replaces wall-clock time with monotonic time everywhere the SDK measures an interval, duration, or deadline. Wall clocks (
Time.now, epoch millis) remain only for values that leave the process or are compared against externally supplied absolute times. Implements SDK-3104.A wall-clock step (NTP correction, manual clock set, container/VM time resync, the time correction when a frozen environment thaws) previously could cause:
RepeatingTask(polling, event flush, big-segment status polling, store-status polling): an immediate re-run on a forward step, or up to interval-plus-step of silence on a backward step.ExpiringCache(big-segments membership cache, persistent-store read-through cache): entries served past their TTL after a backward step; whole-cache eviction (thundering herd against Redis/DynamoDB) on a forward step. A backward step could also strand genuinely expired entries behind the eviction scan's early exit until capacity pressure removed them.durationMillisafter a backward step; FDv2 clamped negatives but still inflated forward steps.Changes
Impl::Util.monotonic_seconds(Process.clock_gettime(CLOCK_MONOTONIC, :float_second)) is the single monotonic seam — now the only clock read inlib/besidesUtil.current_time_millis.RepeatingTaskmeasures each run monotonically; fixed-rate semantics unchanged.ExpiringCachestamps and evicts on an injectable monotonic clock (clock:kwarg). This also makes the eviction scan's "stop at first unexpired entry" early exit a true invariant (stamps are now non-decreasing in insertion order).Migrations::Executormeasures operation latency monotonically.StatusProviderV2keeps a monotonic twin ofstate_since, stamped under the same write lock on state change, exposed via the atomicstatus_and_seconds_in_state(one read-lock acquisition returns[status, seconds], so a verdict can never pair a stale state with a fresh duration). Publicstatus.state_sinceremains a wall-clockTime. Also fixed here: anilstate from a custom synchronizer previously fell into the change path and could permanently disable FDv2 fallback/recovery; it is now ignored, matching the FDv1 sink.fallback_condition/recovery_conditionare pure functions of(status, seconds_in_state), with a truth-table spec pinning the 60/10/300s thresholds and the strict->boundary semantics.durationMillisis measured monotonically (integer ms); the reportedtimestampstays wall-clock. The monotonic stamp is also the "attempt in flight" sentinel, so lifecycle and measurement share one typed value.Deliberately unchanged (wall clock is correct): analytics event
creationDate, diagnosticcreationDate/dataSinceDate,Status#state_since,ErrorInfo#time,debugEventsUntilDatecomparisons (server-supplied absolute dates), and big-segments staleness —last_up_to_dateis written by another process, so only a wall clock is comparable across hosts; it remains sensitive to host-to-host skew, which no local clock choice can fix.Behavioral notes
Not a breaking change. All modified classes are
Impl-private; additions are optional kwargs and a@privateaccessor; behavior under a stable clock is identical. Three nuances:CLOCK_MONOTONICdoes not advance during system suspend, so TTLs and the FDv2 state windows now measure process-visible time. We consideredCLOCK_BOOTTIMEand choseCLOCK_MONOTONICeverywhere for cross-platform consistency (macOS/JRuby lack BOOTTIME, and macOS's monotonic clock already counts sleep). Notably, this costs nothing in AWS Lambda: a frozen execution environment pauses the entire microVM with no guest-visible suspend, so BOOTTIME and MONOTONIC behave identically there. The practical consequence in warm Lambdas is that TTLs become cumulative active time — persistent-store/big-segments cache entries can live longer in wall time than the configured TTL under sporadic short invocations (the old wall-clock behavior instead re-fetched everything at thaw, since the thaw time-sync is a forward step). If that ever matters in practice, dual-stamping entries (wall + monotonic, expire on whichever elapses first) is the known escape hatch.Time.nowto expire the SDK's caches — will find the SDK no longer follows the wall clock. Timecop's opt-inTimecop.mock_process_clock = truewith the offset forms (Timecop.travel(3600)) still works, since it mocksProcess.clock_gettime. Our ownExpiringCachespecs needed exactly this rewrite.timestamp + durationMilliswill not equal the wall end time if a step occurred mid-measurement. The duration is the trustworthy value.One correction to the originating audit's framing: a Ruby
Timeis an absolute epoch instant (the zone affects display only), soTimesubtraction was never DST-affected — Ruby's actual exposure was NTP/manual/VM clock steps. The fixes are identical either way.Review
A three-agent review (general / security / adversarial) ran against these changes: no Critical or High findings. All seven adversarial attack vectors failed against production code — including an exhaustive 4096-sequence brute force showing the wall/monotonic stamps cannot diverge through any
update_statusinput sequence, and a 3.1M-sample race probe of the status/duration pairing (zero violations, zero negative durations). Each new regression test was verified to fail against the pre-fix implementation. Review-driven hardening is included here (atomic accessor, nil-state guard, condition truth table, spec Timecop hygiene).Two pre-existing issues surfaced by the review are intentionally out of scope and will be ticketed separately: no minimum gap between
RepeatingTaskruns when a task overruns its interval (reachable via an unvalidatedstatus_poll_interval), and interval type validation.Relationship to #448
The
RepeatingTaskrewrite in #448 removes the elapsed-time measurement entirely (fixed-delay semantics), which supersedes this PR'srepeating_task.rbchange. The fix here is deliberate insurance on main's current code: whichever PR lands second should resolve the conflict in #448's favor for that one file. No other files overlap.Precedent
Same change-class as the .NET fixes in launchdarkly/dotnet-core#339 (propagated by launchdarkly/dotnet-core#343).
Testing
Full suite passes (1118 examples; the one failure,
launchdarkly-server-sdk_spec.rb'sBundler.requiretest, is pre-existing and environment-dependent — it shells out to a barerubyoutside the bundle — and touches nothing in this change). New regression tests simulate wall-clock steps for the scheduler, cache, migrator, status provider, and both stream-init paths. rubocop clean on all changed files.Note
Overview
Wall-clock steps (NTP, manual set, VM resync) could skew polling intervals, cache TTLs, migration latency, FDv2 synchronizer failover/recovery, and stream-init diagnostics. This PR routes all internal interval and duration measurement through new
Impl::Util.monotonic_seconds, while keeping wall time for outward-facing timestamps likeStatus#state_since.StatusProviderV2tracks a monotonic twin of state entry time, exposes atomicstatus_and_seconds_in_state, and ignoresnilstate updates. FDv2 uses that duration for the 60s / 10s / 300s fallback and recovery thresholds instead ofTime.now - state_since. FDv1 and FDv2 streaming compute diagnosticdurationMillisfrom monotonic stamps; reported init timestamps stay wall-clock.ExpiringCache,RepeatingTask, and migrationExecutorlatency also switch to monotonic measurement (cache accepts an optional injectableclock:for tests).Regression specs cover wall-clock manipulation via Timecop, FDv2 condition boundaries, and global
Timecop.returnafter each example.Reviewed by Cursor Bugbot for commit 49aa78a. Bugbot is set up for automated code reviews on this repo. Configure here.