From 664ceb61711d57014f8a817ad9742896bd6b919c Mon Sep 17 00:00:00 2001 From: Alex Wagner Date: Thu, 22 Oct 2026 11:59:47 +0200 Subject: [PATCH] fix(db): limit the -wal so a VACUUM does not double disk use In WAL mode a VACUUM writes every page through the -wal. A checkpoint copies it back and the next write restarts at its head, but nothing ever shrank the file, so after the first VACUUM the -wal stayed as large as the database for good: 702 MB beside a 698 MB file on a WHOOP MG install in the growth soak (#609). Set journal_size_limit (32 MB) on every open, so each restart truncates the file back, healing existing installs on their next write; and checkpoint with TRUNCATE right after the VACUUM so the space comes back immediately. Both pragmas return a row, so they go through rawQuery and are never fatal. Co-Authored-By: Claude Opus 5.5 --- lib/data/db.dart | 23 +++++++++++++++++++++++ test/db_storage_hygiene_test.dart | 30 ++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/lib/data/db.dart b/lib/data/db.dart index ed954c51b..e7ae45862 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -528,6 +528,9 @@ class LocalDb { } } + /// The most the -wal keeps once a checkpoint has emptied it. See [_open]. + static const int walSizeLimitBytes = 32 << 20; + static Future _open() async { final dir = await getDatabasesPath(); final path = p.join(dir, dbName); @@ -556,6 +559,18 @@ class LocalDb { } catch (_) { /* keep the default journal — this is a perf tweak, not a requirement */ } + // DISK. A checkpoint copies the -wal into the database and the next + // write starts over at its head, but the FILE keeps its high-water + // mark. One big transaction (a VACUUM writes every page through it, a + // backlog drain) left a -wal as large as the database for good. With + // a limit the file is truncated back each time it restarts, on this + // connection and on an existing install's oversized -wal alike. + // Per-connection, and it returns a row: rawQuery, never fatal. + try { + await db.rawQuery('PRAGMA journal_size_limit=$walSizeLimitBytes'); + } catch (_) { + /* non-fatal */ + } try { await db.execute('PRAGMA synchronous=NORMAL'); // ~40 MB page cache (negative = KiB) so hot b-tree pages stay resident @@ -13169,6 +13184,14 @@ class LocalDb { // ("Query reaches storage outside the coach views") for the rest of the // process. The invariant belongs to whoever moves the pages. await CoachDb.close(); + // The VACUUM wrote the whole database through the -wal. Copy it back and + // truncate it now, rather than at the next write; the size limit set on + // open covers it if a reader holds this checkpoint back. + try { + await db.rawQuery('PRAGMA wal_checkpoint(TRUNCATE)'); + } catch (_) { + /* non-fatal: the bytes are reclaimed, the -wal shrinks on a later write */ + } return free; } diff --git a/test/db_storage_hygiene_test.dart b/test/db_storage_hygiene_test.dart index bf2f772f0..f4504dc63 100644 --- a/test/db_storage_hygiene_test.dart +++ b/test/db_storage_hygiene_test.dart @@ -6,6 +6,11 @@ // pruned. They are keyed (day_id, algo_version), so every kAlgoVersion // bump wrote a whole new generation beside the old one and nothing // removed it. +// 3. A VACUUM does not leave the -wal the size of the whole database: in WAL +// mode it writes every page through the -wal, and without a size limit +// the file is reused but never shrunk, doubling what the app holds. +import 'dart:io'; + import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; @@ -153,6 +158,31 @@ void main() { )).map((r) => r['algo_version'] as int).toList(); expect(recent, [49, 50]); }); + + test('the -wal is size-limited, so a VACUUM does not leave it the size of ' + 'the database', () async { + final db = await LocalDb.instance; + final limit = + (await db.rawQuery('PRAGMA journal_size_limit')).first.values.first; + expect(limit, LocalDb.walSizeLimitBytes); + + // 48 MB to keep and 16 MB to free: the VACUUM then writes ~48 MB of pages + // through the -wal, more than the limit. + await db.execute('CREATE TABLE wal_probe (b BLOB)'); + for (var i = 0; i < 64; i++) { + await db.rawInsert('INSERT INTO wal_probe VALUES (randomblob(1048576))'); + } + await db.delete('wal_probe', where: 'rowid > 48'); + expect(await LocalDb.vacuumIfBloated(minFreeBytes: 1), greaterThan(0)); + + final wal = File( + p.join(await databaseFactory.getDatabasesPath(), '${LocalDb.dbName}-wal'), + ); + final walBytes = wal.existsSync() ? wal.lengthSync() : 0; + expect(walBytes, lessThanOrEqualTo(LocalDb.walSizeLimitBytes), + reason: 'the -wal kept ${walBytes >> 20} MB after the VACUUM'); + await db.execute('DROP TABLE wal_probe'); + }); } Future> _rrIndexes(Database db) async => (await db.rawQuery(