Skip to content

Darling and Lite: a daily collector runs at a time of day you set, in every surface, and a schedule Reset clears it (#4938) - #5018

Merged
erikdarlingdata merged 106 commits into
devfrom
feature/4938-run-time-surfaces
Oct 3, 2026
Merged

erikdarlingdata merged 106 commits into
devfrom
feature/4938-run-time-surfaces

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Oct 3, 2026 •

Copy link
Copy Markdown
Owner

Closes #4938.

A daily collector can have a run time, a fixed time of day such as 02:00 on the monitored server's clock. The table that holds it, config.config_collector_run_times (V160), the shared run-time rules, and the detached daily runs with their fleet-wide cap (#4999) are already in dev. This change moves every surface onto that table. It makes the worker and Lite start a daily collector at its slot, and it adds one rule for Reset.

What changes

Every Darling surface below reads or writes config.config_collector_run_times. None of them puts a run time on a schedule row. The viewer's schedule Save deletes a scope's rows and inserts them again with a fixed column list. A viewer that does not know such a column clears it on every Save.

  • The viewer's Collector Schedules window. The window has a Run at column (Use default, None on a server row, or a HH:MM time). A line says what the time means in server time and in UTC, and a server row also gets a next-run line. The window reads the table next to the schedule rows. A store below V160 has no table. The read answers that with no run times, so the window still opens.
  • One-transaction Save. SaveCollectorScheduleAsync writes the run-time changes (one statement each) and then the scope's schedule rows on one connection in one transaction. A schedule write that fails leaves the run time unsaved, and a run-time write that fails leaves the schedule rows as they were. A Save that changed no run time sends no run-time statement, so it still saves its schedules on a store below V160. The schedule statements are the ones a released viewer runs and never name the run time.
  • --set-collector-run-at. The service's CLI verb sets a time (HH:MM), stops a fleet-wide time on one server (none), or clears the row (default). It works fleet-wide or with --server. It writes only the run time and keeps the enabled flag, cadence and retention on the row. The service's command executor writes the same table for the matching command.
  • The MCP and web health reads. get_collection_health shows each collector's run time and next run, and a note when a day was skipped. The web Collection Health tab shows the run time beside Every (min). Its Collectors table also has Run at, Next run and Run time columns.
  • The Darling worker. A daily collector that has a run time starts at its slot. The slot is the run time plus a spread of under 60 minutes taken from the server id. The pass hands the collector off from the slot until 60 minutes after it. A day it cannot hand off in that hour is skipped, not replayed. The skip counts as a skipped slot only when the sweep loop was running through it. The worker holds each server's clock in memory. It reads the clock once at connect, and again after a run of server_properties (SQL Server) or pg_server_config (PostgreSQL), never on a tick. A slot is computed again only when the run time, the interval or the clock changes. The detached run, its cap, its permits and its watch are Every daily collector runs detached from its server's pass, behind a fleet-wide cap sized to the store's pool (#4938) #4999's. This change only decides when a collector with a run time is handed to them.
  • Lite. The schedule file takes an optional run_at (HH:MM on the monitored server's clock). The schedule editor sets it, checks it and shows the next run. The sweep starts a collector with a run time once a day, from its slot until 60 minutes after it. A missed day is skipped. At start-up Lite reads each daily collector's last run from collection_log. It also reads the server's clock from the newest server_properties row. A restart or a tab open then no longer re-runs a daily collector that ran today. A failed attempt of a collector that runs once a day or less often ends its period, with or without a run time. Lite's get_collection_health carries run_at, next_run_utc and run_time_note for its own schedule, so the two products answer alike.
  • The docs. Darling/README.md has what the run time sets, what stays fixed, and the --set-collector-run-at verb. docs/how-collection-works.md has a section, "When the daily collectors run". Its one sentence on detached runs gives Every daily collector runs detached from its server's pass, behind a fleet-wide cap sized to the store's pool (#4938) #4999's cap. The cap is the smaller of 16 and what the store pool leaves after the sweep and 8 kept free for reads. It is never below 1. The top-level README.md gets a line on run_at.
  • The Reset rule. A schedule Reset also deletes that scope's run times, in the same transaction as the schedule rows. Reset means back to the shipped defaults, and they have no fixed time. Three paths in the viewer reset schedule rows, and each follows the rule:
    • "Reset to Defaults" in the window. Before this change the Save deleted only the run-time rows the window had loaded for collectors this build defines. Now the window remembers the Reset, and its Save passes clearScopeRunTimes. The scope's run-time rows are deleted by scope, first, in the Save's transaction. That covers a row for a collector this build does not define and a row another writer added while the window was open. A time typed into the grid after the Reset is inserted after the clear and kept.
    • A server on "Use default schedule". Same call, same clear.
    • "Apply Default to All Servers". ResetAllServerSchedulesAsync is now ONE call. In one transaction it deletes every server's schedule rows and then every server's run times. It keeps the fleet's rows in both tables. A failure in the run-time delete takes the schedule delete back with it. The 42501 error still maps to the read-only message, and 42P01 or 42703 to the schema-skew message. It returns the rows removed from each table, counted apart, and the status line names each. ResetAllServerRunTimesAsync, the separate statement the window used to call after it, is gone.
    • On a store below V160 the run-time half is skipped, so a Reset still works there. The table is looked up by name first, because a failed statement aborts the transaction.
    • No other code path resets schedule rows. The search covered the viewer, the service CLI and command executor, the MCP tools and the web files. It looked for a DELETE or TRUNCATE on config_collector_schedules and for reset verbs. Only the viewer's data service deletes them. The service's --enable-collector and --disable-collector verbs and its enable and disable commands upsert one enabled value. Lite keeps its schedule in its own file, not in this store.
    • A viewer released before the table existed resets the schedule rows only. It never touches the run-time table. So a run time set by this viewer survives a Reset done in an older one.
  • The serial-loop census. StartupCommandTimeoutTests now lists ReadNotificationRoutesAsync, which the service's config load already runs on the serial loop. 11 members hold 12 commands, and 9 of the 12 also run once at start-up. The prose in ServiceCommandDeadlines.cs says the same.
  • Three names that Every daily collector runs detached from its server's pass, behind a fleet-wide cap sized to the store's pool (#4938) #4999 left saying "daily". _dailyRuns, TrackDailyRun and InFlightDailyRuns hold every detached run, the daily ones and the three detached by name. They are now _detachedRuns, TrackDetachedRun and InFlightDetachedRuns, with the tests and comments that name them. Behavior does not change.

Test plan

Each RED below was run against a live store, with the code as it stood before the Reset change.

  • Apply Default to All Servers removes both tables' server rows in one call. ApplyDefaultToAllServers_RemovesEveryServersScheduleRowsAndRunTimes_AndKeepsTheFleets. RED: Expected: 5, Actual: 2. GREEN after.
  • A failure after the schedule delete leaves the schedule rows and the run times in place. A trigger refuses the run-time delete, and the same call without the trigger removes both. ApplyDefaultToAllServers_ThatFailsAfterTheScheduleDelete_LeavesTheScheduleRowsAndTheRunTimesInPlace. RED: No exception was thrown. GREEN after.
  • The updated schedule-statement test: the replace statements still leave run times alone, and the all-servers reset removes the server run times. AScheduleSave_ThroughTheViewersRealSql_LeavesEveryRunTimeAlone_AndApplyDefaultToAllRemovesOnlyTheServerRunTimes. RED: Expected: 2, Actual: 0. GREEN after.
  • Reset to Defaults, then Save, leaves no run-time row for the scope, and no other scope's rows change. It covers a row for a collector this build lacks and a row added while the window was open. It also covers a time typed after the Reset, a server on Use default schedule, and the fleet scope. ASaveAfterAReset_LeavesNoRunTimeRowForTheScope_AndKeepsEveryOtherScopes. RED: server 7's rows survived. GREEN after.
  • Source pin. The window arms the clear in ResetDefaults_Click and passes it to its one Save call. It calls ResetAllServerSchedulesAsync once and has no run-time reset of its own. That method opens one transaction and deletes the schedule rows before the run times. AScheduleReset_DeletesTheScopesRunTimes_InTheSameTransactionAsItsScheduleRows. Written with the fix, and not run against the old code.
  • A store below V160 (table dropped). The all-servers reset still removes the server's schedule rows and returns their count. A Reset's Save (clear flag set, no run-time change) still saves its schedules. Test: AStoreWithoutTheRunTimeTable_ReadsNoRunTimes_AndASaveSaysTheStoreIsOlder. The reset half passes before and after. The Reset's Save check was added with the fix.
  • These were RED earlier on this branch. They were not re-run for the RED here, and they pass now.
    • The CLI verb: CollectorRunTimeCliVerbTests and CollectorRunTimeCliVerbLiveTests.
    • The 42P01 handling on the read and on the save: the store-below-V160 test above.
    • A Save that leaves run-time rows alone: TheViewerScheduleSave_LeavesEveryRunTimeRowAlone_ForAFleetScopeAndAServerScope and TheViewersScheduleStatements_NeverNameTheRunTime.
    • The one-transaction Save: AScheduleWriteThatFails_LeavesTheRunTimeUnsaved_BecauseTheSaveIsOneTransaction and TheWindowsSave_IsOneCallInOneTransaction_AndNeverCommitsTheRunTimesOnItsOwn.
  • The merge with dev conflicted in DarlingWorker.cs, DarlingMcpDataTools.cs, ViewerDataService.CollectorSchedules.cs and DailyCollectorDetachTests.cs. Each took dev's (Every daily collector runs detached from its server's pass, behind a fleet-wide cap sized to the store's pool (#4938) #4999's) detached-run code and kept this branch's run-time code on top. git diff origin/dev...HEAD holds only run-time work and the three renames. SweepBodyDetachPolicyTests.cs is dev's unchanged, and DailyCollectorDetachTests.cs is dev's apart from the renames.
  • The targeted Darling classes without a store: Total 827, Failed 0, Skipped 61 (the live facts). The same classes live: Total 827, Failed 0, Skipped 0. BothSkusToolDescriptions_StayByteIdentical: 1 of 1.
    every class that names config_collector_run_times, CollectorRunTime, ResetAllServer, SaveCollectorScheduleAsync,
    RanDetached, BeginDailyRunWatch, MaxConcurrentDailyRuns or DailyRun, and by name:
    ViewerCollectorSchedules*, CollectorRunTimeCliVerb*, CollectionHealth*, DailyCollectorDetachTests,
    SweepBodyDetachPolicyTests, DetachedByNameWatchdogTests, DailyRunCapAndWatchdogTests, StartupCommandTimeoutTests,
    StragglerCommandTimeoutTests, ViewerCommandTimeoutTests, SerialLoopStoreSizeSourceTests, DarlingSweepSchedulingTests,
    ViewerDataServiceTests, McpPayloadContractCensusTests, RepoFileAdoptionTests, DocCommentHygieneTests,
    CommentFilterAdoptionTests
    
  • Lite.Tests CollectorRunTime*, CollectionHealth* and CollectorFrequency*: Total 112, Failed 0.
  • Darling.Tests, Lite.Tests, the viewer and the service build with 0 warnings and 0 errors.
  • The full Darling.Tests suite runs in CI's PostgreSQL test jobs, green at 3f3fbd66e. An earlier local live run on a throwaway cluster did not complete: it had 24 command timeouts under the parallel load, and then the cluster crashed (could not reserve shared memory region). Every class that timed out passed when re-run alone on a fresh database: CollectorRunTimeCliVerbLiveTests 6 of 6, and the other 15 classes 57 of 57.
  • The window itself was not opened, and neither was Lite's editor. The data-service tests, the pure ToRunTimeChanges and the source pin cover the Reset and Save, not a click-through.

Fixes from review

What changes (Lite)

  • A run_at in collection_schedule.json that is not a JSON string, such as "run_at": 120, true, an object or an array, now costs only that collector's run time. Before, the whole file failed to load, Lite fell back to the backup or the defaults, and every other edit in the file was lost. The value now reads as its own text, is refused the same way "25:00" is (same warning, naming the collector), and the collector runs on its interval. The file is written back with the value as text. No other property this PR added has this shape.
  • The shipped Lite builds its schedule manager with the app's logger, as the other services built beside it do. Before, the warning for an ignored run time ("25:00", or "02:00" on an hourly collector) was never written.
  • One floor for a daily collector's last run, used by both Lite paths: the collector's own interval plus a day (ScheduleManager.LastRunFloor). A run older than that reads as never run. get_collection_health applies it to the health row's run. The start-up read applies it to each collector it seeds, so a weekly collector on the same server no longer widens the floor of a 3-day one. For a collector on a 2-day-or-longer interval, the tool and the scheduler now name the same next run.

Test plan

  • RED on the PR head (5c71816), green now: a file with "run_at": 120 (also 1.5, true, false, an object, an array) on one collector and a 7-minute interval on another. The 7-minute interval and the other collector's retention survive, no error is logged, one warning names the collector, the collector runs on its interval, and a reload reads the same. 6 cases failed before.
  • Guard, passes before and after: "run_at": "02:30", "25:00" and null read as they always did.
  • RED, green now: the app's MainWindow builds exactly one schedule manager and passes new AppLoggerAdapter<ScheduleManager>(). A second test sends "25:00" (daily) and "02:00" (hourly) through that same adapter and finds the warning in the app log. This second test passes before the fix too, because the adapter already existed: the first one is the RED.
  • RED, green now: a 3-day collector with a run time whose last run is 6 slot dates back (older than interval plus a day, inside the health read's 7 days). The scheduler's first run slot equals the tool's next_run_utc (they differed). Cases for 2, 3 and 5 slot dates back pass before and after: 2 and 3 are inside the floor, and at 5 the old grid happens to land on the same slot.
  • RED, green now: a server with a 3-day and a weekly collector, the 3-day one last run 6 slot dates back. The scheduler treats it as never run and runs it at the next daily slot (before, the weekly neighbour's wider read kept the old run and put it on a later day).
  • Floor helper: interval plus a day, inclusive at the edge, per interval.
  • Lite.Tests, 854 tests in the classes that name ScheduleManager, CollectorSchedule, McpCollectorRunTimes, the run-time part of RemoteCollectorService, or read MainWindow.xaml.cs: 0 failed. After the final merge, the run-time, health-tool, delta, grid and install-id classes: 152 tests, 0 failed.
  • Darling.Tests, no store, final: 198 tests (AlertMasterSwitchSurface, AlertReadFailureSurface, CollectorMeasurementSeam, CollectorStateContract, FleetCardCollectionStale..., LiteJobHistorySource, MuteRuleReloadRetention, RemoteCollectorServiceCancellationCensus, TsqlConventionGuard, RepoFileAdoption, DocCommentHygiene, CommentFilterAdoption), 0 failed, 1 skipped. The member-scan guard failed once on the first version of the floor helper (an expression body it read short), so the helper is a block body.
  • Lite and Lite.Tests build with 0 warnings; Darling.Tests builds with 0 warnings.
  • The full Lite suite runs in CI's Lite tests jobs, green at 3f3fbd66e.

Known limit

The health read covers seven days. The floor for an interval of seven days or more (8 days or more) is wider than that, so a run between 7 days and the floor is seen by the scheduler and not by the tool. This was already true before the floor, and closing it needs a wider read for those collectors.

What changes (Darling viewer, Collector Schedules window)

  • A failed read can no longer make Save delete run times. When the run-time read failed, the window held no run times, so a server whose only override was a run time opened on "Use default schedule", and a plain Save cleared that run time. Now, when the run-time read fails, its warning stays at the top of the status line (the next status text no longer replaces it) and Save sends no run-time change at all: no new time, no delete, no clear. The schedule itself still saves. If the user asked for a run-time change that cannot be written safely (a time typed into a Run at cell, or Reset to Defaults, which clears them), Save is refused with a message that says why, instead of quietly dropping it. Reopening the window reads the run times again.
  • The schedule read has the same guard. When the schedule read fails, the window shows the built-in defaults, and a Save would have replaced the stored rows with them. Save is now refused until the schedules are read, and the warning stays visible.
  • "Apply Default to All Servers" names what it reset. It used to read "Reset 2 per-server schedule override(s)" even when only run times were removed. The call now counts the two kinds apart, and the status names each, singular and plural: "Reset 2 per-server run times", "Reset 1 per-server schedule override", or "Reset 1 per-server schedule override and 2 per-server run times".
  • "Use default schedule" shows the fleet-wide run time. With the box ticked, the line under the grid treated the fleet's time as one the server had of its own. It now says "Uses the fleet-wide run time (02:00)." and then the server's own slot, the same wording as a server row set to "Use default". Display only; Save is unchanged.

Test plan (each check was written first and fails to build on the branch as it stood)

  • Failed run-time read, then a plain Save with nothing changed: no run-time delete, upsert or clear is sent (fails before: the save plan and its guard do not exist). The same Save after a read that worked still clears the server's run times.
  • Failed run-time read, schedule edited: the schedule rows are saved and no run-time change goes with them.
  • Failed run-time read, a time typed or Reset to Defaults: the Save is refused with the run-time message and nothing is sent.
  • Failed schedule read: the Save is refused and nothing is sent, for the fleet scope and for a server, ticked or not.
  • The window writes its status line in one place and builds its Save from the plan (the old Save lines are pinned out).
  • A reset of run times alone reads "Reset 2 per-server run times"; seven count combinations assert the exact status text, and a live check on a throwaway store confirms the counts come back apart (0 and 2 for run times only; 2 and 3, 2 and 2, 1 and 0, 0 and 0 for the other cases).
  • "Use default schedule" with a fleet-wide run time of 02:00 starts with "Uses the fleet-wide run time (02:00)."; with no fleet-wide time it says there is no fixed run time; a fleet row and a server with its own schedule are unchanged.
  • Targeted classes without a store: 200 run, 0 failed, 13 skipped (the live ones). Same classes against a throwaway store: 117 run, 0 failed, 0 skipped. Build: 0 warnings.
  • The full Darling.Tests suite runs in CI, green at 3f3fbd66e.
  • Not run: the window itself on screen.

What changes (Darling service, MCP and docs)

  • A day is served only when its run starts. A daily collector with a run time counted its day as served at hand-off. When yesterday's run was still going, or still waiting for a permit, at today's slot, today's run was then refused at once: the pass had counted it as run and moved the next run to tomorrow, so that day's run never happened. Now the day counts as served, and the next run moves, only once today's run takes its slot. Until then the day stays due and the pass asks again on the next tick, inside the hour after the slot. Past that hour the day counts as one skipped slot, as before.
  • An on-load collector with a run time counts today's slot as served at connect only when its run at connect succeeded. A failed run leaves the slot due inside its hour, so it is tried again that day.
  • get_collection_health no longer fails as a whole when the MCP role cannot read the run-time table. That happens on a store you provisioned yourself before this table existed. The tool shows no run times and logs one warning saying to re-run provision-roles.sql, which now grants the MCP role read on config.config_collector_run_times. The managed store's MCP role already reads every config table.
  • Commands store the catalog's spelling of the collector name. set_collector_run_at, enable_collector and disable_collector, from the command plane and from the CLI, stored the name as typed. The unique indexes compare names exactly, so a typed Wait_Stats and the viewer's wait_stats became two rows for one collector.
  • A PostgreSQL server whose TimeZone this host cannot resolve gets one warning. Examples are localtime, or a POSIX string such as EST5EDT,M3.2.0,M11.1.0. The warning is logged once per server and zone, names the zone, and says that server's run times use UTC. Before, they moved to UTC with no message.
  • The docs:
    • a run held by the fleet's concurrency cap starts when a slot frees, which can be after the hour (Darling/README.md, docs/how-collection-works.md);
    • Lite's "does not catch up for a day it was closed" applies only to a collector with a run time;
    • the top-level README.md says a run can start nearly 2 hours after run_at.
  • Not fixed here: Darling: a run-time edit that lands in the same tick as a daily run's start can run that collector a second time the same day #5033. A run-time edit that a reload applies just as that collector's daily run starts can run it a second time that day.

Test plan (Darling service and MCP)

Each check below fails when its fix is taken out. The second mutation for each was written separately from the fix.

  • Pass_WhileYesterdaysRunStillHoldsTheSlot_DoesNotCountTodayAsRun_AndRetriesInsideTheGrace and its past-the-grace sibling.
    • RED: recording the slot at hand-off again fails both.
    • RED: a run that finds its slot held recording the day anyway fails the first.
  • OnLoadSeed_AfterAFailedRunAtConnect_LeavesTheSlotDueInsideTheGrace.
    • RED: seeding from "now" whatever the outcome fails it.
    • RED: reporting the run at connect as succeeded fails it.
  • AnInsufficientPrivilegeReadAnswersNoRunTimesWithOneWarning and AMissingTableAnswersNoRunTimesAndAnythingElsePropagates (other errors still propagate). RED: not matching 42501 fails the first.
  • ProvisionRolesSql_GrantsMcpReadOnTheCollectorRunTimes. RED: removing the grant line fails it.
  • ResolvePlan_CollectorWrites_BindTheCatalogsSpellingNotTheTypedOne, for every verb, scoped and fleet-wide. RED: binding the typed name for set_collector_run_at fails it.
  • AnUnresolvedZone_WarnsOncePerServer_NamingTheZone_AndReadsAsUtc and a resolvable zone (including UTC) logging nothing.
    • RED: lowering the warning to trace fails it.
    • RED: warning on every read fails it.
  • The viewer's save plan: ASave_AfterAFailedRunTimeRead_ThatChangedNothing_SendsNoRunTimeDeleteOrClear. RED: clearing the run times after a failed read fails it.
  • Builds: Darling.Tests and Lite.Tests, --no-incremental, 0 warnings and 0 errors at the head.
  • At the head without a store, every class 0 failed: DarlingRunTimeSchedulingTests 22, DarlingCollectorRunTimeReaderTests 18, DarlingCommandExecutorTests 34 (8 live skipped), ProvisionRolesAclDriftTests 9, DarlingPgUnresolvedZoneWarningTests 2, CollectorRunTimeCliVerbTests 21, CollectorRunTimeRungTests 19, CollectorScheduleEditorSaveGuardTests 8, CollectorScheduleResetStatusTests 8, CollectorScheduleUseDefaultRunAtLineTests 4, CollectionLogDrainForensicsStoreTests 13, DailyCollectorDetachTests 30, and the guards (TsqlConventionGuardTests, DocCommentHygieneTests, RepoFileAdoptionTests, CommandDeadlineScannerAdoptionTests, FleetIdentifierScrubTests, QueryStoreSliceTieBreakSourceTests).
  • Lite: the REDs for the three Lite fixes above are in CollectorRunTimeScheduleTests and CollectorRunTimeWarningLogTests. CI's Lite tests jobs run them green at 3f3fbd66e.
  • CI is green at 3f3fbd66e, including the full Darling.Tests run in the PostgreSQL test jobs.

Not done here, and notes

  • Not done: a click-through of the Collector Schedules window and of Lite's schedule editor. In the window, that means Reset to Defaults, Use default schedule and Apply Default to All Servers.
  • The Apply Default to All Servers confirmation now says it removes per-server run times too. It also says the fleet-wide schedule and run times are not changed. The "Reset to Defaults" prompt already said it clears every run time, and now it clears them by scope.
  • The Apply Default to All Servers status line names the schedule overrides and the run times it removed apart (see "Fixes from review").
  • A Reset keeps the fleet-wide rows when it runs for servers, and clears the fleet's rows only when the fleet scope itself is reset.
  • This change does not touch the table definition or the migration.

CHANGELOG

No entry in this PR. The release owner adds it under [Unreleased].

Proposed entry:

SECTION: Added
ENTRY:
- **A daily collector can run at a time you choose** ([#5018]) - A collector that runs once a day, or every whole number of days, such as `index_object_stats`, can have a run time, a fixed time of day on the monitored server's own clock, so a heavy collector runs in a quiet hour instead of whenever the service happened to start. In Darling, set it in the Viewer's Collector Schedules window (the new Run at column) or with `--set-collector-run-at`, fleet-wide or per server; the table `config.config_collector_run_times`, added in store version 160 ([#5007]), holds it. In Lite, set it in the schedule editor, or as `run_at` in the schedule file. Each server starts at the run time plus a fixed spread of under 60 minutes, a run may start up to 60 minutes after its slot (later when the fleet's daily-run cap holds it), and a missed day is skipped, not replayed. `get_collection_health` and the web Collection Health tab show each collector's run time and next run. Reset to Defaults, a server's Use default schedule and Apply Default to All Servers also delete the run times of the schedule they reset; a Viewer released before this change resets the schedule rows only, so a run time set here survives a Reset done in an older Viewer. On a store whose roles you provisioned yourself, re-run `provision-roles.sql` after the upgrade so the MCP role can read the run times.
REF:
[#5018]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/5018
[#5007]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/5007

…4938, package 1)

Adds CollectorRunTime to PerformanceMonitor.Collectors: the pure rules for
running a once-a-day collector at a chosen time on the monitored server's
wall clock. Nothing calls it yet except tests, so no behavior changes.

What it holds:
- SlotUtc: the UTC instant of a server's slot on a local date.
- NextDue: the next due time from now, the last run (nullable), the run-time
  minute, the interval and the server id.
- Spread (a fixed per-server offset under 60 minutes, from
  CadencePhaseOffset(serverId, 3600)) and Grace (60 minutes), both constants.
- AllowsRunAt: only a positive multiple of 1440 minutes.
- TryParse / Format for "HH:MM".
- MaxStampAhead: how far ahead a run-time stamp can sit (interval + 60 + 60
  minutes), for the clock-step check, so a stamp on a 25-hour autumn day is
  not read as a clock that stepped back.
- InvalidRunAtMessage and IntervalRefusalMessage: the two error texts, one
  copy for every editor.
- LocalIsUtc: the conversion to pass while a server's clock is not known.

CadencePhaseOffset moves from DarlingWorker to CollectorCadence (same code,
public). Its values are pinned first, as 24 literal (id, period) cases
including period 3600, which the existing pins did not reach, and they pass
unchanged on both sides of the move. DarlingWorker keeps a one-line
forwarding member, so the seed jitter, the cold-start spread, the
on-connect analysis stamp and their existing tests keep one spelling.

Design calls:
- The rules never know a time zone. They take a local-to-UTC delegate (an
  app passes ServerClock.ToUtc; LocalIsUtc while the clock is unknown).
- Only local-to-UTC is available, so the date of "now" is found by probing
  the few candidate dates around the UTC date (an offset is within 14 hours)
  and taking the latest slot at or before the instant. The same probe dates a
  last run. No UTC-to-local conversion is needed.
- A day is identified by its slot, not by the calendar date of a run. A slot
  that spills past local midnight (a late run time plus the spread) keeps the
  date it was computed for, so the run after it is one day later, not two.
  A run before the day's slot (an on-load run in the small hours) counts for
  the day before, so that slot is still owed.
- Each day's slot is computed from that date's local time (run time plus the
  spread, converted), never as the last slot plus 1440 minutes, so it stays
  at the same local time across a daylight-saving change.
- With a last run, the slots are every interval/1440 days from that run's
  date, so a two-day collector keeps its two-day grid. The latest slot at or
  before now is due when now is inside its grace (the answer is now, and the
  caller adds the seed jitter). Past the grace that day is skipped and the
  answer is the next grid slot. A slot still ahead is returned as is. With no
  last run the slots are every day. After a run, pass the run's instant (or
  the slot it ran for) as the last run.
- The grace is closed at both ends: a start at the slot, or at the slot plus
  60 minutes, is inside.
- A last run at the bottom of the calendar (DateTime.MinValue) is read as
  never run, so a sentinel cannot throw in the date arithmetic.
- TryParse takes exactly HH:MM (two digits, ASCII, 00:00 to 23:59) and
  ignores surrounding spaces; "24:00", "2:5", "2:00" and "abc" are refused.
- NextDue throws for an interval AllowsRunAt refuses or a run-time minute
  outside 0 to 1439; callers validate with AllowsRunAt first.

Tests (Darling.Tests, which references both projects, so the daylight-saving
cases use the real ServerClock.ToUtc): 74 in CollectorRunTimeTests, covering
before the slot, after the grace, inside the grace, a missed day, a
two-day interval, a spring-forward and a fall-back day in US Eastern, every
day of a year at the same local time, a fixed offset across the UTC date, an
unknown clock, the spread, the clamp bound, AllowsRunAt, parse and format
round trips and refusals, and a sweep over six clocks against a brute-force
check of every candidate date. The tests were run against stubs that return
wrong answers first: 42 of 74 failed.
…nd resolve (#4938, package 2a)

Migration 158 adds config.config_collector_schedules.run_at_minute, a nullable smallint of minutes
after midnight on the server's clock, CHECK -1 to 1439. NULL falls through per column as the
databases column does; -1 means no fixed time on this server and stops a fleet-wide time. The CHECK
is inline on the ADD COLUMN, so the statement stays idempotent and adds no validation pass. Nothing
writes the column yet.

The version bump brings its obligations: StorageVersion 158, the viewer probe's sentinel with its
MapProbedSchemaVersion arm and GetBoolean(133), the previous top rung's tests handed off, the
version-simulating live test drops the new column, and the pinned service SELECT now ends with it.

ScheduleOverride and EffectiveSchedule carry the run time, and the service's store read fills it.
ResolveSchedule: the server row's value wins (-1 resolves to none), else the fleet row's, else none;
a value outside the CHECK's range counts as not set. A value where the effective interval is not a
whole number of days (CollectorRunTime.AllowsRunAt on the recurring interval, so an on-load collector
counts as daily) resolves to none.

The warning for a dropped run time is raised in StoreConfigProvider.LoadViewAsync, after the servers
and the schedules are read, not in ResolveSchedule: that runs every sweep and stays pure and silent,
while a load happens once per config_version bump, which is once per schedule load. It is judged per
enabled server, not per row, because a per-server frequency override changes the interval (a
fleet-wide time can be fine on one server and refused on another), and a collector the server's
engine never runs is skipped. One warning per dropped run time names the collector, the server, which
row set the time, and the shared refusal text.

Also fixes the stale pg_index_bloat comment: since #3234 it estimates from pg_stats and the catalog
and reads no index pages.
…and word the probe comment without the banned term
…ext run, and the web Collection Health tab shows the run time (#4938)
…uster id is rebound (#4961, #4967)

Tests for the rule as a truth table, the live rebind and replacement facts, the new migration rung and its probe sentinel, and a reader on a store still below the rung.
…, #4967)

pg_upgrade makes a new cluster identifier and keeps the OIDs of the database and of the install id table. Migration 159 adds table_oid to the install id row. The service decides whether a row is this store's by the two OIDs once the row has its table OID, rebinds a changed cluster identifier without changing the id, and names which binding differed when it makes a new id. A row with no table OID follows the old rule once and has it filled in.
… cannot be saved is not used, a create is a move (#4961, #4967)
…e it could not save; a new id is created by a move from a temp file (#4961, #4967)
…plica only, with the Microsoft Learn link, at each stop and in both READMEs (#4961, #4967)
…-run-time

# Conflicts:
#	Darling/Darling.Tests/CaggGroupIndexDropLiveTests.cs
#	Darling/Darling.Tests/MuteRuleServerIdRungTests.cs
#	Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs
#	Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs
…o feature/4938-collector-run-time

# Conflicts:
#	Darling/Darling.Tests/CaggGroupIndexDropLiveTests.cs
#	Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs
#	Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs
…V158 and V159 (#4938)

The run-time column's migration is version 160, appended after the install id's two rungs, and
StorageVersion is 160. The viewer probe's sentinel for the column is the last arm (ordinal 135),
and the version map has a newest-first arm for it that returns 160.

The install id table OID rung (V159) is no longer the top of the ladder, so its tests hand the top
off the way the earlier rungs' did: the rung is registered in a dense ladder below the top, and a
store that stops at its sentinel maps to it. The run-time rung tests and their live half follow the
new number: the map's all-true and one-behind cases, the arm's return value, and the upgrade test
that starts from V159 and rewinds the version stamp from 160.

Comments that named the column's version now say V160.
…nd tab-open runs from the local log, and a file with run_at (#4938)
… under a fleet-wide cap (#4938)

Adds a stand-in for one collector run (RunOneBodyOverride) and makes the
per-server due-collector pass internal, so a test can hold a daily run open
and watch the pass around it. The new cases pin that:

- the server pass ends while a daily run is still going, and a 1-minute
  collector on the same server still runs on time;
- with 17 daily runs due together, 16 start, the 17th waits for a permit and
  starts when one finishes, and none is dropped;
- a daily collector whose previous run is still going skips that tick.

This commit changes no behavior: the daily run is still inline, so these
cases fail here and pass with the change that follows.
…fleet-wide cap of 16 (#4938)

A collector whose effective interval is a day or more (index_object_stats, the
once-a-day recapture of the on-load config collectors, the PostgreSQL daily
collectors, and any collector an operator slows to a day) used to run inline in
its server's sequential pass. A long run, 43 minutes across 72 databases in a
field case, held back that server's 1-minute collectors for its whole length.

- Each daily collector is now dispatched detached, behind the per-(server,
  collector) single-flight slot plan_correction already uses. A held slot means
  the previous run has not finished and the tick skips, with a log line that
  says the next run is a day away.
- A fleet-wide cap of 16 bounds how many detached daily runs go at once. A run
  over the cap waits for a permit on the service's cancellation token, so only
  shutdown ends the wait, and it is logged once at Debug. After a long wait the
  run re-reads the server and does not run if it was removed or reconnected.
- A detached daily run does not fold its duration into the sweep body's peer
  mark, because it finishes minutes to hours after the body that dispatched it.
- Detached daily runs are tracked so a shutdown waits for them as it waits for
  the per-server bodies.
- query_store, plan_correction and pg_wait_sampling run as before, and the
  at-connect run of an on-load collector stays inline.
…t-collector-run-at verb (#4938, package 3a)

The Collector Schedules window gets a Run at cell per collector (a HH:MM time, Use default, and None on a server row), a conversion line, and the shared checks. The save writes the edited run time and keeps an unedited one, so the test that pinned a save clearing a stored run time is renamed and now pins the new behavior. The --set-collector-run-at verb writes the same row as the viewer for the fleet or one server. These tests describe that surface; the code lands in the next commit.
…day's run, so it is not retried on every sweep of the hour (#4938)

A run that did not succeed still writes its collection_log row, and the start-up read counts every row as the last run whatever its status. The session now counts the attempt the same way: a failing collector runs once inside the hour that follows its time, and its next due time is the next day's. A collector with no run time keeps the interval rule.
…ite side of the run time comes in its own change (#4938)
… server's clock, a 60-minute spread and grace, skipped days, detached runs and Lite (#4938)
…and a note when a day was skipped (#4938)

A collector with a run time carries run_at (24-hour HH:MM on the monitored server's clock) and next_run_utc (the shared rules' next due time: the run time plus the server's spread, on the server's own clock from its newest server_properties row, or UTC with none). The server row wins over the fleet row, -1 on the server row stops the fleet's, and a disabled collector shows its run_at with no next run. A collector past its stale line with the next slot still ahead carries run_time_note. Health bands stay on the shipped cadence.
A PostgreSQL TimeZone the host cannot resolve reads as UTC; the worker now logs a
warning once per server and zone text naming the text and saying run times use UTC.
…y read the table, and collector writes bind the catalog's spelling (#4938)
…the slot, and an on-load seed waits for a successful run (#4938)
…me, not the whole schedule file (#4938)

A hand edit such as "run_at": 120 threw while the schedule file was read, so Lite restored the backup or the defaults and every other edit in the file was lost. The property now has a converter: a string reads as before, and any other token reads as its own JSON text, which the run-time check refuses like "25:00" (same warning), and the collector runs on its interval.
… time is logged as a warning (#4938)

MainWindow built ScheduleManager with no logger, so the warning for a run_at that is not HH:MM, or a time on an hourly collector, was never written and the run time was dropped silently. It now takes an AppLoggerAdapter like the other services built beside it.
…_collection_health both read a run older than the interval plus a day as never run (#4938)

For a collector on an interval of two days or more, get_collection_health used the health row's newest run with no floor while the start-up read kept a run only from the longest daily interval plus a day back, so the two could name different next runs. ScheduleManager.LastRunFloor is the collector's own interval plus a day; the tool applies it to the health row's run and the start-up read applies it to each collector it seeds (not just to the longest on the server).
…e say it uses the fleet-wide run time (#4938)

With "Use default schedule" ticked the grid shows the fleet's schedule, so the selected row's Run at cell holds the fleet's own time, and the line described that as a time the server had of its own. The line now reads the cell as "Use default" for a server on the default schedule, which takes the fleet-wide time and says so. Display only: Save is unchanged.
…ides and the run times it reset apart (#4938)

With only run-time rows to reset the status read "Reset 2 per-server schedule override(s)", because the call returned one number for both tables. It now returns the schedule overrides and the run times counted apart, and the status names each in the singular or plural: "Reset 2 per-server run times", or "Reset 1 per-server schedule override and 2 per-server run times".
…e delete what the editor never showed (#4938)

When the run-time read failed, the editor held no run times and its warning was overwritten by the next status text. A server whose only override is a run-time row then opened on "Use default schedule", and a plain Save cleared its run times. No successful read now means no write: after a failed run-time read the Save sends no run-time change at all (no upsert, no delete, no clear) and is refused, saying why, when it would have to write a run time the user asked for; after a failed schedule read the Save is refused, since it replaces the scope's rows. Both warnings stay in front of every later status text until a reload of that read works.
…he user can do (#4938)

The text told the user to leave Reset to Defaults out, which cannot be undone once it is armed. It now names what cannot be saved (a typed time, or the Reset that clears them) and says to reopen the window to read the run times and try again.
@erikdarlingdata erikdarlingdata changed the title Darling and Lite: a daily collector runs at a time of day you set, in every surface, and a schedule Reset clears it (part of #4938) Darling and Lite: a daily collector runs at a time of day you set, in every surface, and a schedule Reset clears it (#4938) Oct 3, 2026
@erikdarlingdata
erikdarlingdata marked this pull request as ready for review October 3, 2026 17:53
@erikdarlingdata
erikdarlingdata merged commit 1cf7dad into dev Oct 3, 2026
22 of 24 checks passed
@erikdarlingdata
erikdarlingdata deleted the feature/4938-run-time-surfaces branch October 3, 2026 18:06
erikdarlingdata added a commit that referenced this pull request Oct 3, 2026
…ing reaches the service log, and provision-roles.sql lists store version 160 (#5039)

Three follow-ups to #5018's run times, from its post-merge review.

The docs' sentence on a run held by the fleet's daily-run cap now says it starts when a place under the cap frees, which can be after the grace hour. "Slot" is the run time plus the per-server spread on the same page, so "when a slot frees" read as waiting for the next day.

When the MCP role cannot read config.config_collector_run_times (SQLSTATE 42501), get_collection_health still answers with no run times, and its one warning now goes to the tool's logger, which reaches the service log. It went to System.Diagnostics.Trace before, and the service wires no Trace listener, so the warning reached nothing. Trace stays only as the fallback when no logger is passed. The same Trace pattern elsewhere is named in the PR, not changed.

provision-roles.sql's header now lists config_collector_run_times as store version 160 among the single-table grants to re-run after an upgrade.

Refs #4938
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