Skip to content

Implement unified time testing with controllable app_now() function#329

Merged
tonyalaribe merged 5 commits into
masterfrom
claude/unified-time-testing-EwfDg
May 26, 2026
Merged

Implement unified time testing with controllable app_now() function#329
tonyalaribe merged 5 commits into
masterfrom
claude/unified-time-testing-EwfDg

Conversation

@tonyalaribe

Copy link
Copy Markdown
Contributor

Summary

This PR introduces a unified time testing system that allows tests to control time across both Haskell effects and PostgreSQL triggers/defaults. It replaces the frozen time approach with a mutable, advanceable test clock that syncs with PostgreSQL's app_now() function via a custom GUC variable.

Key Changes

Database Migration

  • Added static/migrations/0033_app_now_function.sql which:
    • Creates app_now() function that reads from app.current_time GUC variable, falling back to NOW() in production
    • Updates set_updated_at() trigger to use app_now()
    • Updates new_anomaly_proc(), check_triggered_query_monitors(), and check_tests_to_trigger() procedures to use app_now() for background job scheduling

New Test Clock Module

  • Created src/Pkg/TestClock.hs providing:
    • TestClock type backed by an IORef UTCTime
    • advanceTime and setTestTime for controlling the test clock
    • runMutableTime to interpret the Time effect from a mutable clock
    • syncConnectionTime to sync the test clock to PostgreSQL's GUC on each connection checkout
    • runWithTimeSyncedPool to wrap connection pools with automatic time synchronization

Test Infrastructure Updates

  • Updated Pkg.TestUtils to:
    • Accept TestClock parameter in background job runners
    • Export time advancement helpers (advanceTestTime, advanceMinutes, advanceHours, advanceDays)
    • Create and manage TestClock in TestResources
    • Use runMutableTime and runWithTimeSyncedPool instead of runFrozenTime

Query Updates for Time Consistency

  • Updated multiple query functions to accept Time :> es constraint and use Time.currentTime instead of NOW():
    • Models.Apis.RequestDumps: getRequestDumpForReports, getRequestDumpsForPreviousReportPeriod
    • Models.Telemetry.Telemetry: getDataPointsData, getMetricChartListData
    • Models.Apis.Anomalies: countAnomalies, acknowledgeAnomalies
    • Models.Apis.Issues: updateIssueWithNewAnomaly, updateIssueEnhancement
    • Models.Apis.LogPatterns: acknowledgeLogPatterns
    • Models.Apis.Monitors: updateQMonitorTriggeredState
    • Models.Projects.Projects: projectCacheById
    • Models.Projects.ProjectMembers and Models.Projects.ProjectApiKeys: Similar updates

Test Updates

  • Updated integration tests to:
    • Pass TestClock to background job runners and response handlers
    • Use getTestTime instead of getCurrentTime for test assertions
    • Remove dependency on runFrozenTime in favor of mutable clock

Implementation Details

  • The test clock is synced to PostgreSQL connections via set_config('app.current_time', ?, true) which makes the setting transaction-scoped and auto-resets when connections return to the pool
  • All time-dependent queries now explicitly use the test clock's time rather than relying on database-side NOW() calls
  • This ensures consistent time behavior across Haskell effects, background job scheduling, and database triggers during testing

https://claude.ai/code/session_019Mn7XLed4oq6j7dWqciekH

tonyalaribe added a commit that referenced this pull request May 26, 2026
…aster)

Rebases #329 against current master. The original branch (commit 8507fe6)
had 28 file conflicts because master refactored most of the same code
paths over the 60+ commits since the PR was opened, and its migration
0033 collides with master's existing 0033–0093.

This rebase keeps the foundation only — the two genuinely new things —
and defers the wide Time-effect threading through 16 source modules
because those modules have changed shape on master and need re-applying
against current signatures, not a merge.

Foundation included:

