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.
Files changed (290) hide show
  1. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/Cargo.lock +6 -6
  2. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/Cargo.toml +2 -2
  3. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/PKG-INFO +1 -1
  4. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/docs/DELIVERY.md +62 -10
  5. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/mq-bridge.schema.json +11 -1
  6. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/mq_bridge/config.pyi +2 -0
  7. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/file/mod.rs +81 -5
  8. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/file/tests.rs +163 -6
  9. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/http/mod.rs +22 -3
  10. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/http/tests.rs +351 -0
  11. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/mod.rs +210 -16
  12. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/mongodb/readers.rs +85 -18
  13. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/mongodb/tests.rs +46 -6
  14. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/object_store.rs +334 -32
  15. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/sqlx/mod.rs +53 -1
  16. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/sqlx/tests.rs +109 -0
  17. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/models/builders.rs +1 -0
  18. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/models.rs +7 -1
  19. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/route.rs +12 -1
  20. mq_bridge_py_basic-0.4.4/src/support/source_ranges.rs +801 -0
  21. mq_bridge_py_basic-0.4.4/tests/integration/docker-compose/mosquitto_persistence.conf +20 -0
  22. mq_bridge_py_basic-0.4.2/src/support/source_ranges.rs +0 -448
  23. mq_bridge_py_basic-0.4.2/tests/integration/docker-compose/mosquitto_persistence.conf +0 -18
  24. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  25. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  26. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  27. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/.github/dependabot.yml +0 -0
  28. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/.github/workflows/benchmark.yml +0 -0
  29. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/.github/workflows/ci.yml +0 -0
  30. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/.github/workflows/ibm-mq.yml +0 -0
  31. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/.github/workflows/node.yml +0 -0
  32. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/.github/workflows/publish-node.yml +0 -0
  33. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/.github/workflows/publish-python.yml +0 -0
  34. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/.github/workflows/python.yml +0 -0
  35. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/.github/workflows/release.yml +0 -0
  36. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/.github/workflows/test-matrix.yml +0 -0
  37. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/.gitignore +0 -0
  38. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/CHANGELOG.md +0 -0
  39. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/CLAUDE.md +0 -0
  40. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/CONTRIBUTING.md +0 -0
  41. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/LICENSE +0 -0
  42. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/LICENSE.python +0 -0
  43. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/README.md +0 -0
  44. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/SECURITY.md +0 -0
  45. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/benches/ETL_BENCHMARKS.md +0 -0
  46. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/benches/performance_bench.rs +0 -0
  47. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/benches/router_bench.rs +0 -0
  48. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/benches/zeromq_backends.rs +0 -0
  49. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/bindings-common/Cargo.toml +0 -0
  50. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/bindings-common/src/lib.rs +0 -0
  51. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/bindings-common/src/logging.rs +0 -0
  52. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/build.rs +0 -0
  53. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/deny.toml +0 -0
  54. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/docs/ARCHITECTURE.md +0 -0
  55. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/docs/CONFIGURATION.md +0 -0
  56. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/docs/EXTENDING.md +0 -0
  57. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/docs/PLUGINS.md +0 -0
  58. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/docs/REFERENCE.md +0 -0
  59. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/examples/ipc_worker_queue.yaml +0 -0
  60. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/mq_bridge/__init__.py +0 -0
  61. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/mq_bridge/__init__.pyi +0 -0
  62. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/mq_bridge/config.py +0 -0
  63. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/mq_bridge/plugin_packaging.py +0 -0
  64. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/mq_bridge/py.typed +0 -0
  65. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/package-lock.json +0 -0
  66. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/pyproject.toml +0 -0
  67. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/python/mq-bridge-py/Cargo.toml +0 -0
  68. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/python/mq-bridge-py/LICENSE.python +0 -0
  69. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/python/mq-bridge-py/README.md +0 -0
  70. {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
  71. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/python/mq-bridge-py/example.py +0 -0
  72. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/python/mq-bridge-py/examples/bench_memory.py +0 -0
  73. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/python/mq-bridge-py/examples/bench_memory.yaml +0 -0
  74. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/python/mq-bridge-py/examples/consume_pull.py +0 -0
  75. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/python/mq-bridge-py/examples/dlt_source.py +0 -0
  76. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/python/mq-bridge-py/examples/json_route.py +0 -0
  77. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/python/mq-bridge-py/examples/memory.yaml +0 -0
  78. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/python/mq-bridge-py/examples/raw_route.py +0 -0
  79. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/python/mq-bridge-py/pyproject-basic.toml +0 -0
  80. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/python/mq-bridge-py/pyproject-reduced.toml +0 -0
  81. {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
  82. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/python/mq-bridge-py/src/lib.rs +0 -0
  83. {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
  84. {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
  85. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/python/mq-bridge-py/tests/test_logging.py +0 -0
  86. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/python/mq-bridge-py/tests/test_packaging.py +0 -0
  87. {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
  88. {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
  89. {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
  90. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/python/mq-bridge-py/tests/test_routes.py +0 -0
  91. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/python/mq-bridge-py/uv.lock +0 -0
  92. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/scripts/analysis/benches/metadata_bench.rs +0 -0
  93. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/scripts/analysis/debug_ibm_mq_tls.rs +0 -0
  94. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/scripts/analysis/http/analyze_xctrace_time_profile.py +0 -0
  95. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/scripts/analysis/http/http_flamegraph.py +0 -0
  96. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/scripts/analysis/http/mq_bridge_http_profile.rs +0 -0
  97. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/scripts/analysis/http/run_http_ladder.sh +0 -0
  98. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/scripts/ci/format_bench_output.sh +0 -0
  99. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/scripts/httparena/README.md +0 -0
  100. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/scripts/httparena/frameworks/mq-bridge-py/Dockerfile +0 -0
  101. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/scripts/httparena/frameworks/mq-bridge-py/meta.json +0 -0
  102. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/scripts/httparena/frameworks/mq-bridge-py/server.py +0 -0
  103. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/scripts/techempower/Python/mq-bridge-py/README.md +0 -0
  104. {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
  105. {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
  106. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/scripts/techempower/Python/mq-bridge-py/server.py +0 -0
  107. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/scripts/techempower/README.md +0 -0
  108. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/scripts/techempower/postgres.yml +0 -0
  109. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/scripts/techempower/seed.sql +0 -0
  110. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/scripts/techempower/verify.sh +0 -0
  111. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/scripts/the-benchmarker/README.md +0 -0
  112. {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
  113. {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
  114. {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
  115. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/canonical_message.rs +0 -0
  116. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/checkpoint.rs +0 -0
  117. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/command_handler.rs +0 -0
  118. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/amqp.rs +0 -0
  119. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/aws.rs +0 -0
  120. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/clickhouse.rs +0 -0
  121. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/grpc.proto +0 -0
  122. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/grpc.rs +0 -0
  123. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/http/publisher.rs +0 -0
  124. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/http/stream.rs +0 -0
  125. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/ibm_mq.rs +0 -0
  126. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/kafka.rs +0 -0
  127. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/memory/endpoint.rs +0 -0
  128. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/memory/framed.rs +0 -0
  129. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/memory/ipc_unix.rs +0 -0
  130. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/memory/ipc_windows.rs +0 -0
  131. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/memory/memory_transport.rs +0 -0
  132. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/memory/mod.rs +0 -0
  133. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/memory/transport.rs +0 -0
  134. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/mongodb/consumer.rs +0 -0
  135. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/mongodb/dedup.rs +0 -0
  136. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/mongodb/mod.rs +0 -0
  137. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/mongodb/publisher.rs +0 -0
  138. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/mqtt.rs +0 -0
  139. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/nats.rs +0 -0
  140. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/poll.rs +0 -0
  141. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/postgres/VENDORED.md +0 -0
  142. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/postgres/mod.rs +0 -0
  143. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/postgres/pgoutput/decoder.rs +0 -0
  144. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/postgres/pgoutput/messages.rs +0 -0
  145. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/postgres/pgoutput/mod.rs +0 -0
  146. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/postgres/pgoutput/registry.rs +0 -0
  147. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/postgres/pgoutput/values.rs +0 -0
  148. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/postgres/replication.rs +0 -0
  149. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/postgres/state.rs +0 -0
  150. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/redis_streams.rs +0 -0
  151. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/sled.rs +0 -0
  152. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/sqlx/dedup.rs +0 -0
  153. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/structural/fanout.rs +0 -0
  154. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/structural/mod.rs +0 -0
  155. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/structural/null.rs +0 -0
  156. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/structural/reader.rs +0 -0
  157. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/structural/request.rs +0 -0
  158. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/structural/response.rs +0 -0
  159. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/structural/static_endpoint.rs +0 -0
  160. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/structural/stream_buffer.rs +0 -0
  161. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/structural/switch.rs +0 -0
  162. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/websocket.rs +0 -0
  163. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/zeromq/codec.rs +0 -0
  164. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/zeromq/mod.rs +0 -0
  165. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/zeromq/omq.rs +0 -0
  166. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/endpoints/zeromq/zmq.rs +0 -0
  167. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/errors.rs +0 -0
  168. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/event_handler.rs +0 -0
  169. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/event_store.rs +0 -0
  170. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/extensions.rs +0 -0
  171. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/lib.rs +0 -0
  172. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/middleware/buffer.rs +0 -0
  173. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/middleware/compression.rs +0 -0
  174. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/middleware/cookie_jar.rs +0 -0
  175. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/middleware/deduplication.rs +0 -0
  176. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/middleware/delay.rs +0 -0
  177. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/middleware/dlq.rs +0 -0
  178. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/middleware/encryption.rs +0 -0
  179. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/middleware/id.rs +0 -0
  180. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/middleware/limiter.rs +0 -0
  181. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/middleware/metrics.rs +0 -0
  182. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/middleware/mod.rs +0 -0
  183. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/middleware/random_panic.rs +0 -0
  184. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/middleware/retry.rs +0 -0
  185. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/middleware/transform/coerce.rs +0 -0
  186. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/middleware/transform/compiled.rs +0 -0
  187. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/middleware/transform/error.rs +0 -0
  188. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/middleware/transform/mod.rs +0 -0
  189. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/middleware/transform/path.rs +0 -0
  190. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/middleware/transform/schema.rs +0 -0
  191. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/middleware/transform/tests.rs +0 -0
  192. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/middleware/weak_join.rs +0 -0
  193. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/models/defaults.rs +0 -0
  194. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/models/secrets.rs +0 -0
  195. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/models/serde_support.rs +0 -0
  196. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/models/tests.rs +0 -0
  197. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/outcomes.rs +0 -0
  198. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/plugin/conformance.rs +0 -0
  199. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/plugin/endpoint.rs +0 -0
  200. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/plugin/message.rs +0 -0
  201. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/plugin/middleware.rs +0 -0
  202. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/plugin/mod.rs +0 -0
  203. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/plugin/sdk.rs +0 -0
  204. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/plugin/test_support.rs +0 -0
  205. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/publisher.rs +0 -0
  206. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/response.rs +0 -0
  207. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/support/base64_engine.rs +0 -0
  208. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/support/compression.rs +0 -0
  209. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/support/connection_registry.rs +0 -0
  210. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/support/crypto.rs +0 -0
  211. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/support/crypto_envelope.rs +0 -0
  212. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/support/interpolation.rs +0 -0
  213. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/support/mod.rs +0 -0
  214. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/support/plugin_abi.rs +0 -0
  215. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/test_utils.rs +0 -0
  216. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/traits.rs +0 -0
  217. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/src/type_handler.rs +0 -0
  218. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/README.md +0 -0
  219. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/armature_integration.rs +0 -0
  220. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/amqp.rs +0 -0
  221. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/aws.rs +0 -0
  222. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/clickhouse.rs +0 -0
  223. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/config.toml +0 -0
  224. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/amqp.yml +0 -0
  225. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/aws.yml +0 -0
  226. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/clickhouse.yml +0 -0
  227. {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
  228. {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
  229. {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
  230. {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
  231. {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
  232. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/ibm_mq.yml +0 -0
  233. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/ibm_mq_tls.yml +0 -0
  234. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/kafka-tls.yml +0 -0
  235. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/kafka.yml +0 -0
  236. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/mariadb.yml +0 -0
  237. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/mongodb-replica.yml +0 -0
  238. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/mongodb-tls.yml +0 -0
  239. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/mongodb.yml +0 -0
  240. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/mosquitto.conf +0 -0
  241. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/mosquitto_performance.conf +0 -0
  242. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/mq_init.mqsc +0 -0
  243. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/mq_init_tls.mqsc +0 -0
  244. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/mqtt.yml +0 -0
  245. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/mqtt_performance.yml +0 -0
  246. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/mysql.yml +0 -0
  247. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/nats.yml +0 -0
  248. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/object_store.yml +0 -0
  249. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/postgres.yml +0 -0
  250. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/postgres_cdc.yml +0 -0
  251. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/docker-compose/redis.yml +0 -0
  252. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/file.rs +0 -0
  253. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/grpc.rs +0 -0
  254. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/grpc_tls.rs +0 -0
  255. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/http.rs +0 -0
  256. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/http_tls.rs +0 -0
  257. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/ibm_mq.rs +0 -0
  258. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/ibm_mq_tls.rs +0 -0
  259. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/ipc.rs +0 -0
  260. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/kafka.rs +0 -0
  261. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/kafka_tls.rs +0 -0
  262. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/logic_test.rs +0 -0
  263. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/mariadb.rs +0 -0
  264. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/memory.rs +0 -0
  265. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/mod.rs +0 -0
  266. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/mongodb.rs +0 -0
  267. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/mongodb_raw.rs +0 -0
  268. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/mongodb_tls.rs +0 -0
  269. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/mqtt.rs +0 -0
  270. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/mysql.rs +0 -0
  271. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/nats.rs +0 -0
  272. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/object_store.rs +0 -0
  273. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/performance_static.rs +0 -0
  274. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/postgres.rs +0 -0
  275. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/postgres_cdc.rs +0 -0
  276. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/redis_streams.rs +0 -0
  277. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/route.rs +0 -0
  278. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/scripts/gen_certs.sh +0 -0
  279. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/sqlite.rs +0 -0
  280. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/tls_helpers.rs +0 -0
  281. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/websocket.rs +0 -0
  282. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration/zeromq.rs +0 -0
  283. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/integration_test.rs +0 -0
  284. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/memory_leak_test.rs +0 -0
  285. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/ref_test.rs +0 -0
  286. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/reference_docs_test.rs +0 -0
  287. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/sqlite_test.rs +0 -0
  288. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/tls_example.rs +0 -0
  289. {mq_bridge_py_basic-0.4.2 → mq_bridge_py_basic-0.4.4}/tests/unit_tests.md +0 -0
  290. {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.8"
1903
+ version = "0.1.9"
1904
1904
  source = "registry+https://github.com/rust-lang/crates.io-index"
1905
- checksum = "65d7a30239eba18ae6a60dc3c3df98af18f00bba69d2b1d5f2816a831e83bff3"
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.2"
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.2"
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.2"
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.2"
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.2"
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.8"
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"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mq-bridge-py-basic
3
- Version: 0.4.2
3
+ Version: 0.4.4
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: License :: OSI Approved :: MIT License
6
6
  Classifier: Programming Language :: Python :: 3
@@ -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 or Postgres CDC source |
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 # → s3://my-bucket/orders/part-orders-0-0-1023.jsonl
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, which today means **Kafka** (topic/partition/offset) or
385
- **`postgres_cdc`** (commit LSN plus in-transaction ordinal; `sqlx` with `publication` maps onto the
386
- same consumer). A route with an idempotent output turns `source_metadata` on for its input
387
- automatically; set it explicitly only when you want the `mqb.src.*` keys for something else. Any
388
- other input is rejected when the route starts. NATS, AMQP and MongoDB CDC also accept
389
- `source_metadata` and emit provenance keys, but a subject or routing key is not a replayable offset,
390
- so they cannot drive an idempotent sink.
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 Kafka source metadata or postgres_cdc commit LSN plus transaction ordinal metadata.",
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-0-0-1.jsonl".to_string(),
454
- "part-orders-0-2-2.jsonl".to_string(),
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 = output.join("part-orders-0-0-1.jsonl.gz");
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!(names, vec!["part-orders-0-0-1.jsonl.gz".to_string()]);
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-9876543210-0-0-1.jsonl".to_string(),
582
- "part-postgres_cdc-bridge_slot-9876543210-0-2-2.jsonl".to_string(),
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 that must not be copied into message
647
- /// metadata, where they could be logged, persisted, or echoed back on the reply.
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
- || is_sensitive_request_header(k_str)
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
  }