[NOGIL] Fix Producer.close() races with concurrent calls and with itself - #2313
[NOGIL] Fix Producer.close() races with concurrent calls and with itself#2313Ojasva Jain (ojasvajain) wants to merge 9 commits into
Conversation
|
🎉 All Contributor License Agreements have been signed. Ready to merge. |
f1bd331 to
4c936db
Compare
386d71a to
39740bd
Compare
4c936db to
b9118ac
Compare
| #else | ||
| usleep(100000); | ||
| #endif | ||
| CallState_end(self, &cs); |
There was a problem hiding this comment.
CallState_end returns 0 when PyErr_CheckSignals() fired or a callback crashed — and it leaves a Python exception set.
Should we check return value of this?
There was a problem hiding this comment.
Yes, I think we should check the value of PyErr_CheckSignals. Not required for callback crash because the thread will only call sleep so callback crash can not happen. Will try to also add a test case.
There was a problem hiding this comment.
Fixed this and added a test case.
|
|
||
|
|
||
| def test_close_waits_for_in_flight_call(): | ||
| """close() blocks until an in-flight poll() call finishes.""" |
There was a problem hiding this comment.
Currently default for flush(-1) or poll(-1) runs forever, if close blocks on this. Is that usecase left intentionally?
There was a problem hiding this comment.
Valid concern. In poll() and flush, we use chunked polling and check for signals in between. I have modified it to also check if close() has been called or not. If yes, poll() / flush() will exit early. In close(), we are anyway doing a indefinite flush before destroying rk.
There was a problem hiding this comment.
if user code calls producer.close() from inside a delivery-report callback, that callback is running inside poll()/flush(), i.e. inside that same thread's active_calls bump so close() waits for a call that is waiting on close(). Self-deadlock on one thread. How are we handling this usecase?
There was a problem hiding this comment.
Thought about this. Even in current version, calling close() from inside a callback is not supported (the client will simply segfault because after close() completes, rk will be NULL). After my changes, calling a close from cb will cause a deadlock instead of a segfault. There are ways to detect such deadlocks and workaround them but the implementation is not trivial so I am not sure it should be in the scope of free threading changes. We can document that calling close() from a cb will cause a deadlock so it is not recommended.
There was a problem hiding this comment.
Can we add TODO, to remember adding it to documentation ?
There was a problem hiding this comment.
Added a TODO in Producer_close()
|
|
||
| if (!(c_offsets = py_to_c_parts(offsets))) | ||
| if (!(c_offsets = py_to_c_parts(offsets))) { | ||
| Handle_exit_rk_use(self); |
There was a problem hiding this comment.
Earch exist branch calls Handle_exit_rk_use. Wondering if we could use "goto exit:" semantics to future proof codebase from "new branch missing Handle_exit_rk_use calls" bugs?
Eg. go to pattern is used in other parts of codebase
confluent-kafka-python/src/confluent_kafka/src/Producer.c
Lines 891 to 905 in 85f31c7
There was a problem hiding this comment.
I think it makes sense to add it in transaction related methods where there can be multiple exit points. I will refactor them, taking care that we don't introduce a regression.
| """ | ||
| Shared test infrastructure for tests/concurrency/. | ||
|
|
||
| Tests here deliberately race Producer/Consumer methods against each other, |
There was a problem hiding this comment.
Nice optimisation!
| #if defined(_MSC_VER) | ||
| typedef volatile LONG atomic_int_t; | ||
|
|
||
| #define atomic_int_init(p, v) (*(p) = (v)) |
There was a problem hiding this comment.
Seems like dead code?
There was a problem hiding this comment.
No, these are windows implementations of the atomic APIs. The are defined in #if defined(_MSC_VER).
| Handle_exit_rk_use(self); | ||
|
|
||
| /* Cleanup resources */ | ||
| if (rkt) |
There was a problem hiding this comment.
A concurrent close() frees rk, and rd_kafka_topic_destroy will use rkt->rkt_rk. Can this cause concurrency bug?
There was a problem hiding this comment.
Nice catch. Yes, this call needs to be added after the rkt destroy.
| CallState_begin(self, &cs); | ||
|
|
||
| /* Flush any pending messages (wait indefinitely to ensure delivery) */ | ||
| err = rd_kafka_flush(self->rk, -1); |
There was a problem hiding this comment.
- If callback touches the producer, it hits Handle_enter_rk_use(), sees closing == 1, and raises RuntimeError: Producer closed.
- That Error propagates into librdkafka's callback dispatch, which calls rd_kafka_yield.
- So the flush aborts early, close() raises, and messages still in the queue are dropped.
Could we verify, if flushing is able to flush all messages?
There was a problem hiding this comment.
Added a test case to check if all msgs get flushed. As mentioned before, we don't want to support callbacks with close() calls.
Yes, these are out of scope for this PR. I am addressing them in a separate PR as they are common to all clients. |
149a22f to
448f9f6
Compare
85f31c7 to
418c19c
Compare
Producer.close() previously raced with concurrent produce()/poll()/ flush()/produce_batch()/transaction calls and with itself when called from multiple threads, both leading to use-after-free/double-free on the underlying rd_kafka_t handle. Adds an active_calls/closing guard (Handle_enter_rk_use/Handle_exit_rk_use) so every method that touches self->rk registers itself before use, and close() drains in-flight calls before tearing down; a CAS on `closing` ensures only one concurrent close() call performs the actual teardown, with losing callers waiting for it to finish rather than racing it. Adds tests/parallel/test_producer_close_race.py covering each affected method racing close(), close() racing itself, and close()'s blocking behavior. Uses pytest-forked (POSIX only) so a regression segfault fails only that test. Integration tests against a real broker are still pending.
…for Producer close()/transaction races
…ancy tests Concurrent close() calls now return False immediately with a warning instead of waiting for the CAS winner, since waiting could deadlock a caller that already holds an active_calls slot (e.g. a callback invoked from its own poll()/flush()). poll()/flush() now also exit early once closing is set instead of blocking the drain-wait. Fixes an ordering bug in produce_batch() where the topic handle was destroyed after releasing the active_calls slot. Adds integration tests for reentrant callbacks and close()'s internal flush delivering all messages, and documents close()-from-callback as unsupported.
b0425e3 to
7d63d3b
Compare
Kaushik Raina (k-raina)
left a comment
There was a problem hiding this comment.
Left couple of comments on code correctness.
| producer.init_transactions(0.05) | ||
| except RuntimeError: | ||
| break | ||
| except Exception: # noqa: BLE001 - librdkafka state/timeout errors are expected without a broker |
There was a problem hiding this comment.
Should we pass in this case? It has potential to miss real bugs?
There was a problem hiding this comment.
Yes, have removed them.
| if (!atomic_int_cas(&self->closing, 0, 1)) { | ||
| PyErr_WarnFormat(PyExc_RuntimeWarning, 1, | ||
| "Producer is already closing"); | ||
| Py_RETURN_FALSE; |
There was a problem hiding this comment.
IIUC,
Lets take example of code :
with Producer(config) as producer:
producer.produce("topic", b"important")
If there are 2 threads:
T1: with clause producer calls close(), gets lock and passed to flush and destroy.
T2 : producer calls close(), checks cas and immediately returns false. As per current definition, false means "Context manager exit. Automatically flushes and destroys the producer."
So applications running T2 will assume producer is closed sucessfully with all messages flushed. However that is not true, as T1 is flushing which is an expensive call. This will fail applications which are running below usecases:
- If the loser is the thread holding the program open, the process can exit mid-flush. Hence losing messages. This is common usecase for containerized service receiving SIGTERM
- If loser is following up with another cleanup at application layer. eg. applications with lifecyle hooks.
This seems to be more frequently hit bug in multi threaded environment.
There was a problem hiding this comment.
Hmm. Since we will now document that close() from a callback is not supported and will cause deadlocks, it should be safe to add the logic for waiting for the close call to complete.
There was a problem hiding this comment.
Since we will now document that close() from a callback is not supported and will cause deadlocks
Above mentioned issue will occur with two different threads calling producer.close() separately. Its unrelated to callbacks.
There was a problem hiding this comment.
Yes but supporting BOTH 1) calling close() from callbacks and 2) other threads waiting for close to complete, is not straightforward. Now that we are not supporting 1, we can support 2.
There was a problem hiding this comment.
Are we supporting close from producer delivery callbacks?
There was a problem hiding this comment.
No, we will not support it. This is not supported today as well, calling close() from within a callback causes a seg fault, so there can not be existing users calling close() from callbacks. After my changes, seg fault now changes to a deadlock. We will document this behaviour.
| usleep(100000); | ||
| #endif | ||
| if (!CallState_end(self, &cs)) | ||
| return NULL; |
There was a problem hiding this comment.
Returning null leaves rk alive, as rd_kafka_destroy(self->rk); is never reached. Does this need to be fixed?
There was a problem hiding this comment.
Yes. It leaves rk in an inconsistent state where it is not null and closing is set to 1, so any future call on the client will fail, despite rk being alive. Here's what I am thinking:
- When we return NULL here, we also set closing back to 0, so any future call suceeds.
- If other threads were waiting for the close to complete and the winner got interrupted and returned NULL, (ref), the other threads should also get an exception.
There was a problem hiding this comment.
Done, also added a test case.
| ############################################################################### | ||
|
|
||
|
|
||
| def test_close_completes_quickly_with_indefinite_poll_in_progress(): |
There was a problem hiding this comment.
Does these tests, added in new commi, also needs to be isolated?
There was a problem hiding this comment.
I have isolated only those test cases where a segmentation fault is possible.
| assert any(all_results), f"iteration {i}: expected at least one close() call to return True, got: {all_results}" | ||
|
|
||
|
|
||
| ############################################################################### |
There was a problem hiding this comment.
Is this seperator needed? It doesn't have any description of what it seperates
There was a problem hiding this comment.
This was meant to mark the end of test cases where we are racing close() vs other methods. Will make it more clear.
|


Producer.close()previously raced with concurrent produce()/poll()/ flush()/produce_batch()/transaction calls and with itself when called from multiple threads, both leading to use-after-free/double-free on the underlying rd_kafka_t handle.This PR adds:
An active_calls/closing guard (Handle_enter_rk_use/Handle_exit_rk_use) so every method that touches self->rk registers itself before use, and close() drains in-flight calls before tearing down;
A CAS on
closingensures only one concurrent close() call performs the actual teardown, with losing callers getting a False as return value and a warning.Unit and integration test cases
What
Checklist
References
JIRA:
Test & Review
Open questions / Follow-ups