From e571cf363b4f51e0648328a63f4da2e30763b8be Mon Sep 17 00:00:00 2001 From: Timm Preetz Date: Mon, 2 Dec 2024 21:00:04 +0100 Subject: [PATCH] Remove `writeMany(singleStatement)` as the performance gains evaporated once the index-update was fixed Perf test: ``` flutter: Case: singleStatement=false, largeValue=false, batchSize=1000 flutter: 1000 x `write` took 483.64ms flutter: `writeMany` took 8.07ms flutter: `writeMany` again took 14.23ms flutter: Case: singleStatement=true, largeValue=false, batchSize=1000 flutter: 1000 x `write` took 501.32ms flutter: `writeMany` took 9.61ms flutter: `writeMany` again took 13.23ms ``` --- CHANGELOG.md | 2 - example/perf/write_many.dart | 169 ---------------------------- example/run_perf.sh | 5 - lib/src/index_entity_store.dart | 101 +++-------------- test/indexed_entity_store_test.dart | 122 ++++++++++---------- test/performance_test.dart | 8 +- 6 files changed, 78 insertions(+), 329 deletions(-) delete mode 100644 example/perf/write_many.dart delete mode 100755 example/run_perf.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 5486e0f..553215e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,6 @@ ## 2.0.1 * Add tests for internal schema migrations -* Speed up `writeMany` by defaulting to use a single statement for all inserts (as opposed to a single transactions with many individual inserts) - * One can revert to the previous behavior by setting `singleStatement: false` in the call * Add `delete(where: )` to delete all rows matching a specific index-query ## 2.0.0 diff --git a/example/perf/write_many.dart b/example/perf/write_many.dart deleted file mode 100644 index b674688..0000000 --- a/example/perf/write_many.dart +++ /dev/null @@ -1,169 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:flutter/foundation.dart' show FlutterTimeline, debugPrint; -import 'package:flutter/widgets.dart'; -import 'package:indexed_entity_store/indexed_entity_store.dart'; -import 'package:path_provider/path_provider.dart'; - -void main() async { - WidgetsFlutterBinding.ensureInitialized(); - - for (final batchSize in [10, 100, 1000, 10000]) { - for (final singleStatement in [true, false]) { - for (final largeValue in [true, false]) { - debugPrint( - '\nCase: singleStatement=$singleStatement, largeValue=$largeValue, batchSize=$batchSize', - ); - - final path = (await getApplicationCacheDirectory()) - .uri - .resolve('./index_entity_store_test_${FlutterTimeline.now}.sqlite3') - .toFilePath(); - - debugPrint(path); - - final db = IndexedEntityDabase.open(path); - - final fooStore = db.entityStore(fooConnector); - - // Insert one row each, so statements are prepared - fooStore.write( - _FooEntity(id: 0, valueA: 'a', valueB: 1, valueC: true), - ); - fooStore.writeMany( - [_FooEntity(id: 0, valueA: 'a', valueB: 1, valueC: true)], - singleStatement: singleStatement, - ); - fooStore.delete(key: 0); - - // many - { - final sw2 = Stopwatch()..start(); - - for (var i = 1; i <= batchSize; i++) { - fooStore.write( - _FooEntity(id: i, valueA: 'a', valueB: 1, valueC: true), - ); - } - - final durMs = (sw2.elapsedMicroseconds / 1000).toStringAsFixed(2); - debugPrint( - '$batchSize x `write` took ${durMs}ms', - ); - } - - // 10 kB - final valueA = largeValue ? 'a1' * 1024 * 10 : 'a1'; - final valueA2 = largeValue ? 'a2' * 1024 * 10 : 'a2'; - - // writeMany - { - final sw2 = Stopwatch()..start(); - - fooStore.writeMany( - [ - for (var i = batchSize + 1; i <= batchSize * 2; i++) - _FooEntity( - id: i, - valueA: valueA, - valueB: 1, - valueC: true, - ), - ], - singleStatement: singleStatement, - ); - - final durMs = (sw2.elapsedMicroseconds / 1000).toStringAsFixed(2); - debugPrint( - '`writeMany` took ${durMs}ms', - ); - } - - // writeMany again (which needs to replace all existing entities and update the indices) - { - final sw2 = Stopwatch()..start(); - - fooStore.writeMany( - [ - for (var i = batchSize + 1; i <= batchSize * 2; i++) - _FooEntity( - id: i, - valueA: valueA2, - valueB: 111111, - valueC: true, - ), - ], - singleStatement: singleStatement, - ); - - final durMs = (sw2.elapsedMicroseconds / 1000).toStringAsFixed(2); - debugPrint( - '`writeMany` again took ${durMs}ms', - ); - } - - if (fooStore.queryOnce().length != (batchSize * 2)) { - throw 'unexpected store size'; - } - - db.dispose(); - - File(path).deleteSync(); - } - } - } - - exit(0); -} - -class _FooEntity { - _FooEntity({ - required this.id, - required this.valueA, - required this.valueB, - required this.valueC, - }); - - final int id; - - /// indexed via `a` - final String valueA; - - /// indexed via "b" - final int valueB; - - /// not indexed - final bool valueC; - - Map toJSON() { - return { - 'id': id, - 'valueA': valueA, - 'valueB': valueB, - 'valueC': valueC, - }; - } - - static _FooEntity fromJSON(Map json) { - return _FooEntity( - id: json['id'], - valueA: json['valueA'], - valueB: json['valueB'], - valueC: json['valueC'], - ); - } -} - -final fooConnector = IndexedEntityConnector<_FooEntity, int, String>( - entityKey: 'foo', - getPrimaryKey: (f) => f.id, - getIndices: (index) { - index((e) => e.valueA, as: 'a'); - index((e) => e.valueB, as: 'b'); - }, - serialize: (f) => jsonEncode(f.toJSON()), - deserialize: (s) => _FooEntity.fromJSON( - jsonDecode(s) as Map, - ), -); diff --git a/example/run_perf.sh b/example/run_perf.sh deleted file mode 100755 index 720154c..0000000 --- a/example/run_perf.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/sh - -set +x - -fvm flutter run --release -d macos ./perf/write_many.dart diff --git a/lib/src/index_entity_store.dart b/lib/src/index_entity_store.dart index acaadc9..782d705 100644 --- a/lib/src/index_entity_store.dart +++ b/lib/src/index_entity_store.dart @@ -238,79 +238,33 @@ class IndexedEntityStore { _handleUpdate({_connector.getPrimaryKey(e)}); } - /// Insert or update many entities in a single batch + /// Insert or update many entities in a single transaction /// /// Notification for changes will only fire after all changes have been written (meaning queries will get a single update after all writes are finished) - void writeMany( - Iterable entities, { - bool singleStatement = true, - }) { + void writeMany(Iterable entities) { final keys = {}; - final sw = Stopwatch()..start(); - - if (singleStatement) { - if (entities.isEmpty) { - return; - } - - try { - _database.execute('BEGIN'); + try { + _database.execute('BEGIN'); + assert(_database.autocommit == false); - _database.execute( - [ - 'REPLACE INTO `entity` (`type`, `key`, `value`) ' - ' VALUES (?1, ?, ?)', - // Add additional entry values for each further parameter - ', (?1, ?, ?)' * (entities.length - 1), - ].join(' '), - [ - _entityKey, - for (final e in entities) ...[ - _connector.getPrimaryKey(e), - _connector.serialize(e), - ], - ], + for (final e in entities) { + _entityInsertStatement.execute( + [_entityKey, _connector.getPrimaryKey(e), _connector.serialize(e)], ); - _updateIndexInternalSingleStatement(entities); + _updateIndexInternal(e); - _database.execute('COMMIT'); - } catch (e) { - _database.execute('ROLLBACK'); - - rethrow; + keys.add(_connector.getPrimaryKey(e)); } - keys.addAll(entities.map(_connector.getPrimaryKey)); - } else { - // transaction variant - - try { - _database.execute('BEGIN'); - assert(_database.autocommit == false); - - for (final e in entities) { - _entityInsertStatement.execute( - [_entityKey, _connector.getPrimaryKey(e), _connector.serialize(e)], - ); - - _updateIndexInternal(e); - - keys.add(_connector.getPrimaryKey(e)); - } - - _database.execute('COMMIT'); - } catch (e) { - _database.execute('ROLLBACK'); + _database.execute('COMMIT'); + } catch (e) { + _database.execute('ROLLBACK'); - rethrow; - } + rethrow; } - print( - '$singleStatement ${(sw.elapsedMicroseconds / 1000).toStringAsFixed(2)}ms'); - _handleUpdate(keys); } @@ -334,33 +288,6 @@ class IndexedEntityStore { } } - void _updateIndexInternalSingleStatement(Iterable entities) { - if (_indexColumns._indexColumns.values.isEmpty) { - return; - } - - _database.execute( - [ - 'INSERT INTO `index` (`type`, `entity`, `field`, `value`, `referenced_type`, `unique`) ' - ' VALUES (?1, ?, ?, ?, ?, ?)', - // Add additional entry values for each further entity - ', (?1, ?, ?, ?, ?, ?)' * - (entities.length * _indexColumns._indexColumns.values.length - 1), - ].join(' '), - [ - _entityKey, - for (final indexColumn in _indexColumns._indexColumns.values) - for (final e in entities) ...[ - _connector.getPrimaryKey(e), - indexColumn._field, - indexColumn._getIndexValue(e), - indexColumn._referencedEntity, - indexColumn._unique, - ], - ], - ); - } - /// Delete the specified entries void delete({ final T? entity, diff --git a/test/indexed_entity_store_test.dart b/test/indexed_entity_store_test.dart index 7a53339..3b3e145 100644 --- a/test/indexed_entity_store_test.dart +++ b/test/indexed_entity_store_test.dart @@ -1236,80 +1236,78 @@ void main() { }, ); - for (final singleStatement in [true, false]) { - test( - 'writeMany(singleStatement: $singleStatement)', - () { - final path = - '/tmp/index_entity_store_test_${FlutterTimeline.now}.sqlite3'; + test( + 'writeMany', + () { + final path = + '/tmp/index_entity_store_test_${FlutterTimeline.now}.sqlite3'; - final db = IndexedEntityDabase.open(path); + final db = IndexedEntityDabase.open(path); - final valueWrappingConnector = - IndexedEntityConnector<_IntValueWrapper, int, String>( - entityKey: 'value_wrapper', - getPrimaryKey: (f) => f.key, - getIndices: (index) { - index((e) => e.value % 2 == 0, as: 'even'); - index((e) => e.batch, as: 'batch'); - }, - serialize: (f) => jsonEncode(f.toJSON()), - deserialize: (s) => _IntValueWrapper.fromJSON( - jsonDecode(s) as Map, - ), - ); + final valueWrappingConnector = + IndexedEntityConnector<_IntValueWrapper, int, String>( + entityKey: 'value_wrapper', + getPrimaryKey: (f) => f.key, + getIndices: (index) { + index((e) => e.value % 2 == 0, as: 'even'); + index((e) => e.batch, as: 'batch'); + }, + serialize: (f) => jsonEncode(f.toJSON()), + deserialize: (s) => _IntValueWrapper.fromJSON( + jsonDecode(s) as Map, + ), + ); - final store = db.entityStore(valueWrappingConnector); + final store = db.entityStore(valueWrappingConnector); - final allEntities = store.query(); - final evenEntities = store.query( - where: (cols) => cols['even'].equals(true), - ); - final batch1Entities = store.query( - where: (cols) => cols['batch'].equals(1), - ); - final batch2Entities = store.query( - where: (cols) => cols['batch'].equals(2), - ); + final allEntities = store.query(); + final evenEntities = store.query( + where: (cols) => cols['even'].equals(true), + ); + final batch1Entities = store.query( + where: (cols) => cols['batch'].equals(1), + ); + final batch2Entities = store.query( + where: (cols) => cols['batch'].equals(2), + ); - // writeMany - { - final entities = [ - for (var i = 0; i < 1000; i++) _IntValueWrapper(i, i, 1), - ]; + // writeMany + { + final entities = [ + for (var i = 0; i < 1000; i++) _IntValueWrapper(i, i, 1), + ]; - store.writeMany(entities, singleStatement: singleStatement); - } + store.writeMany(entities); + } - expect(allEntities.value, hasLength(1000)); - expect(evenEntities.value, hasLength(500)); - expect(batch1Entities.value, hasLength(1000)); - expect(batch2Entities.value, isEmpty); + expect(allEntities.value, hasLength(1000)); + expect(evenEntities.value, hasLength(500)); + expect(batch1Entities.value, hasLength(1000)); + expect(batch2Entities.value, isEmpty); - // writeMany again (in-place updates, index update with new batch ID) - { - final entities = [ - for (var i = 0; i < 1000; i++) _IntValueWrapper(i, i, 2), - ]; + // writeMany again (in-place updates, index update with new batch ID) + { + final entities = [ + for (var i = 0; i < 1000; i++) _IntValueWrapper(i, i, 2), + ]; - store.writeMany(entities, singleStatement: singleStatement); - } + store.writeMany(entities); + } - expect(allEntities.value, hasLength(1000)); - expect(evenEntities.value, hasLength(500)); - expect(evenEntities.value.first.batch, 2); // value got updated - expect(batch1Entities.value, isEmpty); - expect(batch2Entities.value, hasLength(1000)); + expect(allEntities.value, hasLength(1000)); + expect(evenEntities.value, hasLength(500)); + expect(evenEntities.value.first.batch, 2); // value got updated + expect(batch1Entities.value, isEmpty); + expect(batch2Entities.value, hasLength(1000)); - allEntities.dispose(); - evenEntities.dispose(); - batch1Entities.dispose(); - batch2Entities.dispose(); + allEntities.dispose(); + evenEntities.dispose(); + batch1Entities.dispose(); + batch2Entities.dispose(); - db.dispose(); - }, - ); - } + db.dispose(); + }, + ); } class _FooEntity { diff --git a/test/performance_test.dart b/test/performance_test.dart index b6f6396..f29fd21 100644 --- a/test/performance_test.dart +++ b/test/performance_test.dart @@ -9,8 +9,6 @@ import 'package:indexed_entity_store/indexed_entity_store.dart'; void main() { WidgetsFlutterBinding.ensureInitialized(); - const singleStatement = true; - test( 'Performance', () async { @@ -54,7 +52,6 @@ void main() { for (var i = batchSize + 1; i <= batchSize * 2; i++) _FooEntity(id: i, valueA: 'a', valueB: 1, valueC: true), ], - singleStatement: singleStatement, ); debugPrint( @@ -71,7 +68,6 @@ void main() { for (var i = batchSize + 1; i <= batchSize * 2; i++) _FooEntity(id: i, valueA: 'aaaaaa', valueB: 111111, valueC: true), ], - singleStatement: singleStatement, ); debugPrint( @@ -80,6 +76,10 @@ void main() { } expect(fooStore.queryOnce(), hasLength(batchSize * 2 + 1)); + expect( + fooStore.queryOnce(where: (cols) => cols['b'].greaterThan(0)), + hasLength(batchSize * 2 + 1), + ); }, skip: !Platform.isMacOS, // only run locally for now );