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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user