- static/migrations/0094_app_now_function.sql:
  - app_now() reads the 'app.current_time' GUC, falls back to NOW().
  - Updates set_updated_at / new_anomaly_proc / check_triggered_query_monitors
    / check_tests_to_trigger to call app_now() instead of now()/current_timestamp.
  - Preserves master's later changes that the original PR clobbered:
    new_anomaly_proc keeps the 1-hour batching delay (migration 0049).
  - Renamed from 0033 because the slot is long-taken.

- src/Pkg/TestClock.hs:
  - IORef-backed clock with advanceTime / setTestTime / getTestTime.
  - runMutableTime: Time-effect interpreter that reads from the IORef.
  - syncConnectionTime / runWithTimeSyncedPool: pool wrapper that sets the
    GUC on each connection checkout so app_now() in triggers matches.

Deferred (open follow-up): plumbing TestClock through Pkg.TestUtils,
threading the Time effect into the queries currently using NOW() at the
SQL level, and converting test specs from runFrozenTime to runMutableTime.
@tonyalaribe
tonyalaribe force-pushed the claude/unified-time-testing-EwfDg branch from 89e9c97 to 8eafc90 Compare May 26, 2026 15:31
@claude

claude Bot commented May 26, 2026

Copy link
Copy Markdown

Code Review: PR #329 — Unified time testing with app_now()

The core idea is sound — a GUC-synced app_now() SQL function plus an IORef-backed Haskell clock is a clean approach. Several issues to address before merging.


Bug: monitors.check_triggered_query_monitors was already dropped in migration 0038

static/migrations/0094_app_now_function.sql lines 106–116

Migration 0038_cleanup_dead_background_jobs.sql explicitly drops this procedure. The CREATE OR REPLACE PROCEDURE in 0094 silently re-creates an orphaned, unreachable procedure (no add_job() scheduling it). On a fresh DB (0001 → 0094), 0038 drops it, then 0094 re-creates it as dead code. Remove it from this migration.


Bug: app_now() marked STABLE but reads a session-mutable GUC

static/migrations/0094_app_now_function.sql line 16

$$ LANGUAGE plpgsql STABLE;

STABLE lets PostgreSQL cache the result within a single statement. But app_now() reads current_setting('app.current_time', true), which can change mid-test (after advanceTime + syncConnectionTime). The planner may reuse the pre-advance value. Change to VOLATILE (the default) to prevent intra-statement caching.


Correctness: is_local=true in syncConnectionTime is transaction-scoped, not connection-scoped

src/Pkg/TestClock.hs lines 68–75

With is_local=true, the GUC resets at end of transaction. In autocommit mode (the default for postgresql-simple), each statement is its own transaction — meaning the GUC resets after every syncConnectionTime call itself, before your actual query runs. Consider is_local=false so the setting persists for the full connection session and is cleared on the next pool checkout, or document the requirement that all interactions be wrapped in an explicit transaction.


Incomplete feature: TestClock is never wired into TestUtils/TestResources

The PR description mentions replacing runFrozenTime with runMutableTime/runWithTimeSyncedPool — but TestUtils.hs still uses runFrozenTime throughout (lines 542, 832, 846, 1008, 1015, 1022, etc.) and System/Types.hs line 163 is unchanged. Pkg.TestClock is exported but entirely unused. The GUC sync will never be called in tests. This ships infrastructure without the integration.


Code quality: Redundant import Data.IORef

src/Pkg/TestClock.hs line 20

import Data.IORef (IORef, modifyIORef', newIORef, readIORef, writeIORef)

Relude already re-exports all of these. Remove the explicit import.


Code quality: Leak the IORef abstraction vs. keeping it internal

src/Pkg/TestClock.hs line 36

newtype TestClock = TestClock {unTestClock :: IORef UTCTime}

unTestClock is never used outside the module — all internal functions pattern-match. Either use positional style (newtype TestClock = TestClock (IORef UTCTime)) or don't export the accessor. As-is it leaks the IORef implementation detail.


Minor: Hardcoded +00 timezone suffix in syncConnectionTime

src/Pkg/TestClock.hs line 74

let timeStr = formatTime defaultTimeLocale "%F %T%Q+00" t

Using %z or leveraging iso8601DateFormat (Just "%H:%M:%S%Q") from Data.Time.Format.ISO8601 would be more idiomatic and self-documenting (PostgreSQL accepts either).


Minor: PR description references wrong migration number

The description says 0033_app_now_function.sql but the actual file is 0094_app_now_function.sql.


Priority order:

  1. Remove re-created check_triggered_query_monitors (dead code, contradicts 0038)
  2. app_now()VOLATILE (correctness: planner caching)
  3. Reconsider is_local=true (GUC resets per-statement in autocommit mode)
  4. Wire TestClock into TestResources and replace runFrozenTime calls (feature is incomplete)
  5. Drop redundant import Data.IORef
  6. Hide or remove unTestClock

…aster)

