Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
16 changes: 10 additions & 6 deletions lib/src/bones_api_entity_db_object_directory.dart
Original file line number Diff line number Diff line change
Expand Up @@ -749,14 +749,18 @@ class DBObjectDirectoryAdapter
return _finishOperation(op, id, preFinish);
}

Future<void> _saveObject(
String table,
Object? id,
Map<String, dynamic> obj,
) async {
/// Writes synchronously, and must stay synchronous.
///
/// Every reader in this adapter checks the filesystem synchronously
/// ([Directory.listSync], [File.existsSync]), so an asynchronous write would
/// let a store return before its object is visible: a `store` immediately
/// followed by a `selectAll`/`selectByID` could miss it, and
/// [_doSelectAllImpl] would silently drop it (a not-yet-written file reads
/// back as `null`, which `resolveAllNotNull` discards).
void _saveObject(String table, Object? id, Map<String, dynamic> obj) {
var file = _resolveObjectFile(table, id);
var enc = dart_convert.json.encode(obj);
await file.writeAsString(enc);
file.writeAsStringSync(enc);
}

Future<Map<String, dynamic>?> _readObject(String table, Object? id) async {
Expand Down
76 changes: 75 additions & 1 deletion test/bones_api_entity_db_directory_test.dart
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
@TestOn('vm')
@Tags(['entities'])
@Timeout(Duration(seconds: 30))
@Timeout(Duration(seconds: 60))
import 'dart:io';
import 'dart:typed_data';

import 'package:bones_api/bones_api_db_directory.dart';
import 'package:bones_api/bones_api_test.dart';
import 'package:test/test.dart';

import 'bones_api_entity_db_tests_base.dart';
import 'bones_api_test_entities.dart';

class MemoryTestConfig extends APITestConfigDBSQLMemory {
MemoryTestConfig()
Expand All @@ -23,6 +25,78 @@ Future<void> main() async {
await _runTest(false, false);
await _runTest(true, true);
await _runTest(false, true);

_runStoreVisibilityTest();
}

/// REGRESSION: `DBObjectDirectoryAdapter._saveObject` used to be `async`, and
/// `doInsert`/`doUpdate` dropped its `Future`. Since every reader in that
/// adapter inspects the filesystem synchronously, a `store` could return
/// before its object was on disk — and `selectAll` would then *silently omit*
/// it, because a not-yet-written file reads back as `null` and is discarded by
/// `resolveAllNotNull`.
///
/// That is what made `Pagination [objectAdapter]` flaky on CI: entries went
/// missing from the result, and which ones varied per run. Confirmed by adding
/// a 30ms delay before the (un-awaited) write, which reproduces that failure
/// exactly.
///
/// Note this test only *fails* where the write is slow enough to lose the
/// race — it does not on a fast local disk. It is kept as a cheap statement of
/// the invariant; `Pagination [objectAdapter]` remains the sensitive guard.
void _runStoreVisibilityTest() {
group('DBObjectDirectoryAdapter', () {
test('a stored object is immediately visible', () async {
var tempDir = Directory.systemTemp.createTempSync(
'bones_api_tests_object_dir_visibility',
);

var provider = createEntityRepositoryProvider2(
true,
(p, dbPort, dbConfig) =>
DBObjectDirectoryAdapter(tempDir, parentRepositoryProvider: p),
0,
null,
);

addTearDown(() {
provider.close();
try {
tempDir.deleteSync(recursive: true);
} catch (_) {}
});

await provider.ensureInitialized();

var photoRepo = provider.photoAPIRepository;

// Big enough to give a slow filesystem a chance to lose the race:
var data = Uint8List(512 * 1024);

var ids = <String>[];

for (var i = 1; i <= 4; ++i) {
var id = 'PG-SYNC-0$i';
ids.add(id);

expect(await photoRepo.store(Photo.fromData(data, id: id)), equals(id));

expect(
await photoRepo.selectByID(id),
isNotNull,
reason: '`$id` not readable right after `store`',
);
}

var all = await photoRepo.selectAll();

expect(
all.map((e) => e.id).where(ids.contains).toList()..sort(),
equals(ids),
reason: '`selectAll` dropped a stored object',
);
});
});
}

Future<bool> _runTest(bool useReflection, bool populateSource) {
Expand Down