Repository navigation
After a job-history identity reset, the collector re-reads once instead of on every run (#4487) - #4496
Merged
Conversation
…all-time max After a sysjobhistory identity reseed, the target's own MAX(instance_id) drops below what the store remembers, and the store's all-time MAX never stops being the higher, pre-reseed number. Darling's and Lite's watermark reads and Darling's in-memory watermark cache all kept that all-time max, so the collector's bounded 24h regressed-arm re-read ran every cycle instead of once. Fixes #4487. Refs #3885.
…ance_id After a failover away and back (or a reverted restore) to an epoch this server already stored rows under, the newest-batch watermark fix alone was not enough: the incremental read still had no time bound, so it could re-collect every already-stored row from the returning higher epoch. The steady-state arm now also requires run_datetime >= a host-supplied floor (the newest stored run_datetime minus 7 days), composed the same sargable way as the existing archival-emptied window. The cost: a job step running longer than 7 days is missed by this bound, since sysjobhistory writes a step row only when the step ends.
…ct's real batch ordering (#4487) The equivalence pin planted a replay batch whose instance_id (50) was lower than the prior batch's (102), which the #4487 newest-batch numeric-watermark scoping correctly treats as an identity regression and no longer merges into the cache the way a plain MAX did before. No real collector arm can produce a newest batch with a lower max than an earlier one (the steady arm reads only ids above the current watermark; the regressed and archival-emptied arms re-read a window bounded by the target's own current max; the first run reads everything), so this shape was never reachable in the product - only in this test's own data. The replay batch now keeps its older run_datetime (proving the timestamp watermark still does not move backwards) while raising its instance_id above the prior batch's, matching how a real batch would look. The regressed-batch shape (a genuinely lower newest-batch max) already has its own coverage in the pure ServerWatermarkCache tests and the runner's invalidate-on-regression wiring.
…Watermark Moves the regressed-vs-honest branch that runs after a batch write (invalidate the cache on an identity regression, else advance it) out of RunCoreAsync's plain write path and into its own CommitServerWatermark<TRow> method, called from the same spot under the same guard. Behaviour-preserving; the #4487 comment moved with it. Adds two live pins to ServerWatermarkCacheRunnerLiveTests: a regressed batch invalidates the cache so the next resolve re-seeds from the store and reads it exactly once, and an honest batch still advances the cache with zero store reads.
…oping and failback bound (#4487)
A failover and failback re-reads up to 7 days of a returning epoch's job_history rows on purpose (long-running steps, clock skew). Until now that honest re-read landed as duplicates: the numeric watermark alone cannot see a returning epoch's own already-stored rows. Both hosts now read the batch's own natural key (server, instance_id, job_id, step_id, run_datetime) back from the store once before the write and drop any row already stored under either epoch. instance_id alone is ambiguous across an epoch that returns, so the key is the full tuple. The 7-day lookback itself is unchanged. Darling: DarlingCollectorRunner.WriteBatchAsync reads the keys with one statement bounded on collection_time (chunk exclusion), before the #3099 retry loop, so a retry never re-reads. Lite: WriteBatch does the same keyed read against DuckDB before the appender opens. The contract (INaturalKeyDedupedCollector) is opt-in; only JobHistoryCollector implements it, so no other collector's write path changes.
Source-scan pins (macOS) prove the numeric watermark read is scoped to the newest batch and the pre-insert dedupe runs before the appender opens, both RED against the pre-fix text on dev. Real-DuckDB facts prove the newest-batch scope and the dedupe's effect on a real batch write; they build but cannot run on macOS.
The in-memory natural-key dedupe compared DateTimes exactly, so a batch row's RunDateTime (100 ns ticks on Windows, whole microseconds elsewhere) never matched a stored key read back from PostgreSQL or DuckDB (both microsecond precision), and the dedupe dropped nothing. JobHistoryCollector.GetNaturalKey and both stored-key reads (Darling and Lite) now truncate through one ToMicroseconds helper before the comparison.
erikdarlingdata
marked this pull request as ready for review
September 27, 2026 21:22
This was referenced Sep 27, 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.
Fixes #4487.
Refs #3885.
Why
JobHistoryCollector's identity guard (#3885) already detects when a monitored server's SQL Agent job history has been reset — its ownMAX(instance_id)inmsdb.dbo.sysjobhistoryfalls below the id the monitoring store last saw — and falls back to a bounded 24-hour re-read byrun_datetimeinstead of matching nothing forever. Its doc comment promised "one run of fallback per reseed", because the store's ownMAX(instance_id)for that server becomes the new, lower maximum on the very next run.That promise didn't hold, because the store-side watermark reads never stopped returning the all-time maximum id ever stored for a server:
GetLastCollectedInstanceIdAsync(bounded probe over a 6-hour window, falling back to an unboundedMAX) and Lite's equivalent DuckDB read both read the all-time max. The old epoch's rows are retained for a year, so the all-time max stays at the pre-reset number forever, and the guard's regressed arm ran on every cycle, not once.ServerWatermarkCache). ItsAdvancerule keeps the greater of the cached and the new batch's value — correct for ordinary incremental progress, but it means a regressed batch's new, lower ids can never pull the cache back down, so once a reseed happens while the service is running, the cache would keep taking the regressed arm even after the store itself had already recovered.What causes the reseed is not something the collected rows can tell us — a
sysjobhistorypurge-and-reseed, anmsdbrestore, or the monitored endpoint moving to a different Availability Group replica (each replica has its ownmsdband its own identity sequence) would all look the same from here.sp_purge_jobhistoryon its own does not do this: it deletes the oldest rows and does not lowerMAX(instance_id).Measured before cleanup: one production store held 8,970,159 extra copies of 895,439 job runs across 41 servers (up to ~13 copies of a single run); a second held 13,492 extra copies of 120 runs across 10 servers (one run had 245 copies); a third store had none. The stored duplicates on both affected stores have already been removed, separately from this fix. This PR stops new ones from accumulating; it does not touch any already-stored row.
What changes
DarlingCollectorRunner.BuildServerWatermarkInstanceIdSql,GetLastCollectedInstanceIdAsync): both the bounded and unbounded forms now scope theMAX(instance_id)to rows from the server's newest collected batch (an innerMAX(collection_time)subquery), not an all-time max. The run right after a reseed stores the new epoch's ids, so the very next run's newest batch is that epoch's own max, and the guard's regressed arm takes over for exactly one run, as documented.DarlingCollectorRunner.CommitServerWatermark, extracted unchanged from the run path so it can be tested without a SQL Server): a batch that the collector flags as an identity regression (job_history_identity_regressions= 1, already recorded viacontext.Measure) now invalidates the cached entry instead of merging it withAdvance. The next run re-seeds from the store, which by then holds the new epoch's rows and returns the honest, new max. Ordinary (non-regressed) batches are unaffected — they still advance the cache exactly as before.RemoteCollectorService.GetLastCollectedInstanceIdAsync): the same newest-batch scoping against DuckDB. Lite has no in-memory watermark cache, so there is nothing else to change there.instance_id > @last_instance_idread re-delivers rows that are already stored. Two parts close it:(server_id, instance_id, job_id, step_id, run_datetime).instance_idalone is ambiguous across epochs. A small opt-in contract,INaturalKeyDedupedCollector<TRow>inPerformanceMonitor.Collectors, is implemented only byJobHistoryCollector, so no other collector changes.DarlingCollectorRunner.WriteBatchAsyncreads the batch's own keys back fromjob_historyonce, before the COPY and outside its one-retry path, and writes only the rows not already stored. The read is anunnestof the batch's keys joined on those columns, withserver_id = $1 AND collection_time >= min(run_datetime) − 1 day. That bound is safe because a row can't be collected before it started, andrun_datetime(the target's local clock) is within ±14 h of the store's UTCcollection_time. It also lets TimescaleDB skip every older chunk.RemoteCollectorService.WriteBatchdoes the same DuckDB read before the appender opens. Lite's hot ∪ archive viewv_job_historyalready reads one copy per natural key, so this stops a duplicate reaching the hot table at all.JobHistoryCollector.ToMicrosecondstruncates the batch rows'run_datetimeand the stored keys read back, in Darling and in Lite. PostgreSQL and DuckDB timestamps keep microseconds, while a .NETDateTimecan carry 100 ns ticks, so without it a sub-microsecond source time would never match its stored copy. The failback and long-running live tests plant.AddTicks(7)times so this is exercised on every platform.run_datetime >= @min_run_datetime, where@min_run_datetimeis the host's already-read newest storedrun_datetimeminus 7 days. It uses the same sargable shape as the archival-emptied window (an integerrun_datepre-filter plus the exact decoded bound). It keeps a failback from re-reading the returning epoch's whole history, and the dedupe drops whatever inside the window is already stored.@min_run_datetimeis null and imposes nothing.sysjobhistorystamps a step with its START time but writes the row when it ends.run_datetime(a target whose clock was once wrong) would otherwise carry@min_run_datetimeinto the future, and starve collection until that date. The collector's SQL clamps it toDATEADD(DAY, -7, GETDATE())on the target's own clock.JobHistoryCollector's doc comment now describes the real mechanism end to end: the host must read the newest batch's max, Darling's cache must invalidate rather than merge on a regressed batch, and a failback is closed by the pre-insert natural-key dedupe, with the 7-day lookback kept for long-running steps. This replaces the previous doc's overclaim and its stated-but-unclosed gap.Test plan
In-process on macOS against a live PostgreSQL + TimescaleDB,
Darling.TestsRelease with-p:EnableWindowsTargeting=true.Live pins, new:
JobHistoryWatermarkEpochLiveTests: a server stores an old-epoch batch (ids 10,200,001–10,200,040) and then a newer new-epoch batch (9,545,001–9,545,030).GetLastCollectedInstanceIdAsyncreturns 9,545,030, both when the newest batch is outside the 6-hour bounded probe (the unbounded fallback) and when both batches are inside it (the bounded path).devat runtime: it returns 10,200,040, the all-time max.run_datetime2026-09-10), then a newer batch B (ids 1–12,run_datetimeup to 2026-09-21).run_datetime.@last_instance_id= 12 and@min_run_datetime= thatrun_datetime− 7 days.dev: the bound doesn't exist there.ServerWatermarkCacheRunnerLiveTests.RegressedBatch_InvalidatesTheCache_SoTheNextResolveReadsTheNewEpochFromTheStore: the cache seeded at 10,200,040; a new-epoch batch committed withjob_history_identity_regressions= 1.HonestBatch_AdvancesTheCache_WithZeroStoreReads: an ordinary batch still advances the cache with no store read.devboth fail at runtime, because the method they reach doesn't exist there.ServerWatermarkCacheRunnerLiveTests.Equivalence_JobHistory_CacheMatchesFreshStoreRead_…: its replay batch now carries a HIGHERinstance_idthan the batch before it (and an olderrun_datetime), which is the only order a real collector produces. Every arm's batch max is at least the target's current max, and a lower one means an identity reset, which the fact above covers. A comment in the test gives the reason.Live pins, the failback dedupe (
JobHistoryWatermarkEpochLiveTests, through the product's ownDarlingCollectorRunner.WriteBatchAsync):WriteBatchAsync_FailbackToAPriorEpoch_StoresZeroDuplicateNaturalKeys: store A1 (ids 500–520, 21 rows), then B (ids 1–12), then the failback batch = A1's 21 rows again + A2 (ids 521–530).devat runtime AND on this PR's earlier head (the watermark fix without the dedupe):Expected: 10, Actual: 31. Every re-delivered row was stored a second time. This ran as a standalone class that uses only members present on both.WriteBatchAsync_ALongRunningStep_IsStoredOnce_AndDroppedOnReplay: a step row with arun_datetime5 days old is stored once, and replaying the batch adds nothing.WriteBatchAsync_ASameKeyRowWithADifferentJobIdOrStepId_IsStored: the sameinstance_id+run_datetimewith a differentjob_id, or a differentstep_id, is stored. The key is the full tuple.JobHistoryNaturalKeyDedupeTests(pure, 8):JobHistoryCollectorimplements the contract; the key columns and field order;DropAlreadyStoreddrops exact tuples only;Lite:
LiteJobHistorySourceTests(Darling.Tests, runs everywhere): Lite'sGetLastCollectedInstanceIdAsyncscopes to the newest batch (collection_time = (SELECT MAX(collection_time) …)), andWriteBatchcalls the dedupe beforeCreateAppender. Both RED ondevat runtime.JobHistoryDedupeAndWatermarkLiteTests(Lite.Tests, real DuckDB; runs on Windows CI, can't run on macOS):Mutations, each RED and then reverted:
MAX→ all threeJobHistoryWatermarkEpochLiveTestsRED.Expected 9545030, Actual 10200040.Expected 10, Actual 31.Pure pins:
JobHistoryIdentityEpochTests9/9: the guard's SQL shape with the new@min_run_datetime; the bound derived as the newest storedrun_datetime− 7 days, and null with nothing stored; the future-date cap (CASE WHEN … DATEADD(DAY, -7, GETDATE())in the SQL, the raw value in the parameter).ServerWatermarkReadFloorTests5/5,ServerWatermarkCacheTests11/11,ServerWatermarkDispatchGateTests7/7,DocCommentHygieneTests77/77.Totals:
JobHistoryWatermarkEpochLiveTests7/7,JobHistoryNaturalKeyDedupeTests8/8,LiteJobHistorySourceTests2/2.ServerWatermarkCacheRunnerLiveTests,JobHistoryIdentityEpochTests,ServerWatermarkCacheTests,DarlingCollectorRunnerTestsandDocCommentHygieneTests: 113, 0 failed (3 environment-gated skips).Darling.TestsandLite.Testsbuild with 0 warnings. The Lite.Tests facts, andJobHistoryCollectorDefinitionTests(the steady arm's@last_instance_idby name, the first-run and archival arms unchanged), run on Windows CI.CHANGELOG
SECTION: Fixed
ENTRY:
REF:
[After a job-history identity reset, the collector re-reads once instead of on every run (#4487) #4496]: After a job-history identity reset, the collector re-reads once instead of on every run (#4487) #4496