Rebases #329 against current master. The original branch (commit 8507fe6)
had 28 file conflicts because master refactored most of the same code
paths over the 60+ commits since the PR was opened, and its migration
0033 collides with master's existing 0033–0093.

This rebase keeps the foundation only — the two genuinely new things —
and defers the wide Time-effect threading through 16 source modules
because those modules have changed shape on master and need re-applying
against current signatures, not a merge.

Foundation included:

- static/migrations/0094_app_now_function.sql:
  - app_now() reads the 'app.current_time' GUC, falls back to NOW().
  - Updates set_updated_at / new_anomaly_proc / check_triggered_query_monitors
    / check_tests_to_trigger to call app_now() instead of now()/current_timestamp.
  - Preserves master's later changes that the original PR clobbered:
    new_anomaly_proc keeps the 1-hour batching delay (migration 0049).
  - Renamed from 0033 because the slot is long-taken.

- src/Pkg/TestClock.hs:
  - IORef-backed clock with advanceTime / setTestTime / getTestTime.
  - runMutableTime: Time-effect interpreter that reads from the IORef.
  - syncConnectionTime / runWithTimeSyncedPool: pool wrapper that sets the
    GUC on each connection checkout so app_now() in triggers matches.

Deferred (open follow-up): plumbing TestClock through Pkg.TestUtils,
threading the Time effect into the queries currently using NOW() at the
SQL level, and converting test specs from runFrozenTime to runMutableTime.
@tonyalaribe
tonyalaribe force-pushed the claude/unified-time-testing-EwfDg branch from d5b0733 to 62eb053 Compare May 26, 2026 15:43
@claude

claude Bot commented May 26, 2026

Copy link
Copy Markdown

Code Review: PR 329 - Unified time testing with app_now()

OVERVIEW

This PR adds the foundational layer for controllable time in tests: a PostgreSQL app_now() function driven by a GUC, and a TestClock Haskell module. The approach is sound.

However, the diff is materially incomplete vs. the PR description. The description lists 15+ changed files (TestUtils wiring, TestResources.testClock, runWithTimeSyncedPool, query-function Time constraints, test updates) but the actual diff contains only 3 files. TestUtils.hs still calls runFrozenTime on lines 542/832/846 and has no TestClock integration. Until those follow-up changes land, the new module is dead code.


CORRECTNESS: set_config transaction scope

syncConnectionTime calls set_config with third argument true, making the GUC transaction-local. In autocommit mode (the default for postgresql-simple connections not inside an explicit BEGIN), each statement is its own transaction, so the GUC resets immediately after set_config returns, before the next query can read it. The docstring says resets when connection returns to the pool, which is misleading.

For the pattern to work, syncConnectionTime must be called within the same explicit transaction as the queries that need to see the test time. If the intended use is per-connection-checkout, consider false (session-scoped) instead, or document the transaction requirement explicitly. This needs resolving before wiring into the pool wrapper.


CODE SUCCINCTNESS (eta-reductions available)

Current:
advanceTime (TestClock ref) dt = modifyIORef' ref (addUTCTime dt)
getTestTime (TestClock ref) = readIORef ref
newTestClock t = TestClock <$> newIORef t

More concise:
advanceTime (TestClock ref) = modifyIORef' ref . addUTCTime
getTestTime = readIORef . unTestClock
newTestClock = fmap TestClock . newIORef

