writer: detect overwrite conflict at start for back-channel transports

Previously an existing output file was only detected at the final rename,
after the whole collection had run. Add an up-front check in the FileWriter
constructor that fails before acquisition arms.

Only the master file is checked, and only by the single writer that owns it
(write_master_file). In a multi-writer TCP/ZMQ setup the per-image data files
are staggered across writers by file number, and a writer does not even know
the total writer count, so it cannot enumerate its own subset; checking all
data files in every writer would make each writer stat files it never writes
and race the writers already creating them. Data-file conflicts stay caught by
their owning writer at the final rename.

Gate the check on whether the transport can report the failure to the broker:
the direct HDF5 pusher throws in-process and the TCP writer returns a
START-failure ACK, so both abort the collection cleanly (verified: an asymmetric
rejection cancels the already-started sibling). The ZeroMQ path is fire-and-forget
with no back-channel, so StreamWriter opts out via image_puller.SupportsAck() and
keeps writing .tmp files, failing only at the final rename (leaving the images on
disk). Documented in docs/JFJOCH_WRITER.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 15:34:37 +02:00
co-authored by Claude Fable 5
parent 36be60774f
commit ad73b4af99
6 changed files with 199 additions and 18 deletions
+88
View File
@@ -167,6 +167,94 @@ TEST_CASE("TCPImageCommTest_2Writers_WithAck", "[TCP]") {
p->Disconnect();
}
// One writer rejects START (as it would on an overwrite conflict) while its sibling
// accepts. The broker must abort the whole collection and cleanly cancel the sibling
// that already started - no half-armed collection, no stuck writer.
TEST_CASE("TCPImageCommTest_StartRejectedByOneWriter_AbortsCleanly", "[TCP]") {
const int64_t npullers = 2;
const int64_t images_per_file = 16;
TCPStreamPusher pusher("tcp://127.0.0.1:*", npullers);
std::vector<std::unique_ptr<TCPImagePuller> > puller;
for (int i = 0; i < npullers; i++)
puller.push_back(std::make_unique<TCPImagePuller>(pusher.GetAddress()[0], 8 * 1024 * 1024));
for (int attempt = 0; attempt < 100 && pusher.GetConnectedWriters() < static_cast<size_t>(npullers); ++attempt)
std::this_thread::sleep_for(std::chrono::milliseconds(50));
REQUIRE(pusher.GetConnectedWriters() == static_cast<size_t>(npullers));
std::atomic<bool> start_threw{false};
std::atomic<bool> socket0_cancelled{false};
std::thread sender([&] {
StartMessage start{.images_per_file = images_per_file, .write_master_file = true};
try {
pusher.StartDataCollection(start);
} catch (const JFJochException &) {
start_threw = true;
}
});
std::vector<std::thread> receivers;
receivers.reserve(npullers);
for (int w = 0; w < npullers; w++) {
receivers.emplace_back([&, w] {
std::optional<uint32_t> my_socket;
for (int polls = 0; polls < 200; polls++) {
auto out = puller[w]->PollImage(std::chrono::milliseconds(100));
if (!out.has_value()) {
if (my_socket.has_value() && *my_socket == 1)
break; // socket 1 is done once it has rejected
continue;
}
const auto &h = out->tcp_msg->header;
if (static_cast<TCPFrameType>(h.type) == TCPFrameType::CANCEL) {
PullerAckMessage ack;
ack.ack_for = TCPFrameType::CANCEL;
ack.ok = true;
ack.run_number = h.run_number;
ack.socket_number = h.socket_number;
ack.error_code = TCPAckCode::None;
puller[w]->SendAck(ack);
if (h.socket_number == 0)
socket0_cancelled = true;
break;
}
if (out->cbor && out->cbor->start_message) {
my_socket = h.socket_number;
PullerAckMessage ack;
ack.ack_for = TCPFrameType::START;
ack.run_number = h.run_number;
ack.socket_number = h.socket_number;
if (h.socket_number == 1) { // this writer refuses (e.g. output exists)
ack.ok = false;
ack.fatal = true;
ack.error_code = TCPAckCode::StartFailed;
ack.error_text = "output file already exists";
} else { // sibling starts fine
ack.ok = true;
ack.error_code = TCPAckCode::None;
}
puller[w]->SendAck(ack);
if (h.socket_number == 1)
break;
}
}
});
}
sender.join();
for (auto &t: receivers) t.join();
REQUIRE(start_threw); // collection aborted
REQUIRE(socket0_cancelled); // the already-started sibling was cleanly cancelled
for (auto &p: puller)
p->Disconnect();
}
TEST_CASE("TCPImageCommTest_DataFatalAck_PropagatesToPusher", "[TCP]") {
const size_t nframes = 64;
const int64_t npullers = 2;