Skip to content

fix(actions): handle Avro JSON encoding in cloud event source and harden ack manager - #19897

Closed
shirshanka wants to merge 4 commits into
masterfrom
fix/actions-avro-unwrap-and-ack-guard
Closed

shirshanka wants to merge 4 commits into
masterfrom
fix/actions-avro-unwrap-and-ack-guard

Conversation

@shirshanka

@shirshanka shirshanka commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Add _unwrap_avro_json and _fix_avro_bytes helpers to handle Avro JSON union encoding in the cloud event source. Events may arrive Avro-encoded ({"typeName": value} wrappers) which the Pegasus from_json parser rejects; these helpers normalize them to plain dicts before parsing.
  • Use .get("name") with fallback in handle_pe instead of direct key access to prevent KeyError when the event name is at the outer envelope level.
  • Reduce poll timeout from 2s to 1s and add a 0.1s idle sleep for improved responsiveness.
  • Harden the 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 normal).

Test plan

  • Verify cloud event source correctly parses both Avro-encoded and plain JSON events
  • Verify ack manager handles duplicate ack calls without raising

🤖 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_obj default mode instead of the Pegasus from_json parser.

  • build_metadata_change_log_event and handle_pe now use MetadataChangeLogClass.from_obj and PlatformEventClass.from_obj, which resolve Avro union wrappers ({"typeName": value}) natively.
  • Adds unit tests covering Avro-wrapped MCL and platform event payloads.

Written for commit fb4341f. Summary will update on new commits.

Review in cubic

…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>
@cursor

cursor Bot commented Sep 21, 2026

Copy link
Copy Markdown

PR Summary

Overview
Parses cloud events through Pegasus schema classes so Avro JSON union wrappers ({"typeName": value}) are handled by from_obj instead of manual dict shaping.

Metadata change logs are built by json.loadsMetadataChangeLogClass.from_objMetadataChangeLogEvent.from_class, replacing MetadataChangeLogEvent.from_json on the raw message.

Platform events use PlatformEventClass.from_obj for the envelope and read name / payload from the typed object; post_json_transform and direct value["name"] access are removed.

Unit tests cover Avro-wrapped MCL payloads and a platform entityChangeEvent end-to-end through handle_pe.

Reviewed by Cursor Bugbot for commit fb4341f. Bugbot is set up for automated code reviews on this repo. Configure here.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

result = {}
for k, v in obj.items():
if k == "value" and isinstance(v, str) and "contentType" in obj:
result[k] = v.encode("utf-8")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

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.
@shirshanka
shirshanka force-pushed the fix/actions-avro-unwrap-and-ack-guard branch from 29d6ee7 to 175baff Compare September 22, 2026 16:17

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

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>
Comment on lines +281 to +291
@@ -223,6 +288,7 @@ def _poll_and_process_events(self) -> Iterable[EventEnvelope]:

# Handle Idle Timeout
if total_events == 0:
time.sleep(0.1)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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}")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@skrydal

skrydal commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator

After analyzing this situation I understand that the source of all problems is that the GMS Avro serializer (Java, ExternalEventsService.convertGenericRecordToJson) encodes bytes fields as latin-1 strings (as per the Avro spec!), while our own avro-gen3 (Python) decodes them as utf-8 (against the spec — _primitive_from_json / _primitive_to_json in avrojson.py). Every non-ASCII aspect value crossing that boundary gets corrupted. That mismatch — not the union wrappers — is the actual production bug.

Therefore I propose fixing the serde bug in the serde layer, not here. This PR's _fix_avro_bytes re-implements bytes decoding by recursively pattern-matching dict keys inside an actions plugin — serde logic leaking into yet another layer, and schema-blind on top (it decides "wrapper or data" from key spelling; a single-entry map like {"consumer.id": "x"} gets destroyed, the helper is not idempotent, and its safety silently depends on every map in the MCL/PE envelope schemas staying optional — one non-optional map field added to MetadataChangeLog later turns this into a data-dependent poison-event crash loop no test catches).

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. "数据集") cannot be latin-1-encoded and breaks deserialization — in this PR and in any latin-1-based approach alike. So step zero is: Avro JSON is the only supported input; delete the plain-format fixtures and regenerate the rest from actual JsonEncoder output.

The fix, in acryldata/avro_gen: add an optional bytes_encoding argument to from_obj / to_obj (threaded from DictWrapper into _primitive_from_json / _primitive_to_json, applied symmetrically), defaulting to utf-8 — today's behavior, zero blast radius. Changing the default is not safe: avro-gen3 is self-consistent (utf-8 in both directions), so every Python↔Python round-trip and every previously serialized artifact depends on the current convention. Spec-compliant decoding becomes an explicit opt-in at the boundary that needs it. Release, bump the pin in OSS, done — both repos are ours.

Then this code becomes pure consumption, with no serde logic at allfrom_json in event_registry.py (also ours) forwards the kwarg to from_obj, and the event source goes back to the pre-PR one-liner plus one argument:

def build_metadata_change_log_event(msg: ExternalEvent) -> MetadataChangeLogEvent:
    return MetadataChangeLogEvent.from_json(msg.value, bytes_encoding="iso-8859-1")

No _unwrap_avro_json, no _fix_avro_bytes, no tuples=True: the default avro-gen3 converter already resolves Avro JSON union wrappers schema-aware (it matches the single-key dict against the union's declared branches) — tuples=True is what disabled that and created the need for the heuristic in the first place. And handle_pe becomes symmetric instead of hand-parsing dict keys:

    pe = PlatformEventClass.from_obj(raw, bytes_encoding="iso-8859-1")
    if pe.name == ENTITY_CHANGE_EVENT_NAME: ...

PlatformEvent needs no special handling: it has zero unions (all three fields required), so the encoder never wraps anything in it.

One open question: the test fixtures wrap required fields ("name": {"string": ...}, "entityType": {"string": ...}). The Avro JsonEncoder cannot produce that from these schemas — only union (optional) fields get wrapped. If some component in the gms-lite stack really emits this shape, please share a wire capture; that producer violates its own schema and should be fixed there, not compensated for in every client. If no capture exists, these fixtures should go.

…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>
@shirshanka

Copy link
Copy Markdown
Contributor Author

No longer needed — the existing from_json on master handles Avro JSON unions correctly. The real fix (missing sourceDetails in documentation ECEs) is in #19936.

@shirshanka shirshanka closed this Sep 23, 2026

This branch was successfully deployed

1 active deployment
Preview fb4341f6 Deployed Sep 22, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants