mq-bridge-py-basic 0.4.2__tar.gz → 0.4.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/Cargo.lock +6 -6
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/Cargo.toml +2 -2
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/PKG-INFO +1 -1
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/docs/DELIVERY.md +62 -10
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/mq-bridge.schema.json +11 -1
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/mq_bridge/config.pyi +2 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/file/mod.rs +81 -5
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/file/tests.rs +163 -6
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/http/mod.rs +22 -3
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/http/tests.rs +351 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/mod.rs +210 -16
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/mongodb/readers.rs +85 -18
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/mongodb/tests.rs +46 -6
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/object_store.rs +334 -32
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/sqlx/mod.rs +53 -1
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/sqlx/tests.rs +109 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/models/builders.rs +1 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/models.rs +7 -1
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/route.rs +12 -1
- mq_bridge_py_basic-0.4.4/src/support/source_ranges.rs +801 -0
- mq_bridge_py_basic-0.4.4/tests/integration/docker-compose/mosquitto_persistence.conf +20 -0
- mq_bridge_py_basic-0.4.2/src/support/source_ranges.rs +0 -448
- mq_bridge_py_basic-0.4.2/tests/integration/docker-compose/mosquitto_persistence.conf +0 -18
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/.github/dependabot.yml +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/.github/workflows/benchmark.yml +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/.github/workflows/ci.yml +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/.github/workflows/ibm-mq.yml +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/.github/workflows/node.yml +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/.github/workflows/publish-node.yml +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/.github/workflows/publish-python.yml +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/.github/workflows/python.yml +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/.github/workflows/release.yml +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/.github/workflows/test-matrix.yml +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/.gitignore +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/CHANGELOG.md +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/CLAUDE.md +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/CONTRIBUTING.md +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/LICENSE +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/LICENSE.python +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/README.md +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/SECURITY.md +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/benches/ETL_BENCHMARKS.md +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/benches/performance_bench.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/benches/router_bench.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/benches/zeromq_backends.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/bindings-common/Cargo.toml +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/bindings-common/src/lib.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/bindings-common/src/logging.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/build.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/deny.toml +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/docs/ARCHITECTURE.md +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/docs/CONFIGURATION.md +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/docs/EXTENDING.md +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/docs/PLUGINS.md +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/docs/REFERENCE.md +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/examples/ipc_worker_queue.yaml +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/mq_bridge/__init__.py +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/mq_bridge/__init__.pyi +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/mq_bridge/config.py +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/mq_bridge/plugin_packaging.py +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/mq_bridge/py.typed +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/package-lock.json +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/pyproject.toml +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/python/mq-bridge-py/Cargo.toml +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/python/mq-bridge-py/LICENSE.python +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/python/mq-bridge-py/README.md +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/python/mq-bridge-py/analysis/bench_http_native.py +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/python/mq-bridge-py/example.py +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/python/mq-bridge-py/examples/bench_memory.py +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/python/mq-bridge-py/examples/bench_memory.yaml +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/python/mq-bridge-py/examples/consume_pull.py +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/python/mq-bridge-py/examples/dlt_source.py +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/python/mq-bridge-py/examples/json_route.py +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/python/mq-bridge-py/examples/memory.yaml +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/python/mq-bridge-py/examples/raw_route.py +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/python/mq-bridge-py/pyproject-basic.toml +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/python/mq-bridge-py/pyproject-reduced.toml +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/python/mq-bridge-py/scripts/gen_config_types.py +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/python/mq-bridge-py/src/lib.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/python/mq-bridge-py/tests/test_config_types.py +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/python/mq-bridge-py/tests/test_custom_endpoint.py +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/python/mq-bridge-py/tests/test_logging.py +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/python/mq-bridge-py/tests/test_packaging.py +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/python/mq-bridge-py/tests/test_performance_smoke.py +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/python/mq-bridge-py/tests/test_plugin_package.py +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/python/mq-bridge-py/tests/test_public_api.py +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/python/mq-bridge-py/tests/test_routes.py +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/python/mq-bridge-py/uv.lock +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/scripts/analysis/benches/metadata_bench.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/scripts/analysis/debug_ibm_mq_tls.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/scripts/analysis/http/analyze_xctrace_time_profile.py +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/scripts/analysis/http/http_flamegraph.py +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/scripts/analysis/http/mq_bridge_http_profile.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/scripts/analysis/http/run_http_ladder.sh +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/scripts/ci/format_bench_output.sh +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/scripts/httparena/README.md +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/scripts/httparena/frameworks/mq-bridge-py/Dockerfile +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/scripts/httparena/frameworks/mq-bridge-py/meta.json +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/scripts/httparena/frameworks/mq-bridge-py/server.py +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/scripts/techempower/Python/mq-bridge-py/README.md +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/scripts/techempower/Python/mq-bridge-py/benchmark_config.json +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/scripts/techempower/Python/mq-bridge-py/mq-bridge-py.dockerfile +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/scripts/techempower/Python/mq-bridge-py/server.py +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/scripts/techempower/README.md +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/scripts/techempower/postgres.yml +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/scripts/techempower/seed.sql +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/scripts/techempower/verify.sh +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/scripts/the-benchmarker/README.md +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/scripts/the-benchmarker/python/mq-bridge-py/config.yaml +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/scripts/the-benchmarker/python/mq-bridge-py/pyproject.toml +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/scripts/the-benchmarker/python/mq-bridge-py/server.py +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/canonical_message.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/checkpoint.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/command_handler.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/amqp.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/aws.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/clickhouse.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/grpc.proto +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/grpc.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/http/publisher.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/http/stream.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/ibm_mq.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/kafka.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/memory/endpoint.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/memory/framed.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/memory/ipc_unix.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/memory/ipc_windows.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/memory/memory_transport.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/memory/mod.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/memory/transport.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/mongodb/consumer.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/mongodb/dedup.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/mongodb/mod.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/mongodb/publisher.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/mqtt.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/nats.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/poll.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/postgres/VENDORED.md +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/postgres/mod.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/postgres/pgoutput/decoder.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/postgres/pgoutput/messages.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/postgres/pgoutput/mod.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/postgres/pgoutput/registry.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/postgres/pgoutput/values.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/postgres/replication.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/postgres/state.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/redis_streams.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/sled.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/sqlx/dedup.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/structural/fanout.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/structural/mod.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/structural/null.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/structural/reader.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/structural/request.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/structural/response.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/structural/static_endpoint.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/structural/stream_buffer.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/structural/switch.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/websocket.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/zeromq/codec.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/zeromq/mod.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/zeromq/omq.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/zeromq/zmq.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/errors.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/event_handler.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/event_store.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/extensions.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/lib.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/middleware/buffer.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/middleware/compression.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/middleware/cookie_jar.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/middleware/deduplication.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/middleware/delay.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/middleware/dlq.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/middleware/encryption.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/middleware/id.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/middleware/limiter.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/middleware/metrics.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/middleware/mod.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/middleware/random_panic.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/middleware/retry.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/middleware/transform/coerce.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/middleware/transform/compiled.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/middleware/transform/error.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/middleware/transform/mod.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/middleware/transform/path.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/middleware/transform/schema.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/middleware/transform/tests.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/middleware/weak_join.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/models/defaults.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/models/secrets.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/models/serde_support.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/models/tests.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/outcomes.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/plugin/conformance.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/plugin/endpoint.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/plugin/message.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/plugin/middleware.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/plugin/mod.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/plugin/sdk.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/plugin/test_support.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/publisher.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/response.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/support/base64_engine.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/support/compression.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/support/connection_registry.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/support/crypto.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/support/crypto_envelope.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/support/interpolation.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/support/mod.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/support/plugin_abi.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/test_utils.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/traits.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/type_handler.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/README.md +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/armature_integration.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/amqp.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/aws.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/clickhouse.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/config.toml +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/amqp.yml +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/aws.yml +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/clickhouse.yml +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/ibm-mq-certs/client.crl +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/ibm-mq-certs/client.kdb +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/ibm-mq-certs/client.p12 +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/ibm-mq-certs/client.rdb +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/ibm-mq-certs/client.sth +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/ibm_mq.yml +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/ibm_mq_tls.yml +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/kafka-tls.yml +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/kafka.yml +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/mariadb.yml +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/mongodb-replica.yml +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/mongodb-tls.yml +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/mongodb.yml +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/mosquitto.conf +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/mosquitto_performance.conf +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/mq_init.mqsc +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/mq_init_tls.mqsc +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/mqtt.yml +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/mqtt_performance.yml +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/mysql.yml +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/nats.yml +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/object_store.yml +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/postgres.yml +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/postgres_cdc.yml +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/redis.yml +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/file.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/grpc.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/grpc_tls.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/http.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/http_tls.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/ibm_mq.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/ibm_mq_tls.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/ipc.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/kafka.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/kafka_tls.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/logic_test.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/mariadb.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/memory.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/mod.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/mongodb.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/mongodb_raw.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/mongodb_tls.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/mqtt.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/mysql.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/nats.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/object_store.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/performance_static.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/postgres.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/postgres_cdc.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/redis_streams.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/route.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/scripts/gen_certs.sh +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/sqlite.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/tls_helpers.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/websocket.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/zeromq.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration_test.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/memory_leak_test.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/ref_test.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/reference_docs_test.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/sqlite_test.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/tls_example.rs +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/unit_tests.md +0 -0
- {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/websocket_test.rs +0 -0
|
@@ -1900,9 +1900,9 @@ dependencies = [
|
|
|
1900
1900
|
|
|
1901
1901
|
[[package]]
|
|
1902
1902
|
name = "fast-uuid-v7"
|
|
1903
|
-
version = "0.1.
|
|
1903
|
+
version = "0.1.9"
|
|
1904
1904
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1905
|
-
checksum = "
|
|
1905
|
+
checksum = "dc759201eb9b09178fb2174087a451d19fd46a8b8b4f5f6103de83b9f5949f52"
|
|
1906
1906
|
dependencies = [
|
|
1907
1907
|
"rand 0.10.2",
|
|
1908
1908
|
]
|
|
@@ -3348,7 +3348,7 @@ dependencies = [
|
|
|
3348
3348
|
|
|
3349
3349
|
[[package]]
|
|
3350
3350
|
name = "mq-bridge"
|
|
3351
|
-
version = "0.4.
|
|
3351
|
+
version = "0.4.4"
|
|
3352
3352
|
dependencies = [
|
|
3353
3353
|
"aes-gcm",
|
|
3354
3354
|
"anyhow",
|
|
@@ -3431,7 +3431,7 @@ dependencies = [
|
|
|
3431
3431
|
|
|
3432
3432
|
[[package]]
|
|
3433
3433
|
name = "mq-bridge-bindings-common"
|
|
3434
|
-
version = "0.4.
|
|
3434
|
+
version = "0.4.4"
|
|
3435
3435
|
dependencies = [
|
|
3436
3436
|
"anyhow",
|
|
3437
3437
|
"fast-uuid-v7",
|
|
@@ -3445,7 +3445,7 @@ dependencies = [
|
|
|
3445
3445
|
|
|
3446
3446
|
[[package]]
|
|
3447
3447
|
name = "mq-bridge-node"
|
|
3448
|
-
version = "0.4.
|
|
3448
|
+
version = "0.4.4"
|
|
3449
3449
|
dependencies = [
|
|
3450
3450
|
"anyhow",
|
|
3451
3451
|
"async-trait",
|
|
@@ -3487,7 +3487,7 @@ dependencies = [
|
|
|
3487
3487
|
|
|
3488
3488
|
[[package]]
|
|
3489
3489
|
name = "mq-bridge-py"
|
|
3490
|
-
version = "0.4.
|
|
3490
|
+
version = "0.4.4"
|
|
3491
3491
|
dependencies = [
|
|
3492
3492
|
"anyhow",
|
|
3493
3493
|
"async-trait",
|
|
@@ -16,13 +16,13 @@ members = ["bindings-common", "python/mq-bridge-py"]
|
|
|
16
16
|
resolver = "2"
|
|
17
17
|
|
|
18
18
|
[workspace.package]
|
|
19
|
-
version = "0.4.
|
|
19
|
+
version = "0.4.4"
|
|
20
20
|
|
|
21
21
|
[workspace.dependencies]
|
|
22
22
|
anyhow = "1.0"
|
|
23
23
|
async-trait = "0.1"
|
|
24
24
|
bytes = "1"
|
|
25
|
-
fast-uuid-v7 = "0.1.
|
|
25
|
+
fast-uuid-v7 = "0.1.9"
|
|
26
26
|
serde = "1.0"
|
|
27
27
|
serde_json = { version = "1.0", features = ["raw_value"] }
|
|
28
28
|
serde_yaml_ng = "0.10"
|
|
@@ -22,7 +22,7 @@ sink writes data.
|
|
|
22
22
|
| MongoDB | `id_field` set to a payload field or replay-stable template such as `${metadata:mqb.id}` |
|
|
23
23
|
| PostgreSQL / SQLite | `sqlx.insert_query` uses a unique key with `ON CONFLICT` |
|
|
24
24
|
| MySQL / MariaDB | `sqlx.insert_query` uses a unique key with `ON DUPLICATE KEY` |
|
|
25
|
-
| File / object store | `idempotency: true` with a replayable Kafka
|
|
25
|
+
| File / object store | `idempotency: true` with a replayable Kafka, Postgres CDC, SQL cursor, MongoDB CDC or file source |
|
|
26
26
|
|
|
27
27
|
For a source without stable identity, derive one once and consume it at the sink:
|
|
28
28
|
|
|
@@ -143,7 +143,7 @@ one identity to every message missing the field, so it is dropped instead.
|
|
|
143
143
|
| `mongodb` | Unique `_id` index; dup-key (11000) is treated as an idempotent success | `id_field` |
|
|
144
144
|
| `sqlx` (PostgreSQL / MySQL / SQLite) | The table's own `UNIQUE`/`PRIMARY KEY` | `ON CONFLICT` / `ON DUPLICATE KEY` in `insert_query` |
|
|
145
145
|
| `clickhouse` | `ReplacingMergeTree` collapses at merge time | Table DDL — no `mq-bridge` config |
|
|
146
|
-
| `file`, `object_store` | Deterministic part names + covered-range recovery | `idempotency: true` (needs a replayable source) |
|
|
146
|
+
| `file`, `object_store` | Deterministic, sortable part names + covered-range recovery | `idempotency: true` (needs a replayable source) |
|
|
147
147
|
| `kafka` | `enable.idempotence` dedups **producer retries within one session** — this is *not* exactly-once semantics | On by default |
|
|
148
148
|
| `nats`, `amqp`, `mqtt`, `redis_streams`, `aws`, `ibm_mq`, `zeromq` | None | Deduplicate at the next consumer instead |
|
|
149
149
|
|
|
@@ -359,6 +359,13 @@ but before the branch's downstream send committed, the replay hits a duplicate k
|
|
|
359
359
|
|
|
360
360
|
### Files & object storage — `idempotency`
|
|
361
361
|
|
|
362
|
+
> **Check first whether you need any of this.** If your records already carry a business key — an
|
|
363
|
+
> `id` field in the payload, or `mqb.id` from the [`id` middleware](#giving-a-source-an-identity) —
|
|
364
|
+
> then a key-addressed sink (Mongo `id_field`, SQL `ON CONFLICT`) already gives you effective
|
|
365
|
+
> exactly-once, and the order objects happen to land in does not matter. Positional naming below is
|
|
366
|
+
> for the case where the *sink itself* has to recognise a replay, or where a downstream reader
|
|
367
|
+
> depends on replay **order**. Turning it on when you do not need it only adds restrictions.
|
|
368
|
+
|
|
362
369
|
A filesystem has no unique constraint, so the `file` and `object_store` sinks get replay safety a
|
|
363
370
|
different way: **deterministic names plus covered-range recovery**. Set `idempotency: true` and the
|
|
364
371
|
sink stops writing UUID-named objects. Instead it groups each batch into runs of consecutive source
|
|
@@ -371,7 +378,7 @@ kafka_to_s3:
|
|
|
371
378
|
output:
|
|
372
379
|
object_store:
|
|
373
380
|
url: "s3://my-bucket/orders"
|
|
374
|
-
idempotency: true # →
|
|
381
|
+
idempotency: true # → part-orders-<partition>-<start>-<end>.jsonl (zero-padded)
|
|
375
382
|
```
|
|
376
383
|
|
|
377
384
|
On startup the sink lists what is already there, parses the ranges out of the part names, and drops
|
|
@@ -381,13 +388,58 @@ checkpoint protocol. Local files stage to a temp name, `fsync`, then `rename` (a
|
|
|
381
388
|
filesystem); object stores have no atomic rename, so a single PUT under the final name *is* the
|
|
382
389
|
commit.
|
|
383
390
|
|
|
384
|
-
This needs a replayable source position
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
`
|
|
390
|
-
|
|
391
|
+
This needs a replayable source position. Today that means:
|
|
392
|
+
|
|
393
|
+
| Source | Position | Restriction |
|
|
394
|
+
| --- | --- | --- |
|
|
395
|
+
| `kafka` | topic / partition / offset | — |
|
|
396
|
+
| `postgres_cdc` | commit LSN + in-transaction ordinal | `sqlx` with `publication` maps onto the same consumer |
|
|
397
|
+
| `mongodb` | cluster time + ordinal; the initial snapshot uses its `_id` scan index | `capture_new` / `capture_all` only — `consumer` and `snapshot` have no position |
|
|
398
|
+
| `file` | record index within the file | all modes; only `consume` deduplicates across runs (see below) |
|
|
399
|
+
| `sqlx` | the `cursor_column` value | cursor polling only; the column must be a unique, non-negative integer |
|
|
400
|
+
|
|
401
|
+
A route with an idempotent output turns `source_metadata` on for its input automatically; set it
|
|
402
|
+
explicitly only when you want the `mqb.src.*` keys for something else. Any other input is rejected
|
|
403
|
+
when the route starts. NATS and AMQP also accept `source_metadata` and emit provenance keys, but a
|
|
404
|
+
subject or routing key is not a replayable offset, so they cannot drive an idempotent sink.
|
|
405
|
+
|
|
406
|
+
**Without `idempotency: true`, an `object_store` sink names objects in write order**, and key order
|
|
407
|
+
is the only order a bucket has. At `concurrency: 1` that is source order; above it the name is minted
|
|
408
|
+
inside the worker pool, so it is arrival order, and replaying a change stream through the bucket can
|
|
409
|
+
reorder updates to the same key. The route logs a warning when it starts in that configuration.
|
|
410
|
+
|
|
411
|
+
**Numbers inside a part name are zero-padded** so that ASCII sort equals numeric sort — a bucket is
|
|
412
|
+
listed lexicographically and that listing order *is* the replay order. A bucket written by a version
|
|
413
|
+
before this padding contains unpadded names; the two forms do not sort correctly against each other,
|
|
414
|
+
so do not mix them in one prefix.
|
|
415
|
+
|
|
416
|
+
**MongoDB `capture_all`** reads the collection, then streams changes. Both phases are numbered into
|
|
417
|
+
the key so the snapshot always sorts ahead of the changes; a change can never replay before the
|
|
418
|
+
document it modifies. The snapshot does not resume across runs — it re-scans from the start — but the
|
|
419
|
+
numbering is deterministic, so a restart reproduces the same names and the covered-range recovery
|
|
420
|
+
skips them. The exception is inserts or deletes landing in the collection between the two runs: those
|
|
421
|
+
shift the numbering, and some documents are then written twice.
|
|
422
|
+
|
|
423
|
+
**The `sqlx` cursor source** uses the `cursor_column` value itself as the position — the same shape
|
|
424
|
+
Kafka Connect's S3 sink gives a Kafka offset. That only works for a unique, non-negative integer
|
|
425
|
+
column: a text cursor pages fine but has no contiguous numeric order, and a repeated value would
|
|
426
|
+
resolve two rows to one position and drop one of them. The reader rejects both at read time rather
|
|
427
|
+
than naming records wrongly. Consecutive ids coalesce into one part file; gaps split it.
|
|
428
|
+
|
|
429
|
+
**The `file` source** numbers records by their index in the file, not their byte offset (byte offsets
|
|
430
|
+
are not consecutive, so every record would become its own part).
|
|
431
|
+
|
|
432
|
+
That index repeats across runs only in `consume` mode, which always reads from byte 0 — and repeating
|
|
433
|
+
is the point: a re-read produces the same names, so the sink recognises them as already covered and
|
|
434
|
+
writes nothing. `subscribe` starts at the current end of the file and `group_subscribe` resumes at a
|
|
435
|
+
stored byte offset, so their index restarts at 0 over records an earlier run already numbered. Those
|
|
436
|
+
two modes therefore carry a **run epoch** in the object name.
|
|
437
|
+
|
|
438
|
+
The epoch keeps names distinct and keeps runs in order (a later run reads later records, so its
|
|
439
|
+
objects sort after the earlier run's). What it does not give you is deduplication across a restart:
|
|
440
|
+
records re-read after a crash are written again under new names. That is ordinary at-least-once, and
|
|
441
|
+
it is the honest guarantee for a source with no durable per-record position — these modes are
|
|
442
|
+
allowed, not rejected, because that guarantee is fine for plenty of pipelines.
|
|
391
443
|
|
|
392
444
|
For the `file` sink, `idempotency: true` changes what `path` means: it is the directory that receives
|
|
393
445
|
the part files, not the file that is appended to. The sink creates it on startup, so pointing it at an
|
|
@@ -1012,13 +1012,18 @@
|
|
|
1012
1012
|
"default": "normal"
|
|
1013
1013
|
},
|
|
1014
1014
|
"idempotency": {
|
|
1015
|
-
"description": "Write replay-safe source ranges as immutable part files under this directory.\nRequires
|
|
1015
|
+
"description": "Write replay-safe source ranges as immutable part files under this directory.\nRequires a source that stamps a replayable position: kafka, postgres_cdc, mongodb CDC, sqlx or file.",
|
|
1016
1016
|
"type": "boolean",
|
|
1017
1017
|
"default": false
|
|
1018
1018
|
},
|
|
1019
1019
|
"path": {
|
|
1020
1020
|
"description": "Path to the file, or to the directory holding the part files when `idempotency` is true.",
|
|
1021
1021
|
"type": "string"
|
|
1022
|
+
},
|
|
1023
|
+
"source_metadata": {
|
|
1024
|
+
"description": "(Consumer only) Include authoritative `mqb.src.file_*` source positions; only `consume` mode reproduces them across restarts. Defaults to false.",
|
|
1025
|
+
"type": "boolean",
|
|
1026
|
+
"default": false
|
|
1022
1027
|
}
|
|
1023
1028
|
},
|
|
1024
1029
|
"anyOf": [
|
|
@@ -3217,6 +3222,11 @@
|
|
|
3217
3222
|
"null"
|
|
3218
3223
|
]
|
|
3219
3224
|
},
|
|
3225
|
+
"source_metadata": {
|
|
3226
|
+
"description": "(Consumer only) Include authoritative `mqb.src.sqlx_*` source positions; `cursor_column` must then be a unique integer. Defaults to false.",
|
|
3227
|
+
"type": "boolean",
|
|
3228
|
+
"default": false
|
|
3229
|
+
},
|
|
3220
3230
|
"table": {
|
|
3221
3231
|
"description": "The table to interact with.",
|
|
3222
3232
|
"type": "string"
|
|
@@ -154,6 +154,7 @@ class FileConfig(TypedDict, total=False):
|
|
|
154
154
|
format: FileFormat
|
|
155
155
|
idempotency: bool
|
|
156
156
|
path: Required[str]
|
|
157
|
+
source_metadata: bool
|
|
157
158
|
|
|
158
159
|
|
|
159
160
|
class GrpcConfig(TypedDict, total=False):
|
|
@@ -476,6 +477,7 @@ class SqlxConfig(TypedDict, total=False):
|
|
|
476
477
|
select_query: Optional[str]
|
|
477
478
|
shared: Optional[bool]
|
|
478
479
|
slot_name: Optional[str]
|
|
480
|
+
source_metadata: bool
|
|
479
481
|
table: Required[str]
|
|
480
482
|
test_before_acquire: Optional[bool]
|
|
481
483
|
tls: TlsConfig
|
|
@@ -19,7 +19,7 @@ use std::any::Any;
|
|
|
19
19
|
use std::collections::HashMap;
|
|
20
20
|
use std::io::Seek;
|
|
21
21
|
use std::path::Path;
|
|
22
|
-
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
|
22
|
+
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
|
23
23
|
use std::sync::{Arc, Mutex as StdMutex, Weak};
|
|
24
24
|
use std::time::{Duration, SystemTime};
|
|
25
25
|
use tokio::fs::{self, File, OpenOptions};
|
|
@@ -2013,24 +2013,73 @@ pub struct FileConsumer {
|
|
|
2013
2013
|
/// A live tail keeps waiting for the file to appear; a drain (`exit_on_empty`)
|
|
2014
2014
|
/// cannot, so it fails with this instead of blocking forever.
|
|
2015
2015
|
startup_open_error: Option<String>,
|
|
2016
|
+
/// Stamp `mqb.src.file_*` so an idempotent sink can name records in source order.
|
|
2017
|
+
source_metadata: bool,
|
|
2018
|
+
/// Index of the next record in this file.
|
|
2019
|
+
next_record: u64,
|
|
2020
|
+
/// Set only for the modes that do not start at byte 0, so their record indexes cannot
|
|
2021
|
+
/// collide with a previous run's. `None` for `consume`, whose names must repeat.
|
|
2022
|
+
run_epoch: Option<u64>,
|
|
2016
2023
|
exit_on_empty: bool,
|
|
2017
2024
|
}
|
|
2018
2025
|
|
|
2026
|
+
/// Hands out a strictly increasing run epoch. Seeded from wall-clock millis so epochs still
|
|
2027
|
+
/// sort across process restarts, but never repeats or goes backwards within one process —
|
|
2028
|
+
/// consumers built in the same millisecond, or across a clock step back, stay distinct.
|
|
2029
|
+
fn next_run_epoch() -> u64 {
|
|
2030
|
+
static LAST: AtomicU64 = AtomicU64::new(0);
|
|
2031
|
+
let now = SystemTime::now()
|
|
2032
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
2033
|
+
.map(|d| d.as_millis() as u64)
|
|
2034
|
+
.unwrap_or(0);
|
|
2035
|
+
let mut last = LAST.load(Ordering::Relaxed);
|
|
2036
|
+
loop {
|
|
2037
|
+
let epoch = now.max(last + 1);
|
|
2038
|
+
match LAST.compare_exchange_weak(last, epoch, Ordering::Relaxed, Ordering::Relaxed) {
|
|
2039
|
+
Ok(_) => return epoch,
|
|
2040
|
+
Err(observed) => last = observed,
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2019
2045
|
impl FileConsumer {
|
|
2020
2046
|
fn wrap(backend: ConsumerBackend) -> Self {
|
|
2021
2047
|
Self {
|
|
2022
2048
|
backend,
|
|
2023
2049
|
path: String::new(),
|
|
2024
2050
|
startup_open_error: None,
|
|
2051
|
+
source_metadata: false,
|
|
2052
|
+
next_record: 0,
|
|
2053
|
+
run_epoch: None,
|
|
2025
2054
|
exit_on_empty: false,
|
|
2026
2055
|
}
|
|
2027
2056
|
}
|
|
2028
2057
|
|
|
2029
2058
|
pub async fn new(config: &FileConfig) -> anyhow::Result<Self> {
|
|
2059
|
+
Self::new_with_source_metadata(config, config.source_metadata).await
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
/// `source_metadata` is the effective flag: the route enables it for an idempotent
|
|
2063
|
+
/// output even when the input config leaves it unset.
|
|
2064
|
+
pub async fn new_with_source_metadata(
|
|
2065
|
+
config: &FileConfig,
|
|
2066
|
+
source_metadata: bool,
|
|
2067
|
+
) -> anyhow::Result<Self> {
|
|
2030
2068
|
let startup_open_error = probe_source_path(&config.path)?;
|
|
2031
2069
|
let mut consumer = Self::new_backend(config).await?;
|
|
2032
2070
|
consumer.path = config.path.clone();
|
|
2033
2071
|
consumer.startup_open_error = startup_open_error;
|
|
2072
|
+
consumer.source_metadata = source_metadata;
|
|
2073
|
+
// `consume` always reads from byte 0, so its record index is reproducible and two
|
|
2074
|
+
// runs deliberately produce the same names — that is what makes the sink idempotent.
|
|
2075
|
+
// `subscribe` starts at the current end and `group_subscribe` resumes at a stored
|
|
2076
|
+
// byte offset, so their index restarts at 0 over records a previous run already
|
|
2077
|
+
// numbered. Without an epoch those names would collide and the sink would discard
|
|
2078
|
+
// the new records as already-covered; with one they stay distinct and ordered, at
|
|
2079
|
+
// the cost of cross-restart deduplication.
|
|
2080
|
+
consumer.run_epoch = (source_metadata
|
|
2081
|
+
&& !matches!(&config.mode, None | Some(FileConsumerMode::Consume { .. })))
|
|
2082
|
+
.then(next_run_epoch);
|
|
2034
2083
|
Ok(consumer)
|
|
2035
2084
|
}
|
|
2036
2085
|
|
|
@@ -2344,6 +2393,37 @@ impl MessageConsumer for FileConsumer {
|
|
|
2344
2393
|
// a cumulative byte offset (the max acked `file_offset`), so out-of-order
|
|
2345
2394
|
// commits could advance the offset past un-acked messages and lose them.
|
|
2346
2395
|
async fn receive_batch(&mut self, max_messages: usize) -> Result<ReceivedBatch, ConsumerError> {
|
|
2396
|
+
let mut batch = self.receive_batch_inner(max_messages).await?;
|
|
2397
|
+
if self.source_metadata {
|
|
2398
|
+
for message in &mut batch.messages {
|
|
2399
|
+
message
|
|
2400
|
+
.metadata
|
|
2401
|
+
.insert("mqb.src.file_path".to_string(), self.path.clone());
|
|
2402
|
+
message.metadata.insert(
|
|
2403
|
+
"mqb.src.file_record".to_string(),
|
|
2404
|
+
self.next_record.to_string(),
|
|
2405
|
+
);
|
|
2406
|
+
if let Some(epoch) = self.run_epoch {
|
|
2407
|
+
message
|
|
2408
|
+
.metadata
|
|
2409
|
+
.insert("mqb.src.file_epoch".to_string(), epoch.to_string());
|
|
2410
|
+
}
|
|
2411
|
+
self.next_record += 1;
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
Ok(batch)
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2417
|
+
fn as_any(&self) -> &dyn Any {
|
|
2418
|
+
self
|
|
2419
|
+
}
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
impl FileConsumer {
|
|
2423
|
+
async fn receive_batch_inner(
|
|
2424
|
+
&mut self,
|
|
2425
|
+
max_messages: usize,
|
|
2426
|
+
) -> Result<ReceivedBatch, ConsumerError> {
|
|
2347
2427
|
// The path was unopenable at startup. A live tail waits for it (rotation,
|
|
2348
2428
|
// a writer that hasn't created it yet), but a drain has nothing to wait
|
|
2349
2429
|
// for, so re-probe and fail rather than block forever on a typo'd path.
|
|
@@ -2566,10 +2646,6 @@ impl MessageConsumer for FileConsumer {
|
|
|
2566
2646
|
}
|
|
2567
2647
|
}
|
|
2568
2648
|
}
|
|
2569
|
-
|
|
2570
|
-
fn as_any(&self) -> &dyn Any {
|
|
2571
|
-
self
|
|
2572
|
-
}
|
|
2573
2649
|
}
|
|
2574
2650
|
|
|
2575
2651
|
/// Wraps a message body for the Json/Text file formats. Generic over the payload type
|
|
@@ -450,8 +450,8 @@ async fn idempotent_file_sink_replays_only_uncovered_kafka_offsets_after_restart
|
|
|
450
450
|
names,
|
|
451
451
|
vec![
|
|
452
452
|
".stage-inflight".to_string(),
|
|
453
|
-
"part-orders-
|
|
454
|
-
"part-orders-
|
|
453
|
+
"part-orders-0000000000-00000000000000000000-00000000000000000001.jsonl".to_string(),
|
|
454
|
+
"part-orders-0000000000-00000000000000000002-00000000000000000002.jsonl".to_string(),
|
|
455
455
|
]
|
|
456
456
|
);
|
|
457
457
|
}
|
|
@@ -489,7 +489,8 @@ async fn idempotent_file_parts_are_compressed_and_named_for_it() {
|
|
|
489
489
|
drop(publisher);
|
|
490
490
|
|
|
491
491
|
// One part file, named for the codec, holding one gzip member with both records.
|
|
492
|
-
let part =
|
|
492
|
+
let part =
|
|
493
|
+
output.join("part-orders-0000000000-00000000000000000000-00000000000000000001.jsonl.gz");
|
|
493
494
|
let raw = std::fs::read(&part).unwrap();
|
|
494
495
|
let plain =
|
|
495
496
|
crate::support::compression::decompress_all(crate::models::Compression::Gzip, &raw, None)
|
|
@@ -512,7 +513,12 @@ async fn idempotent_file_parts_are_compressed_and_named_for_it() {
|
|
|
512
513
|
.unwrap()
|
|
513
514
|
.map(|entry| entry.unwrap().file_name().into_string().unwrap())
|
|
514
515
|
.collect::<Vec<_>>();
|
|
515
|
-
assert_eq!(
|
|
516
|
+
assert_eq!(
|
|
517
|
+
names,
|
|
518
|
+
vec![
|
|
519
|
+
"part-orders-0000000000-00000000000000000000-00000000000000000001.jsonl.gz".to_string()
|
|
520
|
+
]
|
|
521
|
+
);
|
|
516
522
|
}
|
|
517
523
|
|
|
518
524
|
#[tokio::test]
|
|
@@ -533,6 +539,157 @@ async fn idempotent_file_sink_rejects_records_without_source_metadata() {
|
|
|
533
539
|
assert!(std::fs::read_dir(output).unwrap().next().is_none());
|
|
534
540
|
}
|
|
535
541
|
|
|
542
|
+
#[tokio::test]
|
|
543
|
+
async fn file_source_metadata_numbers_records_and_feeds_an_idempotent_sink() {
|
|
544
|
+
use crate::traits::MessageConsumer;
|
|
545
|
+
|
|
546
|
+
let dir = tempdir().unwrap();
|
|
547
|
+
let input = dir.path().join("orders.jsonl");
|
|
548
|
+
std::fs::write(&input, "{\"id\":1}\n{\"id\":2}\n{\"id\":3}\n").unwrap();
|
|
549
|
+
|
|
550
|
+
let source = FileConfig {
|
|
551
|
+
path: input.to_string_lossy().into_owned(),
|
|
552
|
+
source_metadata: true,
|
|
553
|
+
..Default::default()
|
|
554
|
+
};
|
|
555
|
+
let mut consumer = FileConsumer::new(&source).await.unwrap();
|
|
556
|
+
let batch = consumer.receive_batch(10).await.unwrap();
|
|
557
|
+
assert_eq!(batch.messages.len(), 3);
|
|
558
|
+
|
|
559
|
+
// Records are numbered by index, not byte offset, so they stay consecutive.
|
|
560
|
+
let records = batch
|
|
561
|
+
.messages
|
|
562
|
+
.iter()
|
|
563
|
+
.map(|m| m.metadata.get("mqb.src.file_record").unwrap().as_str())
|
|
564
|
+
.collect::<Vec<_>>();
|
|
565
|
+
assert_eq!(records, vec!["0", "1", "2"]);
|
|
566
|
+
|
|
567
|
+
// The whole batch lands as one part file covering records 0-2.
|
|
568
|
+
let output = dir.path().join("parts");
|
|
569
|
+
let sink = FileConfig {
|
|
570
|
+
path: output.to_string_lossy().into_owned(),
|
|
571
|
+
idempotency: true,
|
|
572
|
+
..Default::default()
|
|
573
|
+
};
|
|
574
|
+
let publisher = FilePublisher::new(&sink).await.unwrap();
|
|
575
|
+
publisher.send_batch(batch.messages).await.unwrap();
|
|
576
|
+
|
|
577
|
+
let names = std::fs::read_dir(&output)
|
|
578
|
+
.unwrap()
|
|
579
|
+
.map(|entry| entry.unwrap().file_name().into_string().unwrap())
|
|
580
|
+
.filter(|name| !name.starts_with(".stage"))
|
|
581
|
+
.collect::<Vec<_>>();
|
|
582
|
+
assert_eq!(names.len(), 1, "one object per contiguous run: {names:?}");
|
|
583
|
+
assert!(
|
|
584
|
+
names[0].ends_with("-00000000000000000000-00000000000000000002.jsonl"),
|
|
585
|
+
"unexpected part name {}",
|
|
586
|
+
names[0]
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
#[tokio::test]
|
|
591
|
+
async fn resuming_file_modes_get_a_run_epoch_so_reruns_cannot_reuse_a_record_index() {
|
|
592
|
+
use crate::support::source_ranges::SourcePosition;
|
|
593
|
+
use crate::traits::MessageConsumer;
|
|
594
|
+
|
|
595
|
+
let dir = tempdir().unwrap();
|
|
596
|
+
let input = dir.path().join("orders.jsonl");
|
|
597
|
+
std::fs::write(&input, "{\"id\":1}\n{\"id\":2}\n").unwrap();
|
|
598
|
+
|
|
599
|
+
let config = FileConfig {
|
|
600
|
+
path: input.to_string_lossy().into_owned(),
|
|
601
|
+
source_metadata: true,
|
|
602
|
+
mode: Some(FileConsumerMode::GroupSubscribe {
|
|
603
|
+
group_id: "g1".into(),
|
|
604
|
+
read_from_tail: false,
|
|
605
|
+
}),
|
|
606
|
+
..Default::default()
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
// Allowed, not rejected: the setup is only weaker, not wrong.
|
|
610
|
+
let mut first = FileConsumer::new(&config).await.unwrap();
|
|
611
|
+
let batch = first.receive_batch(10).await.unwrap();
|
|
612
|
+
assert!(!batch.messages.is_empty());
|
|
613
|
+
let first_run = SourcePosition::from_message(&batch.messages[0]).unwrap();
|
|
614
|
+
assert!(batch.messages[0]
|
|
615
|
+
.metadata
|
|
616
|
+
.contains_key("mqb.src.file_epoch"));
|
|
617
|
+
|
|
618
|
+
// A second run restarts the record index at 0, so the epoch is what stops it from
|
|
619
|
+
// naming those records the same as the first run's and having them dropped.
|
|
620
|
+
let mut second = FileConsumer::new(&config).await.unwrap();
|
|
621
|
+
let batch = second.receive_batch(10).await.unwrap();
|
|
622
|
+
let second_run = SourcePosition::from_message(&batch.messages[0]).unwrap();
|
|
623
|
+
|
|
624
|
+
assert_ne!(first_run.source, second_run.source);
|
|
625
|
+
// A later run reads later records, so its objects must sort after the earlier run's.
|
|
626
|
+
assert!(first_run.source < second_run.source);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
#[tokio::test]
|
|
630
|
+
async fn run_epochs_are_distinct_for_consumers_created_in_the_same_millisecond() {
|
|
631
|
+
use crate::support::source_ranges::SourcePosition;
|
|
632
|
+
|
|
633
|
+
let dir = tempdir().unwrap();
|
|
634
|
+
let input = dir.path().join("orders.jsonl");
|
|
635
|
+
std::fs::write(&input, "{\"id\":1}\n").unwrap();
|
|
636
|
+
|
|
637
|
+
let config = FileConfig {
|
|
638
|
+
path: input.to_string_lossy().into_owned(),
|
|
639
|
+
source_metadata: true,
|
|
640
|
+
mode: Some(FileConsumerMode::GroupSubscribe {
|
|
641
|
+
group_id: "same-ms".into(),
|
|
642
|
+
read_from_tail: false,
|
|
643
|
+
}),
|
|
644
|
+
..Default::default()
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
// No sleep between them: the epoch is allocated monotonically, not read off the clock.
|
|
648
|
+
let mut first = FileConsumer::new(&config).await.unwrap();
|
|
649
|
+
let mut second = FileConsumer::new(&config).await.unwrap();
|
|
650
|
+
|
|
651
|
+
let a = first.receive_batch(10).await.unwrap();
|
|
652
|
+
let b = second.receive_batch(10).await.unwrap();
|
|
653
|
+
let first_run = SourcePosition::from_message(&a.messages[0]).unwrap();
|
|
654
|
+
let second_run = SourcePosition::from_message(&b.messages[0]).unwrap();
|
|
655
|
+
|
|
656
|
+
assert!(
|
|
657
|
+
first_run.source < second_run.source,
|
|
658
|
+
"epochs must be distinct and increasing: {:?} vs {:?}",
|
|
659
|
+
first_run.source,
|
|
660
|
+
second_run.source
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
#[tokio::test]
|
|
665
|
+
async fn consume_mode_repeats_its_record_identity_across_runs() {
|
|
666
|
+
use crate::support::source_ranges::SourcePosition;
|
|
667
|
+
use crate::traits::MessageConsumer;
|
|
668
|
+
|
|
669
|
+
let dir = tempdir().unwrap();
|
|
670
|
+
let input = dir.path().join("orders.jsonl");
|
|
671
|
+
std::fs::write(&input, "{\"id\":1}\n{\"id\":2}\n").unwrap();
|
|
672
|
+
|
|
673
|
+
let config = FileConfig {
|
|
674
|
+
path: input.to_string_lossy().into_owned(),
|
|
675
|
+
source_metadata: true,
|
|
676
|
+
..Default::default()
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
let mut first = FileConsumer::new(&config).await.unwrap();
|
|
680
|
+
let a = first.receive_batch(10).await.unwrap();
|
|
681
|
+
let mut second = FileConsumer::new(&config).await.unwrap();
|
|
682
|
+
let b = second.receive_batch(10).await.unwrap();
|
|
683
|
+
|
|
684
|
+
// No epoch: re-reading the same file must produce the same names, which is exactly
|
|
685
|
+
// how the idempotent sink recognises the rewrite and skips it.
|
|
686
|
+
assert!(!a.messages[0].metadata.contains_key("mqb.src.file_epoch"));
|
|
687
|
+
assert_eq!(
|
|
688
|
+
SourcePosition::from_message(&a.messages[0]).unwrap(),
|
|
689
|
+
SourcePosition::from_message(&b.messages[0]).unwrap()
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
|
|
536
693
|
#[tokio::test]
|
|
537
694
|
async fn idempotent_file_sink_replays_postgres_cdc_changes_in_one_commit() {
|
|
538
695
|
fn postgres_message(ordinal: u64) -> crate::CanonicalMessage {
|
|
@@ -578,8 +735,8 @@ async fn idempotent_file_sink_replays_postgres_cdc_changes_in_one_commit() {
|
|
|
578
735
|
assert_eq!(
|
|
579
736
|
names,
|
|
580
737
|
vec![
|
|
581
|
-
"part-postgres_cdc-bridge_slot-
|
|
582
|
-
"part-postgres_cdc-bridge_slot-
|
|
738
|
+
"part-postgres_cdc-bridge_slot-00000000009876543210-0000000000-00000000000000000000-00000000000000000001.jsonl".to_string(),
|
|
739
|
+
"part-postgres_cdc-bridge_slot-00000000009876543210-0000000000-00000000000000000002-00000000000000000002.jsonl".to_string(),
|
|
583
740
|
]
|
|
584
741
|
);
|
|
585
742
|
}
|
|
@@ -643,8 +643,9 @@ fn http_version_str(version: hyper::Version) -> &'static str {
|
|
|
643
643
|
}
|
|
644
644
|
}
|
|
645
645
|
|
|
646
|
-
/// Credential-bearing request headers
|
|
647
|
-
///
|
|
646
|
+
/// Credential-bearing request headers. They reach metadata under
|
|
647
|
+
/// [`SENSITIVE_HEADER_METADATA_PREFIX`] rather than under their own name, so they stop at the
|
|
648
|
+
/// route handler instead of being forwarded.
|
|
648
649
|
fn is_sensitive_request_header(name: &str) -> bool {
|
|
649
650
|
const SENSITIVE: [&str; 6] = [
|
|
650
651
|
"authorization",
|
|
@@ -657,6 +658,15 @@ fn is_sensitive_request_header(name: &str) -> bool {
|
|
|
657
658
|
SENSITIVE.iter().any(|h| name.eq_ignore_ascii_case(h))
|
|
658
659
|
}
|
|
659
660
|
|
|
661
|
+
/// Metadata namespace for credential-bearing request headers, e.g. `mqb.src.http_authorization`,
|
|
662
|
+
/// keeping the header name verbatim after the prefix.
|
|
663
|
+
///
|
|
664
|
+
/// Sits under [`SOURCE_METADATA_PREFIX`](crate::canonical_message::SOURCE_METADATA_PREFIX), which
|
|
665
|
+
/// is what makes carrying the value safe: every publisher drops this prefix when serializing
|
|
666
|
+
/// metadata, so the route handler sees the header the client actually sent while no sink, and no
|
|
667
|
+
/// response header, ever does. See the `sensitive_header_*` tests.
|
|
668
|
+
pub const SENSITIVE_HEADER_METADATA_PREFIX: &str = "mqb.src.http_";
|
|
669
|
+
|
|
660
670
|
fn request_metadata_matches(request: &RequestMetadataView<'_>, key: &str, value: &str) -> bool {
|
|
661
671
|
match key {
|
|
662
672
|
HTTP_METHOD => request.method.as_str() == value,
|
|
@@ -1586,16 +1596,25 @@ async fn handle_request_internal(
|
|
|
1586
1596
|
content_encoding = Some(v_str.to_string());
|
|
1587
1597
|
}
|
|
1588
1598
|
let k_str = key.as_str();
|
|
1599
|
+
// `mqb.src.*` is reserved for framework provenance, so a request header may never
|
|
1600
|
+
// supply one: without this a client could forge its own `mqb.src.http_authorization`.
|
|
1589
1601
|
if k_str == HTTP_METHOD
|
|
1590
1602
|
|| k_str == HTTP_PATH
|
|
1591
1603
|
|| k_str == HTTP_QUERY
|
|
1592
1604
|
|| k_str == HTTP_VERSION
|
|
1593
1605
|
|| k_str.eq_ignore_ascii_case("tls_cipher_suite")
|
|
1594
1606
|
|| k_str.eq_ignore_ascii_case("tls_protocol_version")
|
|
1595
|
-
||
|
|
1607
|
+
|| crate::canonical_message::is_source_metadata_key(k_str)
|
|
1596
1608
|
{
|
|
1597
1609
|
continue;
|
|
1598
1610
|
}
|
|
1611
|
+
if is_sensitive_request_header(k_str) {
|
|
1612
|
+
metadata.insert(
|
|
1613
|
+
format!("{SENSITIVE_HEADER_METADATA_PREFIX}{k_str}"),
|
|
1614
|
+
v_str.to_string(),
|
|
1615
|
+
);
|
|
1616
|
+
continue;
|
|
1617
|
+
}
|
|
1599
1618
|
metadata.insert(k_str.to_string(), v_str.to_string());
|
|
1600
1619
|
}
|
|
1601
1620
|
}
|