In runMutableTime, CurrentTime -> liftIO (readIORef (unTestClock clock)) removes the extra hop through getTestTime.


app_now() VOLATILITY

Declared STABLE but reads current_setting (a session variable). VOLATILE is more accurate since PostgreSQL may cache STABLE results across repeated calls in a single query. Recommend VOLATILE unless there is a measured performance reason to keep STABLE.


SMALLER NITS

  • syncConnectionTime uses PGS.execute for SELECT set_config(). PGS.execute_ fits better semantically (no result); functionally harmless.
  • GHC.Clock.getMonotonicTime is an internal GHC module; prefer System.Clock or effectful-provided equivalent if available.
  • TestClock (..) export exposes unTestClock, letting callers bypass the API. Fine for test code, just be intentional.
  • PR body says 0033_app_now_function.sql; actual file is 0094.

SUMMARY

The migration and TestClock module are clean. Two things to resolve: (1) land the TestUtils/query-function integration the PR description promises, or explicitly scope the PR down to foundation-only; and (2) clarify set_config transaction-scope semantics before the pool-wrapper is wired in.

tonyalaribe and others added 2 commits May 26, 2026 18:13
Builds on the foundation in the previous commit. Tests now share one
mutable clock per spec via 'tr.trTestClock' instead of each call site
seeding a fresh frozen clock. New helpers expose the advancement API
('advanceTestTime', 'advanceMinutes', 'advanceHours', 'advanceDays',
'getTestTime'); 'runTestBg t tr' still accepts a 'UTCTime' for backward
compatibility but now resets the shared clock to that time rather than
constructing a throwaway one.

Signature cleanup:

- 'effToServantHandlerTest' takes a 'TestClock' parameter (drives the
  Time effect via 'runMutableTime' instead of 'runFrozenTime').

- 'toServantResponse' / 'toBaseServantResponse' take 'TestResources'
  directly. Old call sites passing
    toBaseServantResponse tr.trATCtx tr.trLogger
  are now just
    toBaseServantResponse tr
  Same simplification for 'testServant' which collapses to an alias.

External callers updated: ApiV1Spec, ClientMetadataSpec,
BusinessFlowsSpec, BotTestHelpers (drops a now-unused
'runFrozenTime' import).

Deferred for a follow-up — wholesale SQL 'NOW()' → 'Time.currentTime'
threading through model query functions. That's per-function refactor
work that fits better as incremental commits against current master
signatures.
@claude

claude Bot commented May 26, 2026

Copy link
Copy Markdown

Code Review

Good overall design — the IORef-backed mutable clock threaded through both the Haskell Time effect and PostgreSQL's app_now() GUC is the right approach for unified time testing. The API simplifications (toServantResponse tr instead of three args, testServant = toServantResponse) are clean wins. A few issues worth addressing before merge:


Bug / correctness

syncConnectionTime gap: The SQL migration replaces NOW() with app_now() in set_updated_at, new_anomaly_proc, etc., but syncConnectionTime is never called on pool checkouts (the runWithTimeSyncedPool wrapper is explicitly deferred). Until that follow-up lands, app_now() falls back to real NOW() on every connection, so updated_at stamps written by triggers during tests will use wall-clock time, not test-clock time. Any test that asserts on trigger-stamped timestamps will be comparing against the wrong clock. Worth a comment in the migration and/or a TODO in TestClock.hs so it's discoverable.


Stale module doc

The TestClock.hs module Haddock says:

Pass a test connection through runWithTimeSyncedPool to wrap connection pools…

runWithTimeSyncedPool is neither implemented nor exported in this PR. Remove the reference or replace it with a note that pool-level sync is a follow-up.


Minor style

runTestBg spurious lambda (TestUtils.hs):

-- current
runTestBg t TestResources{..} = \action -> do
  setTestTime trTestClock t
  ...

-- cleaner — move the arg into the binding
runTestBg t TestResources{..} action = do
  setTestTime trTestClock t
  ...

