Skip to content

feat: Message data classes - #284

Open
chrisk314 wants to merge 19 commits into
mainfrom
feat/message-data-classes
Open

chrisk314 wants to merge 19 commits into
mainfrom
feat/message-data-classes

Conversation

@chrisk314

@chrisk314 chrisk314 commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Implements #102: feat: Base component for external communication (#102).

Adds MessageDataReader / MessageDataWriter for reading and writing records through a pub/sub broker - the broker equivalent of FileReader/FileWriter, where each message carries one record and field_names become the component's outputs (reader) or inputs (writer). Both extend the existing DataReader/DataWriter bases, adding what a broker needs and a finite source does not: a long-lived connection, reconnection with backoff, and acknowledgment.

Two implementations are included: Google Cloud PubSub and AWS SQS/SNS. Apache Kafka is split out into draft PR #298 - see the note at the bottom for why.

Integration tests against real infrastructure are deliberately deferred - no AWS/GCP accounts or broker resources exist yet. Terraform for the required PubSub topic/subscription and SNS topic/SQS queue lives in plugboard-infra (terraform/ci-data-message-testing), and plugboard-infra/docs/running-message-data-tests.md describes wiring its outputs into this suite. All 104 tests here are mock-based, so no credentials are needed.

Changes

Base classes

  • MessageDataReader / MessageDataWriter extend DataReader / DataWriter (buffer, input binding and batching are inherited, not copied), adding _connect, _disconnect, _receive/_send, _convert, and _ack (reader).
  • Retry, exponential backoff and reconnect live in one shared place: plugboard/utils/retry.py, with a frozen RetryPolicy value object used by both bases.
  • Broker exceptions (MessageBrokerConnectionError / TransientError / PermanentError) are raised and honoured: each implementation maps its SDK's failures onto them, and permanent errors skip retries that cannot help.
  • Settings for GCP/AWS are read as constructor fallbacks (GCP_PUBSUB_PROJECT_ID, AWS_REGION); explicit arguments win.
  • All four concrete classes are exported from plugboard.library.

Implementations

  • GCPPubSubDataReader / GCPPubSubDataWriter (google-cloud-pubsub) - every synchronous PubSub call is dispatched to a worker thread, so a pull or publish never stalls the shared event loop.
  • AWSSQSDataReader / AWSSNSDataWriter (aiobotocore) - long polling, delete_message_batch for acks, and the 10-message SQS cap is logged rather than applied silently.

Packaging: optional extras gcp-pubsub and aws-messaging; the broker SDKs are also declared in the test dependency group, which is what lets the tests import the real client and error types.

Docs: new Message Data usage page (extras, worked example, delivery semantics), broker settings in the configuration page, new extras in the README.

Behaviour worth knowing

  • An empty poll is not the end of the stream. A reader started before its producer keeps waiting and picks up messages as they arrive, like WebsocketReader. A subclass signals a genuinely exhausted source by raising NoMoreDataException, which closes the IO as other data readers do.
  • Messages are acknowledged after they are processed. A batch fetched from the broker is acked only once all of its records have been published downstream, so a crash mid-batch leaves the unread messages for redelivery (at-least-once). Unconsumed pre-fetched messages are deliberately left unacked.
  • Sends are not cancelled at teardown: an in-flight batch is awaited (and any failure logged) rather than dropped.

Testing

  • 104 unit tests, all against teardown-aware monkeypatch fixtures rather than global sys.modules mocks, asserting broker calls and arguments rather than private attribute identity.
  • Coverage for the paths the review flagged as untested: retry exhaustion and the delay cap, empty-batch handling, SDK error mapping, reconnect/disconnect failures, pending-send failure at teardown, and ack timing.
  • Branch coverage of the new modules: utils/retry.py 100%, message_reader.py 95%, message_writer.py 94%, gcp_pubsub_io.py 94%, aws_messaging_io.py 95% - the remaining misses are abstract-method bodies.
  • ruff check, ruff format --check, ty over plugboard/, plugboard-schemas/ and tests/ pass; mkdocs build is clean.
  • Full unit suite: 387 passed, 6 skipped. Integration suite against the compose services: 141 passed, 1 pre-existing ZMQ flake (test_process_stop_event[LocalProcess-ZMQConnector-...]), which passes on repeat runs in isolation and is addressed in fix: avoid ZMQ slow-joiner loss on pipeline connect #297.

Related

fsspec license metadata is not detectable by licensecheck, causing
CI failures. Add to ignore list since it's BSD-3-Clause licensed.
Implements issue #102: Base component for external communication via
pub/sub message broker infrastructure.

New base classes:
- MessageDataReader: Abstract base for reading data from message brokers
  with connection management, reconnection with exponential backoff,
  retry logic, message acknowledgment, and chunked/buffered reading.
- MessageDataWriter: Abstract base for writing data to message brokers
  with connection management, reconnection, retry logic, and
  chunked/buffered writing.

Concrete implementations:
- GCPPubSubDataReader/Writer: Google Cloud PubSub
- AWSSQSDataReader/AWSSNSDataWriter: AWS SQS/SNS
- KafkaDataReader/Writer: Apache Kafka

