Fix statements not getting disposed without cache

This commit is contained in:
Simon Binder 2023-06-06 09:58:34 +02:00
parent 1b8d7f9d50
commit dd3f04bf09
No known key found for this signature in database
GPG Key ID: 7891917E4147B8C0
4 changed files with 143 additions and 15 deletions

View File

@ -1,3 +1,8 @@
## 2.8.2
- Fix prepared statements leaking when the statement cache is disabled.
- Disable prepared statement caching by default.
## 2.8.1
- Performance improvement: Cache and re-use prepared statements - thanks to [@davidmartos96](https://github.com/davidmartos96/)

View File

@ -34,6 +34,9 @@ typedef DatabaseSetup = void Function(Database database);
/// A drift database implementation based on `dart:ffi`, running directly in a
/// Dart VM or an AOT compiled Dart/Flutter application.
class NativeDatabase extends DelegatedDatabase {
// when changing this, also update the documentation in `drift_vm_database_factory`.
static const _cacheStatementsByDefault = false;
NativeDatabase._(DatabaseDelegate delegate, bool logStatements)
: super(delegate, isSequential: false, logStatements: logStatements);
@ -43,6 +46,12 @@ class NativeDatabase extends DelegatedDatabase {
/// {@template drift_vm_database_factory}
/// If [logStatements] is true (defaults to `false`), generated sql statements
/// will be printed before executing. This can be useful for debugging.
///
/// The [cachePreparedStatements] flag (defaults to `false`) controls whether
/// drift will cache prepared statement objects, which improves performance as
/// sqlite3 doesn't have to parse statements that are frequently used multiple
/// times. This will be the default in the next minor drift version.
///
/// The optional [setup] function can be used to perform a setup just after
/// the database is opened, before drift is fully ready. This can be used to
/// add custom user-defined sql functions or to provide encryption keys in
@ -52,7 +61,7 @@ class NativeDatabase extends DelegatedDatabase {
File file, {
bool logStatements = false,
DatabaseSetup? setup,
bool cachePreparedStatements = true,
bool cachePreparedStatements = _cacheStatementsByDefault,
}) {
return NativeDatabase._(
_NativeDelegate(
@ -80,10 +89,18 @@ class NativeDatabase extends DelegatedDatabase {
/// __Important limitations__: If the [setup] parameter is given, it must be
/// a static or top-level function. The reason is that it is executed on
/// another isolate.
static QueryExecutor createInBackground(File file,
{bool logStatements = false, DatabaseSetup? setup}) {
return createBackgroundConnection(file,
logStatements: logStatements, setup: setup);
static QueryExecutor createInBackground(
File file, {
bool logStatements = false,
bool cachePreparedStatements = _cacheStatementsByDefault,
DatabaseSetup? setup,
}) {
return createBackgroundConnection(
file,
logStatements: logStatements,
setup: setup,
cachePreparedStatements: cachePreparedStatements,
);
}
/// Like [createInBackground], except that it returns the whole
@ -91,14 +108,23 @@ class NativeDatabase extends DelegatedDatabase {
///
/// This creates a database writing data to the given [file]. The database
/// runs in a background isolate and is stopped when closed.
static DatabaseConnection createBackgroundConnection(File file,
{bool logStatements = false, DatabaseSetup? setup}) {
static DatabaseConnection createBackgroundConnection(
File file, {
bool logStatements = false,
DatabaseSetup? setup,
bool cachePreparedStatements = _cacheStatementsByDefault,
}) {
return DatabaseConnection.delayed(Future.sync(() async {
final receiveIsolate = ReceivePort();
await Isolate.spawn(
_NativeIsolateStartup.start,
_NativeIsolateStartup(
file.absolute.path, logStatements, setup, receiveIsolate.sendPort),
file.absolute.path,
logStatements,
cachePreparedStatements,
setup,
receiveIsolate.sendPort,
),
debugName: 'Drift isolate worker for ${file.path}',
);
@ -115,7 +141,7 @@ class NativeDatabase extends DelegatedDatabase {
factory NativeDatabase.memory({
bool logStatements = false,
DatabaseSetup? setup,
bool cachePreparedStatements = true,
bool cachePreparedStatements = _cacheStatementsByDefault,
}) {
return NativeDatabase._(
_NativeDelegate(null, setup, cachePreparedStatements),
@ -140,7 +166,7 @@ class NativeDatabase extends DelegatedDatabase {
bool logStatements = false,
DatabaseSetup? setup,
bool closeUnderlyingOnClose = true,
bool cachePreparedStatements = true,
bool cachePreparedStatements = _cacheStatementsByDefault,
}) {
return NativeDatabase._(
_NativeDelegate.opened(
@ -295,17 +321,24 @@ class _NativeDelegate extends Sqlite3Delegate<Database> {
class _NativeIsolateStartup {
final String path;
final bool enableLogs;
final bool cachePreparedStatements;
final DatabaseSetup? setup;
final SendPort sendServer;
_NativeIsolateStartup(
this.path, this.enableLogs, this.setup, this.sendServer);
this.path,
this.enableLogs,
this.cachePreparedStatements,
this.setup,
this.sendServer,
);
static void start(_NativeIsolateStartup startup) {
final isolate = DriftIsolate.inCurrent(() {
return DatabaseConnection(NativeDatabase(
File(startup.path),
logStatements: startup.enableLogs,
cachePreparedStatements: startup.cachePreparedStatements,
setup: startup.setup,
));
});

View File

@ -138,15 +138,24 @@ abstract class Sqlite3Delegate<DB extends CommonDatabase>
database.execute(statement);
} else {
final stmt = _getPreparedStatement(statement);
stmt.execute(args);
try {
stmt.execute(args);
} finally {
_returnStatement(stmt);
}
}
}
@override
Future<QueryResult> runSelect(String statement, List<Object?> args) async {
final stmt = _getPreparedStatement(statement);
final result = stmt.select(args);
return QueryResult.fromRows(result.toList());
try {
final result = stmt.select(args);
return QueryResult.fromRows(result.toList());
} finally {
_returnStatement(stmt);
}
}
CommonPreparedStatement _getPreparedStatement(String sql) {
@ -165,6 +174,14 @@ abstract class Sqlite3Delegate<DB extends CommonDatabase>
return stmt;
}
}
void _returnStatement(CommonPreparedStatement stmt) {
// When using a statement cache, prepared statements are disposed as they
// get evicted from the cache, so we don't need to do anything.
if (!cachePreparedStatements) {
stmt.dispose();
}
}
}
class _SqliteVersionDelegate extends DynamicVersionDelegate {
@ -186,6 +203,12 @@ class _SqliteVersionDelegate extends DynamicVersionDelegate {
/// multiple time when they're used frequently.
@internal
class PreparedStatementsCache {
/// The default amount of prepared statements to keep cached.
///
/// This value is used in tests to verify that evicted statements get disposed.
@visibleForTesting
static const defaultSize = 64;
/// The maximum amount of cached statements.
final int maxSize;
@ -197,7 +220,7 @@ class PreparedStatementsCache {
LinkedHashMap();
/// Create a cache of prepared statements with a capacity of [maxSize].
PreparedStatementsCache({this.maxSize = 64});
PreparedStatementsCache({this.maxSize = defaultSize});
/// Attempts to look up the cached [sql] statement, if it exists.
///

View File

@ -1,8 +1,11 @@
@TestOn('vm')
import 'dart:ffi';
import 'dart:io';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:drift/src/sqlite3/database.dart';
import 'package:sqlite3/open.dart';
import 'package:sqlite3/sqlite3.dart';
import 'package:test/test.dart';
import 'package:test_descriptor/test_descriptor.dart' as d;
@ -120,6 +123,47 @@ void main() {
// Should also prevent subsequent open attempts
await expectLater(db.ensureOpen(_FakeExecutorUser()), throwsA(exception));
});
test('disposes statements directly if cache is disabled', () async {
final underlying = sqlite3.openInMemory();
final db =
NativeDatabase.opened(underlying, cachePreparedStatements: false);
addTearDown(db.close);
await db.ensureOpen(_FakeExecutorUser());
await db.runCustom('CREATE TABLE foo (bar INTEGER);');
await db.runCustom('UPDATE foo SET bar = 1');
expect(underlying.activeStatementCount, isZero);
});
test('disposes statements eventually if cache is enabled', () async {
final underlying = sqlite3.openInMemory();
addTearDown(underlying.dispose);
final db = NativeDatabase.opened(
underlying,
cachePreparedStatements: true,
closeUnderlyingOnClose: false,
);
await db.ensureOpen(_FakeExecutorUser());
expect(underlying.activeStatementCount, isZero);
for (var i = 1; i <= PreparedStatementsCache.defaultSize; i++) {
await db.runSelect('SELECT $i', []);
expect(underlying.activeStatementCount, i);
}
for (var i = 0; i < PreparedStatementsCache.defaultSize; i++) {
// This will evict old statements, so the amount of active statements
// should not change.
await db.runSelect('SELECT $i AS "new"', []);
expect(
underlying.activeStatementCount, PreparedStatementsCache.defaultSize);
}
await db.close();
expect(underlying.activeStatementCount, isZero);
});
}
class _FakeExecutorUser extends QueryExecutorUser {
@ -131,3 +175,26 @@ class _FakeExecutorUser extends QueryExecutorUser {
@override
int get schemaVersion => 1;
}
extension on Database {
/// Counts the amount of statements currently prepared in this database by
/// invoking `sqlite3_next_stmt` repeatedly.
int get activeStatementCount {
final library = open.openSqlite();
final nextStmt = library.lookupFunction<Pointer Function(Pointer, Pointer),
Pointer Function(Pointer, Pointer)>(
'sqlite3_next_stmt',
);
Pointer currentStatement;
var count = 0;
for (currentStatement = nextStmt(handle.cast(), nullptr);
currentStatement.address != 0;
currentStatement = nextStmt(handle.cast(), currentStatement)) {
count++;
}
return count;
}
}