fix(actions): handle Avro JSON encoding in cloud event source and harden ack manager - #19897
shirshanka wants to merge 4 commits into
Conversation
…den ack manager
The cloud event source receives events that may be Avro JSON encoded
(union fields wrapped as {"typeName": value}), which the Pegasus
from_json parser cannot handle directly. This adds two recursive
helpers:
- _unwrap_avro_json: strips Avro union wrappers to produce
Pegasus-compatible plain dicts
- _fix_avro_bytes: converts Avro bytes fields (encoded as strings)
back to Python bytes for GenericAspect.value
Both build_metadata_change_log_event and handle_pe now run incoming
messages through these helpers before parsing.
Additional fixes:
- handle_pe: use .get("name") with fallback instead of direct key
access, preventing KeyError on events with the name at the outer level
- Poll timeout reduced from 2s to 1s with a 0.1s idle sleep to improve
responsiveness
- Ack manager: use dict.pop(key, None) to avoid KeyError on duplicate
acks, and downgrade the not-processed log from warning to debug
(filtered/buffered events are expected, not anomalous)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
PR SummaryOverview Metadata change logs are built by Platform events use Unit tests cover Avro-wrapped MCL payloads and a platform Reviewed by Cursor Bugbot for commit fb4341f. Bugbot is set up for automated code reviews on this repo. Configure here. |
| result = {} | ||
| for k, v in obj.items(): | ||
| if k == "value" and isinstance(v, str) and "contentType" in obj: | ||
| result[k] = v.encode("utf-8") |
There was a problem hiding this comment.
Avro bytes decoded as UTF-8
High Severity
_fix_avro_bytes encodes Avro JSON bytes fields with UTF-8. The Cloud Events API writes records through Avro jsonEncoder, which maps each raw byte to ISO-8859-1. Non-ASCII aspect and platform-event payloads are therefore stored as the wrong bytes, so downstream actions see corrupted descriptions, docs, and other Unicode metadata.
Reviewed by Cursor Bugbot for commit 7a010c0. Configure here.
The Avro JsonEncoder maps each byte to a Unicode code point in U+0000–U+00FF (Latin-1). Using utf-8 to reverse this double-encodes any non-ASCII byte. Switch to latin-1 and expose DATAHUB_AVRO_BYTES_ENCODING env var as a break-glass override. Add 23 unit tests for _unwrap_avro_json and _fix_avro_bytes, including a test that proves utf-8 would corrupt non-ASCII metadata content.
29d6ee7 to
175baff
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 175baff. Configure here.
| result[k] = v.encode(AVRO_BYTES_ENCODING) | ||
| else: | ||
| result[k] = _fix_avro_bytes(v) | ||
| return result |
There was a problem hiding this comment.
Plain JSON Unicode values get corrupted
Medium Severity
_fix_avro_bytes always latin-1-encodes any string value sitting next to contentType, including already-decoded plain JSON. Non-ASCII aspect or payload text then raises UnicodeEncodeError (characters above U+00FF) or becomes invalid UTF-8 (characters in U+0080–U+00FF), so handle_pe and build_metadata_change_log_event fail and the source exits.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit 175baff. Configure here.
_unwrap_avro_json and _fix_avro_bytes return recursive structures that callers index into. object is not indexable under mypy; Any permits it. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| @@ -223,6 +288,7 @@ def _poll_and_process_events(self) -> Iterable[EventEnvelope]: | |||
|
|
|||
| # Handle Idle Timeout | |||
| if total_events == 0: | |||
| time.sleep(0.1) | |||
There was a problem hiding this comment.
It effectively makes it slower, I think. Especially in high-volume cases. /poll returns immediately as long as the requested amount of events is prepared to be returned by the server. By default (and in this case) we will reach for 100 events.
So in high-volume cases we will lose additional 0.1 per 100 events. In all cases we are increasing amount of calls sent to the server by a factor of 2.
Why don't we just make poll timeout seconds value configurable and retain it as default 2? You could adjust the value for your case.
What's the reason for sleeping 100ms in here?
| batch_id, msg_id = (meta["batch_id"], meta["msg_id"]) | ||
| if processed: | ||
| self.acks.pop((batch_id, msg_id)) | ||
| self.acks.pop((batch_id, msg_id), None) |
There was a problem hiding this comment.
Exception here is non-fatal and (batch_id, msg_id) should be strictly unique. If this pop hits void, we should be notified, and we will be via the exception path. Please revert the default argument here.
| self.acks.pop((batch_id, msg_id), None) | ||
| else: | ||
| logger.warning(f"Whoops - we didn't process {meta}") | ||
| logger.debug(f"Event not processed (filtered/buffered): {meta}") |
There was a problem hiding this comment.
This is more serious then it sounds now. If an action returned processed=False, then it will stay in self.acks (we won't "pop"). Main loop in _poll_and_process_events will actually crash after event_processing_time_max_duration_seconds (it is bad design, I know). At least now we will see the warning. It never happens because we always mark processed as True. Unless you want to fix it, let's not reduce severity of the log here.
|
After analyzing this situation I understand that the source of all problems is that the GMS Avro serializer (Java, Therefore I propose fixing the serde bug in the serde layer, not here. This PR's Prerequisite: declare the wire contract. The events API has emitted Avro JSON since it was introduced — we never send plain (Pegasus-style) JSON. The plain-format test fixtures describe a format that doesn't exist on the wire, and they are fundamentally incompatible with latin-1 bytes decoding: a plain aspect value containing real Unicode (e.g. The fix, in Then this code becomes pure consumption, with no serde logic at all — def build_metadata_change_log_event(msg: ExternalEvent) -> MetadataChangeLogEvent:
return MetadataChangeLogEvent.from_json(msg.value, bytes_encoding="iso-8859-1")No pe = PlatformEventClass.from_obj(raw, bytes_encoding="iso-8859-1")
if pe.name == ENTITY_CHANGE_EVENT_NAME: ...
One open question: the test fixtures wrap required fields ( |
…ping Remove _unwrap_avro_json, _fix_avro_bytes, and tuples=True — avro-gen's default from_obj already resolves Avro JSON union wrappers schema-aware. tuples=True was disabling that built-in handling, which forced the need for the heuristic unwrapper. Simplify handle_pe to use PlatformEventClass.from_obj directly. Revert poll timeout to 2s and remove the 0.1s idle sleep (per review). Revert ack manager changes (per review: pop without default hides bugs, warning level is correct for unprocessed events). The remaining serde mismatch (Java Avro JsonEncoder writes bytes as latin-1, Python avro-gen reads as utf-8) is a separate issue that belongs in the avro-gen library, not in per-consumer workarounds. Validated against a live GMS instance: 50/50 MCL events parsed successfully with plain from_obj. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
No longer needed — the existing |


Summary
_unwrap_avro_jsonand_fix_avro_byteshelpers to handle Avro JSON union encoding in the cloud event source. Events may arrive Avro-encoded ({"typeName": value}wrappers) which the Pegasusfrom_jsonparser rejects; these helpers normalize them to plain dicts before parsing..get("name")with fallback inhandle_peinstead of direct key access to preventKeyErrorwhen the event name is at the outer envelope level.dict.pop(key, None)to avoidKeyErroron duplicate acks, and downgrade the "not processed" log from warning to debug (filtered/buffered events are normal).Test plan
🤖 Generated with Claude Code
Summary by cubic
Handles Avro JSON-encoded cloud events in the event source by parsing them with avro-gen's schema-aware
from_objdefault mode instead of the Pegasusfrom_jsonparser.build_metadata_change_log_eventandhandle_penow useMetadataChangeLogClass.from_objandPlatformEventClass.from_obj, which resolve Avro union wrappers ({"typeName": value}) natively.Written for commit fb4341f. Summary will update on new commits.