Also includes:
- Message broker exceptions (ConnectionError, TransientError, PermanentError)
- Settings for GCP PubSub, AWS, and Kafka
- Optional dependencies in pyproject.toml
- Proposal document with design rationale
- Comprehensive unit tests (72 new tests)
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown

Benchmark comparison for fa9a1b92 (base) vs e14a0fae (PR)


------------------------------------------------------------------------------------------------------------------ benchmark: 2 tests -----------------------------------------------------------------------------------------------------------------
Name (time in ms)                                                                         Min                 Max                Mean            StdDev              Median               IQR            Outliers     OPS            Rounds  Iterations
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
test_benchmark_process_run (main/.benchmarks/Linux-CPython-3.14-64bit/0001_base)     538.2940 (1.0)      550.3755 (1.00)     543.8897 (1.0)      5.3367 (2.22)     545.2340 (1.0)      9.4054 (2.37)          3;0  1.8386 (1.0)           5           1
test_benchmark_process_run (pr/.benchmarks/Linux-CPython-3.14-64bit/0001_pr)         543.6806 (1.01)     549.3609 (1.0)      546.7065 (1.01)     2.4078 (1.0)      545.9489 (1.00)     3.9692 (1.0)           2;0  1.8291 (0.99)          5           1
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

Legend:
  Outliers: 1 Standard Deviation from Mean; 1.5 IQR (InterQuartile Range) from 1st Quartile and 3rd Quartile.
  OPS: Operations Per Second, computed as 1 / Mean

- Fix ruff lint errors: import sorting, unused imports, S110 noqa comments
- Fix ruff format errors in gcp_pubsub_io.py
- Fix mypy overlap errors: remove duplicate fields from ArgsDict TypedDicts
- Fix mypy multiple values error: use kwargs.setdefault instead of pop
- Remove untracked test data files causing lint failures
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown

Benchmark comparison for fa9a1b92 (base) vs e538c242 (PR)


------------------------------------------------------------------------------------------------------------------ benchmark: 2 tests -----------------------------------------------------------------------------------------------------------------
Name (time in ms)                                                                         Min                 Max                Mean            StdDev              Median               IQR            Outliers     OPS            Rounds  Iterations
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
test_benchmark_process_run (pr/.benchmarks/Linux-CPython-3.14-64bit/0001_pr)         378.6095 (1.0)      381.9872 (1.0)      380.3807 (1.0)      1.4510 (1.0)      380.8602 (1.0)      2.4960 (1.0)           2;0  2.6289 (1.0)           5           1
test_benchmark_process_run (main/.benchmarks/Linux-CPython-3.14-64bit/0001_base)     380.9969 (1.01)     387.2179 (1.01)     383.3493 (1.01)     2.6439 (1.82)     381.8112 (1.00)     3.9492 (1.58)          1;0  2.6086 (0.99)          5           1
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

Legend:
  Outliers: 1 Standard Deviation from Mean; 1.5 IQR (InterQuartile Range) from 1st Quartile and 3rd Quartile.
  OPS: Operations Per Second, computed as 1 / Mean

Increase connection establishment sleep in _ZMQPipelineConnectorProxy
from 0.1s to 0.5s to allow the proxy subprocess's SUB socket
subscription to propagate to XPUB before the sender starts publishing
(ZMQ slow joiner problem). Also mark the test as flaky with 3 reruns
following the existing pattern used elsewhere in the repo.

Fixes: test_process_with_components_run[RayProcess-zmq_connector_cls-zmq_pubsub_proxy=True-10-2.0]
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown

Benchmark comparison for 7b0cbeda (base) vs e49d472e (PR)


------------------------------------------------------------------------------------------------------------------ benchmark: 2 tests -----------------------------------------------------------------------------------------------------------------
Name (time in ms)                                                                         Min                 Max                Mean            StdDev              Median               IQR            Outliers     OPS            Rounds  Iterations
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
test_benchmark_process_run (pr/.benchmarks/Linux-CPython-3.14-64bit/0001_pr)         286.1344 (1.0)      297.6967 (1.0)      292.9123 (1.0)      4.6218 (1.97)     294.8739 (1.0)      6.6802 (2.40)          2;0  3.4140 (1.0)           5           1
test_benchmark_process_run (main/.benchmarks/Linux-CPython-3.14-64bit/0001_base)     295.0680 (1.03)     301.3671 (1.01)     297.8813 (1.02)     2.3407 (1.0)      297.1355 (1.01)     2.7794 (1.0)           2;0  3.3570 (0.98)          5           1
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

Legend:
  Outliers: 1 Standard Deviation from Mean; 1.5 IQR (InterQuartile Range) from 1st Quartile and 3rd Quartile.
  OPS: Operations Per Second, computed as 1 / Mean

@codecov

codecov Bot commented Sep 9, 2026 •

Copy link
Copy Markdown

- Fix invalid-raise errors in message_reader.py and message_writer.py
  by initializing last_exception with a non-None default instead of
  Optional[Exception]
- Remove PublisherClient.close() call in gcp_pubsub_io.py (method does
  not exist on the client); just set reference to None for GC
- Update test to match new disconnect behavior
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown

Benchmark comparison for 7b0cbeda (base) vs d315dd07 (PR)


