perf(collections): delete selected playlists in one transaction

This commit is contained in:
zarzet committed 2026-10-08 05:14:44 +07:00
1 parent 3da4126762
commit e0bdcb5134
8 files changed
+615 -6

No files matched your search

@@ -0,0 +1,156 @@
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:sqflite/sqflite.dart';
import 'package:spotiflac_android/providers/library_collections_provider.dart';
import '../test/support/library_collections_benchmark.dart';
import '../test/support/playlist_deletion_fixture.dart';
void main() {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final report = <String, dynamic>{
'platform': Platform.operatingSystem,
'note':
'Five alternating pairs of actual notifier calls on private platform SQLite with FK cascades. Playlists retain lazy track membership. Timings include deletion and state publication, not seeding or assertions; heartbeat is not FPS.',
};
var completed = 0;
late Directory root;
late Database db;
setUp(() async {
root = await Directory.systemTemp.createTemp('playlist-deletion-');
db = await openDatabase('${root.path}/collections.db');
await createPlaylistDeletionSchema(db);
});
tearDown(() async {
await db.close();
await root.delete(recursive: true);
});
tearDownAll(() {
report['completed_cases'] = completed;
binding.reportData ??= {};
binding.reportData!['playlist_deletion'] = report;
});
ProviderContainer scope(LibraryCollectionsState state) => ProviderContainer(
overrides: [
libraryCollectionsProvider.overrideWith(
() => PlaylistDeletionCollections(state, db),
),
],
);
for (final count in [20, 200]) {
testWidgets('delete $count playlists with 20 shared tracks each', (
tester,
) async {
final samples = <String, List<Map<String, int>>>{
'single': [],
'batch': [],
};
final ids = [for (var i = 0; i < count; i++) 'playlist-$i'];
for (var pair = 0; pair < 5; pair++) {
for (final bulk in pair.isEven ? [false, true] : [true, false]) {
final initial = await seedPlaylistDeletion(db, count);
final before = await playlistDeletionSnapshot(db);
final container = scope(initial);
try {
final notifier = container.read(
libraryCollectionsProvider.notifier,
);
var publications = 0;
container.listen(
libraryCollectionsProvider,
(_, _) => publications++,
);
final result = await measureCollectionOperation<int>(() async {
if (bulk) return notifier.deletePlaylists(ids);
for (final id in ids) {
await notifier.deletePlaylist(id);
}
return count;
});
expect(result.value, count);
expect(publications, bulk ? 1 : count);
final state = container.read(libraryCollectionsProvider);
expect(state.playlists.single, same(initial.playlists.last));
expect(state.playlists.single.tracksLoaded, false);
expect(state.playlists.single.trackCount, 20);
expect(state.loved, initial.loved);
expect(state.wishlist, initial.wishlist);
final after = await playlistDeletionSnapshot(db);
expect(
after['playlists'],
before['playlists']!.where((p) => p['id'] == "keep'quoted"),
);
expect(
after['playlist_tracks'],
before['playlist_tracks']!.where(
(p) => p['playlist_id'] == "keep'quoted",
),
);
for (final table in ['wishlist_tracks', 'loved_tracks']) {
expect(after[table], before[table]);
}
expect(await db.rawQuery('PRAGMA foreign_key_check'), isEmpty);
samples[bulk ? 'batch' : 'single']!.add({
...result.timing,
'publications': publications,
});
} finally {
container.dispose();
}
}
}
report['playlists_$count'] = samples;
completed++;
});
}
for (final cascade in [false, true]) {
testWidgets(
'failed ${cascade ? 'child' : 'parent'} deletion rolls back all chunks',
(tester) async {
final initial = await seedPlaylistDeletion(
db,
300,
tracksPerPlaylist: 2,
);
final before = await playlistDeletionSnapshot(db);
final container = scope(initial);
try {
final notifier = container.read(libraryCollectionsProvider.notifier);
var publications = 0;
container.listen(
libraryCollectionsProvider,
(_, _) => publications++,
);
final table = cascade ? 'playlist_tracks' : 'playlists';
final column = cascade ? 'playlist_id' : 'id';
await db.execute(
"CREATE TRIGGER reject_delete BEFORE DELETE ON $table WHEN old.$column='playlist-150' BEGIN SELECT RAISE(ABORT,'denied'); END",
);
final ids = [for (var i = 0; i < 300; i++) 'playlist-$i'];
await expectLater(
notifier.deletePlaylists(ids),
throwsA(isA<DatabaseException>()),
);
expect(container.read(libraryCollectionsProvider), same(initial));
expect(publications, 0);
expect(await playlistDeletionSnapshot(db), before);
await db.execute('DROP TRIGGER reject_delete');
expect(await notifier.deletePlaylists(ids), 300);
expect(publications, 1);
expect(await db.query('playlists'), hasLength(1));
expect(await db.query('playlist_tracks'), hasLength(2));
expect(await db.rawQuery('PRAGMA foreign_key_check'), isEmpty);
completed++;
} finally {
container.dispose();
}
},
);
}
}
@@ -463,6 +463,26 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
_invalidatePlaylistPickerSummaries();
});
Future<int> deletePlaylists(Iterable<String> playlistIds) {
final selected = playlistIds.toSet();
return _mutate(() async {
final ids = selected
.where((id) => state.playlistById(id) != null)
.toSet();
if (ids.isEmpty) return 0;
await _db.deletePlaylists(ids.toList(growable: false));
if (ref.mounted) {
state = state.copyWith(
playlists: state.playlists
.where((playlist) => !ids.contains(playlist.id))
.toList(growable: false),
);
_invalidatePlaylistPickerSummaries();
}
return ids.length;
});
}
Future<bool> addTrackToPlaylist(String playlistId, Track track) => _mutate(
() async {
var playlist = state.playlistById(playlistId);
+1 -3
View File
@@ -328,9 +328,7 @@ extension _QueueTabSelectionActions on _QueueTabState {
if (confirmed != true || !context.mounted) return;
final notifier = ref.read(libraryCollectionsProvider.notifier);
for (final id in _selectedPlaylistIds.toList()) {
await notifier.deletePlaylist(id);
}
await notifier.deletePlaylists(_selectedPlaylistIds);
if (!context.mounted) return;
_exitPlaylistSelectionMode();
@@ -782,6 +782,33 @@ class LibraryCollectionsDatabase {
await db.delete(_tablePlaylists, where: 'id = ?', whereArgs: [playlistId]);
}
Future<void> deletePlaylists(List<String> playlistIds) async {
if (playlistIds.isEmpty) return;
await deleteDatabasePlaylists(await database, playlistIds);
}
/// Delete selected parents together; the owner's foreign-key cascade removes
/// their track memberships without loading track metadata into Dart.
static Future<void> deleteDatabasePlaylists(
Database db,
List<String> playlistIds,
) async {
if (playlistIds.isEmpty) return;
await sqlite.transactionWithBusyRetry(db, (txn) async {
for (var start = 0; start < playlistIds.length; start += 128) {
final end = start + 128 < playlistIds.length
? start + 128
: playlistIds.length;
final ids = playlistIds.sublist(start, end);
await txn.delete(
_tablePlaylists,
where: 'id IN (${List.filled(ids.length, '?').join(',')})',
whereArgs: ids,
);
}
});
}
Future<void> upsertPlaylistTrack({
required String playlistId,
required String trackKey,
+102 -3
View File
@@ -171,6 +171,15 @@ class _CollectionsDatabase implements LibraryCollectionsDatabase {
Future<void> deletePlaylist(String playlistId) =>
_write('delete:playlist:$playlistId', () => playlists.remove(playlistId));
@override
Future<void> deletePlaylists(List<String> ids) =>
_write('bulk-playlists:remove', () {
for (final id in ids) {
playlists.remove(id);
playlistTracks.remove(id);
}
});
@override
Future<void> upsertPlaylistTracksBatch({
required String playlistId,
@@ -240,6 +249,96 @@ ProviderContainer _container(_CollectionsDatabase db) {
}
void main() {
for (final failFirst in [false, true]) {
test(
'playlist bulk deletion snapshots IDs and serializes retries (fail: $failFirst)',
() async {
final db = _CollectionsDatabase()..failNextWrite = failFirst;
for (final id in ['a', 'b', 'keep']) {
db.playlists[id] = {
'id': id,
'name': 'Playlist $id',
'cover_image_path': 'cover-$id',
'created_at': '2026-01-01T00:00:00Z',
'updated_at': '2026-01-02T00:00:00Z',
};
db.playlistTracks[id] = [
{
'track_key': 'example:shared',
'track_json': jsonEncode(_track('shared').toJson()),
'added_at': '2026-01-01T00:00:00Z',
},
];
}
final container = _container(db);
final notifier = container.read(libraryCollectionsProvider.notifier);
final ids = ['a', 'b', 'missing', 'a'];
final first = notifier.deletePlaylists(ids);
ids
..clear()
..add('keep');
final failure = failFirst ? expectLater(first, throwsStateError) : null;
await db.writeStarted.future;
final before = container.read(libraryCollectionsProvider);
expect(before.playlists.every((p) => !p.tracksLoaded), true);
var publications = 0;
container.listen(libraryCollectionsProvider, (_, _) => publications++);
final second = notifier.deletePlaylists(['a', 'b']);
expect(db.events, ['bulk-playlists:remove']);
expect(container.read(libraryCollectionsProvider), same(before));
db.releaseWrite.complete();
if (failure != null) {
await failure;
} else {
expect(await first, 2);
}
expect(await second, failFirst ? 2 : 0);
final after = container.read(libraryCollectionsProvider);
expect(after.playlists.single, same(before.playlistById('keep')));
expect(after.playlists.single.coverImagePath, 'cover-keep');
expect(after.playlists.single.trackKeys, {'example:shared'});
expect(db.playlists.keys, ['keep']);
expect(db.playlistTracks.keys, ['keep']);
expect(db.playlistReads, 0);
expect(publications, 1);
expect(await notifier.deletePlaylists([]), 0);
expect(await notifier.deletePlaylists(['missing', 'a']), 0);
expect(db.events.length, failFirst ? 2 : 1);
expect(publications, 1);
},
);
}
test('late hydration cannot resurrect a bulk-deleted playlist', () async {
final db = _CollectionsDatabase();
db.playlists['p'] = {
'id': 'p',
'name': 'Playlist',
'created_at': '2026-01-01T00:00:00Z',
'updated_at': '2026-01-01T00:00:00Z',
};
final rows = [
{
'track_key': 'example:shared',
'track_json': jsonEncode(_track('shared').toJson()),
'added_at': '2026-01-01T00:00:00Z',
},
];
db.playlistTracks['p'] = rows;
db.stagedPlaylistRows = LibraryPlaylistTracksSnapshot(rows: rows);
final container = _container(db);
final notifier = container.read(libraryCollectionsProvider.notifier);
final load = notifier.ensurePlaylistLoaded('p');
await db.playlistReadStarted.future;
db.releaseWrite.complete();
expect(await notifier.deletePlaylists(['p']), 1);
expect(container.read(libraryCollectionsProvider).playlists, isEmpty);
db.releasePlaylistRead.complete();
await load;
expect(container.read(libraryCollectionsProvider).playlists, isEmpty);
expect(db.playlistReads, 1);
});
for (final kind in ['wishlist', 'loved', 'playlist']) {
for (final failFirst in [false, true]) {
test(
@@ -671,11 +770,11 @@ void main() {
final container = _container(db);
final notifier = container.read(libraryCollectionsProvider.notifier);
await notifier.ensurePlaylistsLoaded(const []);
final deletion = notifier.deletePlaylist('target');
final deletion = notifier.deletePlaylists(['target']);
await db.writeStarted.future;
final creation = notifier.createPlaylist('New');
await Future<void>.delayed(Duration.zero);
expect(db.events, ['delete:playlist:target']);
expect(db.events, ['bulk-playlists:remove']);
expect(
container.read(libraryCollectionsProvider).playlists.map((p) => p.id),
['target', 'keep'],
@@ -684,7 +783,7 @@ void main() {
await deletion;
final createdId = await creation;
expect(db.events, [
'delete:playlist:target',
'bulk-playlists:remove',
'insert:playlist:$createdId',
]);
expect(db.playlists.keys.toSet(), {'keep', createdId});
+96
View File
@@ -19,6 +19,7 @@ import 'package:spotiflac_android/services/history_database.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/library_search.dart';
import 'package:spotiflac_android/theme/app_theme.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
class _Settings extends SettingsNotifier {
@override
@@ -72,6 +73,38 @@ class _Queue extends DownloadQueueNotifier {
DownloadQueueState build() => DownloadQueueState();
}
class _SelectionCollections extends LibraryCollectionsNotifier {
_SelectionCollections(this._record);
final void Function(List<String>) _record;
@override
LibraryCollectionsState build() => LibraryCollectionsState(
isLoaded: true,
playlists: [
for (final id in ['First', 'Second', 'Keep'])
UserPlaylistCollection(
id: id,
name: id,
createdAt: DateTime.utc(2026),
updatedAt: DateTime.utc(2026),
tracks: const [],
),
],
);
@override
Future<int> deletePlaylists(Iterable<String> ids) async {
final selected = ids.toSet();
_record(selected.toList());
state = state.copyWith(
playlists: state.playlists
.where((p) => !selected.contains(p.id))
.toList(),
);
return selected.length;
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late _Local local;
@@ -121,6 +154,69 @@ void main() {
addTearDown(() => messenger.setMockMethodCallHandler(sqlite, null));
});
for (final mornye in [false, true]) {
testWidgets('playlist selection confirms one deletion (Mornye: $mornye)', (
tester,
) async {
tester.view.physicalSize = const Size(800, 1200);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final calls = <List<String>>[];
await tester.pumpWidget(
ProviderScope(
overrides: [
settingsProvider.overrideWith(_Settings.new),
downloadHistoryProvider.overrideWith(() => history),
localLibraryProvider.overrideWith(() => local),
libraryCollectionsProvider.overrideWith(
() => _SelectionCollections(calls.add),
),
downloadQueueProvider.overrideWith(_Queue.new),
],
child: MaterialApp(
theme: mornye
? MornyeTheme.build(Brightness.dark)
: AppTheme.light(),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: QueueTab(librarySection: 'playlists')),
),
),
);
await tester.pumpAndSettle();
final l10n = AppLocalizations.of(tester.element(find.byType(QueueTab)));
await tester.ensureVisible(find.text('First'));
await tester.longPress(find.text('First'));
await tester.pumpAndSettle();
await tester.ensureVisible(find.text('Second'));
await tester.tap(find.text('Second'));
await tester.pumpAndSettle();
await tester.tap(find.text(l10n.selectionDeletePlaylistsCount(2)));
await tester.pumpAndSettle();
expect(
find.text(l10n.collectionDeletePlaylistsMessage(2)),
findsOneWidget,
);
await tester.tap(find.text(l10n.dialogCancel));
await tester.pumpAndSettle();
expect(calls, isEmpty);
expect(find.text('First'), findsOneWidget);
await tester.tap(find.text(l10n.selectionDeletePlaylistsCount(2)));
await tester.pumpAndSettle();
await tester.tap(find.text(l10n.dialogDelete));
await tester.pumpAndSettle();
expect(calls, [
['First', 'Second'],
]);
expect(find.text('First'), findsNothing);
expect(find.text('Second'), findsNothing);
expect(find.text('Keep'), findsOneWidget);
expect(find.text(l10n.selectionDeletePlaylistsCount(2)), findsNothing);
expect(find.text(l10n.collectionPlaylistsDeleted(2)), findsOneWidget);
expect(tester.takeException(), isNull);
});
}
test(
'search and browse stop observing the local index when disabled',
() async {
@@ -0,0 +1,60 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/services/library_collections_database.dart';
import 'support/playlist_deletion_fixture.dart';
import 'support/sqlite_process_database.dart';
void main() {
for (final failure in ['none', 'parent', 'cascade']) {
test('playlist deletion cascades atomically (failure: $failure)', () async {
final db = await SqliteProcessDatabase.open();
addTearDown(db.close);
await createPlaylistDeletionSchema(db);
await seedPlaylistDeletion(db, 300, tracksPerPlaylist: 2);
final before = await playlistDeletionSnapshot(db);
final keys = [
for (var i = 0; i < 300; i++) 'playlist-$i',
'missing',
'playlist-0',
];
if (failure != 'none') {
final table = failure == 'parent' ? 'playlists' : 'playlist_tracks';
final column = failure == 'parent' ? 'id' : 'playlist_id';
await db.execute(
"CREATE TRIGGER reject_delete BEFORE DELETE ON $table WHEN old.$column='playlist-150' BEGIN SELECT RAISE(ABORT,'denied'); END",
);
await expectLater(
LibraryCollectionsDatabase.deleteDatabasePlaylists(db, keys),
throwsStateError,
);
expect(await playlistDeletionSnapshot(db), before);
await db.execute('DROP TRIGGER reject_delete');
}
await LibraryCollectionsDatabase.deleteDatabasePlaylists(db, keys);
final after = await playlistDeletionSnapshot(db);
expect(
after['playlists'],
before['playlists']!.where((p) => p['id'] == "keep'quoted"),
);
expect(
after['playlist_tracks'],
before['playlist_tracks']!.where(
(p) => p['playlist_id'] == "keep'quoted",
),
);
for (final table in ['wishlist_tracks', 'loved_tracks']) {
expect(after[table], before[table]);
}
expect(await db.rawQuery('PRAGMA foreign_key_check'), isEmpty);
await LibraryCollectionsDatabase.deleteDatabasePlaylists(db, keys);
await LibraryCollectionsDatabase.deleteDatabasePlaylists(db, []);
expect(await playlistDeletionSnapshot(db), after);
await LibraryCollectionsDatabase.deleteDatabasePlaylists(db, [
"keep'quoted",
]);
expect(await db.query('playlists'), isEmpty);
expect(await db.query('playlist_tracks'), isEmpty);
expect(await db.query('loved_tracks'), hasLength(2));
});
}
}
+153
View File
@@ -0,0 +1,153 @@
import 'dart:convert';
import 'package:sqflite/sqflite.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/services/library_collections_database.dart';
class PlaylistDeletionDatabase implements LibraryCollectionsDatabase {
PlaylistDeletionDatabase(this.owner);
final Database owner;
@override
Future<void> deletePlaylist(String id) async {
await owner.delete('playlists', where: 'id=?', whereArgs: [id]);
}
@override
Future<void> deletePlaylists(List<String> ids) =>
LibraryCollectionsDatabase.deleteDatabasePlaylists(owner, ids);
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class PlaylistDeletionCollections extends LibraryCollectionsNotifier {
PlaylistDeletionCollections(this._initial, Database db)
: super(database: PlaylistDeletionDatabase(db));
final LibraryCollectionsState _initial;
@override
LibraryCollectionsState build() => _initial;
}
const playlistDeletionTables = [
'playlists',
'playlist_tracks',
'wishlist_tracks',
'loved_tracks',
];
Future<void> createPlaylistDeletionSchema(Database db) async {
await db.execute('PRAGMA foreign_keys=ON');
await db.execute('PRAGMA recursive_triggers=ON');
await db.rawQuery('PRAGMA journal_mode=WAL');
await db.execute('PRAGMA synchronous=NORMAL');
await db.rawQuery('PRAGMA busy_timeout=5000');
await db.execute('''CREATE TABLE playlists(
id TEXT PRIMARY KEY, name TEXT NOT NULL, cover_image_path TEXT,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL)''');
await db.execute('''CREATE TABLE playlist_tracks(
playlist_id TEXT NOT NULL, track_key TEXT NOT NULL, track_json TEXT NOT NULL,
added_at TEXT NOT NULL, PRIMARY KEY(playlist_id, track_key),
FOREIGN KEY(playlist_id) REFERENCES playlists(id) ON DELETE CASCADE)''');
await db.execute(
'CREATE INDEX idx_playlists_created_at ON playlists(created_at DESC)',
);
await db.execute(
'CREATE INDEX idx_playlist_tracks_playlist_id ON playlist_tracks(playlist_id)',
);
for (final table in ['wishlist_tracks', 'loved_tracks']) {
await db.execute('''CREATE TABLE $table(track_key TEXT PRIMARY KEY,
track_json TEXT NOT NULL, added_at TEXT NOT NULL)''');
await db.execute(
'CREATE INDEX idx_${table}_added_at ON $table(added_at DESC)',
);
}
}
Future<LibraryCollectionsState> seedPlaylistDeletion(
Database db,
int count, {
int tracksPerPlaylist = 20,
}) async {
final now = DateTime.utc(2026);
final stamp = now.toIso8601String();
final entries = [
for (var i = 0; i < tracksPerPlaylist; i++)
CollectionTrackEntry(
key: 'example:$i',
addedAt: now,
track: Track(
id: '$i',
name: 'Track $i — 音楽',
artistName: 'Artist',
albumName: 'Album',
source: 'example',
duration: 180,
),
),
];
final playlists = [
for (final id in [
for (var i = 0; i < count; i++) 'playlist-$i',
"keep'quoted",
])
UserPlaylistCollection(
id: id,
name: 'Name $id',
createdAt: now,
updatedAt: now,
coverImagePath: 'cover-$id',
tracks: const [],
trackKeys: entries.map((entry) => entry.key).toSet(),
tracksLoaded: false,
),
];
await db.transaction((txn) async {
final batch = txn.batch();
for (final table in playlistDeletionTables) {
batch.delete(table);
}
for (final playlist in playlists) {
batch.insert('playlists', {
'id': playlist.id,
'name': playlist.name,
'cover_image_path': playlist.coverImagePath,
'created_at': stamp,
'updated_at': stamp,
});
for (final entry in entries) {
batch.insert('playlist_tracks', {
'playlist_id': playlist.id,
'track_key': entry.key,
'track_json': jsonEncode(entry.track.toJson()),
'added_at': stamp,
});
}
}
for (final table in ['wishlist_tracks', 'loved_tracks']) {
for (final entry in entries) {
batch.insert(table, {
'track_key': entry.key,
'track_json': jsonEncode(entry.track.toJson()),
'added_at': stamp,
});
}
}
await batch.commit(noResult: true);
});
return LibraryCollectionsState(
isLoaded: true,
playlists: playlists,
wishlist: entries,
loved: entries,
);
}
Future<Map<String, List<Map<String, Object?>>>> playlistDeletionSnapshot(
Database db,
) async => {
for (final table in playlistDeletionTables)
table: await db.rawQuery('SELECT rowid,* FROM $table ORDER BY rowid'),
};