Skip to content

Commit 354a8ed

Browse files
Fix for Issue #186: Prevent JSON queue file growth over 512 KB (#188)
* Prevent queue file growth over 512 KB * Updated version and changelog
1 parent 4d685c7 commit 354a8ed

File tree

3 files changed

+279
-58
lines changed

3 files changed

+279
-58
lines changed

packages/core/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 1.1.11
2+
3+
- Introduces file size checks on analytics-flutter-queue_flushing_plugin.json file so that the SDK avoids queuing events that would push the on-disk queue file above 512 KB.
4+
15
## 1.1.10
26

37
- Migrating storage to proper application data directory

packages/core/lib/utils/store/io.dart

Lines changed: 274 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,30 @@ import 'package:path_provider/path_provider.dart';
1111
class StoreImpl with Store {
1212
final bool storageJson;
1313
late final Future<void> _migrationCompleted;
14-
14+
15+
// Queue size limits (bytes)
16+
static const int _kMaxQueueBytes = 512 * 1024; // 512 KB
17+
static const int _kTargetQueueBytes = 475 * 1024; // target after trimming 475 KB
18+
19+
// The fileKey used by the queue/flushing plugin in this repo.
20+
// The file produced will be analytics-flutter-queue_flushing_plugin.json
21+
static const String _kQueueFileKey = 'queue_flushing_plugin';
22+
23+
// The field name inside the JSON payload that holds events (adjusted per user's example)
24+
// The user's queue JSON uses a top-level "queue" array.
25+
static const String _kQueueField = 'queue';
26+
1527
StoreImpl({this.storageJson = true}) {
1628
// Start migration immediately but don't block construction
1729
_migrationCompleted = _migrateFilesFromDocumentsToSupport();
1830
}
31+
1932
@override
2033
Future get ready => Future.value();
2134

35+
@override
36+
void dispose() {}
37+
2238
@override
2339
Future<Map<String, dynamic>?> getPersisted(String key) async {
2440
if (!storageJson) return Future.value(null);
@@ -34,7 +50,7 @@ class StoreImpl with Store {
3450
await _migrationCompleted;
3551
return _writeFile(key, value);
3652
}
37-
53+
3854
@override
3955
Future deletePersisted(String key) async {
4056
if (!storageJson) return;
@@ -45,37 +61,219 @@ class StoreImpl with Store {
4561
}
4662

4763
Future _writeFile(String fileKey, Map<String, dynamic> data) async {
48-
RandomAccessFile file =
49-
await _getFile(fileKey, create: true) as RandomAccessFile;
50-
final serialized = json.encode(data);
51-
final buffer = utf8.encode(serialized);
52-
53-
file.lockSync(FileLock.blockingExclusive);
54-
file.setPositionSync(0);
55-
file.writeFromSync(buffer);
56-
file.truncateSync(buffer.length);
57-
file.unlockSync();
58-
file.closeSync();
64+
// Serialize once; may be replaced by trimmed version below
65+
String serialized = json.encode(data);
66+
List<int> buffer = utf8.encode(serialized);
67+
68+
// If this is the queue file, enforce size limits by trimming oldest events
69+
if (fileKey == _kQueueFileKey) {
70+
// Ensure we operate against the configured queue field name.
71+
final currentEvents = <dynamic>[];
72+
if (data[_kQueueField] is List) {
73+
currentEvents.addAll(data[_kQueueField] as List);
74+
}
75+
76+
// If the serialized payload is already too large, attempt trim before writing
77+
if (buffer.length > _kMaxQueueBytes) {
78+
final trimmed = _trimQueueToTargetMap(data, _kTargetQueueBytes, _kQueueField);
79+
serialized = json.encode(trimmed);
80+
buffer = utf8.encode(serialized);
81+
82+
// If still too big after trimming to target, write empty queue as last resort
83+
if (buffer.length > _kMaxQueueBytes) {
84+
serialized = json.encode({_kQueueField: []});
85+
buffer = utf8.encode(serialized);
86+
}
87+
}
88+
}
89+
90+
RandomAccessFile? file;
91+
try {
92+
file = await _getFile(fileKey, create: true) as RandomAccessFile;
93+
// Acquire exclusive lock and write
94+
file.lockSync(FileLock.blockingExclusive);
95+
try {
96+
file.setPositionSync(0);
97+
file.writeFromSync(buffer);
98+
file.truncateSync(buffer.length);
99+
} finally {
100+
// Ensure unlock and close
101+
try {
102+
file.unlockSync();
103+
} catch (_) {}
104+
try {
105+
file.closeSync();
106+
} catch (_) {}
107+
}
108+
return;
109+
} on FileSystemException catch (_) {
110+
// Recovery path for disk full / write errors for queue file
111+
if (fileKey != _kQueueFileKey) {
112+
rethrow;
113+
}
114+
115+
// Try reading existing stored events and aggressively trim until we can write
116+
try {
117+
final existing = await _readFile(fileKey);
118+
List<dynamic> events =
119+
(existing != null && existing[_kQueueField] is List)
120+
? List<dynamic>.from(existing[_kQueueField] as List)
121+
: <dynamic>[];
122+
123+
// Try progressively trimming and writing, removing oldest events first
124+
while (true) {
125+
// Build candidate payload
126+
final candidateMap = {_kQueueField: events};
127+
final candidateText = json.encode(candidateMap);
128+
final candidateBuffer = utf8.encode(candidateText);
129+
130+
if (candidateBuffer.length <= _kMaxQueueBytes) {
131+
// Attempt to write this candidate
132+
RandomAccessFile? f;
133+
try {
134+
f = await _getFile(fileKey, create: true) as RandomAccessFile;
135+
f.lockSync(FileLock.blockingExclusive);
136+
try {
137+
f.setPositionSync(0);
138+
f.writeFromSync(candidateBuffer);
139+
f.truncateSync(candidateBuffer.length);
140+
// success
141+
try {
142+
f.unlockSync();
143+
} catch (_) {}
144+
try {
145+
f.closeSync();
146+
} catch (_) {}
147+
return;
148+
} finally {
149+
// Ensure unlock/close in case of failures inside try
150+
try {
151+
f.unlockSync();
152+
} catch (_) {}
153+
try {
154+
f.closeSync();
155+
} catch (_) {}
156+
}
157+
} on FileSystemException {
158+
// Couldn't write; fall through to trimming more events
159+
}
160+
}
161+
162+
// If no events left, try to write an empty queue
163+
if (events.isEmpty) {
164+
final emptyBuf = utf8.encode(json.encode({_kQueueField: []}));
165+
try {
166+
RandomAccessFile? f2 =
167+
await _getFile(fileKey, create: true) as RandomAccessFile;
168+
f2.lockSync(FileLock.blockingExclusive);
169+
try {
170+
f2.setPositionSync(0);
171+
f2.writeFromSync(emptyBuf);
172+
f2.truncateSync(emptyBuf.length);
173+
try {
174+
f2.unlockSync();
175+
} catch (_) {}
176+
try {
177+
f2.closeSync();
178+
} catch (_) {}
179+
return;
180+
} finally {
181+
try {
182+
f2.unlockSync();
183+
} catch (_) {}
184+
try {
185+
f2.closeSync();
186+
} catch (_) {}
187+
}
188+
} on FileSystemException {
189+
// rethrow original error
190+
rethrow;
191+
}
192+
}
193+
194+
// Remove the oldest event and try again
195+
events.removeAt(0);
196+
}
197+
} catch (e) {
198+
// If recovery fails, rethrow the original exception
199+
rethrow;
200+
}
201+
} finally {
202+
// Ensure any opened file is closed/unlocked (defensive)
203+
try {
204+
// Use null-aware calls to avoid calling on null.
205+
file?.unlockSync();
206+
} catch (_) {}
207+
try {
208+
file?.closeSync();
209+
} catch (_) {}
210+
}
211+
}
212+
213+
/// Trim the queue in [data] (expected to be a map containing an array under [queueField])
214+
/// until the serialized size is <= targetBytes. Returns a new Map containing
215+
/// the trimmed queue list.
216+
Map<String, dynamic> _trimQueueToTargetMap(
217+
Map<String, dynamic> data, int targetBytes, String queueField) {
218+
final events = <dynamic>[];
219+
if (data[queueField] is List) {
220+
events.addAll(data[queueField] as List);
221+
}
222+
223+
// If no events or not a list, return minimal representation
224+
if (events.isEmpty) {
225+
return {queueField: []};
226+
}
227+
228+
// Fast path: if current serialized size is already small enough, return input
229+
var candidate = {queueField: events};
230+
var s = json.encode(candidate);
231+
var b = utf8.encode(s);
232+
if (b.length <= targetBytes) {
233+
return candidate;
234+
}
235+
236+
// Iteratively remove oldest events until serialized size <= targetBytes or no events left
237+
while (b.length > targetBytes && events.isNotEmpty) {
238+
events.removeAt(0);
239+
candidate = {queueField: events};
240+
s = json.encode(candidate);
241+
b = utf8.encode(s);
242+
}
243+
244+
return {queueField: events};
59245
}
60246

61247
Future<Map<String, dynamic>?> _readFile(String fileKey) async {
62248
RandomAccessFile? file = await _getFile(fileKey);
63249
if (file == null) {
64250
return null;
65251
}
66-
file = await file.lock(FileLock.blockingShared);
67-
final length = file.lengthSync();
68-
file.setPositionSync(0);
69-
final buffer = Uint8List(length);
70-
file.readIntoSync(buffer);
71-
file.unlockSync();
72-
file.closeSync();
73-
final contentText = utf8.decode(buffer);
74-
if (contentText == "{}") {
75-
return null; // Prefer null to empty map, because we'll want to initialise a valid empty value.
76-
}
77252

78-
return json.decode(contentText) as Map<String, dynamic>;
253+
try {
254+
file = await file.lock(FileLock.blockingShared);
255+
final length = file.lengthSync();
256+
file.setPositionSync(0);
257+
final buffer = Uint8List(length);
258+
file.readIntoSync(buffer);
259+
file.unlockSync();
260+
file.closeSync();
261+
final contentText = utf8.decode(buffer);
262+
if (contentText == "{}") {
263+
return null; // empty file
264+
}
265+
266+
return json.decode(contentText) as Map<String, dynamic>;
267+
} on FileSystemException {
268+
// Can't read the file -> return null for safety
269+
try {
270+
file?.unlockSync();
271+
} catch (_) {}
272+
try {
273+
file?.closeSync();
274+
} catch (_) {}
275+
return null;
276+
}
79277
}
80278

81279
Future<String> _fileName(String fileKey) async {
@@ -88,6 +286,7 @@ class StoreImpl with Store {
88286
final file = File(await _fileName(fileKey));
89287

90288
if (await file.exists()) {
289+
// Open in append mode so we can lock and then write/truncate
91290
return await file.open(mode: FileMode.append);
92291
} else if (create) {
93292
await file.create(recursive: true);
@@ -113,44 +312,62 @@ class StoreImpl with Store {
113312
}
114313
}
115314

116-
/// Migrates existing analytics files from Documents directory to Application Support directory
315+
/// Move any analytics-flutter-*.json files from Documents (old location) to
316+
/// ApplicationSupport (new location). This migration is best-effort and will
317+
/// ignore errors so SDK initialization can proceed.
117318
Future<void> _migrateFilesFromDocumentsToSupport() async {
319+
if (!storageJson) return;
118320
try {
119321
final oldDir = await _getOldDocumentDir();
120322
final newDir = await _getNewDocumentDir();
121-
122-
// List all analytics files in the old directory
123-
final oldDirFiles = oldDir.listSync()
124-
.whereType<File>()
125-
.where((file) => file.path.contains('analytics-flutter-') && file.path.endsWith('.json'))
126-
.toList();
127-
128-
for (final oldFile in oldDirFiles) {
129-
final fileName = oldFile.path.split('/').last;
130-
final newFilePath = '${newDir.path}/$fileName';
131-
final newFile = File(newFilePath);
132-
133-
// Only migrate if the file doesn't already exist in the new location
134-
if (!await newFile.exists()) {
135-
try {
136-
// Ensure the new directory exists
137-
await newDir.create(recursive: true);
138-
139-
// Copy the file to the new location
140-
await oldFile.copy(newFilePath);
141-
142-
// Delete the old file after successful copy
143-
await oldFile.delete();
144-
} catch (e) {
145-
// The app should continue to work even if migration fails
323+
324+
// If same path, nothing to do
325+
if (oldDir.path == newDir.path) return;
326+
327+
// Ensure new dir exists
328+
try {
329+
if (!await Directory(newDir.path).exists()) {
330+
await Directory(newDir.path).create(recursive: true);
331+
}
332+
} catch (_) {}
333+
334+
// List files in old directory and move ones that match analytics-flutter-*.json
335+
final oldDirectory = Directory(oldDir.path);
336+
if (!await oldDirectory.exists()) return;
337+
338+
await for (final entity in oldDirectory.list()) {
339+
if (entity is File) {
340+
final name = entity.uri.pathSegments.isNotEmpty
341+
? entity.uri.pathSegments.last
342+
: '';
343+
if (name.startsWith('analytics-flutter-') &&
344+
name.endsWith('.json')) {
345+
final destPath = '${newDir.path}/$name';
346+
final destFile = File(destPath);
347+
348+
try {
349+
// If destination already exists, skip or optionally merge - we skip here.
350+
if (!await destFile.exists()) {
351+
await entity.rename(destPath);
352+
} else {
353+
// If a file already exists at the destination, attempt to remove the old file.
354+
try {
355+
await entity.delete();
356+
} catch (_) {}
357+
}
358+
} catch (_) {
359+
// Try fallback: copy then delete
360+
try {
361+
await entity.copy(destPath);
362+
await entity.delete();
363+
} catch (_) {
364+
}
365+
}
146366
}
147367
}
148368
}
149-
} catch (e) {
150-
// Migration failure shouldn't break the app
369+
} catch (_) {
370+
return;
151371
}
152372
}
153-
154-
@override
155-
void dispose() {}
156-
}
373+
}

packages/core/lib/version.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
const segmentVersion = "1.1.10";
1+
const segmentVersion = "1.1.11";

0 commit comments

Comments
 (0)