------------------------------------------------------------------------------------------------------------------ benchmark: 2 tests -----------------------------------------------------------------------------------------------------------------
Name (time in ms)                                                                         Min                 Max                Mean            StdDev              Median               IQR            Outliers     OPS            Rounds  Iterations
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
test_benchmark_process_run (main/.benchmarks/Linux-CPython-3.14-64bit/0001_base)     534.4430 (1.0)      539.0566 (1.0)      536.5269 (1.0)      1.8247 (1.0)      535.9234 (1.0)      2.7265 (1.0)           2;0  1.8638 (1.0)           5           1
test_benchmark_process_run (pr/.benchmarks/Linux-CPython-3.14-64bit/0001_pr)         538.3621 (1.01)     549.1566 (1.02)     544.6404 (1.02)     4.0247 (2.21)     545.0155 (1.02)     4.7870 (1.76)          2;0  1.8361 (0.99)          5           1
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

Legend:
  Outliers: 1 Standard Deviation from Mean; 1.5 IQR (InterQuartile Range) from 1st Quartile and 3rd Quartile.
  OPS: Operations Per Second, computed as 1 / Mean

The design proposal is not tracked in the repo. Added to .gitignore
to prevent accidental re-commit.
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown

Benchmark comparison for 7b0cbeda (base) vs 0fa21c58 (PR)


------------------------------------------------------------------------------------------------------------------ benchmark: 2 tests -----------------------------------------------------------------------------------------------------------------
Name (time in ms)                                                                         Min                 Max                Mean            StdDev              Median               IQR            Outliers     OPS            Rounds  Iterations
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
test_benchmark_process_run (main/.benchmarks/Linux-CPython-3.14-64bit/0001_base)     527.9234 (1.0)      542.6921 (1.0)      537.0849 (1.0)      5.5841 (1.28)     537.6015 (1.0)      5.9359 (1.0)           2;0  1.8619 (1.0)           5           1
test_benchmark_process_run (pr/.benchmarks/Linux-CPython-3.14-64bit/0001_pr)         540.4164 (1.02)     550.3291 (1.01)     546.0575 (1.02)     4.3785 (1.0)      548.3933 (1.02)     7.3029 (1.23)          1;0  1.8313 (0.98)          5           1
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

Legend:
  Outliers: 1 Standard Deviation from Mean; 1.5 IQR (InterQuartile Range) from 1st Quartile and 3rd Quartile.
  OPS: Operations Per Second, computed as 1 / Mean

Resolve conflict in tests/integration/test_process_with_components_run.py:
keep main's native pytest parametrization (ConnectorCase + indirect +
ids) replacing pytest-cases, retaining the flaky marker for the
Ray + ZMQ proxy case. Regenerate uv.lock for merged pyproject extras.

Verified: ruff check, ruff format, ty check all pass; new ZMQ backend/
channel/proxy unit tests (21) and message data tests (72) pass; merged
integration file collects cleanly (25 tests).
@github-actions

github-actions Bot commented Oct 4, 2026

Copy link
Copy Markdown

Benchmark comparison for 894671bb (base) vs a7d10b72 (PR)


------------------------------------------------------------------------------------------------------------------- benchmark: 2 tests ------------------------------------------------------------------------------------------------------------------
Name (time in ms)                                                                         Min                 Max                Mean             StdDev              Median                IQR            Outliers     OPS            Rounds  Iterations
---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
test_benchmark_process_run (pr/.benchmarks/Linux-CPython-3.14-64bit/0001_pr)         289.2487 (1.0)      316.7170 (1.06)     297.5400 (1.01)     11.2704 (4.16)     292.8931 (1.0)      12.7278 (2.47)          1;0  3.3609 (0.99)          5           1
test_benchmark_process_run (main/.benchmarks/Linux-CPython-3.14-64bit/0001_base)     292.6107 (1.01)     298.1502 (1.0)      295.5250 (1.0)       2.7097 (1.0)      296.5808 (1.01)      5.1463 (1.0)           2;0  3.3838 (1.0)           5           1
---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

Legend:
  Outliers: 1 Standard Deviation from Mean; 1.5 IQR (InterQuartile Range) from 1st Quartile and 3rd Quartile.
  OPS: Operations Per Second, computed as 1 / Mean

Blocking issues:
- An empty broker poll no longer ends the stream. `_receive` returning [] means
  "nothing yet", so `step()` waits and polls again like `WebsocketReader`; only a
  subclass raising `NoMoreDataException` closes the IO (B1).
- A batch is acknowledged only after its last record is consumed, instead of after
  the first one. Previously a crash mid-batch lost the unread messages (B2).
- `KafkaDataReader._ack` commits the offsets following the processed records, per
  partition, instead of the consumer position, which could mark unprocessed records
  as done (B3).
- GCP reader and writer dispatch every synchronous PubSub call (client construction,
  pull, acknowledge, publish, future.result, stop, close) to a worker thread, so they
  no longer block the shared event loop (B4, B5).

Design and consolidation:
- `MessageDataReader`/`MessageDataWriter` now extend `DataReader`/`DataWriter`,
  reusing the buffer, binding and batching logic instead of copying it (W14).
- Retry, backoff and reconnect live in one place: `plugboard.utils.retry` with a
  frozen `RetryPolicy` value object shared by both bases (W13, L1, L2).
