fix(#24): evict lowest-scored path when contact history is full

PathHistoryService capped each contact's recentPaths at 100 but dropped any new distinct path once full, so the list froze on the first 100 ever seen and never refreshed with newer/better routes. Evict the lowest-_scorePathRecord entry to make room, keeping the strongest 100. Adds a regression test (full history evicts the weakest to admit a new path).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pull/56/head
Strycher 1 month ago
parent efab80c5d0
commit 79b1780648

@ -341,7 +341,10 @@ class PathHistoryService extends ChangeNotifier {
updatedPaths.removeWhere((p) => _pathsEqual(p.pathBytes, pathBytes));
if (existing == null && updatedPaths.length >= _maxHistoryEntries) {
return;
// History is full: evict the lowest-scored path to make room rather than
// dropping the newcomer, so the list keeps refreshing with better routes
// instead of freezing on the first _maxHistoryEntries ever seen. See #24.
_evictLowestScoredPath(updatedPaths);
}
updatedPaths.insert(0, newRecord);
@ -359,6 +362,29 @@ class PathHistoryService extends ChangeNotifier {
notifyListeners();
}
/// Remove the single lowest-scored path from [paths] in place, using the same
/// ranking as path selection so the strongest routes are the ones kept when
/// the history is at capacity.
void _evictLowestScoredPath(List<PathRecord> paths) {
if (paths.isEmpty) return;
final fastestTripMs = _getFastestKnownTripMs(paths);
final highestRouteWeight = _getHighestKnownRouteWeight(paths);
var worstIndex = 0;
var worstScore = double.infinity;
for (var i = 0; i < paths.length; i++) {
final score = _scorePathRecord(
paths[i],
fastestTripMs: fastestTripMs,
highestRouteWeight: highestRouteWeight,
);
if (score < worstScore) {
worstScore = score;
worstIndex = i;
}
}
paths.removeAt(worstIndex);
}
List<PathRecord> getRecentPaths(String contactPubKeyHex) {
final history = _cache[contactPubKeyHex];
if (history != null) {

@ -820,4 +820,69 @@ void main() {
expect(paths.first.routeWeight, closeTo(1.5, 0.001));
});
});
group('history capacity', () {
test('full history evicts the weakest path to admit a new one', () async {
final pubKey = _hex('cap1');
// Seed a full history (100 paths): one clearly-weakest, 99 strong.
final seeded = <PathRecord>[
PathRecord(
hopCount: 1,
tripTimeMs: 0,
timestamp: null,
wasFloodDiscovery: false,
pathBytes: const [0xC8],
successCount: 0,
failureCount: 5,
routeWeight: 0.1,
),
];
for (var i = 1; i <= 99; i++) {
seeded.add(
PathRecord(
hopCount: 1,
tripTimeMs: 100,
timestamp: DateTime.now(),
wasFloodDiscovery: false,
pathBytes: [i],
successCount: 5,
failureCount: 0,
routeWeight: 3.0,
),
);
}
storage._store[pubKey] = ContactPathHistory(
contactPubKeyHex: pubKey,
recentPaths: seeded,
);
svc.getRecentPaths(pubKey); // trigger async load into cache
await _flush();
expect(svc.getRecentPaths(pubKey).length, equals(100));
// A new distinct path arrives while the history is full.
svc.handlePathUpdated(
_makeContact(publicKeyHex: pubKey, pathLength: 1, path: [0xFF]),
);
await _flush();
final result = svc.getRecentPaths(pubKey);
expect(result.length, equals(100), reason: 'cap stays at 100');
expect(
result.any((p) => p.pathBytes.length == 1 && p.pathBytes[0] == 0xFF),
isTrue,
reason: 'the new path must be admitted',
);
expect(
result.any((p) => p.pathBytes.length == 1 && p.pathBytes[0] == 0xC8),
isFalse,
reason: 'the weakest path must be evicted to make room',
);
expect(
result.where((p) => p.routeWeight == 3.0).length,
equals(99),
reason: 'the 99 strong paths are retained',
);
});
});
}

Loading…
Cancel
Save

Powered by TurnKey Linux.