Magic number secondsData.Time already exports nominalDay :: NominalDiffTime. The three helpers could use it to avoid the 60/3600/86400 literals:

advanceMinutes tr n = advanceTestTime tr (fromIntegral n * nominalDay / 1440)
advanceHours   tr n = advanceTestTime tr (fromIntegral n * nominalDay / 24)
advanceDays    tr n = advanceTestTime tr (fromIntegral n * nominalDay)

PGS.execute for a SELECT (syncConnectionTime): execute is conventionally for DML and returns Int64 affected rows. The call works in practice but is unconventional. Using void $ PGS.query @[PGS.Only Text] conn "SELECT set_config(...)" ... would match intent more clearly, or wrap the call as PGS.execute_ conn (fromString ("SET app.current_time = '" <> timeStr <> "'")) (though that loses parameterisation — stick with query then).


SQL

app_now() is declared STABLE. This is safe for the trigger use case (consistent time within one INSERT), but be aware it prevents PostgreSQL from re-evaluating it per-row inside a single query scan. For a clock function that's fine — NOW() behaves the same way — just documenting the intent would help future readers.

The check_tests_to_trigger loop references next_run_at after the inner loop; if the collection's schedule yields zero iterations next_run_at is uninitialized and the trailing UPDATE guard IF next_run_at < collection_record.last_run silently no-ops. This appears to be pre-existing behaviour preserved from migration 0001, but worth a note.


Summary

Foundation is solid. The two blockers are: (1) clean up the stale runWithTimeSyncedPool reference in the module doc, and (2) add a visible note somewhere that DB trigger timestamps will still use wall clock until pool sync lands. The rest are polish.

…lpers

Sites that called the old toBaseServantResponse/toServantResponse with
separate (AuthContext, Logger[, sessAndHeader]) args:

- Pages/Bots/DiscordSpec, Pages/Bots/WorkflowsSpec (Discord half) —
  these inject a modified Config.env; switched to record-update on
  TestResources: toBaseServantResponse tr{trATCtx = testCtx}.
- Pages/Bots/{Whatsapp,Workflows} (Whatsapp/Slack halves),
  Pages/GitSyncSpec, Opentelemetry/GrpcIngestionSpec — straightforward
  drop to toBaseServantResponse tr / toServantResponse tr.
- Pages/BusinessFlowsSpec: the one direct effToServantHandlerTest
  call now passes tr.trTestClock.

Also fix the lint warning that broke CI: inline the lambda parameter in
runTestBg (hlint Redundant-lambda).
@claude

claude Bot commented May 26, 2026

Copy link
Copy Markdown

Code Review: Unified Time Testing with app_now()

Good overall design — replacing the frozen-clock approach with a mutable, advanceable TestClock is the right abstraction. The Haskell side is clean. However, there is one critical functional gap and a few code-quality items worth addressing.


Critical: DB sync is not wired up

syncConnectionTime is defined, exported, and even imported in TestUtils.hs, but it is never called anywhere. The PR description mentions runWithTimeSyncedPool, which would have hooked it into pool checkout — but that function does not appear in the diff at all.

Consequence: app_now() in PostgreSQL always falls back to NOW() during tests. Every trigger (set_updated_at, new_anomaly_proc) and procedure (check_triggered_query_monitors, check_tests_to_trigger) ignores the test clock entirely. The SQL migration in 0094 is effectively a no-op for tests as written.

Fix needed: Wrap the test pool with a connection-checkout callback that calls syncConnectionTime, or call it explicitly before each query that touches a trigger/procedure. The import of syncConnectionTime in TestUtils.hs is currently unused dead code.


Time format string is fragile; iso8601Show already exists in the codebase

TestClock.hs:78:

let timeStr = formatTime defaultTimeLocale "%F %T%Q+00" t

The +00 suffix is hand-rolled. The codebase already uses iso8601Show from Data.Time.Format.ISO8601 (e.g. Models.Apis.LogQueries), which produces a well-formed RFC 3339 string that PostgreSQL parses unambiguously:

