Status: All three phases complete. Every finding from the original plan (#1-#11) is now
found/fixed. Phase 1 done: #4 (commit a24c139), #3 (commit 2acdef0), #9 (commit 6d0acc5),
#10 (mailto → clipboard + Explorer-reveal, plus an added error-log relocation to
%LocalAppData%\Timelapse\ErrorLogs\ with migration — implemented, currently uncommitted).
#11 — done: ResetIDsAndVacuum used to permanently switch the database to WAL journal
mode, which SQLite's own docs say is unreliable over network shares — directly relevant to the
original bug's network-share deployment, and plausibly a bigger contributor to real-world
"database is locked" reports than lock-contention timing. Fixed (stopped setting WAL, added an
on-open migration for already-affected databases) and verified empirically — see finding #11
below for full detail, including a provenance check confirming this pragma predates any
Claude-assisted work on this project (traces to the repo's first commit, 2025-12-11).
#1 — done, accepted at code-review-level confidence (decided 2026-07-14). All four read
primitives in SQLiteWrapper.cs now retry on Busy/Locked with the short budget. Three
separate live-contention test attempts never managed to reproduce a real SQLITE_BUSY/LOCKED
exception, so the new catch branches were never empirically observed to fire — but given #11
was plausibly the dominant real cause of the original read failures and is now fixed
independently, the decision was to accept the code as structurally sound (minimal mirror of
already-proven write-path logic) rather than keep chasing a repro. See finding #1 below for the
full reasoning and the fallback plan if read-lock errors resurface later.
Phase 3 (#5/#6) — done and manually verified end-to-end (2026-07-14):
UpdateSyncImageSetToDatabase and RepairClassificationCategoriesIfNeeded now show a new
Dialogs.TimelapseOperationRetryDialog (single "Retry" button, closing = proceed to fatal)
after their existing automatic retries are exhausted, capped at one manual retry, before
falling through to the unchanged fatal dialog. Live-tested against a real test project with an
OS-level exclusive file lock forcing genuine write failures — confirmed the Retry dialog
appears, one manual retry is attempted, and the existing fatal dialog correctly takes over when
that retry also fails. See Phase 3 below for the full design history, reasoning, and test
detail.
#12 (added beyond the original plan, 2026-07-14) — done: successful retries previously left
no trace in the log at all; now log via AppLog.Warning whenever a retry succeeds, across all
six retry loops. See finding #12 below.
Remaining open item: finding #10's manual test (mailto/clipboard/Explorer-reveal — implemented and build-verified, end-to-end confirmation in the running app still outstanding). This document is written to be self-contained so a fresh session (or a different person) can pick up the work without needing the original conversation.
- Repo:
D:\@Timelapse\Timelapse-2.5.0.7p1(git). Branchmaster. - Reference commit:
74be7247da166a70115ee6cb322f596d42c26e82— "Fix crash and UI freezes from SQLite write failures on network shares" (2026-07-11). Rungit show 74be7247da166a70115ee6cb322f596d42c26e82to see the full diff/message — it's the starting point for everything below. - That commit was triggered by a user report: the app crashed with SQLite
Busy/Locked("database is locked") when the.ddbfile lived on a network file-server share, insideRecognitionSelector.DropSessionTempTables(). - The user then supplied a fuller error log (
TimelapseErrorLog.txt, not stored in the repo) covering the same contention episode, 2026-07-07 → 2026-07-08, all before the fix commit landed (2026-07-11). That log revealed the crash the commit fixed was only one of four distinct failure points hit in that single episode. A 2026-07-13 log entry (after the fix) also surfaced an unrelated bug in the "mail error log" feature. - This document is the result of: (1) my own read of the log + diff, (2) two independent verification/audit agents given the same background and told to check my claims and hunt independently for more issues, (3) manual spot-checks of the most load-bearing new claims. Where a finding is agent-sourced, file:line citations were spot-checked against the current tree before inclusion.
| Time | Site | Symptom |
|---|---|---|
| 07-07 14:01:45, 07-07 14:29:06, 07-08 11:46:17 | DropSessionTempTables |
Unhandled exception → app shutdown |
| 07-07 14:02:38 | UpdateSyncImageSetToDatabase |
Fatal write-error dialog → shutdown |
| 07-07 14:15:27 | RepairClassificationCategoriesIfNeeded |
Fatal write-error dialog → shutdown |
| 07-07 14:28:39 | GetDataTableFromSelectAsync |
Silent read failure (empty result), 27s before failure #1 recurred |
Plus, unrelated: 2026-07-13 12:43:09 — ProcessExecution.TryProcessStart failed to launch a
mailto: link for the in-app "mail error log" feature.
Only failure #1 (DropSessionTempTables) was addressed, exactly matching the commit's
own stated scope.
src/Timelapse/Controls/RecognitionSelector.xaml.cs—DropSessionTempTables()now runs the drop inside a fire-and-forgetTask.Run(...)and on failure callsAppLog.Warning(...)instead ofGlobalReferences.MainWindow.OnUnhandledException(...). No longer fatal, no longer on the UI thread.- Core retry mechanism:
SQLiteWrapper.ExecuteNonQueryWithRollbackCore(SQLiteWrapper.cs, around line 898) gained an opt-inbusyTimeoutMsparameter. Verified arithmetic:int maxBusyAttempt = busyTimeoutMs > 0 ? 7 : 4;(line ~943), linear backoffdelayMs = (busyAttempt + 1) * 250.- Default (
busyTimeoutMs = 0): retriesbusyAttempt0..3 → 5 total attempts, delays 250+500+750+1000 = 2500 ms total. Matches commit message ("~2.5s"). - Extended (
busyTimeoutMs = ThrottleValues.BackgroundWriteExtendedBusyTimeoutMs = 3000): retriesbusyAttempt0..6 → 8 total attempts, delays sum to 250+500+...+1750 = 7000 ms, not the ~9s / 2000ms-max claimed in the commit message and in the code comment atSQLiteWrapper.cs~lines 938-942. This is a documentation mismatch, not a functional bug — see Recommendation 6 below.
- Default (
- A handful of confirmed-background-thread write call sites (bulk delete, ID-reset/vacuum,
checkin merge) were opted into the extended budget; a few previously-silent discards
(empty-table drops, Vacuum) now log via
AppLog.Warning;FileData/TemplateInfo/Templatetable-creation failures now show the fatal write-error dialog instead of degrading later into a confusing generic "SQL read error." - Marker add/delete (
TimelapseMarkingAndCounting.cs) now writes viaUpdateFileAsync(fire-and-forget) instead of blocking the UI thread synchronously on every click.
1. Read-path retry never covers Busy/Locked — systemic, and this is literally failure #4 — FIXED, accepted at code-review-level confidence
src/Timelapse/Database/SQLiteWrapper.cs:
GetDataTableFromSelect(~line 188) andGetDataTableFromSelectAsync(~line 231)GetDistinctValuesInColumn(~line 285)GetScalarFromSelectand everything built on it (ScalarGetScalarFromSelectAsInt,ScalarGetScalarFromSelectAsLong,ScalarBoolFromOneOrZero,ScalarGetMaxValueAsLong,ScalarGetFloatValue) (~line 328)
All of these retry only on SQLiteErrorCode.CantOpen / IoErr (attempt < 3, 200ms
steps). None catch Busy/Locked. A locked SELECT falls straight into the generic
catch (Exception), fires OnReadError once (gated by an _errorFired flag), and returns an
empty/default result immediately — zero backoff. This is architecturally separate from
ExecuteNonQueryWithRollbackCore (the write path this commit fixed) and was completely
untouched. This is exactly what happened at 14:28:39 in the log, 27 seconds before
DropSessionTempTables crashed again — i.e., reads have less resilience than writes did
even before this commit, and the commit didn't change that asymmetry.
Fixed: added a Busy/Locked retry branch (short, UI-safe budget matching the write
path's default schedule) to all four read primitives, narrowly scoped alongside the existing
CantOpen/IoErr clause, preserving the _errorFired single-fire gate unchanged. Build
verified clean.
Verification status — accepted at code-review-level confidence, not empirically proven.
Three separate attempts to reproduce a real SQLITE_BUSY/LOCKED exception in a live-contention
test (same-process BEGIN IMMEDIATE, same-process with PRAGMA locking_mode=EXCLUSIVE, and a
genuinely separate OS process holding a confirmed lock) all failed — every read succeeded
near-instantly regardless of the lock, so the new catch branches were never observed to
actually fire. Decision (2026-07-14): accept this as done anyway, reasoning that finding #11
(WAL mode silently enabled on databases that ever ran ResetIDsAndVacuum, undocumented as
unreliable over network shares by SQLite itself) was plausibly the dominant real-world cause of
the original read failures, and is now fixed independently. The retry code here remains a
structurally sound, minimal-diff mirror of already-proven write-path logic — low residual risk
even without a positive contention test — but if read-lock errors ever resurface in a future
log after the #11 fix ships, revisit this verification gap first.
src/Timelapse/Database/SQLiteWrapper.cs, originally:
public bool PragmaGetQuickCheck()
{
try { ... }
catch
{
// this will be a System.Data.SQLite.SQLiteException
return false;
}
}Bare catch { return false; }, no distinction between "genuinely corrupt" and "transiently
locked," no logging at all. Callers (FileDatabase.cs:802, CommonDatabase.cs:151) treat
false as "the database file is likely corrupt" and abandon opening the file. This runs at
database-open time — precisely when a network-share lock is most likely to be held by another
user. Failure scenario: user opens a .ddb on a busy share, hits a transient lock, and
Timelapse tells them their database is corrupt instead of retrying or reporting the real
cause.
Fixed: added a Busy/Locked-only retry loop (same short budget as finding #1's read-path
fix — 5 attempts, 250ms steps, ~2.5s max), narrowly scoped so any other SQLiteException
(genuine corruption, format errors) falls straight through to the generic catch without
retrying. On retry exhaustion, logs via AppLog.Warning that the failure was contention, not
corruption; the generic catch (real corruption) logs as such. Contract unchanged — still
returns bool, callers untouched.
Verified empirically (deterministic, unlike finding #1's lock-contention test): a valid
scratch database returned true in 16ms; a genuinely corrupt file (plain garbage text, not a
SQLite file at all) returned false in 22ms — confirming the exception filter correctly
distinguishes the two cases and does not delay a real corruption diagnosis with retries
(a mistakenly-broad filter would have shown ~1250ms+, not 22ms).
3. The CreateTable "tiering fix" was applied to only ~3 of ~15 call sites, including two immediately adjacent to a fixed one
Fixed: FileDatabase.cs:243 (FileData), CommonDatabase.cs:1683 (TemplateInfo),
CommonDatabase.cs:1847 (Template) — all now check the result and show the fatal write-error
dialog on failure.
Not fixed (bare Database.CreateTable(...), result discarded), verified in the same
method as the fixed FileData call:
FileDatabase.cs:263—ImageSettable (20 lines after the fixed FileData check)FileDatabase.cs:309—Markerstable (same method)FileDatabase.cs:2004— a generic table-creation pathCommonDatabase.cs:1025— MetadataTemplateCommonDatabase.cs:1039— MetadataInfoRecognitionDatabases.cs:62,70,79,103,117,133— Info, DetectionCategories, ClassificationCategories, Detections, Classifications, DetectionsVideo (all six recognition tables)
Failure scenario: FileData's CREATE TABLE succeeds, but the very next statement
(ImageSet, line 263) hits transient contention — the app proceeds with a half-created schema
and fails later with exactly the confusing generic error the commit's message says it fixed.
Recommendation: apply the same check-and-dialog pattern to all CreateTable call sites,
not just the three that happened to be in the direct path of the original bug report.
src/Timelapse/Database/SQLiteWrapper.cs:
Update(string tableName, List<ColumnTuplesWithWhere> updateQueryList)— line 522 — nobusyTimeoutMsparameterUpdate(string tableName, ColumnTuplesWithWhere columnsToUpdate)— line 553 — nobusyTimeoutMsparameterUpdate(string tableName, ColumnTuple columnToUpdate)— line 565 — nobusyTimeoutMsparameterUpdate(string tableName, string IDColumnName, List<long> listOfIDs, string columnName, string value, int busyTimeoutMs = 0)— line 595 — only this one got the opt-in parameter
This means UpdateSyncImageSetToDatabase (finding #5 below) and anything else using the
first three overloads has no way to request the longer budget even if someone wanted to fix
it today without also touching SQLiteWrapper.cs.
Recommendation: add the same int busyTimeoutMs = 0 parameter (threaded down to
ExecuteNonQueryWithRollback) to all four Update overloads for consistency, before using
it to fix finding #5.
src/Timelapse/Database/FileDatabaseUpdate.cs:307-316. Calls
Database.Update(DBTables.ImageSet, ...) (the line-522 overload — see #4, can't opt into
extended timeout even if desired) with the default short budget, and still calls
Dialogs.TimelapseNeedsToShutDownDataWriteErrorDialog(...) (fatal) on failure.
Traced caller: TimelapseClosing.cs:116, inside CloseTimelapseAndSaveState /
CloseImageSet() — a plain synchronous call chain with no Task.Run, confirmed running
on the UI thread. Also called from TimelapseMenuSort.cs:155, TimelapseQuickPaste.cs:330,
TimelapseMenuEdit.cs:110,1175, TimelapseImageSetLoading.cs:413,
TimelapseCheckAndCorrectFolders.cs:46 — all likely UI-thread callers (menu/close/load
handlers), not yet individually traced.
This is failure #2 in the log, and it's one of the first things to hit lock contention in a session (53 seconds after the log starts) — not an edge case.
Recommendation: this is a UI-thread-synchronous call site, so per the commit's own
policy it should not simply get the long budget (that would trade the crash for a UI
freeze). Instead: keep the short budget, but downgrade the failure from a fatal
app-shutdown dialog to a non-fatal warning (matching the DropSessionTempTables treatment) —
losing a sort-term/search-term sync to the ImageSet row is not data loss of file data, it's
UI-state persistence. Needs a product judgment call: is losing image-set sync state
acceptable to swallow, or does it need to stay fatal? (See Open Questions.)
6. RepairClassificationCategoriesIfNeeded — untouched, still fatal, runs synchronously on the UI thread during database open
src/Timelapse/Database/FileDatabase.cs:709-728. Still
this.Database.ExecuteNonQueryWithRollback(query) with no busyTimeoutMs, still calls the
fatal dialog at line 725.
Traced call chain: RepairClassificationCategoriesIfNeeded ← OnExistingDatabaseOpenedAsync
(line 702) ← FileDatabase.CreateOrOpenAsync (line 189,
await ... .ConfigureAwait(true)) ← TryOpenTemplateAndBeginLoadFoldersAsync
(TimelapseImageSetLoading.cs:81) ← UI handlers (TimelapseMenuFile.cs:309,
TimelapseHandleArgumentsOnOpen.cs:95,120). No Task.Run wraps this segment — confirmed
UI-thread-synchronous, during database-open. This is failure #3 in the log.
Recommendation: same tension as #5 — this is a one-time repair/migration step touching classification labels, not file data. Consider whether failure here should be downgraded to non-fatal-with-warning (the repair can presumably be retried next time the database is opened) rather than blocking the user from opening their data at all. Needs the same product judgment call as #5.
7. Un-migrated background-thread bulk writes that could safely have opted into the extended budget but weren't touched — FIXED
All candidates were individually traced to confirm they genuinely run inside Task.Run (not
inferred from surrounding BusyCancelIndicator/dialog pattern alone) before opting anything in
— this surfaced a real fan-in hazard along the way (see below).
UpdateAdjustedFileTimes (FileDatabaseUpdate.cs:437) — all 4 callers confirmed
background: DateTimeFixedCorrection.xaml.cs:117, DateTimeLinearCorrection.xaml.cs:161/167,
DateTimeDaylightSavingsCorrection.xaml.cs:115 (found during verification, not in the
original candidate list), all via a DatabaseUpdateFileDates helper called from inside each
file's own Task.Run. Opted in directly.
UpdateExchangeDayAndMonthInFileDates (FileDatabaseUpdate.cs:485) — sole caller
DateTimeCorrectAmbiguous.xaml.cs:170, confirmed background. Opted in directly.
UpdateFiles(List<ColumnTuplesWithWhere>) (FileDatabaseUpdate.cs:151) — the fan-in hazard.
This one has 11 callers, not the single site originally listed. Checking all of them found
10 confirmed background (DeleteImages.xaml.cs:409, CsvReaderWriter.cs:833 and :1288,
DateTimeRereadFromFiles.xaml.cs:238, FileMetadataPopulateAll.xaml.cs:232,
FileMetadataPopulateDatesOnly.xaml.cs:216, PopulateCamtrapDataFields.xaml.cs:278,
PopulateFieldWithEpisodeData.xaml.cs:214, PopulateFieldWithGUID.xaml.cs:177,
DarkImagesThreshold.xaml.cs:350) — but 1 confirmed UI-thread-synchronous:
ControlsDataEntry/DataEntryHandler.cs:1035 (DateTimeUpdate, a plain method with no
Task.Run/await, called directly from a date/time picker's value-changed event on every
single edit). Extending the shared method's default behavior would have silently given that
per-edit UI-thread write the long ~7s budget — exactly the freeze-inducing mistake this whole
effort exists to avoid, on a method that runs on literally every date/time field edit.
Fix applied: added an opt-in int busyTimeoutMs = 0 parameter to
UpdateFiles(List<ColumnTuplesWithWhere>, int busyTimeoutMs = 0) — default preserves current
(short-budget) behavior for every caller including DataEntryHandler.cs, which was left
completely untouched. Only the 10 confirmed-background call sites now explicitly pass
ThrottleValues.BackgroundWriteExtendedBusyTimeoutMs.
as following the same Task.Run+dialog pattern used throughout src/Timelapse/Dialog/.
Recommendation: audit all Task.Run-wrapped database-mutation call sites in
src/Timelapse/Dialog/ for the same opt-in, not just the ones the original bug report
happened to touch.
8. No global UnobservedTaskException/AppDomain.UnhandledException safety net for the new fire-and-forget tasks — FIXED
ExecuteNonQueryWithRollbackCore does have a genuine catch-all (catch (Exception)), so the
discarded task in DropSessionTempTables won't leak an exception under normal SQLite failure
modes today. However, no TaskScheduler.UnobservedTaskException or
AppDomain.UnhandledException handler existed anywhere in the app. Given the growing reliance
on fire-and-forget tasks (DropSessionTempTables, marker UpdateFileAsync calls), a future
exception escaping one of them would have vanished with zero diagnostic trail.
Fixed: App.xaml.cs now has an OnStartup override registering
TaskScheduler.UnobservedTaskException, logging via AppLog.Warning and calling
args.SetObserved(). Diagnostics-only, as expected — on this project's target framework
(net10.0-windows), unobserved task exceptions don't crash the process by default, so this
doesn't change failure behavior, only ensures a future silent failure leaves a log trace.
Covered in the "What the commit fixed" section above — the code comment and commit message
both said "~9s" / "2000ms max" for the extended budget; the actual arithmetic gives ~7s /
1750ms max. Resolved: corrected the comment at SQLiteWrapper.cs:938-942 to state the
real numbers (8 attempts, up to 1750ms/step, ~7s total) rather than changing
maxBusyAttempt to match the originally-stated (incorrect) ~9s — chosen because it's a
zero-behavior-change documentation fix rather than a real, if small, change to
already-working retry logic.
10. Mailto error-log feature: separate, pre-existing bug (not caused by the SQLite fix) — IMPLEMENTED, awaiting manual test
src/Timelapse/TimelapseMenuCallbacks/TimelapseMenuHelp.cs:219-253
(MenuItemEmailErrorLog_Click): reads the app log, truncates to the last 1000 lines
(truncation was added earlier, in commit ff95066, predating the SQLite fix), URL-encodes it
via Uri.EscapeDataString, and builds a mailto:...&body=... URI passed to
ProcessExecution.TryProcessStart(Uri) (ProcessExecution.cs:18-40). When the resulting URI
exceeds what Windows' ShellExecute can hand off (which is what happened on 2026-07-13 —
accumulated verbose SQL-dump crash entries pushed the encoded body over the practical length
ceiling), process.Start() throws; this is caught, logged via AppLog.Warning, and a beep
plays. Correction to my earlier read: the caller does check the return value and does
show a MessageBox.Show("Could not open your email client...") on failure (lines 247-252),
so the user isn't left with zero explanation — just an unhelpful one that doesn't say why
(URI too long) or offer an alternative (e.g., "log copied to clipboard" or "save log to file
and attach manually").
Resolved (implemented in TimelapseMenuHelp.cs, MenuItemEmailErrorLog_Click): stop
putting the log content in the mailto: URL at all — any length cap is guessing at a moving,
OS/mail-client-dependent target and still throws away content. A true auto-attached file was
considered and rejected: mailto: has no attachment mechanism in its spec (RFC 6068), and the
only way to get a real auto-attach is legacy Simple MAPI (MAPISendMail), which modern
default-mail setups (Windows 11 Mail, webmail-as-default, Thunderbird without a MAPI plugin)
generally don't support — not worth the added P/Invoke complexity for partial coverage.
Implemented instead:
- Copy
logContentsto the clipboard using the pattern already proven elsewhere in this codebase —Dialog/ExceptionShutdownDialog.xaml.cs:150-155(Clipboard.Clear(); Clipboard.SetText(...)). - Reveal the log file selected in File Explorer via the existing
ProcessExecution.TryProcessStartUsingFileExplorerToSelectFile(logPath)(ProcessExecution.cs:126-140) — lets the user drag the actual file in as a real attachment instead of pasted text, if their mail client makes that easier. - Build the
mailto:URI with just the subject and a short, fixed instructional body mentioning both the clipboard copy and the Explorer selection, not the log itself — this keeps the URI a small, fixed size so it can no longer overflowShellExecute's length ceiling. - The existing failure-path
MessageBox(was lines 249-251) now also mentions both fallbacks, so the user has a path forward even if the mailto launch itself fails.
This removes the failure mode entirely (fixed-size URL can't overflow) rather than just delaying it, reuses two patterns already proven in this codebase, and gives the user a choice of attach-the-real-file or paste-the-text depending on what their mail client supports. Build verified (0 warnings/errors); manual end-to-end test (does the mail client open with subject filled, log on clipboard, and Explorer showing the file selected) still pending.
11. ResetIDsAndVacuum permanently switches the database to WAL journal mode — likely a root-cause contributor — FIXED and verified
src/Timelapse/Database/FileDatabaseResetIdAndVacuum.cs:93, inside GetPreTransactionStatements():
$"{Sql.PragmaJournalModeWall}", // Sql.cs:122 → "PRAGMA journal_mode = WAL"alongside PragmaSynchronousNormal, PragmaTempStoreMemory, and PragmaCacheSize, all
labeled "Performance pragmas — safe to set any time." That label is true for three of the four,
but not for journal_mode: unlike synchronous, temp_store, cache_size, and
foreign_keys (all per-connection settings that reset to defaults the moment the connection
closes), journal_mode is a persistent, database-file-level property stored in the file's
own header. Once set to WAL, it stays WAL for every future connection to that file, by every
future version of the app, until something explicitly issues PRAGMA journal_mode = DELETE (or
another non-WAL mode) — which nothing in this codebase ever does. Confirmed via full-file read
of FileDatabaseResetIdAndVacuum.cs: GetPostTransactionStatements() only restores
foreign_keys = ON and drops temp tables; journal mode is never reverted, anywhere.
ResetIDsAndVacuum is not a rare, manually-invoked maintenance action — per this file's own
header comment, it runs automatically "when a large ID value is detected when loading the
database" or "when a merge check-in completes." Both are ordinary events for an actively-used
project. So in practice, most databases that have been in use for a while, merged, or had files
deleted in bulk have likely already been silently, permanently converted to WAL mode, with zero
user-facing indication that anything changed.
Why this matters for the exact bug this whole investigation started from: SQLite's own
documentation explicitly warns that WAL mode requires shared-memory-mapped file support (the
companion -shm/-wal files) that many network filesystems do not implement correctly, and
recommends against using WAL-mode databases on network shares — recommending the traditional
rollback journal instead for exactly that deployment. The original bug report's database lived
at W:\Projects\16P0245_HD_Murray_River\Wildlife Cams\Photos\2026\... — a mapped network
drive. If that database had ever been through ResetIDsAndVacuum (plausible for an
actively-used, multi-contributor wildlife-camera project with periodic merges), it would have
been running in WAL mode over a network share ever since — a configuration SQLite itself says
not to use. This could plausibly be a bigger, more direct contributor to the "database is
locked" reports than plain lock-contention timing, and would explain why the errors read as
somewhat erratic/inconsistent rather than a clean, deterministic contention pattern.
Provenance check (requested by the user): this WAL pragma is not something introduced by
Claude in any session, including the July 11 reference commit. Traced via git log -p -S to
commit 711ee0053f55dfc3f9af618d76fe525bf7f75366, dated 2025-12-11, titled "Repository
starts with version 2.4.0.1..." — the repository's very first commit. It's original,
human-authored legacy code, predating any AI-assisted work on this project by over seven
months.
Resolved — Option A implemented (stop using WAL entirely for this operation):
FileDatabaseResetIdAndVacuum.cs:93— removed thePragmaJournalModeWallline fromGetPreTransactionStatements(). The other three performance pragmas (synchronous=NORMAL,temp_store=MEMORY,cache_size) remain and provide the bulk-operation speedup without the permanent, file-level mode change.ResetIDsAndVacuumno longer converts any database to WAL going forward.- Migration for already-affected databases: added
RevertJournalModeFromWalIfNeeded(FileDatabase.cs), called from the existingUpgradeDatabasesForBackwardsCompatabilityAsyncTask.Runblock (FileDatabase.cs:906-942) — the same unconditional, on-every-open "IfNeeded" check sequence already used for schema migrations (AddExportToCSVColumnIfNeeded,AddStandardToImageSetColumnIfNeeded, etc.), which runs regardless of the template-sync branching elsewhere in the open flow and is already off the UI thread. Reads the currentjournal_mode; if (and only if) it'swal, reverts todelete. Supporting additions:Sql.PragmaJournalMode(bare read form) andSql.PragmaJournalModeDeleteinSql.cs, andSQLiteWrapper.ScalarGetScalarFromSelectAsString(query)(mirrors the existingScalarGetScalarFromSelectAsInt/AsLongwrappers, reusing the same already-hardened privateGetScalarFromSelecthelper — including this session's new Busy/Locked read retry). - Performance characteristics (asked about explicitly): the check runs once per database
open. For the common case (mode already non-WAL — true for every database going forward, and
for any never affected by the old bug) it's one cheap scalar read, then return — negligible,
same league as its sibling checks. For a genuinely WAL-affected database, the one-time revert
forces a real SQLite checkpoint (flushes WAL contents into the main file, removes the
-wal/-shmcompanion files) — a real but bounded, one-time-per-database cost, off the UI thread. After that single successful revert, every subsequent open of that same database is back to the cheap no-op path, permanently. - Verified empirically (not just build-verified): a standalone test — create a scratch db,
force it to
wal, run the exact same two callsRevertJournalModeFromWalIfNeededmakes — confirmed: mode before =wal; revert call returneddelete; mode after =delete; the-wal/-shmcompanion files were actually removed (confirming a real checkpoint occurred, not just a flag change); a second check on the now-reverted database confirmed the cheap no-op path. Full pass, no ambiguity (unlike the lock-contention test for finding #1).
Raised by the user: "a retry is an indication that something has gone wrong, even though it
may eventually resolve." Every retry attempt across all six retry loops in SQLiteWrapper.cs
(GetDataTableFromSelect, GetDataTableFromSelectAsync, GetDistinctValuesInColumn,
GetScalarFromSelect, PragmaGetQuickCheck, ExecuteNonQueryWithRollbackCore) logged only via
TracePrint.PrintMessage → Debug.Print, visible solely to an attached debugger — never to the
persistent AppLog file a user might send to the developer. Worse: if a retry succeeded
(contention resolved within the budget), there was no log trace at all, anywhere, since
success just returns normally. A database on a chronically-flaky network share that "self-heals"
within its retry budget every time would look completely clean in any log sent to the
developer — right up until the day it didn't resolve in time and hard-failed, with no history
suggesting it had been silently straining for weeks.
Fixed: added one AppLog.Warning call at the success-return point of each of the six retry
loops, firing only if attempt/busyAttempt > 0 (i.e., at least one retry actually happened).
Zero cost in the normal case (no retry occurred). Sample log lines this produces:
2026-07-14 14:32:07.118 | WARNING | SQLiteWrapper.cs(206) GetDataTableFromSelect | GetDataTableFromSelect: succeeded after 2 retry attempt(s) due to transient database contention (busy/locked or temporarily unavailable).
2026-07-14 14:35:41.902 | WARNING | SQLiteWrapper.cs(1075) ExecuteNonQueryWithRollbackCore | ExecuteNonQueryWithRollbackCore: succeeded after 1 retry attempt(s) due to transient database contention (busy/locked).
The log line applies uniformly regardless of which retry cause (CantOpen/IoErr or
Busy/Locked) triggered the earlier attempts — the success point doesn't need to distinguish,
it only needs to know retries happened at all. Build verified (0 warnings/errors).
Essentially zero impact on routine (uncontended) operations. Every fix above only changes
behavior inside an exception handler that fires after SQLite has already returned
Busy/Locked. On a single-user local database with no lock contention, that code path
never executes — reads, writes, opens, and closes take exactly as long as they do today. The
only "cost" is more waiting during an already-bad situation (contention), which is the
intended trade-off, identical in kind to the one the reference commit already made for the
write path.
| Change | Cost when uncontended | Cost when contended |
|---|---|---|
| Read-path Busy/Locked retry (#1) | Zero — one more SQLiteErrorCode comparison in an already-existing catch filter |
Adds retry/backoff before giving up — turns an instant silent failure into a multi-second wait |
PragmaGetQuickCheck fix (#2) |
Zero | A few hundred ms to a couple seconds instead of an instant false "corrupt" verdict |
CreateTable consistency (#3) |
Zero — runs once, at database/schema creation, not per-operation | Same dialog behavior as the already-fixed sites |
Update() overload parity (#4) |
Zero — additive optional parameter, unused unless a caller passes it | N/A by itself |
UpdateSyncImageSetToDatabase/RepairClassificationCategoriesIfNeeded downgrade (#5, #6) |
Zero — same short retry budget, only the outcome after it expires changes | No change in wait time if done as recommended (keep the short budget, just stop showing the fatal dialog) |
| Extending background bulk writes (#7) | Zero | Up to ~7s longer before giving up, but only for operations that already run off the UI thread behind a progress dialog |
UnobservedTaskException handler (#8) |
Negligible — one static event subscription at startup | N/A |
| Comment fix (#9), mailto fix (#10) | Zero — doc/unrelated feature | N/A |
The one place this needs judgment, not mechanical copying: which retry budget applies to
which thread. If the read-path fix (#1) is applied naively — giving UI-thread-synchronous
reads (GetDataTableFromSelect, used directly in several places rather than the async
version) the long 8-attempt/~7s budget — that trades "silent failure" for "UI freezes for
7 seconds," exactly the failure mode the reference commit went out of its way to avoid on
write paths. The read-path fix should use a short, UI-safe budget (mirroring the existing
5-attempt/~2.5s default) unless a specific call site is confirmed to run on a background
thread.
Three risk tiers:
Low risk, high confidence — #3, #4, #9, #10. These are mechanical: applying a pattern
already written, reviewed, and merged elsewhere in the same reference commit, just to more
call sites, or adding an unused optional parameter, or fixing a comment/unrelated feature. The
main way to get these wrong is copy-paste carelessness (e.g., an early return added inside
OnDatabaseCreatedAsync skipping a later step that used to run unconditionally) — worth a
careful control-flow read-through after each edit, but not a design risk.
Medium risk — #1, #2, #7, #8. Not because the SQLite retry mechanics are unproven (the
same Busy/Locked retry-with-backoff idea already works in this codebase), but because each
requires the same threading judgment call the reference commit's own author got burned by once
("database checkout... turned out not to be backgrounded despite appearances," per the commit
message). Specifics:
- #7: each candidate call site needs to be traced, not assumed, to confirm it truly runs off the UI thread before opting into the long budget — finish the per-site verification the two audit agents started rather than pattern-matching by eye.
- #1: the existing
_errorFired/Interlocked.Exchangesingle-fire gate must be preserved so adding a retry loop doesn't change how many timesOnReadErrorfires, and the retry condition must not accidentally swallow a genuine non-transient error by misclassifying it as retryable. - #2: the retry condition must be narrowly scoped to
Busy/Lockedonly — a truly corrupt database returns a different SQLite error code, and making that retryable would turn a fast "corrupt" diagnosis into a multi-second hang before still reporting corrupt. - #8: nearly risk-free technically, but on this project's target framework
(
net10.0-windows), unobserved task exceptions don't crash the process by default anyway — so this handler is pure diagnostics (worth doing, but not itself a safety net against anything).
Requires a product decision before it's "safe" — #5, #6. These aren't code-risk so much as
silent-failure risk dressed as a fix. Changing "show fatal dialog" to "log and continue" for
UpdateSyncImageSetToDatabase and RepairClassificationCategoriesIfNeeded means a user's
sort/search-term state or a classification-label repair can now fail without the user ever
knowing — trading "the app crashed" for "the app quietly didn't do what it said it did."
That's a regression in a different dimension (silent correctness) even though it's zero lines
of new logic risk. This is why the "Open questions" section below leaves it as a decision
rather than a recommendation: it shouldn't be implemented as a pure copy of the
DropSessionTempTables treatment without also deciding whether the user needs some
non-modal indicator (status-bar message, a surfaced log entry) that something didn't save.
Overall: the mechanical consistency fixes (#3, #4, #9, #10) are close to risk-free and should be done first. The read-path and background-write-opt-in fixes (#1, #7) are sound in principle and low-risk if each call site's threading context is individually verified rather than assumed — that verification step is what stands between "confident fix" and "possible new freeze." The two fatal-to-warning downgrades (#5, #6) are the only items where the risk isn't really about bugs at all — it's about whether silent-continue is an acceptable user experience, which needs an answer before it's safe to call "fixed."
- Resolved (partially): for findings #5/#6, the user has decided NOT to reuse the silent log-and-continue treatment. They are leaning toward a modal, informative dialog (distinct from today's fatal shutdown dialog) but have not yet settled the exact design — see "Findings #5, #6" under Phase 3 of the Implementation Plan below for the specific sub-questions still open (Retry/Ignore semantics, whether close-time vs. in-session failure should behave differently).
- Resolved: sequencing — phased by risk tier, safest-first, as separate commits/PRs (see Implementation Plan below). #5/#6 ship last, after the Phase 3 checkpoint is resolved.
- Status: Phase 1 done, Phase 2 in progress (paused). See "Implementation Plan" below.
- Resolved: finding #11 (WAL mode) — Option A implemented (stopped setting WAL in
ResetIDsAndVacuum), plus an on-open migration for already-affected existing databases, both verified empirically. See finding #11 for full detail. - New, unresolved — finding #1 verification: the read-path retry code is written and
builds clean, but live-contention testing didn't manage to reproduce a real
SQLITE_BUSY/LOCKEDexception to confirm the new catch branches actually fire (see the Phase 2 status note above). Options going forward: try a lower-level repro (e.g. a raw Win32 file lock instead of SQLite-level contention, to more literally simulate "another process/network share has this file locked"), accept code-review-level confidence and move on, or revisit once #11 is resolved (since #11 may turn out to be the more direct explanation for the original read failures, making further contention-timing tuning less urgent by comparison).
Sequencing decision: phased by risk tier, as separate commits/PRs, safest-first. Findings #5/#6 are deferred — see Phase 3 below for why and what's still open.
These are direct copies of a pattern already reviewed and merged in the reference commit
(74be7247da166a70115ee6cb322f596d42c26e82) — the fatal-dialog pattern, the AppLog.Warning
non-fatal pattern, and the busyTimeoutMs opt-in parameter — applied to more call sites, or a
comment/unrelated-feature fix. No new design decisions, no threading judgment calls.
- #4 —
Update()overload parity. Addint busyTimeoutMs = 0to the three overloads inSQLiteWrapper.cs(currently lines 522, 553, 565) that lack it, threading it into theirExecuteNonQueryWithRollback(...)calls exactly as the fourth overload (line 595) already does. Purely additive — no caller changes yet. Do this before #3 touches adjacent code in the same files. - #3 —
CreateTableconsistency. Apply the same check-result-and-show-dialog pattern (already used for FileData/TemplateInfo/Template) to the remaining bareDatabase.CreateTable(...)calls:FileDatabase.cs:263(ImageSet),FileDatabase.cs:309(Markers),FileDatabase.cs:2004,CommonDatabase.cs:1025(MetadataTemplate),CommonDatabase.cs:1039(MetadataInfo), andRecognitionDatabases.cs:62,70,79,103,117,133(six recognition tables). Care point: each new earlyreturnon failure must not skip a later step that previously ran unconditionally — read the surrounding method's full control flow before adding the guard. - #9 — Retry-duration doc fix. DONE. The code comment and commit message both claimed
"~9s / 2000ms max" for the extended busy budget; actual arithmetic
(
maxBusyAttempt = busyTimeoutMs > 0 ? 7 : 4inExecuteNonQueryWithRollbackCore,SQLiteWrapper.cs~898-943) gives ~7s/1750ms max. Fixed by correcting the comment atSQLiteWrapper.cs:938-942to match reality (not by bumpingmaxBusyAttempt, to avoid a behavior change for a documentation-only problem). - #10 — Mailto: use the clipboard + Explorer-reveal instead of an oversized URL body.
DONE, awaiting manual test. In
TimelapseMenuHelp.cs:219-253(MenuItemEmailErrorLog_Click): copylogContentsto the clipboard using the existing pattern atDialog/ExceptionShutdownDialog.xaml.cs:150-155(Clipboard.Clear(); Clipboard.SetText(...)); also reveal the log file selected in File Explorer viaProcessExecution.TryProcessStartUsingFileExplorerToSelectFile(logPath)(ProcessExecution.cs:126-140) so the user can attach the real file instead of pasting text; build themailto:URI with just the subject and a short fixed instructional body mentioning both fallbacks, not the log content itself, so the URI can no longer overflowShellExecute's length ceiling; the existing failure-pathMessageBoxnow also mentions both fallbacks. Build verified; manual test (open Help > Email Error Log and confirm mail client opens, clipboard has the log, Explorer shows the file selected) still pending.
Verification (no lock-contention harness needed): run the app uncontended end-to-end
(new database, existing database, recognition features) and confirm every touched
CreateTable site still succeeds silently as today; then force a failure per site
(temporarily rename/lock the .ddb mid-open, or inject a bad query) to confirm the dialog now
fires where it silently didn't before. For #10, generate an oversized encoded body and confirm
the cap/fallback triggers. For #4, confirm behavior is unchanged (new parameter unused so far).
Rollback: one commit per finding (4 commits) — trivially bisectable, each independently revertible without touching phase 2/3 work.
Mechanically similar to the already-proven write-path retry logic, but each requires verifying, not assuming, thread context — exactly where the reference commit's own author got burned once ("database checkout... turned out not to be backgrounded despite appearances").
- #1 — DONE, accepted at code-review-level confidence. Added a
Busy/Lockedbranch to the exception filter inGetDataTableFromSelect,GetDataTableFromSelectAsync,GetDistinctValuesInColumn, andGetScalarFromSelectinSQLiteWrapper.cs, using the short budget (matches the write path's default 5-attempt/~2.5s schedule), preserving the_errorFiredsingle-fire gate unchanged. Live-contention testing never reproduced a realBusy/Lockedexception (three attempts), so this was accepted on code-review-level confidence rather than empirical proof — see finding #1 for the full reasoning. - #2 —
PragmaGetQuickCheckmisdiagnosis. DONE, verified empirically. Replaced the barecatch { return false; }inSQLiteWrapper.cswith one that retriesBusy/Lockedbriefly (same short budget as #1) then logs viaAppLogon exhaustion, while any other SQLite error code (genuine corruption) falls through immediately without retrying and logs as such. No contract change for callers (FileDatabase.cs:802,CommonDatabase.cs:151). Verified with a valid DB (true, 16ms) and a genuinely corrupt file (false, 22ms — confirming corruption isn't delayed by the retry loop). - #7 — DONE. Every candidate individually traced to confirm
Task.Runbackgrounding before opting in (never inferred from file name/pattern alone).UpdateAdjustedFileTimesandUpdateExchangeDayAndMonthInFileDatesopted in directly (all callers confirmed background).UpdateFiles(List<ColumnTuplesWithWhere>)turned out to have 11 callers, not 1 — 10 confirmed background (opted in via a newbusyTimeoutMs = 0default parameter, explicitly passed only at those 10 call sites) and 1 confirmed UI-thread-synchronous (DataEntryHandler.cs:1035, left untouched on the default short budget). See finding #7 for the full site list and the fan-in hazard this verification step caught. - #8 — DONE.
App.xaml.csnow has anOnStartupoverride registeringTaskScheduler.UnobservedTaskException, logging viaAppLog.Warningand callingargs.SetObserved(). Diagnostics only, as expected — doesn't change failure behavior.
Verification — reuse the reference commit's own technique: hold a lock via a second
SQLiteConnection executing BEGIN IMMEDIATE against a scratch copy of a .ddb, for a
controlled duration:
- For #1/#2: trigger the read /
PragmaGetQuickCheckwhile the lock is held; confirm retry-then-success if released within the short budget, and correct classification (not corrupt, not crashed) if the hold outlasts it. Do this while actively interacting with the UI (drag the window, click a menu) to confirm no freeze beyond the short budget. - For #7: hold the lock during a bulk delete / date-correction operation and confirm the progress dialog and its cancel button remain responsive for the full extended wait.
- For #8: deliberately throw inside an uncaught
Task.Runin a scratch build and confirm the new handler logs it.
Rollback: one commit per finding; #7's per-site opt-ins can be split further (one commit per site) if any single site's threading trace turns out inconclusive.
UpdateSyncImageSetToDatabase (FileDatabaseUpdate.cs) and RepairClassificationCategoriesIfNeeded
(FileDatabase.cs) both used to go straight from automatic-retry-exhaustion to the fatal
TimelapseNeedsToShutDownDataWriteErrorDialog, with no intermediate chance to retry.
Decided design (2026-07-14), in three rounds of clarification:
- Automatic short-budget retries (~2.5s, inside
Database.Update/ExecuteNonQueryWithRollback) already happened before either method's caller ever saw a failure — this was already true before this fix and needed no change. - On failure, show a new dialog — exactly one "Retry" button, no "Ignore and continue." Closing the dialog (X, Esc, clicking away) is treated the same as a failed retry.
- Capped at one manual retry total — if that single retry also fails (or the user closes the dialog instead of retrying), fall through to the existing fatal dialog, unchanged.
- Same treatment at both call sites and regardless of close-time vs. in-session context (no
special-casing
TimelapseClosing.csvs. e.g.TimelapseMenuSort.cs) — since the logic lives entirely inside the two shared methods, every caller ofUpdateSyncImageSetToDatabase(TimelapseMenuSort.cs,TimelapseQuickPaste.cs,TimelapseMenuEdit.cs×2,TimelapseImageSetLoading.cs,TimelapseCheckAndCorrectFolders.cs,TimelapseClosing.cs) gets the new behavior automatically and consistently, with no per-call-site changes needed.
Implemented:
- New
Dialogs.TimelapseOperationRetryDialog(Window owner, string operationDescription, SqlOperationResult result)inDialogs.cs, modeled on the existingTimelapseReadErrorNoticeDialog(sameFormattedDialog/MessageBoxButtonType.OKscaffolding), with the OK button relabeled"Retry". Returnsbool?—trueonly if Retry was clicked;null/false(including window-close) means "proceed to the fatal dialog." - Both
UpdateSyncImageSetToDatabaseandRepairClassificationCategoriesIfNeedednow: on failure, show this dialog; iftrue, re-run the exact same write/repair statement once; if that still fails, fall through toTimelapseNeedsToShutDownDataWriteErrorDialogexactly as before.
Build verified (0 warnings/errors). Manual end-to-end test done and passed (2026-07-14):
opened a real test project (C:\Users\saulg\Desktop\TestSets\PracticeImageSet), held an
OS-level exclusive lock (FileShare.None) on TimelapseData.ddb from a separate process, then
changed the sort order in the running app (triggers UpdateSyncImageSetToDatabase). Confirmed:
the new Retry dialog appeared (not the fatal one) once the automatic internal retries were
exhausted; clicking Retry made one more attempt; since the lock was still held throughout, that
attempt also failed and correctly fell through to the existing
TimelapseNeedsToShutDownDataWriteErrorDialog, which shut the app down as designed. The
"retry succeeds because the lock clears in time" branch was not separately tested (would
require releasing the lock mid-dialog) — user confirmed this wasn't necessary given the
fallback path is now verified working end-to-end.
src/Timelapse/Database/SQLiteWrapper.cs— core retry logic,Update()overloads, read primitives,PragmaGetQuickChecksrc/Timelapse/Database/FileDatabase.cs—CreateTablesites,RepairClassificationCategoriesIfNeededsrc/Timelapse/Database/FileDatabaseUpdate.cs—UpdateSyncImageSetToDatabase, bulk update pathssrc/Timelapse/Database/CommonDatabase.cs,RecognitionDatabases.cs— remainingCreateTablesitessrc/Timelapse/TimelapseMenuCallbacks/TimelapseMenuHelp.cs— mailto body capsrc/Timelapse/App.xaml.cs—UnobservedTaskExceptionhandlersrc/Timelapse/Dialog/DeleteImages.xaml.cs,Dialog/DateTimeFixedCorrection.xaml.cs,Dialog/DateTimeLinearCorrection.xaml.cs,Dialog/DateTimeCorrectAmbiguous.xaml.cs— background-write opt-in candidates for #7