fix(db): prevent concurrent startup locks

This commit is contained in:
zarzet
2026-08-08 00:01:00 +07:00
parent 6742fe886f
commit 2612da81c3
6 changed files with 185 additions and 54 deletions
+8 -20
View File
@@ -1,9 +1,8 @@
import 'dart:convert';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:sqflite/sqflite.dart';
import 'package:spotiflac_android/services/sqlite_helpers.dart' as sqlite;
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('AppStateDb');
@@ -25,31 +24,20 @@ const _recentMigrationKey = 'app_state_migrated_recent_to_sqlite_v1';
class AppStateDatabase {
static final AppStateDatabase instance = AppStateDatabase._init();
static Database? _database;
static final sqlite.SingleFlightInitializer<Database> _database =
sqlite.SingleFlightInitializer<Database>();
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
AppStateDatabase._init();
Future<Database> get database async {
if (_database != null) return _database!;
_database = await _initDb();
return _database!;
}
Future<Database> get database => _database.getOrCreate(_initDb);
Future<Database> _initDb() async {
final dbPath = await getApplicationDocumentsDirectory();
final path = join(dbPath.path, _dbFileName);
_log.i('Initializing app state database at: $path');
return openDatabase(
path,
Future<Database> _initDb() {
return sqlite.openAppDatabase(
_dbFileName,
version: _dbVersion,
onConfigure: (db) async {
await db.rawQuery('PRAGMA journal_mode = WAL');
await db.execute('PRAGMA synchronous = NORMAL');
},
incrementalAutoVacuum: false,
onCreate: _createDb,
onUpgrade: _upgradeDb,
);
+11 -10
View File
@@ -67,19 +67,20 @@ class HistoryBatchLookupRequest {
class HistoryDatabase {
static const int schemaVersion = 10;
static final HistoryDatabase instance = HistoryDatabase._init();
static Database? _database;
static final sqlite.SingleFlightInitializer<Database> _database =
sqlite.SingleFlightInitializer<Database>();
HistoryDatabase._init();
Future<Database> get database async {
if (_database != null) return _database!;
_database = await sqlite.openAppDatabase(
'history.db',
version: schemaVersion,
onCreate: _createDB,
onUpgrade: _upgradeDB,
Future<Database> get database {
return _database.getOrCreate(
() => sqlite.openAppDatabase(
'history.db',
version: schemaVersion,
onCreate: _createDB,
onUpgrade: _upgradeDB,
),
);
return _database!;
}
Future<void> _createDB(Database db, int version) async {
@@ -895,7 +896,7 @@ class HistoryDatabase {
Future<void> close() async {
final db = await database;
await db.close();
_database = null;
_database.reset();
}
Future<void> updateFilePath(
+11 -10
View File
@@ -60,22 +60,23 @@ class PlaylistPickerSummaryRow {
class LibraryCollectionsDatabase {
static final LibraryCollectionsDatabase instance =
LibraryCollectionsDatabase._init();
static Database? _database;
static final sqlite.SingleFlightInitializer<Database> _database =
sqlite.SingleFlightInitializer<Database>();
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
LibraryCollectionsDatabase._init();
Future<Database> get database async {
if (_database != null) return _database!;
_database = await sqlite.openAppDatabase(
_dbFileName,
version: _dbVersion,
foreignKeys: true,
onCreate: _createDb,
onUpgrade: _upgradeDb,
Future<Database> get database {
return _database.getOrCreate(
() => sqlite.openAppDatabase(
_dbFileName,
version: _dbVersion,
foreignKeys: true,
onCreate: _createDb,
onUpgrade: _upgradeDb,
),
);
return _database!;
}
Future<void> _createDb(Database db, int version) async {
+11 -10
View File
@@ -18,20 +18,21 @@ class LibraryDatabase {
static final LibraryDatabase instance = LibraryDatabase._init();
static const int schemaVersion = 9;
static const int audioMetadataScanVersion = 1;
static Database? _database;
static final sqlite.SingleFlightInitializer<Database> _database =
sqlite.SingleFlightInitializer<Database>();
bool _historyAttached = false;
LibraryDatabase._init();
Future<Database> get database async {
if (_database != null) return _database!;
_database = await sqlite.openAppDatabase(
'local_library.db',
version: schemaVersion,
onCreate: _createDB,
onUpgrade: _upgradeDB,
Future<Database> get database {
return _database.getOrCreate(
() => sqlite.openAppDatabase(
'local_library.db',
version: schemaVersion,
onCreate: _createDB,
onUpgrade: _upgradeDB,
),
);
return _database!;
}
Future<void> _ensureHistoryAttached(Database db) async {
@@ -1013,7 +1014,7 @@ class LibraryDatabase {
Future<void> close() async {
final db = await database;
await db.close();
_database = null;
_database.reset();
_historyAttached = false;
}
+53 -4
View File
@@ -6,6 +6,39 @@ import 'package:sqflite/sqflite.dart';
final _log = AppLogger('AppSqlite');
/// Caches an asynchronously-created value while also coalescing concurrent
/// callers onto the same in-flight initialization.
class SingleFlightInitializer<T extends Object> {
T? _value;
Future<T>? _initializing;
Future<T> getOrCreate(Future<T> Function() create) {
final value = _value;
if (value != null) return Future<T>.value(value);
final initializing = _initializing;
if (initializing != null) return initializing;
late final Future<T> future;
future = Future<T>.sync(create)
.then((value) {
_value = value;
return value;
})
.whenComplete(() {
if (identical(_initializing, future)) {
_initializing = null;
}
});
_initializing = future;
return future;
}
void reset() {
_value = null;
}
}
/// Opens a database file in the app documents directory with the shared
/// WAL + synchronous=NORMAL configuration.
Future<Database> openAppDatabase(
@@ -15,6 +48,7 @@ Future<Database> openAppDatabase(
required Future<void> Function(Database db, int oldVersion, int newVersion)
onUpgrade,
bool foreignKeys = false,
bool incrementalAutoVacuum = true,
}) async {
final dbPath = await getApplicationDocumentsDirectory();
final path = join(dbPath.path, fileName);
@@ -25,13 +59,28 @@ Future<Database> openAppDatabase(
path,
version: version,
onConfigure: (db) async {
// Set this before any other PRAGMA so transient writer contention waits
// instead of immediately surfacing SQLITE_BUSY during startup.
await db.rawQuery('PRAGMA busy_timeout = 5000');
if (foreignKeys) {
await db.execute('PRAGMA foreign_keys = ON');
}
// Without auto_vacuum the file never shrinks after deletes — its size
// is a permanent high-water mark. Only takes effect on newly created
// databases; existing files keep their mode until a manual VACUUM.
await db.execute('PRAGMA auto_vacuum = INCREMENTAL');
if (incrementalAutoVacuum) {
final tables = await db.rawQuery('''
SELECT 1
FROM sqlite_master
WHERE type = 'table'
AND name NOT LIKE 'sqlite_%'
AND name != 'android_metadata'
LIMIT 1
''');
// Changing from NONE must happen outside a transaction and before the
// first application table is created. Android may already have added
// its internal android_metadata table at this point.
if (tables.isEmpty) {
await db.execute('PRAGMA auto_vacuum = INCREMENTAL');
}
}
await db.rawQuery('PRAGMA journal_mode = WAL');
await db.execute('PRAGMA synchronous = NORMAL');
},
+91
View File
@@ -0,0 +1,91 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/services/sqlite_helpers.dart';
void main() {
group('SingleFlightInitializer', () {
test('coalesces concurrent initialization and caches the result', () async {
final initializer = SingleFlightInitializer<int>();
final gate = Completer<void>();
var calls = 0;
Future<int> create() async {
calls++;
await gate.future;
return 42;
}
final first = initializer.getOrCreate(create);
final second = initializer.getOrCreate(create);
expect(identical(first, second), isTrue);
expect(calls, 1);
gate.complete();
expect(await Future.wait([first, second]), [42, 42]);
expect(await initializer.getOrCreate(create), 42);
expect(calls, 1);
});
test('allows retry after initialization fails', () async {
final initializer = SingleFlightInitializer<int>();
var calls = 0;
Future<int> create() {
calls++;
if (calls == 1) throw StateError('first attempt failed');
return Future<int>.value(7);
}
await expectLater(initializer.getOrCreate(create), throwsStateError);
expect(await initializer.getOrCreate(create), 7);
expect(calls, 2);
});
test('reset permits a fresh initialization', () async {
final initializer = SingleFlightInitializer<int>();
var value = 1;
expect(await initializer.getOrCreate(() async => value), 1);
value = 2;
expect(await initializer.getOrCreate(() async => value), 1);
initializer.reset();
expect(await initializer.getOrCreate(() async => value), 2);
});
});
group('SQLite startup configuration', () {
final source = File('lib/services/sqlite_helpers.dart').readAsStringSync();
test('waits for transient locks before configuring the connection', () {
final configureIndex = source.indexOf('onConfigure:');
final busyTimeoutIndex = source.indexOf('PRAGMA busy_timeout');
final journalModeIndex = source.indexOf('PRAGMA journal_mode');
expect(configureIndex, greaterThanOrEqualTo(0));
expect(busyTimeoutIndex, greaterThan(configureIndex));
expect(busyTimeoutIndex, lessThan(journalModeIndex));
});
test('sets incremental auto-vacuum only before app tables exist', () {
final schemaCheckIndex = source.indexOf('FROM sqlite_master');
final emptySchemaGuardIndex = source.indexOf('if (tables.isEmpty)');
final autoVacuumMatches = RegExp(
r"PRAGMA auto_vacuum = INCREMENTAL",
).allMatches(source).toList();
final onCreateIndex = source.indexOf('onCreate:');
expect(autoVacuumMatches, hasLength(1));
expect(schemaCheckIndex, greaterThanOrEqualTo(0));
expect(emptySchemaGuardIndex, greaterThan(schemaCheckIndex));
expect(
autoVacuumMatches.single.start,
greaterThan(emptySchemaGuardIndex),
);
expect(autoVacuumMatches.single.start, lessThan(onCreateIndex));
});
});
}