import Data.Time.Format.ISO8601 (iso8601Show)
void $ PGS.execute conn "SELECT set_config('app.current_time', ?, true)" (PGS.Only (iso8601Show t))

Prefer the package function over the hand-coded format string.


Magic numbers in advance helpers; use Data.Time.Clock.nominalDay

TestUtils.hs:671-681:

advanceMinutes tr n = advanceTestTime tr (fromIntegral n * 60)
advanceHours   tr n = advanceTestTime tr (fromIntegral n * 3600)
advanceDays    tr n = advanceTestTime tr (fromIntegral n * 86400)

Data.Time.Clock exports nominalDay :: NominalDiffTime (= 86400s). Derive from it to remove the magic numbers:

import Data.Time.Clock (nominalDay)

advanceDays    tr n = advanceTestTime tr (fromIntegral n * nominalDay)
advanceHours   tr n = advanceTestTime tr (fromIntegral n * nominalDay / 24)
advanceMinutes tr n = advanceTestTime tr (fromIntegral n * nominalDay / 1440)

logNotifications runs two effect stacks per notification

TestUtils.hs:233-252: the interpreter chain (Logging.runLog ... & Effectful.runEff) is rebuilt and executed twice per notification — once for logInfo, once for logTrace. Sequence both log actions and run them in a single stack:

let runLog = Logging.runLog ("background-job:" <> show appCtx.config.environment) logger appCtx.config.logLevel
forM_ notifications \notification -> do
  let notifInfo = ...
  (Log.logInfo "Notification" notifInfo >> Log.logTrace "Notification payload" notification)
    & runLog & Effectful.runEff

runTestBg silently resets the shared clock on every call

TestUtils.hs:224-228:

runTestBg t TestResources{..} action = do
  setTestTime trTestClock t   -- resets shared clock

A test that calls advanceMinutes tr 60 and then runTestBg frozenTime tr will have its advancement silently undone. This is noted in the Haddock but is easy to miss. Consider renaming to runTestBgAt to signal the reset semantics.


Minor

  • GHC.Clock is imported directly in TestClock.hs for getMonotonicTime. Worth confirming effectful-time does not already re-export it to avoid an extra raw base/ghc-prim dependency in test infra.
  • Several Haddock comments in TestClock.hs restate the type signature verbatim ("Read the current time from the test clock" over getTestTime :: TestClock -> IO UTCTime). These can be trimmed.

@tonyalaribe
tonyalaribe merged commit 4cc5ea1 into master May 26, 2026
9 checks passed
@tonyalaribe
tonyalaribe deleted the claude/unified-time-testing-EwfDg branch May 26, 2026 16:38
tonyalaribe added a commit that referenced this pull request May 26, 2026
Adds test infrastructure and 8 integration tests that exercise the new
controllable test clock (PR #329) across log patterns, anomalies,
notifications, digest flush, and expiry jobs.

TestUtils additions:
  runTestBgNoReset / runAllBackgroundJobsNoReset — multi-tick variants
    that don't snap the clock back to a fixed time.
  captureNotifs — clock-preserving notification capture.
  ingestLogAt / ingestErrorAt / ingestTraceAt — timestamp records from
    the live test clock instead of taking a UTCTime.

SQL conversions (from raw now() to #{now}):
  ExpireShareEvents, runNotificationDigest hourly flush, selectIssues
  24h chart series.

Migration 0095 swaps now() → app_now() in log_auto_resolve_activity
trigger so auto-resolve activity timestamps honour the test clock.

New tests:
  LogPatternsSpec 13 — acknowledgeIssue opens 24h cooldown
  LogPatternsSpec 14 — pruneStaleLogPatterns 30-day boundary
  LogPatternsSpec 15 — autoAcknowledgeStaleNewPatterns 7-day boundary
  NotificationsSpec 3a — hourly rate-limit bucket rollover
  AnomaliesSpec — selectIssues 24h period filter
  DigestFlushSpec (new) — hourly digest flush honours test clock
  ExpirySpec (new) — share-event 30+48h boundary, replay 30d TTL
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