Repository navigation
Lite's Job History reads only the selected time range, and tab loads read off the UI thread (#5457) - #5553
Merged
Conversation
… not after it (#5457) v_job_history dedups the hot table and the archive on (server_id, instance_id). DuckDB moves a filter below that window only when the filter reads PARTITION BY columns, so each Job History read sorted a server's whole archive, message text included, before its run_datetime filter could drop a row. run_datetime joins the key: every copy of a run carries the same value, so the same copies collapse, and the filter now reaches the table and parquet scans.
…d a run archived and live still collapses (#5457)
…iewer calls that awaited it on the dispatcher move into Task.Run, and a census holds Lite/Controls to it (#5457)
…census covers the main window (#5457)
erikdarlingdata
marked this pull request as ready for review
October 8, 2026 10:48
… memory, applied at startup (#5457) The connection string, the COPY raise and restore and the trim restore follow the setting, and a DuckDB out-of-memory error shown in a tab names the setting and its current value.
…ix/5457-mem-setting
… the Database State Overrides editor, the history and drill-down windows, and the census holds Lite/Windows to it (#5457)
…ix/5457-windows-offui
…res, limit / 256 MB) (#5457)
…ix/5457-mem-setting
…ory line (#5457) AppLogger appends the memory-limit hint once to a WARN or ERROR line that holds DuckDB's exact out-of-memory text, so the catch blocks that only log the raw message name the setting too.
8 of 9 tasks
erikdarlingdata
added a commit
that referenced
this pull request
Oct 8, 2026
Adds the changelog entries for the 3.10.0 PRs that merged after the first splice: #5528, #5532, #5537, #5542, #5543, #5552 and #5553. Each full entry goes into docs/changelog/3.10.md and its title-only line into the CHANGELOG.md index. The archive census row for 3.10.0 moves from 257 to 265 entries. Changelog entries for the 3.10.0 PRs merged after the first splice.
erikdarlingdata
added a commit
that referenced
this pull request
Oct 9, 2026
…kDB memory limit Release checklist step 2b. README.md already covered everything up to #5491. This adds the three user-facing 3.10.0 changes merged after it: the one time range picker (#5570), the Excel-style text column filters (#5577) and Lite's DuckDB memory limit setting (#5553). Collector count, tab lists, edition table, platform notes, permissions and the deprecation callout needed no change.
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 #5457.
Why
A user with 30 servers and an 11 GB data folder saw Lite 3.9 hang on the first tab switch. The log showed the Job History read running out of DuckDB's 1 GB memory cap, and the store write lock timing out.
Two causes are fixed here.
v_job_historyremoves duplicate rows from the live table and the archive withQUALIFY ROW_NUMBER() OVER (PARTITION BY server_id, instance_id ...). DuckDB moves a filter below that window only when the filter reads nothing but the PARTITION BY columns.server_id = $2went below it.run_datetime >= $1did not. So each read loaded and sorted the server's whole job history from the archive, message text included, before the 4-hour filter dropped any rows. The tab makes this read twice per server, once for each of the 30 servers in turn.Plan, measured on DuckDB 1.5.5 (the version Lite ships):
FILTER (run_datetime >= ...)aboveWINDOW. The table and parquet scans carry onlyserver_id=2.server_id=2andrun_datetime>=..., so parquet row groups outside the window are skipped.What changes
Lite/Database/DuckDbInitializer.cs,ArchiveViewDedupKeys(the first commit on this branch): the job_history key is nowserver_id, instance_id, run_datetime. The same copies still collapse. The collector works outrun_datetimeon the server. It uses the msdb row's ownrun_dateandrun_time, which never change for aninstance_id. So every copy of a run has the same value. Rows with a NULLrun_datetimestill group together, because PARTITION BY treats NULLs as equal.Task.Run, as Job History and every ServerTab refresh already did:Lite/Controls/AvailabilityGroupsTab.xaml.csLoadAsync(GetAgTopologyAsync). This runs on the tab's load and refresh.Lite/Controls/FinOpsTab.xaml.csLoadDatabaseSizesAsync(GetLatestServerPropertiesAsync). This runs on the tab's load.Lite/Controls/FinOpsTab.xaml.csRunIndexAnalysis_Clickand the plan-navigation callback. Both are clicks. The selected server is read on the UI thread first and the read runs off it.Lite/Controls/ServerTab.Plans.csOpenPlanTab(plan viewer metadata). This is a click.Lite/MainWindow.xaml.csLoadServerTagsAsync. This runs on every Overview refresh, which makes it the most frequent of the five. Both tag reads now go in one hop.Lite/Windows(the windows, added after the tabs): every store read and write a window starts from a click or itsLoadedhandler now runs inTask.Run, with control values copied to locals first and results marshalled back by theawait, so exceptions reach the same catch and message as before.ManageTagsWindow(the two tag reads, the assign and unassign writes, create, rename, colour and delete),DatabaseStateOverridesWindow(expectations, collector run history, engine edition, reset and save),ProcedureHistoryWindow,QueryStatsHistoryWindowandQueryStoreHistoryWindow(history read, chart rows, cached plan, plan-analysis metadata),WaitDrillDownWindow(the three loads and the plan metadata) andCollectionLogWindow(the drill-down read). Not changed:FetchQueryPlanOnDemandAsyncand the otherFetch*OnDemandcalls (they go to the monitored SQL Server, not the store),ManageMuteRulesWindow(a JSON file, no store),SettingsWindow(another lane's file; it has no data-service call).Lite.Tests/TabLoadsOffUiThreadTests.cs(new): a census overLite/Controls/*.cs,Lite/Windows/*.csandLite/MainWindow*.cs. It fails if adataService.XAsync(call (orEngineEditionAsync(dataService, the one static helper that reads on the caller's thread) is not inside aTask.Run(argument list. The main window's six tag-write names stay exempt there only. A window underLite/Windowshas no exemption. Comments and string literals are blanked first so a parenthesis in them cannot unbalance the scan.Lite.Tests/ArchiveViewDedupTests.cs: two new tests (below).Measured (synthetic store, scratch folder only, DuckDB 1.5.5)
The store had 30 servers and job history only. It held 9.8 million rows and took 0.8 GB on disk: 3 monthly archive files with 2,048-row row groups, 8 days of live rows, and 6 hours of copies of live rows in the archive. It took 28 s to build. The read is the one-server, 4-hour Job History SQL (the
job_statsandbaseshape the tab runs). One read at a time, nothing else running.The first lane measured 11.1 s and 718 MB for the old key at 1 GB and 32 threads on the same store. My run measured 7.6 s and 727 MB. The memory agrees, and I do not know why the time differs. The old key needed 600 to 730 MB for one server on this 0.8 GB store, and its memory grows with the server's archive. I did not build a store as large as the reporter's, so I did not see a read go past 1 GB. The 3.2 GB store from the first lane was deleted unmeasured.
Row contents: the 4-hour read for one server returned 556 rows under both keys and at both settings. The row text, ordered over every column and hashed with md5, is
58d7017f83for all four runs. A second hash leaves outjob_history_id,collection_timeandmessage. Those columns depend on which copy of a run the dedup keeps. It is32839ff535for all four. Repeat runs gave the same values, so the hash is stable. The first lane's hash was not, because it sorted unordered Python objects.Both scratch stores are deleted.
storewas 765 MB, andstore4xwas already gone.What the first tab switch waits on
From the code. I did not reproduce the freeze, and no UI was run.
OpenConnectionAsynctakes the process-wideReaderWriterLockSlimread lock with an untimedEnterReadLock()before its first await. DuckDB.NET's async methods complete synchronously (DuckDbLockModelTests.DuckDbAsyncStillCompletesOnTheCallingThreadpins this). So a data call awaited from the UI thread waits on the lock there, then runs the whole query there.ReaderWriterLockSlimholds back every new reader. The reporter's "could not acquire the store write lock within 5s" line is that writer. The old Job History load ran 30 servers in turn at 4 to 11 s each. It held read locks for minutes. A parked writer queued behind it, and everything new queued behind the writer.Lite/Controlsalready read insideTask.Run, and the census now checks it. The calls that did not are the five above (the Overview tag refresh runs on every Overview refresh). So the most likely first-switch wait is the queue behind the long Job History reads. The UI-thread calls make it freeze the window instead of waiting in the background.Calls in
Lite/Controls,Lite/WindowsandLite/MainWindow*.csthat sat outside a lexicalTask.Runbefore this PR:AvailabilityGroupsTab.LoadAsyncFinOpsTab.LoadDatabaseSizesAsyncFinOpsTab.RunIndexAnalysis_Click, plan navigationServerTab.OpenPlanTabMainWindow.LoadServerTagsAsyncAlertsHistoryTab.ReadCollectedClocksAsync,ServerTab.SafeDiscontinuitiesAsyncTask.Run. The census allows these two by name and checks that every reference to each helper is inside aTask.Run.Lite/WindowsMainWindow.RemoveServerAsyncOpenWriteConnectionAsync, which waits for the write lock at mostWriteLockBudget(5 s) and then throws. The census lists the six method names.Why
Task.Runand not a change to the lock wait:ReaderWriterLockSlimis thread-affine: the thread that enters must exit. Moving only the wait off the UI thread insideOpenConnectionAsyncfails. The caller resumes on the UI thread, and the release there throwsSynchronizationLockException(seeLockReleaser, Lite has two conventions for locking a DuckDB write, and one of them is written down as the rule #2463).Task.Run(() => dataService.XAsync())covers every caller with five small edits, and keeps the enter and exit on one pool thread because DuckDB's async completes synchronously.Test plan
Lite.TestsandDarling.Testsbuild with 0 warnings.ArchiveViewDedupTests: Total 8, Failed 0. Two are new. The first: an archived and a live copy of one run with the same non-NULLrun_datetimecollapse to one row. The live copy wins, and NULLrun_datetimecopies still group. The second:EXPLAIN (FORMAT JSON)ofSELECT * FROM v_job_history WHERE run_datetime >= X AND server_id = Yhas no FILTER onrun_datetime, and both scans carry it. The staged rows straddle the cutoff on purpose. Otherwise DuckDB drops the filter or replaces the scan with an empty result, and there is nothing to pin.server_id, instance_id, the EXPLAIN pin fails ("a FILTER node still reads it above the dedup") and the collapse test still passes. Key restored.TabLoadsOffUiThreadTests: Total 3, Failed 0. They are the census, the helper check, and a scanner self-test. The self-test flags a UI-thread call and ignores one in a multi-lineTask.Runlambda, a comment, or a string.AvailabilityGroupsTab.xaml.cs:78 GetAgTopologyAsync. Call restored.JobHistoryGroupedStatsMatchWindowFunctionTests2/0,JobHistoryDataStartTests24/0,QueryWindowTruncationTests19/0,DataStartBannerSurfaceTests94/0,JobHistoryCollectorDefinitionTests8/0 (Total/Failed).DuckDbLock*3/0,AvailabilityGroups*6/0,FinOps*166/0,LiteServerTagsStoreTests5/0,MainWindowAccessKeyTests5/0,AlertReadFailureSurfaceTests2/0, and 12 more Lite.Tests classes that read the changed files (all 0 failed).TabLoadsOffUiThreadTestsTotal 3, Failed 0 (after the merge with the memory-setting commits too). Revert proof 3: withDeleteServerTagAsync(a write) inManageTagsWindowandEngineEditionAsync(_dataService, ...)inDatabaseStateOverridesWindowput back on the UI thread, the census failed and namedLite/Windows/ManageTagsWindow.xaml.cs:520 DeleteServerTagAsyncandLite/Windows/DatabaseStateOverridesWindow.xaml.cs:149 EngineEditionAsync. Both restored.*Census*58/0,DuckDbLock*3/0,-trait Stage=Guard -failSkips429/0, 0 build warnings.*Census*58/0.*Ratchet*: no class in Lite.Tests (Total 0).-trait Stage=Guard -failSkips: 429/0.*Census*456/0 (1 skipped),*Ratchet*15/0, and about 35 Darling.Tests classes that read the changed Lite files (two batches): all 0 failed.Not done
Lite/Windows: the store calls the first two commits left are moved and the census covers the folder.SettingsWindowbelongs to the memory-setting commits.The reporter reply is drafted (private) at
handoffs/h2/5457-reply-draft.md.CHANGELOG
SECTION: Fixed
ENTRY: - Lite's Job History tab reads only the selected time range, and tab loads no longer wait on the database from the window ([#5553]) - Every Job History read loaded and sorted each server's whole archived job history, message text included. Only then did it drop the runs outside the selected range. On a 30-server test store that cost 4 to 8 seconds and 600 to 730 MB per server. On a larger store it can run out of DuckDB's memory cap. The archive view now applies the time range first: 0.5 to 0.7 seconds, under 300 MB, same rows. The Availability Groups and FinOps tab loads, opening a plan viewer from a tab, the Overview's tag refresh, and the Manage Tags, Database State Overrides, query and procedure history, wait drill-down and collection log windows also awaited their database reads and writes on the window's own thread. There they can wait behind archival or a long read. They now read off that thread.
REF:
[#5553]: #5553
DuckDB memory setting
Erik's ruling 10-08: users could not change DuckDB's memory limit, so it is now a Lite setting.
What changed:
duckdb_memory_limit_gbin settings.json. Default 2 GB (dev's Lite: a read that includes query text can run out of memory at the 1 GB DuckDB limit #5381 value, not lowered). Range: a whole number of GB, 1 GB minimum, maximum 80% of physical memory rounded down, never below 1 GB. A missing, unparsable or out-of-range stored value falls back to 2 GB and logs one line. The Settings window rejects an out-of-range entry with a message that states the range; the rest of the page still saves, and the save dialog says the new limit applies after Lite restarts.DuckDbInitializer.MainConnectionMemoryLimitis now the setting (read once at startup, before the first connection).ConnectionString, the trim cycle's restore (it parses the connection string, so it follows) and ArchiveService's resting value all follow it. The COPY raise is the larger of 4 GB and the setting. Compaction's own 4 GB instance and the importer's plain connection are unchanged.Out of Memory,could not free up enough memory,memory_limit), including inner exceptions.AppLoggerappends the same hint, once, to any WARN or ERROR log line that holds DuckDB's exactOut of Memory Error:text (in the message, or anywhere in the exception chain), so the catch blocks that only log the raw message name the setting too. SQL Server memory errors and lines that already hold the hint are left alone. 8 new tests inAppLoggerDuckDbOutOfMemoryHintTests(11 cases) fail with the hook removed and pass with it.min(8, cores, limit / 256 MB), at least 1. At the 2 GB default that is 8 threads, as today; the 1 GB minimum runs 4. The trim cycle only moves memory_limit (to 64 MB and back) and the COPY raise only moves memory_limit (to the larger of 4 GB and the setting and back), so neither changes threads; a test pins both.preserve_insertion_orderstays on for the main connection (pinned); ParquetCompaction's own setting is untouched.Why the cap of 8 stays and 256 MB per thread: timed on the laptop's real Lite archive (copied to scratch; the live store file was locked by the running app, so the live part of the store was an empty fresh database and only the archive's parquet files were read; copy deleted afterwards). 32 cores, one process per setting, Job History over 20 weeks (17,812 rows) and the top-queries-by-CPU read over 90 days for the busiest server, first and second run each, peak is the process's peak working set (baseline about 150 MB):
4 and 8 threads are within noise on the Job History read and 8 is a little faster on the wide read; 16 and 32 are slower and use more memory. So more threads than 8 do not help, the cap stays, and raising the memory setting above 2 GB does not raise threads. The 1 GB minimum keeping 4 threads is the memory-per-thread rule (256 MB is double DuckDB's documented 125 MB minimum). This store's archive is small (the job history parquet files total under 200 KB), so these reads never came near any limit; the numbers show thread scaling, not out-of-memory behavior.
Tests (Lite.Tests, targeted):
DuckDbMemoryLimitSettingTests53 total, 0 failed (default, range, fallback table,LoadAtStartup, connection string and a real connection carry the setting, COPY raise below and above 4 GB and the restore, trim restore, thread formula, threads after trim and COPY raise, preserve_insertion_order, out-of-memory message names the setting and value including inner exceptions, other errors untouched, save report objection, Settings window source pin). Also run:MainConnectionLimitsTests4/0,*Archive*255/0,*DuckDb*99/0,*Census*58/0,*Settings*82/0,*Compaction*75/0 (before the thread change),-trait Stage=Guard -failSkips429/0. No*Ratchet*class exists in Lite.Tests (0 matched). 0 build warnings. I did not run the full suite.Not done: the Settings window box and the dialogs are checked by source pins and the build, not by running the WPF window. The out-of-memory sentence is added at the DuckDB-reading surfaces listed above, not at every one of the ~85 catch blocks in Lite's controls that only log; the log line of those is are covered by the AppLogger change above.
CHANGELOG (this part)
SECTION: Added
ENTRY: - Lite has a DuckDB memory limit setting ([#5553]) - Settings, Dashboard Defaults, "DuckDB memory limit" sets the main DuckDB connection's memory limit (default 2 GB, range 1 GB to 80% of memory, applied after a restart); the thread count follows it as the smaller of 8, the core count and the limit divided by 256 MB, and out-of-memory errors name the setting and its current value.