- A failing reconnect no longer escapes the retry loop; it costs an attempt and the
  broker error is raised once attempts run out (W12).
- Row-to-message encoding is shared as `iter_records`/`encode_records`/
  `encode_records_bytes`; the old per-row deque indexing was quadratic in buffer size
  and duplicated across three writers (W16, W19).
- Connection access is serialised with an `asyncio.Lock` across ack and reconnect (W15).
- Kafka writer submits all records then flushes once, instead of one round trip per
  record (W18). SQS deletes ack batches of up to 10 with `delete_message_batch` and
  reports partial failures rather than pretending success (L3).
- The GCP publisher is stopped on disconnect, so a reconnect no longer leaks a channel
  and its threads (W17).
- `MessageBrokerPermanentError`/`TransientError` are now actually raised and honoured:
  each broker maps its SDK's failures onto the hierarchy, and permanent errors skip the
  retries that previously burnt against a guaranteed failure (W1).

Configuration and packaging:
- The GCP/AWS/Kafka settings are read as constructor fallbacks via
  `resolve_argument`, so `GCP_PUBSUB_PROJECT_ID`, `AWS_REGION` and
  `KAFKA_BOOTSTRAP_SERVERS` work (W2).
- `aws-messaging` now uses `aiobotocore` instead of `aioboto3`, which no longer forces
  the shared botocore/boto3 stack backwards (W5).
- Broker SDKs are declared in the `test` dependency group, which also resolves the three
  `ty` unresolved-import findings (W10 root cause).
- The six concrete broker classes are exported from `plugboard.library` (W3).
- Removed the misleading broker-specific ArgsDicts that documented attributes they did
  not declare (W20).

Tests: 126 tests, up from 72, all using teardown-aware monkeypatch fixtures instead of
global `sys.modules` mocks, asserting broker calls rather than private attribute
identity (W10, W11). New coverage for retry exhaustion and the delay cap, empty-batch
handling, error-branch mapping, offset commits, reconnect failure, and the
acknowledgment timing that previously lost records (W7, W8, W9). Record encoding is
parametrised once rather than copied across three modules (L4).

Docs: new Message Data usage page (extras, worked example, delivery semantics), broker
settings in the configuration page, and the new extras in the README (W4).

Test isolation: the S3 file tests pinned their bucket region so they no longer depend
on the developer's ambient AWS profile, which the newer botocore resolves over
`AWS_REGION`.
The connection delay and the flaky marker on the Ray + ZMQ integration test fix an
unrelated ZMQ connector issue; they now move with it to their own change.
@github-actions

github-actions Bot commented Oct 4, 2026

Copy link
Copy Markdown

Benchmark comparison for 894671bb (base) vs 6ac64739 (PR)


------------------------------------------------------------------------------------------------------------------ benchmark: 2 tests ------------------------------------------------------------------------------------------------------------------
Name (time in ms)                                                                         Min                 Max                Mean            StdDev              Median                IQR            Outliers     OPS            Rounds  Iterations
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
test_benchmark_process_run (main/.benchmarks/Linux-CPython-3.14-64bit/0001_base)     541.8068 (1.0)      555.3819 (1.01)     548.8945 (1.00)     5.9871 (3.94)     546.7437 (1.00)     10.3207 (4.72)          3;0  1.8218 (1.00)          5           1
test_benchmark_process_run (pr/.benchmarks/Linux-CPython-3.14-64bit/0001_pr)         544.6977 (1.01)     548.6559 (1.0)      546.6321 (1.0)      1.5215 (1.0)      546.2889 (1.0)       2.1859 (1.0)           2;0  1.8294 (1.0)           5           1
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

Legend:
  Outliers: 1 Standard Deviation from Mean; 1.5 IQR (InterQuartile Range) from 1st Quartile and 3rd Quartile.
  OPS: Operations Per Second, computed as 1 / Mean

…ure at teardown

Closes the two remaining uncovered branches from the error-handling paths the review
asked about: a reconnect whose disconnect raises must still connect and let the retry
proceed, and a send still in flight when destroy runs must be reported rather than
swallowed. plugboard/utils/retry.py is now at 100% branch coverage.
@chrisk314

Copy link
Copy Markdown
Contributor Author

Review response

Addressed in b569b78, 6ac6473, 3af63fe:

What changed
B1 Empty poll = "nothing yet": step() waits and re-polls instead of closing the stream; NoMoreDataException is reserved for an exhausted source, and KafkaDataReader._receive now returns [] rather than raising.
B2 A batch is acked only when its last record has been consumed (_ack_pending fires on drain), so records still in the buffer stay redeliverable.
B3 KafkaDataReader._ack commits TopicPartition -> OffsetAndMetadata(offset + 1) for the records passed in, highest per partition, instead of the consumer position.
B4/B5 Every synchronous GCP call (client construction, pull, acknowledge, publish, future.result, close, stop) runs via asyncio.to_thread. Covered by tests that assert the SDK call happens off the event loop thread.
W1 Brokers map SDK failures onto the hierarchy (NotFound/QueueDoesNotExist -> NoMoreDataException, auth/invalid-argument -> permanent, throttling/transport -> transient); with_retry re-raises MessageBrokerPermanentError immediately.
W2 resolve_argument reads GCP_PUBSUB_PROJECT_ID, AWS_REGION, KAFKA_BOOTSTRAP_SERVERS as constructor fallbacks, with an error naming the variable to set.
W3 Six concrete classes exported from plugboard.library; verified the package still imports with all three SDKs blocked.
W4 New docs/usage/message-data.md (in nav), broker-settings section in configuration.md, extras in the README; API entries now render via W3.
W5 aioboto3 dropped for aiobotocore>=3.4,<4: the shared stack moved up (aiobotocore 3.4.0 -> 3.9.2, botocore 1.42.84 -> 1.43.106) instead of down.
W7/W8/W9 Retry exhaustion raises the broker error after exactly max_retries+1 attempts / max_retries reconnects (asserted, with the delay cap); empty-batch path, SDK error branches, disconnect-failure and pending-send-failure arms covered.
W10/W11/L4 sys.modules.setdefault mocks replaced by monkeypatch fixtures over the implementation modules; assertions target broker calls/arguments; record encoding tested once.
W12 A failing reconnect is recorded and the loop continues; the broker error is raised once attempts run out.
W13/L1/L2 One shared with_retry + RetryPolicy (frozen msgspec.Struct) + attempt_reconnect, used by both bases; nesting depth reduced.
W14/W19 MessageDataReader/MessageDataWriter inherit DataReader/DataWriter; row-to-payload encoding shared as iter_records/encode_records/encode_records_bytes.
W15 An asyncio.Lock serialises _ack against _reconnect.
W16 iter_records zips the deques once - the per-row deque[i] indexing was quadratic and, with the default chunk_size, ran over the whole buffered stream.
W17 _disconnect calls publisher.stop(), so a reconnect no longer leaks a channel and its threads.
W18 Kafka writer gathers send() futures then flushes once; send_and_wait is asserted unused.
W20 The six ArgsDict subclasses that documented undeclared attributes were removed (matching the FileReader/DataReaderArgsSpec convention); topic is documented on the base dicts.
L3 SQS acks use delete_message_batch in groups of 10 and raise MessageBrokerTransientError on partial failure instead of reporting success.
L5 ZMQ connect_recv delay + flaky marker reverted here and moved to #297.
L6 Redundant # Use getmany... comment removed.

Deferred (W6): real-infrastructure integration tests, pending the AWS account and GCP project plus the Terraform in plugboard-infra. Until then tests/integration/ has no message-data coverage, so wire-format, credential, long-poll and ack/commit behaviour against live brokers is still unverified - which is also expected to be where the remaining design questions surface.

@github-actions

github-actions Bot commented Oct 4, 2026

Copy link
Copy Markdown

Benchmark comparison for 894671bb (base) vs 3af63fee (PR)


------------------------------------------------------------------------------------------------------------------ benchmark: 2 tests -----------------------------------------------------------------------------------------------------------------
Name (time in ms)                                                                         Min                 Max                Mean            StdDev              Median               IQR            Outliers     OPS            Rounds  Iterations
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
test_benchmark_process_run (pr/.benchmarks/Linux-CPython-3.14-64bit/0001_pr)         399.5175 (1.0)      420.8993 (1.03)     404.9637 (1.0)      9.0346 (5.23)     400.8723 (1.0)      7.8745 (2.94)          1;1  2.4694 (1.0)           5           1
test_benchmark_process_run (main/.benchmarks/Linux-CPython-3.14-64bit/0001_base)     403.7804 (1.01)     407.8922 (1.0)      405.3655 (1.00)     1.7286 (1.0)      404.5846 (1.01)     2.6822 (1.0)           1;0  2.4669 (1.00)          5           1
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

Legend:
  Outliers: 1 Standard Deviation from Mean; 1.5 IQR (InterQuartile Range) from 1st Quartile and 3rd Quartile.
  OPS: Operations Per Second, computed as 1 / Mean

Kafka is not part of the current testing round: there is no broker available to
integration-test against, and whether to run one (managed MSK/Confluent versus a
container) is still an open decision. Everything Kafka-specific moves to
`feat/kafka-message-data`, which stacks on this branch:

- `plugboard/library/kafka_io.py` and `tests/unit/test_kafka_io.py`
- the `kafka` extra and `aiokafka` in the test dependency group (dropped from the
  lockfile, so `async-timeout` goes too)
- `_KafkaSettings` and the `Settings.kafka` field
- the Kafka exports, doc rows, install extra, settings table row and example

The message data base classes, the PubSub and SQS/SNS implementations, the shared
retry/backoff helper and the docs structure all stay: none of them depended on Kafka.
chrisk314 added a commit that referenced this pull request Oct 18, 2026
Adds `KafkaDataReader` and `KafkaDataWriter` on the `MessageDataReader` /
`MessageDataWriter` bases:

- Consumer uses a consumer group with auto-commit disabled; acknowledging a batch
  commits the offsets following the processed records, highest per partition, so
  records still buffered remain eligible for redelivery.
- Producer submits a batch's records together and flushes once, keeping the
  client's internal batching instead of one round trip per record.
- `bootstrap_servers` falls back to `KAFKA_BOOTSTRAP_SERVERS` and accepts a string
  or a list; aiokafka failures are mapped onto the broker exception hierarchy
  (authorization / record-too-large are permanent, connection and rebalance errors
  are retried).
