Extract the App Insights source, watermark and persistence ports; fix the culture-formatted KQL window (#374, #398) - #399
Open
sambetts wants to merge 3 commits into
Open
Conversation
…ce ports (#374 part 2) Finishes #374. Part 1 (PR #390) extracted AppInsightsImportWindow; this puts the importer's remaining I/O behind ports so the orchestration itself is testable. New in WebJob.AppInsightsImporter.Engine/Interfaces.cs: IAppInsightsSourceLoader - a day of telemetry from the App Insights REST API IHitWatermarkStore - the newest stored hit_timestamp ISiteFilterLoader - the org-URL whitelist IImportDbMaintenance - startup schema maintenance (also named in #369) IAppInsightsDayPersistenceManager - saving one day's page-views and custom events SQL adapters (Sql/SqlAppInsightsAdapters.cs) wrap the queries that previously sat inline in AppInsightsImporter, unchanged, and all borrow the caller's context rather than creating one - the whole run keeps using the single context it always has. AppInsightsAPIClient now implements IAppInsightsSourceLoader (thin GetPageViewsAsync / GetCustomEventsAsync aliases over the existing method names, so no call site changes) and gained a constructor overload taking an HttpMessageHandler, which is what makes the retry, back-off and transient-classification logic testable against a stub. AppInsightsImporter takes the five ports plus IAnalyticsDbContextFactory, all optional. When every database-backed port is supplied it runs without creating a context at all; otherwise it opens one exactly as before and builds SQL adapters over it. The day loop moved into its own method so it can be driven with either the injected source or the production API client. Every log line and its ordering is unchanged. Separately, and clearly labelled: FIXES A REAL BUG (#398). GetWhereString formatted the KQL time window with the current culture, which selects the CALENDAR - the same day renders as 2569-05-30 under th-TH and 1447-12-13 under ar-SA. App Insights then returns an empty result set rather than an error, so on such a host the importer silently imports nothing forever. Now formatted with InvariantCulture, with a regression test across th-TH, ar-SA and en-US. Tests: 12 new (7 AppInsightsApiClientTests against a stub HttpMessageHandler, 5 AppInsightsImporterOrchestrationTests), zero HTTP and zero SQL. New in-memory fakes in Tests.UnitTests/FakeLoaderClasses/FakeAppInsightsPorts.cs. AppInsightsImportWindowTests' class docstring is updated: it documented that no test would catch swapping _clock.UtcNow for DateTime.Now inside ImportAndSave. That gap is now closed by AppInsightsImporter_WindowIsDrivenByTheInjectedClock_NotTheWallClock. Area run: 107 of 108 passing, the one failure being the pre-existing CommentsCognitiveTests DNS failure against the placeholder local cognitive endpoint. Addresses #374 Addresses #398 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…e section finished The reviewer found an actual behavioural regression, not just a bad claim. Extracting the day loop into ImportDaysAndSave turned the download catch's Release-only 'return' from 'return out of ImportAndSave' into 'return out of the inner method'. Control then resumed in ImportAndSaveWith and ran jobTimer.TrackFinishedEventAndStopTimer, so a cycle that failed to download anything would have emitted FinishedSectionImport - a false success to liveness monitoring. Invisible in DEBUG, which rethrows. ImportDaysAndSave now returns false on that path and the caller skips the completion event, restoring the original behaviour. Guarded in BOTH build configurations by AppInsightsImporter_FatalDownloadFailure_DoesNotReportTheSectionAsFinished (DEBUG asserts the throw, Release asserts the return; both assert the event is absent), plus AppInsightsImporter_SuccessfulRun_DoesReportTheSectionAsFinished so the fix cannot be 'achieved' by never emitting the event at all. Also from the same review: * The retry tests inferred back-off from elapsed wall-clock time, which measures scheduling, GC and deserialisation too - flaky under CI load, and able to pass after a no-back-off regression. AppInsightsAPIClient now takes an internal RetryDelay function (defaulting to Task.Delay) and the tests assert the delays actually REQUESTED: 1s, 2s, 4s for exponential back-off, and 7s twice when the server sends Retry-After: 7. No test in this class now touches the wall clock. * Every orchestration test passed daysBeforeOverride: null, and ResolveOverrideStartUtc ignores its clock argument in that case - so the first _clock.UtcNow read in ImportAndSave was not actually guarded. Added AppInsightsImporter_DaysBeforeOverride_IsAlsoResolvedFromTheInjectedClock. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ng way, CI claim wrong Three findings from the second review round on #399. * The back-off cap test drove the retry loop, which stops at MaxRetries = 5 - so delays only ever reach 16 seconds and the 60-second cap was unreachable. Deleting Math.Min would have left it green. Extracted the calculation as AppInsightsAPIClient.BackoffSecondsFor(attempt) (used by both call sites) and asserted it directly, including attempt 6 where 2^6 = 64 first exceeds the cap. * FakeAppInsightsSourceLoader threw synchronously on a failing day. A real HTTP failure arrives as a FAULTED TASK observed at 'await Task.WhenAll(...)', so the regression test was exercising the wrong shape - a fix that only handled synchronous throws would have passed. Now returns Task.FromException<T>. * I claimed CI covers both build configurations. It does not: ci.yml, pr.yml and tests.yml all set 'configuration: [Release]' with Debug commented out. So CI runs the #else arm - the one guarding the actual regression - and the DEBUG arm only runs in a local Debug build. Comment corrected here, and the same false claim corrected in #396's ActivitySaveConcurrencyPolicyTests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
sambetts
added a commit
that referenced
this pull request
Sep 2, 2026
A reviewer on the sibling PR #399 checked the workflows: ci.yml, pr.yml and tests.yml all set 'configuration: [Release]' with the Debug matrix entry commented out. So the comment here claiming 'CI runs the suite in both Debug and Release, so between them this covers both' was false - the DEBUG case is covered by local Debug runs only. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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
Finishes #374. Part 1 (PR #390) extracted the pure window rule; this puts the importer's remaining I/O behind ports, so the orchestration itself — the day loop, the window it derives, the per-day failure isolation — runs in a unit test with zero HTTP and zero SQL Server.
It also fixes a real, silent, production bug found while writing the culture test the task called for: #398. That change is deliberately separate from the extraction and is called out below.
Ports
WebJob.AppInsightsImporter.Engine/Interfaces.cs:IAppInsightsSourceLoaderai.GetPageViewsFromAppInsights/GetCustomEventsFromAppInsightsIHitWatermarkStoredb.hits.OrderByDescending(h => h.hit_timestamp).Take(1)ISiteFilterLoaderSiteFilterLoader.Load(db)IImportDbMaintenanceImportDbHacks.CleanDuplicateHitsAndCreateIX_PageRequestID(db)IAppInsightsDayPersistenceManagerpageViewsResult.SaveToSQL(...)/events.SaveAllEventTypesToSql(...)Sql/SqlAppInsightsAdapters.csholds the four SQL adapters. Each wraps the original query or call verbatim and borrows the caller'sAnalyticsEntitiesContextrather than creating one, so the whole run keeps using the single context it has always used — creating a context per day would change both the change-tracker lifetime and the connection churn.IImportDbMaintenanceis also named in #369; it is defined once, here. #369 part 2b will use this definition rather than adding a second.IAppInsightsDayPersistenceManageris deliberately coarse — one method per thing the day loop saves. #369 decomposes the SQL side further (page-views, searches, clicks, page-updates, hit-updates); those become collaborators of the SQL adapter, not changes to this port.AppInsightsAPIClientIAppInsightsSourceLoadervia thinGetPageViewsAsync/GetCustomEventsAsyncaliases over the existing method names, so no existing call site or test changes.HttpMessageHandler. That is what makes the retry, back-off, transient classification and token-refresh logic testable. The handler is passed withdisposeHandler: falseso a caller-owned stub is not disposed by the client; the production constructor is unchanged in behaviour (new HttpClient { Timeout = 10 min }).AppInsightsImporterTakes the five ports plus
IAnalyticsDbContextFactory(#368), all optional trailing parameters — no call site breaks. When every database-backed port is supplied it runs without creating a context at all; otherwise it opens one exactly as before and builds SQL adapters over whichever ports were not supplied. The day loop moved into its own method so it can be driven by either the injected source or the production API client.Every log message, its wording and its ordering is unchanged. Two shape-only differences worth a reviewer's eye:
newestHit != nullbecamenewestHitTimestamp.HasValue, and the log interpolatesnewestHitTimestamp.Valuewhere it used to interpolatenewestHit.hit_timestamp. Same rendered text (hit_timestampis a non-nullableDateTimeon the entity, andNullable<DateTime>.ToString()on a value-bearing instance is identical to the underlying value's).jobTimer.TrackFinishedEventAndStopTimerrather than just before it, because theusingmoved out one level. It is anHttpClientwith no outstanding requests at that point.Separate change: fixes #398 — the KQL window was culture-formatted
GetWhereStringbuilt the per-day KQL window with a culture-sensitiveToString("yyyy-MM-dd HH:mm:ss"). The current culture selects the calendar, not just the separators:new DateTime(2026, 5, 30)renders asen-US/ invariant2026-05-30 00:00:00th-TH(Thai Buddhist)2569-05-30 00:00:00ar-SA(Umm al-Qura)1447-12-13 00:00:00(verified on this machine, not assumed). KQL
todatetime()only understands Gregorian ISO dates, so on such a host the importer asks App Insights for a window centuries away, gets an empty result set rather than an error, logs0 page-views, 0 eventsfor every day, and completes "successfully" — forever, with nothing in the logs explaining why the tenant has no hits data.Fixed by formatting with
InvariantCulture, withAppInsightsClient_BuildsKqlWindow_ForRequestedDayInUtc_UnderAnyCulturepinning it acrossth-TH,ar-SAanden-US.Note the window rule (
AppInsightsImportWindow) is not affected — .NET date arithmetic is tick-based and Gregorian, so culture is a no-op there. This is exactly the distinction PR #390 drew: a culture test is theatre where nothing formats, and meaningful where something does. Here something does.Found by the review loop: a real regression, now fixed
The first review round caught an actual behavioural defect in this PR — the first the loop has found across five PRs, so it is worth calling out.
Extracting the day loop into
ImportDaysAndSavechanged what the download catch''s Release-onlyreturndid. It used to return out ofImportAndSaveentirely, sojobTimer.TrackFinishedEventAndStopTimernever ran; after the extraction it returned only out of the inner method, and control resumed inImportAndSaveWithand emittedFinishedSectionImport— reporting a successful section import for a cycle that downloaded nothing. Invisible in DEBUG, which rethrows.ImportDaysAndSavenow returnsfalseon that path and the caller skips the completion event. Guarded in both build configurations byAppInsightsImporter_FatalDownloadFailure_DoesNotReportTheSectionAsFinished(DEBUG asserts the throw, Release asserts the return; both assert the event is absent), paired withAppInsightsImporter_SuccessfulRun_DoesReportTheSectionAsFinishedso the fix cannot be "achieved" by never emitting the event at all.Two test-quality findings from the same round were also applied:
AppInsightsAPIClientnow has an internalRetryDelayfunction (defaulting toTask.Delay) and the tests assert the delays actually requested: 1s / 2s / 4s for exponential back-off, and 7s twice when the server sendsRetry-After: 7.daysBeforeOverride: null, andResolveOverrideStartUtcignores its clock argument in that case, so the first_clock.UtcNowread inImportAndSavewas unguarded. AddedAppInsightsImporter_DaysBeforeOverride_IsAlsoResolvedFromTheInjectedClock.Testing
New: 16 tests, zero HTTP, zero SQL Server, zero wall clock.
AppInsightsApiClientTests(8) — against a stubHttpMessageHandler:BackoffSecondsFor(attempt), because the retry loop stops atMaxRetries(16s) and can never reach the 60s cap, so a loop-driven test would stay green ifMath.Minwere deleted;Retry-After: 7is preferred over exponential back-off — asserted as two 7s waits where the exponential path would have asked for 1s then 2s;MaxRetries + 1attempts and the failure then surfaces, so an App Insights outage cannot hang the web-job;Authorizationheader;AppInsightsImporterOrchestrationTests(8):AppInsightsImportWindowTestsdocumented ("nothing invokesImportAndSave, so replacing those reads withDateTime.Nowwould leave every test green"); that docstring is updated in this PR;Pre-existing tests unchanged and passing, including
AppInsightsKqlGetWhereStringTests,AppInsightsImportWindowTests,AppInsightsAuthTests,AppInsightsImportTests,HitImportFanoutTestsandDuplicateUrlTests. Area run: 107 passed / 1 failed of 108 — the failure is the pre-existingCommentsCognitiveTests, which cannot resolve the placeholder cognitive endpoint locally and passes in CI. Full solution builds (Debug).Reviewer hotspots
ImportAndSave— is the "all database ports supplied" fast path genuinely equivalent to the adapter path, and does the context still live exactly as long as it did?newestHitTimestamp.Valueinterpolation and the movedusingnoted above.disposeHandler: false— correct for a caller-owned stub, but confirm the production path (which passesnulland uses the plainHttpClientconstructor) still owns and disposes its own handler.#if DEBUGinAppInsightsImporter_FatalDownloadFailure_DoesNotReportTheSectionAsFinished— it is there because the production path it guards is itself#if DEBUG-forked, and CI runs the suite in both configurations. Is there a cleaner way to cover both, short of removing the fork?Addresses #374
Addresses #398