-
Notifications
You must be signed in to change notification settings - Fork 29
perf: prune unbounded local storage growth #716
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 5 commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
11eb523
perf: serve mostro message queries from a single in-memory index
grunch 166fc5b
perf: prune unbounded local storage growth
grunch 6f564bf
Merge branch 'main' into perf/single-store-watcher
grunch aa83b7a
Merge branch 'perf/single-store-watcher' into perf/storage-pruning
grunch 1931064
fix: clear the message index on full wipes and recover from a failed …
grunch 5767aa9
fix: correct pruning cutoffs and rate-limit the storage pruner
grunch 7261d41
fix: surface a failed index warm-up to watchers and keep deletions on…
grunch 82ec380
Merge branch 'main' into perf/storage-pruning
grunch 7be9e62
fix: pick the newest orphaned message on normalized timestamps
grunch a489723
Merge remote-tracking branch 'origin/perf/single-store-watcher' into …
grunch 5ee6f03
Revert "Merge remote-tracking branch 'origin/perf/single-store-watche…
grunch 4e1956b
chore: restore pubspec.lock from main
grunch File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,150 @@ | ||
| import 'package:mostro_mobile/data/repositories/event_storage.dart'; | ||
| import 'package:mostro_mobile/data/repositories/mostro_storage.dart'; | ||
| import 'package:mostro_mobile/data/repositories/notifications_history_repository.dart'; | ||
| import 'package:mostro_mobile/services/logger_service.dart'; | ||
| import 'package:sembast/sembast.dart'; | ||
|
|
||
| /// Bounded growth for the local databases. | ||
| /// | ||
| /// Both Sembast files are fully loaded into RAM and JSON-parsed at every | ||
| /// launch, so unbounded growth taxes every cold start and every store scan. | ||
| /// Three families grew forever: DM reservation records ({id, created_at}, | ||
| /// no order_id — unreachable by the session cleanup), chat/message records | ||
| /// for orders whose session is gone, and the notification history. In | ||
| /// "keep forever" session mode nothing pruned at all. | ||
| class StoragePruner { | ||
| StoragePruner({ | ||
| required this.eventStorage, | ||
| required this.messageStorage, | ||
| required this.notificationsStorage, | ||
| }); | ||
|
|
||
| final EventStorage eventStorage; | ||
| final MostroStorage messageStorage; | ||
| final NotificationsRepository notificationsStorage; | ||
|
|
||
| /// Floor for reservation retention. It is only a floor: the orders filter | ||
| /// carries no `since`, so a relay may re-deliver any event a live session's | ||
| /// trade key matches, and the reservation is the only dedup for Mostro DMs. | ||
| /// An event for a live session cannot predate that session, so the real | ||
| /// cutoff is the older of this window and the oldest live session's start. | ||
| static const Duration reservationRetention = Duration(days: 7); | ||
|
|
||
| /// A full scan of both stores on the main isolate is not worth paying at | ||
| /// every session-cleanup tick; storage growth is a slow process. | ||
| static const Duration minimumInterval = Duration(hours: 6); | ||
|
|
||
| /// Orphaned records (no live session) get a grace window before deletion | ||
| /// so a restore in progress is never raced. | ||
| static const Duration orphanRetention = Duration(days: 30); | ||
|
|
||
| static const int notificationCap = 300; | ||
|
|
||
| /// Timestamps below this are seconds, above are milliseconds (the boundary | ||
| /// sits in the year 33658 for seconds and 2001 for milliseconds). | ||
| static const int _millisecondThreshold = 1000000000000; | ||
|
|
||
| DateTime? _lastRunAt; | ||
|
|
||
| /// Errors propagate: a pruner that silently stopped working would keep the | ||
| /// growth it exists to bound. | ||
| Future<void> prune({ | ||
| required Set<String> liveOrderIds, | ||
| required Set<String> liveDisputeIds, | ||
| DateTime? oldestLiveSessionAt, | ||
| DateTime? now, | ||
| }) async { | ||
| final at = now ?? DateTime.now(); | ||
| final last = _lastRunAt; | ||
| if (last != null && at.difference(last) < minimumInterval) return; | ||
| _lastRunAt = at; | ||
| await _pruneReservations(at, oldestLiveSessionAt); | ||
| await _pruneOrphanEvents(at, liveOrderIds, liveDisputeIds); | ||
| await _pruneOrphanMessages(at, liveOrderIds); | ||
| await _capNotifications(); | ||
| } | ||
|
|
||
| Future<void> _pruneReservations( | ||
| DateTime now, | ||
| DateTime? oldestLiveSessionAt, | ||
| ) async { | ||
| var cutoffAt = now.subtract(reservationRetention); | ||
| if (oldestLiveSessionAt != null && | ||
| oldestLiveSessionAt.isBefore(cutoffAt)) { | ||
| // Keep every reservation a live session could still see replayed. | ||
| cutoffAt = oldestLiveSessionAt; | ||
| } | ||
| final cutoff = cutoffAt.millisecondsSinceEpoch ~/ 1000; | ||
| final removed = await eventStorage.deleteWhere(Filter.and([ | ||
| Filter.isNull('type'), | ||
| Filter.lessThan('created_at', cutoff), | ||
| ])); | ||
| _logRemoved('DM reservations', removed); | ||
| } | ||
|
|
||
| Future<void> _pruneOrphanEvents( | ||
| DateTime now, | ||
| Set<String> liveOrderIds, | ||
| Set<String> liveDisputeIds, | ||
| ) async { | ||
| final cutoff = | ||
| now.subtract(orphanRetention).millisecondsSinceEpoch ~/ 1000; | ||
| final removed = await eventStorage.deleteWhere(Filter.custom((record) { | ||
| final value = record.value; | ||
| if (value is! Map) return false; | ||
| final type = value['type']; | ||
| if (type != 'chat' && type != 'dispute_chat') return false; | ||
| final createdAt = value['created_at']; | ||
| if (createdAt is! int || createdAt >= cutoff) return false; | ||
| if (type == 'chat') { | ||
| return !liveOrderIds.contains(value['order_id']); | ||
| } | ||
| return !liveDisputeIds.contains(value['dispute_id']); | ||
| })); | ||
| _logRemoved('orphaned chat events', removed); | ||
| } | ||
|
|
||
| Future<void> _pruneOrphanMessages( | ||
| DateTime now, | ||
| Set<String> liveOrderIds, | ||
| ) async { | ||
| final cutoffMs = now.subtract(orphanRetention).millisecondsSinceEpoch; | ||
| // Through the storage API so the in-memory index stays coherent. | ||
| for (final orderId in await messageStorage.allOrderIds()) { | ||
| if (liveOrderIds.contains(orderId)) continue; | ||
| final latest = await messageStorage.getLatestMessageById(orderId); | ||
| // The daemon sends seconds, the app fills in milliseconds when the | ||
| // field is absent, and both units coexist in the store. An unknown or | ||
| // non-positive timestamp is never treated as proof of age. | ||
| final ts = _timestampMs(latest?.timestamp); | ||
| if (ts != null && ts < cutoffMs) { | ||
| await messageStorage.deleteAllMessagesByOrderId(orderId); | ||
| logger.i('Pruned orphaned messages for order $orderId'); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| Future<void> _capNotifications() async { | ||
| final all = await notificationsStorage.getAllNotifications(); | ||
| if (all.length <= notificationCap) return; | ||
| // Deleting the exact overflow entries rather than everything older than | ||
| // the cap-th timestamp: a burst of tied timestamps would otherwise leave | ||
| // the history above the cap on every pass. | ||
| final sorted = [...all]..sort((a, b) { | ||
| final byTime = b.timestamp.compareTo(a.timestamp); | ||
| return byTime != 0 ? byTime : b.id.compareTo(a.id); | ||
| }); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| final overflow = sorted.skip(notificationCap).map((n) => n.id); | ||
| final removed = await notificationsStorage.deleteByIds(overflow); | ||
| _logRemoved('old notifications', removed); | ||
| } | ||
|
|
||
| static int? _timestampMs(int? raw) { | ||
| if (raw == null || raw <= 0) return null; | ||
| return raw < _millisecondThreshold ? raw * 1000 : raw; | ||
| } | ||
|
|
||
| void _logRemoved(String what, Object? removed) { | ||
| logger.i('Storage pruner: removed $what ($removed)'); | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For sessions kept longer than seven days, this deletes the only durable marker used to suppress already-processed Mostro order events, even though
buildOrdersFilterinlib/features/subscriptions/subscription_manager.dart:670-686sets nosincebound. On a later background-service restart or relay reconnection, the listener inlib/background/background.dart:321-332therefore accepts the historical event again and can show old trade notifications as new; forever-retained sessions can repeat this indefinitely. Either retain these markers while their session is live or bound the order subscription before expiring them.Useful? React with 👍 / 👎.