feat(python): add user header and origin timestamp support - #3613
feat(python): add user header and origin timestamp support#3613jiengup wants to merge 4 commits into
Conversation
|
one additional request from my side: please update python examples to include a code that would show how to use iggy message user headers. there is one for rust already. |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #3613 +/- ##
============================================
- Coverage 74.53% 74.50% -0.04%
Complexity 969 969
============================================
Files 1306 1307 +1
Lines 150028 150564 +536
Branches 125462 125536 +74
============================================
+ Hits 111829 112182 +353
- Misses 34700 34790 +90
- Partials 3499 3592 +93
🚀 New features to boost your workflow:
|
That will be included when the PR is ready. |
a5a6357 to
6d29d49
Compare
|
I was thinking that maybe we should change the signature of the headers from This would allow us to have two advantages: users that do not have much experience/do not require explicit control can use What do you guys think? |
I think it's a good idea and well aligned with the first point worth discussing in my PR. I'll push a new patch soon. |
|
@slbotbm for me this sounds fine, however one thing comes to my mind: will it have any impact on performance? |
Actually, I think additional typed The only overhead we introduce is the iteration of |
|
alright, then don't worry about it now. |
0fdd454 to
8daf546
Compare
|
Hi, I just reflected the change and merged all the commits into a final one. The PR message is also updated. Instead of a plain-only Please have a look at it when you are free. |
8daf546 to
90b153d
Compare
|
/author |
90b153d to
209ecd7
Compare
|
/ready |
slbotbm
left a comment
There was a problem hiding this comment.
Some more comments:
- You have set the type of user headers in some places to [Any, Any], which makes any type checker incapable of catching mistakes. Would be better to explicitly declare the types.
Add user headers to SendMessage and ReceiveMessage, expose origin timestamp, and fix Docker test infrastructure: - explicit typed header and plain Python header support - type transformation and validation across Rust and Python - user header usage examples - minor fix: Docker test config file
to_scalar_dict Split message-headers example into plain-headers and typed-headers variants sharing logic via common.py. Rename UserHeaders.to_plain() to to_scalar_dict() to better describe the returned dict type.
209ecd7 to
5710bff
Compare
|
/ready |
examples to avoid infinitly produce_message loop.
5710bff to
6d9c1ff
Compare
| fn py_any_to_plain<'py, T: ToPlainScalar + PyClass>( | ||
| py: Python<'py>, | ||
| any: &Bound<'py, PyAny>, | ||
| ) -> PyResult<Bound<'py, PyAny>> { | ||
| if let Ok(header) = any.extract::<PyRef<'_, T>>() { | ||
| return Bound::<PyAny>::try_from(HeaderToPlainRef { | ||
| py, | ||
| value: &*header, | ||
| }); | ||
| } | ||
| Ok(any.clone()) |
There was a problem hiding this comment.
Right now, since UserHeaders is a
publicly constructible and mutable dict, this is possible:
UserHeaders({object(): object()}).to_scalar_dict()The resulting dictionary can contain arbitrary objects despite the documented
and generated scalar-only return type.
Validate entries during construction and mutation, or reject unsupported
objects inside to_scalar_dict(). Add tests for unsupported keys and values
inserted through both the constructor and normal dictionary mutation.
| /// Uses `Float32` when the value is exactly representable as an f32, otherwise | ||
| /// `Float64`, so the plain float is always stored losslessly. | ||
| fn float_to_header_field<T>(value: f64) -> HeaderField<T> { | ||
| let narrowed = value as f32; | ||
| if f64::from(narrowed) == value { | ||
| narrowed.into() | ||
| } else { | ||
| value.into() | ||
| } | ||
| } |
There was a problem hiding this comment.
This accepts plain float("inf") and stores it as Float32, because narrowing infinity to f32 still compares equal after widening. In contrast, checked_float32 reject the equivalent typed HeaderValue.Float32(float("inf")).
This also means a Float32 infinity or NaN received from a Rust client can be
represented by Python but cannot be sent again. Float64 and plain NaN follow
different paths again.
Let's reject non-finite headers everywhere in plain, Float32, and Float64 paths.
Add tests for positive and negative infinity and NaN across plain, typed, and
receive-then-resend paths.
| /// Retrieves user headers attached to the received message. | ||
| #[gen_stub(override_return_type(type_repr = "UserHeaders | None"))] | ||
| pub fn user_headers<'a>(&self, py: Python<'a>) -> PyResult<Option<Bound<'a, UserHeaders>>> { | ||
| let Some(headers) = self | ||
| .inner | ||
| .user_headers_map() | ||
| .map_err(|e| PyValueError::new_err(e.to_string()))? | ||
| else { | ||
| return Ok(None); | ||
| }; | ||
| rust_user_headers_to_py(py, headers).map(Some) | ||
| } |
There was a problem hiding this comment.
The linked issue notes that structurally malformed wire headers are logged and
returned as None, making them indistinguishable from absent headers.
Document the None behavior and distinguish it from known semantic decode
errors, which become ValueError.
Add user headers to SendMessage and ReceiveMessage, expose origin timestamp, and fix Docker test infrastructure.
Which issue does this PR address?
Closes #3601 #3612
Rationale
The Python SDK could send and receive message payloads, but it did not expose the typed user headers already carried by the underlying Rust
IggyMessage. This left Python behind the Rust, Node, Go, Java, and C# SDKs for message metadata.What changed?
Python messages can attach user headers and read them back from received messages.
SendMessageacceptsuser_headersand an optional customid, whileReceiveMessageexposesuser_headers()andorigin_timestamp().According to the discussion below, unlike the original proposal in #3601 (which exposed only a plain
dict[str, str | bytes | bool | int | float]), the binding now supports the full Rust header type surface through two exposed classes, plus a plain-scalar convenience layer:HeaderKey/HeaderValue— typed complex enums covering everyHeaderKind:Raw,String,Bool,Int8/16/32/64/128,UnsignedInt8/16/32/64/128,Float32,Float64.UserHeaders— the mapping returned byReceiveMessage.user_headers(). It is a realdictsubclass (dict[HeaderKey, HeaderValue]), so all mapping operations work, and it adds a chainableto_scalar_dict()for the convenient scalar form:message.user_headers().to_scalar_dict().Sending (
plain → Rust)Each key/value pair is converted independently, so typed and plain forms can be freely mixed in one dict (e.g.
{HeaderKey.String("a"): 7, "b": HeaderValue.Bool(True), "c": "plain"}):str):str → String,bytes → Raw,bool → Boolint →the smallest header kind that holds it exactly: non-negative →Uint8/16/32/64/128, negative →Int8/16/32/64/128; values beyond the 128-bit range raiseValueError.float → Float32when the value is exactly representable as f32, otherwiseFloat64(always lossless).OverflowErrorfor out-of-range, e.g.HeaderValue.Int8(300)), and an explicitFloat32whose value overflows f32 (→inf) is rejected withValueError.Receiving (
Rust → plain)Every
HeaderKindmaps losslessly onto a Python scalar (128-bit ints fit Python's arbitrary-precisionint;f32 → f64is exact), so:user_headers()returns typedUserHeaders(dict[HeaderKey, HeaderValue]), orNonewhen the message carries no user headers.UserHeaders.to_scalar_dict()returnsdict[str | bytes | bool | int | float, str | bytes | bool | int | float]— a direct, lossless conversion (no logging); it only raisesValueErroron a genuine decode error of a stored field.Header decode errors from known-but-invalid or unknown semantic kinds surface as
ValueError.SendMessage(id=...)(mapped tou128) is part of the same binding update.Minor Fix
The Python test compose setup starts the server with fresh default root credentials, binds HTTP/TCP/QUIC addresses explicitly, and uses
iggy pingfor the healthcheck instead of the HTTP stats endpoint.Discussion Notes
Choices from the issue discussion, updated to the final implementation:
dictAPI for the common case and explicitHeaderKey/HeaderValuewrapper classes; the two can be mixed per entry.ValueError.Float32/Float64by exact representability instead of alwaysFloat64.HeaderKey), instead of being rejected.ReceiveMessage.user_headers()returnsNonefor no headers.origin_timestamp()is exposed on received messages.API Usage
Sending — plain scalars (common case)
Sending — explicit typed kinds
Sending — mixed typed + plain in one dict
Receiving
Validation / errors
Local Execution
cargo fmt --check --manifest-path foreign/python/Cargo.tomlcargo check --manifest-path foreign/python/Cargo.tomlcargo test --manifest-path foreign/python/Cargo.tomluv run --extra dev ruff check tests/test_message_operations.py tests/test_consumer_group.py.venv/bin/python -m pytest tests/test_message_operations.py::TestMessageOperations::test_invalid_user_headers_are_rejected -qexample/message-headers/typed-headers/consumer.py,example/message-headers/typed-headers/producer.py,example/message-headers/plain-headers/consumer.py,example/message-headers/plain-headers/producer.pyAI Usage
Codex was used to inspect the existing Python, Rust, Node, and Go SDK behavior, implement the Python binding changes, add tests. All the modification was reviewed carefully by the human.