- The `kafka` extra declares aiokafka, and aiokafka is in the test dependency group
  so the unit tests can import the real error and offset types.

372 lines of tests cover offset commits (including out-of-order records across
partitions), empty polls returning no messages, multi-partition flattening, batched
sends with a single flush, error mapping, settings resolution and JSON vs raw
encoding.

Split out of #284, where it landed with the PubSub and SQS/SNS implementations.
@github-actions

Copy link
Copy Markdown

Benchmark comparison for 894671bb (base) vs 1be92fad (PR)


------------------------------------------------------------------------------------------------------------------ benchmark: 2 tests -----------------------------------------------------------------------------------------------------------------
Name (time in ms)                                                                         Min                 Max                Mean            StdDev              Median               IQR            Outliers     OPS            Rounds  Iterations
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
test_benchmark_process_run (main/.benchmarks/Linux-CPython-3.14-64bit/0001_base)     550.4919 (1.0)      555.5193 (1.0)      553.5894 (1.0)      2.4275 (1.0)      555.1032 (1.0)      4.2382 (1.18)          1;0  1.8064 (1.0)           5           1
test_benchmark_process_run (pr/.benchmarks/Linux-CPython-3.14-64bit/0001_pr)         552.1897 (1.00)     560.8486 (1.01)     557.5974 (1.01)     3.2891 (1.35)     558.5914 (1.01)     3.5858 (1.0)           1;0  1.7934 (0.99)          5           1
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

Legend:
  Outliers: 1 Standard Deviation from Mean; 1.5 IQR (InterQuartile Range) from 1st Quartile and 3rd Quartile.
  OPS: Operations Per Second, computed as 1 / Mean

@chrisk314

Copy link
Copy Markdown
Contributor Author

