| .. | .. |
|---|
| 88 | 88 | } |
|---|
| 89 | 89 | } |
|---|
| 90 | 90 | |
|---|
| 91 | + /// Session id, without decoding the message it belongs to. |
|---|
| 92 | + /// |
|---|
| 93 | + /// The index needs ONE short field per line. jsonDecode gave it by parsing |
|---|
| 94 | + /// the whole record — including an inline base64 attachment that can be tens |
|---|
| 95 | + /// of megabytes — so building the index cost the size of the entire history. |
|---|
| 96 | + /// With enough of it, startup exceeded the iOS watchdog and the app could |
|---|
| 97 | + /// never open again: 9.5 seconds of CPU, killed at 0x8BADF00D, on a store |
|---|
| 98 | + /// that had grown a few megabytes at a time for weeks. |
|---|
| 99 | + /// |
|---|
| 100 | + /// A line is written by us and always carries "sessionId":"…", so reading it |
|---|
| 101 | + /// is a scan, not a parse. |
|---|
| 102 | + static final RegExp _sessionIdPattern = RegExp(r'"sessionId"\s*:\s*"([^"]+)"'); |
|---|
| 103 | + |
|---|
| 104 | + static String? _sessionIdOf(String line) => |
|---|
| 105 | + _sessionIdPattern.firstMatch(line)?.group(1); |
|---|
| 106 | + |
|---|
| 91 | 107 | static Future<void> _rebuildIndex(File logFile) async { |
|---|
| 92 | 108 | _index.clear(); |
|---|
| 93 | 109 | final lines = logFile.readAsLinesSync(); |
|---|
| 94 | 110 | for (var i = 0; i < lines.length; i++) { |
|---|
| 95 | 111 | final line = lines[i].trim(); |
|---|
| 96 | 112 | if (line.isEmpty) continue; |
|---|
| 97 | | - try { |
|---|
| 98 | | - final map = jsonDecode(line) as Map<String, dynamic>; |
|---|
| 99 | | - final sessionId = map['sessionId'] as String?; |
|---|
| 100 | | - if (sessionId != null) { |
|---|
| 101 | | - _index.putIfAbsent(sessionId, () => []).add(i); |
|---|
| 102 | | - } |
|---|
| 103 | | - } catch (_) {} |
|---|
| 113 | + final sessionId = _sessionIdOf(line); |
|---|
| 114 | + if (sessionId != null) { |
|---|
| 115 | + _index.putIfAbsent(sessionId, () => []).add(i); |
|---|
| 116 | + } |
|---|
| 104 | 117 | } |
|---|
| 105 | 118 | } |
|---|
| 106 | 119 | |
|---|
| .. | .. |
|---|
| 189 | 202 | |
|---|
| 190 | 203 | // ------------------------------------------------------------- compact -- |
|---|
| 191 | 204 | |
|---|
| 205 | + /// Above this, compact regardless of how few lines there are. |
|---|
| 206 | + static const int _compactAboveBytes = 8 * 1024 * 1024; |
|---|
| 207 | + |
|---|
| 192 | 208 | /// Rewrite the log keeping at most [keepPerSession] messages per session. |
|---|
| 193 | 209 | /// Called once on startup after initialize(). NOT called during normal use. |
|---|
| 194 | 210 | static Future<void> compact({int keepPerSession = 200}) async { |
|---|
| .. | .. |
|---|
| 198 | 214 | if (!logFile.existsSync()) return; |
|---|
| 199 | 215 | |
|---|
| 200 | 216 | final allLines = logFile.readAsLinesSync(); |
|---|
| 201 | | - if (allLines.length < 500) return; // nothing worth compacting |
|---|
| 217 | + // Compact on SIZE as well as line count. A store is bounded at 200 |
|---|
| 218 | + // messages per session, which bounds nothing when a message carries an |
|---|
| 219 | + // inline attachment: 200 screenshots is tens of megabytes, and the count |
|---|
| 220 | + // never trips the threshold. Bytes are what startup has to read. |
|---|
| 221 | + final bytes = logFile.lengthSync(); |
|---|
| 222 | + if (allLines.length < 500 && bytes < _compactAboveBytes) return; |
|---|
| 202 | 223 | |
|---|
| 203 | 224 | // Build a set of line numbers to keep: last keepPerSession per session. |
|---|
| 204 | 225 | final keepLines = <int>{}; |
|---|
| .. | .. |
|---|
| 224 | 245 | final line = allLines[i].trim(); |
|---|
| 225 | 246 | if (line.isEmpty) continue; |
|---|
| 226 | 247 | buffer.write('$line\n'); |
|---|
| 227 | | - // Extract sessionId for new index. |
|---|
| 228 | | - try { |
|---|
| 229 | | - final map = jsonDecode(line) as Map<String, dynamic>; |
|---|
| 230 | | - final sid = map['sessionId'] as String?; |
|---|
| 231 | | - if (sid != null) { |
|---|
| 232 | | - newIndex.putIfAbsent(sid, () => []).add(newLine); |
|---|
| 233 | | - } |
|---|
| 234 | | - } catch (_) {} |
|---|
| 248 | + // Same scan as the index build, for the same reason: the only field |
|---|
| 249 | + // needed here is short, and decoding the record to reach it makes |
|---|
| 250 | + // compaction cost the size of the history it exists to bound. |
|---|
| 251 | + final sid = _sessionIdOf(line); |
|---|
| 252 | + if (sid != null) { |
|---|
| 253 | + newIndex.putIfAbsent(sid, () => []).add(newLine); |
|---|
| 254 | + } |
|---|
| 235 | 255 | newLine++; |
|---|
| 236 | 256 | } |
|---|
| 237 | 257 | |
|---|