Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 98 additions & 59 deletions lib/data/repositories/mostro_storage.dart
Original file line number Diff line number Diff line change
@@ -1,26 +1,97 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:mostro_mobile/services/logger_service.dart';
import 'package:mostro_mobile/data/models/payload.dart';
import 'package:sembast/sembast.dart';
import 'package:mostro_mobile/data/models/mostro_message.dart';
import 'package:mostro_mobile/data/repositories/base_storage.dart';

class MostroStorage extends BaseStorage<MostroMessage> {


MostroStorage({required Database db})
: super(db, stringMapStoreFactory.store('orders'));

/// In-memory index by order id (newest first). Every write used to wake
/// one Sembast query listener per OrderNotifier and per visible trade row,
/// each re-filtering the whole unindexed store on the UI isolate. The
/// index is warmed from disk once; watchers are served from memory and
/// demultiplexed per order.
final Map<String, List<MostroMessage>> _byOrder = {};
final StreamController<String> _orderChanges = StreamController.broadcast();
Future<void>? _warmup;

@visibleForTesting
int get debugIndexSize => _byOrder.length;

/// Order ids currently holding messages (index-backed).
Future<List<String>> allOrderIds() async {
await _ensureIndex();
return _byOrder.keys.toList(growable: false);
}

Future<void> _ensureIndex() {
return _warmup ??= () async {
final all = await getAll();
for (final message in all) {
_indexAdd(message, notify: false);
}
logger.i('Mostro message index warmed: ${_byOrder.length} orders');
}();
}

void _indexAdd(MostroMessage message, {bool notify = true}) {
final orderId = message.id;
if (orderId == null) return;
final list = _byOrder.putIfAbsent(orderId, () => <MostroMessage>[]);
list.add(message);
list.sort((a, b) => (b.timestamp ?? 0).compareTo(a.timestamp ?? 0));
if (notify) _notifyOrder(orderId);
}

void _notifyOrder(String orderId) {
if (!_orderChanges.isClosed) _orderChanges.add(orderId);
}

MostroMessage? _latestFor(String orderId) {
final list = _byOrder[orderId];
return (list == null || list.isEmpty) ? null : list.first;
}

List<MostroMessage> _historyFor(String orderId) =>
List.unmodifiable(_byOrder[orderId] ?? const <MostroMessage>[]);

Stream<R> _watchOrder<R>(String orderId, R Function() read) {
late StreamController<R> controller;
StreamSubscription<String>? changes;
controller = StreamController<R>(
onListen: () async {
await _ensureIndex();
if (controller.isClosed) return;
controller.add(read());
changes = _orderChanges.stream
.where((changed) => changed == orderId)
.listen((_) => controller.add(read()));
},
onCancel: () async {
await changes?.cancel();
await controller.close();
},
);
return controller.stream;
}

/// Save or update any MostroMessage
Future<void> addMessage(String key, MostroMessage message) async {
final id = key;
try {
await _ensureIndex();
if (await hasItem(id)) return;
// Add metadata for easier querying
final Map<String, dynamic> dbMap = message.toJson();
message.timestamp ??= DateTime.now().millisecondsSinceEpoch;
dbMap['timestamp'] = message.timestamp;

await store.record(id).put(db, dbMap);
_indexAdd(message);
logger.i(
'Saved message of type ${message.action} with order id ${message.id}',
);
Expand All @@ -47,7 +118,11 @@ class MostroStorage extends BaseStorage<MostroMessage> {
/// Delete all messages
Future<void> deleteAllMessages() async {
try {
await _ensureIndex();
await deleteAll();
final orderIds = _byOrder.keys.toList();
_byOrder.clear();
orderIds.forEach(_notifyOrder);
logger.i('All messages deleted');
} catch (e, stack) {
logger.e('deleteAllMessages failed', error: e, stackTrace: stack);
Expand All @@ -57,9 +132,12 @@ class MostroStorage extends BaseStorage<MostroMessage> {

/// Delete all messages by Id regardless of type
Future<void> deleteAllMessagesByOrderId(String orderId) async {
await _ensureIndex();
await deleteWhere(
Filter.equals('id', orderId),
);
_byOrder.remove(orderId);
_notifyOrder(orderId);
}

/// Filter messages by payload type
Expand Down Expand Up @@ -106,63 +184,30 @@ class MostroStorage extends BaseStorage<MostroMessage> {

/// Get the latest message for an order, regardless of type
Future<MostroMessage?> getLatestMessageById(String orderId) async {
final finder = Finder(
filter: Filter.equals('id', orderId),
sortOrders: _getDefaultSort(),
limit: 1,
);

final snapshot = await store.findFirst(db, finder: finder);
if (snapshot != null) {
return MostroMessage.fromJson(snapshot.value);
}
return null;
await _ensureIndex();
return _latestFor(orderId);
}

/// Stream of the latest message for an order
Stream<MostroMessage?> watchLatestMessage(String orderId) {
final query = store.query(
finder: Finder(
filter: Filter.equals('id', orderId),
sortOrders: _getDefaultSort(),
limit: 1,
),
);

return query.onSnapshots(db).map((snaps) =>
snaps.isEmpty ? null : MostroMessage.fromJson(snaps.first.value));
}
Stream<MostroMessage?> watchLatestMessage(String orderId) =>
_watchOrder(orderId, () => _latestFor(orderId));

/// Stream of the latest message for an order whose payload is of type T
Stream<MostroMessage?> watchLatestMessageOfType<T>(String orderId) {
// Watch all messages for the orderId, sorted by timestamp descending
final query = store.query(
finder: Finder(
filter: Filter.equals('id', orderId),
sortOrders: _getDefaultSort(),
),
);
return query.onSnapshots(db).map((snaps) {
for (final snap in snaps) {
final msg = MostroMessage.fromJson(snap.value);
if (msg.payload is T) {
return msg;
Stream<MostroMessage?> watchLatestMessageOfType<T>(String orderId) =>
_watchOrder(orderId, () {
for (final message in _byOrder[orderId] ?? const <MostroMessage>[]) {
if (message.payload is T) return message;
}
}
return null;
});
}
return null;
});

// Use the same sorting across all methods that return lists of messages
List<SortOrder> _getDefaultSort() => [SortOrder('timestamp', false, true)];
/// Stream of all messages for an order (newest first)
Stream<List<MostroMessage>> watchAllMessages(String orderId) =>
_watchOrder(orderId, () => _historyFor(orderId));

/// Stream of all messages for an order
Stream<List<MostroMessage>> watchAllMessages(String orderId) {
return watch(
filter: Filter.equals('id', orderId),
sort: _getDefaultSort(),
);
}
// Sorting for the remaining DB-backed query (request-id lookups are
// transient one-offs during order creation and stay on Sembast).
List<SortOrder> _getDefaultSort() => [SortOrder('timestamp', false, true)];

Stream<MostroMessage?> watchByRequestId(int requestId) {
final query = store.query(
Expand All @@ -178,13 +223,7 @@ class MostroStorage extends BaseStorage<MostroMessage> {
}

Future<List<MostroMessage>> getAllMessagesForOrderId(String orderId) async {
final finder = Finder(
filter: Filter.equals('id', orderId),
sortOrders: [SortOrder('timestamp', false)]);

final snapshots = await store.find(db, finder: finder);
return snapshots
.map((snapshot) => MostroMessage.fromJson(snapshot.value))
.toList();
await _ensureIndex();
return _historyFor(orderId);
}
}
123 changes: 123 additions & 0 deletions lib/services/storage_pruner.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
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 NotificationsStorage notificationsStorage;

/// Reservations only guard replay dedup within the subscription's since
/// window; anything older than the widest lookback is dead weight.
static const Duration reservationRetention = Duration(days: 7);

/// 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;

Future<void> prune({
required Set<String> liveOrderIds,
required Set<String> liveDisputeIds,
DateTime? now,
}) async {
final at = now ?? DateTime.now();
try {
await _pruneReservations(at);
await _pruneOrphanEvents(at, liveOrderIds, liveDisputeIds);
await _pruneOrphanMessages(at, liveOrderIds);
await _capNotifications();
} catch (e, stackTrace) {
logger.e('Storage pruning failed', error: e, stackTrace: stackTrace);
}
}

Future<void> _pruneReservations(DateTime now) async {
final cutoff =
now.subtract(reservationRetention).millisecondsSinceEpoch ~/ 1000;
final removed = await eventStorage.deleteWhere(Filter.and([
Filter.isNull('type'),
Filter.lessThan('created_at', cutoff),
]));
Comment on lines +77 to +80

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Retain dedup markers for unbounded order subscriptions

For sessions kept longer than seven days, this deletes the only durable marker used to suppress already-processed Mostro order events, even though buildOrdersFilter in lib/features/subscriptions/subscription_manager.dart:670-686 sets no since bound. On a later background-service restart or relay reconnection, the listener in lib/background/background.dart:321-332 therefore 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 👍 / 👎.

_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);
final ts = 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.getAll();
if (all.length <= notificationCap) return;
final sorted = [...all]
..sort((a, b) => b.timestamp.compareTo(a.timestamp));
final cutoff = sorted[notificationCap - 1].timestamp;
final removed = await notificationsStorage.deleteWhere(
Filter.custom((record) {
final value = record.value;
if (value is! Map) return false;
final raw = value['timestamp'];
if (raw is! String) return false;
final ts = DateTime.tryParse(raw);
return ts != null && ts.isBefore(cutoff);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Delete exact overflow entries when timestamps tie

When multiple notifications share the 300th entry's timestamp, the strict isBefore(cutoff) predicate retains every tied record, so the history remains above notificationCap; if a burst produces 320 identical timestamps, this removes nothing on every pruning pass. Delete the specific sorted entries beyond index 299, using their IDs or a deterministic timestamp-and-ID tie-breaker, so the cap is actually enforced.

Useful? React with 👍 / 👎.

}),
);
_logRemoved('old notifications', removed);
}

void _logRemoved(String what, Object? removed) {
logger.i('Storage pruner: removed $what ($removed)');
}
}
21 changes: 21 additions & 0 deletions lib/shared/notifiers/session_notifier.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:mostro_mobile/data/models/order.dart';
import 'package:mostro_mobile/features/settings/settings.dart';
import 'package:mostro_mobile/services/push_notification_service.dart';
import 'package:mostro_mobile/data/repositories/notifications_history_repository.dart';
import 'package:mostro_mobile/services/logger_service.dart';
import 'package:mostro_mobile/services/storage_pruner.dart';
import 'package:mostro_mobile/shared/utils/chat_keys.dart';
import 'package:mostro_mobile/shared/utils/nostr_utils.dart';
import 'package:dart_nostr/dart_nostr.dart';
Expand Down Expand Up @@ -162,6 +164,25 @@ class SessionNotifier extends StateNotifier<List<Session>> {
}

void _cleanup() async {
// Bounded-growth pruning runs regardless of the session retention policy:
// reservations, orphaned chat/message records and the notification cap
// are storage hygiene, not session lifetime.
try {
final pruner = StoragePruner(
eventStorage: ref.read(eventStorageProvider),
messageStorage: ref.read(mostroStorageProvider),
notificationsStorage:
ref.read(notificationsRepositoryProvider) as NotificationsStorage,
);
await pruner.prune(
liveOrderIds: _sessions.keys.toSet(),
liveDisputeIds:
_sessions.values.map((s) => s.disputeId).whereType<String>().toSet(),
);
} catch (e) {
logger.w('Storage pruning skipped: $e');
}

if (_isForever) return;

final cutoff = DateTime.now()
Expand Down
Loading