Kafka split out (#298)

Kafka is no longer part of this PR. Everything Kafka-specific moved to the draft branch feat/kafka-message-data ( #298, targeting this branch):

  • plugboard/library/kafka_io.py and tests/unit/test_kafka_io.py (24 tests)
  • the kafka extra and aiokafka in the test dependency group (uv lock drops aiokafka and async-timeout)
  • _KafkaSettings and the Settings.kafka field
  • the KafkaDataReader/KafkaDataWriter exports, and every Kafka row/example in the docs and README

Review items that concerned Kafka code specifically now live in #298 rather than here: B3 (commit processed offsets rather than the consumer position), W18 (send + one flush instead of send_and_wait per record), the Kafka half of W16/W19 (shared row encoding) and L6 (redundant # Use getmany comment). The remaining review findings were all in the base classes, the PubSub/SQS-SNS implementations or shared plumbing, and stay fixed here.

Nothing else moved: the base classes, plugboard/utils/retry.py, the exception hierarchy and the settings plumbing never depended on Kafka, and the GCP/AWS suites pass unchanged (104 tests, full unit suite 387 passed / 6 skipped).

Why draft: there is no broker to integration-test against, and standing one up is still an open decision (managed MSK/Confluent versus a container such as Redpanda in compose.yaml). It stays draft until a testing setup is determined and there's appetite to invest the time — mocked tests alone can't tell us anything about rebalances, session timeouts or commit semantics under real partition assignment.

Fix two bugs found by running against real infrastructure:
- AWSSQSDataReader._ack used the wrong parameter name
  (DeleteMessageRequests instead of Entries) for delete_message_batch,
  causing every ack to fail with a validation error.
- AWSSQSDataReader._convert did not unwrap the SNS notification
  envelope that SQS delivers when a queue is subscribed to an SNS
  topic. Add _decode_body to transparently extract the nested record.

Add gated integration tests (tests/integration/test_message_data_brokers.py)
that verify real SNS→SQS and PubSub topic→subscription round-trips.
Tests are skipped unless PLUGBOARD_MESSAGE_BROKER_TESTS is set, and the
CI workflow enables them in the test-integration job with the
Terraform-provisioned credentials.
@github-actions

Copy link
Copy Markdown

Benchmark comparison for 894671bb (base) vs cc5bd585 (PR)


------------------------------------------------------------------------------------------------------------------ benchmark: 2 tests -----------------------------------------------------------------------------------------------------------------
Name (time in ms)                                                                         Min                 Max                Mean            StdDev              Median               IQR            Outliers     OPS            Rounds  Iterations
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
test_benchmark_process_run (main/.benchmarks/Linux-CPython-3.14-64bit/0001_base)     541.0747 (1.0)      547.3276 (1.0)      543.5680 (1.0)      2.4714 (1.0)      543.0538 (1.0)      3.5568 (1.0)           2;0  1.8397 (1.0)           5           1
test_benchmark_process_run (pr/.benchmarks/Linux-CPython-3.14-64bit/0001_pr)         543.4579 (1.00)     552.7151 (1.01)     547.4215 (1.01)     3.8700 (1.57)     545.5310 (1.00)     6.0558 (1.70)          2;0  1.8267 (0.99)          5           1
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

Legend:
  Outliers: 1 Standard Deviation from Mean; 1.5 IQR (InterQuartile Range) from 1st Quartile and 3rd Quartile.
  OPS: Operations Per Second, computed as 1 / Mean

Before each test the queue/subscription is checked and, if non-empty, a
warning is emitted and it is drained, then the clean state is asserted.
After each test it is drained again so a failed run leaves no messages to
interfere with the next one.

- AWS: check via ApproximateNumberOfMessages, drain via purge +
  receive-and-delete (SQS is the sink for the SNS fan-out, so anything
  left on it is stray).
- GCP: a single timed pull-and-ack drain both checks and empties the
  subscription (only pubsub.subscriptions.pull/acknowledge are needed).
@github-actions

Copy link
Copy Markdown

Benchmark comparison for 894671bb (base) vs 19a55895 (PR)


------------------------------------------------------------------------------------------------------------------ benchmark: 2 tests -----------------------------------------------------------------------------------------------------------------
Name (time in ms)                                                                         Min                 Max                Mean            StdDev              Median               IQR            Outliers     OPS            Rounds  Iterations
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
test_benchmark_process_run (pr/.benchmarks/Linux-CPython-3.14-64bit/0001_pr)         539.0552 (1.0)      544.3096 (1.0)      540.9421 (1.0)      2.4832 (1.0)      539.2291 (1.0)      4.0804 (1.0)           1;0  1.8486 (1.0)           5           1
test_benchmark_process_run (main/.benchmarks/Linux-CPython-3.14-64bit/0001_base)     541.2221 (1.00)     556.9644 (1.02)     546.4537 (1.01)     6.1729 (2.49)     545.5545 (1.01)     6.1035 (1.50)          1;0  1.8300 (0.99)          5           1
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

Legend:
  Outliers: 1 Standard Deviation from Mean; 1.5 IQR (InterQuartile Range) from 1st Quartile and 3rd Quartile.
  OPS: Operations Per Second, computed as 1 / Mean

Blocking:
- B1: MessageDataReader.step now returns after io.close() (not raise), so an
  exhausted reader completes instead of being marked FAILED.
- B2: GCPPubSubDataReader._receive returns [] on a DeadlineExceeded idle pull
  instead of treating it as a broker error.
- B3: RetryPolicy.run now reconnects at the start of each retry attempt and skips
  that attempt when the reconnect fails, so the operation never runs on a
  disconnected client; on exhaustion the last real broker error is raised rather
  than a synthetic "not connected" error.
- B4: Removed unnecessary _connect() calls from pure _convert unit tests so they
  never reach a real cloud client.
- B5: Serialised the test-integration job with a concurrency group and gated
  PLUGBOARD_MESSAGE_BROKER_TESTS to the 3.12 matrix leg so the shared SQS queue
  and GCP subscription are not raced by concurrent jobs.

Warnings:
- W1: MessageDataReader._ack_pending retries acknowledgments through the policy.
- W2: gcp_pubsub_io._wrap_error maps NotFound to NoMoreDataException only when
  the reader is exhausted, else a permanent error.
- W3: GCP _decode_payload falls back to a "data" field for non-UTF-8, non-JSON,
  and JSON scalar/array payloads.
- W4: MessageDataReader/WriterArgsDict now inherit the DataReader/DataWriter
  specs; topic is NotRequired with an empty default.
- W5: Moved retry execution onto RetryPolicy.run and dropped the module-level
  helpers that exceeded the parameter limits.
- W6: resolve_argument takes keyword-only names and rejects empty resolutions.
- W7: Fixed the Raises sections on _fetch/_save and removed unimplemented
  MessageBrokerConnectionError claims from _connect.
- W8: Export RetryPolicy from plugboard.utils and plugboard.library; removed the
  internal resolve_argument from the public exports.
- W9: Assert the broker is empty right after reading and drain it in finally.
- W10: Drive the integration writer through its public Component lifecycle
  (AsyncioConnector inputs + step + io.close + run) and assert region /
  subscription path via recorded client calls instead of private attributes.
- W11: mock_s3_server now scopes its env vars with a MonkeyPatch context and
  keeps its temp file in a pytest temp dir.
- W12: Added unit tests for the _decode_body / _decode_payload fallbacks and the
  _ack / _disconnect error-wrapping branches.
- W13: Fixed the docs example to use AsyncioConnector-wrapped connectors,
  async with process, a transform that uses both inputs, and the project env var.
- W14: Condensed the message-data guide to user-facing bullets (including the
  writer chunk_size default) and linked the config table instead of duplicating.

Low:
- L1: parse_json is keyword-only in the shared encoders and broker constructors.
- L2: Extracted _parse_json_or_raw to de-nest _decode_body.
- L3: Removed the never-read project/subscription/topic attributes on GCP.
- L4: Trimmed the remaining rationale-narrating docstrings.
- L5: Shared the backoff_delays fixture in conftest so the writer retry tests run
  without real sleeps.
- L6: _drain_gcp_subscription catches DeadlineExceeded specifically.
- L7: Extracted a _log_leftover helper in the integration test.
- L8: Fixed the "evironment" typo and the /latest/ docs link in the README.
- L9: Removed the section-banner comments from the new test modules.
- L10: Deleted the superseded local proposal doc and its .gitignore entry.
@github-actions

Copy link
Copy Markdown

Benchmark comparison for 894671bb (base) vs 127c6157 (PR)


------------------------------------------------------------------------------------------------------------------ benchmark: 2 tests -----------------------------------------------------------------------------------------------------------------
Name (time in ms)                                                                         Min                 Max                Mean            StdDev              Median               IQR            Outliers     OPS            Rounds  Iterations
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
test_benchmark_process_run (main/.benchmarks/Linux-CPython-3.14-64bit/0001_base)     288.6633 (1.0)      295.6229 (1.0)      292.1001 (1.0)      2.9670 (1.42)     290.7422 (1.0)      4.8023 (1.62)          2;0  3.4235 (1.0)           5           1
test_benchmark_process_run (pr/.benchmarks/Linux-CPython-3.14-64bit/0001_pr)         292.5773 (1.01)     298.0208 (1.01)     295.2389 (1.01)     2.0967 (1.0)      295.7721 (1.02)     2.9567 (1.0)           2;0  3.3871 (0.99)          5           1
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

Legend:
  Outliers: 1 Standard Deviation from Mean; 1.5 IQR (InterQuartile Range) from 1st Quartile and 3rd Quartile.
  OPS: Operations Per Second, computed as 1 / Mean

The broker tests must run on every supported Python version, but they share a
single SQS queue and GCP subscription, so they cannot run at the same time.

- Split the broker tests out of the parallel test-integration matrix into a
  dedicated test-message-brokers job that runs them for 3.12, 3.13 and 3.14 in
  turn (a per-version uv loop in one job). A single job per run contributes at
  most one member to its concurrency group, so it is serialized across runs
  without ever cancelling a waiting request — the failure mode the previous
  shared-group matrix leg hit.
- Drop the concurrency group and the 3.12-only gate from test-integration, and
  exclude the broker test file so the matrix legs no longer touch the brokers.
- Move the GCP round-trip emptiness assertion out of finally and before the
  post-test drain, so a leftover unacked message is caught instead of being
  silently removed (and an assert no longer masks an exception from the try).
@github-actions

Copy link
Copy Markdown

Benchmark comparison for 894671bb (base) vs 99b2b2fa (PR)


------------------------------------------------------------------------------------------------------------------ benchmark: 2 tests -----------------------------------------------------------------------------------------------------------------
Name (time in ms)                                                                         Min                 Max                Mean            StdDev              Median               IQR            Outliers     OPS            Rounds  Iterations
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
test_benchmark_process_run (main/.benchmarks/Linux-CPython-3.14-64bit/0001_base)     289.3052 (1.0)      295.2576 (1.0)      292.4471 (1.0)      2.3102 (1.02)     293.1066 (1.0)      3.3521 (1.0)           2;0  3.4194 (1.0)           5           1
test_benchmark_process_run (pr/.benchmarks/Linux-CPython-3.14-64bit/0001_pr)         290.9803 (1.01)     296.0782 (1.00)     293.6102 (1.00)     2.2582 (1.0)      294.3366 (1.00)     4.0207 (1.20)          2;0  3.4059 (1.00)          5           1
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

Legend:
  Outliers: 1 Standard Deviation from Mean; 1.5 IQR (InterQuartile Range) from 1st Quartile and 3rd Quartile.
  OPS: Operations Per Second, computed as 1 / Mean

The message broker tests must run on every Python version, but they share a
single SQS queue and GCP subscription, so they cannot run in parallel. Instead
of forcing them into one serial job, each matrix leg now uses its own dedicated
broker lane (a separate SNS+SQS pair and Pub/Sub topic+subscription per Python
version, provisioned in plugboard-infra).

- Drop the serial test-message-brokers job and the --ignore of the broker test
  file; the broker round trips run again in the normal parallel test-integration
  matrix, one leg per version.
- Each leg resolves its lane from the new BROKER_LANES_JSON repository variable
  (keyed by python version) with jq, exporting only its own lane's queue/topic
  identifiers, so legs never contend on a shared broker.
- If the lanes variable is absent (e.g. a fork pull request) the broker tests
  self-skip; a present-but-missing lane for a version fails loudly.
@github-actions

Copy link
Copy Markdown

Benchmark comparison for 894671bb (base) vs f058e365 (PR)


------------------------------------------------------------------------------------------------------------------ benchmark: 2 tests -----------------------------------------------------------------------------------------------------------------
Name (time in ms)                                                                         Min                 Max                Mean            StdDev              Median               IQR            Outliers     OPS            Rounds  Iterations
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
test_benchmark_process_run (pr/.benchmarks/Linux-CPython-3.14-64bit/0001_pr)         315.9407 (1.0)      324.7480 (1.0)      321.1403 (1.0)      3.2571 (1.05)     321.6331 (1.0)      3.5164 (1.0)           2;0  3.1139 (1.0)           5           1
test_benchmark_process_run (main/.benchmarks/Linux-CPython-3.14-64bit/0001_base)     328.2504 (1.04)     335.5274 (1.03)     332.6135 (1.04)     3.0940 (1.0)      332.9642 (1.04)     5.1797 (1.47)          1;0  3.0065 (0.97)          5           1
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

Legend:
  Outliers: 1 Standard Deviation from Mean; 1.5 IQR (InterQuartile Range) from 1st Quartile and 3rd Quartile.
  OPS: Operations Per Second, computed as 1 / Mean

This branch has not been deployed

No deployments
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.

1 participant