You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
90 lines
3.0 KiB
90 lines
3.0 KiB
import '../utils/app_logger.dart';
|
|
import 'reaction_helper.dart';
|
|
|
|
class _Pending {
|
|
final String scopeKey;
|
|
final ReactionInfo info;
|
|
final String reactingSender;
|
|
final DateTime queuedAt;
|
|
|
|
const _Pending(this.scopeKey, this.info, this.reactingSender, this.queuedAt);
|
|
}
|
|
|
|
/// Reactions that arrived before the message they point at.
|
|
///
|
|
/// Out-of-order delivery is normal on a mesh, so a reaction with no local
|
|
/// target is not junk, it is early. Before this existed the connector consumed
|
|
/// such a reaction and dropped it with no badge, no message and no log, which
|
|
/// is indistinguishable from nobody having reacted (GH #382).
|
|
///
|
|
/// Entries are bounded and expire. A drop is logged at warn so it surfaces in
|
|
/// the in-app log and the file log rather than vanishing (SAFELANE 6). Whether
|
|
/// dropping is the right end state is still open; the ratio of late matches to
|
|
/// expiries in the log is the evidence for revisiting it.
|
|
class PendingReactions {
|
|
static const int maxEntries = 50;
|
|
static const Duration ttl = Duration(minutes: 15);
|
|
|
|
final List<_Pending> _entries = [];
|
|
|
|
int get length => _entries.length;
|
|
|
|
void add(
|
|
String scopeKey,
|
|
ReactionInfo info,
|
|
String reactingSender,
|
|
DateTime now,
|
|
) {
|
|
expire(now);
|
|
_entries.add(_Pending(scopeKey, info, reactingSender, now));
|
|
if (_entries.length > maxEntries) {
|
|
_drop(_entries.removeAt(0), now, 'queue full');
|
|
}
|
|
appLogger.info(
|
|
'Queued reaction ${info.emoji} from $reactingSender for unseen target '
|
|
'${info.targetHash} in $scopeKey (${_entries.length}/$maxEntries pending)',
|
|
tag: 'Reactions',
|
|
);
|
|
}
|
|
|
|
/// Re-attempt every live entry for [scopeKey]. [apply] receives the queued
|
|
/// reaction and the name of whoever sent it, and reports whether it found its
|
|
/// target; matched entries are removed, the rest stay.
|
|
void retry(
|
|
String scopeKey,
|
|
bool Function(ReactionInfo info, String reactingSender) apply,
|
|
DateTime now,
|
|
) {
|
|
expire(now);
|
|
_entries.removeWhere((entry) {
|
|
if (entry.scopeKey != scopeKey) return false;
|
|
if (!apply(entry.info, entry.reactingSender)) return false;
|
|
appLogger.info(
|
|
'Late-matched reaction ${entry.info.emoji} from ${entry.reactingSender} '
|
|
'to ${entry.info.targetHash} in $scopeKey after '
|
|
'${now.difference(entry.queuedAt).inSeconds}s',
|
|
tag: 'Reactions',
|
|
);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
void expire(DateTime now) {
|
|
_entries.removeWhere((entry) {
|
|
if (now.difference(entry.queuedAt) < ttl) return false;
|
|
_drop(entry, now, 'expired after ${ttl.inMinutes}m');
|
|
return true;
|
|
});
|
|
}
|
|
|
|
void _drop(_Pending entry, DateTime now, String why) {
|
|
appLogger.warn(
|
|
'Dropping unmatched reaction ${entry.info.emoji} from '
|
|
'${entry.reactingSender} for target ${entry.info.targetHash} in '
|
|
'${entry.scopeKey} ($why, age ${now.difference(entry.queuedAt).inSeconds}s). '
|
|
'The message it points at was never seen locally.',
|
|
tag: 'Reactions',
|
|
);
|
|
}
|
|
}
|