Repository: ThreeDotsLabs/watermill Branch: master Commit: c9b951f72c9d Files: 495 Total size: 2.1 MB Directory structure: gitextract_cdt0elb2/ ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ ├── config.yml │ │ └── feature_request.md │ ├── pull_request_template.md │ └── workflows/ │ ├── master.yml │ ├── pr-examples.yml │ ├── pr.yml │ └── tests.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── RELEASE-PROCEDURE.md ├── UPGRADE-0.3.md ├── UPGRADE-0.4.md ├── UPGRADE-1.0.md ├── _examples/ │ ├── basic/ │ │ ├── 1-your-first-app/ │ │ │ ├── .validate_example.yml │ │ │ ├── README.md │ │ │ ├── docker-compose.yml │ │ │ ├── go.mod │ │ │ ├── go.sum │ │ │ └── main.go │ │ ├── 2-realtime-feed/ │ │ │ ├── .validate_example_subscribing.yml │ │ │ ├── README.md │ │ │ ├── consumer/ │ │ │ │ ├── go.mod │ │ │ │ ├── go.sum │ │ │ │ └── main.go │ │ │ ├── docker-compose.yml │ │ │ └── producer/ │ │ │ ├── go.mod │ │ │ ├── go.sum │ │ │ └── main.go │ │ ├── 3-router/ │ │ │ ├── .validate_example.yml │ │ │ ├── go.mod │ │ │ ├── go.sum │ │ │ └── main.go │ │ ├── 4-metrics/ │ │ │ ├── .validate_example.yml │ │ │ ├── README.md │ │ │ ├── docker-compose.yml │ │ │ ├── go.mod │ │ │ ├── go.sum │ │ │ ├── main.go │ │ │ └── prometheus.yml │ │ ├── 5-cqrs-protobuf/ │ │ │ ├── .validate_example.yml │ │ │ ├── Makefile │ │ │ ├── README.md │ │ │ ├── docker-compose.yml │ │ │ ├── go.mod │ │ │ ├── go.sum │ │ │ ├── main.go │ │ │ ├── messages.pb.go │ │ │ └── proto/ │ │ │ └── messages.proto │ │ └── 6-cqrs-ordered-events/ │ │ ├── .validate_example.yml │ │ ├── Makefile │ │ ├── README.md │ │ ├── activity.go │ │ ├── docker-compose.yml │ │ ├── go.mod │ │ ├── go.sum │ │ ├── main.go │ │ ├── message.go │ │ ├── messages.pb.go │ │ ├── proto/ │ │ │ └── messages.proto │ │ └── subscribers.go │ ├── pubsubs/ │ │ ├── amqp/ │ │ │ ├── .validate_example.yml │ │ │ ├── docker-compose.yml │ │ │ ├── go.mod │ │ │ ├── go.sum │ │ │ └── main.go │ │ ├── aws-sns/ │ │ │ ├── .validate_example.yml │ │ │ ├── docker-compose.yml │ │ │ ├── go.mod │ │ │ ├── go.sum │ │ │ └── main.go │ │ ├── aws-sqs/ │ │ │ ├── .validate_example.yml │ │ │ ├── docker-compose.yml │ │ │ ├── go.mod │ │ │ ├── go.sum │ │ │ └── main.go │ │ ├── go-channel/ │ │ │ ├── .validate_example.yml │ │ │ ├── go.mod │ │ │ ├── go.sum │ │ │ └── main.go │ │ ├── googlecloud/ │ │ │ ├── .validate_example.yml │ │ │ ├── docker-compose.yml │ │ │ ├── go.mod │ │ │ ├── go.sum │ │ │ └── main.go │ │ ├── kafka/ │ │ │ ├── .validate_example.yml │ │ │ ├── docker-compose.yml │ │ │ ├── go.mod │ │ │ ├── go.sum │ │ │ └── main.go │ │ ├── nats-core/ │ │ │ ├── .validate_example.yml │ │ │ ├── docker-compose.yml │ │ │ ├── go.mod │ │ │ ├── go.sum │ │ │ └── main.go │ │ ├── nats-jetstream/ │ │ │ ├── .validate_example.yml │ │ │ ├── docker-compose.yml │ │ │ ├── go.mod │ │ │ ├── go.sum │ │ │ └── main.go │ │ ├── nats-streaming/ │ │ │ ├── .validate_example.yml │ │ │ ├── docker-compose.yml │ │ │ ├── go.mod │ │ │ ├── go.sum │ │ │ └── main.go │ │ ├── redisstream/ │ │ │ ├── .validate_example.yml │ │ │ ├── docker-compose.yml │ │ │ ├── go.mod │ │ │ ├── go.sum │ │ │ └── main.go │ │ ├── sql/ │ │ │ ├── .validate_example.yml │ │ │ ├── docker-compose.yml │ │ │ ├── go.mod │ │ │ ├── go.sum │ │ │ └── main.go │ │ ├── sqlite/ │ │ │ ├── .gitignore │ │ │ ├── .validate_example.yml │ │ │ ├── go.mod │ │ │ ├── go.sum │ │ │ ├── main.go │ │ │ └── transaction.go │ │ └── sqlite-zombiezen/ │ │ ├── .gitignore │ │ ├── .validate_example.yml │ │ ├── go.mod │ │ ├── go.sum │ │ ├── main.go │ │ └── transaction.go │ └── real-world-examples/ │ ├── consumer-groups/ │ │ ├── README.md │ │ ├── api/ │ │ │ ├── http.go │ │ │ ├── main.go │ │ │ ├── public/ │ │ │ │ └── index.html │ │ │ └── storage.go │ │ ├── common/ │ │ │ ├── events.go │ │ │ └── messaging.go │ │ ├── crm-service/ │ │ │ └── main.go │ │ ├── docker-compose.yml │ │ ├── go.mod │ │ ├── go.sum │ │ └── newsletter-service/ │ │ └── main.go │ ├── delayed-messages/ │ │ ├── docker-compose.yml │ │ ├── go.mod │ │ ├── go.sum │ │ └── main.go │ ├── delayed-requeue/ │ │ ├── docker-compose.yml │ │ ├── go.mod │ │ ├── go.sum │ │ └── main.go │ ├── exactly-once-delivery-counter/ │ │ ├── README.md │ │ ├── docker-compose.yml │ │ ├── run.go │ │ ├── schema.sql │ │ ├── server/ │ │ │ ├── go.mod │ │ │ ├── go.sum │ │ │ └── main.go │ │ └── worker/ │ │ ├── go.mod │ │ ├── go.sum │ │ └── main.go │ ├── persistent-event-log/ │ │ ├── .validate_example.yml │ │ ├── README.md │ │ ├── docker-compose.yml │ │ ├── go.mod │ │ ├── go.sum │ │ └── main.go │ ├── receiving-webhooks/ │ │ ├── .validate_example.yml │ │ ├── README.md │ │ ├── docker-compose.yml │ │ ├── go.mod │ │ ├── go.sum │ │ └── main.go │ ├── sending-webhooks/ │ │ ├── .validate_example.yml │ │ ├── README.md │ │ ├── docker-compose.yml │ │ ├── producer/ │ │ │ ├── go.mod │ │ │ ├── go.sum │ │ │ └── main.go │ │ ├── router/ │ │ │ ├── go.mod │ │ │ ├── go.sum │ │ │ └── main.go │ │ └── webhooks-server/ │ │ └── main.go │ ├── server-sent-events/ │ │ ├── README.md │ │ ├── docker-compose.yml │ │ ├── schema.sql │ │ └── server/ │ │ ├── event_handlers.go │ │ ├── feeds_storage.go │ │ ├── go.mod │ │ ├── go.sum │ │ ├── http.go │ │ ├── main.go │ │ ├── models.go │ │ ├── posts_storage.go │ │ └── public/ │ │ └── index.html │ ├── server-sent-events-htmx/ │ │ ├── Dockerfile │ │ ├── README.md │ │ ├── docker/ │ │ │ ├── Dockerfile │ │ │ └── reflex.conf │ │ ├── docker-compose.yml │ │ ├── events.go │ │ ├── go.mod │ │ ├── go.sum │ │ ├── http.go │ │ ├── main.go │ │ ├── models.go │ │ ├── repository.go │ │ └── views/ │ │ ├── base.templ │ │ ├── base_templ.go │ │ ├── pages.templ │ │ └── pages_templ.go │ ├── synchronizing-databases/ │ │ ├── .validate_example.yml │ │ ├── README.md │ │ ├── docker-compose.yml │ │ ├── go.mod │ │ ├── go.sum │ │ ├── main.go │ │ ├── mysql.go │ │ └── postgres.go │ ├── transactional-events/ │ │ ├── .validate_example.yml │ │ ├── README.md │ │ ├── docker-compose.yml │ │ ├── go.mod │ │ ├── go.sum │ │ └── main.go │ └── transactional-events-forwarder/ │ ├── .validate_example.yml │ ├── README.md │ ├── docker-compose.yml │ ├── go.mod │ ├── go.sum │ └── main.go ├── codecov.yml ├── components/ │ ├── cqrs/ │ │ ├── command_bus.go │ │ ├── command_bus_test.go │ │ ├── command_handler.go │ │ ├── command_handler_test.go │ │ ├── command_processor.go │ │ ├── command_processor_test.go │ │ ├── cqrs.go │ │ ├── cqrs_test.go │ │ ├── ctx.go │ │ ├── doc.go │ │ ├── event_bus.go │ │ ├── event_bus_test.go │ │ ├── event_handler.go │ │ ├── event_handler_test.go │ │ ├── event_processor.go │ │ ├── event_processor_group.go │ │ ├── event_processor_group_test.go │ │ ├── event_processor_test.go │ │ ├── marshaler.go │ │ ├── marshaler_json.go │ │ ├── marshaler_json_test.go │ │ ├── marshaler_protobuf.go │ │ ├── marshaler_protobuf_events_new_test.go │ │ ├── marshaler_protobuf_events_test.go │ │ ├── marshaler_protobuf_gogo.go │ │ ├── marshaler_protobuf_gogo_test.go │ │ ├── marshaler_protobuf_test.go │ │ ├── name.go │ │ ├── name_test.go │ │ ├── object.go │ │ └── testdata/ │ │ └── events.proto │ ├── delay/ │ │ ├── delay.go │ │ ├── publisher.go │ │ └── publisher_test.go │ ├── fanin/ │ │ ├── fanin.go │ │ └── fanin_test.go │ ├── forwarder/ │ │ ├── envelope.go │ │ ├── envelope_test.go │ │ ├── forwarder.go │ │ ├── forwarder_test.go │ │ └── publisher.go │ ├── metrics/ │ │ ├── builder.go │ │ ├── ctx.go │ │ ├── handler.go │ │ ├── http.go │ │ ├── http_test.go │ │ ├── labels.go │ │ ├── publisher.go │ │ └── subscriber.go │ ├── requestreply/ │ │ ├── backend_pubsub.go │ │ ├── backend_pubsub_marshaler.go │ │ ├── command_bus.go │ │ ├── handler.go │ │ ├── requestreply.go │ │ └── requestreply_test.go │ └── requeuer/ │ ├── requeuer.go │ └── requeuer_test.go ├── dev/ │ ├── consolidate-gomods/ │ │ └── main.go │ ├── coverage.sh │ ├── prometheus.yml │ ├── update-examples-deps/ │ │ ├── go.mod │ │ ├── go.sum │ │ └── main.go │ └── validate-examples/ │ ├── go.mod │ ├── go.sum │ └── main.go ├── doc.go ├── docs/ │ ├── .npmignore │ ├── .npmrc │ ├── .prettierignore │ ├── .prettierrc.yaml │ ├── DEVELOP.md │ ├── assets/ │ │ ├── images/ │ │ │ └── .gitkeep │ │ ├── js/ │ │ │ └── custom.js │ │ ├── jsconfig.json │ │ ├── scss/ │ │ │ └── common/ │ │ │ ├── _custom.scss │ │ │ └── _variables-custom.scss │ │ └── svgs/ │ │ └── .gitkeep │ ├── build.sh │ ├── config/ │ │ ├── _default/ │ │ │ ├── hugo.toml │ │ │ ├── languages.toml │ │ │ ├── markup.toml │ │ │ ├── menus/ │ │ │ │ └── menus.en.toml │ │ │ ├── module.toml │ │ │ └── params.toml │ │ ├── babel.config.js │ │ ├── next/ │ │ │ └── hugo.toml │ │ ├── postcss.config.js │ │ └── production/ │ │ └── hugo.toml │ ├── content/ │ │ ├── _index.md │ │ ├── advanced/ │ │ │ ├── delayed-messages.md │ │ │ ├── fanin.md │ │ │ ├── fanout.md │ │ │ ├── forwarder.md │ │ │ ├── metrics.md │ │ │ └── requeuing-after-error.md │ │ ├── development/ │ │ │ ├── benchmark.md │ │ │ ├── contributing.md │ │ │ ├── pub-sub-implementing.md │ │ │ └── releases.md │ │ ├── docs/ │ │ │ ├── _index.md │ │ │ ├── articles.md │ │ │ ├── awesome.md │ │ │ ├── cqrs.md │ │ │ ├── message/ │ │ │ │ ├── .validate_example.yml │ │ │ │ ├── go.mod │ │ │ │ ├── go.sum │ │ │ │ └── receiving-ack.go │ │ │ ├── message.md │ │ │ ├── messages-router.md │ │ │ ├── middlewares.md │ │ │ ├── pub-sub.md │ │ │ ├── snippets/ │ │ │ │ ├── amqp-consumer-groups/ │ │ │ │ │ ├── .validate_example.yml │ │ │ │ │ ├── docker-compose.yml │ │ │ │ │ ├── go.mod │ │ │ │ │ ├── go.sum │ │ │ │ │ └── main.go │ │ │ │ └── tail-log-file/ │ │ │ │ ├── go.mod │ │ │ │ ├── go.sum │ │ │ │ └── main.go │ │ │ └── troubleshooting.md │ │ ├── learn/ │ │ │ ├── _index.md │ │ │ ├── getting-started.md │ │ │ └── quickstart.md │ │ ├── pubsubs/ │ │ │ ├── _index.md │ │ │ ├── amqp.md │ │ │ ├── aws.md │ │ │ ├── bolt.md │ │ │ ├── firestore.md │ │ │ ├── gochannel.md │ │ │ ├── googlecloud.md │ │ │ ├── http.md │ │ │ ├── io.md │ │ │ ├── kafka.md │ │ │ ├── nats.md │ │ │ ├── redisstream.md │ │ │ ├── sql.md │ │ │ └── sqlite.md │ │ └── support.md │ ├── extract_middleware_godocs.py │ ├── layouts/ │ │ ├── _default/ │ │ │ ├── _markup/ │ │ │ │ └── render-link.html │ │ │ ├── learn.html │ │ │ └── quickstart.html │ │ ├── index.html │ │ ├── partials/ │ │ │ ├── footer/ │ │ │ │ ├── footer.html │ │ │ │ └── script-footer-custom.html │ │ │ ├── head/ │ │ │ │ ├── custom-head.html │ │ │ │ ├── resource-hints.html │ │ │ │ └── script-header.html │ │ │ ├── header/ │ │ │ │ └── header.html │ │ │ ├── main/ │ │ │ │ └── edit-page.html │ │ │ ├── private/ │ │ │ │ └── has-headings.html │ │ │ ├── seo/ │ │ │ │ ├── opengraph.html │ │ │ │ └── twitter.html │ │ │ └── sidebar/ │ │ │ └── section-menu.html │ │ └── shortcodes/ │ │ ├── load-snippet-partial.html │ │ ├── load-snippet.html │ │ ├── readfile.html │ │ ├── tab.html │ │ └── tabs.html │ ├── package.json │ ├── resources/ │ │ └── _gen/ │ │ └── assets/ │ │ └── scss/ │ │ ├── app.scss_901a6e181e810c5c7347a10d84f037ab.content │ │ ├── app.scss_901a6e181e810c5c7347a10d84f037ab.json │ │ ├── app.scss_cdf9d7c9eb97e4550ded64a8776dd9e8.content │ │ └── app.scss_cdf9d7c9eb97e4550ded64a8776dd9e8.json │ └── static/ │ └── .gitkeep ├── go.mod ├── go.sum ├── internal/ │ ├── channel.go │ ├── channel_test.go │ ├── name.go │ ├── name_test.go │ ├── norace.go │ ├── publisher/ │ │ ├── errors.go │ │ ├── retry.go │ │ └── retry_test.go │ ├── race.go │ └── subscriber/ │ └── multiplier.go ├── log.go ├── log_test.go ├── message/ │ ├── decorator.go │ ├── decorator_bench_test.go │ ├── decorator_test.go │ ├── message.go │ ├── message_test.go │ ├── messages.go │ ├── messages_test.go │ ├── metadata.go │ ├── pubsub.go │ ├── router/ │ │ ├── middleware/ │ │ │ ├── circuit_breaker.go │ │ │ ├── circuit_breaker_test.go │ │ │ ├── correlation.go │ │ │ ├── correlation_test.go │ │ │ ├── deduplicator.go │ │ │ ├── deduplicator_test.go │ │ │ ├── delay_on_error.go │ │ │ ├── delay_on_error_test.go │ │ │ ├── duplicator.go │ │ │ ├── duplicator_test.go │ │ │ ├── ignore_errors.go │ │ │ ├── ignore_errors_test.go │ │ │ ├── instant_ack.go │ │ │ ├── instant_ack_test.go │ │ │ ├── message_test.go │ │ │ ├── poison.go │ │ │ ├── poison_test.go │ │ │ ├── randomfail.go │ │ │ ├── randomfail_test.go │ │ │ ├── recoverer.go │ │ │ ├── recoverer_test.go │ │ │ ├── retry.go │ │ │ ├── retry_test.go │ │ │ ├── throttle.go │ │ │ ├── throttle_test.go │ │ │ ├── timeout.go │ │ │ └── timeout_test.go │ │ └── plugin/ │ │ └── signals.go │ ├── router.go │ ├── router_context.go │ ├── router_context_test.go │ ├── router_test.go │ └── subscriber/ │ ├── read.go │ └── read_test.go ├── netlify.toml ├── pubsub/ │ ├── doc.go │ ├── gochannel/ │ │ ├── doc.go │ │ ├── fanout.go │ │ ├── fanout_test.go │ │ ├── pubsub.go │ │ ├── pubsub_bench_test.go │ │ ├── pubsub_internal_test.go │ │ ├── pubsub_stress_test.go │ │ └── pubsub_test.go │ ├── sync/ │ │ ├── waitgroup.go │ │ └── waitgroup_test.go │ └── tests/ │ ├── bench_pubsub.go │ ├── test_asserts.go │ ├── test_pubsub.go │ └── test_pubsub_stress.go ├── slog.go ├── slog_test.go ├── tools/ │ ├── mill/ │ │ ├── .default-config.yml │ │ ├── Makefile │ │ ├── README.md │ │ ├── cmd/ │ │ │ ├── amqp.go │ │ │ ├── consume.go │ │ │ ├── googlecloud.go │ │ │ ├── internal/ │ │ │ │ └── indent.go │ │ │ ├── kafka.go │ │ │ ├── produce.go │ │ │ └── root.go │ │ ├── go.mod │ │ ├── go.sum │ │ └── main.go │ └── pq/ │ ├── README.md │ ├── backend/ │ │ └── postgres.go │ ├── cli/ │ │ ├── backend.go │ │ ├── message.go │ │ └── model.go │ ├── go.mod │ ├── go.sum │ └── main.go ├── uuid.go └── uuid_test.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: '' labels: bug assignees: '' --- ### Steps to reproduce docker-compose.yml ```yaml ``` ```go // Your reproduction code goes here ``` ### Expected behavior ### Actual behavior ### Possible solution ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: true ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: '' labels: enhancement assignees: '' --- ## Feature request ### Description ### Example use case #### How it can look like in code ```go ``` ================================================ FILE: .github/pull_request_template.md ================================================ ### Motivation / Background ### Detail ### Alternative approaches considered (if applicable) ### Checklist The resources of our team are limited. **There are a couple of things that you can do to help us merge your PR faster**: - [ ] I wrote tests for the changes. - [ ] All tests are passing. - If you are testing a Pub/Sub, you can start Docker with `make up`. - You can start with `make test_short` for a quick check. - If you want to run all tests, use `make test`. - [ ] Code has no breaking changes. - [ ] _(If applicable)_ documentation on [watermill.io](https://watermill.io/) is updated. - Documentation is built in the [github.com/ThreeDotsLabs/watermill/docs](https://github.com/ThreeDotsLabs/watermill/tree/master/docs). - You can find development instructions in the [DEVELOP.md](https://github.com/ThreeDotsLabs/watermill/tree/master/docs/DEVELOP.md). ================================================ FILE: .github/workflows/master.yml ================================================ name: master on: push: branches: - master jobs: ci: uses: ThreeDotsLabs/watermill/.github/workflows/tests.yml@master with: stress-tests: true codecov: true secrets: codecov_token: ${{ secrets.CODECOV_TOKEN }} ================================================ FILE: .github/workflows/pr-examples.yml ================================================ name: pr-examples on: pull_request: paths: - '_examples/**/*' jobs: validate-examples: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v4 with: go-version: '^1.21.1' - run: make validate_examples timeout-minutes: 30 ================================================ FILE: .github/workflows/pr.yml ================================================ name: pr on: pull_request: jobs: ci: uses: ThreeDotsLabs/watermill/.github/workflows/tests.yml@master with: codecov: true secrets: codecov_token: ${{ secrets.CODECOV_TOKEN }} ================================================ FILE: .github/workflows/tests.yml ================================================ name: tests on: workflow_call: inputs: stress-tests: description: 'Run stress tests' required: false type: boolean default: false codecov: required: false type: boolean default: false runs-on: required: false type: string default: 'ubuntu-latest' secrets: codecov_token: required: false jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v4 with: go-version: '^1.21.1' - run: make build detect-modules: runs-on: ubuntu-latest outputs: modules: ${{ steps.set-modules.outputs.modules }} steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version: '^1.21.1' - id: set-modules run: echo "modules=$(go list -m -json | jq -s '.' | jq -c '[.[].Dir]')" >> $GITHUB_OUTPUT lint: needs: detect-modules runs-on: ubuntu-latest strategy: matrix: modules: ${{ fromJSON(needs.detect-modules.outputs.modules) }} steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v4 with: go-version: '^1.21.1' - name: golangci-lint ${{ matrix.modules }} uses: golangci/golangci-lint-action@v6.5.2 with: working-directory: ${{ matrix.modules }} tests: needs: [build] runs-on: ${{ inputs.runs-on }} steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v4 with: go-version: '^1.21.1' - run: cat .env >> $GITHUB_ENV || true - run: make up - run: make wait - run: make test_short - run: make test timeout-minutes: 30 - run: make test_race - run: make test_stress if: ${{ inputs.stress-tests }} - name: Dump docker logs on failure if: failure() uses: jwalton/gh-docker-logs@a8cb5301950dd4d2b86619cd487b3b281526b178 # v2.2.0 codecov: runs-on: ${{ inputs.runs-on }} if: ${{ inputs.codecov }} steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v4 with: go-version: '^1.21.1' - run: cat .env >> $GITHUB_ENV || true - run: make up - run: make wait - run: make test_codecov - uses: codecov/codecov-action@v4 with: fail_ci_if_error: true files: ./coverage.out token: ${{ secrets.codecov_token }} ================================================ FILE: .gitignore ================================================ .idea vendor docs/themes/ docs/node_modules/ docs/public/ docs/content/src-link docs/content/middleware docs/hugo_stats.json *.out *.log .mod-cache ================================================ FILE: CONTRIBUTING.md ================================================ # Contributors guide v0.1 ## How can I help? We are always happy to help you in contributing to Watermill. If you have any ideas, please let us know on our [Discord server](https://watermill.io/support/). There are multiple ways in which you can help us. ### Existing issues You can pick one of the existing issues. Most of the issues should have an estimation (S - small, M - medium, L - large). - [Good first issues list](https://github.com/ThreeDotsLabs/watermill/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) - simple issues to begin with - [Help wanted issues list](https://github.com/ThreeDotsLabs/watermill/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22) - tasks that are already more or less clear, and you can start to implement them pretty quickly ### New Pub/Sub implementations If you have an idea to create a Pub/Sub based on some technology and it is not listed yet in our issues (because we don't know it, or it is just some crazy idea, like physical mail based Pub/Sub), feel free to add your own implementation. You can do it in your private repository or if you want, we can move it to `ThreeDotsLabs/watermill-[name]`. *Please keep in mind that you will not be able to push changes directly to the master branch in a project in our organization*. When adding a new Pub/Sub implementation, you should start with this guide: [https://watermill.io/docs/pub-sub-implementing/](https://watermill.io/docs/pub-sub-implementing/). ### New ideas If you have any idea that is not covered in the issues list, please post a new issue describing it. It's recommended to discuss your idea on [Discord](https://discord.gg/QV6VFg4YQE)/GitHub before creating production-ready implementation - in some situations, it may save a lot of your time before implementing something that can be simplified or done more easily. :) In general, it's helpful to discuss a Proof of Concept to align with the idea. ## Local development Makefile and docker-compose (for Pub/Subs) are your friends. You can run all tests locally (they are running in CI in the same way). Useful commands: - `make up` - docker-compose up - `make test` - tests - `make test_short` - run short tests (useful to perform a very fast check after changes) - `make fmt` - do goimports ## Code standards - you should run `make fmt` - [CodeReviewComments](https://github.com/golang/go/wiki/CodeReviewComments) - [Effective Go](https://golang.org/doc/effective_go.html) - SOLID - code should be open for configuration and not coupled to any serialization method (for example: [AMQP marshaler](https://github.com/ThreeDotsLabs/watermill-amqp/blob/master/pkg/amqp/marshaler.go), [AMQP Config](https://github.com/ThreeDotsLabs/watermill-amqp/blob/master/pkg/amqp/config.go) ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2019 Three Dots Labs Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ up: test: go test ./... test_v: go test -v ./... test_short: go test ./... -short test_race: go test ./... -short -race test_stress: go test -tags=stress -timeout=30m ./... test_codecov: go test -coverprofile=coverage.out -covermode=atomic ./... test_reconnect: go test -tags=reconnect ./... build: go build ./... wait: fmt: go fmt ./... goimports -l -w . generate_gomod: rm go.mod go.sum || true go mod init github.com/ThreeDotsLabs/watermill go install ./... sed -i '\|go |d' go.mod go mod edit -fmt update_examples_deps: go run dev/update-examples-deps/main.go validate_examples: (cd dev/validate-examples/ && go run main.go) ================================================ FILE: README.md ================================================ # Watermill [![CI Status](https://github.com/ThreeDotsLabs/watermill/actions/workflows/master.yml/badge.svg)](https://github.com/ThreeDotsLabs/watermill/actions/workflows/master.yml) [![Go Reference](https://pkg.go.dev/badge/github.com/ThreeDotsLabs/watermill.svg)](https://pkg.go.dev/github.com/ThreeDotsLabs/watermill) [![Go Report Card](https://goreportcard.com/badge/github.com/ThreeDotsLabs/watermill)](https://goreportcard.com/report/github.com/ThreeDotsLabs/watermill) [![codecov](https://codecov.io/gh/ThreeDotsLabs/watermill/branch/master/graph/badge.svg)](https://codecov.io/gh/ThreeDotsLabs/watermill) Watermill is a Go library for working efficiently with message streams. It is intended for building event driven applications, enabling event sourcing, RPC over messages, sagas and basically whatever else comes to your mind. You can use conventional pub/sub implementations like Kafka or RabbitMQ, but also HTTP or PostgreSQL if that fits your use case. ## Goals * **Easy** to understand. * **Universal** - event-driven architecture, messaging, stream processing, CQRS - use it for whatever you need. * **Fast** (see [Benchmarks](#benchmarks)). * **Flexible** with middlewares, plugins and Pub/Sub configurations. * **Resilient** - using proven technologies and passing stress tests (see [Stability](#stability)). ## Getting Started Pick what you like the best or see in order: 1. [Quickstart](https://watermill.io/learn/quickstart/) — learn by coding! 2. Follow the [Getting Started guide](https://watermill.io/learn/getting-started/). 3. See examples below. 4. Read the full documentation: https://watermill.io/ ## Our online hands-on training Go Event-Driven goes beyond Watermill Quickstart. You'll learn industry standard concepts and patterns like: * Handling at-least-once delivery * Asynchronous read models * Events & Commands * Observability * Message ordering * Sagas ## Examples * Basic * [Your first app](_examples/basic/1-your-first-app) - **start here!** * [Realtime feed](_examples/basic/2-realtime-feed) * [Router](_examples/basic/3-router) * [Metrics](_examples/basic/4-metrics) * [CQRS with protobuf](_examples/basic/5-cqrs-protobuf) * [Pub/Subs usage](_examples/pubsubs) * These examples are part of the [Getting started guide](https://watermill.io/learn/getting-started/) and show usage of a single Pub/Sub at a time. * Real-world examples * [Exactly-once delivery counter](_examples/real-world-examples/exactly-once-delivery-counter) * [Receiving webhooks](_examples/real-world-examples/receiving-webhooks) * [Sending webhooks](_examples/real-world-examples/sending-webhooks) * [Synchronizing Databases](_examples/real-world-examples/synchronizing-databases) * [Persistent Event Log](_examples/real-world-examples/persistent-event-log) * [Transactional Events](_examples/real-world-examples/transactional-events) * [Real-time HTTP updates with Server-Sent Events](_examples/real-world-examples/server-sent-events) * [Real-time HTTP updates with Server-Sent Events and htmx](_examples/real-world-examples/server-sent-events-htmx) * Complete projects * [NATS example with live code reloading](https://github.com/ThreeDotsLabs/nats-example) * [RabbitMQ, webhooks and Kafka integration](https://github.com/ThreeDotsLabs/event-driven-example) ## Background Building distributed and scalable services is rarely as easy as some may suggest. There is a lot of hidden knowledge that comes with writing such systems. Just like you don't need to know the whole TCP stack to create a HTTP REST server, you shouldn't need to study all of this knowledge to start with building message-driven applications. Watermill's goal is to make communication with messages as easy to use as HTTP routers. It provides the tools needed to begin working with event-driven architecture and allows you to learn the details on the go. At the heart of Watermill there is one simple interface: ```go func(*Message) ([]*Message, error) ``` Your handler receives a message and decides whether to publish new message(s) or return an error. What happens next is up to the middlewares you've chosen. You can find more about our motivations in our [*Introducing Watermill* blog post](https://threedots.tech/post/introducing-watermill/). ## Pub/Subs All publishers and subscribers have to implement an interface: ```go type Publisher interface { Publish(topic string, messages ...*Message) error Close() error } type Subscriber interface { Subscribe(ctx context.Context, topic string) (<-chan *Message, error) Close() error } ``` Supported Pub/Subs: - AMQP (RabbitMQ) Pub/Sub [(`github.com/ThreeDotsLabs/watermill-amqp/v3`)](https://github.com/ThreeDotsLabs/watermill-amqp/) - AWS SNS/SQS Pub/Sub [(`github.com/ThreeDotsLabs/watermill-aws`)](https://github.com/ThreeDotsLabs/watermill-aws/) - Bolt Pub/Sub [(`github.com/ThreeDotsLabs/watermill-bolt`)](https://github.com/ThreeDotsLabs/watermill-bolt/) - Firestore Pub/Sub [(`github.com/ThreeDotsLabs/watermill-firestore`)](https://github.com/ThreeDotsLabs/watermill-firestore/) - Google Cloud Pub/Sub [(`github.com/ThreeDotsLabs/watermill-googlecloud/v2`)](https://github.com/ThreeDotsLabs/watermill-googlecloud/) - HTTP Pub/Sub [(`github.com/ThreeDotsLabs/watermill-http/v2`)](https://github.com/ThreeDotsLabs/watermill-http/) - io.Reader/io.Writer Pub/Sub [(`github.com/ThreeDotsLabs/watermill-io`)](https://github.com/ThreeDotsLabs/watermill-io/) - Kafka Pub/Sub [(`github.com/ThreeDotsLabs/watermill-kafka/v3`)](https://github.com/ThreeDotsLabs/watermill-kafka/) - NATS Jetstream Pub/Sub [(`github.com/ThreeDotsLabs/watermill-nats/v2`)](https://github.com/ThreeDotsLabs/watermill-nats/) - Redis Stream Pub/Sub [(`github.com/ThreeDotsLabs/watermill-redisstream`)](https://github.com/ThreeDotsLabs/watermill-redisstream/) - SQL (MySQL / PostgreSQL) Pub/Sub [(`github.com/ThreeDotsLabs/watermill-sql/v4`)](https://github.com/ThreeDotsLabs/watermill-sql/) - SQLite Pub/Sub (Beta) [(`github.com/ThreeDotsLabs/watermill-sqlite/`)](https://github.com/ThreeDotsLabs/watermill-sqlite/) All Pub/Subs implementation documentation can be found in the [documentation](https://watermill.io/pubsubs/). ## Unofficial libraries Can't find your favorite Pub/Sub or library integration? Check [Awesome Watermill](https://watermill.io/docs/awesome/). If you know another library or are an author of one, please [add it to the list](https://github.com/ThreeDotsLabs/watermill/edit/master/docs/content/docs/awesome.md). ## Contributing Please check our [contributing guide](CONTRIBUTING.md). ## Stability Watermill v1.0.0 has been released and is production-ready. The public API is stable and will not change without changing the major version. To ensure that all Pub/Subs are stable and safe to use in production, we created a [set of tests](https://github.com/ThreeDotsLabs/watermill/blob/master/pubsub/tests/test_pubsub.go#L34) that need to pass for each of the implementations before merging to master. All tests are also executed in [*stress*](https://github.com/ThreeDotsLabs/watermill/blob/master/pubsub/tests/test_pubsub.go#L171) mode - that means that we are running all the tests **20x** in parallel. All tests are run with the race condition detector enabled (`-race` flag in tests). For more information about debugging tests, you should check [tests troubleshooting guide](http://watermill.io/docs/troubleshooting/#debugging-pubsub-tests). ## Benchmarks Initial tools for benchmarking Pub/Subs can be found in [watermill-benchmark](https://github.com/ThreeDotsLabs/watermill-benchmark). All benchmarks are being done on a single 16 CPU VM instance, running one binary and dependencies in Docker Compose. These numbers are meant to serve as a rough estimate of how fast messages can be processed by different Pub/Subs. Keep in mind that the results can be vastly different, depending on the setup and configuration (both much lower and higher). Here's the short version for message size of 16 bytes. | Pub/Sub | Publish (messages / s) | Subscribe (messages / s) | |---------------------------------|------------------------|--------------------------| | GoChannel | 315,776 | 138,743 | | Redis Streams | 59,158 | 12,134 | | NATS Jetstream (16 Subscribers) | 50,668 | 34,713 | | Kafka (one node) | 41,492 | 101,669 | | SQL (MySQL, batch size=100) | 6,371 | 2,794 | | SQL (PostgreSQL, batch size=1) | 2,831 | 9,460 | | Google Cloud Pub/Sub | 3,027 | 28,589 | | AMQP (RabbitMQ) | 2,770 | 14,604 | ## Support If you didn't find the answer to your question in [the documentation](https://watermill.io/), feel free to ask us directly! Please join us on the `#watermill` channel on the [Three Dots Labs Discord](https://discord.gg/QV6VFg4YQE). ## Why the name? It processes streams! ## License [MIT License](./LICENSE) ================================================ FILE: RELEASE-PROCEDURE.md ================================================ # Release procedure 1. Generate clean go.mod: `make generate_gomod` 2. Push to master 3. Update missing documentation 4. Check snippets in documentation (sometimes `first_line_contains` or `last_line_contains` can change position and load too much) 5. Add breaking changes to `UPGRADE-[new-version].md` 6. Push to master 7. [Add release in GitHub](https://github.com/ThreeDotsLabs/watermill/releases) 8. Update Pub/Subs versions 9. Update and validate examples: `make validate_examples` ================================================ FILE: UPGRADE-0.3.md ================================================ # UPGRADE FROM 0.2.x to 0.3 # `watermill/message` - `message.Message.Ack` and `message.Message.Nack` now return `bool` instead of `error` - `message.Subscriber.Subscribe` now accepts `context.Context` as the first argument - `message.Subscriber.Subscribe` now returns `<-chan *Message` instead of `chan *Message` - `message.Router.AddHandler` and `message.Router.AddNoPublisherHandler` now panic, instead of returning error # `watermill/message/infrastructure` - updated all Pub/Subs to new `message.Subscriber` interface - `gochannel.NewGoChannel` now accepts `gochannel.Config`, instead of positional parameters - `http.NewSubscriber` now accepts `http.SubscriberConfig`, instead of positional parameters # `watermill/message/router/middleware` - `metrics.NewMetrics` is removed, please use the [metrics](components/metrics) component instead # `watermill` - `watermill.LoggerAdapter` interface now requires a `With(fields LogFields) LoggerAdapter` method ================================================ FILE: UPGRADE-0.4.md ================================================ # UPGRADE FROM 0.3.x to 0.4 ## `watermill/components/cqrs` ### `CommandHandler.HandlerName` and `EventHandler.HandlerName` was added to the interface. If you are using metrics component, you may want to keep backward capability with handler names. In other cases, you can implement your own method of generating handler name. Keeping backward capability for **event handlers**: ``` func (h CommandHandler) HandlerName() string { return fmt.Sprintf("command_processor-%s", h) } ``` Keeping backward capability for **command handlers**: ``` func (h EventHandler) HandlerName() string { return fmt.Sprintf("event_processor-%s", ObjectName(h)) } ``` ### Added `CommandsSubscriberConstructor` and `EventsSubscriberConstructor` From now on, `CommandsSubscriberConstructor` and `EventsSubscriberConstructor` are passed to constructors in CQRS component. They allow creating customized subscribers for every handler. For usage examples please check [_examples/cqrs-protobuf](_examples/cqrs-protobuf). ### Added context to `CommandHandler.Handle`, `CommandBus.Send`, `EventHandler.Handle` and `EventBus.Send` Added missing context, which is passed to Publish function and handlers. ### Other - `NewCommandProcessor` and `NewEventProcessor` now return an error instead of panic - `DuplicateCommandHandlerError` is returned instead of panic when two handlers are handling the same command - `CommandProcessor.routerHandlerFunc` and `EventProcessor.routerHandlerFunc` are now private - using `GenerateCommandsTopic` and `GenerateEventsTopic` functions instead of constant topic to allow more flexibility ## `watermill/message/infrastructure/amqp` ### `Config.QueueBindConfig.RoutingKey` was replaced with `GenerateRoutingKey` For backward compatibility, when using the constant value you should use a function: ``` func(topic string) string { return "routing_key" } ``` ## `message/router/middleware` - `PoisonQueue` is now `PoisonQueue(pub message.Publisher, topic string) (message.HandlerMiddleware, error)`, not a struct ## `message/router.go` - From now on, when all handlers are stopped, the router will also stop (`TestRouter_stop_when_all_handlers_stopped` test) ================================================ FILE: UPGRADE-1.0.md ================================================ # Upgrade instructions from v0.4.X In v1.0.0 we introduced a couple of breaking changes, to keep a stable API until version v2. ## Migrating Pub/Subs All Pub/Subs (excluding go-channel implementation) were moved to separated repositories. You can replace all import paths, with provided `sed`: find . -type f -iname '*.go' -exec sed -i -E "s/github\.com\/ThreeDotsLabs\/watermill\/message\/infrastructure\/(amqp|googlecloud|http|io|kafka|nats|sql)/github.com\/ThreeDotsLabs\/watermill-\1\/pkg\/\1/" "{}" +; find . -type f -iname '*.go' -exec sed -i -E "s/github\.com\/ThreeDotsLabs\/watermill\/message\/infrastructure\/gochannel/github\.com\/ThreeDotsLabs\/watermill\/pubsub\/gochannel/" "{}" +; # Breaking changes - `message.PubSub` interface was removed - `message.NewPubSub` constructor was removed - `message.NoPublishHandlerFunc` is now passed to `message.Router.AddNoPublisherHandler`, instead of `message.HandlerFunc`. - `message.Router.Run` now requires `context.Context` in parameter - `PrometheusMetricsBuilder.DecoratePubSub` was removed (because of `message.PubSub` interface removal) - `cars.ObjectName` was renamed to `cqrs.FullyQualifiedStructName` - `github.com/ThreeDotsLabs/watermill/message/infrastructure/gochannel` was moved to `github.com/ThreeDotsLabs/watermill/pubsub/gochannel` - `middleware.Retry` configuration parameters have been renamed - Universal Pub/Sub tests have been moved from `github.com/ThreeDotsLabs/watermill/message/infrastructure` to `github.com/ThreeDotsLabs/watermill/pubsub/tests` - All universal tests require now `TestContext`. - Removed `context` from `googlecloud.NewPublisher` ================================================ FILE: _examples/basic/1-your-first-app/.validate_example.yml ================================================ validation_cmd: "docker compose up" teardown_cmd: "docker compose down" timeout: 180 expected_output: "received event {ID:[0-9]+}" ================================================ FILE: _examples/basic/1-your-first-app/README.md ================================================ # Your first Watermill app This example project shows a basic setup of Watermill. The application runs in a loop, consuming events from a Kafka topic, modifying them and publishing to another topic. There's a docker-compose file included, so you can run the example and see it in action. To understand the background and internals, see [getting started guide](https://watermill.io/learn/getting-started/). ## Files - [main.go](main.go) - example source code, the **most interesting file for you** - [docker-compose.yml](docker-compose.yml) - local environment Docker Compose configuration, contains Golang, Kafka and Zookeeper - [go.mod](go.mod) - Go modules dependencies, you can find more information at [Go wiki](https://github.com/golang/go/wiki/Modules) - [go.sum](go.sum) - Go modules checksums ## Requirements To run this example you will need Docker and docker-compose installed. See the [installation guide](https://docs.docker.com/compose/install/). ## Running ```bash > docker-compose up [some initial logs] server_1 | 2019/08/29 19:41:23 received event {ID:0} server_1 | 2019/08/29 19:41:23 received event {ID:1} server_1 | 2019/08/29 19:41:23 received event {ID:2} server_1 | 2019/08/29 19:41:23 received event {ID:3} server_1 | 2019/08/29 19:41:24 received event {ID:4} server_1 | 2019/08/29 19:41:25 received event {ID:5} server_1 | 2019/08/29 19:41:26 received event {ID:6} server_1 | 2019/08/29 19:41:27 received event {ID:7} server_1 | 2019/08/29 19:41:28 received event {ID:8} server_1 | 2019/08/29 19:41:29 received event {ID:9} ``` Open another terminal and take a look at Kafka topics to see that all messages are there. The initial events should be present on the `events` topic: ```bash > docker-compose exec server mill kafka consume -b kafka:9092 --topic events {"id":12} {"id":13} {"id":14} {"id":15} {"id":16} {"id":17} ``` And the processed messages will be stored in the `events-processed` topic: ```bash > docker-compose exec server mill kafka consume -b kafka:9092 -t events-processed {"processed_id":21,"time":"2019-08-29T19:42:31.4464598Z"} {"processed_id":22,"time":"2019-08-29T19:42:32.4501767Z"} {"processed_id":23,"time":"2019-08-29T19:42:33.4530692Z"} {"processed_id":24,"time":"2019-08-29T19:42:34.4561694Z"} {"processed_id":25,"time":"2019-08-29T19:42:35.4608918Z"} ``` ================================================ FILE: _examples/basic/1-your-first-app/docker-compose.yml ================================================ services: server: image: golang:1.25 restart: unless-stopped volumes: - .:/app - $GOPATH/pkg/mod:/go/pkg/mod working_dir: /app command: > /bin/sh -c "go install github.com/ThreeDotsLabs/watermill/tools/mill@latest && go run main.go" kafka: image: bitnami/kafka:3.5.0 restart: unless-stopped environment: ALLOW_PLAINTEXT_LISTENER: yes KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092 KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true" ================================================ FILE: _examples/basic/1-your-first-app/go.mod ================================================ module main.go go 1.25 require ( github.com/ThreeDotsLabs/watermill v1.5.1 github.com/ThreeDotsLabs/watermill-kafka/v3 v3.1.0 ) require ( github.com/IBM/sarama v1.46.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dnwe/otelsarama v0.0.0-20240308230250-9388d9d40bc0 // indirect github.com/eapache/go-resiliency v1.7.0 // indirect github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 // indirect github.com/eapache/queue v1.1.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/golang/snappy v1.0.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/jcmturner/aescts/v2 v2.0.0 // indirect github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect github.com/jcmturner/gofork v1.7.6 // indirect github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect github.com/jcmturner/rpc/v2 v2.0.3 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/lithammer/shortuuid/v3 v3.0.7 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect github.com/sony/gobreaker v1.0.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/otel v1.38.0 // indirect go.opentelemetry.io/otel/metric v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect golang.org/x/crypto v0.41.0 // indirect golang.org/x/net v0.43.0 // indirect ) ================================================ FILE: _examples/basic/1-your-first-app/go.sum ================================================ github.com/IBM/sarama v1.46.0 h1:+YTM1fNd6WKMchlnLKRUB5Z0qD4M8YbvwIIPLvJD53s= github.com/IBM/sarama v1.46.0/go.mod h1:0lOcuQziJ1/mBGHkdp5uYrltqQuKQKM5O5FOWUQVVvo= github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY= github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892LARrKAdki6WtXS4= github.com/ThreeDotsLabs/watermill-kafka/v3 v3.1.0 h1:DfhVM1ieq+rb+bboB7aoymUgfKsEM3UbH3noQZ6+RJ4= github.com/ThreeDotsLabs/watermill-kafka/v3 v3.1.0/go.mod h1:o1GcoF/1CSJ9JSmQzUkULvpZeO635pZe+WWrYNFlJNk= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dnwe/otelsarama v0.0.0-20240308230250-9388d9d40bc0 h1:R2zQhFwSCyyd7L43igYjDrH0wkC/i+QBPELuY0HOu84= github.com/dnwe/otelsarama v0.0.0-20240308230250-9388d9d40bc0/go.mod h1:2MqLKYJfjs3UriXXF9Fd0Qmh/lhxi/6tHXkqtXxyIHc= github.com/eapache/go-resiliency v1.7.0 h1:n3NRTnBn5N0Cbi/IeOHuQn9s2UwVUH7Ga0ZWcP+9JTA= github.com/eapache/go-resiliency v1.7.0/go.mod h1:5yPzW0MIvSe0JDsv0v+DvcjEv2FyD6iZYSs1ZI+iQho= github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 h1:Oy0F4ALJ04o5Qqpdz8XLIpNA3WM/iSIXqxtqo7UGVws= github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3/go.mod h1:YvSRo5mw33fLEx1+DlK6L2VV43tJt5Eyel9n9XBcR+0= github.com/eapache/queue v1.1.0 h1:YOEu7KNc61ntiQlcEeUIoDTJ2o8mQznoNvUhiigpIqc= github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg= github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8= github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg= github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ= github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: _examples/basic/1-your-first-app/main.go ================================================ package main import ( "context" "encoding/json" "fmt" "time" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill-kafka/v3/pkg/kafka" "github.com/ThreeDotsLabs/watermill/message" "github.com/ThreeDotsLabs/watermill/message/router/middleware" "github.com/ThreeDotsLabs/watermill/message/router/plugin" ) var ( brokers = []string{"kafka:9092"} consumeTopic = "events" publishTopic = "events-processed" logger = watermill.NewStdLogger( true, // debug false, // trace ) marshaler = kafka.DefaultMarshaler{} ) type event struct { ID int `json:"id"` } type processedEvent struct { ProcessedID int `json:"processed_id"` Time time.Time `json:"time"` } func main() { publisher := createPublisher() // Subscriber is created with consumer group handler_1 subscriber := createSubscriber("handler_1") router, err := message.NewRouter(message.RouterConfig{}, logger) if err != nil { panic(err) } router.AddPlugin(plugin.SignalsHandler) router.AddMiddleware(middleware.Recoverer) // Adding a handler (multiple handlers can be added) router.AddHandler( "handler_1", // handler name, must be unique consumeTopic, // topic from which messages should be consumed subscriber, publishTopic, // topic to which messages should be published publisher, func(msg *message.Message) ([]*message.Message, error) { consumedPayload := event{} err := json.Unmarshal(msg.Payload, &consumedPayload) if err != nil { // When a handler returns an error, the default behavior is to send a Nack (negative-acknowledgement). // The message will be processed again. // // You can change the default behaviour by using middlewares, like Retry or PoisonQueue. // You can also implement your own middleware. return nil, err } fmt.Printf("received event %+v\n", consumedPayload) newPayload, err := json.Marshal(processedEvent{ ProcessedID: consumedPayload.ID, Time: time.Now(), }) if err != nil { return nil, err } newMessage := message.NewMessage(watermill.NewUUID(), newPayload) return []*message.Message{newMessage}, nil }, ) // Simulate incoming events in the background go simulateEvents(publisher) if err := router.Run(context.Background()); err != nil { panic(err) } } // createPublisher is a helper function that creates a Publisher, in this case - the Kafka Publisher. func createPublisher() message.Publisher { kafkaPublisher, err := kafka.NewPublisher( kafka.PublisherConfig{ Brokers: brokers, Marshaler: marshaler, }, logger, ) if err != nil { panic(err) } return kafkaPublisher } // createSubscriber is a helper function similar to the previous one, but in this case it creates a Subscriber. func createSubscriber(consumerGroup string) message.Subscriber { kafkaSubscriber, err := kafka.NewSubscriber( kafka.SubscriberConfig{ Brokers: brokers, Unmarshaler: marshaler, ConsumerGroup: consumerGroup, // every handler will use a separate consumer group }, logger, ) if err != nil { panic(err) } return kafkaSubscriber } // simulateEvents produces events that will be later consumed. func simulateEvents(publisher message.Publisher) { i := 0 for { e := event{ ID: i, } payload, err := json.Marshal(e) if err != nil { panic(err) } err = publisher.Publish(consumeTopic, message.NewMessage( watermill.NewUUID(), // internal uuid of the message, useful for debugging payload, )) if err != nil { panic(err) } i++ time.Sleep(time.Second) } } ================================================ FILE: _examples/basic/2-realtime-feed/.validate_example_subscribing.yml ================================================ validation_cmd: "docker compose up" teardown_cmd: "docker compose down" timeout: 180 expected_output: "Adding to feed" ================================================ FILE: _examples/basic/2-realtime-feed/README.md ================================================ # Realtime Feed This example features a very busy blogging platform, with thousands of messages showing up on your feed. There are two separate applications (microservices) integrating over a Kafka topic. The [`producer`](producer/) generates thousands of "posts" and publishes them to the topic. The [`consumer`](consumer/) subscribes to this topic and displays each post on the standard output. The consumer has a throttling middleware enabled, so you have a chance to actually read the posts. To understand the background and internals, see [getting started guide](https://watermill.io/learn/getting-started/). ## Requirements To run this example you will need Docker and docker-compose installed. See the [installation guide](https://docs.docker.com/compose/install/). ## Running ```bash docker-compose up ``` You should see the live feed of posts on the standard output. ## Exercises 1. Peek into the posts counter published on `posts_count` topic. ``` docker-compose exec consumer mill kafka consume -b kafka:9092 -t posts_count ``` 2. Add a persistent storage for incoming posts in the consumer service, instead of displaying them. Consider using the [SQL Publisher](https://github.com/ThreeDotsLabs/watermill-sql). ================================================ FILE: _examples/basic/2-realtime-feed/consumer/go.mod ================================================ module main.go go 1.25 require ( github.com/ThreeDotsLabs/watermill v1.5.1 github.com/ThreeDotsLabs/watermill-kafka/v3 v3.1.0 ) require ( github.com/IBM/sarama v1.46.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dnwe/otelsarama v0.0.0-20240308230250-9388d9d40bc0 // indirect github.com/eapache/go-resiliency v1.7.0 // indirect github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 // indirect github.com/eapache/queue v1.1.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/golang/snappy v1.0.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/jcmturner/aescts/v2 v2.0.0 // indirect github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect github.com/jcmturner/gofork v1.7.6 // indirect github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect github.com/jcmturner/rpc/v2 v2.0.3 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/lithammer/shortuuid/v3 v3.0.7 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect github.com/sony/gobreaker v1.0.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/otel v1.38.0 // indirect go.opentelemetry.io/otel/metric v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect golang.org/x/crypto v0.41.0 // indirect golang.org/x/net v0.43.0 // indirect ) ================================================ FILE: _examples/basic/2-realtime-feed/consumer/go.sum ================================================ github.com/IBM/sarama v1.46.0 h1:+YTM1fNd6WKMchlnLKRUB5Z0qD4M8YbvwIIPLvJD53s= github.com/IBM/sarama v1.46.0/go.mod h1:0lOcuQziJ1/mBGHkdp5uYrltqQuKQKM5O5FOWUQVVvo= github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY= github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892LARrKAdki6WtXS4= github.com/ThreeDotsLabs/watermill-kafka/v3 v3.1.0 h1:DfhVM1ieq+rb+bboB7aoymUgfKsEM3UbH3noQZ6+RJ4= github.com/ThreeDotsLabs/watermill-kafka/v3 v3.1.0/go.mod h1:o1GcoF/1CSJ9JSmQzUkULvpZeO635pZe+WWrYNFlJNk= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dnwe/otelsarama v0.0.0-20240308230250-9388d9d40bc0 h1:R2zQhFwSCyyd7L43igYjDrH0wkC/i+QBPELuY0HOu84= github.com/dnwe/otelsarama v0.0.0-20240308230250-9388d9d40bc0/go.mod h1:2MqLKYJfjs3UriXXF9Fd0Qmh/lhxi/6tHXkqtXxyIHc= github.com/eapache/go-resiliency v1.7.0 h1:n3NRTnBn5N0Cbi/IeOHuQn9s2UwVUH7Ga0ZWcP+9JTA= github.com/eapache/go-resiliency v1.7.0/go.mod h1:5yPzW0MIvSe0JDsv0v+DvcjEv2FyD6iZYSs1ZI+iQho= github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 h1:Oy0F4ALJ04o5Qqpdz8XLIpNA3WM/iSIXqxtqo7UGVws= github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3/go.mod h1:YvSRo5mw33fLEx1+DlK6L2VV43tJt5Eyel9n9XBcR+0= github.com/eapache/queue v1.1.0 h1:YOEu7KNc61ntiQlcEeUIoDTJ2o8mQznoNvUhiigpIqc= github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg= github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8= github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg= github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ= github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: _examples/basic/2-realtime-feed/consumer/main.go ================================================ package main import ( "context" "encoding/json" "fmt" "sync/atomic" "time" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill-kafka/v3/pkg/kafka" "github.com/ThreeDotsLabs/watermill/message" "github.com/ThreeDotsLabs/watermill/message/router/middleware" "github.com/ThreeDotsLabs/watermill/message/router/plugin" ) var ( marshaler = kafka.DefaultMarshaler{} brokers = []string{"kafka:9092"} ) func main() { logger := watermill.NewStdLogger(false, false) logger.Info("Starting the consumer", nil) pub, err := kafka.NewPublisher( kafka.PublisherConfig{ Brokers: brokers, Marshaler: marshaler, }, logger, ) if err != nil { panic(err) } r, err := message.NewRouter(message.RouterConfig{}, logger) if err != nil { panic(err) } retryMiddleware := middleware.Retry{ MaxRetries: 1, InitialInterval: time.Millisecond * 10, } poisonQueue, err := middleware.PoisonQueue(pub, "poison_queue") if err != nil { panic(err) } r.AddMiddleware( // Recoverer middleware recovers panic from handlers and middlewares middleware.Recoverer, // Limit incoming messages to 10 per second middleware.NewThrottle(10, time.Second).Middleware, // If the retries limit is exceeded (see retryMiddleware below), the message is sent // to the poison queue (published to poison_queue topic) poisonQueue, // Retry middleware retries message processing if an error occurred in the handler retryMiddleware.Middleware, // Correlation ID middleware adds the correlation ID of the consumed message to each produced message. // It's useful for debugging. middleware.CorrelationID, // Simulate errors or panics from handler middleware.RandomFail(0.01), middleware.RandomPanic(0.01), ) // Close the router when a SIGTERM is sent r.AddPlugin(plugin.SignalsHandler) // Handler that counts consumed posts r.AddHandler( "posts_counter", "posts_published", createSubscriber("posts_counter", logger), "posts_count", pub, PostsCounter{memoryCountStorage{new(int64)}}.Count, ) // Handler that generates "feed" from consumed posts // // This implementation just prints the posts on stdout, // but production ready implementation would save posts to some persistent storage. r.AddConsumerHandler( "feed_generator", "posts_published", createSubscriber("feed_generator", logger), FeedGenerator{printFeedStorage{}}.UpdateFeed, ) if err = r.Run(context.Background()); err != nil { panic(err) } } func createSubscriber(consumerGroup string, logger watermill.LoggerAdapter) message.Subscriber { sub, err := kafka.NewSubscriber( kafka.SubscriberConfig{ Brokers: brokers, Unmarshaler: marshaler, ConsumerGroup: consumerGroup, }, logger, ) if err != nil { panic(err) } return sub } type postsCountUpdated struct { NewCount int64 `json:"new_count"` } type countStorage interface { CountAdd() (int64, error) Count() (int64, error) } type memoryCountStorage struct { count *int64 } func (m memoryCountStorage) CountAdd() (int64, error) { return atomic.AddInt64(m.count, 1), nil } func (m memoryCountStorage) Count() (int64, error) { return atomic.LoadInt64(m.count), nil } type PostsCounter struct { countStorage countStorage } func (p PostsCounter) Count(msg *message.Message) ([]*message.Message, error) { // When implementing counter for production use, you'd probably need to add some kind of deduplication here, // unless the used Pub/Sub supports exactly-once delivery. newCount, err := p.countStorage.CountAdd() if err != nil { return nil, fmt.Errorf("cannot add count: %w", err) } producedMsg := postsCountUpdated{NewCount: newCount} b, err := json.Marshal(producedMsg) if err != nil { return nil, err } return []*message.Message{message.NewMessage(watermill.NewUUID(), b)}, nil } // postAdded might look similar to the postAdded type from producer. // It's intentionally not imported here. We avoid coupling the services at the cost of duplication. // We don't need all of its data either (content is not displayed on the feed). type postAdded struct { OccurredOn time.Time `json:"occurred_on"` Author string `json:"author"` Title string `json:"title"` } type feedStorage interface { AddToFeed(title, author string, time time.Time) error } type printFeedStorage struct{} func (printFeedStorage) AddToFeed(title, author string, time time.Time) error { fmt.Printf("Adding to feed: %s by %s @%s\n", title, author, time) return nil } type FeedGenerator struct { feedStorage feedStorage } func (f FeedGenerator) UpdateFeed(message *message.Message) error { event := postAdded{} if err := json.Unmarshal(message.Payload, &event); err != nil { return err } err := f.feedStorage.AddToFeed(event.Title, event.Author, event.OccurredOn) if err != nil { return fmt.Errorf("cannot update feed: %w", err) } return nil } ================================================ FILE: _examples/basic/2-realtime-feed/docker-compose.yml ================================================ services: producer: image: golang:1.25 restart: unless-stopped depends_on: - kafka volumes: - .:/app - $GOPATH/pkg/mod:/go/pkg/mod working_dir: /app/producer/ command: go run main.go consumer: image: golang:1.25 restart: unless-stopped depends_on: - kafka volumes: - .:/app - $GOPATH/pkg/mod:/go/pkg/mod working_dir: /app/consumer/ command: > /bin/sh -c "go install github.com/ThreeDotsLabs/watermill/tools/mill@latest && go run main.go" zookeeper: image: confluentinc/cp-zookeeper:7.3.1 restart: unless-stopped environment: ZOOKEEPER_CLIENT_PORT: 2181 logging: driver: none kafka: image: confluentinc/cp-kafka:7.3.1 restart: unless-stopped logging: driver: none depends_on: - zookeeper environment: KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092 KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true" ================================================ FILE: _examples/basic/2-realtime-feed/producer/go.mod ================================================ module main.go go 1.25 require ( github.com/ThreeDotsLabs/watermill v1.5.1 github.com/ThreeDotsLabs/watermill-kafka/v3 v3.1.0 github.com/brianvoe/gofakeit/v6 v6.28.0 ) require ( github.com/IBM/sarama v1.46.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dnwe/otelsarama v0.0.0-20240308230250-9388d9d40bc0 // indirect github.com/eapache/go-resiliency v1.7.0 // indirect github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 // indirect github.com/eapache/queue v1.1.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/golang/snappy v1.0.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/jcmturner/aescts/v2 v2.0.0 // indirect github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect github.com/jcmturner/gofork v1.7.6 // indirect github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect github.com/jcmturner/rpc/v2 v2.0.3 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/lithammer/shortuuid/v3 v3.0.7 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect github.com/sony/gobreaker v1.0.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/otel v1.38.0 // indirect go.opentelemetry.io/otel/metric v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect golang.org/x/crypto v0.41.0 // indirect golang.org/x/net v0.43.0 // indirect ) ================================================ FILE: _examples/basic/2-realtime-feed/producer/go.sum ================================================ github.com/IBM/sarama v1.46.0 h1:+YTM1fNd6WKMchlnLKRUB5Z0qD4M8YbvwIIPLvJD53s= github.com/IBM/sarama v1.46.0/go.mod h1:0lOcuQziJ1/mBGHkdp5uYrltqQuKQKM5O5FOWUQVVvo= github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY= github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892LARrKAdki6WtXS4= github.com/ThreeDotsLabs/watermill-kafka/v3 v3.1.0 h1:DfhVM1ieq+rb+bboB7aoymUgfKsEM3UbH3noQZ6+RJ4= github.com/ThreeDotsLabs/watermill-kafka/v3 v3.1.0/go.mod h1:o1GcoF/1CSJ9JSmQzUkULvpZeO635pZe+WWrYNFlJNk= github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4= github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dnwe/otelsarama v0.0.0-20240308230250-9388d9d40bc0 h1:R2zQhFwSCyyd7L43igYjDrH0wkC/i+QBPELuY0HOu84= github.com/dnwe/otelsarama v0.0.0-20240308230250-9388d9d40bc0/go.mod h1:2MqLKYJfjs3UriXXF9Fd0Qmh/lhxi/6tHXkqtXxyIHc= github.com/eapache/go-resiliency v1.7.0 h1:n3NRTnBn5N0Cbi/IeOHuQn9s2UwVUH7Ga0ZWcP+9JTA= github.com/eapache/go-resiliency v1.7.0/go.mod h1:5yPzW0MIvSe0JDsv0v+DvcjEv2FyD6iZYSs1ZI+iQho= github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 h1:Oy0F4ALJ04o5Qqpdz8XLIpNA3WM/iSIXqxtqo7UGVws= github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3/go.mod h1:YvSRo5mw33fLEx1+DlK6L2VV43tJt5Eyel9n9XBcR+0= github.com/eapache/queue v1.1.0 h1:YOEu7KNc61ntiQlcEeUIoDTJ2o8mQznoNvUhiigpIqc= github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg= github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8= github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg= github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ= github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: _examples/basic/2-realtime-feed/producer/main.go ================================================ package main import ( "encoding/json" "fmt" "math/rand" "os" "os/signal" "sync" "time" "github.com/brianvoe/gofakeit/v6" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill-kafka/v3/pkg/kafka" "github.com/ThreeDotsLabs/watermill/message" "github.com/ThreeDotsLabs/watermill/message/router/middleware" ) var ( brokers = []string{"kafka:9092"} messagesPerSecond = 100 numWorkers = 20 ) func main() { logger := watermill.NewStdLogger(false, false) logger.Info("Starting the producer", watermill.LogFields{}) publisher, err := kafka.NewPublisher( kafka.PublisherConfig{ Brokers: brokers, Marshaler: kafka.DefaultMarshaler{}, }, logger, ) if err != nil { panic(err) } defer publisher.Close() closeCh := make(chan struct{}) workersGroup := &sync.WaitGroup{} workersGroup.Add(numWorkers) for i := 0; i < numWorkers; i++ { go worker(publisher, workersGroup, closeCh) } // wait for SIGINT c := make(chan os.Signal, 1) signal.Notify(c, os.Interrupt) <-c // signal for the workers to stop publishing close(closeCh) // Waiting for all messages to be published workersGroup.Wait() logger.Info("All messages published", nil) } // worker publishes messages until closeCh is closed. func worker(publisher message.Publisher, wg *sync.WaitGroup, closeCh chan struct{}) { ticker := time.NewTicker(time.Duration(int(time.Second) / messagesPerSecond)) for { select { case <-closeCh: ticker.Stop() wg.Done() return case <-ticker.C: } msgPayload := postAdded{ OccurredOn: time.Now(), Author: gofakeit.Username(), Title: gofakeit.Sentence(rand.Intn(5) + 1), Content: gofakeit.Sentence(rand.Intn(10) + 5), } payload, err := json.Marshal(msgPayload) if err != nil { panic(err) } msg := message.NewMessage(watermill.NewUUID(), payload) // Use a middleware to set the correlation ID, it's useful for debugging middleware.SetCorrelationID(watermill.NewShortUUID(), msg) err = publisher.Publish("posts_published", msg) if err != nil { fmt.Println("cannot publish message:", err) continue } } } type postAdded struct { OccurredOn time.Time `json:"occurred_on"` Author string `json:"author"` Title string `json:"title"` Content string `json:"content"` } ================================================ FILE: _examples/basic/3-router/.validate_example.yml ================================================ validation_cmd: "go run main.go" timeout: 120 expected_output: "Received message: [0-9a-f\\-]+" ================================================ FILE: _examples/basic/3-router/go.mod ================================================ module main.go go 1.25 require github.com/ThreeDotsLabs/watermill v1.5.1 require ( github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/google/uuid v1.6.0 // indirect github.com/lithammer/shortuuid/v3 v3.0.7 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/sony/gobreaker v1.0.0 // indirect ) ================================================ FILE: _examples/basic/3-router/go.sum ================================================ github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY= github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892LARrKAdki6WtXS4= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ= github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: _examples/basic/3-router/main.go ================================================ // Sources for https://watermill.io/learn/getting-started/ package main import ( "context" "fmt" "log" "time" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill/message" "github.com/ThreeDotsLabs/watermill/message/router/middleware" "github.com/ThreeDotsLabs/watermill/message/router/plugin" "github.com/ThreeDotsLabs/watermill/pubsub/gochannel" ) var ( // For this example, we're using just a simple logger implementation, // You probably want to ship your own implementation of `watermill.LoggerAdapter`. logger = watermill.NewStdLogger(false, false) ) func main() { router, err := message.NewRouter(message.RouterConfig{}, logger) if err != nil { panic(err) } // SignalsHandler will gracefully shutdown Router when SIGTERM is received. // You can also close the router by just calling `r.Close()`. router.AddPlugin(plugin.SignalsHandler) // Router level middleware are executed for every message sent to the router router.AddMiddleware( // CorrelationID will copy the correlation id from the incoming message's metadata to the produced messages middleware.CorrelationID, // The handler function is retried if it returns an error. // After MaxRetries, the message is Nacked and it's up to the PubSub to resend it. middleware.Retry{ MaxRetries: 3, InitialInterval: time.Millisecond * 100, Logger: logger, }.Middleware, // Recoverer handles panics from handlers. // In this case, it passes them as errors to the Retry middleware. middleware.Recoverer, ) // For simplicity, we are using the gochannel Pub/Sub here, // You can replace it with any Pub/Sub implementation, it will work the same. pubSub := gochannel.NewGoChannel(gochannel.Config{}, logger) // Producing some incoming messages in background go publishMessages(pubSub) // AddHandler returns a handler which can be used to add handler level middleware // or to stop handler. handler := router.AddHandler( "struct_handler", // handler name, must be unique "incoming_messages_topic", // topic from which we will read events pubSub, "outgoing_messages_topic", // topic to which we will publish events pubSub, structHandler{}.Handler, ) // Handler level middleware is only executed for a specific handler // Such middleware can be added the same way the router level ones handler.AddMiddleware(func(h message.HandlerFunc) message.HandlerFunc { return func(message *message.Message) ([]*message.Message, error) { log.Println("executing handler specific middleware for ", message.UUID) return h(message) } }) // just for debug, we are printing all messages received on `incoming_messages_topic` router.AddConsumerHandler( "print_incoming_messages", "incoming_messages_topic", pubSub, printMessages, ) // just for debug, we are printing all events sent to `outgoing_messages_topic` router.AddConsumerHandler( "print_outgoing_messages", "outgoing_messages_topic", pubSub, printMessages, ) // Now that all handlers are registered, we're running the Router. // Run is blocking while the router is running. ctx := context.Background() if err := router.Run(ctx); err != nil { panic(err) } } func publishMessages(publisher message.Publisher) { for { msg := message.NewMessage(watermill.NewUUID(), []byte("Hello, world!")) middleware.SetCorrelationID(watermill.NewUUID(), msg) log.Printf("sending message %s, correlation id: %s\n", msg.UUID, middleware.MessageCorrelationID(msg)) if err := publisher.Publish("incoming_messages_topic", msg); err != nil { panic(err) } time.Sleep(time.Second) } } func printMessages(msg *message.Message) error { fmt.Printf( "\n> Received message: %s\n> %s\n> metadata: %v\n\n", msg.UUID, string(msg.Payload), msg.Metadata, ) return nil } type structHandler struct { // we can add some dependencies here } func (s structHandler) Handler(msg *message.Message) ([]*message.Message, error) { log.Println("structHandler received message", msg.UUID) msg = message.NewMessage(watermill.NewUUID(), []byte("message produced by structHandler")) return message.Messages{msg}, nil } ================================================ FILE: _examples/basic/4-metrics/.validate_example.yml ================================================ validation_cmd: "docker compose up" teardown_cmd: "docker compose down" timeout: 180 expected_output: "msg=\"Message acked\"" ================================================ FILE: _examples/basic/4-metrics/README.md ================================================ # Prometheus metrics showcase This is an example application that showcases how Watermill may be monitored with Prometheus metrics. The docker-compose bundle contains the following services: #### Golang A [Golang](https://hub.docker.com/_/golang) image which runs the [example code](https://github.com/ThreeDotsLabs/watermill/blob/master/_examples/basic/4-metrics/main.go). It consists of a router with a single handler. The handler consumes messages from a [Gochannel PubSub](https://github.com/ThreeDotsLabs/watermill/tree/master/message/infrastructure/gochannel), and publishes 0-4 copies of the message with a preconfigured random delay. Additionally, there is one goroutine which produces messages incoming to the handler with a gochannel publisher, and another goroutine which consumes the messages outgoing from the handler. The router, the standalone publisher and the standalone subscriber are all decorated with the metrics code and their statistics will appear in the dashboard. #### Prometheus [Prometheus](https://hub.docker.com/r/prom/prometheus/), to scrape the metrics which the golang application exposes at `:8081/metrics` by default. It is configured by a [prometheus.yml](https://github.com/ThreeDotsLabs/watermill/blob/master/_examples/basic/4-metrics/prometheus.yml) file, which declares the endpoints that Prometheus will scrape. #### Grafana [Grafana](https://hub.docker.com/r/grafana/grafana), to visualize the metrics in a dashboard. #### Running the example To run the docker-compose bundle, go to `_examples/metrics` and execute: ``` docker-compose up ``` The golang app will start running, producing messages, passing them through the handler, and consuming the copies. With default settings, the raw Prometheus metrics should appear at your http://localhost:8081/metrics. The Prometheus image should expose a more advanced UI at http://localhost:9090/graph, where you can investigate all the scraped metrics. However, what is the most useful way to monitor is through the use of Grafana, which you can access at http://localhost:3000. #### Adding the Prometheus data source to Grafana The fresh Grafana image should greet you with a login screen: ![Grafana login screen](https://threedots.tech/watermill-io/grafana_login.png) Just use the default `admin:admin` credentials. You can skip changing the password, if you wish. The next thing that we need to do is to add the Prometheus data source. Click on `Add data source`. In the following screen: 1. Enter a name for the Prometheus data source. Let's name this data source `prometheus`. 1. Choose `Prometheus` from the `Type` dropdown. 1. Enter the `http://localhost:9090` value in the HTTP/URL section. 1. You can leave the remaining settings at default and click `Save & Test`. ![Prometheus data source configuration](https://threedots.tech/watermill-io/prometheus_data_source_config.png) The Prometheus data source is now ready to use in Grafana. #### Importing the Grafana dashboard We have prepared a Grafana dashboard that visualizes the metrics exported by this example. To import the Grafana dashboard, select Dashboard/Manage from the left menu, and then click on `+Import` (or go to http://localhost:3000/dashboard/import). Enter the dashboard URL https://grafana.com/dashboards/9777 (or just the ID, 9777), and click on Load. ![Importing the dashboard](https://threedots.tech/watermill-io/grafana_import_dashboard.png) Then select the Prometheus data source created in the previous step. Click on `Import`, and you're done! ### Find out more To find out more, about metrics be sure to check out the [Watermill docs](https://watermill.io/docs/metrics). ================================================ FILE: _examples/basic/4-metrics/docker-compose.yml ================================================ services: golang: image: golang:1.25 restart: unless-stopped ports: - 8080:8080 - 8081:8081 depends_on: - prometheus volumes: - ../../../:/watermill - $GOPATH/pkg/mod:/go/pkg/mod working_dir: /watermill/_examples/basic/4-metrics command: go run main.go -metrics :8081 -delay 0.1 prometheus: image: prom/prometheus restart: unless-stopped network_mode: host volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml grafana: image: grafana/grafana:5.2.4 network_mode: host ================================================ FILE: _examples/basic/4-metrics/go.mod ================================================ module main.go go 1.25 require github.com/ThreeDotsLabs/watermill v1.5.1 require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/go-chi/chi/v5 v5.2.3 // indirect github.com/google/uuid v1.6.0 // indirect github.com/lithammer/shortuuid/v3 v3.0.7 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/prometheus/client_golang v1.23.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.65.0 // indirect github.com/prometheus/procfs v0.17.0 // indirect github.com/sony/gobreaker v1.0.0 // indirect golang.org/x/sys v0.35.0 // indirect google.golang.org/protobuf v1.36.8 // indirect ) // uncomment to use local sources // replace github.com/ThreeDotsLabs/watermill => ../../../../watermill ================================================ FILE: _examples/basic/4-metrics/go.sum ================================================ github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY= github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892LARrKAdki6WtXS4= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc= github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ= github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: _examples/basic/4-metrics/main.go ================================================ package main import ( "context" "errors" "flag" "math" "math/rand" "time" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill/components/metrics" "github.com/ThreeDotsLabs/watermill/message" "github.com/ThreeDotsLabs/watermill/message/router/middleware" "github.com/ThreeDotsLabs/watermill/message/router/plugin" "github.com/ThreeDotsLabs/watermill/pubsub/gochannel" ) var ( metricsAddr = flag.String("metrics", ":8081", "The address that will expose /metrics for Prometheus") handlerDelay = flag.Float64("delay", 0, "The stdev of normal distribution of delay in handler (in seconds), to simulate load") logger = watermill.NewStdLogger(true, true) random = rand.New(rand.NewSource(time.Now().Unix())) ) func delay() { seconds := *handlerDelay if seconds == 0 { return } delay := math.Abs(random.NormFloat64() * seconds) time.Sleep(time.Duration(float64(time.Second) * delay)) } // handler publishes 0-4 messages with a random delay. func handler(msg *message.Message) ([]*message.Message, error) { delay() numOutgoing := random.Intn(4) outgoing := make([]*message.Message, numOutgoing) for i := 0; i < numOutgoing; i++ { outgoing[i] = msg.Copy() } return outgoing, nil } // consumeMessages consumes the messages exiting the handler. func consumeMessages(subscriber message.Subscriber) { messages, err := subscriber.Subscribe(context.Background(), "pub_topic") if err != nil { panic(err) } for msg := range messages { msg.Ack() } } // produceMessages produces the incoming messages in delays of 50-100 milliseconds. func produceMessages(routerClosed chan struct{}, publisher message.Publisher) { for { select { case <-routerClosed: return default: // go on } time.Sleep(50*time.Millisecond + time.Duration(random.Intn(50))*time.Millisecond) msg := message.NewMessage(watermill.NewUUID(), []byte{}) _ = publisher.Publish("sub_topic", msg) } } func main() { flag.Parse() pubSub := gochannel.NewGoChannel(gochannel.Config{}, logger) router, err := message.NewRouter( message.RouterConfig{}, logger, ) if err != nil { panic(err) } prometheusRegistry, closeMetricsServer := metrics.CreateRegistryAndServeHTTP(*metricsAddr) defer closeMetricsServer() // we leave the namespace and subsystem empty metricsBuilder := metrics.NewPrometheusMetricsBuilder(prometheusRegistry, "", "") metricsBuilder.AddPrometheusRouterMetrics(router) router.AddMiddleware( middleware.Recoverer, middleware.RandomFail(0.1), middleware.RandomPanic(0.1), ) router.AddPlugin(plugin.SignalsHandler) router.AddHandler( "metrics-example", "sub_topic", pubSub, "pub_topic", pubSub, handler, ) pub := randomFailPublisherDecorator{pubSub, 0.1} // The handler's publisher and subscriber will be decorated by `AddPrometheusRouterMetrics`. // We are using the same pub/sub to generate messages incoming to the handler // and consume the outgoing messages. // They will have `handler_name=` label in Prometheus. subWithMetrics, err := metricsBuilder.DecorateSubscriber(pubSub) if err != nil { panic(err) } pubWithMetrics, err := metricsBuilder.DecoratePublisher(pub) if err != nil { panic(err) } routerClosed := make(chan struct{}) go produceMessages(routerClosed, pubWithMetrics) go consumeMessages(subWithMetrics) _ = router.Run(context.Background()) close(routerClosed) } type randomFailPublisherDecorator struct { message.Publisher failProbability float64 } func (r randomFailPublisherDecorator) Publish(topic string, messages ...*message.Message) error { if random.Float64() < r.failProbability { return errors.New("random publishing failure") } return r.Publisher.Publish(topic, messages...) } ================================================ FILE: _examples/basic/4-metrics/prometheus.yml ================================================ # my global config global: scrape_interval: 15s # Set the scrape interval to every 15 seconds. Default is every 1 minute. evaluation_interval: 15s # Evaluate rules every 15 seconds. The default is every 1 minute. # scrape_timeout is set to the global default (10s). scrape_configs: # # The job name is added as a label `job=` to any timeseries scraped from this config. # - job_name: 'prometheus' # # # metrics_path defaults to '/metrics' # # scheme defaults to 'http'. # # static_configs: # - targets: ['localhost:9090'] - job_name: 'metrics_example' static_configs: - targets: ['localhost:8081'] ================================================ FILE: _examples/basic/5-cqrs-protobuf/.validate_example.yml ================================================ validation_cmd: "docker compose up" teardown_cmd: "docker compose down" timeout: 120 expected_outputs: - "beers ordered to room \\d+" - "Already booked rooms for \\$\\d{2,}" ================================================ FILE: _examples/basic/5-cqrs-protobuf/Makefile ================================================ .PHONY: proto proto: protoc --proto_path=proto --go_out=. --go_opt=paths=source_relative proto/messages.proto ================================================ FILE: _examples/basic/5-cqrs-protobuf/README.md ================================================ # Example Golang CQRS application This application is using [Watermill CQRS](http://watermill.io/docs/cqrs) component. Detailed documentation for CQRS can be found in Watermill's docs: [http://watermill.io/docs/cqrs#usage](http://watermill.io/docs/cqrs). ![CQRS Event Storming](https://threedots.tech/watermill-io/cqrs-example-storming.png) ```mermaid sequenceDiagram participant M as Main participant CB as CommandBus participant BRH as BookRoomHandler participant EB as EventBus participant OBRB as OrderBeerOnRoomBooked participant OBH as OrderBeerHandler participant BFR as BookingsFinancialReport Note over M,BFR: Commands use AMQP queue, Events use AMQP pub/sub M->>CB: Send(BookRoom)
topic: commands.BookRoom CB->>BRH: Handle(BookRoom) BRH->>EB: Publish(RoomBooked)
topic: events.RoomBooked par Process RoomBooked Event EB->>OBRB: Handle(RoomBooked) OBRB->>CB: Send(OrderBeer)
topic: commands.OrderBeer CB->>OBH: Handle(OrderBeer) OBH->>EB: Publish(BeerOrdered)
topic: events.BeerOrdered EB->>BFR: Handle(RoomBooked) Note over BFR: Updates financial report end ``` ## Running ```bash docker-compose up ``` ================================================ FILE: _examples/basic/5-cqrs-protobuf/docker-compose.yml ================================================ services: golang: image: golang:1.25 restart: unless-stopped ports: - 8080:8080 depends_on: - rabbitmq links: - rabbitmq volumes: - .:/app - $GOPATH/pkg/mod:/go/pkg/mod working_dir: /app command: go run . rabbitmq: image: rabbitmq:3.7 restart: unless-stopped attach: false ================================================ FILE: _examples/basic/5-cqrs-protobuf/go.mod ================================================ module main.go require ( github.com/ThreeDotsLabs/watermill v1.5.1 github.com/ThreeDotsLabs/watermill-amqp/v3 v3.0.2 google.golang.org/protobuf v1.36.8 ) require ( github.com/cenkalti/backoff/v3 v3.2.2 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/lithammer/shortuuid/v3 v3.0.7 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/rabbitmq/amqp091-go v1.10.0 // indirect github.com/sony/gobreaker v1.0.0 // indirect ) go 1.25 ================================================ FILE: _examples/basic/5-cqrs-protobuf/go.sum ================================================ github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY= github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892LARrKAdki6WtXS4= github.com/ThreeDotsLabs/watermill-amqp/v3 v3.0.2 h1:aeyFSR4SUsbszmocuFiYY13nsHorc6CXIS2Hy7+xgFU= github.com/ThreeDotsLabs/watermill-amqp/v3 v3.0.2/go.mod h1:+8tCh6VCuBcQWhfETCwzRINKQ1uyeg9moH3h7jMKxQk= github.com/cenkalti/backoff/v3 v3.2.2 h1:cfUAAO3yvKMYKPrvhDuHSwQnhZNk/RMHKdZqKTxfm6M= github.com/cenkalti/backoff/v3 v3.2.2/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4rc0ij+ULvLYs= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw= github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ= github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: _examples/basic/5-cqrs-protobuf/main.go ================================================ package main import ( "context" "fmt" "log/slog" "math/rand" "sync" "time" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill-amqp/v3/pkg/amqp" "github.com/ThreeDotsLabs/watermill/components/cqrs" "github.com/ThreeDotsLabs/watermill/message" "github.com/ThreeDotsLabs/watermill/message/router/middleware" "google.golang.org/protobuf/types/known/timestamppb" ) // BookRoomHandler is a command handler, which handles BookRoom command and emits RoomBooked. // // In CQRS, one command must be handled by only one handler. // When another handler with this command is added to command processor, error will be returned. type BookRoomHandler struct { eventBus *cqrs.EventBus } func (b BookRoomHandler) Handle(ctx context.Context, cmd *BookRoom) error { // some random price, in production you probably will calculate in wiser way price := (rand.Int63n(40) + 1) * 10 slog.Info( "Booked room", "room_id", cmd.RoomId, "guest_name", cmd.GuestName, "start_date", time.Unix(cmd.StartDate.Seconds, int64(cmd.StartDate.Nanos)), "end_date", time.Unix(cmd.EndDate.Seconds, int64(cmd.EndDate.Nanos)), ) // RoomBooked will be handled by OrderBeerOnRoomBooked event handler, // in future RoomBooked may be handled by multiple event handler if err := b.eventBus.Publish(ctx, &RoomBooked{ ReservationId: watermill.NewUUID(), RoomId: cmd.RoomId, GuestName: cmd.GuestName, Price: price, StartDate: cmd.StartDate, EndDate: cmd.EndDate, }); err != nil { return err } return nil } // OrderBeerOnRoomBooked is an event handler, which handles RoomBooked event and emits OrderBeer command. type OrderBeerOnRoomBooked struct { commandBus *cqrs.CommandBus } func (o OrderBeerOnRoomBooked) Handle(ctx context.Context, event *RoomBooked) error { orderBeerCmd := &OrderBeer{ RoomId: event.RoomId, Count: rand.Int63n(10) + 1, } return o.commandBus.Send(ctx, orderBeerCmd) } // OrderBeerHandler is a command handler, which handles OrderBeer command and emits BeerOrdered. // BeerOrdered is not handled by any event handler, but we may use persistent Pub/Sub to handle it in the future. type OrderBeerHandler struct { eventBus *cqrs.EventBus } func (o OrderBeerHandler) Handle(ctx context.Context, cmd *OrderBeer) error { if rand.Int63n(10) == 0 { // sometimes there is no beer left, command will be retried return fmt.Errorf("no beer left for room %s, please try later", cmd.RoomId) } if err := o.eventBus.Publish(ctx, &BeerOrdered{ RoomId: cmd.RoomId, Count: cmd.Count, }); err != nil { return err } slog.Info(fmt.Sprintf("%d beers ordered to room %s", cmd.Count, cmd.RoomId)) return nil } // BookingsFinancialReport is a read model, which calculates how much money we may earn from bookings. // Like OrderBeerOnRoomBooked, it listens for RoomBooked event. // // This implementation is just writing to the memory. In production, you will probably will use some persistent storage. type BookingsFinancialReport struct { handledBookings map[string]struct{} totalCharge int64 lock sync.Mutex } func NewBookingsFinancialReport() *BookingsFinancialReport { return &BookingsFinancialReport{handledBookings: map[string]struct{}{}} } func (b *BookingsFinancialReport) Handle(ctx context.Context, event *RoomBooked) error { // Handle may be called concurrently, so it need to be thread safe. b.lock.Lock() defer b.lock.Unlock() // When we are using Pub/Sub which doesn't provide exactly-once delivery semantics, we need to deduplicate messages. // GoChannel Pub/Sub provides exactly-once delivery, // but let's make this example ready for other Pub/Sub implementations. if _, ok := b.handledBookings[event.ReservationId]; ok { return nil } b.handledBookings[event.ReservationId] = struct{}{} b.totalCharge += event.Price slog.Info(fmt.Sprintf(">>> Already booked rooms for $%d\n", b.totalCharge)) return nil } var amqpAddress = "amqp://guest:guest@rabbitmq:5672/" func main() { logger := watermill.NewSlogLoggerWithLevelMapping(nil, map[slog.Level]slog.Level{ slog.LevelInfo: slog.LevelDebug, }) cqrsMarshaler := cqrs.ProtoMarshaler{ // It will generate topic names based on the event/command type. // So for example, for "RoomBooked" name will be "RoomBooked". // // This value is used to generate topic names with "generateEventsTopic" and "generateCommandsTopic" functions. GenerateName: cqrs.StructName, } generateEventsTopic := func(eventName string) string { return "events." + eventName } generateCommandsTopic := func(commandName string) string { return "commands." + commandName } // You can use any Pub/Sub implementation from here: https://watermill.io/pubsubs/ // Detailed RabbitMQ implementation: https://watermill.io/pubsubs/amqp/ // Commands will be sent to queue, because they need to be consumed once. commandsAMQPConfig := amqp.NewDurableQueueConfig(amqpAddress) commandsPublisher, err := amqp.NewPublisher(commandsAMQPConfig, logger) if err != nil { panic(err) } commandsSubscriber, err := amqp.NewSubscriber(commandsAMQPConfig, logger) if err != nil { panic(err) } // Events will be published to PubSub configured Rabbit, because they may be consumed by multiple consumers. // (in that case BookingsFinancialReport and OrderBeerOnRoomBooked). eventsPublisher, err := amqp.NewPublisher(amqp.NewDurablePubSubConfig(amqpAddress, nil), logger) if err != nil { panic(err) } // CQRS is built on messages router. Detailed documentation: https://watermill.io/docs/messages-router/ router, err := message.NewRouter(message.RouterConfig{}, logger) if err != nil { panic(err) } // Simple middleware which will recover panics from event or command handlers. // More about router middlewares you can find in the documentation: // https://watermill.io/docs/messages-router/#middleware // // List of available middlewares you can find in message/router/middleware. router.AddMiddleware(middleware.Recoverer) commandBus, err := cqrs.NewCommandBusWithConfig(commandsPublisher, cqrs.CommandBusConfig{ GeneratePublishTopic: func(params cqrs.CommandBusGeneratePublishTopicParams) (string, error) { return generateCommandsTopic(params.CommandName), nil }, OnSend: func(params cqrs.CommandBusOnSendParams) error { logger.Info("Sending command", watermill.LogFields{ "command_name": params.CommandName, }) params.Message.Metadata.Set("sent_at", time.Now().String()) return nil }, Marshaler: cqrsMarshaler, Logger: logger, }) if err != nil { panic(err) } commandProcessor, err := cqrs.NewCommandProcessorWithConfig( router, cqrs.CommandProcessorConfig{ GenerateSubscribeTopic: func(params cqrs.CommandProcessorGenerateSubscribeTopicParams) (string, error) { return generateCommandsTopic(params.CommandName), nil }, SubscriberConstructor: func(params cqrs.CommandProcessorSubscriberConstructorParams) (message.Subscriber, error) { // we can reuse subscriber, because all commands have separated topics return commandsSubscriber, nil }, OnHandle: func(params cqrs.CommandProcessorOnHandleParams) error { start := time.Now() err := params.Handler.Handle(params.Message.Context(), params.Command) logger.Info("Command handled", watermill.LogFields{ "command_name": params.CommandName, "duration": time.Since(start), "err": err, }) return err }, Marshaler: cqrsMarshaler, Logger: logger, }, ) if err != nil { panic(err) } eventBus, err := cqrs.NewEventBusWithConfig(eventsPublisher, cqrs.EventBusConfig{ GeneratePublishTopic: func(params cqrs.GenerateEventPublishTopicParams) (string, error) { return generateEventsTopic(params.EventName), nil }, OnPublish: func(params cqrs.OnEventSendParams) error { logger.Info("Publishing event", watermill.LogFields{ "event_name": params.EventName, }) params.Message.Metadata.Set("published_at", time.Now().String()) return nil }, Marshaler: cqrsMarshaler, Logger: logger, }) if err != nil { panic(err) } eventProcessor, err := cqrs.NewEventProcessorWithConfig( router, cqrs.EventProcessorConfig{ GenerateSubscribeTopic: func(params cqrs.EventProcessorGenerateSubscribeTopicParams) (string, error) { return generateEventsTopic(params.EventName), nil }, SubscriberConstructor: func(params cqrs.EventProcessorSubscriberConstructorParams) (message.Subscriber, error) { config := amqp.NewDurablePubSubConfig( amqpAddress, amqp.GenerateQueueNameTopicNameWithSuffix(params.HandlerName), ) return amqp.NewSubscriber(config, logger) }, OnHandle: func(params cqrs.EventProcessorOnHandleParams) error { start := time.Now() err := params.Handler.Handle(params.Message.Context(), params.Event) logger.Info("Event handled", watermill.LogFields{ "event_name": params.EventName, "duration": time.Since(start), "err": err, }) return err }, Marshaler: cqrsMarshaler, Logger: logger, }, ) if err != nil { panic(err) } err = commandProcessor.AddHandlers( cqrs.NewCommandHandler("BookRoomHandler", BookRoomHandler{eventBus}.Handle), cqrs.NewCommandHandler("OrderBeerHandler", OrderBeerHandler{eventBus}.Handle), ) if err != nil { panic(err) } err = eventProcessor.AddHandlers( cqrs.NewEventHandler( "OrderBeerOnRoomBooked", OrderBeerOnRoomBooked{commandBus}.Handle, ), cqrs.NewEventHandler( "LogBeerOrdered", func(ctx context.Context, event *BeerOrdered) error { logger.Info("Beer ordered", watermill.LogFields{ "room_id": event.RoomId, }) return nil }, ), cqrs.NewEventHandler( "BookingsFinancialReport", NewBookingsFinancialReport().Handle, ), ) if err != nil { panic(err) } // publish BookRoom commands every second to simulate incoming traffic go publishCommands(commandBus) // processors are based on router, so they will work when router will start if err := router.Run(context.Background()); err != nil { panic(err) } } func publishCommands(commandBus *cqrs.CommandBus) func() { i := 0 for { i++ startDate := timestamppb.New(time.Now()) endDate := timestamppb.New(time.Now().Add(time.Hour * 24 * 3)) bookRoomCmd := &BookRoom{ RoomId: fmt.Sprintf("%d", i), GuestName: "John", StartDate: startDate, EndDate: endDate, } if err := commandBus.Send(context.Background(), bookRoomCmd); err != nil { panic(err) } time.Sleep(time.Second) } } ================================================ FILE: _examples/basic/5-cqrs-protobuf/messages.pb.go ================================================ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.31.0 // protoc v4.24.4 // source: messages.proto package main import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" timestamppb "google.golang.org/protobuf/types/known/timestamppb" reflect "reflect" sync "sync" ) const ( // Verify that this generated code is sufficiently up-to-date. _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) // Verify that runtime/protoimpl is sufficiently up-to-date. _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) type BookRoom struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields RoomId string `protobuf:"bytes,1,opt,name=room_id,json=roomId,proto3" json:"room_id,omitempty"` GuestName string `protobuf:"bytes,2,opt,name=guest_name,json=guestName,proto3" json:"guest_name,omitempty"` StartDate *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=start_date,json=startDate,proto3" json:"start_date,omitempty"` EndDate *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=end_date,json=endDate,proto3" json:"end_date,omitempty"` } func (x *BookRoom) Reset() { *x = BookRoom{} if protoimpl.UnsafeEnabled { mi := &file_messages_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } } func (x *BookRoom) String() string { return protoimpl.X.MessageStringOf(x) } func (*BookRoom) ProtoMessage() {} func (x *BookRoom) ProtoReflect() protoreflect.Message { mi := &file_messages_proto_msgTypes[0] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use BookRoom.ProtoReflect.Descriptor instead. func (*BookRoom) Descriptor() ([]byte, []int) { return file_messages_proto_rawDescGZIP(), []int{0} } func (x *BookRoom) GetRoomId() string { if x != nil { return x.RoomId } return "" } func (x *BookRoom) GetGuestName() string { if x != nil { return x.GuestName } return "" } func (x *BookRoom) GetStartDate() *timestamppb.Timestamp { if x != nil { return x.StartDate } return nil } func (x *BookRoom) GetEndDate() *timestamppb.Timestamp { if x != nil { return x.EndDate } return nil } type RoomBooked struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields ReservationId string `protobuf:"bytes,1,opt,name=reservation_id,json=reservationId,proto3" json:"reservation_id,omitempty"` RoomId string `protobuf:"bytes,2,opt,name=room_id,json=roomId,proto3" json:"room_id,omitempty"` GuestName string `protobuf:"bytes,3,opt,name=guest_name,json=guestName,proto3" json:"guest_name,omitempty"` Price int64 `protobuf:"varint,4,opt,name=price,proto3" json:"price,omitempty"` StartDate *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=start_date,json=startDate,proto3" json:"start_date,omitempty"` EndDate *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=end_date,json=endDate,proto3" json:"end_date,omitempty"` } func (x *RoomBooked) Reset() { *x = RoomBooked{} if protoimpl.UnsafeEnabled { mi := &file_messages_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } } func (x *RoomBooked) String() string { return protoimpl.X.MessageStringOf(x) } func (*RoomBooked) ProtoMessage() {} func (x *RoomBooked) ProtoReflect() protoreflect.Message { mi := &file_messages_proto_msgTypes[1] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use RoomBooked.ProtoReflect.Descriptor instead. func (*RoomBooked) Descriptor() ([]byte, []int) { return file_messages_proto_rawDescGZIP(), []int{1} } func (x *RoomBooked) GetReservationId() string { if x != nil { return x.ReservationId } return "" } func (x *RoomBooked) GetRoomId() string { if x != nil { return x.RoomId } return "" } func (x *RoomBooked) GetGuestName() string { if x != nil { return x.GuestName } return "" } func (x *RoomBooked) GetPrice() int64 { if x != nil { return x.Price } return 0 } func (x *RoomBooked) GetStartDate() *timestamppb.Timestamp { if x != nil { return x.StartDate } return nil } func (x *RoomBooked) GetEndDate() *timestamppb.Timestamp { if x != nil { return x.EndDate } return nil } type OrderBeer struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields RoomId string `protobuf:"bytes,1,opt,name=room_id,json=roomId,proto3" json:"room_id,omitempty"` Count int64 `protobuf:"varint,2,opt,name=count,proto3" json:"count,omitempty"` } func (x *OrderBeer) Reset() { *x = OrderBeer{} if protoimpl.UnsafeEnabled { mi := &file_messages_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } } func (x *OrderBeer) String() string { return protoimpl.X.MessageStringOf(x) } func (*OrderBeer) ProtoMessage() {} func (x *OrderBeer) ProtoReflect() protoreflect.Message { mi := &file_messages_proto_msgTypes[2] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use OrderBeer.ProtoReflect.Descriptor instead. func (*OrderBeer) Descriptor() ([]byte, []int) { return file_messages_proto_rawDescGZIP(), []int{2} } func (x *OrderBeer) GetRoomId() string { if x != nil { return x.RoomId } return "" } func (x *OrderBeer) GetCount() int64 { if x != nil { return x.Count } return 0 } type BeerOrdered struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields RoomId string `protobuf:"bytes,1,opt,name=room_id,json=roomId,proto3" json:"room_id,omitempty"` Count int64 `protobuf:"varint,2,opt,name=count,proto3" json:"count,omitempty"` } func (x *BeerOrdered) Reset() { *x = BeerOrdered{} if protoimpl.UnsafeEnabled { mi := &file_messages_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } } func (x *BeerOrdered) String() string { return protoimpl.X.MessageStringOf(x) } func (*BeerOrdered) ProtoMessage() {} func (x *BeerOrdered) ProtoReflect() protoreflect.Message { mi := &file_messages_proto_msgTypes[3] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use BeerOrdered.ProtoReflect.Descriptor instead. func (*BeerOrdered) Descriptor() ([]byte, []int) { return file_messages_proto_rawDescGZIP(), []int{3} } func (x *BeerOrdered) GetRoomId() string { if x != nil { return x.RoomId } return "" } func (x *BeerOrdered) GetCount() int64 { if x != nil { return x.Count } return 0 } var File_messages_proto protoreflect.FileDescriptor var file_messages_proto_rawDesc = []byte{ 0x0a, 0x0e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x04, 0x6d, 0x61, 0x69, 0x6e, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xb4, 0x01, 0x0a, 0x08, 0x42, 0x6f, 0x6f, 0x6b, 0x52, 0x6f, 0x6f, 0x6d, 0x12, 0x17, 0x0a, 0x07, 0x72, 0x6f, 0x6f, 0x6d, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x6f, 0x6f, 0x6d, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x67, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x67, 0x75, 0x65, 0x73, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x39, 0x0a, 0x0a, 0x73, 0x74, 0x61, 0x72, 0x74, 0x5f, 0x64, 0x61, 0x74, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x73, 0x74, 0x61, 0x72, 0x74, 0x44, 0x61, 0x74, 0x65, 0x12, 0x35, 0x0a, 0x08, 0x65, 0x6e, 0x64, 0x5f, 0x64, 0x61, 0x74, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x07, 0x65, 0x6e, 0x64, 0x44, 0x61, 0x74, 0x65, 0x22, 0xf3, 0x01, 0x0a, 0x0a, 0x52, 0x6f, 0x6f, 0x6d, 0x42, 0x6f, 0x6f, 0x6b, 0x65, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x72, 0x65, 0x73, 0x65, 0x72, 0x76, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x72, 0x65, 0x73, 0x65, 0x72, 0x76, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x72, 0x6f, 0x6f, 0x6d, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x6f, 0x6f, 0x6d, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x67, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x67, 0x75, 0x65, 0x73, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x70, 0x72, 0x69, 0x63, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x70, 0x72, 0x69, 0x63, 0x65, 0x12, 0x39, 0x0a, 0x0a, 0x73, 0x74, 0x61, 0x72, 0x74, 0x5f, 0x64, 0x61, 0x74, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x73, 0x74, 0x61, 0x72, 0x74, 0x44, 0x61, 0x74, 0x65, 0x12, 0x35, 0x0a, 0x08, 0x65, 0x6e, 0x64, 0x5f, 0x64, 0x61, 0x74, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x07, 0x65, 0x6e, 0x64, 0x44, 0x61, 0x74, 0x65, 0x22, 0x3a, 0x0a, 0x09, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x42, 0x65, 0x65, 0x72, 0x12, 0x17, 0x0a, 0x07, 0x72, 0x6f, 0x6f, 0x6d, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x6f, 0x6f, 0x6d, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x3c, 0x0a, 0x0b, 0x42, 0x65, 0x65, 0x72, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x65, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x72, 0x6f, 0x6f, 0x6d, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x6f, 0x6f, 0x6d, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x42, 0x08, 0x5a, 0x06, 0x2e, 0x2f, 0x6d, 0x61, 0x69, 0x6e, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( file_messages_proto_rawDescOnce sync.Once file_messages_proto_rawDescData = file_messages_proto_rawDesc ) func file_messages_proto_rawDescGZIP() []byte { file_messages_proto_rawDescOnce.Do(func() { file_messages_proto_rawDescData = protoimpl.X.CompressGZIP(file_messages_proto_rawDescData) }) return file_messages_proto_rawDescData } var file_messages_proto_msgTypes = make([]protoimpl.MessageInfo, 4) var file_messages_proto_goTypes = []interface{}{ (*BookRoom)(nil), // 0: main.BookRoom (*RoomBooked)(nil), // 1: main.RoomBooked (*OrderBeer)(nil), // 2: main.OrderBeer (*BeerOrdered)(nil), // 3: main.BeerOrdered (*timestamppb.Timestamp)(nil), // 4: google.protobuf.Timestamp } var file_messages_proto_depIdxs = []int32{ 4, // 0: main.BookRoom.start_date:type_name -> google.protobuf.Timestamp 4, // 1: main.BookRoom.end_date:type_name -> google.protobuf.Timestamp 4, // 2: main.RoomBooked.start_date:type_name -> google.protobuf.Timestamp 4, // 3: main.RoomBooked.end_date:type_name -> google.protobuf.Timestamp 4, // [4:4] is the sub-list for method output_type 4, // [4:4] is the sub-list for method input_type 4, // [4:4] is the sub-list for extension type_name 4, // [4:4] is the sub-list for extension extendee 0, // [0:4] is the sub-list for field type_name } func init() { file_messages_proto_init() } func file_messages_proto_init() { if File_messages_proto != nil { return } if !protoimpl.UnsafeEnabled { file_messages_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*BookRoom); i { case 0: return &v.state case 1: return &v.sizeCache case 2: return &v.unknownFields default: return nil } } file_messages_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*RoomBooked); i { case 0: return &v.state case 1: return &v.sizeCache case 2: return &v.unknownFields default: return nil } } file_messages_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*OrderBeer); i { case 0: return &v.state case 1: return &v.sizeCache case 2: return &v.unknownFields default: return nil } } file_messages_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*BeerOrdered); i { case 0: return &v.state case 1: return &v.sizeCache case 2: return &v.unknownFields default: return nil } } } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_messages_proto_rawDesc, NumEnums: 0, NumMessages: 4, NumExtensions: 0, NumServices: 0, }, GoTypes: file_messages_proto_goTypes, DependencyIndexes: file_messages_proto_depIdxs, MessageInfos: file_messages_proto_msgTypes, }.Build() File_messages_proto = out.File file_messages_proto_rawDesc = nil file_messages_proto_goTypes = nil file_messages_proto_depIdxs = nil } ================================================ FILE: _examples/basic/5-cqrs-protobuf/proto/messages.proto ================================================ syntax = "proto3"; package main; option go_package = "./main"; import "google/protobuf/timestamp.proto"; message BookRoom { string room_id = 1; string guest_name = 2; google.protobuf.Timestamp start_date = 4; google.protobuf.Timestamp end_date = 5; } message RoomBooked { string reservation_id = 1; string room_id = 2; string guest_name = 3; int64 price = 4; google.protobuf.Timestamp start_date = 5; google.protobuf.Timestamp end_date = 6; } message OrderBeer { string room_id = 1; int64 count = 2; } message BeerOrdered { string room_id = 1; int64 count = 2; } ================================================ FILE: _examples/basic/6-cqrs-ordered-events/.validate_example.yml ================================================ validation_cmd: "docker compose up" teardown_cmd: "docker compose down" timeout: 120 expected_outputs: - "Subscriber added subscriber_id" - "Subscriber updated subscriber_id" - "Subscriber removed subscriber_id" - "\\[ACTIVITY\\] activity_type=SUBSCRIBED" - "\\[ACTIVITY\\] activity_type=UNSUBSCRIBED" - "\\[ACTIVITY\\] activity_type=EMAIL_UPDATED" ================================================ FILE: _examples/basic/6-cqrs-ordered-events/Makefile ================================================ .PHONY: proto proto: protoc --proto_path=proto --go_out=. --go_opt=paths=source_relative proto/messages.proto ================================================ FILE: _examples/basic/6-cqrs-ordered-events/README.md ================================================ # Example Golang CQRS application - ordered events with Kafka This application is using [Watermill CQRS](http://watermill.io/docs/cqrs) component. Detailed documentation for CQRS can be found in Watermill's docs: [http://watermill.io/docs/cqrs#usage](http://watermill.io/docs/cqrs). This example, uses event groups to keep order for multiple events. You can read more about them in the [Watermill documentation](https://watermill.io/docs/cqrs/). We are also using Kafka's partitioning keys to increase processing throughput without losing order of events. ## What does this application do? This application manages an email subscription system where users can: 1. Subscribe to receive emails by providing their email address 2. Update their email address after subscribing 3. Unsubscribe from the mailing list The system maintains: - A current list of all active subscribers - A timeline of all subscription-related activities In this example, keeping order of events is crucial. If events won't be ordered, and `SubscriberSubscribed` would arrive after `SubscriberUnsubscribed` event, the subscriber will be still subscribed. ## Possible improvements In this example, we are using global `events` and `commands` topics. You can consider splitting them into smaller topics, for example, per aggregate type. Thanks to that, you can scale your application horizontally and increase the throughput and processing less events. ## Running ```bash docker-compose up ``` ================================================ FILE: _examples/basic/6-cqrs-ordered-events/activity.go ================================================ package main import ( "context" "fmt" "log/slog" "sync" "time" ) // ActivityEntry represents a single event in the timeline type ActivityEntry struct { Timestamp time.Time SubscriberID string ActivityType string Details string } // ActivityTimelineReadModel maintains a chronological log of all subscription-related events type ActivityTimelineReadModel struct { activities []ActivityEntry lock sync.RWMutex } func NewActivityTimelineModel() *ActivityTimelineReadModel { return &ActivityTimelineReadModel{ activities: make([]ActivityEntry, 0), } } // OnSubscribed handles subscription events func (m *ActivityTimelineReadModel) OnSubscribed(ctx context.Context, event *SubscriberSubscribed) error { m.lock.Lock() defer m.lock.Unlock() entry := ActivityEntry{ Timestamp: time.Now(), SubscriberID: event.SubscriberId, ActivityType: "SUBSCRIBED", Details: fmt.Sprintf("Subscribed with email: %s", event.Email), } m.activities = append(m.activities, entry) m.logActivity(entry) return nil } // OnUnsubscribed handles unsubscription events func (m *ActivityTimelineReadModel) OnUnsubscribed(ctx context.Context, event *SubscriberUnsubscribed) error { m.lock.Lock() defer m.lock.Unlock() entry := ActivityEntry{ Timestamp: time.Now(), SubscriberID: event.SubscriberId, ActivityType: "UNSUBSCRIBED", Details: "Subscriber unsubscribed", } m.activities = append(m.activities, entry) m.logActivity(entry) return nil } // OnEmailUpdated handles email update events func (m *ActivityTimelineReadModel) OnEmailUpdated(ctx context.Context, event *SubscriberEmailUpdated) error { m.lock.Lock() defer m.lock.Unlock() entry := ActivityEntry{ Timestamp: time.Now(), SubscriberID: event.SubscriberId, ActivityType: "EMAIL_UPDATED", Details: fmt.Sprintf("Email updated to: %s", event.NewEmail), } m.activities = append(m.activities, entry) m.logActivity(entry) return nil } func (m *ActivityTimelineReadModel) logActivity(entry ActivityEntry) { slog.Info( "[ACTIVITY]", "activity_type", entry.ActivityType, "subscriber_id", entry.SubscriberID, "details", entry.Details, ) } ================================================ FILE: _examples/basic/6-cqrs-ordered-events/docker-compose.yml ================================================ services: golang: image: golang:1.25 restart: unless-stopped ports: - 8080:8080 depends_on: - kafka - zookeeper links: - kafka - zookeeper volumes: - .:/app - $GOPATH/pkg/mod:/go/pkg/mod working_dir: /app command: go run . zookeeper: container_name: zk attach: false image: confluentinc/cp-zookeeper:7.7.1 environment: ZOOKEEPER_CLIENT_PORT: 2181 ZOOKEEPER_TICK_TIME: 2000 ports: - 2181:2181 kafka: container_name: kafka attach: false image: confluentinc/cp-kafka:7.7.1 depends_on: - zookeeper ports: - 9093:9093 environment: KAFKA_BROKER_ID: 1 KAFKA_ZOOKEEPER_CONNECT: zk:2181 KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092,PLAINTEXT_HOST://localhost:9093 KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 ================================================ FILE: _examples/basic/6-cqrs-ordered-events/go.mod ================================================ module main.go require ( github.com/ThreeDotsLabs/watermill v1.5.1 github.com/ThreeDotsLabs/watermill-kafka/v3 v3.1.0 google.golang.org/protobuf v1.36.8 ) require ( github.com/IBM/sarama v1.46.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dnwe/otelsarama v0.0.0-20240308230250-9388d9d40bc0 // indirect github.com/eapache/go-resiliency v1.7.0 // indirect github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 // indirect github.com/eapache/queue v1.1.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/snappy v1.0.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/jcmturner/aescts/v2 v2.0.0 // indirect github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect github.com/jcmturner/gofork v1.7.6 // indirect github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect github.com/jcmturner/rpc/v2 v2.0.3 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/lithammer/shortuuid/v3 v3.0.7 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect github.com/sony/gobreaker v1.0.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/otel v1.38.0 // indirect go.opentelemetry.io/otel/metric v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect golang.org/x/crypto v0.41.0 // indirect golang.org/x/net v0.43.0 // indirect ) go 1.25 ================================================ FILE: _examples/basic/6-cqrs-ordered-events/go.sum ================================================ github.com/IBM/sarama v1.46.0 h1:+YTM1fNd6WKMchlnLKRUB5Z0qD4M8YbvwIIPLvJD53s= github.com/IBM/sarama v1.46.0/go.mod h1:0lOcuQziJ1/mBGHkdp5uYrltqQuKQKM5O5FOWUQVVvo= github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY= github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892LARrKAdki6WtXS4= github.com/ThreeDotsLabs/watermill-kafka/v3 v3.1.0 h1:DfhVM1ieq+rb+bboB7aoymUgfKsEM3UbH3noQZ6+RJ4= github.com/ThreeDotsLabs/watermill-kafka/v3 v3.1.0/go.mod h1:o1GcoF/1CSJ9JSmQzUkULvpZeO635pZe+WWrYNFlJNk= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dnwe/otelsarama v0.0.0-20240308230250-9388d9d40bc0 h1:R2zQhFwSCyyd7L43igYjDrH0wkC/i+QBPELuY0HOu84= github.com/dnwe/otelsarama v0.0.0-20240308230250-9388d9d40bc0/go.mod h1:2MqLKYJfjs3UriXXF9Fd0Qmh/lhxi/6tHXkqtXxyIHc= github.com/eapache/go-resiliency v1.7.0 h1:n3NRTnBn5N0Cbi/IeOHuQn9s2UwVUH7Ga0ZWcP+9JTA= github.com/eapache/go-resiliency v1.7.0/go.mod h1:5yPzW0MIvSe0JDsv0v+DvcjEv2FyD6iZYSs1ZI+iQho= github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 h1:Oy0F4ALJ04o5Qqpdz8XLIpNA3WM/iSIXqxtqo7UGVws= github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3/go.mod h1:YvSRo5mw33fLEx1+DlK6L2VV43tJt5Eyel9n9XBcR+0= github.com/eapache/queue v1.1.0 h1:YOEu7KNc61ntiQlcEeUIoDTJ2o8mQznoNvUhiigpIqc= github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg= github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8= github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg= github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ= github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: _examples/basic/6-cqrs-ordered-events/main.go ================================================ package main import ( "context" "fmt" "log/slog" "time" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill-kafka/v3/pkg/kafka" "github.com/ThreeDotsLabs/watermill/components/cqrs" "github.com/ThreeDotsLabs/watermill/message" "github.com/ThreeDotsLabs/watermill/message/router/middleware" ) func main() { slog.SetLogLoggerLevel(slog.LevelDebug) logger := watermill.NewSlogLoggerWithLevelMapping(nil, map[slog.Level]slog.Level{ slog.LevelInfo: slog.LevelDebug, }) // We are decorating ProtobufMarshaler to add extra metadata to the message. cqrsMarshaler := CqrsMarshalerDecorator{ cqrs.ProtoMarshaler{ // It will generate topic names based on the event/command type. // So for example, for "RoomBooked" name will be "RoomBooked". GenerateName: cqrs.StructName, }, } watermillLogger := watermill.NewSlogLoggerWithLevelMapping( slog.With("watermill", true), map[slog.Level]slog.Level{ slog.LevelInfo: slog.LevelDebug, }, ) // This marshaler converts Watermill messages to Kafka messages. // We are using it to add partition key to the Kafka message. kafkaMarshaler := kafka.NewWithPartitioningMarshaler(GenerateKafkaPartitionKey) // You can use any Pub/Sub implementation from here: https://watermill.io/pubsubs/ publisher, err := kafka.NewPublisher( kafka.PublisherConfig{ Brokers: []string{"kafka:9092"}, Marshaler: kafkaMarshaler, }, watermillLogger, ) if err != nil { panic(err) } // CQRS is built on messages router. Detailed documentation: https://watermill.io/docs/messages-router/ router, err := message.NewRouter(message.RouterConfig{}, logger) if err != nil { panic(err) } // Simple middleware which will recover panics from event or command handlers. // More about router middlewares you can find in the documentation: // https://watermill.io/docs/messages-router/#middleware // // List of available middlewares you can find in message/router/middleware. router.AddMiddleware(middleware.Recoverer) router.AddMiddleware(func(h message.HandlerFunc) message.HandlerFunc { return func(msg *message.Message) ([]*message.Message, error) { slog.Debug("Received message", "metadata", msg.Metadata) return h(msg) } }) commandBus, err := cqrs.NewCommandBusWithConfig(publisher, cqrs.CommandBusConfig{ GeneratePublishTopic: func(params cqrs.CommandBusGeneratePublishTopicParams) (string, error) { // We are using one topic for all commands to maintain the order of commands. return "commands", nil }, Marshaler: cqrsMarshaler, Logger: logger, }) if err != nil { panic(err) } eventBus, err := cqrs.NewEventBusWithConfig(publisher, cqrs.EventBusConfig{ GeneratePublishTopic: func(params cqrs.GenerateEventPublishTopicParams) (string, error) { // We are using one topic for all events to maintain the order of events. return "events", nil }, Marshaler: cqrsMarshaler, Logger: logger, }) if err != nil { panic(err) } commandProcessor, err := cqrs.NewCommandProcessorWithConfig( router, cqrs.CommandProcessorConfig{ GenerateSubscribeTopic: func(params cqrs.CommandProcessorGenerateSubscribeTopicParams) (string, error) { return "commands", nil }, SubscriberConstructor: func(params cqrs.CommandProcessorSubscriberConstructorParams) (message.Subscriber, error) { return kafka.NewSubscriber( kafka.SubscriberConfig{ Brokers: []string{"kafka:9092"}, ConsumerGroup: params.HandlerName, Unmarshaler: kafkaMarshaler, }, watermillLogger, ) }, Marshaler: cqrsMarshaler, Logger: logger, }, ) if err != nil { panic(err) } eventProcessor, err := cqrs.NewEventGroupProcessorWithConfig( router, cqrs.EventGroupProcessorConfig{ GenerateSubscribeTopic: func(params cqrs.EventGroupProcessorGenerateSubscribeTopicParams) (string, error) { return "events", nil }, SubscriberConstructor: func(params cqrs.EventGroupProcessorSubscriberConstructorParams) (message.Subscriber, error) { return kafka.NewSubscriber( kafka.SubscriberConfig{ Brokers: []string{"kafka:9092"}, ConsumerGroup: params.EventGroupName, Unmarshaler: kafkaMarshaler, }, watermillLogger, ) }, Marshaler: cqrsMarshaler, Logger: logger, }, ) if err != nil { panic(err) } err = commandProcessor.AddHandlers( cqrs.NewCommandHandler("SubscribeHandler", SubscribeHandler{eventBus}.Handle), cqrs.NewCommandHandler("UnsubscribeHandler", UnsubscribeHandler{eventBus}.Handle), cqrs.NewCommandHandler("UpdateEmailHandler", UpdateEmailHandler{eventBus}.Handle), ) if err != nil { panic(err) } subscribersReadModel := NewSubscriberReadModel() // All messages from this group will have one subscription. // When message arrives, Watermill will match it with the correct handler. err = eventProcessor.AddHandlersGroup( "SubscriberReadModel", cqrs.NewGroupEventHandler(subscribersReadModel.OnSubscribed), cqrs.NewGroupEventHandler(subscribersReadModel.OnUnsubscribed), cqrs.NewGroupEventHandler(subscribersReadModel.OnEmailUpdated), ) if err != nil { panic(err) } activityReadModel := NewActivityTimelineModel() // All messages from this group will have one subscription. // When message arrives, Watermill will match it with the correct handler. err = eventProcessor.AddHandlersGroup( "ActivityTimelineReadModel", cqrs.NewGroupEventHandler(activityReadModel.OnSubscribed), cqrs.NewGroupEventHandler(activityReadModel.OnUnsubscribed), cqrs.NewGroupEventHandler(activityReadModel.OnEmailUpdated), ) if err != nil { panic(err) } slog.Info("Starting service") go simulateTraffic(commandBus) if err := router.Run(context.Background()); err != nil { panic(err) } } func simulateTraffic(commandBus *cqrs.CommandBus) { for i := 0; ; i++ { subscriberID := watermill.NewUUID() err := commandBus.Send(context.Background(), &Subscribe{ Metadata: GenerateMessageMetadata(subscriberID), SubscriberId: subscriberID, Email: fmt.Sprintf("user%d@example.com", i), }) if err != nil { slog.Error("Error sending Subscribe command", "err", err) } time.Sleep(time.Millisecond * 500) err = commandBus.Send(context.Background(), &UpdateEmail{ Metadata: GenerateMessageMetadata(subscriberID), SubscriberId: subscriberID, NewEmail: fmt.Sprintf("updated%d@example.com", i), }) if err != nil { slog.Error("Error sending UpdateEmail command", "err", err) } time.Sleep(time.Millisecond * 500) if i%3 == 0 { err = commandBus.Send(context.Background(), &Unsubscribe{ Metadata: GenerateMessageMetadata(subscriberID), SubscriberId: subscriberID, }) if err != nil { slog.Error("Error sending Unsubscribe command", "err", err) } } time.Sleep(time.Millisecond * 500) } } type SubscribeHandler struct { eventBus *cqrs.EventBus } func (h SubscribeHandler) Handle(ctx context.Context, cmd *Subscribe) error { return h.eventBus.Publish(ctx, &SubscriberSubscribed{ Metadata: GenerateMessageMetadata(cmd.SubscriberId), SubscriberId: cmd.SubscriberId, Email: cmd.Email, }) } type UnsubscribeHandler struct { eventBus *cqrs.EventBus } func (h UnsubscribeHandler) Handle(ctx context.Context, cmd *Unsubscribe) error { return h.eventBus.Publish(ctx, &SubscriberUnsubscribed{ Metadata: GenerateMessageMetadata(cmd.SubscriberId), SubscriberId: cmd.SubscriberId, }) } type UpdateEmailHandler struct { eventBus *cqrs.EventBus } func (h UpdateEmailHandler) Handle(ctx context.Context, cmd *UpdateEmail) error { return h.eventBus.Publish(ctx, &SubscriberEmailUpdated{ Metadata: GenerateMessageMetadata(cmd.SubscriberId), SubscriberId: cmd.SubscriberId, NewEmail: cmd.NewEmail, }) } ================================================ FILE: _examples/basic/6-cqrs-ordered-events/message.go ================================================ package main import ( "fmt" "log/slog" "github.com/ThreeDotsLabs/watermill/components/cqrs" "github.com/ThreeDotsLabs/watermill/message" "google.golang.org/protobuf/types/known/timestamppb" ) func GenerateMessageMetadata(partitionKey string) *MessageMetadata { return &MessageMetadata{ PartitionKey: partitionKey, CreatedAt: timestamppb.Now(), } } type CqrsMarshalerDecorator struct { cqrs.ProtoMarshaler } const PartitionKeyMetadataField = "partition_key" func (c CqrsMarshalerDecorator) Marshal(v interface{}) (*message.Message, error) { msg, err := c.ProtoMarshaler.Marshal(v) if err != nil { return nil, err } pm, ok := v.(ProtoMessage) if !ok { return nil, fmt.Errorf("%T does not implement ProtoMessage and can't be marshaled", v) } metadata := pm.GetMetadata() if metadata == nil { return nil, fmt.Errorf("%T.GetMetadata returned nil", v) } msg.Metadata.Set(PartitionKeyMetadataField, metadata.PartitionKey) msg.Metadata.Set("created_at", metadata.CreatedAt.AsTime().String()) return msg, nil } type ProtoMessage interface { GetMetadata() *MessageMetadata } // GenerateKafkaPartitionKey is a function that generates a partition key for Kafka messages. func GenerateKafkaPartitionKey(topic string, msg *message.Message) (string, error) { slog.Debug("Setting partition key", "topic", topic, "msg_metadata", msg.Metadata) return msg.Metadata.Get(PartitionKeyMetadataField), nil } ================================================ FILE: _examples/basic/6-cqrs-ordered-events/messages.pb.go ================================================ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.31.0 // protoc v4.24.4 // source: messages.proto package main import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" timestamppb "google.golang.org/protobuf/types/known/timestamppb" reflect "reflect" sync "sync" ) const ( // Verify that this generated code is sufficiently up-to-date. _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) // Verify that runtime/protoimpl is sufficiently up-to-date. _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) type MessageMetadata struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields PartitionKey string `protobuf:"bytes,1,opt,name=partition_key,json=partitionKey,proto3" json:"partition_key,omitempty"` CreatedAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` } func (x *MessageMetadata) Reset() { *x = MessageMetadata{} if protoimpl.UnsafeEnabled { mi := &file_messages_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } } func (x *MessageMetadata) String() string { return protoimpl.X.MessageStringOf(x) } func (*MessageMetadata) ProtoMessage() {} func (x *MessageMetadata) ProtoReflect() protoreflect.Message { mi := &file_messages_proto_msgTypes[0] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use MessageMetadata.ProtoReflect.Descriptor instead. func (*MessageMetadata) Descriptor() ([]byte, []int) { return file_messages_proto_rawDescGZIP(), []int{0} } func (x *MessageMetadata) GetPartitionKey() string { if x != nil { return x.PartitionKey } return "" } func (x *MessageMetadata) GetCreatedAt() *timestamppb.Timestamp { if x != nil { return x.CreatedAt } return nil } // Commands type Subscribe struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields Metadata *MessageMetadata `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` SubscriberId string `protobuf:"bytes,2,opt,name=subscriber_id,json=subscriberId,proto3" json:"subscriber_id,omitempty"` Email string `protobuf:"bytes,3,opt,name=email,proto3" json:"email,omitempty"` } func (x *Subscribe) Reset() { *x = Subscribe{} if protoimpl.UnsafeEnabled { mi := &file_messages_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } } func (x *Subscribe) String() string { return protoimpl.X.MessageStringOf(x) } func (*Subscribe) ProtoMessage() {} func (x *Subscribe) ProtoReflect() protoreflect.Message { mi := &file_messages_proto_msgTypes[1] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use Subscribe.ProtoReflect.Descriptor instead. func (*Subscribe) Descriptor() ([]byte, []int) { return file_messages_proto_rawDescGZIP(), []int{1} } func (x *Subscribe) GetMetadata() *MessageMetadata { if x != nil { return x.Metadata } return nil } func (x *Subscribe) GetSubscriberId() string { if x != nil { return x.SubscriberId } return "" } func (x *Subscribe) GetEmail() string { if x != nil { return x.Email } return "" } type Unsubscribe struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields Metadata *MessageMetadata `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` SubscriberId string `protobuf:"bytes,2,opt,name=subscriber_id,json=subscriberId,proto3" json:"subscriber_id,omitempty"` } func (x *Unsubscribe) Reset() { *x = Unsubscribe{} if protoimpl.UnsafeEnabled { mi := &file_messages_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } } func (x *Unsubscribe) String() string { return protoimpl.X.MessageStringOf(x) } func (*Unsubscribe) ProtoMessage() {} func (x *Unsubscribe) ProtoReflect() protoreflect.Message { mi := &file_messages_proto_msgTypes[2] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use Unsubscribe.ProtoReflect.Descriptor instead. func (*Unsubscribe) Descriptor() ([]byte, []int) { return file_messages_proto_rawDescGZIP(), []int{2} } func (x *Unsubscribe) GetMetadata() *MessageMetadata { if x != nil { return x.Metadata } return nil } func (x *Unsubscribe) GetSubscriberId() string { if x != nil { return x.SubscriberId } return "" } type UpdateEmail struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields Metadata *MessageMetadata `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` SubscriberId string `protobuf:"bytes,2,opt,name=subscriber_id,json=subscriberId,proto3" json:"subscriber_id,omitempty"` NewEmail string `protobuf:"bytes,3,opt,name=new_email,json=newEmail,proto3" json:"new_email,omitempty"` } func (x *UpdateEmail) Reset() { *x = UpdateEmail{} if protoimpl.UnsafeEnabled { mi := &file_messages_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } } func (x *UpdateEmail) String() string { return protoimpl.X.MessageStringOf(x) } func (*UpdateEmail) ProtoMessage() {} func (x *UpdateEmail) ProtoReflect() protoreflect.Message { mi := &file_messages_proto_msgTypes[3] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use UpdateEmail.ProtoReflect.Descriptor instead. func (*UpdateEmail) Descriptor() ([]byte, []int) { return file_messages_proto_rawDescGZIP(), []int{3} } func (x *UpdateEmail) GetMetadata() *MessageMetadata { if x != nil { return x.Metadata } return nil } func (x *UpdateEmail) GetSubscriberId() string { if x != nil { return x.SubscriberId } return "" } func (x *UpdateEmail) GetNewEmail() string { if x != nil { return x.NewEmail } return "" } // Events type SubscriberSubscribed struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields Metadata *MessageMetadata `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` SubscriberId string `protobuf:"bytes,2,opt,name=subscriber_id,json=subscriberId,proto3" json:"subscriber_id,omitempty"` Email string `protobuf:"bytes,3,opt,name=email,proto3" json:"email,omitempty"` } func (x *SubscriberSubscribed) Reset() { *x = SubscriberSubscribed{} if protoimpl.UnsafeEnabled { mi := &file_messages_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } } func (x *SubscriberSubscribed) String() string { return protoimpl.X.MessageStringOf(x) } func (*SubscriberSubscribed) ProtoMessage() {} func (x *SubscriberSubscribed) ProtoReflect() protoreflect.Message { mi := &file_messages_proto_msgTypes[4] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use SubscriberSubscribed.ProtoReflect.Descriptor instead. func (*SubscriberSubscribed) Descriptor() ([]byte, []int) { return file_messages_proto_rawDescGZIP(), []int{4} } func (x *SubscriberSubscribed) GetMetadata() *MessageMetadata { if x != nil { return x.Metadata } return nil } func (x *SubscriberSubscribed) GetSubscriberId() string { if x != nil { return x.SubscriberId } return "" } func (x *SubscriberSubscribed) GetEmail() string { if x != nil { return x.Email } return "" } type SubscriberUnsubscribed struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields Metadata *MessageMetadata `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` SubscriberId string `protobuf:"bytes,2,opt,name=subscriber_id,json=subscriberId,proto3" json:"subscriber_id,omitempty"` } func (x *SubscriberUnsubscribed) Reset() { *x = SubscriberUnsubscribed{} if protoimpl.UnsafeEnabled { mi := &file_messages_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } } func (x *SubscriberUnsubscribed) String() string { return protoimpl.X.MessageStringOf(x) } func (*SubscriberUnsubscribed) ProtoMessage() {} func (x *SubscriberUnsubscribed) ProtoReflect() protoreflect.Message { mi := &file_messages_proto_msgTypes[5] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use SubscriberUnsubscribed.ProtoReflect.Descriptor instead. func (*SubscriberUnsubscribed) Descriptor() ([]byte, []int) { return file_messages_proto_rawDescGZIP(), []int{5} } func (x *SubscriberUnsubscribed) GetMetadata() *MessageMetadata { if x != nil { return x.Metadata } return nil } func (x *SubscriberUnsubscribed) GetSubscriberId() string { if x != nil { return x.SubscriberId } return "" } type SubscriberEmailUpdated struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields Metadata *MessageMetadata `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` SubscriberId string `protobuf:"bytes,2,opt,name=subscriber_id,json=subscriberId,proto3" json:"subscriber_id,omitempty"` NewEmail string `protobuf:"bytes,3,opt,name=new_email,json=newEmail,proto3" json:"new_email,omitempty"` } func (x *SubscriberEmailUpdated) Reset() { *x = SubscriberEmailUpdated{} if protoimpl.UnsafeEnabled { mi := &file_messages_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } } func (x *SubscriberEmailUpdated) String() string { return protoimpl.X.MessageStringOf(x) } func (*SubscriberEmailUpdated) ProtoMessage() {} func (x *SubscriberEmailUpdated) ProtoReflect() protoreflect.Message { mi := &file_messages_proto_msgTypes[6] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use SubscriberEmailUpdated.ProtoReflect.Descriptor instead. func (*SubscriberEmailUpdated) Descriptor() ([]byte, []int) { return file_messages_proto_rawDescGZIP(), []int{6} } func (x *SubscriberEmailUpdated) GetMetadata() *MessageMetadata { if x != nil { return x.Metadata } return nil } func (x *SubscriberEmailUpdated) GetSubscriberId() string { if x != nil { return x.SubscriberId } return "" } func (x *SubscriberEmailUpdated) GetNewEmail() string { if x != nil { return x.NewEmail } return "" } var File_messages_proto protoreflect.FileDescriptor var file_messages_proto_rawDesc = []byte{ 0x0a, 0x0e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x04, 0x6d, 0x61, 0x69, 0x6e, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x71, 0x0a, 0x0f, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x23, 0x0a, 0x0d, 0x70, 0x61, 0x72, 0x74, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x61, 0x72, 0x74, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x4b, 0x65, 0x79, 0x12, 0x39, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x22, 0x79, 0x0a, 0x09, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x62, 0x65, 0x12, 0x31, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x69, 0x6e, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x62, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x62, 0x65, 0x72, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x22, 0x65, 0x0a, 0x0b, 0x55, 0x6e, 0x73, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x62, 0x65, 0x12, 0x31, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x69, 0x6e, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x62, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x62, 0x65, 0x72, 0x49, 0x64, 0x22, 0x82, 0x01, 0x0a, 0x0b, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x31, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x69, 0x6e, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x62, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x62, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x6e, 0x65, 0x77, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6e, 0x65, 0x77, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x22, 0x84, 0x01, 0x0a, 0x14, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x62, 0x65, 0x72, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x62, 0x65, 0x64, 0x12, 0x31, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x69, 0x6e, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x62, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x62, 0x65, 0x72, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x22, 0x70, 0x0a, 0x16, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x62, 0x65, 0x72, 0x55, 0x6e, 0x73, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x62, 0x65, 0x64, 0x12, 0x31, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x69, 0x6e, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x62, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x62, 0x65, 0x72, 0x49, 0x64, 0x22, 0x8d, 0x01, 0x0a, 0x16, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x62, 0x65, 0x72, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x12, 0x31, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x69, 0x6e, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x62, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x62, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x6e, 0x65, 0x77, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6e, 0x65, 0x77, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x42, 0x08, 0x5a, 0x06, 0x2e, 0x2f, 0x6d, 0x61, 0x69, 0x6e, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( file_messages_proto_rawDescOnce sync.Once file_messages_proto_rawDescData = file_messages_proto_rawDesc ) func file_messages_proto_rawDescGZIP() []byte { file_messages_proto_rawDescOnce.Do(func() { file_messages_proto_rawDescData = protoimpl.X.CompressGZIP(file_messages_proto_rawDescData) }) return file_messages_proto_rawDescData } var file_messages_proto_msgTypes = make([]protoimpl.MessageInfo, 7) var file_messages_proto_goTypes = []interface{}{ (*MessageMetadata)(nil), // 0: main.MessageMetadata (*Subscribe)(nil), // 1: main.Subscribe (*Unsubscribe)(nil), // 2: main.Unsubscribe (*UpdateEmail)(nil), // 3: main.UpdateEmail (*SubscriberSubscribed)(nil), // 4: main.SubscriberSubscribed (*SubscriberUnsubscribed)(nil), // 5: main.SubscriberUnsubscribed (*SubscriberEmailUpdated)(nil), // 6: main.SubscriberEmailUpdated (*timestamppb.Timestamp)(nil), // 7: google.protobuf.Timestamp } var file_messages_proto_depIdxs = []int32{ 7, // 0: main.MessageMetadata.created_at:type_name -> google.protobuf.Timestamp 0, // 1: main.Subscribe.metadata:type_name -> main.MessageMetadata 0, // 2: main.Unsubscribe.metadata:type_name -> main.MessageMetadata 0, // 3: main.UpdateEmail.metadata:type_name -> main.MessageMetadata 0, // 4: main.SubscriberSubscribed.metadata:type_name -> main.MessageMetadata 0, // 5: main.SubscriberUnsubscribed.metadata:type_name -> main.MessageMetadata 0, // 6: main.SubscriberEmailUpdated.metadata:type_name -> main.MessageMetadata 7, // [7:7] is the sub-list for method output_type 7, // [7:7] is the sub-list for method input_type 7, // [7:7] is the sub-list for extension type_name 7, // [7:7] is the sub-list for extension extendee 0, // [0:7] is the sub-list for field type_name } func init() { file_messages_proto_init() } func file_messages_proto_init() { if File_messages_proto != nil { return } if !protoimpl.UnsafeEnabled { file_messages_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*MessageMetadata); i { case 0: return &v.state case 1: return &v.sizeCache case 2: return &v.unknownFields default: return nil } } file_messages_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*Subscribe); i { case 0: return &v.state case 1: return &v.sizeCache case 2: return &v.unknownFields default: return nil } } file_messages_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*Unsubscribe); i { case 0: return &v.state case 1: return &v.sizeCache case 2: return &v.unknownFields default: return nil } } file_messages_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*UpdateEmail); i { case 0: return &v.state case 1: return &v.sizeCache case 2: return &v.unknownFields default: return nil } } file_messages_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*SubscriberSubscribed); i { case 0: return &v.state case 1: return &v.sizeCache case 2: return &v.unknownFields default: return nil } } file_messages_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*SubscriberUnsubscribed); i { case 0: return &v.state case 1: return &v.sizeCache case 2: return &v.unknownFields default: return nil } } file_messages_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*SubscriberEmailUpdated); i { case 0: return &v.state case 1: return &v.sizeCache case 2: return &v.unknownFields default: return nil } } } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_messages_proto_rawDesc, NumEnums: 0, NumMessages: 7, NumExtensions: 0, NumServices: 0, }, GoTypes: file_messages_proto_goTypes, DependencyIndexes: file_messages_proto_depIdxs, MessageInfos: file_messages_proto_msgTypes, }.Build() File_messages_proto = out.File file_messages_proto_rawDesc = nil file_messages_proto_goTypes = nil file_messages_proto_depIdxs = nil } ================================================ FILE: _examples/basic/6-cqrs-ordered-events/proto/messages.proto ================================================ syntax = "proto3"; package main; option go_package = "./main"; import "google/protobuf/timestamp.proto"; message MessageMetadata { string partition_key = 1; google.protobuf.Timestamp created_at = 2; } // Commands message Subscribe { MessageMetadata metadata = 1; string subscriber_id = 2; string email = 3; } message Unsubscribe { MessageMetadata metadata = 1; string subscriber_id = 2; } message UpdateEmail { MessageMetadata metadata = 1; string subscriber_id = 2; string new_email = 3; } // Events message SubscriberSubscribed { MessageMetadata metadata = 1; string subscriber_id = 2; string email = 3; } message SubscriberUnsubscribed { MessageMetadata metadata = 1; string subscriber_id = 2; } message SubscriberEmailUpdated { MessageMetadata metadata = 1; string subscriber_id = 2; string new_email = 3; } ================================================ FILE: _examples/basic/6-cqrs-ordered-events/subscribers.go ================================================ package main import ( "context" "log/slog" "sync" ) type SubscriberReadModel struct { subscribers map[string]string // map[subscriberID]email lock sync.RWMutex } func NewSubscriberReadModel() *SubscriberReadModel { return &SubscriberReadModel{ subscribers: make(map[string]string), } } func (m *SubscriberReadModel) OnSubscribed(ctx context.Context, event *SubscriberSubscribed) error { m.lock.Lock() defer m.lock.Unlock() m.subscribers[event.SubscriberId] = event.Email slog.Info( "Subscriber added", "subscriber_id", event.SubscriberId, "email", event.Email, ) return nil } func (m *SubscriberReadModel) OnUnsubscribed(ctx context.Context, event *SubscriberUnsubscribed) error { m.lock.Lock() defer m.lock.Unlock() delete(m.subscribers, event.SubscriberId) slog.Info( "Subscriber removed", "subscriber_id", event.SubscriberId, ) return nil } func (m *SubscriberReadModel) OnEmailUpdated(ctx context.Context, event *SubscriberEmailUpdated) error { m.lock.Lock() defer m.lock.Unlock() m.subscribers[event.SubscriberId] = event.NewEmail slog.Info( "Subscriber updated", "subscriber_id", event.SubscriberId, "email", event.NewEmail, ) return nil } func (m *SubscriberReadModel) GetSubscriberCount() int { m.lock.RLock() defer m.lock.RUnlock() return len(m.subscribers) } ================================================ FILE: _examples/pubsubs/amqp/.validate_example.yml ================================================ validation_cmd: "docker compose up" teardown_cmd: "docker compose down" timeout: 120 expected_output: "received message: [0-9a-f\\-]+, payload: Hello, world!" ================================================ FILE: _examples/pubsubs/amqp/docker-compose.yml ================================================ services: server: image: golang:1.25 restart: unless-stopped depends_on: - rabbitmq volumes: - .:/app - $GOPATH/pkg/mod:/go/pkg/mod working_dir: /app command: go run main.go rabbitmq: image: rabbitmq:3.7 restart: unless-stopped ================================================ FILE: _examples/pubsubs/amqp/go.mod ================================================ module main.go require ( github.com/ThreeDotsLabs/watermill v1.5.1 github.com/ThreeDotsLabs/watermill-amqp/v3 v3.0.2 ) require ( github.com/cenkalti/backoff/v3 v3.2.2 // indirect github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/lithammer/shortuuid/v3 v3.0.7 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/rabbitmq/amqp091-go v1.10.0 // indirect ) go 1.25 ================================================ FILE: _examples/pubsubs/amqp/go.sum ================================================ github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY= github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892LARrKAdki6WtXS4= github.com/ThreeDotsLabs/watermill-amqp/v3 v3.0.2 h1:aeyFSR4SUsbszmocuFiYY13nsHorc6CXIS2Hy7+xgFU= github.com/ThreeDotsLabs/watermill-amqp/v3 v3.0.2/go.mod h1:+8tCh6VCuBcQWhfETCwzRINKQ1uyeg9moH3h7jMKxQk= github.com/cenkalti/backoff/v3 v3.2.2 h1:cfUAAO3yvKMYKPrvhDuHSwQnhZNk/RMHKdZqKTxfm6M= github.com/cenkalti/backoff/v3 v3.2.2/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4rc0ij+ULvLYs= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw= github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: _examples/pubsubs/amqp/main.go ================================================ // Sources for https://watermill.io/learn/getting-started/ package main import ( "context" "log" "time" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill-amqp/v3/pkg/amqp" "github.com/ThreeDotsLabs/watermill/message" ) var amqpURI = "amqp://guest:guest@rabbitmq:5672/" func main() { amqpConfig := amqp.NewDurableQueueConfig(amqpURI) subscriber, err := amqp.NewSubscriber( // This config is based on this example: https://www.rabbitmq.com/tutorials/tutorial-two-go.html // It works as a simple queue. // // If you want to implement a Pub/Sub style service instead, check // https://watermill.io/pubsubs/amqp/#amqp-consumer-groups amqpConfig, watermill.NewStdLogger(false, false), ) if err != nil { panic(err) } messages, err := subscriber.Subscribe(context.Background(), "example.topic") if err != nil { panic(err) } go process(messages) publisher, err := amqp.NewPublisher(amqpConfig, watermill.NewStdLogger(false, false)) if err != nil { panic(err) } publishMessages(publisher) } func publishMessages(publisher message.Publisher) { for { msg := message.NewMessage(watermill.NewUUID(), []byte("Hello, world!")) if err := publisher.Publish("example.topic", msg); err != nil { panic(err) } time.Sleep(time.Second) } } func process(messages <-chan *message.Message) { for msg := range messages { log.Printf("received message: %s, payload: %s", msg.UUID, string(msg.Payload)) // we need to Acknowledge that we received and processed the message, // otherwise, it will be resent over and over again. msg.Ack() } } ================================================ FILE: _examples/pubsubs/aws-sns/.validate_example.yml ================================================ validation_cmd: "docker compose up" teardown_cmd: "docker compose down" timeout: 120 expected_output: "A received message: [0-9a-f\\-]+, payload: Hello, world!" ================================================ FILE: _examples/pubsubs/aws-sns/docker-compose.yml ================================================ services: server: image: golang:1.25 restart: unless-stopped volumes: - .:/app - $GOPATH/pkg/mod:/go/pkg/mod working_dir: /app command: go run main.go localstack: image: localstack/localstack:3.8 environment: - SERVICES=sqs,sns - AWS_DEFAULT_REGION=us-east-1 - EDGE_PORT=4566 ports: - "4566-4597:4566-4597" healthcheck: test: awslocal sqs list-queues && awslocal sns list-topics interval: 5s timeout: 5s retries: 5 start_period: 30s ================================================ FILE: _examples/pubsubs/aws-sns/go.mod ================================================ module main require ( github.com/ThreeDotsLabs/watermill v1.5.1 github.com/ThreeDotsLabs/watermill-aws v1.0.1 github.com/aws/aws-sdk-go-v2 v1.38.3 github.com/aws/aws-sdk-go-v2/service/sns v1.38.1 github.com/aws/aws-sdk-go-v2/service/sqs v1.42.3 github.com/aws/smithy-go v1.23.0 github.com/samber/lo v1.51.0 ) require ( github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6 // indirect github.com/google/uuid v1.6.0 // indirect github.com/lithammer/shortuuid/v3 v3.0.7 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/pkg/errors v0.9.1 // indirect golang.org/x/text v0.28.0 // indirect ) go 1.25 ================================================ FILE: _examples/pubsubs/aws-sns/go.sum ================================================ github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY= github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892LARrKAdki6WtXS4= github.com/ThreeDotsLabs/watermill-aws v1.0.1 h1:lsXp7iIih2Eqlm9p05u9QC3G9DemAMi88qMFkq+810w= github.com/ThreeDotsLabs/watermill-aws v1.0.1/go.mod h1:jlGFr7vhmzAESlU/PE5BCyuat3w/gr5zmwx1oNm1yh8= github.com/aws/aws-sdk-go-v2 v1.38.3 h1:B6cV4oxnMs45fql4yRH+/Po/YU+597zgWqvDpYMturk= github.com/aws/aws-sdk-go-v2 v1.38.3/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY= github.com/aws/aws-sdk-go-v2/config v1.31.2 h1:NOaSZpVGEH2Np/c1toSeW0jooNl+9ALmsUTZ8YvkJR0= github.com/aws/aws-sdk-go-v2/config v1.31.2/go.mod h1:17ft42Yb2lF6OigqSYiDAiUcX4RIkEMY6XxEMJsrAes= github.com/aws/aws-sdk-go-v2/credentials v1.18.6 h1:AmmvNEYrru7sYNJnp3pf57lGbiarX4T9qU/6AZ9SucU= github.com/aws/aws-sdk-go-v2/credentials v1.18.6/go.mod h1:/jdQkh1iVPa01xndfECInp1v1Wnp70v3K4MvtlLGVEc= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.4 h1:lpdMwTzmuDLkgW7086jE94HweHCqG+uOJwHf3LZs7T0= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.4/go.mod h1:9xzb8/SV62W6gHQGC/8rrvgNXU6ZoYM3sAIJCIrXJxY= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6 h1:uF68eJA6+S9iVr9WgX1NaRGyQ/6MdIyc4JNUo6TN1FA= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6/go.mod h1:qlPeVZCGPiobx8wb1ft0GHT5l+dc6ldnwInDFaMvC7Y= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6 h1:pa1DEC6JoI0zduhZePp3zmhWvk/xxm4NB8Hy/Tlsgos= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6/go.mod h1:gxEjPebnhWGJoaDdtDkA0JX46VRg1wcTHYe63OfX5pE= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0 h1:6+lZi2JeGKtCraAj1rpoZfKqnQ9SptseRZioejfUOLM= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0/go.mod h1:eb3gfbVIxIoGgJsi9pGne19dhCBpK6opTYpQqAmdy44= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.4 h1:ueB2Te0NacDMnaC+68za9jLwkjzxGWm0KB5HTUHjLTI= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.4/go.mod h1:nLEfLnVMmLvyIG58/6gsSA03F1voKGaCfHV7+lR8S7s= github.com/aws/aws-sdk-go-v2/service/sns v1.38.1 h1:6AqFh9gI+BEOlKRXaYryGMCwygwaTlISVUs6qEMosaU= github.com/aws/aws-sdk-go-v2/service/sns v1.38.1/go.mod h1:wZGK3CJNllAOeJ/xrnyTHotaXEvtC27KOLMMKGBeT+4= github.com/aws/aws-sdk-go-v2/service/sqs v1.42.3 h1:0dWg1Tkz3FnEo48DgAh7CT22hYyMShly8WMd3sGx0xI= github.com/aws/aws-sdk-go-v2/service/sqs v1.42.3/go.mod h1:hpOo4IGPfGPlHRcf2nizYAzKfz8GzbQ8tTDIUR4H4GQ= github.com/aws/aws-sdk-go-v2/service/sso v1.28.2 h1:ve9dYBB8CfJGTFqcQ3ZLAAb/KXWgYlgu/2R2TZL2Ko0= github.com/aws/aws-sdk-go-v2/service/sso v1.28.2/go.mod h1:n9bTZFZcBa9hGGqVz3i/a6+NG0zmZgtkB9qVVFDqPA8= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.33.2 h1:pd9G9HQaM6UZAZh19pYOkpKSQkyQQ9ftnl/LttQOcGI= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.33.2/go.mod h1:eknndR9rU8UpE/OmFpqU78V1EcXPKFTTm5l/buZYgvM= github.com/aws/aws-sdk-go-v2/service/sts v1.38.0 h1:iV1Ko4Em/lkJIsoKyGfc0nQySi+v0Udxr6Igq+y9JZc= github.com/aws/aws-sdk-go-v2/service/sts v1.38.0/go.mod h1:bEPcjW7IbolPfK67G1nilqWyoxYMSPrDiIQ3RdIdKgo= github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE= github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/samber/lo v1.51.0 h1:kysRYLbHy/MB7kQZf5DSN50JHmMsNEdeY24VzJFu7wI= github.com/samber/lo v1.51.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: _examples/pubsubs/aws-sns/main.go ================================================ // Sources for https://watermill.io/learn/getting-started/ package main import ( "context" "fmt" "log" "net/url" "time" "github.com/aws/aws-sdk-go-v2/aws" amazonsns "github.com/aws/aws-sdk-go-v2/service/sns" amazonsqs "github.com/aws/aws-sdk-go-v2/service/sqs" transport "github.com/aws/smithy-go/endpoints" "github.com/samber/lo" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill-aws/sns" "github.com/ThreeDotsLabs/watermill-aws/sqs" "github.com/ThreeDotsLabs/watermill/message" ) func main() { logger := watermill.NewStdLogger(false, false) snsOpts := []func(*amazonsns.Options){ amazonsns.WithEndpointResolverV2(sns.OverrideEndpointResolver{ Endpoint: transport.Endpoint{ URI: *lo.Must(url.Parse("http://localstack:4566")), }, }), } sqsOpts := []func(*amazonsqs.Options){ amazonsqs.WithEndpointResolverV2(sqs.OverrideEndpointResolver{ Endpoint: transport.Endpoint{ URI: *lo.Must(url.Parse("http://localstack:4566")), }, }), } topicResolver, err := sns.NewGenerateArnTopicResolver("000000000000", "us-east-1") if err != nil { panic(err) } newSubscriber := func(name string) (message.Subscriber, error) { subscriberConfig := sns.SubscriberConfig{ AWSConfig: aws.Config{ Credentials: aws.AnonymousCredentials{}, }, OptFns: snsOpts, TopicResolver: topicResolver, GenerateSqsQueueName: func(ctx context.Context, snsTopic sns.TopicArn) (string, error) { topic, err := sns.ExtractTopicNameFromTopicArn(snsTopic) if err != nil { return "", err } return fmt.Sprintf("%v-%v", topic, name), nil }, } sqsSubscriberConfig := sqs.SubscriberConfig{ AWSConfig: aws.Config{ Credentials: aws.AnonymousCredentials{}, }, OptFns: sqsOpts, } return sns.NewSubscriber(subscriberConfig, sqsSubscriberConfig, logger) } subA, err := newSubscriber("subA") if err != nil { panic(err) } subB, err := newSubscriber("subB") if err != nil { panic(err) } messagesA, err := subA.Subscribe(context.Background(), "example-topic") if err != nil { panic(err) } messagesB, err := subB.Subscribe(context.Background(), "example-topic") if err != nil { panic(err) } go process("A", messagesA) go process("B", messagesB) publisherConfig := sns.PublisherConfig{ AWSConfig: aws.Config{ Credentials: aws.AnonymousCredentials{}, }, OptFns: snsOpts, TopicResolver: topicResolver, } publisher, err := sns.NewPublisher(publisherConfig, logger) if err != nil { panic(err) } publishMessages(publisher) } func publishMessages(publisher message.Publisher) { for { msg := message.NewMessage(watermill.NewUUID(), []byte("Hello, world!")) if err := publisher.Publish("example-topic", msg); err != nil { panic(err) } time.Sleep(time.Second) } } func process(prefix string, messages <-chan *message.Message) { for msg := range messages { log.Printf("%v received message: %s, payload: %s", prefix, msg.UUID, string(msg.Payload)) // we need to Acknowledge that we received and processed the message, // otherwise, it will be resent over and over again. msg.Ack() } } ================================================ FILE: _examples/pubsubs/aws-sqs/.validate_example.yml ================================================ validation_cmd: "docker compose up" teardown_cmd: "docker compose down" timeout: 120 expected_output: "received message: [0-9a-f\\-]+, payload: Hello, world!" ================================================ FILE: _examples/pubsubs/aws-sqs/docker-compose.yml ================================================ services: server: image: golang:1.25 restart: unless-stopped volumes: - .:/app - $GOPATH/pkg/mod:/go/pkg/mod working_dir: /app command: go run main.go localstack: image: localstack/localstack:latest environment: - SERVICES=sqs,sns - AWS_DEFAULT_REGION=us-east-1 - EDGE_PORT=4566 ports: - "4566-4597:4566-4597" healthcheck: test: awslocal sqs list-queues && awslocal sns list-topics interval: 5s timeout: 5s retries: 5 start_period: 30s ================================================ FILE: _examples/pubsubs/aws-sqs/go.mod ================================================ module main require ( github.com/ThreeDotsLabs/watermill v1.5.1 github.com/ThreeDotsLabs/watermill-aws v1.0.1 github.com/aws/aws-sdk-go-v2 v1.38.3 github.com/aws/aws-sdk-go-v2/service/sqs v1.42.3 github.com/aws/smithy-go v1.23.0 github.com/samber/lo v1.51.0 ) require ( github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6 // indirect github.com/google/uuid v1.6.0 // indirect github.com/lithammer/shortuuid/v3 v3.0.7 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/pkg/errors v0.9.1 // indirect golang.org/x/text v0.28.0 // indirect ) go 1.25 ================================================ FILE: _examples/pubsubs/aws-sqs/go.sum ================================================ github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY= github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892LARrKAdki6WtXS4= github.com/ThreeDotsLabs/watermill-aws v1.0.1 h1:lsXp7iIih2Eqlm9p05u9QC3G9DemAMi88qMFkq+810w= github.com/ThreeDotsLabs/watermill-aws v1.0.1/go.mod h1:jlGFr7vhmzAESlU/PE5BCyuat3w/gr5zmwx1oNm1yh8= github.com/aws/aws-sdk-go-v2 v1.38.3 h1:B6cV4oxnMs45fql4yRH+/Po/YU+597zgWqvDpYMturk= github.com/aws/aws-sdk-go-v2 v1.38.3/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY= github.com/aws/aws-sdk-go-v2/config v1.31.2 h1:NOaSZpVGEH2Np/c1toSeW0jooNl+9ALmsUTZ8YvkJR0= github.com/aws/aws-sdk-go-v2/config v1.31.2/go.mod h1:17ft42Yb2lF6OigqSYiDAiUcX4RIkEMY6XxEMJsrAes= github.com/aws/aws-sdk-go-v2/credentials v1.18.6 h1:AmmvNEYrru7sYNJnp3pf57lGbiarX4T9qU/6AZ9SucU= github.com/aws/aws-sdk-go-v2/credentials v1.18.6/go.mod h1:/jdQkh1iVPa01xndfECInp1v1Wnp70v3K4MvtlLGVEc= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.4 h1:lpdMwTzmuDLkgW7086jE94HweHCqG+uOJwHf3LZs7T0= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.4/go.mod h1:9xzb8/SV62W6gHQGC/8rrvgNXU6ZoYM3sAIJCIrXJxY= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6 h1:uF68eJA6+S9iVr9WgX1NaRGyQ/6MdIyc4JNUo6TN1FA= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6/go.mod h1:qlPeVZCGPiobx8wb1ft0GHT5l+dc6ldnwInDFaMvC7Y= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6 h1:pa1DEC6JoI0zduhZePp3zmhWvk/xxm4NB8Hy/Tlsgos= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6/go.mod h1:gxEjPebnhWGJoaDdtDkA0JX46VRg1wcTHYe63OfX5pE= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0 h1:6+lZi2JeGKtCraAj1rpoZfKqnQ9SptseRZioejfUOLM= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0/go.mod h1:eb3gfbVIxIoGgJsi9pGne19dhCBpK6opTYpQqAmdy44= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.4 h1:ueB2Te0NacDMnaC+68za9jLwkjzxGWm0KB5HTUHjLTI= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.4/go.mod h1:nLEfLnVMmLvyIG58/6gsSA03F1voKGaCfHV7+lR8S7s= github.com/aws/aws-sdk-go-v2/service/sqs v1.42.3 h1:0dWg1Tkz3FnEo48DgAh7CT22hYyMShly8WMd3sGx0xI= github.com/aws/aws-sdk-go-v2/service/sqs v1.42.3/go.mod h1:hpOo4IGPfGPlHRcf2nizYAzKfz8GzbQ8tTDIUR4H4GQ= github.com/aws/aws-sdk-go-v2/service/sso v1.28.2 h1:ve9dYBB8CfJGTFqcQ3ZLAAb/KXWgYlgu/2R2TZL2Ko0= github.com/aws/aws-sdk-go-v2/service/sso v1.28.2/go.mod h1:n9bTZFZcBa9hGGqVz3i/a6+NG0zmZgtkB9qVVFDqPA8= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.33.2 h1:pd9G9HQaM6UZAZh19pYOkpKSQkyQQ9ftnl/LttQOcGI= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.33.2/go.mod h1:eknndR9rU8UpE/OmFpqU78V1EcXPKFTTm5l/buZYgvM= github.com/aws/aws-sdk-go-v2/service/sts v1.38.0 h1:iV1Ko4Em/lkJIsoKyGfc0nQySi+v0Udxr6Igq+y9JZc= github.com/aws/aws-sdk-go-v2/service/sts v1.38.0/go.mod h1:bEPcjW7IbolPfK67G1nilqWyoxYMSPrDiIQ3RdIdKgo= github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE= github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/samber/lo v1.51.0 h1:kysRYLbHy/MB7kQZf5DSN50JHmMsNEdeY24VzJFu7wI= github.com/samber/lo v1.51.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: _examples/pubsubs/aws-sqs/main.go ================================================ // Sources for https://watermill.io/learn/getting-started/ package main import ( "context" "log" "net/url" "time" "github.com/aws/aws-sdk-go-v2/aws" amazonsqs "github.com/aws/aws-sdk-go-v2/service/sqs" transport "github.com/aws/smithy-go/endpoints" "github.com/samber/lo" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill-aws/sqs" "github.com/ThreeDotsLabs/watermill/message" ) func main() { logger := watermill.NewStdLogger(false, false) sqsOpts := []func(*amazonsqs.Options){ amazonsqs.WithEndpointResolverV2(sqs.OverrideEndpointResolver{ Endpoint: transport.Endpoint{ URI: *lo.Must(url.Parse("http://localstack:4566")), }, }), } subscriberConfig := sqs.SubscriberConfig{ AWSConfig: aws.Config{ Credentials: aws.AnonymousCredentials{}, }, OptFns: sqsOpts, } subscriber, err := sqs.NewSubscriber(subscriberConfig, logger) if err != nil { panic(err) } messages, err := subscriber.Subscribe(context.Background(), "example-topic") if err != nil { panic(err) } go process(messages) publisherConfig := sqs.PublisherConfig{ AWSConfig: aws.Config{ Credentials: aws.AnonymousCredentials{}, }, OptFns: sqsOpts, } publisher, err := sqs.NewPublisher(publisherConfig, logger) if err != nil { panic(err) } publishMessages(publisher) } func publishMessages(publisher message.Publisher) { for { msg := message.NewMessage(watermill.NewUUID(), []byte("Hello, world!")) if err := publisher.Publish("example-topic", msg); err != nil { panic(err) } time.Sleep(time.Second) } } func process(messages <-chan *message.Message) { for msg := range messages { log.Printf("received message: %s, payload: %s", msg.UUID, string(msg.Payload)) // we need to Acknowledge that we received and processed the message, // otherwise, it will be resent over and over again. msg.Ack() } } ================================================ FILE: _examples/pubsubs/go-channel/.validate_example.yml ================================================ validation_cmd: "go run main.go" timeout: 30 expected_output: "payload: Hello, world!" ================================================ FILE: _examples/pubsubs/go-channel/go.mod ================================================ module main.go require github.com/ThreeDotsLabs/watermill v1.5.1 require ( github.com/google/uuid v1.6.0 // indirect github.com/lithammer/shortuuid/v3 v3.0.7 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/pkg/errors v0.9.1 // indirect ) go 1.25 ================================================ FILE: _examples/pubsubs/go-channel/go.sum ================================================ github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY= github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892LARrKAdki6WtXS4= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: _examples/pubsubs/go-channel/main.go ================================================ // Sources for https://watermill.io/learn/getting-started/ package main import ( "context" "fmt" "time" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill/message" "github.com/ThreeDotsLabs/watermill/pubsub/gochannel" ) func main() { pubSub := gochannel.NewGoChannel( gochannel.Config{}, watermill.NewStdLogger(false, false), ) messages, err := pubSub.Subscribe(context.Background(), "example.topic") if err != nil { panic(err) } go process(messages) publishMessages(pubSub) } func publishMessages(publisher message.Publisher) { for { msg := message.NewMessage(watermill.NewUUID(), []byte("Hello, world!")) if err := publisher.Publish("example.topic", msg); err != nil { panic(err) } time.Sleep(time.Second) } } func process(messages <-chan *message.Message) { for msg := range messages { fmt.Printf("received message: %s, payload: %s\n", msg.UUID, string(msg.Payload)) // we need to Acknowledge that we received and processed the message, // otherwise, it will be resent over and over again. msg.Ack() } } ================================================ FILE: _examples/pubsubs/googlecloud/.validate_example.yml ================================================ validation_cmd: "docker compose up" teardown_cmd: "docker compose down" timeout: 180 expected_output: "payload: Hello, world!" ================================================ FILE: _examples/pubsubs/googlecloud/docker-compose.yml ================================================ services: server: image: golang:1.25 restart: unless-stopped depends_on: - googlecloud volumes: - .:/app - $GOPATH/pkg/mod:/go/pkg/mod environment: # use local emulator instead of google cloud engine PUBSUB_EMULATOR_HOST: "googlecloud:8085" working_dir: /app command: go run main.go googlecloud: image: google/cloud-sdk:414.0.0 entrypoint: gcloud --quiet beta emulators pubsub start --host-port=0.0.0.0:8085 --verbosity=debug --log-http restart: unless-stopped ================================================ FILE: _examples/pubsubs/googlecloud/go.mod ================================================ module main.go require ( github.com/ThreeDotsLabs/watermill v1.5.1 github.com/ThreeDotsLabs/watermill-googlecloud/v2 v2.0.0 ) require ( cloud.google.com/go v0.121.6 // indirect cloud.google.com/go/auth v0.16.5 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.8.0 // indirect cloud.google.com/go/iam v1.5.2 // indirect cloud.google.com/go/pubsub/v2 v2.0.0 // indirect github.com/cenkalti/backoff/v3 v3.2.2 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect github.com/lithammer/shortuuid/v3 v3.0.7 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/pkg/errors v0.9.1 // indirect go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect go.opentelemetry.io/otel v1.38.0 // indirect go.opentelemetry.io/otel/metric v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect golang.org/x/crypto v0.41.0 // indirect golang.org/x/net v0.43.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sync v0.16.0 // indirect golang.org/x/sys v0.35.0 // indirect golang.org/x/text v0.28.0 // indirect golang.org/x/time v0.12.0 // indirect google.golang.org/api v0.248.0 // indirect google.golang.org/genproto v0.0.0-20250826171959-ef028d996bc1 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250826171959-ef028d996bc1 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1 // indirect google.golang.org/grpc v1.75.0 // indirect google.golang.org/protobuf v1.36.8 // indirect ) go 1.25 ================================================ FILE: _examples/pubsubs/googlecloud/go.sum ================================================ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.121.6 h1:waZiuajrI28iAf40cWgycWNgaXPO06dupuS+sgibK6c= cloud.google.com/go v0.121.6/go.mod h1:coChdst4Ea5vUpiALcYKXEpR1S9ZgXbhEzzMcMR66vI= cloud.google.com/go/auth v0.16.5 h1:mFWNQ2FEVWAliEQWpAdH80omXFokmrnbDhUS9cBywsI= cloud.google.com/go/auth v0.16.5/go.mod h1:utzRfHMP+Vv0mpOkTRQoWD2q3BatTOoWbA7gCc2dUhQ= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.8.0 h1:HxMRIbao8w17ZX6wBnjhcDkW6lTFpgcaobyVfZWqRLA= cloud.google.com/go/compute/metadata v0.8.0/go.mod h1:sYOGTp851OV9bOFJ9CH7elVvyzopvWQFNNghtDQ/Biw= cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8= cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE= cloud.google.com/go/pubsub/v2 v2.0.0 h1:0qS6mRJ41gD1lNmM/vdm6bR7DQu6coQcVwD+VPf0Bz0= cloud.google.com/go/pubsub/v2 v2.0.0/go.mod h1:0aztFxNzVQIRSZ8vUr79uH2bS3jwLebwK6q1sgEub+E= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY= github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892LARrKAdki6WtXS4= github.com/ThreeDotsLabs/watermill-googlecloud/v2 v2.0.0 h1:GXR+tsxPs/Vpmm0t4yEJUZdqLP9EytWvR+KN3Un5mNY= github.com/ThreeDotsLabs/watermill-googlecloud/v2 v2.0.0/go.mod h1:3IHyi1bNqQ8J2/wVWj4cQjzWXoEPauLm8ViyOCNaKbM= github.com/cenkalti/backoff/v3 v3.2.2 h1:cfUAAO3yvKMYKPrvhDuHSwQnhZNk/RMHKdZqKTxfm6M= github.com/cenkalti/backoff/v3 v3.2.2/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4rc0ij+ULvLYs= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.einride.tech/aip v0.73.0 h1:bPo4oqBo2ZQeBKo4ZzLb1kxYXTY1ysJhpvQyfuGzvps= go.einride.tech/aip v0.73.0/go.mod h1:Mj7rFbmXEgw0dq1dqJ7JGMvYCZZVxmGOR3S4ZcV5LvQ= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/api v0.248.0 h1:hUotakSkcwGdYUqzCRc5yGYsg4wXxpkKlW5ryVqvC1Y= google.golang.org/api v0.248.0/go.mod h1:yAFUAF56Li7IuIQbTFoLwXTCI6XCFKueOlS7S9e4F9k= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= google.golang.org/genproto v0.0.0-20250826171959-ef028d996bc1 h1:Nm5SEGIguOIBDXs5rhfz2aKwEVWlgwC58UcmEnLDc8Y= google.golang.org/genproto v0.0.0-20250826171959-ef028d996bc1/go.mod h1:Jz9LrroM7Mcm+a0QrLh4UpZ1B/WhjIbqwEcUf4y08nQ= google.golang.org/genproto/googleapis/api v0.0.0-20250826171959-ef028d996bc1 h1:APHvLLYBhtZvsbnpkfknDZ7NyH4z5+ub/I0u8L3Oz6g= google.golang.org/genproto/googleapis/api v0.0.0-20250826171959-ef028d996bc1/go.mod h1:xUjFWUnWDpZ/C0Gu0qloASKFb6f8/QXiiXhSPFsD668= google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1 h1:pmJpJEvT846VzausCQ5d7KreSROcDqmO388w5YbnltA= google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1/go.mod h1:GmFNa4BdJZ2a8G+wCe9Bg3wwThLrJun751XstdJt5Og= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4= google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= ================================================ FILE: _examples/pubsubs/googlecloud/main.go ================================================ // Sources for https://watermill.io/learn/getting-started/ package main import ( "context" "log" "time" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill-googlecloud/v2/pkg/googlecloud" "github.com/ThreeDotsLabs/watermill/message" ) func main() { logger := watermill.NewStdLogger(false, false) subscriber, err := googlecloud.NewSubscriber( googlecloud.SubscriberConfig{ // custom function to generate Subscription Name, // there are also predefined TopicSubscriptionName and TopicSubscriptionNameWithSuffix available. GenerateSubscriptionName: func(topic string) string { return "test-sub_" + topic }, ProjectID: "test-project", }, logger, ) if err != nil { panic(err) } // Subscribe will create the subscription. Only messages that are sent after the subscription is created may be received. messages, err := subscriber.Subscribe(context.Background(), "example.topic") if err != nil { panic(err) } go process(messages) publisher, err := googlecloud.NewPublisher(googlecloud.PublisherConfig{ ProjectID: "test-project", }, logger) if err != nil { panic(err) } publishMessages(publisher) } func publishMessages(publisher message.Publisher) { for { msg := message.NewMessage(watermill.NewUUID(), []byte("Hello, world!")) if err := publisher.Publish("example.topic", msg); err != nil { panic(err) } time.Sleep(time.Second) } } func process(messages <-chan *message.Message) { for msg := range messages { log.Printf("received message: %s, payload: %s", msg.UUID, string(msg.Payload)) // we need to Acknowledge that we received and processed the message, // otherwise, it will be resent over and over again. msg.Ack() } } ================================================ FILE: _examples/pubsubs/kafka/.validate_example.yml ================================================ validation_cmd: "docker compose up" teardown_cmd: "docker compose down" timeout: 180 expected_output: "payload: Hello, world!" ================================================ FILE: _examples/pubsubs/kafka/docker-compose.yml ================================================ services: server: image: golang:1.25 restart: unless-stopped depends_on: - kafka volumes: - .:/app - $GOPATH/pkg/mod:/go/pkg/mod working_dir: /app command: go run main.go zookeeper: image: confluentinc/cp-zookeeper:7.3.1 restart: unless-stopped logging: driver: none environment: ZOOKEEPER_CLIENT_PORT: 2181 kafka: image: confluentinc/cp-kafka:7.3.1 restart: unless-stopped depends_on: - zookeeper logging: driver: none environment: KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092 KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true" ================================================ FILE: _examples/pubsubs/kafka/go.mod ================================================ module main.go require ( github.com/IBM/sarama v1.46.0 github.com/ThreeDotsLabs/watermill v1.5.1 github.com/ThreeDotsLabs/watermill-kafka/v3 v3.1.0 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/dnwe/otelsarama v0.0.0-20240308230250-9388d9d40bc0 // indirect github.com/eapache/go-resiliency v1.7.0 // indirect github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 // indirect github.com/eapache/queue v1.1.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/golang/snappy v1.0.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/jcmturner/aescts/v2 v2.0.0 // indirect github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect github.com/jcmturner/gofork v1.7.6 // indirect github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect github.com/jcmturner/rpc/v2 v2.0.3 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/lithammer/shortuuid/v3 v3.0.7 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/otel v1.38.0 // indirect go.opentelemetry.io/otel/metric v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect golang.org/x/crypto v0.41.0 // indirect golang.org/x/net v0.43.0 // indirect ) go 1.25 ================================================ FILE: _examples/pubsubs/kafka/go.sum ================================================ github.com/IBM/sarama v1.46.0 h1:+YTM1fNd6WKMchlnLKRUB5Z0qD4M8YbvwIIPLvJD53s= github.com/IBM/sarama v1.46.0/go.mod h1:0lOcuQziJ1/mBGHkdp5uYrltqQuKQKM5O5FOWUQVVvo= github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY= github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892LARrKAdki6WtXS4= github.com/ThreeDotsLabs/watermill-kafka/v3 v3.1.0 h1:DfhVM1ieq+rb+bboB7aoymUgfKsEM3UbH3noQZ6+RJ4= github.com/ThreeDotsLabs/watermill-kafka/v3 v3.1.0/go.mod h1:o1GcoF/1CSJ9JSmQzUkULvpZeO635pZe+WWrYNFlJNk= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dnwe/otelsarama v0.0.0-20240308230250-9388d9d40bc0 h1:R2zQhFwSCyyd7L43igYjDrH0wkC/i+QBPELuY0HOu84= github.com/dnwe/otelsarama v0.0.0-20240308230250-9388d9d40bc0/go.mod h1:2MqLKYJfjs3UriXXF9Fd0Qmh/lhxi/6tHXkqtXxyIHc= github.com/eapache/go-resiliency v1.7.0 h1:n3NRTnBn5N0Cbi/IeOHuQn9s2UwVUH7Ga0ZWcP+9JTA= github.com/eapache/go-resiliency v1.7.0/go.mod h1:5yPzW0MIvSe0JDsv0v+DvcjEv2FyD6iZYSs1ZI+iQho= github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 h1:Oy0F4ALJ04o5Qqpdz8XLIpNA3WM/iSIXqxtqo7UGVws= github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3/go.mod h1:YvSRo5mw33fLEx1+DlK6L2VV43tJt5Eyel9n9XBcR+0= github.com/eapache/queue v1.1.0 h1:YOEu7KNc61ntiQlcEeUIoDTJ2o8mQznoNvUhiigpIqc= github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg= github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8= github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg= github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: _examples/pubsubs/kafka/main.go ================================================ // Sources for https://watermill.io/learn/getting-started/ package main import ( "context" "log" "time" "github.com/IBM/sarama" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill-kafka/v3/pkg/kafka" "github.com/ThreeDotsLabs/watermill/message" ) func main() { saramaSubscriberConfig := kafka.DefaultSaramaSubscriberConfig() // equivalent of auto.offset.reset: earliest saramaSubscriberConfig.Consumer.Offsets.Initial = sarama.OffsetOldest subscriber, err := kafka.NewSubscriber( kafka.SubscriberConfig{ Brokers: []string{"kafka:9092"}, Unmarshaler: kafka.DefaultMarshaler{}, OverwriteSaramaConfig: saramaSubscriberConfig, ConsumerGroup: "test_consumer_group", }, watermill.NewStdLogger(false, false), ) if err != nil { panic(err) } messages, err := subscriber.Subscribe(context.Background(), "example.topic") if err != nil { panic(err) } go process(messages) publisher, err := kafka.NewPublisher( kafka.PublisherConfig{ Brokers: []string{"kafka:9092"}, Marshaler: kafka.DefaultMarshaler{}, }, watermill.NewStdLogger(false, false), ) if err != nil { panic(err) } publishMessages(publisher) } func publishMessages(publisher message.Publisher) { for { msg := message.NewMessage(watermill.NewUUID(), []byte("Hello, world!")) if err := publisher.Publish("example.topic", msg); err != nil { panic(err) } time.Sleep(time.Second) } } func process(messages <-chan *message.Message) { for msg := range messages { log.Printf("received message: %s, payload: %s", msg.UUID, string(msg.Payload)) // we need to Acknowledge that we received and processed the message, // otherwise, it will be resent over and over again. msg.Ack() } } ================================================ FILE: _examples/pubsubs/nats-core/.validate_example.yml ================================================ validation_cmd: "docker compose up" teardown_cmd: "docker compose down" timeout: 180 expected_output: "payload: Hello, world!" ================================================ FILE: _examples/pubsubs/nats-core/docker-compose.yml ================================================ services: server: image: golang:1.25 restart: unless-stopped depends_on: - nats volumes: - .:/app - $GOPATH/pkg/mod:/go/pkg/mod working_dir: /app command: go run main.go nats: image: nats:2 ports: - "0.0.0.0:4222:4222" restart: unless-stopped command: ["-js"] ulimits: nofile: soft: 65536 hard: 65536 ================================================ FILE: _examples/pubsubs/nats-core/go.mod ================================================ module main go 1.25 require ( github.com/ThreeDotsLabs/watermill v1.5.1 github.com/ThreeDotsLabs/watermill-nats/v2 v2.1.3 github.com/nats-io/nats.go v1.45.0 ) require ( github.com/google/uuid v1.6.0 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/lithammer/shortuuid/v3 v3.0.7 // indirect github.com/nats-io/nkeys v0.4.11 // indirect github.com/nats-io/nuid v1.0.1 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/pkg/errors v0.9.1 // indirect golang.org/x/crypto v0.41.0 // indirect golang.org/x/sys v0.35.0 // indirect ) ================================================ FILE: _examples/pubsubs/nats-core/go.sum ================================================ github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY= github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892LARrKAdki6WtXS4= github.com/ThreeDotsLabs/watermill-nats/v2 v2.1.3 h1:/5IfNugBb9H+BvEHHNRnICmF3jaI9P7wVRzA12kDDDs= github.com/ThreeDotsLabs/watermill-nats/v2 v2.1.3/go.mod h1:stjbT+s4u/s5ime5jdIyvPyjBGwGeJewIN7jxH8gp4k= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= github.com/nats-io/nats.go v1.45.0 h1:/wGPbnYXDM0pLKFjZTX+2JOw9TQPoIgTFrUaH97giwA= github.com/nats-io/nats.go v1.45.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0= github.com/nats-io/nkeys v0.4.11/go.mod h1:szDimtgmfOi9n25JpfIdGw12tZFYXqhGxjhVxsatHVE= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: _examples/pubsubs/nats-core/main.go ================================================ package main import ( "context" "fmt" "log" "os" "os/signal" "syscall" "time" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill-nats/v2/pkg/nats" "github.com/ThreeDotsLabs/watermill/message" nc "github.com/nats-io/nats.go" ) func main() { natsURL := "nats://nats:4222" marshaler := &nats.GobMarshaler{} logger := watermill.NewStdLogger(false, false) options := []nc.Option{ nc.RetryOnFailedConnect(true), nc.Timeout(30 * time.Second), nc.ReconnectWait(1 * time.Second), } jsConfig := nats.JetStreamConfig{Disabled: true} subscriber, err := nats.NewSubscriber( nats.SubscriberConfig{ URL: natsURL, CloseTimeout: 30 * time.Second, AckWaitTimeout: 30 * time.Second, NatsOptions: options, Unmarshaler: marshaler, JetStream: jsConfig, }, logger, ) if err != nil { panic(err) } c := make(chan os.Signal) signal.Notify(c, os.Interrupt, syscall.SIGTERM) go func() { <-c fmt.Println("\r- Ctrl+C pressed in Terminal - closing subscriber") subscriber.Close() os.Exit(0) }() messages, err := subscriber.Subscribe(context.Background(), "example_topic_nats") if err != nil { panic(err) } go process(messages) publisher, err := nats.NewPublisher( nats.PublisherConfig{ URL: natsURL, NatsOptions: options, Marshaler: marshaler, JetStream: jsConfig, }, logger, ) if err != nil { panic(err) } publishMessages(publisher) } func publishMessages(publisher message.Publisher) { for { msg := message.NewMessage(watermill.NewUUID(), []byte("Hello, world!")) if err := publisher.Publish("example_topic_nats", msg); err != nil { panic(err) } time.Sleep(time.Second) } } func process(messages <-chan *message.Message) { for msg := range messages { log.Printf("received message: %s, payload: %s", msg.UUID, string(msg.Payload)) // we need to Acknowledge that we received and processed the message, // otherwise, it will be resent over and over again. msg.Ack() } } ================================================ FILE: _examples/pubsubs/nats-jetstream/.validate_example.yml ================================================ validation_cmd: "docker compose up" teardown_cmd: "docker compose down" timeout: 180 expected_output: "payload: Hello, world!" ================================================ FILE: _examples/pubsubs/nats-jetstream/docker-compose.yml ================================================ services: server: image: golang:1.25 restart: unless-stopped depends_on: - nats volumes: - .:/app - $GOPATH/pkg/mod:/go/pkg/mod working_dir: /app command: go run main.go nats: image: nats:2 ports: - "0.0.0.0:4222:4222" restart: unless-stopped command: ["-js"] ulimits: nofile: soft: 65536 hard: 65536 ================================================ FILE: _examples/pubsubs/nats-jetstream/go.mod ================================================ module main go 1.25 require ( github.com/ThreeDotsLabs/watermill v1.5.1 github.com/ThreeDotsLabs/watermill-nats/v2 v2.1.3 github.com/nats-io/nats.go v1.45.0 ) require ( github.com/google/uuid v1.6.0 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/lithammer/shortuuid/v3 v3.0.7 // indirect github.com/nats-io/nkeys v0.4.11 // indirect github.com/nats-io/nuid v1.0.1 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/pkg/errors v0.9.1 // indirect golang.org/x/crypto v0.41.0 // indirect golang.org/x/sys v0.35.0 // indirect ) ================================================ FILE: _examples/pubsubs/nats-jetstream/go.sum ================================================ github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY= github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892LARrKAdki6WtXS4= github.com/ThreeDotsLabs/watermill-nats/v2 v2.1.3 h1:/5IfNugBb9H+BvEHHNRnICmF3jaI9P7wVRzA12kDDDs= github.com/ThreeDotsLabs/watermill-nats/v2 v2.1.3/go.mod h1:stjbT+s4u/s5ime5jdIyvPyjBGwGeJewIN7jxH8gp4k= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= github.com/nats-io/nats.go v1.45.0 h1:/wGPbnYXDM0pLKFjZTX+2JOw9TQPoIgTFrUaH97giwA= github.com/nats-io/nats.go v1.45.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0= github.com/nats-io/nkeys v0.4.11/go.mod h1:szDimtgmfOi9n25JpfIdGw12tZFYXqhGxjhVxsatHVE= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: _examples/pubsubs/nats-jetstream/main.go ================================================ package main import ( "context" "fmt" "log" "os" "os/signal" "syscall" "time" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill-nats/v2/pkg/nats" "github.com/ThreeDotsLabs/watermill/message" nc "github.com/nats-io/nats.go" ) func main() { natsURL := "nats://nats:4222" marshaler := &nats.GobMarshaler{} logger := watermill.NewStdLogger(false, false) options := []nc.Option{ nc.RetryOnFailedConnect(true), nc.Timeout(30 * time.Second), nc.ReconnectWait(1 * time.Second), } subscribeOptions := []nc.SubOpt{ nc.DeliverAll(), nc.AckExplicit(), } jsConfig := nats.JetStreamConfig{ Disabled: false, AutoProvision: true, ConnectOptions: nil, SubscribeOptions: subscribeOptions, PublishOptions: nil, TrackMsgId: false, AckAsync: false, DurablePrefix: "", } subscriber, err := nats.NewSubscriber( nats.SubscriberConfig{ URL: natsURL, CloseTimeout: 30 * time.Second, AckWaitTimeout: 30 * time.Second, NatsOptions: options, Unmarshaler: marshaler, JetStream: jsConfig, }, logger, ) if err != nil { panic(err) } c := make(chan os.Signal) signal.Notify(c, os.Interrupt, syscall.SIGTERM) go func() { <-c fmt.Println("\r- Ctrl+C pressed in Terminal - closing subscriber") subscriber.Close() os.Exit(0) }() messages, err := subscriber.Subscribe(context.Background(), "example_topic") if err != nil { panic(err) } go processJS(messages) publisher, err := nats.NewPublisher( nats.PublisherConfig{ URL: natsURL, NatsOptions: options, Marshaler: marshaler, JetStream: jsConfig, }, logger, ) if err != nil { panic(err) } for { msg := message.NewMessage(watermill.NewUUID(), []byte("Hello, world!")) if err := publisher.Publish("example_topic", msg); err != nil { panic(err) } time.Sleep(time.Second) } } func processJS(messages <-chan *message.Message) { for msg := range messages { log.Printf("received message: %s, payload: %s", msg.UUID, string(msg.Payload)) // we need to Acknowledge that we received and processed the message, // otherwise, it will be resent over and over again. msg.Ack() } } ================================================ FILE: _examples/pubsubs/nats-streaming/.validate_example.yml ================================================ validation_cmd: "docker compose up" teardown_cmd: "docker compose down" timeout: 180 expected_output: "payload: Hello, world!" ================================================ FILE: _examples/pubsubs/nats-streaming/docker-compose.yml ================================================ services: server: image: golang:1.25 restart: unless-stopped depends_on: - nats-streaming volumes: - .:/app - $GOPATH/pkg/mod:/go/pkg/mod working_dir: /app command: go run main.go nats-streaming: image: nats-streaming:0.11.2 restart: unless-stopped ================================================ FILE: _examples/pubsubs/nats-streaming/go.mod ================================================ module main.go require ( github.com/ThreeDotsLabs/watermill v1.5.1 github.com/ThreeDotsLabs/watermill-nats v1.0.7 github.com/nats-io/stan.go v0.10.4 ) require ( github.com/gogo/protobuf v1.3.2 // indirect github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/go-hclog v1.4.0 // indirect github.com/hashicorp/go-msgpack v0.5.5 // indirect github.com/hashicorp/raft v1.3.11 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/lithammer/shortuuid/v3 v3.0.7 // indirect github.com/minio/highwayhash v1.0.2 // indirect github.com/nats-io/jwt/v2 v2.3.0 // indirect github.com/nats-io/nats.go v1.45.0 // indirect github.com/nats-io/nkeys v0.4.11 // indirect github.com/nats-io/nuid v1.0.1 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/pkg/errors v0.9.1 // indirect go.etcd.io/bbolt v1.3.6 // indirect golang.org/x/crypto v0.41.0 // indirect golang.org/x/sys v0.35.0 // indirect golang.org/x/time v0.3.0 // indirect ) go 1.25 ================================================ FILE: _examples/pubsubs/nats-streaming/go.sum ================================================ github.com/DataDog/datadog-go v2.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY= github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892LARrKAdki6WtXS4= github.com/ThreeDotsLabs/watermill-nats v1.0.7 h1:hOquWq0GAwm5jaIc3wGaDoVCPYL+If4NZPb+RUaHni4= github.com/ThreeDotsLabs/watermill-nats v1.0.7/go.mod h1:t5A8XbO/v8CPM+AIljgoO9NR1jBk3ixYBGAtvn1N4lA= github.com/armon/go-metrics v0.0.0-20190430140413-ec5e00d3c878 h1:EFSB7Zo9Eg91v7MJPVsifUysc/wPdN+NOnVe6bWbdBM= github.com/armon/go-metrics v0.0.0-20190430140413-ec5e00d3c878/go.mod h1:3AMJUQhVx52RsWOnlkpikZr01T/yAVN2gn0861vByNg= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-hclog v0.9.1/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= github.com/hashicorp/go-hclog v1.4.0 h1:ctuWFGrhFha8BnnzxqeRGidlEcQkDyL5u8J8t5eA11I= github.com/hashicorp/go-hclog v1.4.0/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-immutable-radix v1.0.0 h1:AKDB1HM5PWEA7i4nhcpwOrO2byshxBjXVn/J/3+z5/0= github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-msgpack v0.5.5 h1:i9R9JSrqIz0QVLz3sz+i3YJdT7TTSLcfLLzJi9aZTuI= github.com/hashicorp/go-msgpack v0.5.5/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/golang-lru v0.5.0 h1:CL2msUPvZTLb5O648aiLNJw3hnBxN2+1Jq8rCOH9wdo= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/raft v1.3.11 h1:p3v6gf6l3S797NnK5av3HcczOC1T5CLoaRvg0g9ys4A= github.com/hashicorp/raft v1.3.11/go.mod h1:J8naEwc6XaaCfts7+28whSeRvCqTd6e20BlCU3LtEO4= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/minio/highwayhash v1.0.2 h1:Aak5U0nElisjDCfPSG79Tgzkn2gl66NxOMspRrKnA/g= github.com/minio/highwayhash v1.0.2/go.mod h1:BQskDq+xkJ12lmlUUi7U0M5Swg3EWR+dLTk+kldvVxY= github.com/nats-io/jwt/v2 v2.3.0 h1:z2mA1a7tIf5ShggOFlR1oBPgd6hGqcDYsISxZByUzdI= github.com/nats-io/jwt/v2 v2.3.0/go.mod h1:0tqz9Hlu6bCBFLWAASKhE5vUA4c24L9KPUUgvwumE/k= github.com/nats-io/nats-server/v2 v2.6.1 h1:cJy+ia7/4EaJL+ZYDmIy2rD1mDWTfckhtPBU0GYo8xM= github.com/nats-io/nats-server/v2 v2.6.1/go.mod h1:Az91TbZiV7K4a6k/4v6YYdOKEoxCXj+iqhHVf/MlrKo= github.com/nats-io/nats-streaming-server v0.22.1 h1:YKDdLAWZud3UnEBvUPaYppMxSDuh+9czTCDriq19tJY= github.com/nats-io/nats-streaming-server v0.22.1/go.mod h1:1WpVkVV5NyZbHuGGxkaPWopLFnxNthO/TK/BkzFdnPE= github.com/nats-io/nats.go v1.22.1/go.mod h1:tLqubohF7t4z3du1QDPYJIQQyhb4wl6DhjxEajSI7UA= github.com/nats-io/nats.go v1.45.0 h1:/wGPbnYXDM0pLKFjZTX+2JOw9TQPoIgTFrUaH97giwA= github.com/nats-io/nats.go v1.45.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= github.com/nats-io/nkeys v0.3.0/go.mod h1:gvUNGjVcM2IPr5rCsRsC6Wb3Hr2CQAm08dsxtV6A5y4= github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0= github.com/nats-io/nkeys v0.4.11/go.mod h1:szDimtgmfOi9n25JpfIdGw12tZFYXqhGxjhVxsatHVE= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= github.com/nats-io/stan.go v0.10.4 h1:19GS/eD1SeQJaVkeM9EkvEYattnvnWrZ3wkSWSw4uXw= github.com/nats-io/stan.go v0.10.4/go.mod h1:3XJXH8GagrGqajoO/9+HgPyKV5MWsv7S5ccdda+pc6k= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v0.9.2/go.mod h1:OsXs2jCmiKlQ1lTBmv21f2mNfw4xf/QclQDMrYNZzcM= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/common v0.0.0-20181126121408-4724e9255275/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU= go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210314154223-e6e6c4f2bb5b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190130150945-aca44879d564/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: _examples/pubsubs/nats-streaming/main.go ================================================ // Sources for https://watermill.io/learn/getting-started/ package main import ( "context" "log" "time" stan "github.com/nats-io/stan.go" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill-nats/pkg/nats" "github.com/ThreeDotsLabs/watermill/message" ) func main() { subscriber, err := nats.NewStreamingSubscriber( nats.StreamingSubscriberConfig{ ClusterID: "test-cluster", ClientID: "example-subscriber", QueueGroup: "example", DurableName: "my-durable", SubscribersCount: 4, // how many goroutines should consume messages CloseTimeout: time.Minute, AckWaitTimeout: time.Second * 30, StanOptions: []stan.Option{ stan.NatsURL("nats://nats-streaming:4222"), }, Unmarshaler: nats.GobMarshaler{}, }, watermill.NewStdLogger(false, false), ) if err != nil { panic(err) } messages, err := subscriber.Subscribe(context.Background(), "example.topic") if err != nil { panic(err) } go process(messages) publisher, err := nats.NewStreamingPublisher( nats.StreamingPublisherConfig{ ClusterID: "test-cluster", ClientID: "example-publisher", StanOptions: []stan.Option{ stan.NatsURL("nats://nats-streaming:4222"), }, Marshaler: nats.GobMarshaler{}, }, watermill.NewStdLogger(false, false), ) if err != nil { panic(err) } publishMessages(publisher) } func publishMessages(publisher message.Publisher) { for { msg := message.NewMessage(watermill.NewUUID(), []byte("Hello, world!")) if err := publisher.Publish("example.topic", msg); err != nil { panic(err) } time.Sleep(time.Second) } } func process(messages <-chan *message.Message) { for msg := range messages { log.Printf("received message: %s, payload: %s", msg.UUID, string(msg.Payload)) // we need to Acknowledge that we received and processed the message, // otherwise, it will be resent over and over again. msg.Ack() } } ================================================ FILE: _examples/pubsubs/redisstream/.validate_example.yml ================================================ validation_cmd: "docker compose up" teardown_cmd: "docker compose down" timeout: 180 expected_output: "payload: Hello, world!" ================================================ FILE: _examples/pubsubs/redisstream/docker-compose.yml ================================================ services: server: image: golang:1.25 restart: unless-stopped depends_on: - redis volumes: - .:/app - $GOPATH/pkg/mod:/go/pkg/mod working_dir: /app command: go run main.go redis: image: redis:7 ports: - 6379:6379 restart: unless-stopped ================================================ FILE: _examples/pubsubs/redisstream/go.mod ================================================ module main.go go 1.25 require ( github.com/ThreeDotsLabs/watermill v1.5.1 github.com/ThreeDotsLabs/watermill-redisstream v1.4.4 github.com/redis/go-redis/v9 v9.12.1 ) require ( github.com/Rican7/retry v0.3.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/uuid v1.6.0 // indirect github.com/lithammer/shortuuid/v3 v3.0.7 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect google.golang.org/appengine v1.6.8 // indirect google.golang.org/protobuf v1.36.8 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) ================================================ FILE: _examples/pubsubs/redisstream/go.sum ================================================ github.com/Rican7/retry v0.3.1 h1:scY4IbO8swckzoA/11HgBwaZRJEyY9vaNJshcdhp1Mc= github.com/Rican7/retry v0.3.1/go.mod h1:CxSDrhAyXmTMeEuRAnArMu1FHu48vtfjLREWqVl7Vw0= github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY= github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892LARrKAdki6WtXS4= github.com/ThreeDotsLabs/watermill-redisstream v1.4.4 h1:vkpSm2MZHacjN4H8R0PA9IKQ++uQMq6wA0m1bnGjipo= github.com/ThreeDotsLabs/watermill-redisstream v1.4.4/go.mod h1:Da3wqG1OcvHPODjuJcxSCY1O7D4loIZQpVbZ5u94xRo= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/redis/go-redis/v9 v9.12.1 h1:k5iquqv27aBtnTm2tIkROUDp8JBXhXZIVu1InSgvovg= github.com/redis/go-redis/v9 v9.12.1/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: _examples/pubsubs/redisstream/main.go ================================================ // Sources for https://watermill.io/learn/getting-started/ package main import ( "context" "log" "time" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill-redisstream/pkg/redisstream" "github.com/ThreeDotsLabs/watermill/message" "github.com/redis/go-redis/v9" ) func main() { subClient := redis.NewClient(&redis.Options{ Addr: "redis:6379", DB: 0, }) subscriber, err := redisstream.NewSubscriber( redisstream.SubscriberConfig{ Client: subClient, Unmarshaller: redisstream.DefaultMarshallerUnmarshaller{}, ConsumerGroup: "test_consumer_group", }, watermill.NewStdLogger(false, false), ) if err != nil { panic(err) } messages, err := subscriber.Subscribe(context.Background(), "example.topic") if err != nil { panic(err) } go process(messages) pubClient := redis.NewClient(&redis.Options{ Addr: "redis:6379", DB: 0, }) publisher, err := redisstream.NewPublisher( redisstream.PublisherConfig{ Client: pubClient, Marshaller: redisstream.DefaultMarshallerUnmarshaller{}, }, watermill.NewStdLogger(false, false), ) if err != nil { panic(err) } publishMessages(publisher) } func publishMessages(publisher message.Publisher) { for { msg := message.NewMessage(watermill.NewUUID(), []byte("Hello, world!")) if err := publisher.Publish("example.topic", msg); err != nil { panic(err) } time.Sleep(time.Second) } } func process(messages <-chan *message.Message) { for msg := range messages { log.Printf("received message: %s, payload: %s", msg.UUID, string(msg.Payload)) // we need to Acknowledge that we received and processed the message, // otherwise, it will be resent over and over again. msg.Ack() } } ================================================ FILE: _examples/pubsubs/sql/.validate_example.yml ================================================ validation_cmd: "docker compose up" teardown_cmd: "docker compose down" timeout: 180 expected_output: "Hello, world!" ================================================ FILE: _examples/pubsubs/sql/docker-compose.yml ================================================ services: server: image: golang:1.25 restart: unless-stopped depends_on: - mysql volumes: - .:/app - $GOPATH/pkg/mod:/go/pkg/mod working_dir: /app command: go run main.go mysql: image: mysql:8.0 restart: unless-stopped ports: - 3306:3306 environment: MYSQL_DATABASE: watermill MYSQL_ALLOW_EMPTY_PASSWORD: "yes" ================================================ FILE: _examples/pubsubs/sql/go.mod ================================================ module main.go go 1.25 require ( github.com/ThreeDotsLabs/watermill v1.5.1 github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0 github.com/go-sql-driver/mysql v1.9.3 ) require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.7.5 // indirect github.com/lib/pq v1.10.9 // indirect github.com/lithammer/shortuuid/v3 v3.0.7 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/sony/gobreaker v1.0.0 // indirect golang.org/x/crypto v0.41.0 // indirect golang.org/x/text v0.28.0 // indirect ) ================================================ FILE: _examples/pubsubs/sql/go.sum ================================================ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY= github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892LARrKAdki6WtXS4= github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0 h1:vd0eoMPriNjiFTEo7tQ1wmN933lNAWyVhxX931JG5Pw= github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0/go.mod h1:Ce2GVZVnyajAh0AkwxSJXwx8ajBBveu1DI/yatan5jc= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ= github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: _examples/pubsubs/sql/main.go ================================================ // Sources for https://watermill.io/learn/getting-started/ package main import ( "context" stdSQL "database/sql" "log" "time" driver "github.com/go-sql-driver/mysql" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill-sql/v4/pkg/sql" "github.com/ThreeDotsLabs/watermill/message" ) func main() { db := createDB() logger := watermill.NewStdLogger(false, false) subscriber, err := sql.NewSubscriber( sql.BeginnerFromStdSQL(db), sql.SubscriberConfig{ SchemaAdapter: sql.DefaultMySQLSchema{}, OffsetsAdapter: sql.DefaultMySQLOffsetsAdapter{}, InitializeSchema: true, }, logger, ) if err != nil { panic(err) } messages, err := subscriber.Subscribe(context.Background(), "example_topic") if err != nil { panic(err) } go process(messages) publisher, err := sql.NewPublisher( sql.BeginnerFromStdSQL(db), sql.PublisherConfig{ SchemaAdapter: sql.DefaultMySQLSchema{}, }, logger, ) if err != nil { panic(err) } publishMessages(publisher) } func createDB() *stdSQL.DB { conf := driver.NewConfig() conf.Net = "tcp" conf.User = "root" conf.Addr = "mysql" conf.DBName = "watermill" db, err := stdSQL.Open("mysql", conf.FormatDSN()) if err != nil { panic(err) } err = db.Ping() if err != nil { panic(err) } return db } func publishMessages(publisher message.Publisher) { for { msg := message.NewMessage(watermill.NewUUID(), []byte(`{"message": "Hello, world!"}`)) if err := publisher.Publish("example_topic", msg); err != nil { panic(err) } time.Sleep(time.Second) } } func process(messages <-chan *message.Message) { for msg := range messages { log.Printf("received message: %s, payload: %s", msg.UUID, string(msg.Payload)) // we need to Acknowledge that we received and processed the message, // otherwise, it will be resent over and over again. msg.Ack() } } ================================================ FILE: _examples/pubsubs/sqlite/.gitignore ================================================ db.sqlite3* ================================================ FILE: _examples/pubsubs/sqlite/.validate_example.yml ================================================ validation_cmd: "go run ." timeout: 30 expected_stdout: - "received message:" ================================================ FILE: _examples/pubsubs/sqlite/go.mod ================================================ module sqlite go 1.24.0 require ( github.com/ThreeDotsLabs/watermill v1.5.1 github.com/ThreeDotsLabs/watermill-sqlite/wmsqlitemodernc v0.1.1 modernc.org/sqlite v1.39.0 ) require ( github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/lithammer/shortuuid/v3 v3.0.7 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/sony/gobreaker v1.0.0 // indirect golang.org/x/exp v0.0.0-20250911091902-df9299821621 // indirect golang.org/x/sys v0.36.0 // indirect modernc.org/libc v1.66.8 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect ) ================================================ FILE: _examples/pubsubs/sqlite/go.sum ================================================ github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY= github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892LARrKAdki6WtXS4= github.com/ThreeDotsLabs/watermill-sqlite/wmsqlitemodernc v0.0.7 h1:yrHtw0WxXn+aoZU0+PdVCS+AC0d5wTC3v73sVhMLgXc= github.com/ThreeDotsLabs/watermill-sqlite/wmsqlitemodernc v0.0.7/go.mod h1:+sg/sEWQtVzzzDnrcd/Lva2CP9D3gDTE9nBkM2toujI= github.com/ThreeDotsLabs/watermill-sqlite/wmsqlitemodernc v0.0.8/go.mod h1:Os8F1QAkBJFoWNABp6iql3lJaKzG3b70mesQV5Iu+oU= github.com/ThreeDotsLabs/watermill-sqlite/wmsqlitemodernc v0.1.0 h1:NNgUlhjuG5oAcXlXcB9HHYBcqwG12VcJz1DaVBaUwQg= github.com/ThreeDotsLabs/watermill-sqlite/wmsqlitemodernc v0.1.0/go.mod h1:Os8F1QAkBJFoWNABp6iql3lJaKzG3b70mesQV5Iu+oU= github.com/ThreeDotsLabs/watermill-sqlite/wmsqlitemodernc v0.1.1 h1:t4f8bPmZfGv8+/VO3prla5rklVbex4OpmsK6+SkPR+c= github.com/ThreeDotsLabs/watermill-sqlite/wmsqlitemodernc v0.1.1/go.mod h1:Os8F1QAkBJFoWNABp6iql3lJaKzG3b70mesQV5Iu+oU= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ= github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= golang.org/x/exp v0.0.0-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8tUK/Qs5IsmBtrc+FtgU= golang.org/x/exp v0.0.0-20250911091902-df9299821621/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= modernc.org/libc v1.66.8 h1:/awsvTnyN/sNjvJm6S3lb7KZw5WV4ly/sBEG7ZUzmIE= modernc.org/libc v1.66.8/go.mod h1:aVdcY7udcawRqauu0HukYYxtBSizV+R80n/6aQe9D5k= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY= modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= ================================================ FILE: _examples/pubsubs/sqlite/main.go ================================================ // Sources for https://watermill.io/docs/getting-started/ package main import ( "context" "database/sql" "log" "time" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill-sqlite/wmsqlitemodernc" "github.com/ThreeDotsLabs/watermill/message" _ "modernc.org/sqlite" ) func main() { db := createDB() defer db.Close() logger := watermill.NewStdLogger(false, false) subscriber, err := wmsqlitemodernc.NewSubscriber( db, wmsqlitemodernc.SubscriberOptions{ InitializeSchema: true, Logger: logger, }, ) if err != nil { panic(err) } messages, err := subscriber.Subscribe(context.Background(), "example_topic") if err != nil { panic(err) } go process(messages) publisher, err := wmsqlitemodernc.NewPublisher( db, wmsqlitemodernc.PublisherOptions{ InitializeSchema: true, Logger: logger, }, ) if err != nil { panic(err) } publishMessages(publisher) } func createDB() *sql.DB { connectionDSN := ":memory:" // or "db.sqlite3?journal_mode=WAL&busy_timeout=1000&cache=shared" db, err := sql.Open("sqlite", connectionDSN) if err != nil { panic(err) } // limit the number of concurrent connections to one // this is a limitation of `modernc.org/sqlite` driver db.SetMaxOpenConns(1) err = db.Ping() if err != nil { panic(err) } return db } func publishMessages(publisher message.Publisher) { for { msg := message.NewMessage(watermill.NewUUID(), []byte(`{"message": "Hello, world!"}`)) if err := publisher.Publish("example_topic", msg); err != nil { panic(err) } time.Sleep(time.Second) } } func process(messages <-chan *message.Message) { for msg := range messages { log.Printf("received message: %s, payload: %s", msg.UUID, string(msg.Payload)) // we need to Acknowledge that we received and processed the message, // otherwise, it will be resent over and over again. msg.Ack() } } ================================================ FILE: _examples/pubsubs/sqlite/transaction.go ================================================ package main import ( "context" "database/sql" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill-sqlite/wmsqlitemodernc" "github.com/ThreeDotsLabs/watermill/message" ) func publishWithInTransaction(db *sql.DB) { tx, err := db.BeginTx(context.Background(), &sql.TxOptions{ Isolation: sql.LevelReadCommitted, }) if err != nil { panic(err) } defer func() { _ = tx.Commit() }() publisher, err := wmsqlitemodernc.NewPublisher( tx, // transaction presented as database wmsqlitemodernc.PublisherOptions{ // schema must be initialized elsewhere before using // the publisher within the transaction InitializeSchema: false, }, ) if err != nil { panic(err) } msg := message.NewMessage(watermill.NewUUID(), []byte(`{"message": "Hello, world!"}`)) if err := publisher.Publish("example_topic", msg); err != nil { _ = tx.Rollback() panic(err) } } ================================================ FILE: _examples/pubsubs/sqlite-zombiezen/.gitignore ================================================ db.sqlite3* ================================================ FILE: _examples/pubsubs/sqlite-zombiezen/.validate_example.yml ================================================ validation_cmd: "go run main.go" timeout: 30 expected_stdout: - "received message:" ================================================ FILE: _examples/pubsubs/sqlite-zombiezen/go.mod ================================================ module sqlite go 1.24.0 require ( github.com/ThreeDotsLabs/watermill v1.5.1 github.com/ThreeDotsLabs/watermill-sqlite/wmsqlitezombiezen v0.1.1 modernc.org/sqlite v1.39.0 zombiezen.com/go/sqlite v1.4.2 ) require ( github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/lithammer/shortuuid/v3 v3.0.7 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/sony/gobreaker v1.0.0 // indirect golang.org/x/exp v0.0.0-20250911091902-df9299821621 // indirect golang.org/x/sys v0.36.0 // indirect modernc.org/libc v1.66.8 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect ) ================================================ FILE: _examples/pubsubs/sqlite-zombiezen/go.sum ================================================ github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY= github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892LARrKAdki6WtXS4= github.com/ThreeDotsLabs/watermill-sqlite/test v0.0.5 h1:50Y9mhgsbskpxOqJiqNMZWs6RAmDJaQkH00ukGXTdcg= github.com/ThreeDotsLabs/watermill-sqlite/test v0.0.5/go.mod h1:0pqGSkSBj849FqsgYGbuO0k1Coav/i2cXgF8j+wRGtE= github.com/ThreeDotsLabs/watermill-sqlite/wmsqlitezombiezen v0.0.8 h1:sslOW/2x2m4vBVoZ9eNP/VG3/YeIZCBVEYoVegB8dg4= github.com/ThreeDotsLabs/watermill-sqlite/wmsqlitezombiezen v0.0.8/go.mod h1:XudXyl3g3JuyBvZ8Di8dZcuGLP450MlLadGIiKTek+g= github.com/ThreeDotsLabs/watermill-sqlite/wmsqlitezombiezen v0.1.1 h1:/u/c3KdnbQcLK7+RK7vK3m9mnrl/Ish+euwtFIRx7Tc= github.com/ThreeDotsLabs/watermill-sqlite/wmsqlitezombiezen v0.1.1/go.mod h1:XudXyl3g3JuyBvZ8Di8dZcuGLP450MlLadGIiKTek+g= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ= github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 h1:pVgRXcIictcr+lBQIFeiwuwtDIs4eL21OuM9nyAADmo= golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= golang.org/x/exp v0.0.0-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8tUK/Qs5IsmBtrc+FtgU= golang.org/x/exp v0.0.0-20250911091902-df9299821621/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk= golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8= golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg= golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI= golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0= modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/cc/v4 v4.26.4 h1:jPhG8oNjtTYuP2FA4YefTJ/wioNUGALmGuEWt7SUR6s= modernc.org/ccgo/v4 v4.23.16 h1:Z2N+kk38b7SfySC1ZkpGLN2vthNJP1+ZzGZIlH7uBxo= modernc.org/ccgo/v4 v4.23.16/go.mod h1:nNma8goMTY7aQZQNTyN9AIoJfxav4nvTnvKThAeMDdo= modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= modernc.org/fileutil v1.3.28 h1:Vp156KUA2nPu9F1NEv036x9UGOjg2qsi5QlWTjZmtMk= modernc.org/gc/v2 v2.6.3 h1:aJVhcqAte49LF+mGveZ5KPlsp4tdGdAOT4sipJXADjw= modernc.org/gc/v2 v2.6.3/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8= modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E= modernc.org/libc v1.66.8 h1:/awsvTnyN/sNjvJm6S3lb7KZw5WV4ly/sBEG7ZUzmIE= modernc.org/libc v1.66.8/go.mod h1:aVdcY7udcawRqauu0HukYYxtBSizV+R80n/6aQe9D5k= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.8.2 h1:cL9L4bcoAObu4NkxOlKWBWtNHIsnnACGF/TbqQ6sbcI= modernc.org/memory v1.8.2/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sqlite v1.36.1 h1:bDa8BJUH4lg6EGkLbahKe/8QqoF8p9gArSc6fTqYhyQ= modernc.org/sqlite v1.36.1/go.mod h1:7MPwH7Z6bREicF9ZVUR78P1IKuxfZ8mRIDHD0iD+8TU= modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY= modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= zombiezen.com/go/sqlite v1.4.0 h1:N1s3RIljwtp4541Y8rM880qgGIgq3fTD2yks1xftnKU= zombiezen.com/go/sqlite v1.4.0/go.mod h1:0w9F1DN9IZj9AcLS9YDKMboubCACkwYCGkzoy3eG5ik= zombiezen.com/go/sqlite v1.4.2 h1:KZXLrBuJ7tKNEm+VJcApLMeQbhmAUOKA5VWS93DfFRo= zombiezen.com/go/sqlite v1.4.2/go.mod h1:5Kd4taTAD4MkBzT25mQ9uaAlLjyR0rFhsR6iINO70jc= ================================================ FILE: _examples/pubsubs/sqlite-zombiezen/main.go ================================================ // Sources for https://watermill.io/docs/getting-started/ package main import ( "context" "log" "time" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill-sqlite/wmsqlitezombiezen" "github.com/ThreeDotsLabs/watermill/message" _ "modernc.org/sqlite" "zombiezen.com/go/sqlite" ) func main() { logger := watermill.NewStdLogger(false, false) // &cache=shared is critical, see: https://github.com/zombiezen/go-sqlite/issues/92#issuecomment-2052330643 // connectionDSN := "file:db.sqlite3?journal_mode=WAL&busy_timeout=1000&secure_delete=true&foreign_keys=true&cache=shared" connectionDSN := "file:ephemeral?mode=memory&cache=shared" conn, err := sqlite.OpenConn(connectionDSN) if err != nil { panic(err) } defer conn.Close() publisher, err := wmsqlitezombiezen.NewPublisher(conn, wmsqlitezombiezen.PublisherOptions{ InitializeSchema: true, Logger: logger, }) if err != nil { panic(err) } subscriber, err := wmsqlitezombiezen.NewSubscriber(connectionDSN, wmsqlitezombiezen.SubscriberOptions{ InitializeSchema: true, Logger: logger, }) if err != nil { panic(err) } messages, err := subscriber.Subscribe(context.Background(), "example_topic") if err != nil { panic(err) } go process(messages) publishMessages(publisher) } func publishMessages(publisher message.Publisher) { for { msg := message.NewMessage(watermill.NewUUID(), []byte(`{"message": "Hello from ZombieZen!"}`)) if err := publisher.Publish("example_topic", msg); err != nil { panic(err) } time.Sleep(time.Second) } } func process(messages <-chan *message.Message) { for msg := range messages { log.Printf("ZombieZen received message: %s, payload: %s", msg.UUID, string(msg.Payload)) msg.Ack() } } ================================================ FILE: _examples/pubsubs/sqlite-zombiezen/transaction.go ================================================ package main import ( "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill-sqlite/wmsqlitezombiezen" "github.com/ThreeDotsLabs/watermill/message" "zombiezen.com/go/sqlite" "zombiezen.com/go/sqlite/sqlitex" ) func publishWithInTransaction(connectionDSN string) { var err error // create a new connection for each transaction // unless you guard it with a sync.Mutex conn, err := sqlite.OpenConn(connectionDSN) if err != nil { panic(err) } defer conn.Close() closer := sqlitex.Transaction(conn) defer func() { if closer(&err); err != nil { panic(err) } }() publisher, err := wmsqlitezombiezen.NewPublisher( conn, wmsqlitezombiezen.PublisherOptions{ // schema must be initialized elsewhere before using // the publisher within the transaction InitializeSchema: false, }, ) if err != nil { panic(err) } msg := message.NewMessage(watermill.NewUUID(), []byte(`{"message": "Hello, world!"}`)) if err = publisher.Publish("example_topic", msg); err != nil { panic(err) } } ================================================ FILE: _examples/real-world-examples/consumer-groups/README.md ================================================ # Interactive Consumer Groups Example (Routing Events) This example shows how Customer Groups work, i.e. how to decide which handlers receive which events. Consumer Group is a concept used in Apache Kafka®, but many other Pub/Subs use a similar mechanism. The example uses Watermill and Redis Streams Pub/Sub, but the same idea applies to other Pub/Subs as well. ## Live video This example was showcased on the Watermill v1.2 Launch Event. You can see the [recording on YouTube](https://www.youtube.com/live/wjnd0Hj6CaM?t=1020) (starts at 17:00). [![Live Recording](https://img.youtube.com/vi/wjnd0Hj6CaM/0.jpg)](https://www.youtube.com/live/wjnd0Hj6CaM?t=1020) ## Running ``` docker-compose up ``` Then visit [localhost:8080](http://localhost:8080) and check the examples in each tab. ## Screenshots ![](docs/screen2.png) ![](docs/screen1.png) ## Code See [crm-service](crm-service) and [newsletter-service](newsletter-service) for the Watermill handlers setup. ## How does it work? This example uses SSE for pushing events to the frontend UI. See the [other example on SEE](../server-sent-events) for more details. ================================================ FILE: _examples/real-world-examples/consumer-groups/api/http.go ================================================ package main import ( "context" "encoding/json" "fmt" "net/http" "strings" "github.com/ThreeDotsLabs/watermill" watermillHTTP "github.com/ThreeDotsLabs/watermill-http/pkg/http" "github.com/ThreeDotsLabs/watermill-routing-example/server/common" "github.com/ThreeDotsLabs/watermill/message" "github.com/go-chi/chi/v5" ) type Handler struct { storage *storage subscriber message.Subscriber publisher message.Publisher logger watermill.LoggerAdapter lastIDs map[string]int } func (h Handler) Mux() *chi.Mux { r := chi.NewRouter() fileServer(r, "/", http.Dir("./public")) sseRouter, err := watermillHTTP.NewSSERouter( watermillHTTP.SSERouterConfig{ UpstreamSubscriber: h.subscriber, }, h.logger, ) if err != nil { panic(err) } messagesHandler := sseRouter.AddHandler(common.UpdatesTopic, messagesStream{ logger: h.logger, storage: h.storage, }) r.Route("/api", func(r chi.Router) { r.Use(requestIDMiddleware) r.Get("/messages", messagesHandler) r.Post("/messages/{topic}", h.SendMessage) }) go func() { err = sseRouter.Run(context.Background()) if err != nil { panic(err) } }() <-sseRouter.Running() return r } func (h Handler) SendMessage(w http.ResponseWriter, r *http.Request) { topic := chi.URLParam(r, "topic") h.lastIDs[topic]++ msgID := fmt.Sprintf("%v", h.lastIDs[topic]) event := common.UserSignedUp{ UserID: watermill.NewUUID(), Consents: common.Consents{ Marketing: true, News: true, }, } payload, err := json.Marshal(event) if err != nil { logAndWriteError(h.logger, w, err) return } msg := message.NewMessage(msgID, payload) msg.Metadata.Set("name", "UserSignedUp") err = h.publisher.Publish(topic, msg) if err != nil { logAndWriteError(h.logger, w, err) return } w.WriteHeader(204) } type messagesStream struct { storage *storage logger watermill.LoggerAdapter } func (p messagesStream) GetResponse(w http.ResponseWriter, r *http.Request) (response interface{}, ok bool) { reqID := r.Context().Value("request_id").(string) messages := p.storage.PopAll(reqID) return messages, true } func (p messagesStream) Validate(r *http.Request, msg *message.Message) (ok bool) { var payload common.MessageReceived err := json.Unmarshal(msg.Payload, &payload) if err != nil { return false } p.storage.Append(payload) return true } func fileServer(r chi.Router, path string, root http.FileSystem) { if strings.ContainsAny(path, "{}*") { panic("FileServer does not permit URL parameters.") } fs := http.StripPrefix(path, http.FileServer(root)) if path != "/" && path[len(path)-1] != '/' { r.Get(path, http.RedirectHandler(path+"/", 301).ServeHTTP) path += "/" } path += "*" r.Get(path, func(w http.ResponseWriter, r *http.Request) { fs.ServeHTTP(w, r) }) } func logAndWriteError(logger watermill.LoggerAdapter, w http.ResponseWriter, err error) { logger.Error("Error", err, nil) w.WriteHeader(500) } func requestIDMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() requestID := watermill.NewUUID() ctx = context.WithValue(ctx, "request_id", requestID) next.ServeHTTP(w, r.WithContext(ctx)) }) } ================================================ FILE: _examples/real-world-examples/consumer-groups/api/main.go ================================================ package main import ( "context" "net/http" "sync" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill-redisstream/pkg/redisstream" "github.com/ThreeDotsLabs/watermill-routing-example/server/common" "github.com/ThreeDotsLabs/watermill/message" "github.com/ThreeDotsLabs/watermill/message/router/middleware" "github.com/redis/go-redis/v9" ) func main() { logger := watermill.NewStdLogger(false, false) router, err := message.NewRouter(message.RouterConfig{}, logger) if err != nil { panic(err) } router.AddMiddleware(middleware.Recoverer) pubClient := redis.NewClient(&redis.Options{ Addr: "redis:6379", }) publisher, err := redisstream.NewPublisher( redisstream.PublisherConfig{ Client: pubClient, }, logger, ) if err != nil { panic(err) } subClient := redis.NewClient(&redis.Options{ Addr: "redis:6379", }) subscriber, err := redisstream.NewSubscriber( redisstream.SubscriberConfig{ Client: subClient, }, logger, ) go func() { err = router.Run(context.Background()) if err != nil { panic(err) } }() <-router.Running() storage := &storage{ lock: &sync.Mutex{}, receivedMessages: map[string][]common.MessageReceived{}, } httpRouter := Handler{ storage: storage, subscriber: subscriber, publisher: publisher, logger: logger, lastIDs: map[string]int{}, } err = http.ListenAndServe(":8080", httpRouter.Mux()) if err != nil { panic(err) } } ================================================ FILE: _examples/real-world-examples/consumer-groups/api/public/index.html ================================================ Watermill Consumer Groups Example
Watermill Consumer Groups Example
================================================ FILE: _examples/real-world-examples/consumer-groups/api/storage.go ================================================ package main import ( "sync" "github.com/ThreeDotsLabs/watermill-routing-example/server/common" ) type storage struct { lock *sync.Mutex receivedMessages map[string][]common.MessageReceived } func (s *storage) Append(message common.MessageReceived) { s.lock.Lock() defer s.lock.Unlock() for k, v := range s.receivedMessages { s.receivedMessages[k] = append(v, message) } } func (s *storage) PopAll(key string) []common.MessageReceived { s.lock.Lock() defer s.lock.Unlock() if _, ok := s.receivedMessages[key]; !ok { s.receivedMessages[key] = []common.MessageReceived{} return []common.MessageReceived{} } messages := s.receivedMessages[key] s.receivedMessages[key] = []common.MessageReceived{} return messages } ================================================ FILE: _examples/real-world-examples/consumer-groups/common/events.go ================================================ package common type UserSignedUp struct { UserID string `json:"id"` Consents Consents `json:"consents"` } type Consents struct { Marketing bool `json:"marketing"` News bool `json:"news"` } type MessageReceived struct { ID string `json:"id"` Service string `json:"service"` Handler string `json:"handler"` Topic string `json:"topic"` } ================================================ FILE: _examples/real-world-examples/consumer-groups/common/messaging.go ================================================ package common import ( "encoding/json" "strings" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill/message" ) const UpdatesTopic = "updates" func NotifyMiddleware(pub message.Publisher, serviceName string) func(message.HandlerFunc) message.HandlerFunc { return func(next message.HandlerFunc) message.HandlerFunc { return func(msg *message.Message) ([]*message.Message, error) { topic := message.SubscribeTopicFromCtx(msg.Context()) handler := strings.Split(message.HandlerNameFromCtx(msg.Context()), "-")[0] msgs, err := next(msg) if err != nil { return msgs, err } payload := MessageReceived{ ID: msg.UUID, Service: serviceName, Handler: handler, Topic: topic, } jsonPayload, err := json.Marshal(payload) if err != nil { return nil, err } newMsg := message.NewMessage(watermill.NewUUID(), jsonPayload) err = pub.Publish(UpdatesTopic, newMsg) if err != nil { return nil, err } return msgs, nil } } } ================================================ FILE: _examples/real-world-examples/consumer-groups/crm-service/main.go ================================================ package main import ( "context" "encoding/json" "fmt" "os" "strings" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill-redisstream/pkg/redisstream" "github.com/ThreeDotsLabs/watermill-routing-example/server/common" "github.com/ThreeDotsLabs/watermill/components/cqrs" "github.com/ThreeDotsLabs/watermill/message" "github.com/ThreeDotsLabs/watermill/message/router/middleware" "github.com/redis/go-redis/v9" ) var ( replica = os.Getenv("REPLICA") serviceName = "crm-service" serviceNameWithReplica = serviceName + "-" + replica ) func main() { logger := watermill.NewStdLogger(false, false) router, err := message.NewRouter(message.RouterConfig{}, logger) if err != nil { panic(err) } pubClient := redis.NewClient(&redis.Options{ Addr: "redis:6379", }) publisher, err := redisstream.NewPublisher( redisstream.PublisherConfig{ Client: pubClient, }, logger, ) if err != nil { panic(err) } router.AddMiddleware(middleware.Recoverer) router.AddMiddleware(common.NotifyMiddleware(publisher, serviceNameWithReplica)) subClient := redis.NewClient(&redis.Options{ Addr: "redis:6379", }) subscriber, err := redisstream.NewSubscriber( redisstream.SubscriberConfig{ Client: subClient, ConsumerGroup: "", }, logger, ) if err != nil { panic(err) } if replica == "1" { router.AddConsumerHandler( "OnUserSignedUp-2", "UserSignedUp-2", subscriber, func(msg *message.Message) error { var event common.UserSignedUp err := json.Unmarshal(msg.Payload, &event) if err != nil { return err } fmt.Println("Adding user", event.UserID, "to the CRM") return nil }, ) } if replica == "1" { eventProc8, err := cqrs.NewEventProcessorWithConfig(router, cqrs.EventProcessorConfig{ GenerateSubscribeTopic: func(params cqrs.EventProcessorGenerateSubscribeTopicParams) (string, error) { return fmt.Sprintf("%s-8", params.EventName), nil }, SubscriberConstructor: func(params cqrs.EventProcessorSubscriberConstructorParams) (message.Subscriber, error) { handlerName := strings.Split(params.HandlerName, "-")[0] return redisstream.NewSubscriber( redisstream.SubscriberConfig{ Client: subClient, ConsumerGroup: fmt.Sprintf("%s_%s", serviceName, handlerName), }, logger, ) }, Marshaler: cqrs.JSONMarshaler{ GenerateName: cqrs.StructName, }, Logger: logger, }) if err != nil { panic(err) } err = eventProc8.AddHandlers( cqrs.NewEventHandler("AddToCRM-8", HandleCRM), cqrs.NewEventHandler("AddToSupport-8", HandleSupport), ) if err != nil { panic(err) } } eventProc9, err := cqrs.NewEventProcessorWithConfig(router, cqrs.EventProcessorConfig{ GenerateSubscribeTopic: func(params cqrs.EventProcessorGenerateSubscribeTopicParams) (string, error) { return fmt.Sprintf("%s-9", params.EventName), nil }, SubscriberConstructor: func(params cqrs.EventProcessorSubscriberConstructorParams) (message.Subscriber, error) { handlerName := strings.Split(params.HandlerName, "-")[0] return redisstream.NewSubscriber( redisstream.SubscriberConfig{ Client: subClient, ConsumerGroup: fmt.Sprintf("%s_%s", serviceName, handlerName), }, logger, ) }, Marshaler: cqrs.JSONMarshaler{ GenerateName: cqrs.StructName, }, Logger: logger, }) if err != nil { panic(err) } err = eventProc9.AddHandlers( cqrs.NewEventHandler("AddToCRM-9", HandleCRM), cqrs.NewEventHandler("AddToSupport-9", HandleSupport), ) if err != nil { panic(err) } err = router.Run(context.Background()) if err != nil { panic(err) } } func HandleCRM(ctx context.Context, e *common.UserSignedUp) error { fmt.Println("Adding user", e.UserID, "to the CRM") return nil } func HandleSupport(ctx context.Context, e *common.UserSignedUp) error { fmt.Println("Adding user", e.UserID, "to the support channel") return nil } ================================================ FILE: _examples/real-world-examples/consumer-groups/docker-compose.yml ================================================ services: api: image: golang:1.25 restart: unless-stopped depends_on: - redis volumes: - .:/app working_dir: /app/api ports: - "8080:8080" command: go run . newsletter-1: image: golang:1.25 restart: unless-stopped depends_on: - redis volumes: - .:/app working_dir: /app/newsletter-service command: go run . environment: REPLICA: 1 newsletter-2: image: golang:1.25 restart: unless-stopped depends_on: - redis volumes: - .:/app working_dir: /app/newsletter-service command: go run . environment: REPLICA: 2 crm-1: image: golang:1.25 restart: unless-stopped depends_on: - redis volumes: - .:/app working_dir: /app/crm-service command: go run . environment: REPLICA: 1 crm-2: image: golang:1.25 restart: unless-stopped depends_on: - redis volumes: - .:/app working_dir: /app/crm-service command: go run . environment: REPLICA: 2 redis: image: redis:7 ports: - "6379:6379" restart: unless-stopped ================================================ FILE: _examples/real-world-examples/consumer-groups/go.mod ================================================ module github.com/ThreeDotsLabs/watermill-routing-example/server go 1.25 require ( github.com/ThreeDotsLabs/watermill v1.5.1 github.com/ThreeDotsLabs/watermill-http v1.1.4 github.com/go-chi/chi/v5 v5.2.3 github.com/go-chi/render v1.0.3 // indirect github.com/google/uuid v1.6.0 // indirect ) require ( github.com/ThreeDotsLabs/watermill-redisstream v1.4.4 github.com/redis/go-redis/v9 v9.12.1 ) require ( github.com/Rican7/retry v0.3.1 // indirect github.com/ajg/form v1.5.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/go-chi/chi v4.1.2+incompatible // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/lithammer/shortuuid/v3 v3.0.7 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/sony/gobreaker v1.0.0 // indirect github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect google.golang.org/appengine v1.6.8 // indirect google.golang.org/protobuf v1.36.8 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) ================================================ FILE: _examples/real-world-examples/consumer-groups/go.sum ================================================ github.com/Rican7/retry v0.3.1 h1:scY4IbO8swckzoA/11HgBwaZRJEyY9vaNJshcdhp1Mc= github.com/Rican7/retry v0.3.1/go.mod h1:CxSDrhAyXmTMeEuRAnArMu1FHu48vtfjLREWqVl7Vw0= github.com/ThreeDotsLabs/watermill v1.1.0/go.mod h1:Qd1xNFxolCAHCzcMrm6RnjW0manbvN+DJVWc1MWRFlI= github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY= github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892LARrKAdki6WtXS4= github.com/ThreeDotsLabs/watermill-http v1.1.4 h1:wRM54z/BPnIWjGbXMrOnwOlrCAESzoSNxTAHiLysFA4= github.com/ThreeDotsLabs/watermill-http v1.1.4/go.mod h1:mkQ9CC0pxTZerNwr281rBoOy355vYt/lePkmYSX/BRg= github.com/ThreeDotsLabs/watermill-redisstream v1.4.4 h1:vkpSm2MZHacjN4H8R0PA9IKQ++uQMq6wA0m1bnGjipo= github.com/ThreeDotsLabs/watermill-redisstream v1.4.4/go.mod h1:Da3wqG1OcvHPODjuJcxSCY1O7D4loIZQpVbZ5u94xRo= github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/cenkalti/backoff/v3 v3.0.0/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4rc0ij+ULvLYs= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/go-chi/chi v4.0.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyNz34tQRec= github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-chi/render v1.0.1/go.mod h1:pq4Rr7HbnsdaeHagklXub+p6Wd16Af5l9koip1OvJns= github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4= github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lithammer/shortuuid/v3 v3.0.4/go.mod h1:RviRjexKqIzx/7r1peoAITm6m7gnif/h+0zmolKJjzw= github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/redis/go-redis/v9 v9.12.1 h1:k5iquqv27aBtnTm2tIkROUDp8JBXhXZIVu1InSgvovg= github.com/redis/go-redis/v9 v9.12.1/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ= github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: _examples/real-world-examples/consumer-groups/newsletter-service/main.go ================================================ package main import ( "context" "encoding/json" "fmt" "os" "strings" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill-redisstream/pkg/redisstream" "github.com/ThreeDotsLabs/watermill-routing-example/server/common" "github.com/ThreeDotsLabs/watermill/components/cqrs" "github.com/ThreeDotsLabs/watermill/message" "github.com/ThreeDotsLabs/watermill/message/router/middleware" "github.com/redis/go-redis/v9" ) var ( replica = os.Getenv("REPLICA") serviceName = "newsletter-service" serviceNameWithReplica = serviceName + "-" + replica ) func main() { logger := watermill.NewStdLogger(false, false) router, err := message.NewRouter(message.RouterConfig{}, logger) if err != nil { panic(err) } pubClient := redis.NewClient(&redis.Options{ Addr: "redis:6379", }) publisher, err := redisstream.NewPublisher( redisstream.PublisherConfig{ Client: pubClient, }, logger, ) if err != nil { panic(err) } router.AddMiddleware(middleware.Recoverer) router.AddMiddleware(common.NotifyMiddleware(publisher, serviceNameWithReplica)) subClient := redis.NewClient(&redis.Options{ Addr: "redis:6379", }) subscriber, err := redisstream.NewSubscriber( redisstream.SubscriberConfig{ Client: subClient, ConsumerGroup: "", }, logger, ) if err != nil { panic(err) } newsletterServiceGroupSubscriber, err := redisstream.NewSubscriber( redisstream.SubscriberConfig{ Client: subClient, ConsumerGroup: "newsletter-service", }, logger, ) if err != nil { panic(err) } addToPromotionsListGroupSubscriber, err := redisstream.NewSubscriber( redisstream.SubscriberConfig{ Client: subClient, ConsumerGroup: "AddToPromotionsList", }, logger, ) if err != nil { panic(err) } addToNewsListGroupSubscriber, err := redisstream.NewSubscriber( redisstream.SubscriberConfig{ Client: subClient, ConsumerGroup: "AddToNewsList", }, logger, ) if err != nil { panic(err) } if replica == "1" { router.AddConsumerHandler( "OnUserSignedUp-1", "UserSignedUp-1", subscriber, func(msg *message.Message) error { var event common.UserSignedUp err := json.Unmarshal(msg.Payload, &event) if err != nil { return err } if !event.Consents.Marketing { return nil } fmt.Println("Adding user", event.UserID, "to the promotions list") return nil }, ) router.AddConsumerHandler( "OnUserSignedUp-2", "UserSignedUp-2", subscriber, func(msg *message.Message) error { var event common.UserSignedUp err := json.Unmarshal(msg.Payload, &event) if err != nil { return err } if !event.Consents.Marketing { return nil } fmt.Println("Adding user", event.UserID, "to the promotions list") return nil }, ) router.AddConsumerHandler( "AddToPromotionsList-5", "UserSignedUp-5", newsletterServiceGroupSubscriber, func(msg *message.Message) error { var event common.UserSignedUp err := json.Unmarshal(msg.Payload, &event) if err != nil { return err } if !event.Consents.Marketing { return nil } fmt.Println("Adding user", event.UserID, "to the promotions list") return nil }, ) router.AddConsumerHandler( "AddToNewsList-5", "UserSignedUp-5", newsletterServiceGroupSubscriber, func(msg *message.Message) error { var event common.UserSignedUp err := json.Unmarshal(msg.Payload, &event) if err != nil { return err } if !event.Consents.News { return nil } fmt.Println("Adding user", event.UserID, "to the news list") return nil }, ) router.AddConsumerHandler( "AddToPromotionsList-6", "UserSignedUp-6", addToPromotionsListGroupSubscriber, func(msg *message.Message) error { var event common.UserSignedUp err := json.Unmarshal(msg.Payload, &event) if err != nil { return err } if !event.Consents.Marketing { return nil } fmt.Println("Adding user", event.UserID, "to the promotions list") return nil }, ) router.AddConsumerHandler( "AddToNewsList-6", "UserSignedUp-6", addToNewsListGroupSubscriber, func(msg *message.Message) error { var event common.UserSignedUp err := json.Unmarshal(msg.Payload, &event) if err != nil { return err } if !event.Consents.News { return nil } fmt.Println("Adding user", event.UserID, "to the news list") return nil }, ) } router.AddConsumerHandler( "OnUserSignedUp-3", "UserSignedUp-3", subscriber, func(msg *message.Message) error { var event common.UserSignedUp err := json.Unmarshal(msg.Payload, &event) if err != nil { return err } if !event.Consents.Marketing { return nil } fmt.Println("Adding user", event.UserID, "to the promotions list") return nil }, ) router.AddConsumerHandler( "OnUserSignedUp-4", "UserSignedUp-4", newsletterServiceGroupSubscriber, func(msg *message.Message) error { var event common.UserSignedUp err := json.Unmarshal(msg.Payload, &event) if err != nil { return err } if !event.Consents.Marketing { return nil } fmt.Println("Adding user", event.UserID, "to the promotions list") return nil }, ) router.AddConsumerHandler( "AddToPromotionsList-7", "UserSignedUp-7", addToPromotionsListGroupSubscriber, func(msg *message.Message) error { var event common.UserSignedUp err := json.Unmarshal(msg.Payload, &event) if err != nil { return err } if !event.Consents.Marketing { return nil } fmt.Println("Adding user", event.UserID, "to the promotions list") return nil }, ) router.AddConsumerHandler( "AddToNewsList-7", "UserSignedUp-7", addToNewsListGroupSubscriber, func(msg *message.Message) error { var event common.UserSignedUp err := json.Unmarshal(msg.Payload, &event) if err != nil { return err } if !event.Consents.News { return nil } fmt.Println("Adding user", event.UserID, "to the news list") return nil }, ) if replica == "1" { eventProc8, err := cqrs.NewEventProcessorWithConfig(router, cqrs.EventProcessorConfig{ GenerateSubscribeTopic: func(params cqrs.EventProcessorGenerateSubscribeTopicParams) (string, error) { return fmt.Sprintf("%s-8", params.EventName), nil }, SubscriberConstructor: func(params cqrs.EventProcessorSubscriberConstructorParams) (message.Subscriber, error) { handlerName := strings.Split(params.HandlerName, "-")[0] return redisstream.NewSubscriber( redisstream.SubscriberConfig{ Client: subClient, ConsumerGroup: fmt.Sprintf("%s_%s", serviceName, handlerName), }, logger, ) }, Marshaler: cqrs.JSONMarshaler{ GenerateName: cqrs.StructName, }, Logger: logger, }) if err != nil { panic(err) } err = eventProc8.AddHandlers( cqrs.NewEventHandler("AddToPromotionsList-8", HandlePromotions), cqrs.NewEventHandler("AddToNewsList-8", HandleNews), ) if err != nil { panic(err) } } eventProc9, err := cqrs.NewEventProcessorWithConfig(router, cqrs.EventProcessorConfig{ GenerateSubscribeTopic: func(params cqrs.EventProcessorGenerateSubscribeTopicParams) (string, error) { return fmt.Sprintf("%s-9", params.EventName), nil }, SubscriberConstructor: func(params cqrs.EventProcessorSubscriberConstructorParams) (message.Subscriber, error) { handlerName := strings.Split(params.HandlerName, "-")[0] return redisstream.NewSubscriber( redisstream.SubscriberConfig{ Client: subClient, ConsumerGroup: fmt.Sprintf("%s_%s", serviceName, handlerName), }, logger, ) }, Marshaler: cqrs.JSONMarshaler{ GenerateName: cqrs.StructName, }, Logger: logger, }) if err != nil { panic(err) } err = eventProc9.AddHandlers( cqrs.NewEventHandler("AddToPromotionsList-9", HandlePromotions), cqrs.NewEventHandler("AddToNewsList-9", HandleNews), ) if err != nil { panic(err) } err = router.Run(context.Background()) if err != nil { panic(err) } } func HandleNews(ctx context.Context, e *common.UserSignedUp) error { if !e.Consents.News { return nil } fmt.Println("Adding user", e.UserID, "to the news list") return nil } func HandlePromotions(ctx context.Context, e *common.UserSignedUp) error { if !e.Consents.Marketing { return nil } fmt.Println("Adding user", e.UserID, "to the promotions list") return nil } ================================================ FILE: _examples/real-world-examples/delayed-messages/docker-compose.yml ================================================ services: server: image: golang:1.25 restart: unless-stopped volumes: - .:/app - $GOPATH/pkg/mod:/go/pkg/mod working_dir: /app command: go run main.go redis: image: redis:7 ports: - 6379:6379 restart: unless-stopped postgres: image: postgres:15 restart: unless-stopped ports: - 5432:5432 environment: POSTGRES_USER: watermill POSTGRES_DB: watermill POSTGRES_PASSWORD: "password" ================================================ FILE: _examples/real-world-examples/delayed-messages/go.mod ================================================ module delayed-messages go 1.25 require ( github.com/ThreeDotsLabs/watermill v1.5.1 github.com/ThreeDotsLabs/watermill-redisstream v1.4.4 github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0 github.com/brianvoe/gofakeit/v6 v6.28.0 github.com/google/uuid v1.6.0 github.com/lib/pq v1.10.9 github.com/redis/go-redis/v9 v9.12.1 ) require ( github.com/Rican7/retry v0.3.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.7.5 // indirect github.com/lithammer/shortuuid/v3 v3.0.7 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/sony/gobreaker v1.0.0 // indirect github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect golang.org/x/crypto v0.41.0 // indirect golang.org/x/text v0.28.0 // indirect google.golang.org/appengine v1.6.8 // indirect google.golang.org/protobuf v1.36.8 // indirect ) ================================================ FILE: _examples/real-world-examples/delayed-messages/go.sum ================================================ github.com/Rican7/retry v0.3.1 h1:scY4IbO8swckzoA/11HgBwaZRJEyY9vaNJshcdhp1Mc= github.com/Rican7/retry v0.3.1/go.mod h1:CxSDrhAyXmTMeEuRAnArMu1FHu48vtfjLREWqVl7Vw0= github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY= github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892LARrKAdki6WtXS4= github.com/ThreeDotsLabs/watermill-redisstream v1.4.4 h1:vkpSm2MZHacjN4H8R0PA9IKQ++uQMq6wA0m1bnGjipo= github.com/ThreeDotsLabs/watermill-redisstream v1.4.4/go.mod h1:Da3wqG1OcvHPODjuJcxSCY1O7D4loIZQpVbZ5u94xRo= github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0 h1:vd0eoMPriNjiFTEo7tQ1wmN933lNAWyVhxX931JG5Pw= github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0/go.mod h1:Ce2GVZVnyajAh0AkwxSJXwx8ajBBveu1DI/yatan5jc= github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4= github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/redis/go-redis/v9 v9.12.1 h1:k5iquqv27aBtnTm2tIkROUDp8JBXhXZIVu1InSgvovg= github.com/redis/go-redis/v9 v9.12.1/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ= github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: _examples/real-world-examples/delayed-messages/main.go ================================================ package main import ( "context" stdSQL "database/sql" "fmt" "strings" "time" "github.com/brianvoe/gofakeit/v6" "github.com/google/uuid" _ "github.com/lib/pq" "github.com/redis/go-redis/v9" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill-redisstream/pkg/redisstream" "github.com/ThreeDotsLabs/watermill-sql/v4/pkg/sql" "github.com/ThreeDotsLabs/watermill/components/cqrs" "github.com/ThreeDotsLabs/watermill/components/delay" "github.com/ThreeDotsLabs/watermill/components/forwarder" "github.com/ThreeDotsLabs/watermill/message" ) func main() { db, err := stdSQL.Open("postgres", "postgres://watermill:password@postgres:5432/watermill?sslmode=disable") if err != nil { panic(err) } logger := watermill.NewStdLogger(false, false) redisClient := redis.NewClient(&redis.Options{Addr: "redis:6379"}) marshaler := cqrs.JSONMarshaler{ GenerateName: cqrs.StructName, } redisPublisher, err := redisstream.NewPublisher(redisstream.PublisherConfig{ Client: redisClient, }, logger) if err != nil { panic(err) } var sqlPublisher message.Publisher sqlPublisher, err = sql.NewDelayedPostgreSQLPublisher(db, sql.DelayedPostgreSQLPublisherConfig{ DelayPublisherConfig: delay.PublisherConfig{}, Logger: logger, }) if err != nil { panic(err) } sqlPublisher = forwarder.NewPublisher(sqlPublisher, forwarder.PublisherConfig{ ForwarderTopic: "forwarder", }) eventBus, err := cqrs.NewEventBusWithConfig(redisPublisher, cqrs.EventBusConfig{ GeneratePublishTopic: func(params cqrs.GenerateEventPublishTopicParams) (string, error) { return params.EventName, nil }, Marshaler: marshaler, Logger: logger, }) if err != nil { panic(err) } commandBus, err := cqrs.NewCommandBusWithConfig(sqlPublisher, cqrs.CommandBusConfig{ GeneratePublishTopic: func(params cqrs.CommandBusGeneratePublishTopicParams) (string, error) { return params.CommandName, nil }, Marshaler: marshaler, Logger: logger, }) if err != nil { panic(err) } router := message.NewDefaultRouter(logger) eventProcessor, err := cqrs.NewEventProcessorWithConfig(router, cqrs.EventProcessorConfig{ GenerateSubscribeTopic: func(params cqrs.EventProcessorGenerateSubscribeTopicParams) (string, error) { return params.EventName, nil }, SubscriberConstructor: func(params cqrs.EventProcessorSubscriberConstructorParams) (message.Subscriber, error) { return redisstream.NewSubscriber(redisstream.SubscriberConfig{ Client: redisClient, ConsumerGroup: params.HandlerName, }, logger) }, Marshaler: marshaler, Logger: logger, }) if err != nil { panic(err) } commandProcessor, err := cqrs.NewCommandProcessorWithConfig(router, cqrs.CommandProcessorConfig{ GenerateSubscribeTopic: func(params cqrs.CommandProcessorGenerateSubscribeTopicParams) (string, error) { return params.CommandName, nil }, SubscriberConstructor: func(params cqrs.CommandProcessorSubscriberConstructorParams) (message.Subscriber, error) { return redisstream.NewSubscriber(redisstream.SubscriberConfig{ Client: redisClient, ConsumerGroup: params.HandlerName, }, logger) }, Marshaler: marshaler, Logger: logger, }) if err != nil { panic(err) } err = eventProcessor.AddHandlers( cqrs.NewEventHandler( "OnOrderPlacedHandler", func(ctx context.Context, event *OrderPlaced) error { fmt.Printf("💰 Received order from %v <%v>\n", event.Customer.Name, event.Customer.Email) cmd := SendFeedbackForm{ To: event.Customer.Email, Name: event.Customer.Name, } // In a real world scenario, we would delay the command by a few days ctx = delay.WithContext(ctx, delay.For(8*time.Second)) err := commandBus.Send(ctx, cmd) if err != nil { return err } return nil }, ), ) if err != nil { panic(err) } err = commandProcessor.AddHandlers( cqrs.NewCommandHandler( "OnSendFeedbackForm", func(ctx context.Context, cmd *SendFeedbackForm) error { fmt.Printf("📧 Sending feedback form to %v <%v>\n", cmd.Name, cmd.To) // In a real world scenario, we would send an email to the customer here return nil }, ), ) if err != nil { panic(err) } sqlSubscriber, err := sql.NewDelayedPostgreSQLSubscriber(db, sql.DelayedPostgreSQLSubscriberConfig{ DeleteOnAck: true, Logger: logger, }) if err != nil { panic(err) } _, err = forwarder.NewForwarder( sqlSubscriber, redisPublisher, logger, forwarder.Config{ ForwarderTopic: "forwarder", Router: router, }, ) if err != nil { panic(err) } go func() { err = router.Run(context.Background()) if err != nil { panic(err) } }() <-router.Running() for { name := gofakeit.FirstName() e := OrderPlaced{ OrderID: uuid.NewString(), Customer: Customer{ Name: name, Email: fmt.Sprintf("%v@%v", strings.ToLower(name), gofakeit.DomainName()), }, } err = eventBus.Publish(context.Background(), e) if err != nil { panic(err) } time.Sleep(5 * time.Second) } } type Customer struct { Name string `json:"name"` Email string `json:"email"` } type OrderPlaced struct { OrderID string `json:"order_id"` Customer Customer `json:"customer"` } type SendFeedbackForm struct { To string `json:"to"` Name string `json:"name"` } ================================================ FILE: _examples/real-world-examples/delayed-requeue/docker-compose.yml ================================================ services: server: image: golang:1.25 restart: unless-stopped volumes: - .:/app - $GOPATH/pkg/mod:/go/pkg/mod working_dir: /app command: go run main.go redis: image: redis:7 ports: - 6379:6379 restart: unless-stopped postgres: image: postgres:15 restart: unless-stopped ports: - 5432:5432 environment: POSTGRES_USER: watermill POSTGRES_DB: watermill POSTGRES_PASSWORD: "password" ================================================ FILE: _examples/real-world-examples/delayed-requeue/go.mod ================================================ module delayed-requeue go 1.25 require ( github.com/ThreeDotsLabs/watermill v1.5.1 github.com/ThreeDotsLabs/watermill-redisstream v1.4.4 github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0 github.com/brianvoe/gofakeit/v6 v6.28.0 github.com/lib/pq v1.10.9 github.com/redis/go-redis/v9 v9.12.1 ) require ( github.com/Rican7/retry v0.3.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.7.5 // indirect github.com/lithammer/shortuuid/v3 v3.0.7 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/sony/gobreaker v1.0.0 // indirect github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect golang.org/x/crypto v0.41.0 // indirect golang.org/x/text v0.28.0 // indirect google.golang.org/appengine v1.6.8 // indirect google.golang.org/protobuf v1.36.8 // indirect ) ================================================ FILE: _examples/real-world-examples/delayed-requeue/go.sum ================================================ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/Rican7/retry v0.3.1 h1:scY4IbO8swckzoA/11HgBwaZRJEyY9vaNJshcdhp1Mc= github.com/Rican7/retry v0.3.1/go.mod h1:CxSDrhAyXmTMeEuRAnArMu1FHu48vtfjLREWqVl7Vw0= github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY= github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892LARrKAdki6WtXS4= github.com/ThreeDotsLabs/watermill-redisstream v1.4.4 h1:vkpSm2MZHacjN4H8R0PA9IKQ++uQMq6wA0m1bnGjipo= github.com/ThreeDotsLabs/watermill-redisstream v1.4.4/go.mod h1:Da3wqG1OcvHPODjuJcxSCY1O7D4loIZQpVbZ5u94xRo= github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0 h1:vd0eoMPriNjiFTEo7tQ1wmN933lNAWyVhxX931JG5Pw= github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0/go.mod h1:Ce2GVZVnyajAh0AkwxSJXwx8ajBBveu1DI/yatan5jc= github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4= github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/redis/go-redis/v9 v9.12.1 h1:k5iquqv27aBtnTm2tIkROUDp8JBXhXZIVu1InSgvovg= github.com/redis/go-redis/v9 v9.12.1/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ= github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: _examples/real-world-examples/delayed-requeue/main.go ================================================ package main import ( "context" stdSQL "database/sql" "fmt" "math/rand" "time" "github.com/ThreeDotsLabs/watermill/message/router/middleware" "github.com/brianvoe/gofakeit/v6" _ "github.com/lib/pq" "github.com/redis/go-redis/v9" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill-redisstream/pkg/redisstream" "github.com/ThreeDotsLabs/watermill-sql/v4/pkg/sql" "github.com/ThreeDotsLabs/watermill/components/cqrs" "github.com/ThreeDotsLabs/watermill/message" ) func main() { db, err := stdSQL.Open("postgres", "postgres://watermill:password@postgres:5432/watermill?sslmode=disable") if err != nil { panic(err) } logger := watermill.NewStdLogger(false, false) redisClient := redis.NewClient(&redis.Options{Addr: "redis:6379"}) redisPublisher, err := redisstream.NewPublisher(redisstream.PublisherConfig{ Client: redisClient, }, logger) if err != nil { panic(err) } delayedRequeuer, err := sql.NewPostgreSQLDelayedRequeuer(sql.DelayedRequeuerConfig{ DB: db, Publisher: redisPublisher, DelayOnError: &middleware.DelayOnError{ InitialInterval: 10 * time.Second, MaxInterval: 3 * time.Minute, Multiplier: 2, }, Logger: logger, }) if err != nil { panic(err) } marshaler := cqrs.JSONMarshaler{ GenerateName: cqrs.StructName, } eventBus, err := cqrs.NewEventBusWithConfig(redisPublisher, cqrs.EventBusConfig{ GeneratePublishTopic: func(params cqrs.GenerateEventPublishTopicParams) (string, error) { return params.EventName, nil }, Marshaler: marshaler, Logger: logger, }) if err != nil { panic(err) } router := message.NewDefaultRouter(logger) router.AddMiddleware(delayedRequeuer.Middleware()...) eventProcessor, err := cqrs.NewEventProcessorWithConfig(router, cqrs.EventProcessorConfig{ GenerateSubscribeTopic: func(params cqrs.EventProcessorGenerateSubscribeTopicParams) (string, error) { return params.EventName, nil }, SubscriberConstructor: func(params cqrs.EventProcessorSubscriberConstructorParams) (message.Subscriber, error) { return redisstream.NewSubscriber(redisstream.SubscriberConfig{ Client: redisClient, ConsumerGroup: params.HandlerName, }, logger) }, Marshaler: marshaler, Logger: logger, }) if err != nil { panic(err) } err = eventProcessor.AddHandlers( cqrs.NewEventHandler( "OnOrderPlacedHandler", func(ctx context.Context, event *OrderPlaced) error { if event.OrderID == "" { fmt.Println("ERROR: Received order placed without order_id") return fmt.Errorf("empty order_id") } fmt.Println("Received order placed:", event.OrderID) return nil }, ), ) if err != nil { panic(err) } go func() { err = delayedRequeuer.Run(context.Background()) if err != nil { panic(err) } }() go func() { err = router.Run(context.Background()) if err != nil { panic(err) } }() <-router.Running() i := 0 for { e := newFakeOrderPlaced() i++ if i == 10 { e.OrderID = "" i = 0 } err = eventBus.Publish(context.Background(), e) if err != nil { panic(err) } time.Sleep(1 * time.Second) } } func newFakeOrderPlaced() OrderPlaced { var products []Product for i := 0; i < rand.Intn(5)+1; i++ { products = append(products, Product{ ID: watermill.NewShortUUID(), Name: gofakeit.ProductName(), }) } return OrderPlaced{ OrderID: watermill.NewUUID(), Customer: Customer{ ID: watermill.NewULID(), Name: gofakeit.Name(), Email: gofakeit.Email(), Phone: gofakeit.Phone(), }, Address: Address{ Street: gofakeit.Street(), City: gofakeit.City(), Zip: gofakeit.Zip(), Country: gofakeit.Country(), }, Products: products, } } type OrderPlaced struct { OrderID string `json:"order_id"` Customer Customer `json:"customer"` Address Address `json:"address"` Products []Product `json:"products"` } type Customer struct { ID string `json:"id"` Name string `json:"name"` Email string `json:"email"` Phone string `json:"phone"` } type Address struct { Street string `json:"street"` City string `json:"city"` Zip string `json:"zip"` Country string `json:"country"` } type Product struct { ID string `json:"id"` Name string `json:"name"` } ================================================ FILE: _examples/real-world-examples/exactly-once-delivery-counter/README.md ================================================ # Exactly-once delivery counter Is exactly-once delivery impossible? Well, it depends a lot on the definition of exactly-once delivery. When we assume we want to avoid the situation when a message is delivered more than once when our broker or worker died -- it's possible. I'll say more, it's even possible with Watermill! ![](./at-least-once-delivery.jpg) *At-least once delivery - this is not what we want!* There are just two constraints: 1. you need to use a Pub/Sub implementation that does support exactly-once delivery (only [MySQL/PostgreSQL](https://github.com/ThreeDotsLabs/watermill-sql) for now), 2. writes need to go to the same DB. In practice, our model is pretty similar to how does it work with Kafka exactly-once delivery. If you want to know more details, you can check [their article](https://www.confluent.io/blog/exactly-once-semantics-are-possible-heres-how-apache-kafka-does-it/). In our example, we use a MySQL database to implement a **simple counter**. It can be triggered by calling the `http://localhost:8080/count/{counterUUID}` endpoint. Calling this endpoint will publish a message to MySQL via our [Pub/Sub implementation](https://github.com/ThreeDotsLabs/watermill-sql). The endpoint is provided by [server/main.go](server/main.go). Later, the message is consumed by [worker/main.go](worker/main.go). The only responsibility of the worker is to update the counter in the MySQL database. **Counter update is done in the same transaction as message consumption.** Normally, we would need to de-duplicate messages. But thanks to that fact and [A.C.I.D](https://en.wikipedia.org/wiki/ACID) even if server, worker or network failure happens during processing our data will stay consistent. ![](./architecture.jpg) *Watermill's exactly-once delivery* To check if the created code works, I've created a small `run.go` program, that sends 10k requests to the server and verifies if the count at the end is equal to 10k. But to not make it too easy, I'm restarting the worker and MySQL a couple of times. I also forgot about graceful shutdown in my worker. ;-) The biggest downside of this approach is performance. Due to [our benchmark](https://github.com/ThreeDotsLabs/watermill-benchmark#sql-mysql), MySQL subscriber can consume up to 154 messages per second. Fortunately, it's still 13,305,600 messages per day. It's more than enough for a lot of systems. ## Running docker-compose up go run run.go *Please note that `run.go` needs to be executed by a user having privileges to manage Docker. It's due to the fact that `run.go` is restarting containers.* ================================================ FILE: _examples/real-world-examples/exactly-once-delivery-counter/docker-compose.yml ================================================ services: server: image: golang:1.25 restart: unless-stopped ports: - 8080:8080 volumes: - ./server:/app - $GOPATH/pkg/mod:/go/pkg/mod working_dir: /app command: 'go run .' worker: image: golang:1.25 restart: unless-stopped volumes: - ./worker:/app - $GOPATH/pkg/mod:/go/pkg/mod working_dir: /app command: 'go run .' mysql: image: mysql:8.0 restart: unless-stopped ports: - 3306:3306 environment: MYSQL_DATABASE: example MYSQL_ALLOW_EMPTY_PASSWORD: "yes" volumes: - ./schema.sql:/docker-entrypoint-initdb.d/schema.sql ================================================ FILE: _examples/real-world-examples/exactly-once-delivery-counter/run.go ================================================ package main import ( stdSQL "database/sql" "fmt" "net/http" "os/exec" "sync" "time" "github.com/cheggaaa/pb/v3" "github.com/go-sql-driver/mysql" "github.com/google/uuid" ) const messagesCount = 5000 // at these messages we will restart MySQL var restartMySQLAt = map[int]struct{}{ 50: {}, 1000: {}, 1500: {}, 3000: {}, } // at these messages we will restart counter worker var restartWorkerAt = map[int]struct{}{ 100: {}, 1500: {}, 1600: {}, 3000: {}, } const senderGoroutines = 5 func main() { db := createDB() counterUUID := uuid.New().String() wg := &sync.WaitGroup{} wg.Add(messagesCount) bar := pb.StartNew(messagesCount) // sending value to sendCounter counter HTTP call sendCounter := make(chan struct{}, 0) go func() { for i := 0; i < messagesCount; i++ { sendCounter <- struct{}{} // let's challenge exactly-once delivery a bit // normally it should trigger re-delivery of the message if _, ok := restartMySQLAt[i]; ok { restartMySQL() } if _, ok := restartWorkerAt[i]; ok { restartWorker() } } close(sendCounter) }() for i := 0; i < senderGoroutines; i++ { go func() { for range sendCounter { sendCountRequest(counterUUID) wg.Done() bar.Increment() } }() } wg.Wait() bar.Finish() timeout := time.Now().Add(time.Second * 30) fmt.Println("checking counter with DB, expected count:", messagesCount) matchedOnce := true for { if time.Now().After(timeout) { fmt.Println("timeout") break } dbCounterValue, err := getDbCounterValue(db, counterUUID) if err != nil { fmt.Println("err:", err) continue } fmt.Println("db counter value", dbCounterValue) if dbCounterValue == messagesCount { if !matchedOnce { // let's ensure that nothing new will arrive matchedOnce = true time.Sleep(time.Second * 2) continue } else { fmt.Println("expected counter value is matching DB value") break } } time.Sleep(time.Second) } } func getDbCounterValue(db *stdSQL.DB, counterUUID string) (int, error) { var dbCounterValue int row := db.QueryRow("SELECT value from counter WHERE id = ?", counterUUID) if err := row.Scan(&dbCounterValue); err != nil { return 0, err } return dbCounterValue, nil } func restartWorker() { fmt.Println("restarting worker") err := exec.Command("docker-compose", "restart", "worker").Run() if err != nil { fmt.Println("restarting worker failed", err) } } func restartMySQL() { fmt.Println("restarting mysql") err := exec.Command("docker-compose", "restart", "mysql").Run() if err != nil { fmt.Println("restarting mysql failed", err) } } func sendCountRequest(counterUUID string) { for { resp, err := http.Post("http://localhost:8080/count/"+counterUUID, "", nil) if err != nil { continue } if resp.StatusCode == http.StatusNoContent { break } } } func createDB() *stdSQL.DB { conf := mysql.NewConfig() conf.Net = "tcp" conf.User = "root" conf.Addr = "localhost" conf.DBName = "example" db, err := stdSQL.Open("mysql", conf.FormatDSN()) if err != nil { panic(err) } err = db.Ping() if err != nil { panic(err) } return db } ================================================ FILE: _examples/real-world-examples/exactly-once-delivery-counter/schema.sql ================================================ CREATE TABLE counter ( id VARCHAR(36) NOT NULL UNIQUE, value int NOT NULL ); ================================================ FILE: _examples/real-world-examples/exactly-once-delivery-counter/server/go.mod ================================================ module exactly-once-delivery go 1.25 require ( github.com/ThreeDotsLabs/watermill v1.5.1 github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0 github.com/go-chi/chi/v5 v5.2.3 github.com/go-sql-driver/mysql v1.9.3 ) require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.7.5 // indirect github.com/lib/pq v1.10.9 // indirect github.com/lithammer/shortuuid/v3 v3.0.7 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/sony/gobreaker v1.0.0 // indirect golang.org/x/crypto v0.41.0 // indirect golang.org/x/text v0.28.0 // indirect ) ================================================ FILE: _examples/real-world-examples/exactly-once-delivery-counter/server/go.sum ================================================ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY= github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892LARrKAdki6WtXS4= github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0 h1:vd0eoMPriNjiFTEo7tQ1wmN933lNAWyVhxX931JG5Pw= github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0/go.mod h1:Ce2GVZVnyajAh0AkwxSJXwx8ajBBveu1DI/yatan5jc= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ= github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: _examples/real-world-examples/exactly-once-delivery-counter/server/main.go ================================================ package main import ( stdSQL "database/sql" "encoding/json" "log" "net/http" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill-sql/v4/pkg/sql" "github.com/ThreeDotsLabs/watermill/message" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" driver "github.com/go-sql-driver/mysql" ) const topic = "counter" func main() { db := createDB() logger := watermill.NewStdLogger(false, false) r := chi.NewRouter() r.Use(middleware.Recoverer) r.Use(middleware.Logger) publisher, err := sql.NewPublisher( sql.BeginnerFromStdSQL(db), sql.PublisherConfig{ SchemaAdapter: sql.DefaultMySQLSchema{}, }, logger, ) if err != nil { panic(err) } r.Post("/count/{counterUUID}", func(w http.ResponseWriter, r *http.Request) { payload, err := json.Marshal(messagePayload{ CounterUUID: chi.URLParam(r, "counterUUID"), }) if err != nil { log.Print(err) w.WriteHeader(http.StatusInternalServerError) return } msg := message.NewMessage(watermill.NewUUID(), payload) if err := publisher.Publish(topic, msg); err != nil { log.Print(err) w.WriteHeader(http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) }) err = http.ListenAndServe(":8080", r) if err != nil { panic(err) } } type messagePayload struct { CounterUUID string `json:"counter_uuid"` } func createDB() *stdSQL.DB { conf := driver.NewConfig() conf.Net = "tcp" conf.User = "root" conf.Addr = "mysql" conf.DBName = "example" db, err := stdSQL.Open("mysql", conf.FormatDSN()) if err != nil { panic(err) } err = db.Ping() if err != nil { panic(err) } return db } ================================================ FILE: _examples/real-world-examples/exactly-once-delivery-counter/worker/go.mod ================================================ module exactly-once-delivery go 1.25 require ( github.com/ThreeDotsLabs/watermill v1.5.1 github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0 github.com/go-sql-driver/mysql v1.9.3 github.com/pkg/errors v0.9.1 ) require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.7.5 // indirect github.com/lib/pq v1.10.9 // indirect github.com/lithammer/shortuuid/v3 v3.0.7 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/sony/gobreaker v1.0.0 // indirect golang.org/x/crypto v0.41.0 // indirect golang.org/x/text v0.28.0 // indirect ) ================================================ FILE: _examples/real-world-examples/exactly-once-delivery-counter/worker/go.sum ================================================ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY= github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892LARrKAdki6WtXS4= github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0 h1:vd0eoMPriNjiFTEo7tQ1wmN933lNAWyVhxX931JG5Pw= github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0/go.mod h1:Ce2GVZVnyajAh0AkwxSJXwx8ajBBveu1DI/yatan5jc= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ= github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: _examples/real-world-examples/exactly-once-delivery-counter/worker/main.go ================================================ package main import ( "context" stdSQL "database/sql" "encoding/json" "os" "os/signal" "syscall" "github.com/ThreeDotsLabs/watermill/message" driver "github.com/go-sql-driver/mysql" "github.com/pkg/errors" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill-sql/v4/pkg/sql" ) const topic = "counter" func main() { db := createDB() logger := watermill.NewStdLogger(false, false) go runWatermillRouter(db, logger) sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) // no graceful shutdown, to increase chance of problems :-) <-sigs } type messagePayload struct { CounterUUID string `json:"counter_uuid"` } func runWatermillRouter(db *stdSQL.DB, logger watermill.LoggerAdapter) { subscriber, err := sql.NewSubscriber( sql.BeginnerFromStdSQL(db), sql.SubscriberConfig{ SchemaAdapter: sql.DefaultMySQLSchema{}, OffsetsAdapter: sql.DefaultMySQLOffsetsAdapter{}, InitializeSchema: true, }, logger, ) if err != nil { panic(err) } router, err := message.NewRouter(message.RouterConfig{}, logger) if err != nil { panic(err) } router.AddConsumerHandler( "counter", topic, subscriber, processMessage, ) if err := router.Run(context.Background()); err != nil { panic(err) } } func processMessage(msg *message.Message) error { tx, ok := sql.TxFromContext(msg.Context()) if !ok { return errors.New("tx not found in message context") } payload := messagePayload{} err := json.Unmarshal(msg.Payload, &payload) if err != nil { return errors.Wrap(err, "unable to unmarshal payload") } // let's do it more fragile, let's get the value from DB instead of simple increment counterValue, err := dbCounterValue(msg.Context(), tx, payload.CounterUUID) if err != nil { return err } counterValue += 1 if err := updateDbCounter(msg.Context(), tx, payload.CounterUUID, counterValue); err != nil { return err } return nil } func updateDbCounter(ctx context.Context, tx sql.Tx, counterUUD string, counterValue int) error { _, err := tx.ExecContext( ctx, "INSERT INTO counter (id, value) VALUES (?, ?) ON DUPLICATE KEY UPDATE value = ?", counterUUD, counterValue, counterValue, ) if err != nil { return errors.Wrap(err, "can't update counter value") } return nil } func dbCounterValue(ctx context.Context, tx sql.Tx, counterUUID string) (int, error) { var counterValue int rows, err := tx.QueryContext(ctx, "SELECT value from counter WHERE id = ?", counterUUID) if err != nil { return 0, errors.Wrap(err, "can't get counter value") } if !rows.Next() { return 0, nil } err = rows.Scan(&counterValue) if err != nil { return 0, errors.Wrap(err, "can't get counter value") } return counterValue, nil } func createDB() *stdSQL.DB { conf := driver.NewConfig() conf.Net = "tcp" conf.User = "root" conf.Addr = "mysql" conf.DBName = "example" db, err := stdSQL.Open("mysql", conf.FormatDSN()) if err != nil { panic(err) } err = db.Ping() if err != nil { panic(err) } return db } ================================================ FILE: _examples/real-world-examples/persistent-event-log/.validate_example.yml ================================================ validation_cmd: "docker compose up" teardown_cmd: "docker compose down" timeout: 180 expected_output: "received event" ================================================ FILE: _examples/real-world-examples/persistent-event-log/README.md ================================================ # Persistent Event Log (Google Cloud Pub/Sub to MySQL) This example shows how to use the SQL Publisher from [SQL Pub/Sub](https://github.com/ThreeDotsLabs/watermill-sql). ## Background Some PubSubs (e.g. Kafka) come with support for storing processed messages, possibly even with no expiration date. This can be useful for audit purposes or to reply selected messages again in the future. But what if you'd like to use a PubSub that offers no storage and an event log is needed? For more detailed description, see [When an SQL database makes a great Pub/Sub](https://threedots.tech/post/when-sql-database-makes-great-pub-sub/). ## Solution Plugging a pair of a publisher and a subscriber into Watermill's `Router` can work as a proxy from one PubSub to another. To ensure that events are written to a persistent storage, you can use the SQL publisher. Google Cloud Pub/Sub subscriber consumes events from a topic and inserts them into a MySQL table. While this particular PubSub doesn't guarantee proper order of messages, you can use `OccurredAt` field of the payload for sorting. Google Cloud Pub/Sub is used just as an example and any other subscriber can be used instead. The example uses `DefaultMySQLSchema`, but you can define your own table definition and queries. See [SQL Pub/Sub documentation](https://watermill.io/pubsubs/sql) for details. ## Requirements To run this example you will need Docker and docker-compose installed. See installation guide at https://docs.docker.com/compose/install/ ## Running ```bash docker-compose up ``` After few seconds, some events should be saved in the table: ``` docker-compose exec mysql mysql -e 'select * from watermill.watermill_events;' +--------+--------------------------------------+---------------------+---------+----------+ | offset | uuid | created_at | payload | metadata | +--------+--------------------------------------+---------------------+---------+----------+ | 1 | 2faf6a14-f52a-4d6c-a4be-7355db428be1 | 2019-08-17 12:23:35 | {...} | {} | | 2 | cccfe73c-1968-4e20-b8b7-3763f68dc60b | 2019-08-17 12:23:35 | {...} | {} | | 3 | e8585f50-5e38-4569-bd93-fe4f6e960e61 | 2019-08-17 12:23:36 | {...} | {} | | 4 | 2d364b7e-fc4d-459c-972a-8859c8f1a655 | 2019-08-17 12:23:37 | {...} | {} | | 5 | 3b9da717-aad8-4e4b-a6e2-2d7040454015 | 2019-08-17 12:23:38 | {...} | {} | | 6 | 5c07a2e7-464e-4ffb-8ada-0e2f02e48111 | 2019-08-17 12:23:39 | {...} | {} | | 7 | 60a30b9e-6a40-4f41-94f9-8e7c8a38a998 | 2019-08-17 12:23:40 | {...} | {} | | 8 | 3d28a15a-7448-4535-9b79-27111579e341 | 2019-08-17 12:23:41 | {...} | {} | | 9 | 3c448aff-6bdd-4fc4-9b56-8bacab0b2746 | 2019-08-17 12:23:42 | {...} | {} | | 10 | 9b56ca67-4c47-4bcd-931f-86f9af62775d | 2019-08-17 12:23:43 | {...} | {} | +--------+--------------------------------------+---------------------+---------+----------+ ``` ================================================ FILE: _examples/real-world-examples/persistent-event-log/docker-compose.yml ================================================ services: server: image: golang:1.25 restart: unless-stopped depends_on: - mysql - googlecloud volumes: - .:/app - $GOPATH/pkg/mod:/go/pkg/mod working_dir: /app environment: PUBSUB_EMULATOR_HOST: googlecloud:8085 command: go run main.go mysql: image: mysql:8.0 logging: driver: none restart: unless-stopped ports: - 3306:3306 environment: MYSQL_DATABASE: watermill MYSQL_ALLOW_EMPTY_PASSWORD: "yes" googlecloud: image: google/cloud-sdk:228.0.0 logging: driver: none entrypoint: gcloud --quiet beta emulators pubsub start --host-port=0.0.0.0:8085 --verbosity=debug --log-http ports: - 8085:8085 environment: PUBSUB_EMULATOR_HOST: googlecloud:8085 restart: unless-stopped ================================================ FILE: _examples/real-world-examples/persistent-event-log/go.mod ================================================ module main.go go 1.25 require ( github.com/ThreeDotsLabs/watermill v1.5.1 github.com/ThreeDotsLabs/watermill-googlecloud/v2 v2.0.0 github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0 github.com/go-sql-driver/mysql v1.9.3 ) require ( cloud.google.com/go v0.121.6 // indirect cloud.google.com/go/auth v0.16.5 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.8.0 // indirect cloud.google.com/go/iam v1.5.2 // indirect cloud.google.com/go/pubsub/v2 v2.0.0 // indirect filippo.io/edwards25519 v1.1.0 // indirect github.com/cenkalti/backoff/v3 v3.2.2 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.7.5 // indirect github.com/lib/pq v1.10.9 // indirect github.com/lithammer/shortuuid/v3 v3.0.7 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/sony/gobreaker v1.0.0 // indirect go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect go.opentelemetry.io/otel v1.38.0 // indirect go.opentelemetry.io/otel/metric v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect golang.org/x/crypto v0.41.0 // indirect golang.org/x/net v0.43.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sync v0.16.0 // indirect golang.org/x/sys v0.35.0 // indirect golang.org/x/text v0.28.0 // indirect golang.org/x/time v0.12.0 // indirect google.golang.org/api v0.248.0 // indirect google.golang.org/genproto v0.0.0-20250826171959-ef028d996bc1 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250826171959-ef028d996bc1 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1 // indirect google.golang.org/grpc v1.75.0 // indirect google.golang.org/protobuf v1.36.8 // indirect ) ================================================ FILE: _examples/real-world-examples/persistent-event-log/go.sum ================================================ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.121.6 h1:waZiuajrI28iAf40cWgycWNgaXPO06dupuS+sgibK6c= cloud.google.com/go v0.121.6/go.mod h1:coChdst4Ea5vUpiALcYKXEpR1S9ZgXbhEzzMcMR66vI= cloud.google.com/go/auth v0.16.5 h1:mFWNQ2FEVWAliEQWpAdH80omXFokmrnbDhUS9cBywsI= cloud.google.com/go/auth v0.16.5/go.mod h1:utzRfHMP+Vv0mpOkTRQoWD2q3BatTOoWbA7gCc2dUhQ= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.8.0 h1:HxMRIbao8w17ZX6wBnjhcDkW6lTFpgcaobyVfZWqRLA= cloud.google.com/go/compute/metadata v0.8.0/go.mod h1:sYOGTp851OV9bOFJ9CH7elVvyzopvWQFNNghtDQ/Biw= cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8= cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE= cloud.google.com/go/pubsub/v2 v2.0.0 h1:0qS6mRJ41gD1lNmM/vdm6bR7DQu6coQcVwD+VPf0Bz0= cloud.google.com/go/pubsub/v2 v2.0.0/go.mod h1:0aztFxNzVQIRSZ8vUr79uH2bS3jwLebwK6q1sgEub+E= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY= github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892LARrKAdki6WtXS4= github.com/ThreeDotsLabs/watermill-googlecloud/v2 v2.0.0 h1:GXR+tsxPs/Vpmm0t4yEJUZdqLP9EytWvR+KN3Un5mNY= github.com/ThreeDotsLabs/watermill-googlecloud/v2 v2.0.0/go.mod h1:3IHyi1bNqQ8J2/wVWj4cQjzWXoEPauLm8ViyOCNaKbM= github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0 h1:vd0eoMPriNjiFTEo7tQ1wmN933lNAWyVhxX931JG5Pw= github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0/go.mod h1:Ce2GVZVnyajAh0AkwxSJXwx8ajBBveu1DI/yatan5jc= github.com/cenkalti/backoff/v3 v3.2.2 h1:cfUAAO3yvKMYKPrvhDuHSwQnhZNk/RMHKdZqKTxfm6M= github.com/cenkalti/backoff/v3 v3.2.2/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4rc0ij+ULvLYs= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ= github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.einride.tech/aip v0.73.0 h1:bPo4oqBo2ZQeBKo4ZzLb1kxYXTY1ysJhpvQyfuGzvps= go.einride.tech/aip v0.73.0/go.mod h1:Mj7rFbmXEgw0dq1dqJ7JGMvYCZZVxmGOR3S4ZcV5LvQ= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/api v0.248.0 h1:hUotakSkcwGdYUqzCRc5yGYsg4wXxpkKlW5ryVqvC1Y= google.golang.org/api v0.248.0/go.mod h1:yAFUAF56Li7IuIQbTFoLwXTCI6XCFKueOlS7S9e4F9k= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= google.golang.org/genproto v0.0.0-20250826171959-ef028d996bc1 h1:Nm5SEGIguOIBDXs5rhfz2aKwEVWlgwC58UcmEnLDc8Y= google.golang.org/genproto v0.0.0-20250826171959-ef028d996bc1/go.mod h1:Jz9LrroM7Mcm+a0QrLh4UpZ1B/WhjIbqwEcUf4y08nQ= google.golang.org/genproto/googleapis/api v0.0.0-20250826171959-ef028d996bc1 h1:APHvLLYBhtZvsbnpkfknDZ7NyH4z5+ub/I0u8L3Oz6g= google.golang.org/genproto/googleapis/api v0.0.0-20250826171959-ef028d996bc1/go.mod h1:xUjFWUnWDpZ/C0Gu0qloASKFb6f8/QXiiXhSPFsD668= google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1 h1:pmJpJEvT846VzausCQ5d7KreSROcDqmO388w5YbnltA= google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1/go.mod h1:GmFNa4BdJZ2a8G+wCe9Bg3wwThLrJun751XstdJt5Og= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4= google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= ================================================ FILE: _examples/real-world-examples/persistent-event-log/main.go ================================================ package main import ( "context" stdSQL "database/sql" "encoding/json" "log" "time" driver "github.com/go-sql-driver/mysql" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill-googlecloud/v2/pkg/googlecloud" "github.com/ThreeDotsLabs/watermill-sql/v4/pkg/sql" "github.com/ThreeDotsLabs/watermill/message" "github.com/ThreeDotsLabs/watermill/message/router/middleware" "github.com/ThreeDotsLabs/watermill/message/router/plugin" ) var ( logger = watermill.NewStdLogger(false, false) googleCloudTopic = "events" mysqlTable = "events" ) type event struct { Name string `json:"name"` OccurredAt string `json:"occurred_at"` } func main() { router, err := message.NewRouter(message.RouterConfig{}, logger) if err != nil { panic(err) } router.AddPlugin(plugin.SignalsHandler) router.AddMiddleware(middleware.Recoverer) db := createDB() subscriber := createSubscriber() publisher := createPublisher(db) go simulateEvents() router.AddHandler( "googlecloud-to-mysql", googleCloudTopic, subscriber, mysqlTable, publisher, func(msg *message.Message) ([]*message.Message, error) { consumedEvent := event{} err := json.Unmarshal(msg.Payload, &consumedEvent) if err != nil { return nil, err } log.Printf("received event %+v with UUID %s", consumedEvent, msg.UUID) return []*message.Message{msg}, nil }, ) if err := router.Run(context.Background()); err != nil { panic(err) } } func createDB() *stdSQL.DB { conf := driver.NewConfig() conf.Net = "tcp" conf.User = "root" conf.Addr = "mysql" conf.DBName = "watermill" db, err := stdSQL.Open("mysql", conf.FormatDSN()) if err != nil { panic(err) } err = db.Ping() if err != nil { panic(err) } return db } func createSubscriber() message.Subscriber { sub, err := googlecloud.NewSubscriber( googlecloud.SubscriberConfig{ ProjectID: "example", }, logger, ) if err != nil { panic(err) } return sub } func createPublisher(db *stdSQL.DB) message.Publisher { pub, err := sql.NewPublisher( sql.BeginnerFromStdSQL(db), sql.PublisherConfig{ SchemaAdapter: sql.DefaultMySQLSchema{}, AutoInitializeSchema: true, }, logger, ) if err != nil { panic(err) } return pub } func simulateEvents() { pub, err := googlecloud.NewPublisher(googlecloud.PublisherConfig{ ProjectID: "example", }, logger) if err != nil { panic(err) } for { e := event{ Name: "UserSignedUp", OccurredAt: time.Now().UTC().Format(time.RFC3339), } payload, err := json.Marshal(e) if err != nil { panic(err) } err = pub.Publish(googleCloudTopic, message.NewMessage( watermill.NewUUID(), payload, )) if err != nil { panic(err) } time.Sleep(time.Second) } } ================================================ FILE: _examples/real-world-examples/receiving-webhooks/.validate_example.yml ================================================ validation_cmd: "docker compose up" teardown_cmd: "docker compose down" timeout: 180 expected_output: "Starting handler" ================================================ FILE: _examples/real-world-examples/receiving-webhooks/README.md ================================================ # Receiving webhooks (HTTP to Kafka) This example showcases the use of the **HTTP Subscriber** to receive webhooks with HTTP POST requests. Received messages are then published to a Kafka topic. ## Requirements To run this example you will need Docker and docker-compose installed. See installation guide at https://docs.docker.com/compose/install/ ## Running To run all services, execute: ``` docker-compose up ``` ================================================ FILE: _examples/real-world-examples/receiving-webhooks/docker-compose.yml ================================================ services: golang: image: golang:1.25 restart: unless-stopped ports: - 8080:8080 depends_on: - kafka volumes: - .:/app - $GOPATH/pkg/mod:/go/pkg/mod working_dir: /app command: go run main.go -kafka kafka:9092 -http :8080 zookeeper: image: confluentinc/cp-zookeeper:7.3.1 restart: unless-stopped environment: ZOOKEEPER_CLIENT_PORT: 2181 logging: driver: none kafka: image: confluentinc/cp-kafka:7.3.1 restart: unless-stopped logging: driver: none depends_on: - zookeeper environment: KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092 KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true" ================================================ FILE: _examples/real-world-examples/receiving-webhooks/go.mod ================================================ module main.go require ( github.com/ThreeDotsLabs/watermill v1.5.1 github.com/ThreeDotsLabs/watermill-http v1.1.4 github.com/ThreeDotsLabs/watermill-kafka/v3 v3.1.0 ) require ( github.com/IBM/sarama v1.46.0 // indirect github.com/ajg/form v1.5.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dnwe/otelsarama v0.0.0-20240308230250-9388d9d40bc0 // indirect github.com/eapache/go-resiliency v1.7.0 // indirect github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 // indirect github.com/eapache/queue v1.1.0 // indirect github.com/go-chi/chi v4.1.2+incompatible // indirect github.com/go-chi/render v1.0.3 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/golang/snappy v1.0.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/jcmturner/aescts/v2 v2.0.0 // indirect github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect github.com/jcmturner/gofork v1.7.6 // indirect github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect github.com/jcmturner/rpc/v2 v2.0.3 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/lithammer/shortuuid/v3 v3.0.7 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect github.com/sony/gobreaker v1.0.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/otel v1.38.0 // indirect go.opentelemetry.io/otel/metric v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect golang.org/x/crypto v0.41.0 // indirect golang.org/x/net v0.43.0 // indirect ) go 1.25 ================================================ FILE: _examples/real-world-examples/receiving-webhooks/go.sum ================================================ github.com/IBM/sarama v1.46.0 h1:+YTM1fNd6WKMchlnLKRUB5Z0qD4M8YbvwIIPLvJD53s= github.com/IBM/sarama v1.46.0/go.mod h1:0lOcuQziJ1/mBGHkdp5uYrltqQuKQKM5O5FOWUQVVvo= github.com/ThreeDotsLabs/watermill v1.1.0/go.mod h1:Qd1xNFxolCAHCzcMrm6RnjW0manbvN+DJVWc1MWRFlI= github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY= github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892LARrKAdki6WtXS4= github.com/ThreeDotsLabs/watermill-http v1.1.4 h1:wRM54z/BPnIWjGbXMrOnwOlrCAESzoSNxTAHiLysFA4= github.com/ThreeDotsLabs/watermill-http v1.1.4/go.mod h1:mkQ9CC0pxTZerNwr281rBoOy355vYt/lePkmYSX/BRg= github.com/ThreeDotsLabs/watermill-kafka/v3 v3.1.0 h1:DfhVM1ieq+rb+bboB7aoymUgfKsEM3UbH3noQZ6+RJ4= github.com/ThreeDotsLabs/watermill-kafka/v3 v3.1.0/go.mod h1:o1GcoF/1CSJ9JSmQzUkULvpZeO635pZe+WWrYNFlJNk= github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/cenkalti/backoff/v3 v3.0.0/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4rc0ij+ULvLYs= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dnwe/otelsarama v0.0.0-20240308230250-9388d9d40bc0 h1:R2zQhFwSCyyd7L43igYjDrH0wkC/i+QBPELuY0HOu84= github.com/dnwe/otelsarama v0.0.0-20240308230250-9388d9d40bc0/go.mod h1:2MqLKYJfjs3UriXXF9Fd0Qmh/lhxi/6tHXkqtXxyIHc= github.com/eapache/go-resiliency v1.7.0 h1:n3NRTnBn5N0Cbi/IeOHuQn9s2UwVUH7Ga0ZWcP+9JTA= github.com/eapache/go-resiliency v1.7.0/go.mod h1:5yPzW0MIvSe0JDsv0v+DvcjEv2FyD6iZYSs1ZI+iQho= github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 h1:Oy0F4ALJ04o5Qqpdz8XLIpNA3WM/iSIXqxtqo7UGVws= github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3/go.mod h1:YvSRo5mw33fLEx1+DlK6L2VV43tJt5Eyel9n9XBcR+0= github.com/eapache/queue v1.1.0 h1:YOEu7KNc61ntiQlcEeUIoDTJ2o8mQznoNvUhiigpIqc= github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= github.com/go-chi/chi v4.0.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyNz34tQRec= github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= github.com/go-chi/render v1.0.1/go.mod h1:pq4Rr7HbnsdaeHagklXub+p6Wd16Af5l9koip1OvJns= github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4= github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg= github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8= github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/lithammer/shortuuid/v3 v3.0.4/go.mod h1:RviRjexKqIzx/7r1peoAITm6m7gnif/h+0zmolKJjzw= github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg= github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ= github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: _examples/real-world-examples/receiving-webhooks/main.go ================================================ package main import ( "context" "encoding/json" "errors" "flag" "fmt" "io" stdHttp "net/http" _ "net/http/pprof" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill-http/pkg/http" "github.com/ThreeDotsLabs/watermill-kafka/v3/pkg/kafka" "github.com/ThreeDotsLabs/watermill/message" "github.com/ThreeDotsLabs/watermill/message/router/middleware" "github.com/ThreeDotsLabs/watermill/message/router/plugin" ) var ( kafkaAddr = flag.String("kafka", "localhost:9092", "The address of the kafka broker") httpAddr = flag.String("http", ":8080", "The address for the http subscriber") ) type Webhook struct { ObjectKind string `json:"object_kind"` } func main() { flag.Parse() logger := watermill.NewStdLogger(true, true) kafkaPublisher, err := kafka.NewPublisher( kafka.PublisherConfig{ Brokers: []string{*kafkaAddr}, Marshaler: kafka.DefaultMarshaler{}, }, logger, ) if err != nil { panic(err) } httpSubscriber, err := http.NewSubscriber( *httpAddr, http.SubscriberConfig{ UnmarshalMessageFunc: func(topic string, request *stdHttp.Request) (*message.Message, error) { b, err := io.ReadAll(request.Body) if err != nil { return nil, fmt.Errorf("cannot read body: %w", err) } return message.NewMessage(watermill.NewUUID(), b), nil }, }, logger, ) if err != nil { panic(err) } r, err := message.NewRouter( message.RouterConfig{}, logger, ) if err != nil { panic(err) } r.AddMiddleware( middleware.Recoverer, middleware.CorrelationID, ) r.AddPlugin(plugin.SignalsHandler) r.AddHandler( "http_to_kafka", "/webhooks", // this is the URL of our API httpSubscriber, "webhooks", // this is the topic the message will be published to kafkaPublisher, func(msg *message.Message) ([]*message.Message, error) { webhook := Webhook{} if err := json.Unmarshal(msg.Payload, &webhook); err != nil { return nil, fmt.Errorf("cannot unmarshal message: %w", err) } // Add simple validation if webhook.ObjectKind == "" { return nil, errors.New("empty object kind") } // Simply forward the message from HTTP Subscriber to Kafka Publisher return []*message.Message{msg}, nil }, ) go func() { // HTTP server needs to be started after the router is ready. <-r.Running() _ = httpSubscriber.StartHTTPServer() }() _ = r.Run(context.Background()) } ================================================ FILE: _examples/real-world-examples/sending-webhooks/.validate_example.yml ================================================ validation_cmd: "docker compose up" teardown_cmd: "docker compose down" timeout: 180 expected_output: "POST /foo_or_bar: message" ================================================ FILE: _examples/real-world-examples/sending-webhooks/README.md ================================================ # Sending webhooks (Kafka to HTTP) This example showcases the use of the **HTTP Publisher** to call webhooks with HTTP POST requests. It consists of three services: 1. `producer` publishes messages on Kafka. The messages come in three varieties: `Foo`, `Bar`, and `Baz`. The event type is encoded in metadata, under the key `event_type`. 1. `webhook_server` is a HTTP server that listens for requests and prints the path, method, and payload on stdout. 1. `router` consumes the Kafka messages, and uses the HTTP producer to send requests to `webhook_server`. To illustrate how one message can spawn multiple webhooks, the following paths are called based on `event_type`: 1. `/foo` for events of type `Foo` 1. `/foo_or_bar` for events of type `Foo` or `Bar` 1. `/all` for all events. Additionally, services `zookeeper` and `kafka` are present to provide backend for the Kafka producer and subscriber. ## Requirements To run this example you will need Docker and docker-compose installed. See installation guide at https://docs.docker.com/compose/install/ ## Running To run all services, execute: ``` docker-compose up ``` To filter messages from a specific service, execute: ``` docker-compose logs [-f] {service} ``` in a separate terminal window while the services are running. Use the `-f` flag to emulate `tail -f` behavior, i.e. follow the output. ================================================ FILE: _examples/real-world-examples/sending-webhooks/docker-compose.yml ================================================ services: webhooks-server: image: golang:1.25 restart: unless-stopped volumes: - .:/app - $GOPATH/pkg/mod:/go/pkg/mod working_dir: /app/webhooks-server/ command: go run main.go router: image: golang:1.25 restart: unless-stopped depends_on: - kafka volumes: - .:/app - $GOPATH/pkg/mod:/go/pkg/mod working_dir: /app/router/ command: go run main.go producer: image: golang:1.25 restart: unless-stopped depends_on: - kafka - webhooks-server - router volumes: - .:/app - $GOPATH/pkg/mod:/go/pkg/mod working_dir: /app/producer/ command: go run main.go zookeeper: image: confluentinc/cp-zookeeper:7.3.1 restart: unless-stopped environment: ZOOKEEPER_CLIENT_PORT: 2181 logging: driver: none kafka: image: confluentinc/cp-kafka:7.3.1 restart: unless-stopped logging: driver: none depends_on: - zookeeper environment: KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092 KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true" ================================================ FILE: _examples/real-world-examples/sending-webhooks/producer/go.mod ================================================ module main.go require ( github.com/ThreeDotsLabs/watermill v1.5.1 github.com/ThreeDotsLabs/watermill-kafka/v3 v3.1.0 ) require ( github.com/IBM/sarama v1.46.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dnwe/otelsarama v0.0.0-20240308230250-9388d9d40bc0 // indirect github.com/eapache/go-resiliency v1.7.0 // indirect github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 // indirect github.com/eapache/queue v1.1.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/golang/snappy v1.0.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/jcmturner/aescts/v2 v2.0.0 // indirect github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect github.com/jcmturner/gofork v1.7.6 // indirect github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect github.com/jcmturner/rpc/v2 v2.0.3 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/lithammer/shortuuid/v3 v3.0.7 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/otel v1.38.0 // indirect go.opentelemetry.io/otel/metric v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect golang.org/x/crypto v0.41.0 // indirect golang.org/x/net v0.43.0 // indirect ) go 1.25 ================================================ FILE: _examples/real-world-examples/sending-webhooks/producer/go.sum ================================================ github.com/IBM/sarama v1.46.0 h1:+YTM1fNd6WKMchlnLKRUB5Z0qD4M8YbvwIIPLvJD53s= github.com/IBM/sarama v1.46.0/go.mod h1:0lOcuQziJ1/mBGHkdp5uYrltqQuKQKM5O5FOWUQVVvo= github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY= github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892LARrKAdki6WtXS4= github.com/ThreeDotsLabs/watermill-kafka/v3 v3.1.0 h1:DfhVM1ieq+rb+bboB7aoymUgfKsEM3UbH3noQZ6+RJ4= github.com/ThreeDotsLabs/watermill-kafka/v3 v3.1.0/go.mod h1:o1GcoF/1CSJ9JSmQzUkULvpZeO635pZe+WWrYNFlJNk= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dnwe/otelsarama v0.0.0-20240308230250-9388d9d40bc0 h1:R2zQhFwSCyyd7L43igYjDrH0wkC/i+QBPELuY0HOu84= github.com/dnwe/otelsarama v0.0.0-20240308230250-9388d9d40bc0/go.mod h1:2MqLKYJfjs3UriXXF9Fd0Qmh/lhxi/6tHXkqtXxyIHc= github.com/eapache/go-resiliency v1.7.0 h1:n3NRTnBn5N0Cbi/IeOHuQn9s2UwVUH7Ga0ZWcP+9JTA= github.com/eapache/go-resiliency v1.7.0/go.mod h1:5yPzW0MIvSe0JDsv0v+DvcjEv2FyD6iZYSs1ZI+iQho= github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 h1:Oy0F4ALJ04o5Qqpdz8XLIpNA3WM/iSIXqxtqo7UGVws= github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3/go.mod h1:YvSRo5mw33fLEx1+DlK6L2VV43tJt5Eyel9n9XBcR+0= github.com/eapache/queue v1.1.0 h1:YOEu7KNc61ntiQlcEeUIoDTJ2o8mQznoNvUhiigpIqc= github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg= github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8= github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg= github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: _examples/real-world-examples/sending-webhooks/producer/main.go ================================================ package main import ( "fmt" "math/rand" "time" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill-kafka/v3/pkg/kafka" "github.com/ThreeDotsLabs/watermill/message" ) var ( brokers = []string{"kafka:9092"} logger = watermill.NewStdLogger(false, false) ) type eventType string const ( Foo eventType = "Foo" Bar eventType = "Bar" Baz eventType = "Baz" ) func main() { pub, err := kafka.NewPublisher( kafka.PublisherConfig{ Brokers: brokers, Marshaler: kafka.DefaultMarshaler{}, }, logger, ) if err != nil { panic(err) } eventTypes := []eventType{Foo, Bar, Baz} for { eventType := eventTypes[rand.Intn(3)] msg := message.NewMessage(watermill.NewUUID(), []byte("message")) msg.Metadata.Set("event_type", string(eventType)) fmt.Printf("%s Publishing %s\n\n", time.Now().String(), eventType) if err := pub.Publish("kafka_to_http_example", msg); err != nil { panic(err) } time.Sleep(time.Second) } } ================================================ FILE: _examples/real-world-examples/sending-webhooks/router/go.mod ================================================ module main.go require ( github.com/ThreeDotsLabs/watermill v1.5.1 github.com/ThreeDotsLabs/watermill-http v1.1.4 github.com/ThreeDotsLabs/watermill-kafka/v3 v3.1.0 ) require ( github.com/IBM/sarama v1.46.0 // indirect github.com/ajg/form v1.5.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dnwe/otelsarama v0.0.0-20240308230250-9388d9d40bc0 // indirect github.com/eapache/go-resiliency v1.7.0 // indirect github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 // indirect github.com/eapache/queue v1.1.0 // indirect github.com/go-chi/chi v4.1.2+incompatible // indirect github.com/go-chi/render v1.0.3 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/golang/snappy v1.0.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/jcmturner/aescts/v2 v2.0.0 // indirect github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect github.com/jcmturner/gofork v1.7.6 // indirect github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect github.com/jcmturner/rpc/v2 v2.0.3 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/lithammer/shortuuid/v3 v3.0.7 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/otel v1.38.0 // indirect go.opentelemetry.io/otel/metric v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect golang.org/x/crypto v0.41.0 // indirect golang.org/x/net v0.43.0 // indirect ) go 1.25 ================================================ FILE: _examples/real-world-examples/sending-webhooks/router/go.sum ================================================ github.com/IBM/sarama v1.46.0 h1:+YTM1fNd6WKMchlnLKRUB5Z0qD4M8YbvwIIPLvJD53s= github.com/IBM/sarama v1.46.0/go.mod h1:0lOcuQziJ1/mBGHkdp5uYrltqQuKQKM5O5FOWUQVVvo= github.com/ThreeDotsLabs/watermill v1.1.0/go.mod h1:Qd1xNFxolCAHCzcMrm6RnjW0manbvN+DJVWc1MWRFlI= github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY= github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892LARrKAdki6WtXS4= github.com/ThreeDotsLabs/watermill-http v1.1.4 h1:wRM54z/BPnIWjGbXMrOnwOlrCAESzoSNxTAHiLysFA4= github.com/ThreeDotsLabs/watermill-http v1.1.4/go.mod h1:mkQ9CC0pxTZerNwr281rBoOy355vYt/lePkmYSX/BRg= github.com/ThreeDotsLabs/watermill-kafka/v3 v3.1.0 h1:DfhVM1ieq+rb+bboB7aoymUgfKsEM3UbH3noQZ6+RJ4= github.com/ThreeDotsLabs/watermill-kafka/v3 v3.1.0/go.mod h1:o1GcoF/1CSJ9JSmQzUkULvpZeO635pZe+WWrYNFlJNk= github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/cenkalti/backoff/v3 v3.0.0/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4rc0ij+ULvLYs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dnwe/otelsarama v0.0.0-20240308230250-9388d9d40bc0 h1:R2zQhFwSCyyd7L43igYjDrH0wkC/i+QBPELuY0HOu84= github.com/dnwe/otelsarama v0.0.0-20240308230250-9388d9d40bc0/go.mod h1:2MqLKYJfjs3UriXXF9Fd0Qmh/lhxi/6tHXkqtXxyIHc= github.com/eapache/go-resiliency v1.7.0 h1:n3NRTnBn5N0Cbi/IeOHuQn9s2UwVUH7Ga0ZWcP+9JTA= github.com/eapache/go-resiliency v1.7.0/go.mod h1:5yPzW0MIvSe0JDsv0v+DvcjEv2FyD6iZYSs1ZI+iQho= github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 h1:Oy0F4ALJ04o5Qqpdz8XLIpNA3WM/iSIXqxtqo7UGVws= github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3/go.mod h1:YvSRo5mw33fLEx1+DlK6L2VV43tJt5Eyel9n9XBcR+0= github.com/eapache/queue v1.1.0 h1:YOEu7KNc61ntiQlcEeUIoDTJ2o8mQznoNvUhiigpIqc= github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= github.com/go-chi/chi v4.0.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyNz34tQRec= github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= github.com/go-chi/render v1.0.1/go.mod h1:pq4Rr7HbnsdaeHagklXub+p6Wd16Af5l9koip1OvJns= github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4= github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg= github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8= github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/lithammer/shortuuid/v3 v3.0.4/go.mod h1:RviRjexKqIzx/7r1peoAITm6m7gnif/h+0zmolKJjzw= github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg= github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: _examples/real-world-examples/sending-webhooks/router/main.go ================================================ package main import ( "context" "github.com/ThreeDotsLabs/watermill" watermill_http "github.com/ThreeDotsLabs/watermill-http/pkg/http" "github.com/ThreeDotsLabs/watermill-kafka/v3/pkg/kafka" "github.com/ThreeDotsLabs/watermill/message" "github.com/ThreeDotsLabs/watermill/message/router/plugin" ) var ( logger = watermill.NewStdLogger(false, false) ) // filterMessages passes the message along if its event type is one of acceptedTypes. func filterMessages(acceptedTypes ...string) message.HandlerFunc { return func(msg *message.Message) ([]*message.Message, error) { // the kafka producer sets this metadata so that we don't have to unmarshal the body // just sort the messages based on event type metadata msgEventType := msg.Metadata.Get("event_type") for _, typ := range acceptedTypes { if typ == msgEventType { return message.Messages{msg}, nil } } return nil, nil } } func main() { publisher, err := watermill_http.NewPublisher(watermill_http.PublisherConfig{ MarshalMessageFunc: watermill_http.DefaultMarshalMessageFunc, }, logger) if err != nil { panic(err) } subscriber, err := kafka.NewSubscriber( kafka.SubscriberConfig{ Brokers: []string{"kafka:9092"}, Unmarshaler: kafka.DefaultMarshaler{}, }, logger, ) if err != nil { panic(err) } router, err := message.NewRouter(message.RouterConfig{}, logger) if err != nil { panic(err) } topic := "kafka_to_http_example" url := "http://webhooks-server:8001/" router.AddHandler("foo", topic, subscriber, url+"foo", publisher, filterMessages("Foo")) router.AddHandler("foo_or_bar", topic, subscriber, url+"foo_or_bar", publisher, filterMessages("Foo", "Bar")) router.AddHandler("all", topic, subscriber, url+"all", publisher, filterMessages("Foo", "Bar", "Baz")) router.AddPlugin(plugin.SignalsHandler) err = router.Run(context.Background()) if err != nil { panic(err) } } ================================================ FILE: _examples/real-world-examples/sending-webhooks/webhooks-server/main.go ================================================ package main import ( "fmt" "io/ioutil" "net/http" "time" ) // handler receives the webhook requests and logs them in stdout. func handler(w http.ResponseWriter, r *http.Request) { body, err := ioutil.ReadAll(r.Body) if err != nil { w.WriteHeader(http.StatusBadRequest) return } fmt.Printf( "[%s] %s %s: %s\n\n", time.Now().String(), r.Method, r.URL.String(), string(body), ) w.WriteHeader(http.StatusOK) } func main() { http.HandleFunc("/", handler) http.ListenAndServe(":8001", http.DefaultServeMux) } ================================================ FILE: _examples/real-world-examples/server-sent-events/README.md ================================================ # HTTP Server push using SSE (Server-Sent Events) This example is a Twitter-like web application using [Server-Sent Events](https://en.wikipedia.org/wiki/Server-sent_events) to support real-time refreshing. ![](./screen.gif) ## Running ``` docker-compose up ``` Then, open http://localhost:8080 You can add your own post or click the button to get randomly generated posts. Either way, the feeds list and posts in a feed should be always up-to-date. Try using a second browser window to see the update. ## How it works * Posts can be created and updated. * Posts can contain tags. * Each tag has its own feed that contains all posts from that tag. * All posts are stored in MySQL. This is the Write Model. * All feeds are updated asynchronously and stored in MongoDB. This is the Read Model. ### Why use separate write and read models? For this example application, using polyglot persistence (two database engines) is, of course, an overkill. We did it to showcase this technique and how easy it is to apply it with Watermill. A dedicated read model is a useful pattern for applications with high read/write ratio. All writes are applied atomically to the write model (MySQL in our case). Event handlers asynchronously update the read model (we use Mongo). The data in the read model is ready to serve as it is. It can also be scaled independently of the write model. Keep in mind that eventual consistency has to be acceptable in your application to use this pattern. Also, you probably won't need to use it for most use cases. Be pragmatic! ![](./diagram.jpg) ### SSE Router The `SSERouter` comes from [watermill-http](https://github.com/ThreeDotsLabs/watermill-http). When creating a new router, you pass an upstream subscriber. Messages coming from that subscriber will trigger pushing updates over HTTP. In this example, we use [NATS](https://nats.io/) as Pub/Sub, but this can be any Pub/Sub supported by Watermill. ```go sseRouter, err := watermillHTTP.NewSSERouter( watermillHTTP.SSERouterConfig{ UpstreamSubscriber: router.Subscriber, ErrorHandler: watermillHTTP.DefaultErrorHandler, }, router.Logger, ) ``` ### Stream Adapters To work with `SSERouter` you need to prepare a `StreamAdapter` with two methods. `GetResponse` is similar to a standard HTTP handler. It should be super easy to modify an existing handler to match this signature. `Validate` is an extra method that tells whether an update should be pushed for a particular `Message`. ```go type StreamAdapter interface { // GetResponse returns the response to be sent back to client. // Any errors that occur should be handled and written to `w`, returning false as `ok`. GetResponse(w http.ResponseWriter, r *http.Request) (response interface{}, ok bool) // Validate validates if the incoming message should be handled by this handler. // Typically this involves checking some kind of model ID. Validate(r *http.Request, msg *message.Message) (ok bool) } ``` An example `Validate` can look like this. It checks whether the message came for the same post ID that the user sent over the HTTP request. ```go func (p postStreamAdapter) Validate(r *http.Request, msg *message.Message) (ok bool) { postUpdated := PostUpdated{} err := json.Unmarshal(msg.Payload, &postUpdated) if err != nil { return false } postID := chi.URLParam(r, "id") return postUpdated.OriginalPost.ID == postID } ``` If you'd like to trigger an update for every message, you can simply return `true`. ```go func (f allFeedsStreamAdapter) Validate(r *http.Request, msg *message.Message) (ok bool) { return true } ``` Before starting the `SSERouter`, you need to add the handler with particular topic. `AddHandler` returns a standard HTTP handler that can be used in any routing library. ```go postHandler := sseRouter.AddHandler(PostUpdatedTopic, postStream) // ... r.Get("/posts/{id}", postHandler) ``` ## Event handlers The example uses Watermill for all asynchronous communication, including SSE. There are several events published: * `PostCreated` * Adds the post to all feeds with tags present in the post. * `FeedUpdated` * Pushes update to all clients currently visiting the feed page. * `PostUpdated` * Pushes update to all clients currently visiting the post page. * Updates post in all feeds with tags present in the post * a) For existing tags, the post content will be updated in the tag. * b) If a new tag has been added, the post will be added to the tag's feed. * c) If a tag has been deleted, the post will be removed from the tag's feed. ## Frontend app The frontend application is built using Vue.js and Bootstrap. The most interesting part is the use of `EventSource`. ```js this.es = new EventSource('/api/feeds/' + this.feed) this.es.addEventListener('data', event => { let data = JSON.parse(event.data); this.posts_stream = data.posts; }, false); ``` Please note the author is not a frontend developer and the code in `index.html` is probably not idiomatic. PRs are welcome. :) ================================================ FILE: _examples/real-world-examples/server-sent-events/docker-compose.yml ================================================ services: server: image: golang:1.25 restart: unless-stopped ports: - 8080:8080 volumes: - ./server:/app - $GOPATH/pkg/mod:/go/pkg/mod working_dir: /app command: 'go run .' mysql: image: mysql:8.0 restart: unless-stopped environment: MYSQL_DATABASE: example MYSQL_ALLOW_EMPTY_PASSWORD: "yes" volumes: - ./schema.sql:/docker-entrypoint-initdb.d/schema.sql mongo: image: mongo:3.6 restart: unless-stopped environment: MONGO_INITDB_ROOT_USERNAME: root MONGO_INITDB_ROOT_PASSWORD: password nats-streaming: image: nats-streaming:0.11.2 restart: unless-stopped logging: driver: none ================================================ FILE: _examples/real-world-examples/server-sent-events/schema.sql ================================================ CREATE TABLE example.posts ( id VARCHAR(36) NOT NULL PRIMARY KEY, title VARCHAR(255) NOT NULL DEFAULT '', content TEXT NOT NULL, author VARCHAR(255) NOT NULL DEFAULT '' ); ================================================ FILE: _examples/real-world-examples/server-sent-events/server/event_handlers.go ================================================ package main import ( "context" "encoding/json" "time" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill-nats/pkg/nats" "github.com/ThreeDotsLabs/watermill/message" "github.com/ThreeDotsLabs/watermill/message/router/middleware" "github.com/nats-io/stan.go" ) const ( PostCreatedTopic = "post-created" PostUpdatedTopic = "post-updated" FeedUpdatedTopic = "feed-updated" ) func SetupMessageRouter( feedsStorage FeedsStorage, logger watermill.LoggerAdapter, ) (message.Publisher, message.Subscriber, error) { router, err := message.NewRouter(message.RouterConfig{}, logger) if err != nil { return nil, nil, err } router.AddMiddleware(middleware.Recoverer) natsURL := stan.NatsURL("nats://nats-streaming:4222") pub, err := nats.NewStreamingPublisher(nats.StreamingPublisherConfig{ ClusterID: "test-cluster", ClientID: "publisher", StanOptions: []stan.Option{natsURL}, Marshaler: nats.GobMarshaler{}, }, logger) if err != nil { return nil, nil, err } sub, err := nats.NewStreamingSubscriber(nats.StreamingSubscriberConfig{ ClusterID: "test-cluster", ClientID: "subscriber", StanOptions: []stan.Option{natsURL}, Unmarshaler: nats.GobMarshaler{}, }, logger) if err != nil { return nil, nil, err } router.AddHandler( "update-feeds-on-post-created", PostCreatedTopic, sub, FeedUpdatedTopic, pub, func(msg *message.Message) (messages []*message.Message, err error) { defer func() { if err == nil { logger.Info("Successfully updated feeds on new post created", nil) } else { logger.Error("Error while updating feeds on new post created", err, nil) } }() event := PostCreated{} err = json.Unmarshal(msg.Payload, &event) if err != nil { return nil, err } logger.Info("Adding post", watermill.LogFields{"post": event.Post}) if len(event.Post.Tags) > 0 { for _, tag := range event.Post.Tags { logger.Info("Adding tag", watermill.LogFields{"tag": tag}) err = feedsStorage.Add(msg.Context(), tag) if err != nil { return nil, err } } err = feedsStorage.AppendPost(msg.Context(), event.Post) if err != nil { return nil, err } } return createFeedUpdatedEvents(event.Post.Tags) }, ) router.AddHandler( "update-feeds-on-post-updated", PostUpdatedTopic, sub, FeedUpdatedTopic, pub, func(msg *message.Message) (messages []*message.Message, err error) { defer func() { if err == nil { logger.Info("Successfully updated feeds on post updated", nil) } else { logger.Error("Error while updating feeds on post updated", err, nil) } }() event := PostUpdated{} err = json.Unmarshal(msg.Payload, &event) if err != nil { return nil, err } for _, tag := range event.NewPost.Tags { logger.Info("Adding tag", watermill.LogFields{"tag": tag}) err = feedsStorage.Add(msg.Context(), tag) if err != nil { return nil, err } } err = feedsStorage.UpdatePost(msg.Context(), event.NewPost) if err != nil { return nil, err } return createFeedUpdatedEvents(append(event.NewPost.Tags, event.OriginalPost.Tags...)) }, ) go func() { err = router.Run(context.Background()) if err != nil { panic(err) } }() <-router.Running() return pub, sub, nil } func createFeedUpdatedEvents(tags []string) ([]*message.Message, error) { var messages []*message.Message for _, tag := range tags { event := FeedUpdated{ Name: tag, OccurredAt: time.Now().UTC(), } payload, err := json.Marshal(event) if err != nil { return nil, err } msg := message.NewMessage(watermill.NewUUID(), payload) messages = append(messages, msg) } return messages, nil } type Publisher struct { publisher message.Publisher } func (p Publisher) Publish(topic string, event interface{}) error { payload, err := json.Marshal(event) if err != nil { return err } msg := message.NewMessage(watermill.NewUUID(), payload) return p.publisher.Publish(topic, msg) } ================================================ FILE: _examples/real-world-examples/server-sent-events/server/feeds_storage.go ================================================ package main import ( "context" "time" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/mongo" "go.mongodb.org/mongo-driver/mongo/options" "go.mongodb.org/mongo-driver/mongo/readpref" ) const collectionName = "feeds" type FeedsStorage struct { collection *mongo.Collection } func NewFeedsStorage() FeedsStorage { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() client, err := mongo.Connect(ctx, options.Client().ApplyURI("mongodb://root:password@mongo:27017")) if err != nil { panic(err) } err = client.Ping(ctx, readpref.Primary()) if err != nil { panic(err) } db := client.Database("example") names, err := db.ListCollectionNames(ctx, bson.M{}) if err != nil { panic(err) } found := false for _, n := range names { if n == collectionName { found = true break } } if !found { err := db.CreateCollection(ctx, collectionName) if err != nil { panic(err) } } return FeedsStorage{ collection: db.Collection(collectionName), } } func (s FeedsStorage) Add(ctx context.Context, name string) error { feed := Feed{ Name: name, Posts: []Post{}, } _, err := s.collection.InsertOne(ctx, feed) if err != nil { if !isDuplicateError(err) { return err } } return nil } func (s FeedsStorage) All(ctx context.Context) ([]Feed, error) { cursor, err := s.collection.Find(ctx, bson.M{}) if err != nil { return nil, err } var feeds []Feed err = cursor.All(ctx, &feeds) if err != nil { return nil, err } return feeds, nil } func (s FeedsStorage) ByName(ctx context.Context, name string) (Feed, error) { filter := bson.M{ "_id": name, } var feed Feed err := s.collection.FindOne(ctx, filter).Decode(&feed) if err != nil { return Feed{}, err } return feed, nil } func (s FeedsStorage) AppendPost(ctx context.Context, post Post) error { return s.appendPostIfNotPresent(ctx, post) } func (s FeedsStorage) UpdatePost(ctx context.Context, post Post) error { err := s.updatePostIfPresent(ctx, post) if err != nil { return err } err = s.appendPostIfNotPresent(ctx, post) if err != nil { return err } err = s.removePostIfNotInFeed(ctx, post) if err != nil { return err } return nil } func (s FeedsStorage) updatePostIfPresent(ctx context.Context, post Post) error { if len(post.Tags) == 0 { return nil } filter := bson.M{ "_id": bson.M{ "$in": post.Tags, }, "posts.id": post.ID, } update := bson.M{ "$set": bson.M{ "posts.$": post, }, } _, err := s.collection.UpdateMany(ctx, filter, update) return err } func (s FeedsStorage) appendPostIfNotPresent(ctx context.Context, post Post) error { if len(post.Tags) == 0 { return nil } filter := bson.M{ "_id": bson.M{ "$in": post.Tags, }, "posts.id": bson.M{ "$ne": post.ID, }, } update := bson.M{ "$push": bson.M{ "posts": bson.M{ "$each": bson.A{post}, "$position": 0, }, }, } _, err := s.collection.UpdateMany(ctx, filter, update) return err } func (s FeedsStorage) removePostIfNotInFeed(ctx context.Context, post Post) error { tags := post.Tags if tags == nil { tags = []string{} } filter := bson.M{ "_id": bson.M{ "$nin": tags, }, "posts.id": post.ID, } update := bson.M{ "$pull": bson.M{ "posts": bson.M{ "id": post.ID, }, }, } _, err := s.collection.UpdateMany(ctx, filter, update) if err != nil { return err } return nil } func isDuplicateError(err error) bool { mErr, ok := err.(mongo.WriteException) if !ok { return false } return mErr.WriteErrors[0].Code == 11000 } ================================================ FILE: _examples/real-world-examples/server-sent-events/server/go.mod ================================================ module main.go go 1.25 require ( github.com/ThreeDotsLabs/watermill v1.5.1 github.com/ThreeDotsLabs/watermill-nats v1.0.7 github.com/go-chi/chi/v5 v5.2.3 github.com/go-chi/render v1.0.3 github.com/go-sql-driver/mysql v1.9.3 github.com/golang/snappy v1.0.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/nats-io/stan.go v0.10.4 go.mongodb.org/mongo-driver v1.17.4 golang.org/x/crypto v0.41.0 // indirect golang.org/x/sync v0.16.0 // indirect golang.org/x/text v0.28.0 // indirect ) require ( github.com/ThreeDotsLabs/watermill-http/v2 v2.3.1 github.com/brianvoe/gofakeit/v6 v6.28.0 ) require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/ajg/form v1.5.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/go-chi/chi v4.1.2+incompatible // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/hashicorp/go-hclog v1.4.0 // indirect github.com/hashicorp/go-msgpack v0.5.5 // indirect github.com/hashicorp/raft v1.3.11 // indirect github.com/lithammer/shortuuid/v3 v3.0.7 // indirect github.com/minio/highwayhash v1.0.2 // indirect github.com/montanaflynn/stats v0.7.1 // indirect github.com/nats-io/jwt/v2 v2.3.0 // indirect github.com/nats-io/nats.go v1.45.0 // indirect github.com/nats-io/nkeys v0.4.11 // indirect github.com/nats-io/nuid v1.0.1 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/sony/gobreaker v1.0.0 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.1.2 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect go.etcd.io/bbolt v1.3.6 // indirect golang.org/x/sys v0.35.0 // indirect golang.org/x/time v0.3.0 // indirect ) ================================================ FILE: _examples/real-world-examples/server-sent-events/server/go.sum ================================================ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/DataDog/datadog-go v2.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY= github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892LARrKAdki6WtXS4= github.com/ThreeDotsLabs/watermill-http/v2 v2.3.1 h1:M0iYM5HsGcoxtiQqprRlYZNZnGk3w5LsE9RbC2R8myQ= github.com/ThreeDotsLabs/watermill-http/v2 v2.3.1/go.mod h1:RwGHEzGsEEXC/rQNLWQqR83+WPlABgOgnv2kTB56Y4Y= github.com/ThreeDotsLabs/watermill-nats v1.0.7 h1:hOquWq0GAwm5jaIc3wGaDoVCPYL+If4NZPb+RUaHni4= github.com/ThreeDotsLabs/watermill-nats v1.0.7/go.mod h1:t5A8XbO/v8CPM+AIljgoO9NR1jBk3ixYBGAtvn1N4lA= github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= github.com/armon/go-metrics v0.0.0-20190430140413-ec5e00d3c878 h1:EFSB7Zo9Eg91v7MJPVsifUysc/wPdN+NOnVe6bWbdBM= github.com/armon/go-metrics v0.0.0-20190430140413-ec5e00d3c878/go.mod h1:3AMJUQhVx52RsWOnlkpikZr01T/yAVN2gn0861vByNg= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4= github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyNz34tQRec= github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4= github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-hclog v0.9.1/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= github.com/hashicorp/go-hclog v1.4.0 h1:ctuWFGrhFha8BnnzxqeRGidlEcQkDyL5u8J8t5eA11I= github.com/hashicorp/go-hclog v1.4.0/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-immutable-radix v1.0.0 h1:AKDB1HM5PWEA7i4nhcpwOrO2byshxBjXVn/J/3+z5/0= github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-msgpack v0.5.5 h1:i9R9JSrqIz0QVLz3sz+i3YJdT7TTSLcfLLzJi9aZTuI= github.com/hashicorp/go-msgpack v0.5.5/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/golang-lru v0.5.0 h1:CL2msUPvZTLb5O648aiLNJw3hnBxN2+1Jq8rCOH9wdo= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/raft v1.3.11 h1:p3v6gf6l3S797NnK5av3HcczOC1T5CLoaRvg0g9ys4A= github.com/hashicorp/raft v1.3.11/go.mod h1:J8naEwc6XaaCfts7+28whSeRvCqTd6e20BlCU3LtEO4= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/minio/highwayhash v1.0.2 h1:Aak5U0nElisjDCfPSG79Tgzkn2gl66NxOMspRrKnA/g= github.com/minio/highwayhash v1.0.2/go.mod h1:BQskDq+xkJ12lmlUUi7U0M5Swg3EWR+dLTk+kldvVxY= github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= github.com/nats-io/jwt/v2 v2.3.0 h1:z2mA1a7tIf5ShggOFlR1oBPgd6hGqcDYsISxZByUzdI= github.com/nats-io/jwt/v2 v2.3.0/go.mod h1:0tqz9Hlu6bCBFLWAASKhE5vUA4c24L9KPUUgvwumE/k= github.com/nats-io/nats-server/v2 v2.6.1 h1:cJy+ia7/4EaJL+ZYDmIy2rD1mDWTfckhtPBU0GYo8xM= github.com/nats-io/nats-server/v2 v2.6.1/go.mod h1:Az91TbZiV7K4a6k/4v6YYdOKEoxCXj+iqhHVf/MlrKo= github.com/nats-io/nats-streaming-server v0.22.1 h1:YKDdLAWZud3UnEBvUPaYppMxSDuh+9czTCDriq19tJY= github.com/nats-io/nats-streaming-server v0.22.1/go.mod h1:1WpVkVV5NyZbHuGGxkaPWopLFnxNthO/TK/BkzFdnPE= github.com/nats-io/nats.go v1.22.1/go.mod h1:tLqubohF7t4z3du1QDPYJIQQyhb4wl6DhjxEajSI7UA= github.com/nats-io/nats.go v1.45.0 h1:/wGPbnYXDM0pLKFjZTX+2JOw9TQPoIgTFrUaH97giwA= github.com/nats-io/nats.go v1.45.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= github.com/nats-io/nkeys v0.3.0/go.mod h1:gvUNGjVcM2IPr5rCsRsC6Wb3Hr2CQAm08dsxtV6A5y4= github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0= github.com/nats-io/nkeys v0.4.11/go.mod h1:szDimtgmfOi9n25JpfIdGw12tZFYXqhGxjhVxsatHVE= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= github.com/nats-io/stan.go v0.10.4 h1:19GS/eD1SeQJaVkeM9EkvEYattnvnWrZ3wkSWSw4uXw= github.com/nats-io/stan.go v0.10.4/go.mod h1:3XJXH8GagrGqajoO/9+HgPyKV5MWsv7S5ccdda+pc6k= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v0.9.2/go.mod h1:OsXs2jCmiKlQ1lTBmv21f2mNfw4xf/QclQDMrYNZzcM= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/common v0.0.0-20181126121408-4724e9255275/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ= github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU= go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4= go.mongodb.org/mongo-driver v1.17.4 h1:jUorfmVzljjr0FLzYQsGP8cgN/qzzxlY9Vh0C9KFXVw= go.mongodb.org/mongo-driver v1.17.4/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210314154223-e6e6c4f2bb5b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190130150945-aca44879d564/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: _examples/real-world-examples/server-sent-events/server/http.go ================================================ package main import ( "context" "encoding/json" "fmt" "math/rand" "net/http" "strings" "time" "github.com/brianvoe/gofakeit/v6" "github.com/go-chi/chi/v5" "github.com/go-chi/render" "github.com/ThreeDotsLabs/watermill" watermillHTTP "github.com/ThreeDotsLabs/watermill-http/v2/pkg/http" "github.com/ThreeDotsLabs/watermill/message" ) var generatedTags = []string{"watermill", "golang", "pubsub", "unicorn", "HelloWorld", "example", "ThreeDotsLabs"} type Router struct { Subscriber message.Subscriber Publisher Publisher PostsStorage PostsStorage FeedsStorage FeedsStorage Logger watermill.LoggerAdapter } func (router Router) Mux() *chi.Mux { r := chi.NewRouter() root := http.Dir("./public") FileServer(r, "/", root) sseRouter, err := watermillHTTP.NewSSERouter( watermillHTTP.SSERouterConfig{ UpstreamSubscriber: router.Subscriber, ErrorHandler: watermillHTTP.DefaultErrorHandler, }, router.Logger, ) if err != nil { panic(err) } postStream := postStreamAdapter{storage: router.PostsStorage, logger: router.Logger} feedStream := feedStreamAdapter{storage: router.FeedsStorage, logger: router.Logger} allFeedsStream := allFeedsStreamAdapter{storage: router.FeedsStorage, logger: router.Logger} postHandler := sseRouter.AddHandler(PostUpdatedTopic, postStream) feedHandler := sseRouter.AddHandler(FeedUpdatedTopic, feedStream) allFeedsHandler := sseRouter.AddHandler(FeedUpdatedTopic, allFeedsStream) r.Route("/api", func(r chi.Router) { r.Get("/posts/{id}", postHandler) r.Post("/posts", router.CreatePost) r.Post("/generate/post", router.GeneratePost) r.Patch("/posts/{id}", router.UpdatePost) r.Get("/feeds/{name}", feedHandler) r.Get("/feeds", allFeedsHandler) }) go func() { err = sseRouter.Run(context.Background()) if err != nil { panic(err) } }() <-sseRouter.Running() return r } type feedSummary struct { Name string `json:"name"` Posts int `json:"posts"` } type AllFeedsResponse struct { Feeds []feedSummary `json:"feeds"` } type allFeedsStreamAdapter struct { storage FeedsStorage logger watermill.LoggerAdapter } func (f allFeedsStreamAdapter) InitialStreamResponse(w http.ResponseWriter, r *http.Request) (response interface{}, ok bool) { resp, err := f.getResponse(r) if err != nil { logAndWriteError(f.logger, w, err) return resp, false } return resp, true } func (f allFeedsStreamAdapter) NextStreamResponse(r *http.Request, msg *message.Message) (response interface{}, ok bool) { resp, err := f.getResponse(r) if err != nil { return resp, false } return resp, true } func (f allFeedsStreamAdapter) getResponse(r *http.Request) (interface{}, error) { feeds, err := f.storage.All(r.Context()) if err != nil { return nil, err } response := AllFeedsResponse{ Feeds: []feedSummary{}, } for _, f := range feeds { response.Feeds = append(response.Feeds, feedSummary{ Name: f.Name, Posts: len(f.Posts), }) } return response, nil } type CreatePostRequest struct { Title string `json:"title"` Content string `json:"content"` Author string `json:"author"` } func (router Router) CreatePost(w http.ResponseWriter, r *http.Request) { req := CreatePostRequest{} err := render.Decode(r, &req) if err != nil { logAndWriteError(router.Logger, w, err) return } post := NewPost( watermill.NewUUID(), req.Title, req.Content, req.Author, ) err = router.addPost(r.Context(), post) if err != nil { logAndWriteError(router.Logger, w, err) return } w.WriteHeader(204) } func (router Router) GeneratePost(w http.ResponseWriter, r *http.Request) { title := gofakeit.Sentence(5) content := gofakeit.Sentence(20) author := gofakeit.Name() tagsCount := rand.Intn(3) + 1 for i := 0; i < tagsCount; i++ { content += fmt.Sprintf(" #%v", generatedTags[rand.Intn(len(generatedTags))]) } post := NewPost( watermill.NewUUID(), title, content, author, ) err := router.addPost(r.Context(), post) if err != nil { logAndWriteError(router.Logger, w, err) return } w.WriteHeader(204) } func (router Router) addPost(ctx context.Context, post Post) error { err := router.PostsStorage.Add(ctx, post) if err != nil { return err } event := PostCreated{ Post: post, OccurredAt: time.Now().UTC(), } err = router.Publisher.Publish(PostCreatedTopic, event) if err != nil { return err } return nil } type UpdatePostRequest struct { ID string `json:"id"` Title string `json:"title"` Content string `json:"content"` } func (router Router) UpdatePost(w http.ResponseWriter, r *http.Request) { req := UpdatePostRequest{} err := render.Decode(r, &req) if err != nil { logAndWriteError(router.Logger, w, err) return } post, err := router.PostsStorage.ByID(r.Context(), req.ID) if err != nil { logAndWriteError(router.Logger, w, err) return } newPost := NewPost( post.ID, req.Title, req.Content, post.Author, ) err = router.PostsStorage.Update(r.Context(), newPost) if err != nil { logAndWriteError(router.Logger, w, err) return } event := PostUpdated{ OriginalPost: post, NewPost: newPost, OccurredAt: time.Now().UTC(), } err = router.Publisher.Publish(PostUpdatedTopic, event) if err != nil { logAndWriteError(router.Logger, w, err) return } w.WriteHeader(204) } type feedStreamAdapter struct { storage FeedsStorage logger watermill.LoggerAdapter } func (f feedStreamAdapter) InitialStreamResponse(w http.ResponseWriter, r *http.Request) (response interface{}, ok bool) { resp, err := f.getResponse(r) if err != nil { logAndWriteError(f.logger, w, err) return nil, false } return resp, true } func (f feedStreamAdapter) NextStreamResponse(r *http.Request, msg *message.Message) (response interface{}, ok bool) { feedUpdated := FeedUpdated{} err := json.Unmarshal(msg.Payload, &feedUpdated) if err != nil { return nil, false } feedName := chi.URLParam(r, "name") if feedUpdated.Name != feedName { return nil, false } resp, err := f.getResponse(r) if err != nil { return nil, false } return resp, true } func (f feedStreamAdapter) getResponse(r *http.Request) (response interface{}, err error) { feedName := chi.URLParam(r, "name") feed, err := f.storage.ByName(r.Context(), feedName) if err != nil { return nil, err } return feed, nil } type postStreamAdapter struct { storage PostsStorage logger watermill.LoggerAdapter } func (p postStreamAdapter) InitialStreamResponse(w http.ResponseWriter, r *http.Request) (response interface{}, ok bool) { resp, err := p.getResponse(r) if err != nil { logAndWriteError(p.logger, w, err) return nil, false } return resp, true } func (p postStreamAdapter) NextStreamResponse(r *http.Request, msg *message.Message) (response interface{}, ok bool) { postUpdated := PostUpdated{} err := json.Unmarshal(msg.Payload, &postUpdated) if err != nil { return nil, false } postID := chi.URLParam(r, "id") if postUpdated.OriginalPost.ID != postID { return nil, false } resp, err := p.getResponse(r) if err != nil { return nil, false } return resp, true } func (p postStreamAdapter) getResponse(r *http.Request) (response interface{}, err error) { postID := chi.URLParam(r, "id") post, err := p.storage.ByID(r.Context(), postID) if err != nil { return nil, err } return post, nil } func FileServer(r chi.Router, path string, root http.FileSystem) { if strings.ContainsAny(path, "{}*") { panic("FileServer does not permit URL parameters.") } fs := http.StripPrefix(path, http.FileServer(root)) if path != "/" && path[len(path)-1] != '/' { r.Get(path, http.RedirectHandler(path+"/", 301).ServeHTTP) path += "/" } path += "*" r.Get(path, func(w http.ResponseWriter, r *http.Request) { fs.ServeHTTP(w, r) }) } func logAndWriteError(logger watermill.LoggerAdapter, w http.ResponseWriter, err error) { logger.Error("Error", err, nil) w.WriteHeader(500) } ================================================ FILE: _examples/real-world-examples/server-sent-events/server/main.go ================================================ package main import ( "net/http" "github.com/ThreeDotsLabs/watermill" ) func main() { logger := watermill.NewStdLogger(false, false) postsStorage := NewPostsStorage() feedsStorage := NewFeedsStorage() pub, sub, err := SetupMessageRouter(feedsStorage, logger) if err != nil { panic(err) } httpRouter := Router{ Subscriber: sub, Publisher: Publisher{publisher: pub}, PostsStorage: postsStorage, FeedsStorage: feedsStorage, Logger: logger, } mux := httpRouter.Mux() err = http.ListenAndServe(":8080", mux) if err != nil { panic(err) } } ================================================ FILE: _examples/real-world-examples/server-sent-events/server/models.go ================================================ package main import ( "regexp" "strings" "time" ) // Note that in a real application using both "json" and "bson" tags in the same structure is strongly discouraged. // We use common models for database storage and HTTP API just to make this example simple and easy to grasp. // See our article about the idea behind this: https://threedots.tech/post/things-to-know-about-dry/ type Post struct { ID string `json:"id" bson:"id"` Title string `json:"title" bson:"title"` Content string `json:"content" bson:"content"` Author string `json:"author" bson:"author"` Tags []string `json:"tags" bson:"tags"` } func NewPost(id, title, content, author string) Post { pattern := regexp.MustCompile("#([a-zA-Z0-9]+)") matches := pattern.FindAllStringSubmatch(content, -1) var tags []string tagsMap := map[string]struct{}{} for _, tag := range matches { tagSlug := strings.ToLower(tag[1]) _, ok := tagsMap[tagSlug] if ok { continue } tagsMap[tagSlug] = struct{}{} tags = append(tags, tagSlug) } return Post{ ID: id, Title: title, Content: content, Author: author, Tags: tags, } } type Feed struct { Name string `json:"name" bson:"_id"` Posts []Post `json:"posts" bson:"posts"` } type PostCreated struct { Post Post `json:"post"` OccurredAt time.Time `json:"occurred_at"` } type PostUpdated struct { OriginalPost Post `json:"original_post"` NewPost Post `json:"new_post"` OccurredAt time.Time `json:"occurred_at"` } type FeedUpdated struct { Name string `json:"name"` OccurredAt time.Time `json:"occurred_at"` } ================================================ FILE: _examples/real-world-examples/server-sent-events/server/posts_storage.go ================================================ package main import ( "context" "database/sql" "fmt" "time" "github.com/go-sql-driver/mysql" ) type PostsStorage struct { db *sql.DB } func NewPostsStorage() PostsStorage { conf := mysql.NewConfig() conf.Net = "tcp" conf.User = "root" conf.Addr = "mysql" conf.DBName = "example" db, err := sql.Open("mysql", conf.FormatDSN()) if err != nil { panic(err) } for { err = db.Ping() if err == nil { break } else { fmt.Println("Could not connect to MySQL, retrying...") time.Sleep(time.Second * 3) } } return PostsStorage{ db: db, } } func (s PostsStorage) ByID(ctx context.Context, id string) (Post, error) { query := "SELECT title, content, author FROM posts WHERE id=?" row := s.db.QueryRowContext(ctx, query, id) var title, content, author string err := row.Scan(&title, &content, &author) if err != nil { return Post{}, err } return NewPost(id, title, content, author), nil } func (s PostsStorage) Add(ctx context.Context, post Post) error { query := "INSERT INTO posts (id, title, content, author) VALUES (?, ?, ?, ?)" _, err := s.db.ExecContext(ctx, query, post.ID, post.Title, post.Content, post.Author) return err } func (s PostsStorage) Update(ctx context.Context, post Post) error { query := "UPDATE posts SET title=?, content=?, author=? WHERE id=?" _, err := s.db.ExecContext(ctx, query, post.Title, post.Content, post.Author, post.ID) return err } ================================================ FILE: _examples/real-world-examples/server-sent-events/server/public/index.html ================================================ Watermill Server-Sent Events Example
Home

================================================ FILE: _examples/real-world-examples/server-sent-events-htmx/Dockerfile ================================================ FROM golang:1.25 AS builder COPY . /src WORKDIR /src/ RUN CGO_ENABLED=0 go build -ldflags="-s -w" -trimpath -o /main . FROM alpine RUN apk add --no-cache ca-certificates COPY --from=builder /main /main CMD ["/main"] ================================================ FILE: _examples/real-world-examples/server-sent-events-htmx/README.md ================================================ # Server Sent Events (htmx) This is an example project described in [Live website updates with Go, SSE, and htmx](https://threedots.tech/post/live-website-updates-go-sse-htmx/). ================================================ FILE: _examples/real-world-examples/server-sent-events-htmx/docker/Dockerfile ================================================ FROM golang:1.25 RUN go install github.com/cespare/reflex@latest RUN go install github.com/a-h/templ/cmd/templ@latest COPY reflex.conf / ENTRYPOINT ["/go/bin/reflex", "-c", "/reflex.conf"] ================================================ FILE: _examples/real-world-examples/server-sent-events-htmx/docker/reflex.conf ================================================ -r '(\.go$|go\.mod$)' -s go run . -r '\.templ$' templ generate ================================================ FILE: _examples/real-world-examples/server-sent-events-htmx/docker-compose.yml ================================================ services: server: build: context: docker volumes: - ./:/src - go_pkg:/go/pkg - go_cache:/go-cache working_dir: /src ports: - '8080:8080' environment: - PORT=8080 - DATABASE_URL=postgres://postgres:postgres@postgres:5432/postgres?sslmode=disable - PUBSUB_PROJECT_ID=local - PUBSUB_EMULATOR_HOST=pubsub:8681 restart: unless-stopped networks: - sse postgres: image: postgres:15 restart: unless-stopped environment: - POSTGRES_PASSWORD=postgres ports: - 5432:5432 networks: - sse pubsub: image: messagebird/gcloud-pubsub-emulator:latest restart: unless-stopped ports: - '8681:8681' networks: - sse networks: sse: volumes: go_pkg: go_cache: ================================================ FILE: _examples/real-world-examples/server-sent-events-htmx/events.go ================================================ package main import ( "context" "fmt" "time" "cloud.google.com/go/pubsub/v2/apiv1/pubsubpb" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill-googlecloud/v2/pkg/googlecloud" "github.com/ThreeDotsLabs/watermill-http/v2/pkg/http" "github.com/ThreeDotsLabs/watermill/components/cqrs" "github.com/ThreeDotsLabs/watermill/message" "github.com/ThreeDotsLabs/watermill/message/router/middleware" "google.golang.org/protobuf/types/known/durationpb" ) type PostViewed struct { PostID int `json:"post_id"` } type PostReactionAdded struct { PostID int `json:"post_id"` ReactionID string `json:"reaction_id"` } type PostStatsUpdated struct { PostID int `json:"post_id"` Views int `json:"views"` ViewsUpdated bool `json:"views_updated"` Reactions map[string]int `json:"reactions"` ReactionUpdated *string `json:"reaction_updated"` } type Routers struct { EventsRouter *message.Router SSERouter http.SSERouter EventBus *cqrs.EventBus } func NewRouters(cfg config, repo *Repository) (Routers, error) { logger := watermill.NewStdLogger(false, false) publisher, err := googlecloud.NewPublisher( googlecloud.PublisherConfig{ ProjectID: cfg.PubSubProjectID, }, logger, ) if err != nil { return Routers{}, err } eventBus, err := cqrs.NewEventBusWithConfig( publisher, cqrs.EventBusConfig{ GeneratePublishTopic: func(params cqrs.GenerateEventPublishTopicParams) (string, error) { return params.EventName, nil }, Marshaler: cqrs.JSONMarshaler{}, Logger: logger, }, ) if err != nil { return Routers{}, err } eventsRouter, err := message.NewRouter(message.RouterConfig{}, logger) if err != nil { return Routers{}, err } eventsRouter.AddMiddleware(middleware.Recoverer) eventProcessor, err := cqrs.NewEventProcessorWithConfig( eventsRouter, cqrs.EventProcessorConfig{ GenerateSubscribeTopic: func(params cqrs.EventProcessorGenerateSubscribeTopicParams) (string, error) { return params.EventName, nil }, SubscriberConstructor: func(params cqrs.EventProcessorSubscriberConstructorParams) (message.Subscriber, error) { return googlecloud.NewSubscriber( googlecloud.SubscriberConfig{ ProjectID: cfg.PubSubProjectID, GenerateSubscriptionName: func(topic string) string { return fmt.Sprintf("%v_%v", topic, params.HandlerName) }, }, logger, ) }, Marshaler: cqrs.JSONMarshaler{}, Logger: logger, }, ) if err != nil { return Routers{}, err } err = eventProcessor.AddHandlers( cqrs.NewEventHandler( "UpdateViews", func(ctx context.Context, event *PostViewed) error { var views int var reactions map[string]int err = repo.UpdatePost(ctx, event.PostID, func(post *Post) { post.Views++ views = post.Views reactions = post.Reactions }) if err != nil { return err } statsUpdated := PostStatsUpdated{ PostID: event.PostID, ViewsUpdated: true, Views: views, Reactions: reactions, } return eventBus.Publish(ctx, statsUpdated) }, ), cqrs.NewEventHandler( "UpdateReactions", func(ctx context.Context, event *PostReactionAdded) error { var views int var reactions map[string]int err := repo.UpdatePost(ctx, event.PostID, func(post *Post) { post.Reactions[event.ReactionID]++ views = post.Views reactions = post.Reactions }) if err != nil { return err } statsUpdated := PostStatsUpdated{ PostID: event.PostID, Views: views, ReactionUpdated: &event.ReactionID, Reactions: reactions, } return eventBus.Publish(ctx, statsUpdated) }, ), ) if err != nil { return Routers{}, err } sseSubscriber, err := googlecloud.NewSubscriber( googlecloud.SubscriberConfig{ ProjectID: cfg.PubSubProjectID, GenerateSubscriptionName: func(topic string) string { return fmt.Sprintf("%v_%v", topic, watermill.NewShortUUID()) }, GenerateSubscription: func(params googlecloud.GenerateSubscriptionParams) *pubsubpb.Subscription { return &pubsubpb.Subscription{ ExpirationPolicy: &pubsubpb.ExpirationPolicy{ Ttl: durationpb.New(time.Hour * 24), }, } }, }, logger, ) if err != nil { return Routers{}, err } sseRouter, err := http.NewSSERouter( http.SSERouterConfig{ UpstreamSubscriber: sseSubscriber, Marshaler: http.StringSSEMarshaler{}, }, logger, ) if err != nil { return Routers{}, err } return Routers{ EventsRouter: eventsRouter, SSERouter: sseRouter, EventBus: eventBus, }, nil } ================================================ FILE: _examples/real-world-examples/server-sent-events-htmx/go.mod ================================================ module main go 1.25 require ( cloud.google.com/go/pubsub/v2 v2.0.0 github.com/ThreeDotsLabs/watermill v1.5.1 github.com/ThreeDotsLabs/watermill-googlecloud/v2 v2.0.0 github.com/ThreeDotsLabs/watermill-http/v2 v2.3.1 github.com/a-h/templ v0.3.943 github.com/kelseyhightower/envconfig v1.4.0 github.com/labstack/echo/v4 v4.13.4 github.com/lib/pq v1.10.9 google.golang.org/protobuf v1.36.8 ) require ( cloud.google.com/go v0.121.6 // indirect cloud.google.com/go/auth v0.16.5 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.8.0 // indirect cloud.google.com/go/iam v1.5.2 // indirect github.com/ajg/form v1.5.1 // indirect github.com/cenkalti/backoff/v3 v3.2.2 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-chi/chi v4.1.2+incompatible // indirect github.com/go-chi/render v1.0.3 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect github.com/labstack/gommon v0.4.2 // indirect github.com/lithammer/shortuuid/v3 v3.0.7 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/sony/gobreaker v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect go.opentelemetry.io/otel v1.38.0 // indirect go.opentelemetry.io/otel/metric v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect golang.org/x/crypto v0.41.0 // indirect golang.org/x/net v0.43.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sync v0.16.0 // indirect golang.org/x/sys v0.35.0 // indirect golang.org/x/text v0.28.0 // indirect golang.org/x/time v0.12.0 // indirect google.golang.org/api v0.248.0 // indirect google.golang.org/genproto v0.0.0-20250826171959-ef028d996bc1 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250826171959-ef028d996bc1 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1 // indirect google.golang.org/grpc v1.75.0 // indirect ) ================================================ FILE: _examples/real-world-examples/server-sent-events-htmx/go.sum ================================================ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.121.6 h1:waZiuajrI28iAf40cWgycWNgaXPO06dupuS+sgibK6c= cloud.google.com/go v0.121.6/go.mod h1:coChdst4Ea5vUpiALcYKXEpR1S9ZgXbhEzzMcMR66vI= cloud.google.com/go/auth v0.16.5 h1:mFWNQ2FEVWAliEQWpAdH80omXFokmrnbDhUS9cBywsI= cloud.google.com/go/auth v0.16.5/go.mod h1:utzRfHMP+Vv0mpOkTRQoWD2q3BatTOoWbA7gCc2dUhQ= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.8.0 h1:HxMRIbao8w17ZX6wBnjhcDkW6lTFpgcaobyVfZWqRLA= cloud.google.com/go/compute/metadata v0.8.0/go.mod h1:sYOGTp851OV9bOFJ9CH7elVvyzopvWQFNNghtDQ/Biw= cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8= cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE= cloud.google.com/go/pubsub/v2 v2.0.0 h1:0qS6mRJ41gD1lNmM/vdm6bR7DQu6coQcVwD+VPf0Bz0= cloud.google.com/go/pubsub/v2 v2.0.0/go.mod h1:0aztFxNzVQIRSZ8vUr79uH2bS3jwLebwK6q1sgEub+E= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY= github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892LARrKAdki6WtXS4= github.com/ThreeDotsLabs/watermill-googlecloud/v2 v2.0.0 h1:GXR+tsxPs/Vpmm0t4yEJUZdqLP9EytWvR+KN3Un5mNY= github.com/ThreeDotsLabs/watermill-googlecloud/v2 v2.0.0/go.mod h1:3IHyi1bNqQ8J2/wVWj4cQjzWXoEPauLm8ViyOCNaKbM= github.com/ThreeDotsLabs/watermill-http/v2 v2.3.1 h1:M0iYM5HsGcoxtiQqprRlYZNZnGk3w5LsE9RbC2R8myQ= github.com/ThreeDotsLabs/watermill-http/v2 v2.3.1/go.mod h1:RwGHEzGsEEXC/rQNLWQqR83+WPlABgOgnv2kTB56Y4Y= github.com/a-h/templ v0.3.943 h1:o+mT/4yqhZ33F3ootBiHwaY4HM5EVaOJfIshvd5UNTY= github.com/a-h/templ v0.3.943/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo= github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= github.com/cenkalti/backoff/v3 v3.2.2 h1:cfUAAO3yvKMYKPrvhDuHSwQnhZNk/RMHKdZqKTxfm6M= github.com/cenkalti/backoff/v3 v3.2.2/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4rc0ij+ULvLYs= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyNz34tQRec= github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4= github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA= github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ= github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.einride.tech/aip v0.73.0 h1:bPo4oqBo2ZQeBKo4ZzLb1kxYXTY1ysJhpvQyfuGzvps= go.einride.tech/aip v0.73.0/go.mod h1:Mj7rFbmXEgw0dq1dqJ7JGMvYCZZVxmGOR3S4ZcV5LvQ= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/api v0.248.0 h1:hUotakSkcwGdYUqzCRc5yGYsg4wXxpkKlW5ryVqvC1Y= google.golang.org/api v0.248.0/go.mod h1:yAFUAF56Li7IuIQbTFoLwXTCI6XCFKueOlS7S9e4F9k= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= google.golang.org/genproto v0.0.0-20250826171959-ef028d996bc1 h1:Nm5SEGIguOIBDXs5rhfz2aKwEVWlgwC58UcmEnLDc8Y= google.golang.org/genproto v0.0.0-20250826171959-ef028d996bc1/go.mod h1:Jz9LrroM7Mcm+a0QrLh4UpZ1B/WhjIbqwEcUf4y08nQ= google.golang.org/genproto/googleapis/api v0.0.0-20250826171959-ef028d996bc1 h1:APHvLLYBhtZvsbnpkfknDZ7NyH4z5+ub/I0u8L3Oz6g= google.golang.org/genproto/googleapis/api v0.0.0-20250826171959-ef028d996bc1/go.mod h1:xUjFWUnWDpZ/C0Gu0qloASKFb6f8/QXiiXhSPFsD668= google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1 h1:pmJpJEvT846VzausCQ5d7KreSROcDqmO388w5YbnltA= google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1/go.mod h1:GmFNa4BdJZ2a8G+wCe9Bg3wwThLrJun751XstdJt5Og= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4= google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= ================================================ FILE: _examples/real-world-examples/server-sent-events-htmx/http.go ================================================ package main import ( "bytes" "context" "encoding/json" "fmt" "main/views" "net/http" "strconv" "sync/atomic" "time" watermillhttp "github.com/ThreeDotsLabs/watermill-http/v2/pkg/http" "github.com/ThreeDotsLabs/watermill/components/cqrs" "github.com/ThreeDotsLabs/watermill/message" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" ) type Handler struct { repo *Repository eventBus *cqrs.EventBus } func NewHandler(repo *Repository, eventBus *cqrs.EventBus, sseRouter watermillhttp.SSERouter) *echo.Echo { h := Handler{ repo: repo, eventBus: eventBus, } marshaler := cqrs.JSONMarshaler{} topic := marshaler.Name(PostStatsUpdated{}) statsHandler := sseRouter.AddHandler(topic, &statsStream{repo: repo}) e := echo.New() e.Use(middleware.Recover()) e.Use(middleware.Logger()) counter := sseHandlersCounter{} e.GET("/", h.Index) e.GET("/posts", h.Posts) e.GET("/idle", h.Idle) e.POST("/posts/:id/reactions", h.AddReaction) e.GET("/posts/:id/stats", func(c echo.Context) error { postID := c.Param("id") c.Request().SetPathValue("id", postID) statsHandler(c.Response(), c.Request()) return nil }, counter.Middleware) go func() { for { fmt.Println("SSE handlers count:", counter.Count.Load()) time.Sleep(60 * time.Second) } }() return e } func (h Handler) Index(c echo.Context) error { posts, err := h.allPosts(c) if err != nil { return err } return views.Index(posts).Render(c.Request().Context(), c.Response()) } func (h Handler) Posts(c echo.Context) error { posts, err := h.allPosts(c) if err != nil { return err } return views.Posts(posts).Render(c.Request().Context(), c.Response()) } func (h Handler) Idle(c echo.Context) error { return views.Idle().Render(c.Request().Context(), c.Response()) } func (h Handler) allPosts(c echo.Context) ([]views.Post, error) { posts, err := h.repo.AllPosts(c.Request().Context()) if err != nil { return nil, err } for _, post := range posts { event := PostViewed{ PostID: post.ID, } err = h.eventBus.Publish(c.Request().Context(), event) if err != nil { return nil, err } } var postViews []views.Post for _, post := range posts { postViews = append(postViews, newPostView(post)) } return postViews, nil } func (h Handler) AddReaction(c echo.Context) error { postID, err := strconv.Atoi(c.Param("id")) if err != nil { return err } reactionID := c.FormValue("reaction_id") var found bool for _, r := range allReactions { if r.ID == reactionID { found = true break } } if !found { return c.String(http.StatusBadRequest, "invalid reaction ID") } event := PostReactionAdded{ PostID: postID, ReactionID: reactionID, } err = h.eventBus.Publish(c.Request().Context(), event) if err != nil { return err } reaction := mustReactionByID(reactionID) return views.UpdatedButton(reaction.Label).Render(c.Request().Context(), c.Response()) } type statsStream struct { repo *Repository } func (s *statsStream) InitialStreamResponse(w http.ResponseWriter, r *http.Request) (response interface{}, ok bool) { postIDStr := r.PathValue("id") postID, err := strconv.Atoi(postIDStr) if err != nil { w.WriteHeader(http.StatusBadRequest) w.Write([]byte("invalid post ID")) return nil, false } post, err := s.repo.PostByID(r.Context(), postID) if err != nil { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte("could not get post")) return nil, false } stats := PostStats{ ID: post.ID, Views: post.Views, Reactions: post.Reactions, } resp, err := newPostStatsView(r.Context(), stats) if err != nil { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(err.Error())) return nil, false } return resp, true } func (s *statsStream) NextStreamResponse(r *http.Request, msg *message.Message) (response interface{}, ok bool) { postIDStr := r.PathValue("id") postID, err := strconv.Atoi(postIDStr) if err != nil { fmt.Println("invalid post ID") return nil, false } var event PostStatsUpdated err = json.Unmarshal(msg.Payload, &event) if err != nil { fmt.Println("cannot unmarshal: " + err.Error()) return "", false } if event.PostID != postID { return "", false } stats := PostStats{ ID: event.PostID, Views: event.Views, ViewsUpdated: event.ViewsUpdated, Reactions: event.Reactions, ReactionUpdated: event.ReactionUpdated, } resp, err := newPostStatsView(r.Context(), stats) if err != nil { fmt.Println("could not get response: " + err.Error()) return nil, false } return resp, true } func newPostStatsView(ctx context.Context, stats PostStats) (interface{}, error) { var reactions []views.Reaction for _, r := range allReactions { reactions = append(reactions, views.Reaction{ ID: r.ID, Label: r.Label, Count: fmt.Sprint(stats.Reactions[r.ID]), JustChanged: stats.ReactionUpdated != nil && *stats.ReactionUpdated == r.ID, }) } view := views.PostStats{ PostID: fmt.Sprint(stats.ID), Views: views.PostViews{ Count: fmt.Sprint(stats.Views), JustChanged: stats.ViewsUpdated, }, Reactions: reactions, } var buffer bytes.Buffer err := views.PostStatsView(view).Render(ctx, &buffer) if err != nil { return nil, err } return buffer.String(), nil } func newPostView(p Post) views.Post { return views.Post{ ID: fmt.Sprint(p.ID), Content: p.Content, Author: p.Author, Date: p.CreatedAt.Format("02 Jan 2006 15:04"), } } type sseHandlersCounter struct { Count atomic.Int64 } func (s *sseHandlersCounter) Middleware(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { s.Count.Add(1) defer s.Count.Add(-1) return next(c) } } ================================================ FILE: _examples/real-world-examples/server-sent-events-htmx/main.go ================================================ package main import ( "context" "database/sql" "fmt" "math/rand" "time" "github.com/kelseyhightower/envconfig" _ "github.com/lib/pq" ) type config struct { Port int `envconfig:"PORT" required:"true"` DatabaseURL string `envconfig:"DATABASE_URL" required:"true"` PubSubProjectID string `envconfig:"PUBSUB_PROJECT_ID" required:"true"` } func main() { var cfg config err := envconfig.Process("", &cfg) if err != nil { panic(err) } db, err := sql.Open("postgres", cfg.DatabaseURL) if err != nil { panic(err) } err = MigrateDB(db) if err != nil { panic(err) } repo := NewRepository(db) routers, err := NewRouters(cfg, repo) if err != nil { panic(err) } go func() { err := routers.EventsRouter.Run(context.Background()) if err != nil { panic(err) } }() go func() { err := routers.SSERouter.Run(context.Background()) if err != nil { panic(err) } }() go func() { // This goroutine simulates some events being published in the background ctx := context.Background() for { postID := 1 + rand.Intn(2) if rand.Intn(2) == 0 { _ = routers.EventBus.Publish(ctx, PostViewed{ PostID: postID, }) } else { _ = routers.EventBus.Publish(ctx, PostReactionAdded{ PostID: postID, ReactionID: allReactions[rand.Intn(len(allReactions))].ID, }) } time.Sleep(time.Millisecond * time.Duration(3000+rand.Intn(5000))) } }() handler := NewHandler(repo, routers.EventBus, routers.SSERouter) err = handler.Start(fmt.Sprintf(":%d", cfg.Port)) if err != nil { panic(err) } } ================================================ FILE: _examples/real-world-examples/server-sent-events-htmx/models.go ================================================ package main import "time" var allReactions = []Reaction{ { ID: "fire", Label: "🔥", }, { ID: "thinking", Label: "🤔", }, { ID: "heart", Label: "🩵", }, { ID: "laugh", Label: "😂", }, { ID: "sad", Label: "😢", }, } func mustReactionByID(id string) Reaction { for _, r := range allReactions { if r.ID == id { return r } } panic("reaction not found") } type Reaction struct { ID string Label string } type Post struct { ID int Author string Content string CreatedAt time.Time Views int Reactions map[string]int } type PostStats struct { ID int Views int ViewsUpdated bool Reactions map[string]int ReactionUpdated *string } ================================================ FILE: _examples/real-world-examples/server-sent-events-htmx/repository.go ================================================ package main import ( "context" "database/sql" "encoding/json" "time" ) const migration = ` CREATE TABLE IF NOT EXISTS posts ( id serial PRIMARY KEY, author VARCHAR NOT NULL, content TEXT NOT NULL, views INT NOT NULL DEFAULT 0, reactions JSONB NOT NULL DEFAULT '{}', created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); INSERT INTO posts (id, author, content) VALUES (1, 'Miłosz', 'Oh, I remember the days when we used to write code in PHP!'), (2, 'Robert', 'Back in my days, we used to write code in assembly!') ON CONFLICT (id) DO NOTHING; ` func MigrateDB(db *sql.DB) error { _, err := db.Exec(migration) return err } type Repository struct { db *sql.DB } func NewRepository(db *sql.DB) *Repository { return &Repository{ db: db, } } func (s *Repository) PostByID(ctx context.Context, id int) (Post, error) { row := s.db.QueryRowContext(ctx, `SELECT id, author, content, views, reactions, created_at FROM posts WHERE id = $1`, id) post, err := scanPost(row) if err != nil { return Post{}, err } return post, nil } func (s *Repository) AllPosts(ctx context.Context) ([]Post, error) { rows, err := s.db.QueryContext(ctx, `SELECT id, author, content, views, reactions, created_at FROM posts ORDER BY id ASC`) if err != nil { return nil, err } var posts []Post for rows.Next() { post, err := scanPost(rows) if err != nil { return nil, err } posts = append(posts, post) } return posts, nil } func (s *Repository) UpdatePost(ctx context.Context, id int, updateFn func(post *Post)) (err error) { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } defer func() { if err == nil { err = tx.Commit() } else { txErr := tx.Rollback() if txErr != nil { err = txErr } } }() row := s.db.QueryRowContext(ctx, `SELECT id, author, content, views, reactions, created_at FROM posts WHERE id = $1 FOR UPDATE`, id) post, err := scanPost(row) if err != nil { return err } updateFn(&post) reactionsJSON, err := json.Marshal(post.Reactions) if err != nil { return err } _, err = tx.ExecContext(ctx, `UPDATE posts SET views = $1, reactions = $2 WHERE id = $3`, post.Views, reactionsJSON, post.ID) if err != nil { return err } return nil } type scanner interface { Scan(dest ...any) error } func scanPost(s scanner) (Post, error) { var id, postViews int var author, content string var reactions []byte var createdAt time.Time err := s.Scan(&id, &author, &content, &postViews, &reactions, &createdAt) if err != nil { return Post{}, err } var reactionsMap map[string]int err = json.Unmarshal(reactions, &reactionsMap) if err != nil { return Post{}, err } return Post{ ID: id, Author: author, Content: content, CreatedAt: createdAt, Views: postViews, Reactions: reactionsMap, }, nil } ================================================ FILE: _examples/real-world-examples/server-sent-events-htmx/views/base.templ ================================================ package views templ base() { Server Sent Events
{ children... }
} ================================================ FILE: _examples/real-world-examples/server-sent-events-htmx/views/base_templ.go ================================================ // Code generated by templ - DO NOT EDIT. // templ: version: v0.2.663 package views //lint:file-ignore SA4006 This context is only used if a nested component is present. import "github.com/a-h/templ" import "context" import "io" import "bytes" func base() templ.Component { return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) if !templ_7745c5c3_IsBuffer { templ_7745c5c3_Buffer = templ.GetBuffer() defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) } ctx = templ.InitializeContext(ctx) templ_7745c5c3_Var1 := templ.GetChildren(ctx) if templ_7745c5c3_Var1 == nil { templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("Server Sent Events
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if !templ_7745c5c3_IsBuffer { _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W) } return templ_7745c5c3_Err }) } ================================================ FILE: _examples/real-world-examples/server-sent-events-htmx/views/pages.templ ================================================ package views type Post struct { ID string Content string Author string Date string } type Reaction struct { ID string Label string Count string JustChanged bool } type PostStats struct { PostID string Views PostViews Reactions []Reaction } type PostViews struct { Count string JustChanged bool } templ Index(posts []Post) { @base() { @Posts(posts) } } templ Posts(posts []Post) {
for _, p := range posts { @postView(p) }
} templ Idle() {
Paused to save resources. 🫢
Still around?
} templ postView(post Post) {
{ post.Author }
{ post.Date }

{ post.Content }

} templ PostStatsView(stats PostStats) {
👁️ { stats.Views.Count + " views" }
for _, r := range stats.Reactions { @reactionButton(stats.PostID, r) }
} templ reactionButton(postID string, reaction Reaction) {
} templ UpdatedButton(label string) { } ================================================ FILE: _examples/real-world-examples/server-sent-events-htmx/views/pages_templ.go ================================================ // Code generated by templ - DO NOT EDIT. // templ: version: v0.2.663 package views //lint:file-ignore SA4006 This context is only used if a nested component is present. import "github.com/a-h/templ" import "context" import "io" import "bytes" type Post struct { ID string Content string Author string Date string } type Reaction struct { ID string Label string Count string JustChanged bool } type PostStats struct { PostID string Views PostViews Reactions []Reaction } type PostViews struct { Count string JustChanged bool } func Index(posts []Post) templ.Component { return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) if !templ_7745c5c3_IsBuffer { templ_7745c5c3_Buffer = templ.GetBuffer() defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) } ctx = templ.InitializeContext(ctx) templ_7745c5c3_Var1 := templ.GetChildren(ctx) if templ_7745c5c3_Var1 == nil { templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) templ_7745c5c3_Var2 := templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) if !templ_7745c5c3_IsBuffer { templ_7745c5c3_Buffer = templ.GetBuffer() defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) } templ_7745c5c3_Err = Posts(posts).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if !templ_7745c5c3_IsBuffer { _, templ_7745c5c3_Err = io.Copy(templ_7745c5c3_W, templ_7745c5c3_Buffer) } return templ_7745c5c3_Err }) templ_7745c5c3_Err = base().Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if !templ_7745c5c3_IsBuffer { _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W) } return templ_7745c5c3_Err }) } func Posts(posts []Post) templ.Component { return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) if !templ_7745c5c3_IsBuffer { templ_7745c5c3_Buffer = templ.GetBuffer() defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) } ctx = templ.InitializeContext(ctx) templ_7745c5c3_Var3 := templ.GetChildren(ctx) if templ_7745c5c3_Var3 == nil { templ_7745c5c3_Var3 = templ.NopComponent } ctx = templ.ClearChildren(ctx) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, p := range posts { templ_7745c5c3_Err = postView(p).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if !templ_7745c5c3_IsBuffer { _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W) } return templ_7745c5c3_Err }) } func Idle() templ.Component { return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) if !templ_7745c5c3_IsBuffer { templ_7745c5c3_Buffer = templ.GetBuffer() defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) } ctx = templ.InitializeContext(ctx) templ_7745c5c3_Var4 := templ.GetChildren(ctx) if templ_7745c5c3_Var4 == nil { templ_7745c5c3_Var4 = templ.NopComponent } ctx = templ.ClearChildren(ctx) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
Paused to save resources. 🫢
Still around?
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if !templ_7745c5c3_IsBuffer { _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W) } return templ_7745c5c3_Err }) } func postView(post Post) templ.Component { return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) if !templ_7745c5c3_IsBuffer { templ_7745c5c3_Buffer = templ.GetBuffer() defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) } ctx = templ.InitializeContext(ctx) templ_7745c5c3_Var5 := templ.GetChildren(ctx) if templ_7745c5c3_Var5 == nil { templ_7745c5c3_Var5 = templ.NopComponent } ctx = templ.ClearChildren(ctx) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(post.Author) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 70, Col: 53} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(post.Date) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 72, Col: 56} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(post.Content) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 76, Col: 43} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if !templ_7745c5c3_IsBuffer { _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W) } return templ_7745c5c3_Err }) } func PostStatsView(stats PostStats) templ.Component { return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) if !templ_7745c5c3_IsBuffer { templ_7745c5c3_Buffer = templ.GetBuffer() defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) } ctx = templ.InitializeContext(ctx) templ_7745c5c3_Var10 := templ.GetChildren(ctx) if templ_7745c5c3_Var10 == nil { templ_7745c5c3_Var10 = templ.NopComponent } ctx = templ.ClearChildren(ctx) var templ_7745c5c3_Var11 = []any{"d-flex", "align-items-center", templ.KV("animated", stats.Views.JustChanged)} templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var11...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
👁️ ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var13 string templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(stats.Views.Count + " views") if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `views/pages.templ`, Line: 85, Col: 64} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, r := range stats.Reactions { templ_7745c5c3_Err = reactionButton(stats.PostID, r).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if !templ_7745c5c3_IsBuffer { _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W) } return templ_7745c5c3_Err }) } func reactionButton(postID string, reaction Reaction) templ.Component { return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) if !templ_7745c5c3_IsBuffer { templ_7745c5c3_Buffer = templ.GetBuffer() defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) } ctx = templ.InitializeContext(ctx) templ_7745c5c3_Var14 := templ.GetChildren(ctx) if templ_7745c5c3_Var14 == nil { templ_7745c5c3_Var14 = templ.NopComponent } ctx = templ.ClearChildren(ctx) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var17 = []any{"btn", "btn-outline-secondary", "m-1", templ.KV("animated", reaction.JustChanged)} templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var17...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if !templ_7745c5c3_IsBuffer { _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W) } return templ_7745c5c3_Err }) } func UpdatedButton(label string) templ.Component { return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) if !templ_7745c5c3_IsBuffer { templ_7745c5c3_Buffer = templ.GetBuffer() defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) } ctx = templ.InitializeContext(ctx) templ_7745c5c3_Var21 := templ.GetChildren(ctx) if templ_7745c5c3_Var21 == nil { templ_7745c5c3_Var21 = templ.NopComponent } ctx = templ.ClearChildren(ctx) var templ_7745c5c3_Var22 = []any{"btn", "btn-outline-secondary", "m-1"} templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var22...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if !templ_7745c5c3_IsBuffer { _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W) } return templ_7745c5c3_Err }) } ================================================ FILE: _examples/real-world-examples/synchronizing-databases/.validate_example.yml ================================================ validation_cmd: "docker compose up" teardown_cmd: "docker compose down" timeout: 180 expected_output: "received user:" ================================================ FILE: _examples/real-world-examples/synchronizing-databases/README.md ================================================ # Synchronizing Databases (MySQL to PostgreSQL) This example shows how to use [SQL Pub/Sub](https://github.com/ThreeDotsLabs/watermill-sql) across two different databases. See also [SQL Pub/Sub documentation](https://watermill.io/pubsubs/sql). ## Background Synchronizing two databases can be a tough task, especially with different data formats. This example shows how to migrate a MySQL table to PostgreSQL table using watermill. The application will first transfer all existing rows and then keep listening for any new inserts, copying them to the new table as soon, as they appear. Only new rows will be detected, there's no support for updates or deletes. The `main.go` file contains watermill-related setup, database connections and the handler translating events from one format to another. In `mysql.go` and `postgres.go` you will find definitions of `SchemaAdapters` for each database. For more detailed description, see [When an SQL database makes a great Pub/Sub](https://threedots.tech/post/when-sql-database-makes-great-pub-sub/). ## Requirements To run this example you will need Docker and docker-compose installed. See installation guide at https://docs.docker.com/compose/install/ ## Running Run the command and observe standard output. It should print out incoming users. ```bash docker-compose up ``` Check what's inside MySQL by running: ``` docker-compose exec mysql mysql -e 'select * from watermill.users;' ``` ``` +----+------------------+------------+-----------+---------------------+ | id | user | first_name | last_name | created_at | +----+------------------+------------+-----------+---------------------+ | 1 | Carroll8506 | Marc | Murphy | 2019-09-28 13:51:53 | | 2 | Metz8415 | Briana | Bauch | 2019-09-28 13:51:54 | | 3 | Lebsack6887 | Tomasa | Steuber | 2019-09-28 13:51:55 | | 4 | Hauck4518 | Alexandra | Halvorson | 2019-09-28 13:51:56 | | 5 | Reynolds7156 | Ariane | Lebsack | 2019-09-28 13:51:57 | +----+------------------+------------+-----------+---------------------+ ``` And the same for PostgreSQL: ``` docker-compose exec postgres psql -U watermill -d watermill -c 'select * from users;' ``` ``` id | username | full_name | created_at -----+------------------+----------------------+--------------------- 1 | Carroll8506 | Marc Murphy | 2019-09-28 13:51:53 2 | Metz8415 | Briana Bauch | 2019-09-28 13:51:54 3 | Lebsack6887 | Tomasa Steuber | 2019-09-28 13:51:55 4 | Hauck4518 | Alexandra Halvorson | 2019-09-28 13:51:56 5 | Reynolds7156 | Ariane Lebsack | 2019-09-28 13:51:57 ``` ================================================ FILE: _examples/real-world-examples/synchronizing-databases/docker-compose.yml ================================================ services: server: image: golang:1.25 restart: unless-stopped depends_on: - mysql - postgres volumes: - .:/app - $GOPATH/pkg/mod:/go/pkg/mod working_dir: /app command: go run . mysql: image: mysql:8.0 restart: unless-stopped ports: - 3306:3306 environment: MYSQL_DATABASE: watermill MYSQL_ALLOW_EMPTY_PASSWORD: "yes" postgres: image: postgres:11 restart: unless-stopped ports: - 5432:5432 environment: POSTGRES_USER: watermill POSTGRES_DB: watermill POSTGRES_PASSWORD: "password" ================================================ FILE: _examples/real-world-examples/synchronizing-databases/go.mod ================================================ module main.go go 1.25 require ( github.com/ThreeDotsLabs/watermill v1.5.1 github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0 github.com/brianvoe/gofakeit/v6 v6.28.0 github.com/go-sql-driver/mysql v1.9.3 github.com/lib/pq v1.10.9 ) require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.7.5 // indirect github.com/lithammer/shortuuid/v3 v3.0.7 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/sony/gobreaker v1.0.0 // indirect golang.org/x/crypto v0.41.0 // indirect golang.org/x/text v0.28.0 // indirect ) ================================================ FILE: _examples/real-world-examples/synchronizing-databases/go.sum ================================================ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY= github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892LARrKAdki6WtXS4= github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0 h1:vd0eoMPriNjiFTEo7tQ1wmN933lNAWyVhxX931JG5Pw= github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0/go.mod h1:Ce2GVZVnyajAh0AkwxSJXwx8ajBBveu1DI/yatan5jc= github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4= github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ= github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: _examples/real-world-examples/synchronizing-databases/main.go ================================================ package main import ( "bytes" "context" stdSQL "database/sql" "encoding/gob" "fmt" "log" "time" "github.com/brianvoe/gofakeit/v6" driver "github.com/go-sql-driver/mysql" _ "github.com/lib/pq" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill-sql/v4/pkg/sql" "github.com/ThreeDotsLabs/watermill/message" "github.com/ThreeDotsLabs/watermill/message/router/middleware" "github.com/ThreeDotsLabs/watermill/message/router/plugin" ) var ( logger = watermill.NewStdLogger(false, false) postgresTable = "users" mysqlTable = "users" ) func main() { router, err := message.NewRouter(message.RouterConfig{}, logger) if err != nil { panic(err) } router.AddPlugin(plugin.SignalsHandler) router.AddMiddleware(middleware.Recoverer) mysqlDB := createMySQLConnection() postgresDB := createPostgresConnection() subscriber := createSubscriber(mysqlDB) publisher := createPublisher(postgresDB) go simulateEvents(mysqlDB) router.AddHandler( "mysql-to-postgres", mysqlTable, subscriber, postgresTable, publisher, func(msg *message.Message) ([]*message.Message, error) { originUser := mysqlUser{} decoder := gob.NewDecoder(bytes.NewBuffer(msg.Payload)) err := decoder.Decode(&originUser) if err != nil { return nil, err } log.Printf("received user: %+v", originUser) newUser := postgresUser{ ID: originUser.ID, Username: originUser.User, FullName: fmt.Sprintf("%s %s", originUser.FirstName, originUser.LastName), CreatedAt: originUser.CreatedAt, } var payload bytes.Buffer encoder := gob.NewEncoder(&payload) err = encoder.Encode(newUser) if err != nil { return nil, err } newMessage := message.NewMessage(watermill.NewULID(), payload.Bytes()) return []*message.Message{newMessage}, nil }, ) if err := router.Run(context.Background()); err != nil { panic(err) } } func createMySQLConnection() *stdSQL.DB { conf := driver.NewConfig() conf.Net = "tcp" conf.User = "root" conf.Addr = "mysql" conf.DBName = "watermill" conf.ParseTime = true db, err := stdSQL.Open("mysql", conf.FormatDSN()) if err != nil { panic(err) } err = db.Ping() if err != nil { panic(err) } return db } func createPostgresConnection() *stdSQL.DB { dsn := "postgres://watermill:password@postgres/watermill?sslmode=disable" db, err := stdSQL.Open("postgres", dsn) if err != nil { panic(err) } err = db.Ping() if err != nil { panic(err) } return db } func createSubscriber(db *stdSQL.DB) message.Subscriber { sub, err := sql.NewSubscriber( sql.BeginnerFromStdSQL(db), sql.SubscriberConfig{ SchemaAdapter: mysqlSchemaAdapter{}, OffsetsAdapter: sql.DefaultMySQLOffsetsAdapter{}, InitializeSchema: true, }, logger, ) if err != nil { panic(err) } return sub } func createPublisher(db *stdSQL.DB) message.Publisher { pub, err := sql.NewPublisher( sql.BeginnerFromStdSQL(db), sql.PublisherConfig{ SchemaAdapter: postgresSchemaAdapter{}, AutoInitializeSchema: true, }, logger, ) if err != nil { panic(err) } return pub } func simulateEvents(db *stdSQL.DB) { pub, err := sql.NewPublisher( sql.BeginnerFromStdSQL(db), sql.PublisherConfig{ SchemaAdapter: mysqlSchemaAdapter{}, AutoInitializeSchema: true, }, logger, ) if err != nil { panic(err) } for { user := mysqlUser{ User: gofakeit.Username(), FirstName: gofakeit.FirstName(), LastName: gofakeit.LastName(), CreatedAt: time.Now().UTC(), } var payload bytes.Buffer encoder := gob.NewEncoder(&payload) err := encoder.Encode(user) if err != nil { panic(err) } err = pub.Publish(mysqlTable, message.NewMessage( watermill.NewUUID(), payload.Bytes(), )) if err != nil { panic(err) } time.Sleep(time.Second) } } ================================================ FILE: _examples/real-world-examples/synchronizing-databases/mysql.go ================================================ package main import ( "bytes" "encoding/gob" "fmt" "strings" "time" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill-sql/v4/pkg/sql" "github.com/ThreeDotsLabs/watermill/message" ) type mysqlUser struct { ID int64 User string FirstName string LastName string CreatedAt time.Time } type mysqlSchemaAdapter struct { sql.DefaultMySQLSchema } func (m mysqlSchemaAdapter) SchemaInitializingQueries(params sql.SchemaInitializingQueriesParams) ([]sql.Query, error) { createQuery := ` CREATE TABLE IF NOT EXISTS ` + params.Topic + ` ( id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, user VARCHAR(36) NOT NULL, first_name VARCHAR(36) NOT NULL, last_name VARCHAR(36) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); ` return []sql.Query{{Query: createQuery}}, nil } func (m mysqlSchemaAdapter) InsertQuery(params sql.InsertQueryParams) (sql.Query, error) { insertQuery := fmt.Sprintf( `INSERT INTO %s (user, first_name, last_name, created_at) VALUES %s`, params.Topic, strings.TrimRight(strings.Repeat(`(?,?,?,?),`, len(params.Msgs)), ","), ) var args []interface{} for _, msg := range params.Msgs { user := mysqlUser{} decoder := gob.NewDecoder(bytes.NewBuffer(msg.Payload)) err := decoder.Decode(&user) if err != nil { return sql.Query{}, err } args = append(args, user.User, user.FirstName, user.LastName, user.CreatedAt) } return sql.Query{Query: insertQuery, Args: args}, nil } func (m mysqlSchemaAdapter) SelectQuery(params sql.SelectQueryParams) (sql.Query, error) { nextOffsetQuery, err := params.OffsetsAdapter.NextOffsetQuery(sql.NextOffsetQueryParams{ Topic: params.Topic, ConsumerGroup: params.ConsumerGroup, }) if err != nil { return sql.Query{}, err } selectQuery := ` SELECT id, user, first_name, last_name, created_at FROM ` + params.Topic + ` WHERE id > (` + nextOffsetQuery.Query + `) ORDER BY id ASC LIMIT 1` return sql.Query{Query: selectQuery, Args: nextOffsetQuery.Args}, nil } func (m mysqlSchemaAdapter) UnmarshalMessage(params sql.UnmarshalMessageParams) (_ sql.Row, err error) { user := mysqlUser{} err = params.Row.Scan(&user.ID, &user.User, &user.FirstName, &user.LastName, &user.CreatedAt) if err != nil { return sql.Row{}, err } var payload bytes.Buffer encoder := gob.NewEncoder(&payload) err = encoder.Encode(user) if err != nil { return sql.Row{}, err } msg := message.NewMessage(watermill.NewULID(), payload.Bytes()) return sql.Row{ Offset: user.ID, Msg: msg, }, nil } ================================================ FILE: _examples/real-world-examples/synchronizing-databases/postgres.go ================================================ package main import ( "bytes" "encoding/gob" "errors" "fmt" "strings" "time" "github.com/ThreeDotsLabs/watermill-sql/v4/pkg/sql" ) type postgresUser struct { ID int64 Username string FullName string CreatedAt time.Time } type postgresSchemaAdapter struct { sql.DefaultPostgreSQLSchema } func (p postgresSchemaAdapter) SchemaInitializingQueries(params sql.SchemaInitializingQueriesParams) ([]sql.Query, error) { createQuery := ` CREATE TABLE IF NOT EXISTS ` + params.Topic + ` ( id INT NOT NULL PRIMARY KEY, username VARCHAR(36) NOT NULL, full_name VARCHAR(36) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); ` return []sql.Query{{Query: createQuery}}, nil } func (p postgresSchemaAdapter) InsertQuery(params sql.InsertQueryParams) (sql.Query, error) { insertQuery := fmt.Sprintf( `INSERT INTO %s (id, username, full_name, created_at) VALUES %s`, params.Topic, strings.TrimRight(strings.Repeat(`($1,$2,$3,$4),`, len(params.Msgs)), ","), ) var args []interface{} for _, msg := range params.Msgs { user := postgresUser{} decoder := gob.NewDecoder(bytes.NewBuffer(msg.Payload)) err := decoder.Decode(&user) if err != nil { return sql.Query{}, err } args = append(args, user.ID, user.Username, user.FullName, user.CreatedAt) } return sql.Query{Query: insertQuery, Args: args}, nil } func (p postgresSchemaAdapter) SelectQuery(params sql.SelectQueryParams) (sql.Query, error) { // No need to implement this method, as PostgreSQL subscriber is not used in this example. return sql.Query{}, nil } func (p postgresSchemaAdapter) UnmarshalMessage(params sql.UnmarshalMessageParams) (sql.Row, error) { return sql.Row{}, errors.New("not implemented") } ================================================ FILE: _examples/real-world-examples/transactional-events/.validate_example.yml ================================================ validation_cmd: "docker compose up" teardown_cmd: "docker compose down" timeout: 180 expected_output: "received event" ================================================ FILE: _examples/real-world-examples/transactional-events/README.md ================================================ # Transactional Events (MySQL to Kafka) This example shows how to use the SQL Subscriber from the [SQL Pub/Sub](https://github.com/ThreeDotsLabs/watermill-sql). ## Background When producing domain events, you may stumble on a dilemma: should you first persist the aggregate to the storage and then publish a domain event or the other way around? Whatever order you choose, one of the operations can fail and you will end up with inconsistent state. For more detailed description, see [When an SQL database makes a great Pub/Sub](https://threedots.tech/post/when-sql-database-makes-great-pub-sub/). ## Solution This example presents a solution to this problem: saving domain events in transaction with the aggregate in the same database and publishing it asynchronously. The SQL subscriber listens for new records on a MySQL table. Each new record will result in a new event published on the Kafka topic. Kafka Publisher is used just as an example and any other publisher can be used instead. The example uses `DefaultMySQLSchema` as the schema adapter, but you can define your own table definition and queries. See [SQL Pub/Sub documentation](https://watermill.io/pubsubs/sql) for details. ## Requirements To run this example you will need Docker and docker-compose installed. See installation guide at https://docs.docker.com/compose/install/ ## Running ```bash docker-compose up ``` Observe the log output. You will notice new events, generated by the example. In another terminal, run the following command to consume events produced on the Kafka topic. ```bash docker-compose exec server mill kafka consume -b kafka:9092 -t events ``` ================================================ FILE: _examples/real-world-examples/transactional-events/docker-compose.yml ================================================ services: server: image: golang:1.25 restart: unless-stopped depends_on: - mysql volumes: - .:/app - $GOPATH/pkg/mod:/go/pkg/mod working_dir: /app command: > /bin/sh -c "go install github.com/ThreeDotsLabs/watermill/tools/mill@latest && go run main.go" mysql: image: mysql:8.0 restart: unless-stopped ports: - 3306:3306 environment: MYSQL_DATABASE: watermill MYSQL_ALLOW_EMPTY_PASSWORD: "yes" zookeeper: image: confluentinc/cp-zookeeper:7.3.1 logging: driver: none restart: unless-stopped environment: ZOOKEEPER_CLIENT_PORT: 2181 kafka: image: confluentinc/cp-kafka:7.3.1 logging: driver: none restart: unless-stopped depends_on: - zookeeper environment: KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092 KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true" ================================================ FILE: _examples/real-world-examples/transactional-events/go.mod ================================================ module main.go go 1.25 require ( github.com/ThreeDotsLabs/watermill v1.5.1 github.com/ThreeDotsLabs/watermill-kafka/v3 v3.1.0 github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0 github.com/go-sql-driver/mysql v1.9.3 ) require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/IBM/sarama v1.46.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dnwe/otelsarama v0.0.0-20240308230250-9388d9d40bc0 // indirect github.com/eapache/go-resiliency v1.7.0 // indirect github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 // indirect github.com/eapache/queue v1.1.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/golang/snappy v1.0.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.7.5 // indirect github.com/jcmturner/aescts/v2 v2.0.0 // indirect github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect github.com/jcmturner/gofork v1.7.6 // indirect github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect github.com/jcmturner/rpc/v2 v2.0.3 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/lib/pq v1.10.9 // indirect github.com/lithammer/shortuuid/v3 v3.0.7 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect github.com/sony/gobreaker v1.0.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/otel v1.38.0 // indirect go.opentelemetry.io/otel/metric v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect golang.org/x/crypto v0.41.0 // indirect golang.org/x/net v0.43.0 // indirect golang.org/x/text v0.28.0 // indirect ) ================================================ FILE: _examples/real-world-examples/transactional-events/go.sum ================================================ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/IBM/sarama v1.46.0 h1:+YTM1fNd6WKMchlnLKRUB5Z0qD4M8YbvwIIPLvJD53s= github.com/IBM/sarama v1.46.0/go.mod h1:0lOcuQziJ1/mBGHkdp5uYrltqQuKQKM5O5FOWUQVVvo= github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY= github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892LARrKAdki6WtXS4= github.com/ThreeDotsLabs/watermill-kafka/v3 v3.1.0 h1:DfhVM1ieq+rb+bboB7aoymUgfKsEM3UbH3noQZ6+RJ4= github.com/ThreeDotsLabs/watermill-kafka/v3 v3.1.0/go.mod h1:o1GcoF/1CSJ9JSmQzUkULvpZeO635pZe+WWrYNFlJNk= github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0 h1:vd0eoMPriNjiFTEo7tQ1wmN933lNAWyVhxX931JG5Pw= github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0/go.mod h1:Ce2GVZVnyajAh0AkwxSJXwx8ajBBveu1DI/yatan5jc= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dnwe/otelsarama v0.0.0-20240308230250-9388d9d40bc0 h1:R2zQhFwSCyyd7L43igYjDrH0wkC/i+QBPELuY0HOu84= github.com/dnwe/otelsarama v0.0.0-20240308230250-9388d9d40bc0/go.mod h1:2MqLKYJfjs3UriXXF9Fd0Qmh/lhxi/6tHXkqtXxyIHc= github.com/eapache/go-resiliency v1.7.0 h1:n3NRTnBn5N0Cbi/IeOHuQn9s2UwVUH7Ga0ZWcP+9JTA= github.com/eapache/go-resiliency v1.7.0/go.mod h1:5yPzW0MIvSe0JDsv0v+DvcjEv2FyD6iZYSs1ZI+iQho= github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 h1:Oy0F4ALJ04o5Qqpdz8XLIpNA3WM/iSIXqxtqo7UGVws= github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3/go.mod h1:YvSRo5mw33fLEx1+DlK6L2VV43tJt5Eyel9n9XBcR+0= github.com/eapache/queue v1.1.0 h1:YOEu7KNc61ntiQlcEeUIoDTJ2o8mQznoNvUhiigpIqc= github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg= github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8= github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg= github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ= github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: _examples/real-world-examples/transactional-events/main.go ================================================ package main import ( "context" stdSQL "database/sql" "encoding/json" "log" "time" driver "github.com/go-sql-driver/mysql" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill-kafka/v3/pkg/kafka" "github.com/ThreeDotsLabs/watermill-sql/v4/pkg/sql" "github.com/ThreeDotsLabs/watermill/message" "github.com/ThreeDotsLabs/watermill/message/router/middleware" "github.com/ThreeDotsLabs/watermill/message/router/plugin" ) var ( logger = watermill.NewStdLogger(false, false) kafkaTopic = "events" mysqlTable = "events" ) func main() { router, err := message.NewRouter(message.RouterConfig{}, logger) if err != nil { panic(err) } router.AddPlugin(plugin.SignalsHandler) router.AddMiddleware(middleware.Recoverer) db := createDB() subscriber := createSubscriber(db) publisher := createPublisher() router.AddHandler( "mysql-to-kafka", mysqlTable, subscriber, kafkaTopic, publisher, func(msg *message.Message) ([]*message.Message, error) { consumedEvent := event{} err := json.Unmarshal(msg.Payload, &consumedEvent) if err != nil { return nil, err } log.Printf("received event %+v with UUID %s", consumedEvent, msg.UUID) return []*message.Message{msg}, nil }, ) go func() { <-router.Running() simulateEvents(db) }() if err := router.Run(context.Background()); err != nil { panic(err) } } func createDB() *stdSQL.DB { conf := driver.NewConfig() conf.Net = "tcp" conf.User = "root" conf.Addr = "mysql" conf.DBName = "watermill" db, err := stdSQL.Open("mysql", conf.FormatDSN()) if err != nil { panic(err) } err = db.Ping() if err != nil { panic(err) } return db } func createSubscriber(db *stdSQL.DB) message.Subscriber { sub, err := sql.NewSubscriber( sql.BeginnerFromStdSQL(db), sql.SubscriberConfig{ SchemaAdapter: sql.DefaultMySQLSchema{}, OffsetsAdapter: sql.DefaultMySQLOffsetsAdapter{}, InitializeSchema: true, }, logger, ) if err != nil { panic(err) } return sub } func createPublisher() message.Publisher { pub, err := kafka.NewPublisher( kafka.PublisherConfig{ Brokers: []string{"kafka:9092"}, Marshaler: kafka.DefaultMarshaler{}, }, logger, ) if err != nil { panic(err) } return pub } type event struct { Name string `json:"name"` OccurredAt string `json:"occurred_at"` } func simulateEvents(db *stdSQL.DB) { for { tx, err := db.Begin() if err != nil { panic(err) } // In an actual application, this is the place where some aggregate would be persisted // using the same transaction. // tx.Exec("INSERT INTO (...)") err = publishEvent(tx) if err != nil { rollbackErr := tx.Rollback() if rollbackErr != nil { panic(rollbackErr) } panic(err) } err = tx.Commit() if err != nil { panic(err) } time.Sleep(time.Second) } } // publishEvent publishes a new event. // To publish the event in a separate transaction, a new SQL Publisher // has to be created each time, passing the proper transaction handle. func publishEvent(tx *stdSQL.Tx) error { pub, err := sql.NewPublisher( sql.TxFromStdSQL(tx), sql.PublisherConfig{ SchemaAdapter: sql.DefaultMySQLSchema{}, }, logger) if err != nil { return err } e := event{ Name: "UserSignedUp", OccurredAt: time.Now().UTC().Format(time.RFC3339), } payload, err := json.Marshal(e) if err != nil { return err } return pub.Publish(mysqlTable, message.NewMessage( watermill.NewUUID(), payload, )) } ================================================ FILE: _examples/real-world-examples/transactional-events-forwarder/.validate_example.yml ================================================ validation_cmd: "docker compose up" teardown_cmd: "docker compose down" timeout: 180 expected_output: "Sending a prize to the winner" ================================================ FILE: _examples/real-world-examples/transactional-events-forwarder/README.md ================================================ # Publishing events in transactions with help of Forwarder component (MySQL to Google Pub/Sub) While working with an event-driven application, you may in some point need to store an application state and publish a message telling the rest of the system about what just happened. As it may look trivial at a first glance, it could become a bit tricky if we consider what can go wrong in case we won't pay enough attention to details. ## Solution This example presents a solution to this problem: saving events in transaction along with persisting application state. It also compares two other approaches which lack transactional publishing therefore expose application to a risk of inconsistency across the system. ## Requirements To run this example you will need Docker and docker-compose installed. See installation guide at https://docs.docker.com/compose/install/ ## Running ```bash docker-compose up ``` ================================================ FILE: _examples/real-world-examples/transactional-events-forwarder/docker-compose.yml ================================================ services: server: image: golang:1.25 environment: - PUBSUB_EMULATOR_HOST=googlecloud:8085 depends_on: - mysql - googlecloud restart: unless-stopped volumes: - .:/app - $GOPATH/pkg/mod:/go/pkg/mod working_dir: /app command: go run . mysql: image: mysql:8.0 restart: unless-stopped logging: driver: none ports: - 3306:3306 environment: MYSQL_DATABASE: watermill MYSQL_ALLOW_EMPTY_PASSWORD: "yes" googlecloud: image: google/cloud-sdk:414.0.0 logging: driver: none entrypoint: gcloud --quiet beta emulators pubsub start --host-port=0.0.0.0:8085 --verbosity=debug --log-http ports: - 8085:8085 restart: unless-stopped ================================================ FILE: _examples/real-world-examples/transactional-events-forwarder/go.mod ================================================ module main.go go 1.25 require ( github.com/ThreeDotsLabs/watermill v1.5.1 github.com/ThreeDotsLabs/watermill-googlecloud/v2 v2.0.0 github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0 github.com/go-sql-driver/mysql v1.9.3 ) require ( cloud.google.com/go v0.121.6 // indirect cloud.google.com/go/auth v0.16.5 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.8.0 // indirect cloud.google.com/go/iam v1.5.2 // indirect cloud.google.com/go/pubsub/v2 v2.0.0 // indirect filippo.io/edwards25519 v1.1.0 // indirect github.com/cenkalti/backoff/v3 v3.2.2 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.7.5 // indirect github.com/lib/pq v1.10.9 // indirect github.com/lithammer/shortuuid/v3 v3.0.7 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/sony/gobreaker v1.0.0 // indirect go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect go.opentelemetry.io/otel v1.38.0 // indirect go.opentelemetry.io/otel/metric v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect golang.org/x/crypto v0.41.0 // indirect golang.org/x/net v0.43.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sync v0.16.0 // indirect golang.org/x/sys v0.35.0 // indirect golang.org/x/text v0.28.0 // indirect golang.org/x/time v0.12.0 // indirect google.golang.org/api v0.248.0 // indirect google.golang.org/genproto v0.0.0-20250826171959-ef028d996bc1 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250826171959-ef028d996bc1 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1 // indirect google.golang.org/grpc v1.75.0 // indirect google.golang.org/protobuf v1.36.8 // indirect ) ================================================ FILE: _examples/real-world-examples/transactional-events-forwarder/go.sum ================================================ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.121.6 h1:waZiuajrI28iAf40cWgycWNgaXPO06dupuS+sgibK6c= cloud.google.com/go v0.121.6/go.mod h1:coChdst4Ea5vUpiALcYKXEpR1S9ZgXbhEzzMcMR66vI= cloud.google.com/go/auth v0.16.5 h1:mFWNQ2FEVWAliEQWpAdH80omXFokmrnbDhUS9cBywsI= cloud.google.com/go/auth v0.16.5/go.mod h1:utzRfHMP+Vv0mpOkTRQoWD2q3BatTOoWbA7gCc2dUhQ= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.8.0 h1:HxMRIbao8w17ZX6wBnjhcDkW6lTFpgcaobyVfZWqRLA= cloud.google.com/go/compute/metadata v0.8.0/go.mod h1:sYOGTp851OV9bOFJ9CH7elVvyzopvWQFNNghtDQ/Biw= cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8= cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE= cloud.google.com/go/pubsub/v2 v2.0.0 h1:0qS6mRJ41gD1lNmM/vdm6bR7DQu6coQcVwD+VPf0Bz0= cloud.google.com/go/pubsub/v2 v2.0.0/go.mod h1:0aztFxNzVQIRSZ8vUr79uH2bS3jwLebwK6q1sgEub+E= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY= github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892LARrKAdki6WtXS4= github.com/ThreeDotsLabs/watermill-googlecloud/v2 v2.0.0 h1:GXR+tsxPs/Vpmm0t4yEJUZdqLP9EytWvR+KN3Un5mNY= github.com/ThreeDotsLabs/watermill-googlecloud/v2 v2.0.0/go.mod h1:3IHyi1bNqQ8J2/wVWj4cQjzWXoEPauLm8ViyOCNaKbM= github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0 h1:vd0eoMPriNjiFTEo7tQ1wmN933lNAWyVhxX931JG5Pw= github.com/ThreeDotsLabs/watermill-sql/v4 v4.0.0/go.mod h1:Ce2GVZVnyajAh0AkwxSJXwx8ajBBveu1DI/yatan5jc= github.com/cenkalti/backoff/v3 v3.2.2 h1:cfUAAO3yvKMYKPrvhDuHSwQnhZNk/RMHKdZqKTxfm6M= github.com/cenkalti/backoff/v3 v3.2.2/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4rc0ij+ULvLYs= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ= github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.einride.tech/aip v0.73.0 h1:bPo4oqBo2ZQeBKo4ZzLb1kxYXTY1ysJhpvQyfuGzvps= go.einride.tech/aip v0.73.0/go.mod h1:Mj7rFbmXEgw0dq1dqJ7JGMvYCZZVxmGOR3S4ZcV5LvQ= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/api v0.248.0 h1:hUotakSkcwGdYUqzCRc5yGYsg4wXxpkKlW5ryVqvC1Y= google.golang.org/api v0.248.0/go.mod h1:yAFUAF56Li7IuIQbTFoLwXTCI6XCFKueOlS7S9e4F9k= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= google.golang.org/genproto v0.0.0-20250826171959-ef028d996bc1 h1:Nm5SEGIguOIBDXs5rhfz2aKwEVWlgwC58UcmEnLDc8Y= google.golang.org/genproto v0.0.0-20250826171959-ef028d996bc1/go.mod h1:Jz9LrroM7Mcm+a0QrLh4UpZ1B/WhjIbqwEcUf4y08nQ= google.golang.org/genproto/googleapis/api v0.0.0-20250826171959-ef028d996bc1 h1:APHvLLYBhtZvsbnpkfknDZ7NyH4z5+ub/I0u8L3Oz6g= google.golang.org/genproto/googleapis/api v0.0.0-20250826171959-ef028d996bc1/go.mod h1:xUjFWUnWDpZ/C0Gu0qloASKFb6f8/QXiiXhSPFsD668= google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1 h1:pmJpJEvT846VzausCQ5d7KreSROcDqmO388w5YbnltA= google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1/go.mod h1:GmFNa4BdJZ2a8G+wCe9Bg3wwThLrJun751XstdJt5Og= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4= google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= ================================================ FILE: _examples/real-world-examples/transactional-events-forwarder/main.go ================================================ package main import ( "context" stdSQL "database/sql" "encoding/json" "errors" "log" "math/rand" "time" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill-googlecloud/v2/pkg/googlecloud" "github.com/ThreeDotsLabs/watermill-sql/v4/pkg/sql" "github.com/ThreeDotsLabs/watermill/components/forwarder" "github.com/ThreeDotsLabs/watermill/message" driver "github.com/go-sql-driver/mysql" ) const ( projectID = "transactional-events" forwarderSQLTopic = "eventsToForward" googleCloudEventTopic = "lottery-concluded" simulatedErrorProbability = 0.5 ) var ( logger = watermill.NewStdLogger(false, false) db = createDB() ) type LotteryConcludedEvent struct { LotteryID int `json:"lottery_id"` } func main() { // Setup the Forwarder component so it takes messages from MySQL subscription and pushes them to Google Pub/Sub. sqlSubscriber, err := sql.NewSubscriber( sql.BeginnerFromStdSQL(db), sql.SubscriberConfig{ SchemaAdapter: sql.DefaultMySQLSchema{}, OffsetsAdapter: sql.DefaultMySQLOffsetsAdapter{}, InitializeSchema: true, }, logger, ) expectNoErr(err) gcpPublisher, err := googlecloud.NewPublisher( googlecloud.PublisherConfig{ ProjectID: projectID, }, logger, ) expectNoErr(err) fwd, err := forwarder.NewForwarder(sqlSubscriber, gcpPublisher, logger, forwarder.Config{ ForwarderTopic: forwarderSQLTopic, }) expectNoErr(err) go func() { err := fwd.Run(context.Background()) expectNoErr(err) }() go runLotteryService(logger) go runPrizeSenderService(logger) time.Sleep(time.Second * 60) } // Lottery service picks a random user at fixed intervals and makes him win a lottery. func runLotteryService(logger watermill.LoggerAdapter) { logger = logger.With(watermill.LogFields{"service": "lottery"}) // We'd like to persist in a database that for a given lottery id, a drawn user is a winner. // At the same time, we want to emit an event that will tell the rest of the system this happened. // We could approach implementing this handler in at least 3 ways: availableServiceHandlers := []func(int, string, watermill.LoggerAdapter) error{ publishEventAndPersistData, persistDataAndPublishEvent, persistDataAndPublishEventInTransaction, } users := []string{"Mike", "Dwight", "Jim", "Pamela"} lotteryID := 1 for range time.Tick(time.Second * 5) { pickedUser := users[rand.Intn(len(users))] logger := logger.With(watermill.LogFields{"user": pickedUser, "lottery_id": lotteryID}) logger.Info("User has been picked as a winner", nil) pickedHandler := availableServiceHandlers[rand.Intn(len(availableServiceHandlers))] err := pickedHandler(lotteryID, pickedUser, logger) if err != nil { logger.Error("Handler failed", err, nil) } lotteryID++ } } // 1. Publishes event to Google Cloud Pub/Sub first, then stores data in MySQL. func publishEventAndPersistData(lotteryID int, pickedUser string, logger watermill.LoggerAdapter) error { publisher, err := googlecloud.NewPublisher( googlecloud.PublisherConfig{ ProjectID: projectID, }, logger, ) if err != nil { return err } event := LotteryConcludedEvent{LotteryID: lotteryID} payload, err := json.Marshal(event) if err != nil { return err } err = publisher.Publish(googleCloudEventTopic, message.NewMessage(watermill.NewULID(), payload)) if err != nil { return err } // In case this fails, we have an event emitted, but no data persisted yet. if err = simulateError(); err != nil { logger.Error("Failed to persist data", err, nil) return err } _, err = db.Exec(`INSERT INTO lotteries (lottery_id, winner) VALUES(?, ?)`, lotteryID, pickedUser) if err != nil { return err } return nil } // 2. Persists data to MySQL first, then publishes an event straight to Google Cloud Pub/Sub. func persistDataAndPublishEvent(lotteryID int, pickedUser string, logger watermill.LoggerAdapter) error { _, err := db.Exec(`INSERT INTO lotteries (lottery_id, winner) VALUES(?, ?)`, lotteryID, pickedUser) if err != nil { return err } var publisher message.Publisher publisher, err = googlecloud.NewPublisher( googlecloud.PublisherConfig{ ProjectID: projectID, }, logger, ) if err != nil { return err } event := LotteryConcludedEvent{LotteryID: lotteryID} payload, err := json.Marshal(event) if err != nil { return err } // In case this fails, we have data persisted, but no event emitted. if err = simulateError(); err != nil { logger.Error("Failed to emit event", err, nil) return err } err = publisher.Publish(googleCloudEventTopic, message.NewMessage(watermill.NewULID(), payload)) if err != nil { return err } return nil } // 3. Persists data in MySQL and emits an event through MySQL to Google Cloud Pub/Sub, all in one transaction. func persistDataAndPublishEventInTransaction(lotteryID int, pickedUser string, logger watermill.LoggerAdapter) error { tx, err := db.Begin() if err != nil { return err } defer func() { if err == nil { tx.Commit() } else { logger.Info("Rolling transaction back due to error", watermill.LogFields{"error": err.Error()}) // In case of an error, we're 100% sure that thanks to MySQL transaction rollback, we won't have any of the undesired situations: // - event is emitted, but no data is persisted, // - data is persisted, but no event is emitted. tx.Rollback() } }() _, err = tx.Exec(`INSERT INTO lotteries (lottery_id, winner) VALUES(?, ?)`, lotteryID, pickedUser) if err != nil { return err } var publisher message.Publisher publisher, err = sql.NewPublisher( sql.TxFromStdSQL(tx), sql.PublisherConfig{ SchemaAdapter: sql.DefaultMySQLSchema{}, }, logger, ) if err != nil { return err } // Decorate publisher so it wraps an event in an envelope understood by the Forwarder component. publisher = forwarder.NewPublisher(publisher, forwarder.PublisherConfig{ ForwarderTopic: forwarderSQLTopic, }) // Publish an event announcing the lottery winner. Please note we're publishing to a Google Cloud topic here, // while using decorated MySQL publisher. event := LotteryConcludedEvent{LotteryID: lotteryID} payload, err := json.Marshal(event) if err != nil { return err } err = publisher.Publish(googleCloudEventTopic, message.NewMessage(watermill.NewULID(), payload)) if err != nil { return err } return nil } // PrizeSender service listens to UserWonLottery events and sends a prize straight to the user that has won. func runPrizeSenderService(logger watermill.LoggerAdapter) { logger = logger.With(watermill.LogFields{"service": "prize_sender"}) ctx := context.Background() googleCloudSubscriber, err := googlecloud.NewSubscriber( googlecloud.SubscriberConfig{ ProjectID: projectID, }, logger, ) expectNoErr(err) events, err := googleCloudSubscriber.Subscribe(ctx, googleCloudEventTopic) for rawEvent := range events { event := LotteryConcludedEvent{} err := json.Unmarshal(rawEvent.Payload, &event) expectNoErr(err) rawEvent.Ack() logger := logger.With(watermill.LogFields{"lottery_id": event.LotteryID}) row := db.QueryRow("SELECT winner FROM lotteries WHERE lottery_id=?", event.LotteryID) var winner string err = row.Scan(&winner) if err != nil { logger.Error("Could not get lottery winner", err, nil) continue } logger.Info("Sending a prize to the winner", watermill.LogFields{ "winner": winner, "lottery_id": event.LotteryID, }) } } func expectNoErr(err error) { if err != nil { log.Fatalf("expected no error, got: %s", err) } } func simulateError() error { if simulatedErrorProbability >= rand.Float64() { return errors.New("simulated error occurred") } return nil } func createDB() *stdSQL.DB { conf := driver.NewConfig() conf.Net = "tcp" conf.User = "root" conf.Addr = "mysql" conf.DBName = "watermill" db, err := stdSQL.Open("mysql", conf.FormatDSN()) expectNoErr(err) err = db.Ping() expectNoErr(err) _, err = db.Exec(`DROP TABLE IF EXISTS lotteries`) expectNoErr(err) _, err = db.Exec(` CREATE TABLE IF NOT EXISTS lotteries ( lottery_id INT NOT NULL PRIMARY KEY, winner VARCHAR(255) NOT NULL ) ENGINE=INNODB; `) expectNoErr(err) return db } ================================================ FILE: codecov.yml ================================================ ignore: - "pubsub/tests" # test helpers used to test Pub/Subs comment: no # do not comment PR with the result coverage: precision: 0 status: patch: false # do not run coverage on patch nor changes project: default: target: auto threshold: 5% ================================================ FILE: components/cqrs/command_bus.go ================================================ package cqrs import ( "context" stdErrors "errors" "github.com/ThreeDotsLabs/watermill" "github.com/pkg/errors" "github.com/ThreeDotsLabs/watermill/message" ) type CommandBusConfig struct { // GeneratePublishTopic is used to generate topic for publishing command. GeneratePublishTopic CommandBusGeneratePublishTopicFn // OnSend is called before publishing the command. // The *message.Message can be modified. // // This option is not required. OnSend CommandBusOnSendFn // Marshaler is used to marshal and unmarshal commands. // It is required. Marshaler CommandEventMarshaler // Logger instance used to log. // If not provided, watermill.NopLogger is used. Logger watermill.LoggerAdapter } func (c *CommandBusConfig) setDefaults() { if c.Logger == nil { c.Logger = watermill.NopLogger{} } } func (c CommandBusConfig) Validate() error { var err error if c.Marshaler == nil { err = stdErrors.Join(err, errors.New("missing Marshaler")) } if c.GeneratePublishTopic == nil { err = stdErrors.Join(err, errors.New("missing GeneratePublishTopic")) } return err } type CommandBusGeneratePublishTopicFn func(CommandBusGeneratePublishTopicParams) (string, error) type CommandBusGeneratePublishTopicParams struct { CommandName string Command any } type CommandBusOnSendFn func(params CommandBusOnSendParams) error type CommandBusOnSendParams struct { CommandName string Command any // Message is never nil and can be modified. Message *message.Message } // CommandBus transports commands to command handlers. type CommandBus struct { publisher message.Publisher config CommandBusConfig } // NewCommandBusWithConfig creates a new CommandBus. func NewCommandBusWithConfig(publisher message.Publisher, config CommandBusConfig) (*CommandBus, error) { if publisher == nil { return nil, errors.New("missing publisher") } config.setDefaults() if err := config.Validate(); err != nil { return nil, errors.Wrap(err, "invalid config") } return &CommandBus{publisher, config}, nil } // NewCommandBus creates a new CommandBus. // Deprecated: use NewCommandBusWithConfig instead. func NewCommandBus( publisher message.Publisher, generateTopic func(commandName string) string, marshaler CommandEventMarshaler, ) (*CommandBus, error) { if publisher == nil { return nil, errors.New("missing publisher") } if generateTopic == nil { return nil, errors.New("missing generateTopic") } if marshaler == nil { return nil, errors.New("missing marshaler") } return &CommandBus{publisher, CommandBusConfig{ GeneratePublishTopic: func(params CommandBusGeneratePublishTopicParams) (string, error) { return generateTopic(params.CommandName), nil }, Marshaler: marshaler, }}, nil } // Send sends command to the command bus. func (c CommandBus) Send(ctx context.Context, cmd any) error { return c.SendWithModifiedMessage(ctx, cmd, nil) } func (c CommandBus) SendWithModifiedMessage(ctx context.Context, cmd any, modify func(*message.Message) error) error { msg, topicName, err := c.newMessage(ctx, cmd) if err != nil { return err } if modify != nil { if err := modify(msg); err != nil { return errors.Wrap(err, "cannot modify message") } } if err := c.publisher.Publish(topicName, msg); err != nil { return err } return nil } func (c CommandBus) newMessage(ctx context.Context, command any) (*message.Message, string, error) { msg, err := c.config.Marshaler.Marshal(command) if err != nil { return nil, "", err } commandName := c.config.Marshaler.Name(command) topicName, err := c.config.GeneratePublishTopic(CommandBusGeneratePublishTopicParams{ CommandName: commandName, Command: command, }) if err != nil { return nil, "", errors.Wrap(err, "cannot generate topic name") } msg.SetContext(ctx) if c.config.OnSend != nil { err := c.config.OnSend(CommandBusOnSendParams{ CommandName: commandName, Command: command, Message: msg, }) if err != nil { return nil, "", errors.Wrap(err, "cannot execute OnSend") } } return msg, topicName, nil } ================================================ FILE: components/cqrs/command_bus_test.go ================================================ package cqrs_test import ( "context" "testing" "github.com/ThreeDotsLabs/watermill/message" "github.com/pkg/errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/ThreeDotsLabs/watermill/components/cqrs" ) func TestCommandBusConfig_Validate(t *testing.T) { testCases := []struct { Name string ModifyValidConfig func(*cqrs.CommandBusConfig) ExpectedErr error }{ { Name: "valid_config", ModifyValidConfig: nil, ExpectedErr: nil, }, { Name: "missing_Marshaler", ModifyValidConfig: func(c *cqrs.CommandBusConfig) { c.Marshaler = nil }, ExpectedErr: errors.Errorf("missing Marshaler"), }, { Name: "missing_GeneratePublishTopic", ModifyValidConfig: func(c *cqrs.CommandBusConfig) { c.GeneratePublishTopic = nil }, ExpectedErr: errors.Errorf("missing GeneratePublishTopic"), }, } for i := range testCases { tc := testCases[i] t.Run(tc.Name, func(t *testing.T) { validConfig := cqrs.CommandBusConfig{ GeneratePublishTopic: func(params cqrs.CommandBusGeneratePublishTopicParams) (string, error) { return "", nil }, Marshaler: cqrs.JSONMarshaler{}, } if tc.ModifyValidConfig != nil { tc.ModifyValidConfig(&validConfig) } err := validConfig.Validate() if tc.ExpectedErr == nil { assert.NoError(t, err) } else { assert.EqualError(t, err, tc.ExpectedErr.Error()) } }) } } func TestNewCommandBus(t *testing.T) { pub := newPublisherStub() config := cqrs.CommandBusConfig{ GeneratePublishTopic: func(params cqrs.CommandBusGeneratePublishTopicParams) (string, error) { return "", nil }, Marshaler: cqrs.JSONMarshaler{}, } require.NoError(t, config.Validate()) cb, err := cqrs.NewCommandBusWithConfig(pub, config) assert.NotNil(t, cb) assert.NoError(t, err) config.GeneratePublishTopic = nil require.Error(t, config.Validate()) cb, err = cqrs.NewCommandBusWithConfig(pub, config) assert.Nil(t, cb) assert.Error(t, err) } type contextKey string func TestCommandBus_Send_ContextPropagation(t *testing.T) { publisher := newPublisherStub() commandBus, err := cqrs.NewCommandBusWithConfig( publisher, cqrs.CommandBusConfig{ GeneratePublishTopic: func(params cqrs.CommandBusGeneratePublishTopicParams) (string, error) { return "whatever", nil }, Marshaler: cqrs.JSONMarshaler{}, }, ) require.NoError(t, err) ctx := context.WithValue(context.Background(), contextKey("key"), "value") err = commandBus.Send(ctx, "message") require.NoError(t, err) assert.Equal(t, ctx, publisher.messages["whatever"][0].Context()) } func TestCommandBus_Send_topic_name(t *testing.T) { cb, err := cqrs.NewCommandBusWithConfig( assertPublishTopicPublisher{ExpectedTopic: "cqrs_test.TestCommand", T: t}, cqrs.CommandBusConfig{ GeneratePublishTopic: func(params cqrs.CommandBusGeneratePublishTopicParams) (string, error) { return params.CommandName, nil }, Marshaler: cqrs.JSONMarshaler{}, }, ) require.NoError(t, err) err = cb.Send(context.Background(), TestCommand{}) require.NoError(t, err) } func TestCommandBus_Send_OnSend(t *testing.T) { publisher := newPublisherStub() cb, err := cqrs.NewCommandBusWithConfig( publisher, cqrs.CommandBusConfig{ GeneratePublishTopic: func(params cqrs.CommandBusGeneratePublishTopicParams) (string, error) { return "whatever", nil }, Marshaler: cqrs.JSONMarshaler{}, OnSend: func(params cqrs.CommandBusOnSendParams) error { params.Message.Metadata.Set("key", "value") return nil }, }, ) require.NoError(t, err) err = cb.Send(context.Background(), TestCommand{}) require.NoError(t, err) assert.Equal(t, "value", publisher.messages["whatever"][0].Metadata.Get("key")) } func TestCommandBus_SendWithModifiedMessage(t *testing.T) { publisher := newPublisherStub() cb, err := cqrs.NewCommandBusWithConfig( publisher, cqrs.CommandBusConfig{ GeneratePublishTopic: func(params cqrs.CommandBusGeneratePublishTopicParams) (string, error) { return "whatever", nil }, Marshaler: cqrs.JSONMarshaler{}, }, ) require.NoError(t, err) err = cb.SendWithModifiedMessage(context.Background(), TestCommand{}, func(message *message.Message) error { message.Metadata.Set("key", "value") return nil }) require.NoError(t, err) assert.Equal(t, "value", publisher.messages["whatever"][0].Metadata.Get("key")) } func TestCommandBus_SendWithModifiedMessage_modify_error(t *testing.T) { publisher := newPublisherStub() cb, err := cqrs.NewCommandBusWithConfig( publisher, cqrs.CommandBusConfig{ GeneratePublishTopic: func(params cqrs.CommandBusGeneratePublishTopicParams) (string, error) { return "whatever", nil }, Marshaler: cqrs.JSONMarshaler{}, }, ) require.NoError(t, err) expectedErr := errors.New("some error") err = cb.SendWithModifiedMessage( context.Background(), TestCommand{}, func(message *message.Message) error { return expectedErr }, ) assert.ErrorContains(t, err, expectedErr.Error()) } func TestCommandBus_Send_OnSend_error(t *testing.T) { publisher := newPublisherStub() expectedErr := errors.New("some error") cb, err := cqrs.NewCommandBusWithConfig( publisher, cqrs.CommandBusConfig{ GeneratePublishTopic: func(params cqrs.CommandBusGeneratePublishTopicParams) (string, error) { return "whatever", nil }, Marshaler: cqrs.JSONMarshaler{}, OnSend: func(params cqrs.CommandBusOnSendParams) error { return expectedErr }, }, ) require.NoError(t, err) err = cb.Send(context.Background(), TestCommand{}) require.EqualError(t, err, "cannot execute OnSend: some error") } ================================================ FILE: components/cqrs/command_handler.go ================================================ package cqrs import ( "context" ) // CommandHandler receives a command defined by NewCommand and handles it with the Handle method. // If using DDD, CommandHandler may modify and persist the aggregate. // // In contrast to EventHandler, every Command must have only one CommandHandler. // // One instance of CommandHandler is used during handling messages. // When multiple commands are delivered at the same time, Handle method can be executed multiple times at the same time. // Because of that, Handle method needs to be thread safe! type CommandHandler interface { // HandlerName is the name used in message.Router while creating handler. // // It will be also passed to CommandsSubscriberConstructor. // May be useful, for example, to create a consumer group per each handler. // // WARNING: If HandlerName was changed and is used for generating consumer groups, // it may result with **reconsuming all messages**! HandlerName() string NewCommand() any Handle(ctx context.Context, cmd any) error } type genericCommandHandler[Command any] struct { handleFunc func(ctx context.Context, cmd *Command) error handlerName string } // NewCommandHandler creates a new CommandHandler implementation based on provided function // and command type inferred from function argument. func NewCommandHandler[Command any]( handlerName string, handleFunc func(ctx context.Context, cmd *Command) error, ) CommandHandler { return &genericCommandHandler[Command]{ handleFunc: handleFunc, handlerName: handlerName, } } func (c genericCommandHandler[Command]) HandlerName() string { return c.handlerName } func (c genericCommandHandler[Command]) NewCommand() any { tVar := new(Command) return tVar } func (c genericCommandHandler[Command]) Handle(ctx context.Context, cmd any) error { command := cmd.(*Command) return c.handleFunc(ctx, command) } ================================================ FILE: components/cqrs/command_handler_test.go ================================================ package cqrs_test import ( "context" "fmt" "testing" "github.com/ThreeDotsLabs/watermill/components/cqrs" "github.com/stretchr/testify/assert" ) type SomeCommand struct { Foo string } func TestNewCommandHandler(t *testing.T) { cmdToSend := &SomeCommand{"bar"} ch := cqrs.NewCommandHandler( "some_handler", func(ctx context.Context, cmd *SomeCommand) error { assert.Equal(t, cmdToSend, cmd) return fmt.Errorf("some error") }, ) assert.Equal(t, "some_handler", ch.HandlerName()) assert.Equal(t, &SomeCommand{}, ch.NewCommand()) err := ch.Handle(context.Background(), cmdToSend) assert.EqualError(t, err, "some error") } ================================================ FILE: components/cqrs/command_processor.go ================================================ package cqrs import ( stdErrors "errors" "fmt" "github.com/pkg/errors" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill/message" ) type CommandProcessorConfig struct { // GenerateSubscribeTopic is used to generate topic for subscribing command. GenerateSubscribeTopic CommandProcessorGenerateSubscribeTopicFn // SubscriberConstructor is used to create subscriber for CommandHandler. SubscriberConstructor CommandProcessorSubscriberConstructorFn // OnHandle is called before handling command. // OnHandle works in a similar way to middlewares: you can inject additional logic before and after handling a command. // // Because of that, you need to explicitly call params.Handler.Handle() to handle the command. // func(params CommandProcessorOnHandleParams) (err error) { // // logic before handle // // (...) // // err := params.Handler.Handle(params.Message.Context(), params.Command) // // // logic after handle // // (...) // // return err // } // // This option is not required. OnHandle CommandProcessorOnHandleFn // Marshaler is used to marshal and unmarshal commands. // It is required. Marshaler CommandEventMarshaler // Logger instance used to log. // If not provided, watermill.NopLogger is used. Logger watermill.LoggerAdapter // If true, CommandProcessor will ack messages even if CommandHandler returns an error. // If RequestReplyBackend is not null and sending reply fails, the message will be nack-ed anyway. // // Warning: It's not recommended to use this option when you are using requestreply component // (requestreply.NewCommandHandler or requestreply.NewCommandHandlerWithResult), as it may ack the // command when sending reply failed. // // When you are using requestreply, you should use requestreply.PubSubBackendConfig.AckCommandErrors. AckCommandHandlingErrors bool // disableRouterAutoAddHandlers is used to keep backwards compatibility. // it is set when CommandProcessor is created by NewCommandProcessor. // Deprecated: please migrate to NewCommandProcessorWithConfig. disableRouterAutoAddHandlers bool } func (c *CommandProcessorConfig) setDefaults() { if c.Logger == nil { c.Logger = watermill.NopLogger{} } } func (c CommandProcessorConfig) Validate() error { var err error if c.Marshaler == nil { err = stdErrors.Join(err, errors.New("missing Marshaler")) } if c.GenerateSubscribeTopic == nil { err = stdErrors.Join(err, errors.New("missing GenerateSubscribeTopic")) } if c.SubscriberConstructor == nil { err = stdErrors.Join(err, errors.New("missing SubscriberConstructor")) } return err } type CommandProcessorGenerateSubscribeTopicFn func(CommandProcessorGenerateSubscribeTopicParams) (string, error) type CommandProcessorGenerateSubscribeTopicParams struct { CommandName string CommandHandler CommandHandler } // CommandProcessorSubscriberConstructorFn creates subscriber for CommandHandler. // It allows you to create a separate customized Subscriber for every command handler. type CommandProcessorSubscriberConstructorFn func(CommandProcessorSubscriberConstructorParams) (message.Subscriber, error) type CommandProcessorSubscriberConstructorParams struct { CommandName string HandlerName string Handler CommandHandler } type CommandProcessorOnHandleFn func(params CommandProcessorOnHandleParams) error type CommandProcessorOnHandleParams struct { Handler CommandHandler CommandName string Command any // Message is never nil and can be modified. Message *message.Message } // CommandProcessor determines which CommandHandler should handle the command received from the command bus. type CommandProcessor struct { router *message.Router handlers []CommandHandler config CommandProcessorConfig } func NewCommandProcessorWithConfig(router *message.Router, config CommandProcessorConfig) (*CommandProcessor, error) { config.setDefaults() if err := config.Validate(); err != nil { return nil, err } if router == nil && !config.disableRouterAutoAddHandlers { return nil, errors.New("missing router") } return &CommandProcessor{ router: router, config: config, }, nil } // NewCommandProcessor creates a new CommandProcessor. // Deprecated. Use NewCommandProcessorWithConfig instead. func NewCommandProcessor( handlers []CommandHandler, generateTopic func(commandName string) string, subscriberConstructor CommandsSubscriberConstructor, marshaler CommandEventMarshaler, logger watermill.LoggerAdapter, ) (*CommandProcessor, error) { if len(handlers) == 0 { return nil, errors.New("missing handlers") } if generateTopic == nil { return nil, errors.New("missing generateTopic") } if subscriberConstructor == nil { return nil, errors.New("missing subscriberConstructor") } cp, err := NewCommandProcessorWithConfig( nil, CommandProcessorConfig{ GenerateSubscribeTopic: func(params CommandProcessorGenerateSubscribeTopicParams) (string, error) { return generateTopic(params.CommandName), nil }, SubscriberConstructor: func(params CommandProcessorSubscriberConstructorParams) (message.Subscriber, error) { return subscriberConstructor(params.HandlerName) }, Marshaler: marshaler, Logger: logger, disableRouterAutoAddHandlers: true, }, ) if err != nil { return nil, err } for _, handler := range handlers { if err := cp.AddHandlers(handler); err != nil { return nil, err } } return cp, nil } // CommandsSubscriberConstructor creates subscriber for CommandHandler. // It allows you to create a separate customized Subscriber for every command handler. // // Deprecated: please use CommandProcessorSubscriberConstructorFn instead. type CommandsSubscriberConstructor func(handlerName string) (message.Subscriber, error) // AddHandlers adds a new CommandHandler to the CommandProcessor and adds it to the router. func (p *CommandProcessor) AddHandlers(handlers ...CommandHandler) error { handledCommands := map[string]struct{}{} for _, handler := range handlers { commandName := p.config.Marshaler.Name(handler.NewCommand()) if _, ok := handledCommands[commandName]; ok { return DuplicateCommandHandlerError{commandName} } handledCommands[commandName] = struct{}{} } if p.config.disableRouterAutoAddHandlers { p.handlers = append(p.handlers, handlers...) return nil } for _, handler := range handlers { if _, err := p.addHandlerToRouter(p.router, handler); err != nil { return err } p.handlers = append(p.handlers, handler) } return nil } // AddHandler adds a new CommandHandler to the CommandProcessor and adds it to the router. func (p *CommandProcessor) AddHandler(handler CommandHandler) (*message.Handler, error) { if p.config.disableRouterAutoAddHandlers { p.handlers = append(p.handlers, handler) return nil, nil } h, err := p.addHandlerToRouter(p.router, handler) if err != nil { return nil, err } p.handlers = append(p.handlers, handler) return h, nil } // DuplicateCommandHandlerError occurs when a handler with the same name already exists. type DuplicateCommandHandlerError struct { CommandName string } func (d DuplicateCommandHandlerError) Error() string { return fmt.Sprintf("command handler for command %s already exists", d.CommandName) } // AddHandlersToRouter adds the CommandProcessor's handlers to the given router. // It should be called only once per CommandProcessor instance. // // It is required to call AddHandlersToRouter only if command processor is created with NewCommandProcessor (disableRouterAutoAddHandlers is set to true). // Deprecated: please migrate to command processor created by NewCommandProcessorWithConfig. func (p CommandProcessor) AddHandlersToRouter(r *message.Router) error { if !p.config.disableRouterAutoAddHandlers { return errors.New("AddHandlersToRouter should be called only when using deprecated NewCommandProcessor") } for i := range p.Handlers() { handler := p.handlers[i] if _, err := p.addHandlerToRouter(r, handler); err != nil { return err } } return nil } func (p CommandProcessor) addHandlerToRouter(r *message.Router, handler CommandHandler) (*message.Handler, error) { handlerName := handler.HandlerName() commandName := p.config.Marshaler.Name(handler.NewCommand()) topicName, err := p.config.GenerateSubscribeTopic(CommandProcessorGenerateSubscribeTopicParams{ CommandName: commandName, CommandHandler: handler, }) if err != nil { return nil, errors.Wrapf(err, "cannot generate topic for command handler %s", handlerName) } logger := p.config.Logger.With(watermill.LogFields{ "command_handler_name": handlerName, "topic": topicName, }) handlerFunc, err := p.routerHandlerFunc(handler, logger) if err != nil { return nil, err } logger.Debug("Adding CQRS command handler to router", nil) subscriber, err := p.config.SubscriberConstructor(CommandProcessorSubscriberConstructorParams{ CommandName: commandName, HandlerName: handlerName, Handler: handler, }) if err != nil { return nil, errors.Wrap(err, "cannot create subscriber for command processor") } return r.AddConsumerHandler( handlerName, topicName, subscriber, handlerFunc, ), nil } // Handlers returns the CommandProcessor's handlers. func (p CommandProcessor) Handlers() []CommandHandler { return p.handlers } func (p CommandProcessor) routerHandlerFunc(handler CommandHandler, logger watermill.LoggerAdapter) (message.NoPublishHandlerFunc, error) { cmd := handler.NewCommand() cmdName := p.config.Marshaler.Name(cmd) if err := p.validateCommand(cmd); err != nil { return nil, err } return func(msg *message.Message) error { cmd := handler.NewCommand() messageCmdName := p.config.Marshaler.NameFromMessage(msg) if messageCmdName != cmdName { logger.Trace("Received different command type than expected, ignoring", watermill.LogFields{ "message_uuid": msg.UUID, "expected_command_type": cmdName, "received_command_type": messageCmdName, }) return nil } logger.Debug("Handling command", watermill.LogFields{ "message_uuid": msg.UUID, "received_command_type": messageCmdName, }) ctx := CtxWithOriginalMessage(msg.Context(), msg) msg.SetContext(ctx) if err := p.config.Marshaler.Unmarshal(msg, cmd); err != nil { return err } handle := func(params CommandProcessorOnHandleParams) (err error) { return params.Handler.Handle(ctx, params.Command) } if p.config.OnHandle != nil { handle = p.config.OnHandle } err := handle(CommandProcessorOnHandleParams{ Handler: handler, CommandName: messageCmdName, Command: cmd, Message: msg, }) if p.config.AckCommandHandlingErrors && err != nil { logger.Error("Error when handling command, acking (AckCommandHandlingErrors is enabled)", err, nil) return nil } if err != nil { logger.Debug("Error when handling command, nacking", watermill.LogFields{"err": err}) return err } return nil }, nil } func (p CommandProcessor) validateCommand(cmd interface{}) error { // CommandHandler's NewCommand must return a pointer, because it is used to unmarshal if err := isPointer(cmd); err != nil { return errors.Wrap(err, "command must be a non-nil pointer") } return nil } ================================================ FILE: components/cqrs/command_processor_test.go ================================================ package cqrs_test import ( "context" "sync/atomic" "testing" "time" "github.com/ThreeDotsLabs/watermill" "github.com/pkg/errors" "github.com/ThreeDotsLabs/watermill/components/cqrs" "github.com/ThreeDotsLabs/watermill/message" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestCommandProcessorConfig_Validate(t *testing.T) { testCases := []struct { Name string ModifyValidConfig func(*cqrs.CommandProcessorConfig) ExpectedErr error }{ { Name: "valid_config", ModifyValidConfig: nil, ExpectedErr: nil, }, { Name: "missing_Marshaler", ModifyValidConfig: func(c *cqrs.CommandProcessorConfig) { c.Marshaler = nil }, ExpectedErr: errors.Errorf("missing Marshaler"), }, { Name: "missing_SubscriberConstructor", ModifyValidConfig: func(c *cqrs.CommandProcessorConfig) { c.SubscriberConstructor = nil }, ExpectedErr: errors.Errorf("missing SubscriberConstructor"), }, { Name: "missing_GenerateHandlerSubscribeTopic", ModifyValidConfig: func(c *cqrs.CommandProcessorConfig) { c.GenerateSubscribeTopic = nil }, ExpectedErr: errors.Errorf("missing GenerateSubscribeTopic"), }, } for i := range testCases { tc := testCases[i] t.Run(tc.Name, func(t *testing.T) { validConfig := cqrs.CommandProcessorConfig{ GenerateSubscribeTopic: func(params cqrs.CommandProcessorGenerateSubscribeTopicParams) (string, error) { return "", nil }, SubscriberConstructor: func(params cqrs.CommandProcessorSubscriberConstructorParams) (message.Subscriber, error) { return nil, nil }, Marshaler: cqrs.JSONMarshaler{}, } if tc.ModifyValidConfig != nil { tc.ModifyValidConfig(&validConfig) } err := validConfig.Validate() if tc.ExpectedErr == nil { assert.NoError(t, err) } else { assert.EqualError(t, err, tc.ExpectedErr.Error()) } }) } } func TestNewCommandProcessor(t *testing.T) { config := cqrs.CommandProcessorConfig{ GenerateSubscribeTopic: func(params cqrs.CommandProcessorGenerateSubscribeTopicParams) (string, error) { return "", nil }, SubscriberConstructor: func(params cqrs.CommandProcessorSubscriberConstructorParams) (message.Subscriber, error) { return nil, nil }, Marshaler: cqrs.JSONMarshaler{}, } require.NoError(t, config.Validate()) router, err := message.NewRouter(message.RouterConfig{}, watermill.NewStdLogger(false, false)) require.NoError(t, err) cp, err := cqrs.NewCommandProcessorWithConfig(router, config) assert.NotNil(t, cp) assert.NoError(t, err) config.SubscriberConstructor = nil require.Error(t, config.Validate()) cp, err = cqrs.NewCommandProcessorWithConfig(router, config) assert.Nil(t, cp) assert.Error(t, err) } type nonPointerCommandHandler struct { } func (nonPointerCommandHandler) HandlerName() string { return "nonPointerCommandHandler" } func (nonPointerCommandHandler) NewCommand() interface{} { return TestCommand{} } func (nonPointerCommandHandler) Handle(ctx context.Context, cmd interface{}) error { panic("not implemented") } func TestCommandProcessor_non_pointer_command(t *testing.T) { ts := NewTestServices() handler := nonPointerCommandHandler{} router, err := message.NewRouter(message.RouterConfig{}, ts.Logger) require.NoError(t, err) commandProcessor, err := cqrs.NewCommandProcessorWithConfig( router, cqrs.CommandProcessorConfig{ GenerateSubscribeTopic: func(params cqrs.CommandProcessorGenerateSubscribeTopicParams) (string, error) { return "", nil }, SubscriberConstructor: func(params cqrs.CommandProcessorSubscriberConstructorParams) (message.Subscriber, error) { return nil, nil }, Marshaler: ts.Marshaler, Logger: ts.Logger, }, ) require.NoError(t, err) err = commandProcessor.AddHandlers(handler) assert.IsType(t, cqrs.NonPointerError{}, errors.Cause(err)) } // TestCommandProcessor_multiple_same_command_handlers checks, that we don't register multiple handlers for the same command. func TestCommandProcessor_multiple_same_command_handlers(t *testing.T) { ts := NewTestServices() router, err := message.NewRouter(message.RouterConfig{}, ts.Logger) require.NoError(t, err) commandProcessor, err := cqrs.NewCommandProcessorWithConfig( router, cqrs.CommandProcessorConfig{ GenerateSubscribeTopic: func(params cqrs.CommandProcessorGenerateSubscribeTopicParams) (string, error) { return "", nil }, SubscriberConstructor: func(params cqrs.CommandProcessorSubscriberConstructorParams) (message.Subscriber, error) { return nil, nil }, Marshaler: ts.Marshaler, Logger: ts.Logger, }, ) require.NoError(t, err) err = commandProcessor.AddHandlers( &CaptureCommandHandler{}, &CaptureCommandHandler{}, ) require.Error(t, err) assert.EqualValues(t, cqrs.DuplicateCommandHandlerError{CommandName: "cqrs_test.TestCommand"}, err) assert.Equal(t, "command handler for command cqrs_test.TestCommand already exists", err.Error()) } type mockSubscriber struct { MessagesToSend []*message.Message WaitForAckBeforeSendingNext bool out chan *message.Message } func (m *mockSubscriber) Subscribe(ctx context.Context, topic string) (<-chan *message.Message, error) { m.out = make(chan *message.Message) go func() { for _, msg := range m.MessagesToSend { m.out <- msg if m.WaitForAckBeforeSendingNext { <-msg.Acked() } } }() return m.out, nil } func (m mockSubscriber) Close() error { close(m.out) return nil } func TestCommandProcessor_AckCommandHandlingErrors_option_true(t *testing.T) { logger := watermill.NewCaptureLogger() marshaler := cqrs.JSONMarshaler{} msgToSend, err := marshaler.Marshal(&TestCommand{ID: "1"}) require.NoError(t, err) mockSub := &mockSubscriber{ MessagesToSend: []*message.Message{ msgToSend, }, } router, err := message.NewRouter(message.RouterConfig{}, logger) require.NoError(t, err) commandProcessor, err := cqrs.NewCommandProcessorWithConfig( router, cqrs.CommandProcessorConfig{ GenerateSubscribeTopic: func(params cqrs.CommandProcessorGenerateSubscribeTopicParams) (string, error) { return "commands", nil }, SubscriberConstructor: func(params cqrs.CommandProcessorSubscriberConstructorParams) (message.Subscriber, error) { return mockSub, nil }, Marshaler: marshaler, Logger: logger, AckCommandHandlingErrors: true, }, ) require.NoError(t, err) expectedErr := errors.New("test error") err = commandProcessor.AddHandlers(cqrs.NewCommandHandler( "handler", func(ctx context.Context, cmd *TestCommand) error { return expectedErr }), ) require.NoError(t, err) go func() { err := router.Run(context.Background()) assert.NoError(t, err) }() <-router.Running() select { case <-msgToSend.Acked(): // ok case <-msgToSend.Nacked(): // nack received t.Fatal("nack received, message should be acked") case <-time.After(1 * time.Second): t.Fatal("timeout waiting for ack") } // it's pretty important to not ack message silently, so let's assert if it's logged properly expectedLogMessage := watermill.CapturedMessage{ Level: watermill.ErrorLogLevel, Fields: map[string]any{ "command_handler_name": "handler", "topic": "commands", }, Msg: "Error when handling command, acking (AckCommandHandlingErrors is enabled)", Err: expectedErr, } assert.True( t, logger.Has(expectedLogMessage), "expected log message not found, logs: %#v", logger.Captured(), ) } func TestCommandProcessor_AckCommandHandlingErrors_option_false(t *testing.T) { logger := watermill.NewCaptureLogger() marshaler := cqrs.JSONMarshaler{} msgToSend, err := marshaler.Marshal(&TestCommand{ID: "1"}) require.NoError(t, err) mockSub := &mockSubscriber{ MessagesToSend: []*message.Message{ msgToSend, }, } router, err := message.NewRouter(message.RouterConfig{}, logger) require.NoError(t, err) commandProcessor, err := cqrs.NewCommandProcessorWithConfig( router, cqrs.CommandProcessorConfig{ GenerateSubscribeTopic: func(params cqrs.CommandProcessorGenerateSubscribeTopicParams) (string, error) { return "commands", nil }, SubscriberConstructor: func(params cqrs.CommandProcessorSubscriberConstructorParams) (message.Subscriber, error) { return mockSub, nil }, Marshaler: marshaler, Logger: logger, AckCommandHandlingErrors: false, }, ) require.NoError(t, err) expectedErr := errors.New("test error") err = commandProcessor.AddHandlers(cqrs.NewCommandHandler( "handler", func(ctx context.Context, cmd *TestCommand) error { return expectedErr }), ) require.NoError(t, err) go func() { err := router.Run(context.Background()) assert.NoError(t, err) }() <-router.Running() select { case <-msgToSend.Acked(): // nack received t.Fatal("ack received, message should be nacked") case <-msgToSend.Nacked(): // ok case <-time.After(1 * time.Second): t.Fatal("timeout waiting for ack") } } func TestNewCommandProcessor_OnHandle(t *testing.T) { ts := NewTestServices() msg1, err := ts.Marshaler.Marshal(&TestCommand{ID: "1"}) require.NoError(t, err) msg2, err := ts.Marshaler.Marshal(&TestCommand{ID: "2"}) require.NoError(t, err) mockSub := &mockSubscriber{ MessagesToSend: []*message.Message{ msg1, msg2, }, } router, err := message.NewRouter(message.RouterConfig{}, ts.Logger) require.NoError(t, err) handlerCalled := 0 defer func() { // for msg 1 we are not calling handler - but returning before assert.Equal(t, 1, handlerCalled) }() handler := cqrs.NewCommandHandler("test", func(ctx context.Context, cmd *TestCommand) error { handlerCalled++ return nil }) onHandleCalled := int64(0) config := cqrs.CommandProcessorConfig{ GenerateSubscribeTopic: func(params cqrs.CommandProcessorGenerateSubscribeTopicParams) (string, error) { return "commands", nil }, SubscriberConstructor: func(params cqrs.CommandProcessorSubscriberConstructorParams) (message.Subscriber, error) { return mockSub, nil }, OnHandle: func(params cqrs.CommandProcessorOnHandleParams) error { atomic.AddInt64(&onHandleCalled, 1) assert.IsType(t, &TestCommand{}, params.Command) assert.Equal(t, "cqrs_test.TestCommand", params.CommandName) assert.Equal(t, handler, params.Handler) if params.Command.(*TestCommand).ID == "1" { assert.Equal(t, msg1, params.Message) return errors.New("test error") } else { assert.Equal(t, msg2, params.Message) } return params.Handler.Handle(params.Message.Context(), params.Command) }, Marshaler: ts.Marshaler, Logger: ts.Logger, } cp, err := cqrs.NewCommandProcessorWithConfig(router, config) require.NoError(t, err) err = cp.AddHandlers(handler) require.NoError(t, err) go func() { err := router.Run(context.Background()) assert.NoError(t, err) }() <-router.Running() select { case <-msg1.Nacked(): // ok case <-msg1.Acked(): // ack received t.Fatal("ack received, message should be nacked") } select { case <-msg2.Acked(): // ok case <-msg2.Nacked(): // nack received } assert.EqualValues(t, 2, onHandleCalled) } func TestCommandProcessor_AddHandlersToRouter_without_disableRouterAutoAddHandlers(t *testing.T) { ts := NewTestServices() router, err := message.NewRouter(message.RouterConfig{}, ts.Logger) require.NoError(t, err) cp, err := cqrs.NewCommandProcessorWithConfig( router, cqrs.CommandProcessorConfig{ GenerateSubscribeTopic: func(params cqrs.CommandProcessorGenerateSubscribeTopicParams) (string, error) { return "commands", nil }, SubscriberConstructor: func(params cqrs.CommandProcessorSubscriberConstructorParams) (message.Subscriber, error) { return ts.CommandsPubSub, nil }, Marshaler: ts.Marshaler, Logger: ts.Logger, }, ) require.NoError(t, err) err = cp.AddHandlersToRouter(router) assert.ErrorContains(t, err, "AddHandlersToRouter should be called only when using deprecated NewCommandProcessor") } func TestCommandProcessor_original_msg_set_to_ctx(t *testing.T) { logger := watermill.NewCaptureLogger() marshaler := cqrs.JSONMarshaler{} msgToSend, err := marshaler.Marshal(&TestCommand{ID: "1"}) require.NoError(t, err) mockSub := &mockSubscriber{ MessagesToSend: []*message.Message{ msgToSend, }, } router, err := message.NewRouter(message.RouterConfig{}, logger) require.NoError(t, err) commandProcessor, err := cqrs.NewCommandProcessorWithConfig( router, cqrs.CommandProcessorConfig{ GenerateSubscribeTopic: func(params cqrs.CommandProcessorGenerateSubscribeTopicParams) (string, error) { return "commands", nil }, SubscriberConstructor: func(params cqrs.CommandProcessorSubscriberConstructorParams) (message.Subscriber, error) { return mockSub, nil }, Marshaler: marshaler, Logger: logger, AckCommandHandlingErrors: true, }, ) require.NoError(t, err) var msgFromCtx *message.Message err = commandProcessor.AddHandlers(cqrs.NewCommandHandler( "handler", func(ctx context.Context, cmd *TestCommand) error { msgFromCtx = cqrs.OriginalMessageFromCtx(ctx) return nil }), ) require.NoError(t, err) go func() { err := router.Run(context.Background()) assert.NoError(t, err) }() <-router.Running() select { case <-msgToSend.Acked(): // ok case <-msgToSend.Nacked(): // nack received t.Fatal("nack received, message should be acked") case <-time.After(1 * time.Second): t.Fatal("timeout waiting for ack") } require.NotNil(t, msgFromCtx) assert.Equal(t, msgToSend, msgFromCtx) } ================================================ FILE: components/cqrs/cqrs.go ================================================ package cqrs import ( stdErrors "errors" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill/message" "github.com/pkg/errors" ) // Deprecated: use CommandProcessor and EventProcessor instead. type FacadeConfig struct { // GenerateCommandsTopic generates topic name based on the command name. // Command name is generated by CommandEventMarshaler's Name method. // // It allows you to use topic per command or one topic for every command. GenerateCommandsTopic func(commandName string) string // CommandHandlers return command handlers which should be executed. CommandHandlers func(commandBus *CommandBus, eventBus *EventBus) []CommandHandler // CommandsPublisher is Publisher used to publish commands. CommandsPublisher message.Publisher // CommandsSubscriberConstructor is constructor for subscribers which will subscribe for messages. // It will be called for every command handler. // It allows you to create separated customized Subscriber for every command handler. CommandsSubscriberConstructor CommandsSubscriberConstructor // GenerateEventsTopic generates topic name based on the event name. // Event name is generated by CommandEventMarshaler's Name method. // // It allows you to use topic per command or one topic for every command. GenerateEventsTopic func(eventName string) string // EventHandlers return event handlers which should be executed. EventHandlers func(commandBus *CommandBus, eventBus *EventBus) []EventHandler // EventsPublisher is Publisher used to publish commands. EventsPublisher message.Publisher // EventsSubscriberConstructor is constructor for subscribers which will subscribe for messages. // It will be called for every event handler. // It allows you to create separated customized Subscriber for every event handler. EventsSubscriberConstructor EventsSubscriberConstructor // Router is a Watermill router, which will be used to handle events and commands. // Router handlers will be automatically generated by AddHandlersToRouter of Command and Event handlers. Router *message.Router CommandEventMarshaler CommandEventMarshaler Logger watermill.LoggerAdapter } func (c FacadeConfig) Validate() error { var err error if c.CommandsEnabled() { if c.GenerateCommandsTopic == nil { err = stdErrors.Join(err, errors.New("GenerateCommandsTopic is nil")) } if c.CommandsSubscriberConstructor == nil { err = stdErrors.Join(err, errors.New("CommandsSubscriberConstructor is nil")) } if c.CommandsPublisher == nil { err = stdErrors.Join(err, errors.New("CommandsPublisher is nil")) } } if c.EventsEnabled() { if c.GenerateEventsTopic == nil { err = stdErrors.Join(err, errors.New("GenerateEventsTopic is nil")) } if c.EventsSubscriberConstructor == nil { err = stdErrors.Join(err, errors.New("EventsSubscriberConstructor is nil")) } if c.EventsPublisher == nil { err = stdErrors.Join(err, errors.New("EventsPublisher is nil")) } } if c.Router == nil { err = stdErrors.Join(err, errors.New("Router is nil")) } if c.Logger == nil { err = stdErrors.Join(err, errors.New("Logger is nil")) } if c.CommandEventMarshaler == nil { err = stdErrors.Join(err, errors.New("CommandEventMarshaler is nil")) } return err } func (c FacadeConfig) EventsEnabled() bool { return c.GenerateEventsTopic != nil || c.EventsPublisher != nil || c.EventsSubscriberConstructor != nil } func (c FacadeConfig) CommandsEnabled() bool { return c.GenerateCommandsTopic != nil || c.CommandsPublisher != nil || c.CommandsSubscriberConstructor != nil } // Deprecated: use CommandHandler and EventHandler instead. // // Facade is a facade for creating the Command and Event buses and processors. // It was created to avoid boilerplate, when using CQRS in the standard way. // You can also create buses and processors manually, drawing inspiration from how it's done in NewFacade. type Facade struct { commandsTopic func(commandName string) string commandBus *CommandBus eventsTopic func(eventName string) string eventBus *EventBus commandEventMarshaler CommandEventMarshaler } func (f Facade) CommandBus() *CommandBus { return f.commandBus } func (f Facade) EventBus() *EventBus { return f.eventBus } func (f Facade) CommandEventMarshaler() CommandEventMarshaler { return f.commandEventMarshaler } // Deprecated: use CommandHandler and EventHandler instead. func NewFacade(config FacadeConfig) (*Facade, error) { if err := config.Validate(); err != nil { return nil, errors.Wrap(err, "invalid config") } c := &Facade{ commandsTopic: config.GenerateCommandsTopic, eventsTopic: config.GenerateEventsTopic, commandEventMarshaler: config.CommandEventMarshaler, } if config.CommandsEnabled() { var err error c.commandBus, err = NewCommandBus( config.CommandsPublisher, config.GenerateCommandsTopic, config.CommandEventMarshaler, ) if err != nil { return nil, errors.Wrap(err, "cannot create command bus") } } else { config.Logger.Info("Empty GenerateCommandsTopic, command bus will be not created", nil) } if config.EventsEnabled() { var err error c.eventBus, err = NewEventBus(config.EventsPublisher, config.GenerateEventsTopic, config.CommandEventMarshaler) if err != nil { return nil, errors.Wrap(err, "cannot create event bus") } } else { config.Logger.Info("Empty GenerateEventsTopic, event bus will be not created", nil) } if config.CommandHandlers != nil { commandProcessor, err := NewCommandProcessor( config.CommandHandlers(c.commandBus, c.eventBus), config.GenerateCommandsTopic, config.CommandsSubscriberConstructor, config.CommandEventMarshaler, config.Logger, ) if err != nil { return nil, errors.Wrap(err, "cannot create command processor") } if err := commandProcessor.AddHandlersToRouter(config.Router); err != nil { return nil, err } } if config.EventHandlers != nil { eventProcessor, err := NewEventProcessor( config.EventHandlers(c.commandBus, c.eventBus), config.GenerateEventsTopic, config.EventsSubscriberConstructor, config.CommandEventMarshaler, config.Logger, ) if err != nil { return nil, errors.Wrap(err, "cannot create event processor") } if err := eventProcessor.AddHandlersToRouter(config.Router); err != nil { return nil, err } } return c, nil } ================================================ FILE: components/cqrs/cqrs_test.go ================================================ package cqrs_test import ( "context" "sync" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill/components/cqrs" "github.com/ThreeDotsLabs/watermill/message" "github.com/ThreeDotsLabs/watermill/pubsub/gochannel" ) // TestCQRS is functional test of CQRS command handler and event handler. func TestCQRS(t *testing.T) { testCases := []struct { Name string CreateCqrs func(t *testing.T, cc *CaptureCommandHandler, ce *CaptureEventHandler) (*message.Router, *cqrs.CommandBus, *cqrs.EventBus) }{ { // facade is deprecated, testing backwards compatibility Name: "facade", CreateCqrs: func(t *testing.T, cc *CaptureCommandHandler, ce *CaptureEventHandler) (*message.Router, *cqrs.CommandBus, *cqrs.EventBus) { router, cqrsFacade := createRouterAndFacade(t, cc, ce) return router, cqrsFacade.CommandBus(), cqrsFacade.EventBus() }, }, { Name: "constructors", CreateCqrs: createCqrsComponents, }, } for i := range testCases { tc := testCases[i] t.Run(tc.Name, func(t *testing.T) { captureCommandHandler := &CaptureCommandHandler{} captureEventHandler := &CaptureEventHandler{} router, commandBus, eventBus := tc.CreateCqrs(t, captureCommandHandler, captureEventHandler) pointerCmd := &TestCommand{ID: watermill.NewULID()} require.NoError(t, commandBus.Send(context.Background(), pointerCmd)) assert.EqualValues(t, []interface{}{pointerCmd}, captureCommandHandler.HandledCommands()) captureCommandHandler.Reset() nonPointerCmd := TestCommand{ID: watermill.NewULID()} require.NoError(t, commandBus.Send(context.Background(), nonPointerCmd)) // command is always unmarshaled to pointer value assert.EqualValues(t, []interface{}{&nonPointerCmd}, captureCommandHandler.HandledCommands()) captureCommandHandler.Reset() pointerEvent := &TestEvent{ID: watermill.NewULID()} require.NoError(t, eventBus.Publish(context.Background(), pointerEvent)) assert.EqualValues(t, []interface{}{pointerEvent}, captureEventHandler.HandledEvents()) captureEventHandler.Reset() nonPointerEvent := TestEvent{ID: watermill.NewULID()} require.NoError(t, eventBus.Publish(context.Background(), nonPointerEvent)) // event is always unmarshaled to pointer value assert.EqualValues(t, []interface{}{&nonPointerEvent}, captureEventHandler.HandledEvents()) captureEventHandler.Reset() assert.NoError(t, router.Close()) }) } } func createCqrsComponents(t *testing.T, commandHandler *CaptureCommandHandler, eventHandler *CaptureEventHandler) (*message.Router, *cqrs.CommandBus, *cqrs.EventBus) { ts := NewTestServices() router, err := message.NewRouter(message.RouterConfig{}, ts.Logger) require.NoError(t, err) eventProcessor, err := cqrs.NewEventProcessorWithConfig( router, cqrs.EventProcessorConfig{ GenerateSubscribeTopic: func(params cqrs.EventProcessorGenerateSubscribeTopicParams) (string, error) { return params.EventName, nil }, AckOnUnknownEvent: true, SubscriberConstructor: func(params cqrs.EventProcessorSubscriberConstructorParams) (message.Subscriber, error) { assert.Equal(t, "CaptureEventHandler", params.HandlerName) assert.Implements(t, new(cqrs.EventHandler), params.EventHandler) assert.NotNil(t, params.EventHandler) return ts.EventsPubSub, nil }, Marshaler: ts.Marshaler, Logger: ts.Logger, }, ) require.NoError(t, err) err = eventProcessor.AddHandlers(eventHandler) require.NoError(t, err) eventBus, err := cqrs.NewEventBusWithConfig( ts.EventsPubSub, cqrs.EventBusConfig{ GeneratePublishTopic: func(params cqrs.GenerateEventPublishTopicParams) (string, error) { assert.Equal(t, "cqrs_test.TestEvent", params.EventName) switch cmd := params.Event.(type) { case *TestEvent: assert.NotEmpty(t, cmd.ID) case TestEvent: assert.NotEmpty(t, cmd.ID) default: assert.Fail(t, "unexpected command type: %T", cmd) } assert.NotEmpty(t, params.Event) return params.EventName, nil }, Marshaler: ts.Marshaler, Logger: ts.Logger, }, ) require.NoError(t, err) commandProcessor, err := cqrs.NewCommandProcessorWithConfig( router, cqrs.CommandProcessorConfig{ GenerateSubscribeTopic: func(params cqrs.CommandProcessorGenerateSubscribeTopicParams) (string, error) { assert.Equal(t, "cqrs_test.TestCommand", params.CommandName) assert.Implements(t, new(cqrs.CommandHandler), params.CommandHandler) assert.NotNil(t, params.CommandHandler) return params.CommandName, nil }, SubscriberConstructor: func(params cqrs.CommandProcessorSubscriberConstructorParams) (message.Subscriber, error) { assert.Equal(t, "CaptureCommandHandler", params.HandlerName) return ts.CommandsPubSub, nil }, Marshaler: ts.Marshaler, Logger: ts.Logger, AckCommandHandlingErrors: false, }, ) require.NoError(t, err) err = commandProcessor.AddHandlers(commandHandler) require.NoError(t, err) commandBus, err := cqrs.NewCommandBusWithConfig(ts.CommandsPubSub, cqrs.CommandBusConfig{ GeneratePublishTopic: func(params cqrs.CommandBusGeneratePublishTopicParams) (string, error) { assert.Equal(t, "cqrs_test.TestCommand", params.CommandName) switch cmd := params.Command.(type) { case *TestCommand: assert.NotEmpty(t, cmd.ID) case TestCommand: assert.NotEmpty(t, cmd.ID) default: assert.Fail(t, "unexpected command type: %T", cmd) } assert.NotNil(t, params.Command) return params.CommandName, nil }, Marshaler: ts.Marshaler, Logger: ts.Logger, }) require.NoError(t, err) go func() { require.NoError(t, router.Run(context.Background())) }() <-router.Running() return router, commandBus, eventBus } func createRouterAndFacade(t *testing.T, commandHandler *CaptureCommandHandler, eventHandler *CaptureEventHandler) (*message.Router, *cqrs.Facade) { ts := NewTestServices() router, err := message.NewRouter(message.RouterConfig{}, ts.Logger) require.NoError(t, err) c, err := cqrs.NewFacade(cqrs.FacadeConfig{ GenerateCommandsTopic: func(commandName string) string { assert.Equal(t, "cqrs_test.TestCommand", commandName) return commandName }, GenerateEventsTopic: func(eventName string) string { assert.Equal(t, "cqrs_test.TestEvent", eventName) return eventName }, CommandHandlers: func(cb *cqrs.CommandBus, eb *cqrs.EventBus) []cqrs.CommandHandler { require.NotNil(t, cb) require.NotNil(t, eb) return []cqrs.CommandHandler{commandHandler} }, EventHandlers: func(cb *cqrs.CommandBus, eb *cqrs.EventBus) []cqrs.EventHandler { require.NotNil(t, cb) require.NotNil(t, eb) return []cqrs.EventHandler{eventHandler} }, Router: router, CommandsPublisher: ts.CommandsPubSub, CommandsSubscriberConstructor: func(handlerName string) (message.Subscriber, error) { assert.Equal(t, "CaptureCommandHandler", handlerName) return ts.CommandsPubSub, nil }, EventsPublisher: ts.EventsPubSub, EventsSubscriberConstructor: func(handlerName string) (message.Subscriber, error) { assert.Equal(t, "CaptureEventHandler", handlerName) return ts.EventsPubSub, nil }, Logger: ts.Logger, CommandEventMarshaler: ts.Marshaler, }) require.NoError(t, err) go func() { require.NoError(t, router.Run(context.Background())) }() <-router.Running() assert.Equal(t, c.CommandEventMarshaler(), ts.Marshaler) return router, c } type TestServices struct { Logger watermill.LoggerAdapter CommandsPubSub *gochannel.GoChannel EventsPubSub *gochannel.GoChannel Marshaler cqrs.CommandEventMarshaler } func NewTestServices() TestServices { logger := watermill.NewStdLogger(true, true) return TestServices{ Logger: logger, CommandsPubSub: gochannel.NewGoChannel( gochannel.Config{BlockPublishUntilSubscriberAck: true}, logger, ), EventsPubSub: gochannel.NewGoChannel( gochannel.Config{BlockPublishUntilSubscriberAck: true}, logger, ), Marshaler: cqrs.JSONMarshaler{}, } } type TestCommand struct { ID string } type CaptureCommandHandler struct { handledCommands []interface{} } func (h CaptureCommandHandler) HandlerName() string { return "CaptureCommandHandler" } func (h CaptureCommandHandler) HandledCommands() []interface{} { return h.handledCommands } func (h *CaptureCommandHandler) Reset() { h.handledCommands = nil } func (CaptureCommandHandler) NewCommand() interface{} { return &TestCommand{} } func (h *CaptureCommandHandler) Handle(ctx context.Context, cmd interface{}) error { h.handledCommands = append(h.handledCommands, cmd.(*TestCommand)) return nil } type TestEvent struct { ID string When time.Time } type AnotherTestEvent struct { ID string } type CaptureEventHandler struct { handledEvents []interface{} } func (h CaptureEventHandler) HandlerName() string { return "CaptureEventHandler" } func (h CaptureEventHandler) HandledEvents() []interface{} { return h.handledEvents } func (h *CaptureEventHandler) Reset() { h.handledEvents = nil } func (CaptureEventHandler) NewEvent() interface{} { return &TestEvent{} } func (h *CaptureEventHandler) Handle(ctx context.Context, event interface{}) error { h.handledEvents = append(h.handledEvents, event.(*TestEvent)) return nil } type assertPublishTopicPublisher struct { ExpectedTopic string T *testing.T } func (a assertPublishTopicPublisher) Publish(topic string, messages ...*message.Message) error { assert.Equal(a.T, a.ExpectedTopic, topic) return nil } func (assertPublishTopicPublisher) Close() error { return nil } type publisherStub struct { messages map[string]message.Messages mu sync.Mutex } func newPublisherStub() *publisherStub { return &publisherStub{ messages: make(map[string]message.Messages), } } func (*publisherStub) Close() error { return nil } func (p *publisherStub) Publish(topic string, messages ...*message.Message) error { p.mu.Lock() defer p.mu.Unlock() p.messages[topic] = append(p.messages[topic], messages...) return nil } func TestFacadeConfig_Validate(t *testing.T) { ts := NewTestServices() router, err := message.NewRouter(message.RouterConfig{}, ts.Logger) require.NoError(t, err) validConfig := cqrs.FacadeConfig{ GenerateCommandsTopic: func(commandName string) string { return commandName }, GenerateEventsTopic: func(eventName string) string { return eventName }, CommandHandlers: func(cb *cqrs.CommandBus, eb *cqrs.EventBus) []cqrs.CommandHandler { return []cqrs.CommandHandler{} }, EventHandlers: func(cb *cqrs.CommandBus, eb *cqrs.EventBus) []cqrs.EventHandler { return []cqrs.EventHandler{} }, Router: router, CommandsPublisher: ts.CommandsPubSub, CommandsSubscriberConstructor: func(handlerName string) (message.Subscriber, error) { return ts.CommandsPubSub, nil }, EventsPublisher: ts.EventsPubSub, EventsSubscriberConstructor: func(handlerName string) (message.Subscriber, error) { return ts.EventsPubSub, nil }, Logger: ts.Logger, CommandEventMarshaler: ts.Marshaler, } testCases := []struct { Name string Config cqrs.FacadeConfig Valid bool }{ { Name: "valid", Config: validConfig, Valid: true, }, { Name: "missing_GenerateCommandsTopic", Config: transformConfig(validConfig, func(config *cqrs.FacadeConfig) { config.GenerateCommandsTopic = nil }), Valid: false, }, { Name: "missing_CommandsSubscriberConstructor", Config: transformConfig(validConfig, func(config *cqrs.FacadeConfig) { config.CommandsSubscriberConstructor = nil }), Valid: false, }, { Name: "missing_CommandsPublisher", Config: transformConfig(validConfig, func(config *cqrs.FacadeConfig) { config.CommandsPublisher = nil }), Valid: false, }, { Name: "missing_GenerateEventsTopic", Config: transformConfig(validConfig, func(config *cqrs.FacadeConfig) { config.GenerateEventsTopic = nil }), Valid: false, }, { Name: "missing_GenerateEventsTopic", Config: transformConfig(validConfig, func(config *cqrs.FacadeConfig) { config.EventsSubscriberConstructor = nil }), Valid: false, }, { Name: "missing_EventsPublisher", Config: transformConfig(validConfig, func(config *cqrs.FacadeConfig) { config.EventsPublisher = nil }), Valid: false, }, { Name: "missing_Router", Config: transformConfig(validConfig, func(config *cqrs.FacadeConfig) { config.Router = nil }), Valid: false, }, { Name: "missing_Logger", Config: transformConfig(validConfig, func(config *cqrs.FacadeConfig) { config.Logger = nil }), Valid: false, }, { Name: "missing_CommandEventMarshaler", Config: transformConfig(validConfig, func(config *cqrs.FacadeConfig) { config.CommandEventMarshaler = nil }), Valid: false, }, } for _, c := range testCases { t.Run(c.Name, func(t *testing.T) { if c.Valid { assert.NoError(t, c.Config.Validate()) } else { assert.Error(t, c.Config.Validate()) } }) } } func transformConfig(config cqrs.FacadeConfig, transformFn func(config *cqrs.FacadeConfig)) cqrs.FacadeConfig { transformFn(&config) return config } ================================================ FILE: components/cqrs/ctx.go ================================================ package cqrs import ( "context" "github.com/ThreeDotsLabs/watermill/message" ) type ctxKey string const ( originalMessage ctxKey = "original_message" ) // OriginalMessageFromCtx returns the original message that was received by the event/command handler. func OriginalMessageFromCtx(ctx context.Context) *message.Message { val, ok := ctx.Value(originalMessage).(*message.Message) if !ok { return nil } return val } // CtxWithOriginalMessage returns a new context with the original message attached. func CtxWithOriginalMessage(ctx context.Context, msg *message.Message) context.Context { return context.WithValue(ctx, originalMessage, msg) } ================================================ FILE: components/cqrs/doc.go ================================================ // Detailed CQRS documentation can be found in https://watermill.io/docs/cqrs/ package cqrs ================================================ FILE: components/cqrs/event_bus.go ================================================ package cqrs import ( "context" stdErrors "errors" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill/message" "github.com/pkg/errors" ) type EventBusConfig struct { // GeneratePublishTopic is used to generate topic name for publishing event. GeneratePublishTopic GenerateEventPublishTopicFn // OnPublish is called before sending the event. // The *message.Message can be modified. // // This option is not required. OnPublish OnEventSendFn // Marshaler is used to marshal and unmarshal events. // It is required. Marshaler CommandEventMarshaler // Logger instance used to log. // If not provided, watermill.NopLogger is used. Logger watermill.LoggerAdapter } func (c *EventBusConfig) setDefaults() { if c.Logger == nil { c.Logger = watermill.NopLogger{} } } func (c EventBusConfig) Validate() error { var err error if c.Marshaler == nil { err = stdErrors.Join(err, errors.New("missing Marshaler")) } if c.GeneratePublishTopic == nil { err = stdErrors.Join(err, errors.New("missing GenerateHandlerTopic")) } return err } type GenerateEventPublishTopicFn func(GenerateEventPublishTopicParams) (string, error) type GenerateEventPublishTopicParams struct { EventName string Event any } type OnEventSendFn func(params OnEventSendParams) error type OnEventSendParams struct { EventName string Event any // Message is never nil and can be modified. Message *message.Message } // EventBus transports events to event handlers. type EventBus struct { publisher message.Publisher config EventBusConfig } // NewEventBus creates a new CommandBus. // Deprecated: use NewEventBusWithConfig instead. func NewEventBus( publisher message.Publisher, generateTopic func(eventName string) string, marshaler CommandEventMarshaler, ) (*EventBus, error) { if publisher == nil { return nil, errors.New("missing publisher") } if generateTopic == nil { return nil, errors.New("missing generateTopic") } if marshaler == nil { return nil, errors.New("missing marshaler") } return &EventBus{ publisher: publisher, config: EventBusConfig{ GeneratePublishTopic: func(params GenerateEventPublishTopicParams) (string, error) { return generateTopic(params.EventName), nil }, Marshaler: marshaler, }, }, nil } // NewEventBusWithConfig creates a new EventBus. func NewEventBusWithConfig(publisher message.Publisher, config EventBusConfig) (*EventBus, error) { if publisher == nil { return nil, errors.New("missing publisher") } config.setDefaults() if err := config.Validate(); err != nil { return nil, errors.Wrap(err, "invalid config") } return &EventBus{publisher, config}, nil } // Publish sends event to the event bus. func (c EventBus) Publish(ctx context.Context, event any) error { msg, err := c.config.Marshaler.Marshal(event) if err != nil { return err } eventName := c.config.Marshaler.Name(event) topicName, err := c.config.GeneratePublishTopic(GenerateEventPublishTopicParams{ EventName: eventName, Event: event, }) if err != nil { return errors.Wrap(err, "cannot generate topic") } msg.SetContext(ctx) if c.config.OnPublish != nil { err := c.config.OnPublish(OnEventSendParams{ EventName: eventName, Event: event, Message: msg, }) if err != nil { return errors.Wrap(err, "cannot execute OnPublish") } } return c.publisher.Publish(topicName, msg) } ================================================ FILE: components/cqrs/event_bus_test.go ================================================ package cqrs_test import ( "context" "fmt" "testing" "github.com/ThreeDotsLabs/watermill/components/cqrs" "github.com/pkg/errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestEventBusConfig_Validate(t *testing.T) { testCases := []struct { Name string ModifyValidConfig func(*cqrs.EventBusConfig) ExpectedErr error }{ { Name: "valid_config", ModifyValidConfig: nil, ExpectedErr: nil, }, { Name: "missing_GenerateEventPublishTopic", ModifyValidConfig: func(config *cqrs.EventBusConfig) { config.GeneratePublishTopic = nil }, ExpectedErr: fmt.Errorf("missing GenerateHandlerTopic"), }, { Name: "missing_marshaler", ModifyValidConfig: func(config *cqrs.EventBusConfig) { config.Marshaler = nil }, ExpectedErr: fmt.Errorf("missing Marshaler"), }, } for i := range testCases { tc := testCases[i] t.Run(tc.Name, func(t *testing.T) { validConfig := cqrs.EventBusConfig{ GeneratePublishTopic: func(params cqrs.GenerateEventPublishTopicParams) (string, error) { return "", nil }, Marshaler: cqrs.JSONMarshaler{}, } if tc.ModifyValidConfig != nil { tc.ModifyValidConfig(&validConfig) } err := validConfig.Validate() if tc.ExpectedErr == nil { assert.NoError(t, err) } else { assert.EqualError(t, err, tc.ExpectedErr.Error()) } }) } } func TestNewEventBus(t *testing.T) { pub := newPublisherStub() generateTopic := func(commandName string) string { return "" } marshaler := cqrs.JSONMarshaler{} cb, err := cqrs.NewEventBus(pub, generateTopic, marshaler) assert.NotNil(t, cb) assert.NoError(t, err) cb, err = cqrs.NewEventBus(nil, generateTopic, marshaler) assert.Nil(t, cb) assert.Error(t, err) cb, err = cqrs.NewEventBus(pub, nil, marshaler) assert.Nil(t, cb) assert.Error(t, err) cb, err = cqrs.NewEventBus(pub, generateTopic, nil) assert.Nil(t, cb) assert.Error(t, err) } func TestEventBus_Send_ContextPropagation(t *testing.T) { publisher := newPublisherStub() eventBus, err := cqrs.NewEventBus( publisher, func(eventName string) string { return "whatever" }, cqrs.JSONMarshaler{}, ) require.NoError(t, err) ctx := context.WithValue(context.Background(), contextKey("key"), "value") err = eventBus.Publish(ctx, "message") require.NoError(t, err) assert.Equal(t, ctx, publisher.messages["whatever"][0].Context()) } func TestEventBus_Send_topic_name(t *testing.T) { cb, err := cqrs.NewEventBus( assertPublishTopicPublisher{ExpectedTopic: "cqrs_test.TestEvent", T: t}, func(commandName string) string { return commandName }, cqrs.JSONMarshaler{}, ) require.NoError(t, err) err = cb.Publish(context.Background(), TestEvent{}) require.NoError(t, err) } func TestEventBus_Send_OnPublish(t *testing.T) { publisher := newPublisherStub() eb, err := cqrs.NewEventBusWithConfig( publisher, cqrs.EventBusConfig{ GeneratePublishTopic: func(params cqrs.GenerateEventPublishTopicParams) (string, error) { return "whatever", nil }, Marshaler: cqrs.JSONMarshaler{}, OnPublish: func(params cqrs.OnEventSendParams) error { params.Message.Metadata.Set("key", "value") return nil }, }, ) require.NoError(t, err) err = eb.Publish(context.Background(), TestEvent{}) require.NoError(t, err) assert.Equal(t, "value", publisher.messages["whatever"][0].Metadata.Get("key")) } func TestEventBus_Send_OnPublish_error(t *testing.T) { publisher := newPublisherStub() expectedErr := errors.New("some error") eb, err := cqrs.NewEventBusWithConfig( publisher, cqrs.EventBusConfig{ GeneratePublishTopic: func(params cqrs.GenerateEventPublishTopicParams) (string, error) { return "whatever", nil }, Marshaler: cqrs.JSONMarshaler{}, OnPublish: func(params cqrs.OnEventSendParams) error { return expectedErr }, }, ) require.NoError(t, err) err = eb.Publish(context.Background(), TestEvent{}) require.EqualError(t, err, "cannot execute OnPublish: some error") } ================================================ FILE: components/cqrs/event_handler.go ================================================ package cqrs import ( "context" ) // EventHandler receives events defined by NewEvent and handles them with its Handle method. // If using DDD, CommandHandler may modify and persist the aggregate. // It can also invoke a process manager, a saga or just build a read model. // // In contrast to CommandHandler, every Event can have multiple EventHandlers. // // One instance of EventHandler is used during handling messages. // When multiple events are delivered at the same time, Handle method can be executed multiple times at the same time. // Because of that, Handle method needs to be thread safe! type EventHandler interface { // HandlerName is the name used in message.Router while creating handler. // // It will be also passed to EventsSubscriberConstructor. // May be useful, for example, to create a consumer group per each handler. // // WARNING: If HandlerName was changed and is used for generating consumer groups, // it may result with **reconsuming all messages** !!! HandlerName() string NewEvent() any Handle(ctx context.Context, event any) error } type genericEventHandler[T any] struct { handleFunc func(ctx context.Context, event *T) error handlerName string } // NewEventHandler creates a new EventHandler implementation based on provided function // and event type inferred from function argument. func NewEventHandler[T any]( handlerName string, handleFunc func(ctx context.Context, event *T) error, ) EventHandler { return &genericEventHandler[T]{ handleFunc: handleFunc, handlerName: handlerName, } } func (c genericEventHandler[T]) HandlerName() string { return c.handlerName } func (c genericEventHandler[T]) NewEvent() any { tVar := new(T) return tVar } func (c genericEventHandler[T]) Handle(ctx context.Context, e any) error { event := e.(*T) return c.handleFunc(ctx, event) } type GroupEventHandler interface { NewEvent() interface{} Handle(ctx context.Context, event interface{}) error } // NewGroupEventHandler creates a new GroupEventHandler implementation based on provided function // and event type inferred from function argument. func NewGroupEventHandler[T any](handleFunc func(ctx context.Context, event *T) error) GroupEventHandler { return &genericEventHandler[T]{ handleFunc: handleFunc, } } ================================================ FILE: components/cqrs/event_handler_test.go ================================================ package cqrs_test import ( "context" "fmt" "testing" "github.com/ThreeDotsLabs/watermill/components/cqrs" "github.com/stretchr/testify/assert" ) type SomeEvent struct { Foo string } func TestNewEventHandler(t *testing.T) { cmdToSend := &SomeEvent{"bar"} ch := cqrs.NewEventHandler( "some_handler", func(ctx context.Context, cmd *SomeEvent) error { assert.Equal(t, cmdToSend, cmd) return fmt.Errorf("some error") }, ) assert.Equal(t, "some_handler", ch.HandlerName()) assert.Equal(t, &SomeEvent{}, ch.NewEvent()) err := ch.Handle(context.Background(), cmdToSend) assert.EqualError(t, err, "some error") } func TestNewGroupEventHandler(t *testing.T) { cmdToSend := &SomeEvent{"bar"} ch := cqrs.NewGroupEventHandler( func(ctx context.Context, cmd *SomeEvent) error { assert.Equal(t, cmdToSend, cmd) return fmt.Errorf("some error") }, ) assert.Equal(t, &SomeEvent{}, ch.NewEvent()) err := ch.Handle(context.Background(), cmdToSend) assert.EqualError(t, err, "some error") } ================================================ FILE: components/cqrs/event_processor.go ================================================ package cqrs import ( stdErrors "errors" "fmt" "github.com/pkg/errors" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill/message" ) type EventProcessorConfig struct { // GenerateSubscribeTopic is used to generate topic for subscribing to events. // If event processor is using handler groups, GenerateSubscribeTopic is used instead. GenerateSubscribeTopic EventProcessorGenerateSubscribeTopicFn // SubscriberConstructor is used to create subscriber for EventHandler. // // This function is called for every EventHandler instance. // If you want to re-use one subscriber for multiple handlers, use GroupEventProcessor instead. SubscriberConstructor EventProcessorSubscriberConstructorFn // OnHandle is called before handling event. // OnHandle works in a similar way to middlewares: you can inject additional logic before and after handling an event. // // Because of that, you need to explicitly call params.Handler.Handle() to handle the event. // // func(params EventProcessorOnHandleParams) (err error) { // // logic before handle // // (...) // // err := params.Handler.Handle(params.Message.Context(), params.Event) // // // logic after handle // // (...) // // return err // } // // This option is not required. OnHandle EventProcessorOnHandleFn // AckOnUnknownEvent is used to decide if message should be acked if event has no handler defined. AckOnUnknownEvent bool // Marshaler is used to marshal and unmarshal events. // It is required. Marshaler CommandEventMarshaler // Logger instance used to log. // If not provided, watermill.NopLogger is used. Logger watermill.LoggerAdapter // disableRouterAutoAddHandlers is used to keep backwards compatibility. // it is set when EventProcessor is created by NewEventProcessor. // Deprecated: please migrate to NewEventProcessorWithConfig. disableRouterAutoAddHandlers bool } func (c *EventProcessorConfig) setDefaults() { if c.Logger == nil { c.Logger = watermill.NopLogger{} } } func (c EventProcessorConfig) Validate() error { var err error if c.Marshaler == nil { err = stdErrors.Join(err, errors.New("missing Marshaler")) } if c.GenerateSubscribeTopic == nil { err = stdErrors.Join(err, errors.New("missing GenerateHandlerTopic")) } if c.SubscriberConstructor == nil { err = stdErrors.Join(err, errors.New("missing SubscriberConstructor")) } return err } type EventProcessorGenerateSubscribeTopicFn func(EventProcessorGenerateSubscribeTopicParams) (string, error) type EventProcessorGenerateSubscribeTopicParams struct { EventName string EventHandler EventHandler } type EventProcessorSubscriberConstructorFn func(EventProcessorSubscriberConstructorParams) (message.Subscriber, error) type EventProcessorSubscriberConstructorParams struct { EventName string HandlerName string EventHandler EventHandler } type EventProcessorOnHandleFn func(params EventProcessorOnHandleParams) error type EventProcessorOnHandleParams struct { Handler EventHandler Event any EventName string // Message is never nil and can be modified. Message *message.Message } // EventProcessor determines which EventHandler should handle event received from event bus. type EventProcessor struct { router *message.Router handlers []EventHandler config EventProcessorConfig } // NewEventProcessorWithConfig creates a new EventProcessor. func NewEventProcessorWithConfig(router *message.Router, config EventProcessorConfig) (*EventProcessor, error) { config.setDefaults() if err := config.Validate(); err != nil { return nil, errors.Wrap(err, "invalid config EventProcessor") } if router == nil && !config.disableRouterAutoAddHandlers { return nil, errors.New("missing router") } return &EventProcessor{ router: router, config: config, }, nil } // NewEventProcessor creates a new EventProcessor. // Deprecated. Use NewEventProcessorWithConfig instead. func NewEventProcessor( individualHandlers []EventHandler, generateTopic func(eventName string) string, subscriberConstructor EventsSubscriberConstructor, marshaler CommandEventMarshaler, logger watermill.LoggerAdapter, ) (*EventProcessor, error) { if len(individualHandlers) == 0 { return nil, errors.New("missing handlers") } if generateTopic == nil { return nil, errors.New("nil generateTopic") } if subscriberConstructor == nil { return nil, errors.New("missing subscriberConstructor") } if marshaler == nil { return nil, errors.New("missing marshaler") } if logger == nil { logger = watermill.NopLogger{} } eventProcessorConfig := EventProcessorConfig{ AckOnUnknownEvent: true, // this is the previous default behaviour - keeping backwards compatibility GenerateSubscribeTopic: func(params EventProcessorGenerateSubscribeTopicParams) (string, error) { return generateTopic(params.EventName), nil }, SubscriberConstructor: func(params EventProcessorSubscriberConstructorParams) (message.Subscriber, error) { return subscriberConstructor(params.HandlerName) }, Marshaler: marshaler, Logger: logger, disableRouterAutoAddHandlers: true, } eventProcessorConfig.setDefaults() ep, err := NewEventProcessorWithConfig(nil, eventProcessorConfig) if err != nil { return nil, err } for _, handler := range individualHandlers { if err := ep.AddHandlers(handler); err != nil { return nil, err } } return ep, nil } // EventsSubscriberConstructor creates a subscriber for EventHandler. // It allows you to create separated customized Subscriber for every command handler. // // When handler groups are used, handler group is passed as handlerName. // Deprecated: please use EventProcessorSubscriberConstructorFn instead. type EventsSubscriberConstructor func(handlerName string) (message.Subscriber, error) // AddHandlers adds a new EventHandler to the EventProcessor and adds it to the router. func (p *EventProcessor) AddHandlers(handlers ...EventHandler) error { if p.config.disableRouterAutoAddHandlers { p.handlers = append(p.handlers, handlers...) return nil } for _, handler := range handlers { if _, err := p.addHandlerToRouter(p.router, handler); err != nil { return err } p.handlers = append(p.handlers, handler) } return nil } // AddHandler adds a new EventHandler to the EventProcessor and adds it to the router. func (p *EventProcessor) AddHandler(handler EventHandler) (*message.Handler, error) { if p.config.disableRouterAutoAddHandlers { p.handlers = append(p.handlers, handler) return nil, nil } h, err := p.addHandlerToRouter(p.router, handler) if err != nil { return nil, err } p.handlers = append(p.handlers, handler) return h, nil } // AddHandlersToRouter adds the EventProcessor's handlers to the given router. // It should be called only once per EventProcessor instance. // // It is required to call AddHandlersToRouter only if command processor is created with NewEventProcessor (disableRouterAutoAddHandlers is set to true). // Deprecated: please migrate to event processor created by NewEventProcessorWithConfig. func (p EventProcessor) AddHandlersToRouter(r *message.Router) error { if !p.config.disableRouterAutoAddHandlers { return errors.New("AddHandlersToRouter should be called only when using deprecated NewEventProcessor") } for i := range p.handlers { handler := p.handlers[i] if _, err := p.addHandlerToRouter(r, handler); err != nil { return err } } return nil } func (p EventProcessor) addHandlerToRouter(r *message.Router, handler EventHandler) (*message.Handler, error) { if err := validateEvent(handler.NewEvent()); err != nil { return nil, errors.Wrapf(err, "invalid event for handler %s", handler.HandlerName()) } handlerName := handler.HandlerName() eventName := p.config.Marshaler.Name(handler.NewEvent()) topicName, err := p.config.GenerateSubscribeTopic(EventProcessorGenerateSubscribeTopicParams{ EventName: eventName, EventHandler: handler, }) if err != nil { return nil, errors.Wrapf(err, "cannot generate topic name for handler %s", handlerName) } logger := p.config.Logger.With(watermill.LogFields{ "event_handler_name": handlerName, "topic": topicName, }) handlerFunc, err := p.routerHandlerFunc(handler, logger) if err != nil { return nil, err } if p.config.SubscriberConstructor == nil { return nil, errors.New("missing SubscriberConstructor config option") } subscriber, err := p.config.SubscriberConstructor(EventProcessorSubscriberConstructorParams{ EventName: eventName, HandlerName: handlerName, EventHandler: handler, }) if err != nil { return nil, errors.Wrap(err, "cannot create subscriber for event processor") } return addHandlerToRouter(p.config.Logger, r, handlerName, topicName, handlerFunc, subscriber), nil } func (p EventProcessor) Handlers() []EventHandler { return p.handlers } func addHandlerToRouter(logger watermill.LoggerAdapter, r *message.Router, handlerName string, topicName string, handlerFunc message.NoPublishHandlerFunc, subscriber message.Subscriber) *message.Handler { logger = logger.With(watermill.LogFields{ "event_handler_name": handlerName, "topic": topicName, }) logger.Debug("Adding CQRS event handler to router", nil) return r.AddConsumerHandler( handlerName, topicName, subscriber, handlerFunc, ) } func (p EventProcessor) routerHandlerFunc(handler EventHandler, logger watermill.LoggerAdapter) (message.NoPublishHandlerFunc, error) { initEvent := handler.NewEvent() expectedEventName := p.config.Marshaler.Name(initEvent) if err := validateEvent(initEvent); err != nil { return nil, err } return func(msg *message.Message) error { event := handler.NewEvent() messageEventName := p.config.Marshaler.NameFromMessage(msg) if messageEventName != expectedEventName { if !p.config.AckOnUnknownEvent { return fmt.Errorf("received unexpected event type %s, expected %s", messageEventName, expectedEventName) } else { logger.Trace("Received different event type than expected, ignoring", watermill.LogFields{ "message_uuid": msg.UUID, "expected_event_type": expectedEventName, "received_event_type": messageEventName, }) return nil } } logger.Debug("Handling event", watermill.LogFields{ "message_uuid": msg.UUID, "received_event_type": messageEventName, }) ctx := CtxWithOriginalMessage(msg.Context(), msg) msg.SetContext(ctx) if err := p.config.Marshaler.Unmarshal(msg, event); err != nil { return err } handle := func(params EventProcessorOnHandleParams) error { return params.Handler.Handle(ctx, params.Event) } if p.config.OnHandle != nil { handle = p.config.OnHandle } err := handle(EventProcessorOnHandleParams{ Handler: handler, Event: event, EventName: messageEventName, Message: msg, }) if err != nil { logger.Debug("Error when handling event", watermill.LogFields{"err": err}) return err } return nil }, nil } func validateEvent(event interface{}) error { // EventHandler's NewEvent must return a pointer, because it is used to unmarshal if err := isPointer(event); err != nil { return errors.Wrap(err, "command must be a non-nil pointer") } return nil } ================================================ FILE: components/cqrs/event_processor_group.go ================================================ package cqrs import ( stdErrors "errors" "fmt" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill/message" "github.com/pkg/errors" ) type EventGroupProcessorConfig struct { // GenerateSubscribeTopic is used to generate topic for subscribing to events for handler groups. // This option is required for EventProcessor if handler groups are used. GenerateSubscribeTopic EventGroupProcessorGenerateSubscribeTopicFn // SubscriberConstructor is used to create subscriber for GroupEventHandler. // This function is called for every events group once - thanks to that it's possible to have one subscription per group. // It's useful, when we are processing events from one stream and we want to do it in order. SubscriberConstructor EventGroupProcessorSubscriberConstructorFn // OnHandle is called before handling event. // OnHandle works in a similar way to middlewares: you can inject additional logic before and after handling an event. // // Because of that, you need to explicitly call params.Handler.Handle() to handle the event. // // func(params EventGroupProcessorOnHandleParams) (err error) { // // logic before handle // // (...) // // err := params.Handler.Handle(params.Message.Context(), params.Event) // // // logic after handle // // (...) // // return err // } // // This option is not required. OnHandle EventGroupProcessorOnHandleFn // AckOnUnknownEvent is used to decide if message should be acked if event has no handler defined. AckOnUnknownEvent bool // Marshaler is used to marshal and unmarshal events. // It is required. Marshaler CommandEventMarshaler // Logger instance used to log. // If not provided, watermill.NopLogger is used. Logger watermill.LoggerAdapter } func (c *EventGroupProcessorConfig) setDefaults() { if c.Logger == nil { c.Logger = watermill.NopLogger{} } } func (c EventGroupProcessorConfig) Validate() error { var err error if c.Marshaler == nil { err = stdErrors.Join(err, errors.New("missing Marshaler")) } if c.GenerateSubscribeTopic == nil { err = stdErrors.Join(err, errors.New("missing GenerateHandlerGroupTopic")) } if c.SubscriberConstructor == nil { err = stdErrors.Join(err, errors.New("missing SubscriberConstructor")) } return err } type EventGroupProcessorGenerateSubscribeTopicFn func(EventGroupProcessorGenerateSubscribeTopicParams) (string, error) type EventGroupProcessorGenerateSubscribeTopicParams struct { EventGroupName string EventGroupHandlers []GroupEventHandler } type EventGroupProcessorSubscriberConstructorFn func(EventGroupProcessorSubscriberConstructorParams) (message.Subscriber, error) type EventGroupProcessorSubscriberConstructorParams struct { EventGroupName string EventGroupHandlers []GroupEventHandler } type EventGroupProcessorOnHandleFn func(params EventGroupProcessorOnHandleParams) error type EventGroupProcessorOnHandleParams struct { GroupName string Handler GroupEventHandler Event any EventName string // Message is never nil and can be modified. Message *message.Message } // EventGroupProcessor determines which EventHandler should handle event received from event bus. // Compared to EventProcessor, EventGroupProcessor allows to have multiple handlers that share the same subscriber instance. type EventGroupProcessor struct { router *message.Router groupEventHandlers map[string][]GroupEventHandler config EventGroupProcessorConfig } // NewEventGroupProcessorWithConfig creates a new EventGroupProcessor. func NewEventGroupProcessorWithConfig(router *message.Router, config EventGroupProcessorConfig) (*EventGroupProcessor, error) { config.setDefaults() if err := config.Validate(); err != nil { return nil, errors.Wrap(err, "invalid config EventProcessor") } if router == nil { return nil, errors.New("missing router") } return &EventGroupProcessor{ router: router, groupEventHandlers: map[string][]GroupEventHandler{}, config: config, }, nil } // AddHandlersGroup adds a new list of GroupEventHandler to the EventGroupProcessor and adds it to the router. // // Compared to AddHandlers, AddHandlersGroup allows to have multiple handlers that share the same subscriber instance. // // It's allowed to have multiple handlers for the same event type in one group, but we recommend to not do that. // Please keep in mind that those handlers will be processed within the same message. // If first handler succeeds and the second fails, the message will be re-delivered and the first will be re-executed. // // Handlers group needs to be unique within the EventProcessor instance. // // Handler group name is used as handler's name in router. func (p *EventGroupProcessor) AddHandlersGroup(groupName string, handlers ...GroupEventHandler) error { if len(handlers) == 0 { return errors.New("no handlers provided") } if _, ok := p.groupEventHandlers[groupName]; ok { return fmt.Errorf("event handler group '%s' already exists", groupName) } if err := p.addHandlerToRouter(p.router, groupName, handlers); err != nil { return err } p.groupEventHandlers[groupName] = handlers return nil } func (p EventGroupProcessor) addHandlerToRouter(r *message.Router, groupName string, handlersGroup []GroupEventHandler) error { for i, handler := range handlersGroup { if err := validateEvent(handler.NewEvent()); err != nil { return errors.Wrapf( err, "invalid event for handler %T (num %d) in group %s", handler, i, groupName, ) } } topicName, err := p.config.GenerateSubscribeTopic(EventGroupProcessorGenerateSubscribeTopicParams{ EventGroupName: groupName, EventGroupHandlers: handlersGroup, }) if err != nil { return errors.Wrapf(err, "cannot generate topic name for handler group %s", groupName) } logger := p.config.Logger.With(watermill.LogFields{ "event_handler_group_name": groupName, "topic": topicName, }) handlerFunc, err := p.routerHandlerGroupFunc(handlersGroup, groupName, logger) if err != nil { return err } subscriber, err := p.config.SubscriberConstructor(EventGroupProcessorSubscriberConstructorParams{ EventGroupName: groupName, EventGroupHandlers: handlersGroup, }) if err != nil { return errors.Wrap(err, "cannot create subscriber for event processor") } _ = addHandlerToRouter(p.config.Logger, r, groupName, topicName, handlerFunc, subscriber) return nil } func (p EventGroupProcessor) routerHandlerGroupFunc(handlers []GroupEventHandler, groupName string, logger watermill.LoggerAdapter) (message.NoPublishHandlerFunc, error) { return func(msg *message.Message) error { messageEventName := p.config.Marshaler.NameFromMessage(msg) handledAnyEvent := false for _, handler := range handlers { initEvent := handler.NewEvent() expectedEventName := p.config.Marshaler.Name(initEvent) event := handler.NewEvent() if messageEventName != expectedEventName { logger.Trace("Received different event type than expected, ignoring", watermill.LogFields{ "message_uuid": msg.UUID, "expected_event_type": expectedEventName, "received_event_type": messageEventName, }) continue } logger.Debug("Handling event", watermill.LogFields{ "message_uuid": msg.UUID, "received_event_type": messageEventName, }) ctx := CtxWithOriginalMessage(msg.Context(), msg) msg.SetContext(ctx) if err := p.config.Marshaler.Unmarshal(msg, event); err != nil { return err } handle := func(params EventGroupProcessorOnHandleParams) error { return params.Handler.Handle(ctx, params.Event) } if p.config.OnHandle != nil { handle = p.config.OnHandle } err := handle(EventGroupProcessorOnHandleParams{ GroupName: groupName, Handler: handler, EventName: messageEventName, Event: event, Message: msg, }) if err != nil { logger.Debug("Error when handling event", watermill.LogFields{"err": err}) return err } handledAnyEvent = true } if handledAnyEvent { return nil } if !p.config.AckOnUnknownEvent { return fmt.Errorf("no handler found for event %s", p.config.Marshaler.NameFromMessage(msg)) } else { logger.Trace("Received event can't be handled by any handler in handler group", watermill.LogFields{ "message_uuid": msg.UUID, "received_event_type": messageEventName, }) return nil } }, nil } ================================================ FILE: components/cqrs/event_processor_group_test.go ================================================ package cqrs_test import ( "context" "fmt" "sync/atomic" "testing" "time" "github.com/ThreeDotsLabs/watermill/components/cqrs" "github.com/ThreeDotsLabs/watermill/message" "github.com/pkg/errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestEventGroupProcessorConfig_Validate(t *testing.T) { testCases := []struct { Name string ModifyValidConfig func(*cqrs.EventGroupProcessorConfig) ExpectedErr error }{ { Name: "valid_config", ModifyValidConfig: nil, ExpectedErr: nil, }, { Name: "valid_with_group_handlers", ExpectedErr: nil, }, { Name: "missing_GroupSubscriberConstructor", ModifyValidConfig: func(config *cqrs.EventGroupProcessorConfig) { config.SubscriberConstructor = nil }, ExpectedErr: fmt.Errorf("missing SubscriberConstructor"), }, { Name: "missing_GenerateHandlerGroupSubscribeTopic", ModifyValidConfig: func(config *cqrs.EventGroupProcessorConfig) { config.GenerateSubscribeTopic = nil }, ExpectedErr: fmt.Errorf("missing GenerateHandlerGroupTopic"), }, { Name: "missing_marshaler", ModifyValidConfig: func(config *cqrs.EventGroupProcessorConfig) { config.Marshaler = nil }, ExpectedErr: fmt.Errorf("missing Marshaler"), }, } for i := range testCases { tc := testCases[i] t.Run(tc.Name, func(t *testing.T) { validConfig := cqrs.EventGroupProcessorConfig{ GenerateSubscribeTopic: func(params cqrs.EventGroupProcessorGenerateSubscribeTopicParams) (string, error) { return "", nil }, SubscriberConstructor: func(params cqrs.EventGroupProcessorSubscriberConstructorParams) (message.Subscriber, error) { return nil, nil }, Marshaler: cqrs.JSONMarshaler{}, } if tc.ModifyValidConfig != nil { tc.ModifyValidConfig(&validConfig) } err := validConfig.Validate() if tc.ExpectedErr == nil { assert.NoError(t, err) } else { assert.EqualError(t, err, tc.ExpectedErr.Error()) } }) } } func TestNewEventProcessor_OnGroupHandle(t *testing.T) { ts := NewTestServices() msg1, err := ts.Marshaler.Marshal(&TestEvent{ID: "1"}) require.NoError(t, err) msg2, err := ts.Marshaler.Marshal(&TestEvent{ID: "2"}) require.NoError(t, err) mockSub := &mockSubscriber{ MessagesToSend: []*message.Message{ msg1, msg2, }, } handlerCalled := 0 defer func() { // for msg 1 we are not calling handler - but returning before assert.Equal(t, 1, handlerCalled) }() handler := cqrs.NewEventHandler("test", func(ctx context.Context, cmd *TestEvent) error { handlerCalled++ return nil }) onHandleCalled := int64(0) router, err := message.NewRouter(message.RouterConfig{}, ts.Logger) require.NoError(t, err) config := cqrs.EventGroupProcessorConfig{ GenerateSubscribeTopic: func(params cqrs.EventGroupProcessorGenerateSubscribeTopicParams) (string, error) { return "events", nil }, SubscriberConstructor: func(params cqrs.EventGroupProcessorSubscriberConstructorParams) (message.Subscriber, error) { return mockSub, nil }, OnHandle: func(params cqrs.EventGroupProcessorOnHandleParams) error { atomic.AddInt64(&onHandleCalled, 1) assert.Equal(t, "some_group", params.GroupName) assert.IsType(t, &TestEvent{}, params.Event) assert.Equal(t, "cqrs_test.TestEvent", params.EventName) assert.Equal(t, handler, params.Handler) if params.Event.(*TestEvent).ID == "1" { assert.Equal(t, msg1, params.Message) return errors.New("test error") } else { assert.Equal(t, msg2, params.Message) } return params.Handler.Handle(params.Message.Context(), params.Event) }, Marshaler: ts.Marshaler, Logger: ts.Logger, } cp, err := cqrs.NewEventGroupProcessorWithConfig(router, config) require.NoError(t, err) err = cp.AddHandlersGroup("some_group", handler) require.NoError(t, err) go func() { err := router.Run(context.Background()) assert.NoError(t, err) }() <-router.Running() select { case <-msg1.Nacked(): // ok case <-msg1.Acked(): // ack received t.Fatal("ack received, message should be nacked") } select { case <-msg2.Acked(): // ok case <-msg2.Nacked(): // nack received } assert.EqualValues(t, 2, onHandleCalled) } func TestNewEventProcessor_AckOnUnknownEvent_handler_group(t *testing.T) { ts := NewTestServices() msg, err := ts.Marshaler.Marshal(&UnknownEvent{}) require.NoError(t, err) mockSub := &mockSubscriber{ MessagesToSend: []*message.Message{ msg, }, } router, err := message.NewRouter(message.RouterConfig{}, ts.Logger) require.NoError(t, err) cp, err := cqrs.NewEventGroupProcessorWithConfig( router, cqrs.EventGroupProcessorConfig{ GenerateSubscribeTopic: func(params cqrs.EventGroupProcessorGenerateSubscribeTopicParams) (string, error) { return "events", nil }, SubscriberConstructor: func(params cqrs.EventGroupProcessorSubscriberConstructorParams) (message.Subscriber, error) { return mockSub, nil }, AckOnUnknownEvent: true, Marshaler: ts.Marshaler, Logger: ts.Logger, }, ) require.NoError(t, err) err = cp.AddHandlersGroup( "foo", cqrs.NewEventHandler("test", func(ctx context.Context, cmd *TestEvent) error { return nil }), ) require.NoError(t, err) go func() { err := router.Run(context.Background()) assert.NoError(t, err) }() <-router.Running() select { case <-msg.Acked(): // ok case <-msg.Nacked(): // ack received t.Fatal("ack received, message should be nacked") } } func TestNewEventProcessor_AckOnUnknownEvent_disabled_handler_group(t *testing.T) { ts := NewTestServices() msg, err := ts.Marshaler.Marshal(&UnknownEvent{}) require.NoError(t, err) mockSub := &mockSubscriber{ MessagesToSend: []*message.Message{ msg, }, } router, err := message.NewRouter(message.RouterConfig{}, ts.Logger) require.NoError(t, err) cp, err := cqrs.NewEventGroupProcessorWithConfig( router, cqrs.EventGroupProcessorConfig{ GenerateSubscribeTopic: func(params cqrs.EventGroupProcessorGenerateSubscribeTopicParams) (string, error) { return "events", nil }, SubscriberConstructor: func(params cqrs.EventGroupProcessorSubscriberConstructorParams) (message.Subscriber, error) { return mockSub, nil }, AckOnUnknownEvent: false, Marshaler: ts.Marshaler, Logger: ts.Logger, }, ) require.NoError(t, err) err = cp.AddHandlersGroup( "foo", cqrs.NewEventHandler("test", func(ctx context.Context, cmd *TestEvent) error { return nil }), ) require.NoError(t, err) go func() { err := router.Run(context.Background()) assert.NoError(t, err) }() <-router.Running() select { case <-msg.Nacked(): // ok case <-msg.Acked(): t.Fatal("ack received, message should be nacked") } } func TestEventProcessor_handler_group(t *testing.T) { ts := NewTestServices() event1 := &TestEvent{ID: "1"} msg1, err := ts.Marshaler.Marshal(event1) require.NoError(t, err) event2 := &AnotherTestEvent{ID: "2"} msg2, err := ts.Marshaler.Marshal(event2) require.NoError(t, err) mockSub := &mockSubscriber{ MessagesToSend: []*message.Message{ msg1, msg2, }, WaitForAckBeforeSendingNext: true, } var handlersCalls []int handlers := []cqrs.GroupEventHandler{ cqrs.NewGroupEventHandler(func(ctx context.Context, event *TestEvent) error { assert.EqualValues(t, event1, event) handlersCalls = append(handlersCalls, 1) return nil }), cqrs.NewGroupEventHandler(func(ctx context.Context, event *AnotherTestEvent) error { assert.EqualValues(t, event2, event) handlersCalls = append(handlersCalls, 2) return nil }), cqrs.NewGroupEventHandler(func(ctx context.Context, event *AnotherTestEvent) error { assert.EqualValues(t, event2, event) handlersCalls = append(handlersCalls, 3) return nil }), } router, err := message.NewRouter(message.RouterConfig{}, ts.Logger) require.NoError(t, err) eventProcessor, err := cqrs.NewEventGroupProcessorWithConfig( router, cqrs.EventGroupProcessorConfig{ GenerateSubscribeTopic: func(params cqrs.EventGroupProcessorGenerateSubscribeTopicParams) (string, error) { assert.Equal(t, "some_group", params.EventGroupName) assert.Equal(t, handlers, params.EventGroupHandlers) return "events", nil }, SubscriberConstructor: func(params cqrs.EventGroupProcessorSubscriberConstructorParams) (message.Subscriber, error) { assert.Equal(t, "some_group", params.EventGroupName) assert.Equal(t, handlers, params.EventGroupHandlers) return mockSub, nil }, Marshaler: ts.Marshaler, Logger: ts.Logger, }, ) require.NoError(t, err) err = eventProcessor.AddHandlersGroup( "some_group", handlers..., ) require.NoError(t, err) err = eventProcessor.AddHandlersGroup( "some_group", handlers..., ) require.ErrorContains(t, err, "event handler group 'some_group' already exists") err = eventProcessor.AddHandlersGroup( "some_group_2", ) require.ErrorContains(t, err, "no handlers provided") go func() { err := router.Run(context.Background()) assert.NoError(t, err) }() <-router.Running() select { case <-msg1.Acked(): // ok case <-time.After(time.Second): t.Fatal("message 1 not acked") } select { case <-msg2.Acked(): // ok case <-time.After(time.Second): t.Fatal("message 2 not acked") } assert.Equal(t, []int{1, 2, 3}, handlersCalls) } func TestEventGroupProcessor_original_msg_set_to_ctx(t *testing.T) { ts := NewTestServices() msg, err := ts.Marshaler.Marshal(&TestEvent{}) require.NoError(t, err) mockSub := &mockSubscriber{ MessagesToSend: []*message.Message{ msg, }, } router, err := message.NewRouter(message.RouterConfig{}, ts.Logger) require.NoError(t, err) cp, err := cqrs.NewEventGroupProcessorWithConfig( router, cqrs.EventGroupProcessorConfig{ GenerateSubscribeTopic: func(params cqrs.EventGroupProcessorGenerateSubscribeTopicParams) (string, error) { return "events", nil }, SubscriberConstructor: func(params cqrs.EventGroupProcessorSubscriberConstructorParams) (message.Subscriber, error) { return mockSub, nil }, AckOnUnknownEvent: true, Marshaler: ts.Marshaler, Logger: ts.Logger, }, ) require.NoError(t, err) var msgFromCtx *message.Message err = cp.AddHandlersGroup( "some_group", cqrs.NewGroupEventHandler( func(ctx context.Context, cmd *TestEvent) error { msgFromCtx = cqrs.OriginalMessageFromCtx(ctx) return nil }), ) require.NoError(t, err) go func() { err := router.Run(context.Background()) assert.NoError(t, err) }() <-router.Running() select { case <-msg.Acked(): // ok case <-msg.Nacked(): // nack received t.Fatal("nack received, message should be acked") case <-time.After(1 * time.Second): t.Fatal("timeout waiting for ack") } require.NotNil(t, msgFromCtx) assert.Equal(t, msg, msgFromCtx) } ================================================ FILE: components/cqrs/event_processor_test.go ================================================ package cqrs_test import ( "context" "fmt" "sync/atomic" "testing" "time" "github.com/pkg/errors" "github.com/ThreeDotsLabs/watermill/components/cqrs" "github.com/ThreeDotsLabs/watermill/message" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestEventProcessorConfig_Validate(t *testing.T) { testCases := []struct { Name string ModifyValidConfig func(*cqrs.EventProcessorConfig) ExpectedErr error }{ { Name: "valid_config", ModifyValidConfig: nil, ExpectedErr: nil, }, { Name: "missing_GenerateHandlerSubscribeTopic", ModifyValidConfig: func(config *cqrs.EventProcessorConfig) { config.GenerateSubscribeTopic = nil }, ExpectedErr: fmt.Errorf("missing GenerateHandlerTopic"), }, { Name: "missing_marshaler", ModifyValidConfig: func(config *cqrs.EventProcessorConfig) { config.Marshaler = nil }, ExpectedErr: fmt.Errorf("missing Marshaler"), }, { Name: "missing_subscriber_constructor", ModifyValidConfig: func(config *cqrs.EventProcessorConfig) { config.SubscriberConstructor = nil }, ExpectedErr: fmt.Errorf("missing SubscriberConstructor"), }, } for i := range testCases { tc := testCases[i] t.Run(tc.Name, func(t *testing.T) { validConfig := cqrs.EventProcessorConfig{ GenerateSubscribeTopic: func(params cqrs.EventProcessorGenerateSubscribeTopicParams) (string, error) { return "", nil }, SubscriberConstructor: func(params cqrs.EventProcessorSubscriberConstructorParams) (message.Subscriber, error) { return nil, nil }, Marshaler: cqrs.JSONMarshaler{}, } if tc.ModifyValidConfig != nil { tc.ModifyValidConfig(&validConfig) } err := validConfig.Validate() if tc.ExpectedErr == nil { assert.NoError(t, err) } else { assert.EqualError(t, err, tc.ExpectedErr.Error()) } }) } } func TestNewEventProcessor(t *testing.T) { eventConfig := cqrs.EventProcessorConfig{ GenerateSubscribeTopic: func(params cqrs.EventProcessorGenerateSubscribeTopicParams) (string, error) { return "", nil }, SubscriberConstructor: func(params cqrs.EventProcessorSubscriberConstructorParams) (message.Subscriber, error) { return nil, nil }, Marshaler: cqrs.JSONMarshaler{}, } require.NoError(t, eventConfig.Validate()) router, err := message.NewRouter(message.RouterConfig{}, nil) require.NoError(t, err) cp, err := cqrs.NewEventProcessorWithConfig(router, eventConfig) assert.NotNil(t, cp) assert.NoError(t, err) eventConfig.SubscriberConstructor = nil require.Error(t, eventConfig.Validate()) cp, err = cqrs.NewEventProcessorWithConfig(router, eventConfig) assert.Nil(t, cp) assert.Error(t, err) } type nonPointerEventProcessor struct { } func (nonPointerEventProcessor) HandlerName() string { return "nonPointerEventProcessor" } func (nonPointerEventProcessor) NewEvent() interface{} { return TestEvent{} } func (nonPointerEventProcessor) Handle(ctx context.Context, cmd interface{}) error { panic("not implemented") } func TestEventProcessor_non_pointer_event(t *testing.T) { ts := NewTestServices() handler := nonPointerEventProcessor{} router, err := message.NewRouter(message.RouterConfig{}, ts.Logger) require.NoError(t, err) eventProcessor, err := cqrs.NewEventProcessorWithConfig( router, cqrs.EventProcessorConfig{ GenerateSubscribeTopic: func(params cqrs.EventProcessorGenerateSubscribeTopicParams) (string, error) { return "", nil }, SubscriberConstructor: func(params cqrs.EventProcessorSubscriberConstructorParams) (message.Subscriber, error) { return nil, nil }, Marshaler: ts.Marshaler, Logger: ts.Logger, }, ) require.NoError(t, err) err = eventProcessor.AddHandlers(handler) assert.IsType(t, cqrs.NonPointerError{}, errors.Cause(err)) } type duplicateTestEventHandler1 struct{} func (h duplicateTestEventHandler1) HandlerName() string { return "duplicateTestEventHandler1" } func (duplicateTestEventHandler1) NewEvent() interface{} { return &TestEvent{} } func (h *duplicateTestEventHandler1) Handle(ctx context.Context, event interface{}) error { return nil } type duplicateTestEventHandler2 struct{} func (h duplicateTestEventHandler2) HandlerName() string { return "duplicateTestEventHandler2" } func (duplicateTestEventHandler2) NewEvent() interface{} { return &TestEvent{} } func (h *duplicateTestEventHandler2) Handle(ctx context.Context, event interface{}) error { return nil } func TestEventProcessor_multiple_same_event_handlers(t *testing.T) { ts := NewTestServices() router, err := message.NewRouter(message.RouterConfig{}, ts.Logger) require.NoError(t, err) eventProcessor, err := cqrs.NewEventProcessorWithConfig( router, cqrs.EventProcessorConfig{ GenerateSubscribeTopic: func(params cqrs.EventProcessorGenerateSubscribeTopicParams) (string, error) { return "", nil }, SubscriberConstructor: func(params cqrs.EventProcessorSubscriberConstructorParams) (message.Subscriber, error) { return nil, nil }, Marshaler: ts.Marshaler, Logger: ts.Logger, }, ) require.NoError(t, err) err = eventProcessor.AddHandlers( &duplicateTestEventHandler1{}, &duplicateTestEventHandler2{}, ) require.NoError(t, err) } func TestNewEventProcessor_OnHandle(t *testing.T) { ts := NewTestServices() msg1, err := ts.Marshaler.Marshal(&TestEvent{ID: "1"}) require.NoError(t, err) msg2, err := ts.Marshaler.Marshal(&TestEvent{ID: "2"}) require.NoError(t, err) mockSub := &mockSubscriber{ MessagesToSend: []*message.Message{ msg1, msg2, }, } handlerCalled := 0 defer func() { // for msg 1 we are not calling handler - but returning before assert.Equal(t, 1, handlerCalled) }() handler := cqrs.NewEventHandler("test", func(ctx context.Context, cmd *TestEvent) error { handlerCalled++ return nil }) router, err := message.NewRouter(message.RouterConfig{}, ts.Logger) require.NoError(t, err) onHandleCalled := int64(0) config := cqrs.EventProcessorConfig{ GenerateSubscribeTopic: func(params cqrs.EventProcessorGenerateSubscribeTopicParams) (string, error) { return "events", nil }, SubscriberConstructor: func(params cqrs.EventProcessorSubscriberConstructorParams) (message.Subscriber, error) { return mockSub, nil }, OnHandle: func(params cqrs.EventProcessorOnHandleParams) error { atomic.AddInt64(&onHandleCalled, 1) assert.IsType(t, &TestEvent{}, params.Event) assert.Equal(t, "cqrs_test.TestEvent", params.EventName) assert.Equal(t, handler, params.Handler) if params.Event.(*TestEvent).ID == "1" { assert.Equal(t, msg1, params.Message) return errors.New("test error") } else { assert.Equal(t, msg2, params.Message) } return params.Handler.Handle(params.Message.Context(), params.Event) }, Marshaler: ts.Marshaler, Logger: ts.Logger, } cp, err := cqrs.NewEventProcessorWithConfig(router, config) require.NoError(t, err) err = cp.AddHandlers(handler) require.NoError(t, err) go func() { err := router.Run(context.Background()) assert.NoError(t, err) }() <-router.Running() select { case <-msg1.Nacked(): // ok case <-msg1.Acked(): // ack received t.Fatal("ack received, message should be nacked") } select { case <-msg2.Acked(): // ok case <-msg2.Nacked(): // nack received } assert.EqualValues(t, 2, onHandleCalled) } type UnknownEvent struct { } func TestNewEventProcessor_AckOnUnknownEvent(t *testing.T) { ts := NewTestServices() msg, err := ts.Marshaler.Marshal(&UnknownEvent{}) require.NoError(t, err) mockSub := &mockSubscriber{ MessagesToSend: []*message.Message{ msg, }, } router, err := message.NewRouter(message.RouterConfig{}, ts.Logger) require.NoError(t, err) cp, err := cqrs.NewEventProcessorWithConfig( router, cqrs.EventProcessorConfig{ GenerateSubscribeTopic: func(params cqrs.EventProcessorGenerateSubscribeTopicParams) (string, error) { return "events", nil }, SubscriberConstructor: func(params cqrs.EventProcessorSubscriberConstructorParams) (message.Subscriber, error) { return mockSub, nil }, AckOnUnknownEvent: true, Marshaler: ts.Marshaler, Logger: ts.Logger, }, ) require.NoError(t, err) err = cp.AddHandlers( cqrs.NewEventHandler("test", func(ctx context.Context, cmd *TestEvent) error { return nil }), ) require.NoError(t, err) go func() { err := router.Run(context.Background()) assert.NoError(t, err) }() <-router.Running() select { case <-msg.Acked(): // ok case <-msg.Nacked(): // ack received t.Fatal("ack received, message should be nacked") } } func TestNewEventProcessor_AckOnUnknownEvent_disabled(t *testing.T) { ts := NewTestServices() msg, err := ts.Marshaler.Marshal(&UnknownEvent{}) require.NoError(t, err) mockSub := &mockSubscriber{ MessagesToSend: []*message.Message{ msg, }, } router, err := message.NewRouter(message.RouterConfig{}, ts.Logger) require.NoError(t, err) cp, err := cqrs.NewEventProcessorWithConfig( router, cqrs.EventProcessorConfig{ GenerateSubscribeTopic: func(params cqrs.EventProcessorGenerateSubscribeTopicParams) (string, error) { return "events", nil }, SubscriberConstructor: func(params cqrs.EventProcessorSubscriberConstructorParams) (message.Subscriber, error) { return mockSub, nil }, AckOnUnknownEvent: false, Marshaler: ts.Marshaler, Logger: ts.Logger, }, ) require.NoError(t, err) err = cp.AddHandlers( cqrs.NewEventHandler("test", func(ctx context.Context, cmd *TestEvent) error { return nil }), ) require.NoError(t, err) go func() { err := router.Run(context.Background()) assert.NoError(t, err) }() <-router.Running() select { case <-msg.Nacked(): // ok case <-msg.Acked(): // ack received t.Fatal("ack received, message should be nacked") } } func TestNewEventProcessor_backward_compatibility_of_AckOnUnknownEvent(t *testing.T) { ts := NewTestServices() msg, err := ts.Marshaler.Marshal(&UnknownEvent{}) require.NoError(t, err) mockSub := &mockSubscriber{ MessagesToSend: []*message.Message{ msg, }, } cp, err := cqrs.NewEventProcessor( []cqrs.EventHandler{ cqrs.NewEventHandler("test", func(ctx context.Context, cmd *TestEvent) error { return nil }), }, func(eventName string) string { return "events" }, func(handlerName string) (message.Subscriber, error) { return mockSub, nil }, ts.Marshaler, ts.Logger, ) require.NoError(t, err) router, err := message.NewRouter(message.RouterConfig{}, ts.Logger) require.NoError(t, err) err = cp.AddHandlersToRouter(router) require.NoError(t, err) go func() { err := router.Run(context.Background()) assert.NoError(t, err) }() <-router.Running() select { case <-msg.Acked(): // ok case <-msg.Nacked(): // ack received t.Fatal("ack received, message should be nacked") } } func TestEventProcessor_AddHandlersToRouter_without_disableRouterAutoAddHandlers(t *testing.T) { ts := NewTestServices() router, err := message.NewRouter(message.RouterConfig{}, ts.Logger) require.NoError(t, err) cp, err := cqrs.NewEventProcessorWithConfig( router, cqrs.EventProcessorConfig{ GenerateSubscribeTopic: func(params cqrs.EventProcessorGenerateSubscribeTopicParams) (string, error) { return "events", nil }, SubscriberConstructor: func(params cqrs.EventProcessorSubscriberConstructorParams) (message.Subscriber, error) { return ts.EventsPubSub, nil }, AckOnUnknownEvent: false, Marshaler: ts.Marshaler, Logger: ts.Logger, }, ) require.NoError(t, err) err = cp.AddHandlersToRouter(router) assert.ErrorContains(t, err, "AddHandlersToRouter should be called only when using deprecated NewEventProcessor") } func TestEventProcessor_original_msg_set_to_ctx(t *testing.T) { ts := NewTestServices() msg, err := ts.Marshaler.Marshal(&TestEvent{}) require.NoError(t, err) mockSub := &mockSubscriber{ MessagesToSend: []*message.Message{ msg, }, } router, err := message.NewRouter(message.RouterConfig{}, ts.Logger) require.NoError(t, err) cp, err := cqrs.NewEventProcessorWithConfig( router, cqrs.EventProcessorConfig{ GenerateSubscribeTopic: func(params cqrs.EventProcessorGenerateSubscribeTopicParams) (string, error) { return "events", nil }, SubscriberConstructor: func(params cqrs.EventProcessorSubscriberConstructorParams) (message.Subscriber, error) { return mockSub, nil }, AckOnUnknownEvent: true, Marshaler: ts.Marshaler, Logger: ts.Logger, }, ) require.NoError(t, err) var msgFromCtx *message.Message err = cp.AddHandlers(cqrs.NewEventHandler( "handler", func(ctx context.Context, cmd *TestEvent) error { msgFromCtx = cqrs.OriginalMessageFromCtx(ctx) return nil }), ) require.NoError(t, err) go func() { err := router.Run(context.Background()) assert.NoError(t, err) }() <-router.Running() select { case <-msg.Acked(): // ok case <-msg.Nacked(): // nack received t.Fatal("nack received, message should be acked") case <-time.After(1 * time.Second): t.Fatal("timeout waiting for ack") } require.NotNil(t, msgFromCtx) assert.Equal(t, msg, msgFromCtx) } ================================================ FILE: components/cqrs/marshaler.go ================================================ package cqrs import ( "errors" "fmt" "github.com/ThreeDotsLabs/watermill/message" ) // CommandEventMarshaler marshals Commands and Events to Watermill's messages and vice versa. // Payload of the command needs to be marshaled to []bytes. type CommandEventMarshaler interface { // Marshal marshals Command or Event to Watermill's message. Marshal(v interface{}) (*message.Message, error) // Unmarshal unmarshals watermill's message to v Command or Event. Unmarshal(msg *message.Message, v interface{}) (err error) // Name returns the name of Command or Event. // Name is used to determine, that received command or event is event which we want to handle. Name(v interface{}) string // NameFromMessage returns the name of Command or Event from Watermill's message (generated by Marshal). // // When we have Command or Event marshaled to Watermill's message, // we should use NameFromMessage instead of Name to avoid unnecessary unmarshaling. NameFromMessage(msg *message.Message) string } // CommandEventMarshalerDecorator decorates CommandEventMarshaler with additional functionality. // It can be used to add additional metadata to the message. type CommandEventMarshalerDecorator struct { CommandEventMarshaler // DecorateFunc is called after marshaling the message. DecorateFunc func(v any, msg *message.Message) error } // Marshal marshals Command or Event to Watermill's message and decorates it. func (c CommandEventMarshalerDecorator) Marshal(v any) (*message.Message, error) { msg, err := c.CommandEventMarshaler.Marshal(v) if err != nil { return nil, err } if c.DecorateFunc == nil { return nil, errors.New("DecorateFunc is nil") } if err := c.DecorateFunc(v, msg); err != nil { return nil, fmt.Errorf("cannot decorate message: %w", err) } return msg, nil } ================================================ FILE: components/cqrs/marshaler_json.go ================================================ package cqrs import ( "encoding/json" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill/message" ) type JSONMarshaler struct { NewUUID func() string GenerateName func(v interface{}) string } func (m JSONMarshaler) Marshal(v interface{}) (*message.Message, error) { b, err := json.Marshal(v) if err != nil { return nil, err } msg := message.NewMessage( m.newUUID(), b, ) msg.Metadata.Set("name", m.Name(v)) return msg, nil } func (m JSONMarshaler) newUUID() string { if m.NewUUID != nil { return m.NewUUID() } // default return watermill.NewUUID() } func (JSONMarshaler) Unmarshal(msg *message.Message, v interface{}) (err error) { return json.Unmarshal(msg.Payload, v) } func (m JSONMarshaler) Name(cmdOrEvent interface{}) string { if m.GenerateName != nil { return m.GenerateName(cmdOrEvent) } return FullyQualifiedStructName(cmdOrEvent) } func (m JSONMarshaler) NameFromMessage(msg *message.Message) string { return msg.Metadata.Get("name") } ================================================ FILE: components/cqrs/marshaler_json_test.go ================================================ package cqrs_test import ( "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill/components/cqrs" ) var jsonEventToMarshal = TestEvent{ ID: watermill.NewULID(), When: time.Date(2016, time.August, 15, 14, 13, 12, 0, time.UTC), } func TestJsonMarshaler(t *testing.T) { marshaler := cqrs.JSONMarshaler{} msg, err := marshaler.Marshal(jsonEventToMarshal) require.NoError(t, err) eventToUnmarshal := TestEvent{} err = marshaler.Unmarshal(msg, &eventToUnmarshal) require.NoError(t, err) assert.EqualValues(t, jsonEventToMarshal, eventToUnmarshal) } func TestJSONMarshaler_Marshal_new_uuid_set(t *testing.T) { marshaler := cqrs.JSONMarshaler{ NewUUID: func() string { return "foo" }, } msg, err := marshaler.Marshal(jsonEventToMarshal) require.NoError(t, err) assert.Equal(t, msg.UUID, "foo") } func TestJSONMarshaler_Marshal_generate_name(t *testing.T) { marshaler := cqrs.JSONMarshaler{ GenerateName: func(v interface{}) string { return "foo" }, } msg, err := marshaler.Marshal(jsonEventToMarshal) require.NoError(t, err) assert.Equal(t, msg.Metadata.Get("name"), "foo") } ================================================ FILE: components/cqrs/marshaler_protobuf.go ================================================ package cqrs import ( "reflect" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill/message" "google.golang.org/protobuf/proto" "github.com/pkg/errors" ) // ProtoMarshaler is the default Protocol Buffers marshaler. type ProtoMarshaler struct { NewUUID func() string GenerateName func(v interface{}) string } // NoProtoMessageError is returned when the given value does not implement proto.Message. type NoProtoMessageError struct { v interface{} } func (e NoProtoMessageError) Error() string { rv := reflect.ValueOf(e.v) if rv.Kind() != reflect.Ptr { return "v is not proto.Message, you must pass pointer value to implement proto.Message" } return "v is not proto.Message" } // Marshal marshals the given protobuf's message into watermill's Message. func (m ProtoMarshaler) Marshal(v interface{}) (*message.Message, error) { protoMsg, ok := v.(proto.Message) if !ok { return nil, errors.WithStack(NoProtoMessageError{v}) } b, err := proto.Marshal(protoMsg) if err != nil { return nil, err } msg := message.NewMessage( m.newUUID(), b, ) msg.Metadata.Set("name", m.Name(v)) return msg, nil } func (m ProtoMarshaler) newUUID() string { if m.NewUUID != nil { return m.NewUUID() } // default return watermill.NewUUID() } // Unmarshal unmarshals given watermill's Message into protobuf's message. func (ProtoMarshaler) Unmarshal(msg *message.Message, v interface{}) (err error) { protoV, ok := v.(proto.Message) if !ok { return errors.WithStack(NoProtoMessageError{v}) } return proto.Unmarshal(msg.Payload, protoV) } // Name returns the command or event's name. func (m ProtoMarshaler) Name(cmdOrEvent interface{}) string { if m.GenerateName != nil { return m.GenerateName(cmdOrEvent) } return FullyQualifiedStructName(cmdOrEvent) } // NameFromMessage returns the metadata name value for a given Message. func (m ProtoMarshaler) NameFromMessage(msg *message.Message) string { return msg.Metadata.Get("name") } ================================================ FILE: components/cqrs/marshaler_protobuf_events_new_test.go ================================================ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.31.0 // protoc v4.24.4 // source: events.proto package cqrs_test import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" timestamppb "google.golang.org/protobuf/types/known/timestamppb" reflect "reflect" sync "sync" ) const ( // Verify that this generated code is sufficiently up-to-date. _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) // Verify that runtime/protoimpl is sufficiently up-to-date. _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) type Status int32 const ( Status_STATUS_UNSPECIFIED Status = 0 Status_ACTIVE Status = 1 Status_DELETED Status = 2 ) // Enum value maps for Status. var ( Status_name = map[int32]string{ 0: "STATUS_UNSPECIFIED", 1: "ACTIVE", 2: "DELETED", } Status_value = map[string]int32{ "STATUS_UNSPECIFIED": 0, "ACTIVE": 1, "DELETED": 2, } ) func (x Status) Enum() *Status { p := new(Status) *p = x return p } func (x Status) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (Status) Descriptor() protoreflect.EnumDescriptor { return file_events_proto_enumTypes[0].Descriptor() } func (Status) Type() protoreflect.EnumType { return &file_events_proto_enumTypes[0] } func (x Status) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Deprecated: Use Status.Descriptor instead. func (Status) EnumDescriptor() ([]byte, []int) { return file_events_proto_rawDescGZIP(), []int{0} } type TestProtobufLegacyEvent struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` When *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=when,proto3" json:"when,omitempty"` } func (x *TestProtobufLegacyEvent) Reset() { *x = TestProtobufLegacyEvent{} if protoimpl.UnsafeEnabled { mi := &file_events_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } } func (x *TestProtobufLegacyEvent) String() string { return protoimpl.X.MessageStringOf(x) } func (*TestProtobufLegacyEvent) ProtoMessage() {} func (x *TestProtobufLegacyEvent) ProtoReflect() protoreflect.Message { mi := &file_events_proto_msgTypes[0] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use TestProtobufLegacyEvent.ProtoReflect.Descriptor instead. func (*TestProtobufLegacyEvent) Descriptor() ([]byte, []int) { return file_events_proto_rawDescGZIP(), []int{0} } func (x *TestProtobufLegacyEvent) GetId() string { if x != nil { return x.Id } return "" } func (x *TestProtobufLegacyEvent) GetWhen() *timestamppb.Timestamp { if x != nil { return x.When } return nil } type SubEvent struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields Tags []string `protobuf:"bytes,1,rep,name=tags,proto3" json:"tags,omitempty"` Flags map[string]bool `protobuf:"bytes,2,rep,name=flags,proto3" json:"flags,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"varint,2,opt,name=value,proto3"` } func (x *SubEvent) Reset() { *x = SubEvent{} if protoimpl.UnsafeEnabled { mi := &file_events_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } } func (x *SubEvent) String() string { return protoimpl.X.MessageStringOf(x) } func (*SubEvent) ProtoMessage() {} func (x *SubEvent) ProtoReflect() protoreflect.Message { mi := &file_events_proto_msgTypes[1] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use SubEvent.ProtoReflect.Descriptor instead. func (*SubEvent) Descriptor() ([]byte, []int) { return file_events_proto_rawDescGZIP(), []int{1} } func (x *SubEvent) GetTags() []string { if x != nil { return x.Tags } return nil } func (x *SubEvent) GetFlags() map[string]bool { if x != nil { return x.Flags } return nil } type TestComplexProtobufEvent struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` Data []byte `protobuf:"bytes,2,opt,name=data,proto3" json:"data,omitempty"` When *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=when,proto3" json:"when,omitempty"` // Complex fields to test edge cases NestedMap map[string]*SubEvent `protobuf:"bytes,4,rep,name=nested_map,json=nestedMap,proto3" json:"nested_map,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` Events []*SubEvent `protobuf:"bytes,5,rep,name=events,proto3" json:"events,omitempty"` // Types that are assignable to Result: // // *TestComplexProtobufEvent_Success // *TestComplexProtobufEvent_Error // *TestComplexProtobufEvent_Fallback Result isTestComplexProtobufEvent_Result `protobuf_oneof:"result"` } func (x *TestComplexProtobufEvent) Reset() { *x = TestComplexProtobufEvent{} if protoimpl.UnsafeEnabled { mi := &file_events_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } } func (x *TestComplexProtobufEvent) String() string { return protoimpl.X.MessageStringOf(x) } func (*TestComplexProtobufEvent) ProtoMessage() {} func (x *TestComplexProtobufEvent) ProtoReflect() protoreflect.Message { mi := &file_events_proto_msgTypes[2] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use TestComplexProtobufEvent.ProtoReflect.Descriptor instead. func (*TestComplexProtobufEvent) Descriptor() ([]byte, []int) { return file_events_proto_rawDescGZIP(), []int{2} } func (x *TestComplexProtobufEvent) GetId() string { if x != nil { return x.Id } return "" } func (x *TestComplexProtobufEvent) GetData() []byte { if x != nil { return x.Data } return nil } func (x *TestComplexProtobufEvent) GetWhen() *timestamppb.Timestamp { if x != nil { return x.When } return nil } func (x *TestComplexProtobufEvent) GetNestedMap() map[string]*SubEvent { if x != nil { return x.NestedMap } return nil } func (x *TestComplexProtobufEvent) GetEvents() []*SubEvent { if x != nil { return x.Events } return nil } func (m *TestComplexProtobufEvent) GetResult() isTestComplexProtobufEvent_Result { if m != nil { return m.Result } return nil } func (x *TestComplexProtobufEvent) GetSuccess() *SubEvent { if x, ok := x.GetResult().(*TestComplexProtobufEvent_Success); ok { return x.Success } return nil } func (x *TestComplexProtobufEvent) GetError() string { if x, ok := x.GetResult().(*TestComplexProtobufEvent_Error); ok { return x.Error } return "" } func (x *TestComplexProtobufEvent) GetFallback() Status { if x, ok := x.GetResult().(*TestComplexProtobufEvent_Fallback); ok { return x.Fallback } return Status_STATUS_UNSPECIFIED } type isTestComplexProtobufEvent_Result interface { isTestComplexProtobufEvent_Result() } type TestComplexProtobufEvent_Success struct { Success *SubEvent `protobuf:"bytes,6,opt,name=success,proto3,oneof"` } type TestComplexProtobufEvent_Error struct { Error string `protobuf:"bytes,7,opt,name=error,proto3,oneof"` } type TestComplexProtobufEvent_Fallback struct { Fallback Status `protobuf:"varint,8,opt,name=fallback,proto3,enum=cqrs_test.Status,oneof"` } func (*TestComplexProtobufEvent_Success) isTestComplexProtobufEvent_Result() {} func (*TestComplexProtobufEvent_Error) isTestComplexProtobufEvent_Result() {} func (*TestComplexProtobufEvent_Fallback) isTestComplexProtobufEvent_Result() {} var File_events_proto protoreflect.FileDescriptor var file_events_proto_rawDesc = []byte{ 0x0a, 0x0c, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x09, 0x63, 0x71, 0x72, 0x73, 0x5f, 0x74, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x59, 0x0a, 0x17, 0x54, 0x65, 0x73, 0x74, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x4c, 0x65, 0x67, 0x61, 0x63, 0x79, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x2e, 0x0a, 0x04, 0x77, 0x68, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x04, 0x77, 0x68, 0x65, 0x6e, 0x22, 0x8e, 0x01, 0x0a, 0x08, 0x53, 0x75, 0x62, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x61, 0x67, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x04, 0x74, 0x61, 0x67, 0x73, 0x12, 0x34, 0x0a, 0x05, 0x66, 0x6c, 0x61, 0x67, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x63, 0x71, 0x72, 0x73, 0x5f, 0x74, 0x65, 0x73, 0x74, 0x2e, 0x53, 0x75, 0x62, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x6c, 0x61, 0x67, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x05, 0x66, 0x6c, 0x61, 0x67, 0x73, 0x1a, 0x38, 0x0a, 0x0a, 0x46, 0x6c, 0x61, 0x67, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0xcb, 0x03, 0x0a, 0x18, 0x54, 0x65, 0x73, 0x74, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x78, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x64, 0x61, 0x74, 0x61, 0x12, 0x2e, 0x0a, 0x04, 0x77, 0x68, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x04, 0x77, 0x68, 0x65, 0x6e, 0x12, 0x51, 0x0a, 0x0a, 0x6e, 0x65, 0x73, 0x74, 0x65, 0x64, 0x5f, 0x6d, 0x61, 0x70, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x32, 0x2e, 0x63, 0x71, 0x72, 0x73, 0x5f, 0x74, 0x65, 0x73, 0x74, 0x2e, 0x54, 0x65, 0x73, 0x74, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x78, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x73, 0x74, 0x65, 0x64, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x09, 0x6e, 0x65, 0x73, 0x74, 0x65, 0x64, 0x4d, 0x61, 0x70, 0x12, 0x2b, 0x0a, 0x06, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x63, 0x71, 0x72, 0x73, 0x5f, 0x74, 0x65, 0x73, 0x74, 0x2e, 0x53, 0x75, 0x62, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x52, 0x06, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x2f, 0x0a, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x63, 0x71, 0x72, 0x73, 0x5f, 0x74, 0x65, 0x73, 0x74, 0x2e, 0x53, 0x75, 0x62, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x48, 0x00, 0x52, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, 0x16, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x2f, 0x0a, 0x08, 0x66, 0x61, 0x6c, 0x6c, 0x62, 0x61, 0x63, 0x6b, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x11, 0x2e, 0x63, 0x71, 0x72, 0x73, 0x5f, 0x74, 0x65, 0x73, 0x74, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x48, 0x00, 0x52, 0x08, 0x66, 0x61, 0x6c, 0x6c, 0x62, 0x61, 0x63, 0x6b, 0x1a, 0x51, 0x0a, 0x0e, 0x4e, 0x65, 0x73, 0x74, 0x65, 0x64, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x29, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x63, 0x71, 0x72, 0x73, 0x5f, 0x74, 0x65, 0x73, 0x74, 0x2e, 0x53, 0x75, 0x62, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x42, 0x08, 0x0a, 0x06, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x4a, 0x04, 0x08, 0x17, 0x10, 0x1f, 0x2a, 0x39, 0x0a, 0x06, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x16, 0x0a, 0x12, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06, 0x41, 0x43, 0x54, 0x49, 0x56, 0x45, 0x10, 0x01, 0x12, 0x0b, 0x0a, 0x07, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x44, 0x10, 0x02, 0x42, 0x0d, 0x5a, 0x0b, 0x2e, 0x2f, 0x63, 0x71, 0x72, 0x73, 0x5f, 0x74, 0x65, 0x73, 0x74, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( file_events_proto_rawDescOnce sync.Once file_events_proto_rawDescData = file_events_proto_rawDesc ) func file_events_proto_rawDescGZIP() []byte { file_events_proto_rawDescOnce.Do(func() { file_events_proto_rawDescData = protoimpl.X.CompressGZIP(file_events_proto_rawDescData) }) return file_events_proto_rawDescData } var file_events_proto_enumTypes = make([]protoimpl.EnumInfo, 1) var file_events_proto_msgTypes = make([]protoimpl.MessageInfo, 5) var file_events_proto_goTypes = []interface{}{ (Status)(0), // 0: cqrs_test.Status (*TestProtobufLegacyEvent)(nil), // 1: cqrs_test.TestProtobufLegacyEvent (*SubEvent)(nil), // 2: cqrs_test.SubEvent (*TestComplexProtobufEvent)(nil), // 3: cqrs_test.TestComplexProtobufEvent nil, // 4: cqrs_test.SubEvent.FlagsEntry nil, // 5: cqrs_test.TestComplexProtobufEvent.NestedMapEntry (*timestamppb.Timestamp)(nil), // 6: google.protobuf.Timestamp } var file_events_proto_depIdxs = []int32{ 6, // 0: cqrs_test.TestProtobufLegacyEvent.when:type_name -> google.protobuf.Timestamp 4, // 1: cqrs_test.SubEvent.flags:type_name -> cqrs_test.SubEvent.FlagsEntry 6, // 2: cqrs_test.TestComplexProtobufEvent.when:type_name -> google.protobuf.Timestamp 5, // 3: cqrs_test.TestComplexProtobufEvent.nested_map:type_name -> cqrs_test.TestComplexProtobufEvent.NestedMapEntry 2, // 4: cqrs_test.TestComplexProtobufEvent.events:type_name -> cqrs_test.SubEvent 2, // 5: cqrs_test.TestComplexProtobufEvent.success:type_name -> cqrs_test.SubEvent 0, // 6: cqrs_test.TestComplexProtobufEvent.fallback:type_name -> cqrs_test.Status 2, // 7: cqrs_test.TestComplexProtobufEvent.NestedMapEntry.value:type_name -> cqrs_test.SubEvent 8, // [8:8] is the sub-list for method output_type 8, // [8:8] is the sub-list for method input_type 8, // [8:8] is the sub-list for extension type_name 8, // [8:8] is the sub-list for extension extendee 0, // [0:8] is the sub-list for field type_name } func init() { file_events_proto_init() } func file_events_proto_init() { if File_events_proto != nil { return } if !protoimpl.UnsafeEnabled { file_events_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*TestProtobufLegacyEvent); i { case 0: return &v.state case 1: return &v.sizeCache case 2: return &v.unknownFields default: return nil } } file_events_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*SubEvent); i { case 0: return &v.state case 1: return &v.sizeCache case 2: return &v.unknownFields default: return nil } } file_events_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*TestComplexProtobufEvent); i { case 0: return &v.state case 1: return &v.sizeCache case 2: return &v.unknownFields default: return nil } } } file_events_proto_msgTypes[2].OneofWrappers = []interface{}{ (*TestComplexProtobufEvent_Success)(nil), (*TestComplexProtobufEvent_Error)(nil), (*TestComplexProtobufEvent_Fallback)(nil), } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_events_proto_rawDesc, NumEnums: 1, NumMessages: 5, NumExtensions: 0, NumServices: 0, }, GoTypes: file_events_proto_goTypes, DependencyIndexes: file_events_proto_depIdxs, EnumInfos: file_events_proto_enumTypes, MessageInfos: file_events_proto_msgTypes, }.Build() File_events_proto = out.File file_events_proto_rawDesc = nil file_events_proto_goTypes = nil file_events_proto_depIdxs = nil } ================================================ FILE: components/cqrs/marshaler_protobuf_events_test.go ================================================ // Code generated by protoc-gen-go. DO NOT EDIT. // source: testdata/events.proto package cqrs_test import ( fmt "fmt" math "math" proto "github.com/golang/protobuf/proto" timestamp "github.com/golang/protobuf/ptypes/timestamp" ) // Reference imports to suppress errors if they are not otherwise used. var _ = proto.Marshal var _ = fmt.Errorf var _ = math.Inf // This is a compile-time assertion to ensure that this generated file // is compatible with the proto package it is being compiled against. // A compilation error at this line likely means your copy of the // proto package needs to be updated. const _ = proto.ProtoPackageIsVersion3 // please upgrade the proto package type TestProtobufEvent struct { Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` When *timestamp.Timestamp `protobuf:"bytes,3,opt,name=when,proto3" json:"when,omitempty"` XXX_NoUnkeyedLiteral struct{} `json:"-"` XXX_unrecognized []byte `json:"-"` XXX_sizecache int32 `json:"-"` } func (m *TestProtobufEvent) Reset() { *m = TestProtobufEvent{} } func (m *TestProtobufEvent) String() string { return proto.CompactTextString(m) } func (*TestProtobufEvent) ProtoMessage() {} func (*TestProtobufEvent) Descriptor() ([]byte, []int) { return fileDescriptor_37faf0ac8d97ee4c, []int{0} } func (m *TestProtobufEvent) XXX_Unmarshal(b []byte) error { return xxx_messageInfo_TestProtobufEvent.Unmarshal(m, b) } func (m *TestProtobufEvent) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { return xxx_messageInfo_TestProtobufEvent.Marshal(b, m, deterministic) } func (m *TestProtobufEvent) XXX_Merge(src proto.Message) { xxx_messageInfo_TestProtobufEvent.Merge(m, src) } func (m *TestProtobufEvent) XXX_Size() int { return xxx_messageInfo_TestProtobufEvent.Size(m) } func (m *TestProtobufEvent) XXX_DiscardUnknown() { xxx_messageInfo_TestProtobufEvent.DiscardUnknown(m) } var xxx_messageInfo_TestProtobufEvent proto.InternalMessageInfo func (m *TestProtobufEvent) GetId() string { if m != nil { return m.Id } return "" } func (m *TestProtobufEvent) GetWhen() *timestamp.Timestamp { if m != nil { return m.When } return nil } func init() { proto.RegisterType((*TestProtobufEvent)(nil), "cqrs_test.TestProtobufEvent") } func init() { proto.RegisterFile("testdata/events.proto", fileDescriptor_37faf0ac8d97ee4c) } var fileDescriptor_37faf0ac8d97ee4c = []byte{ // 146 bytes of a gzipped FileDescriptorProto 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xe2, 0x12, 0x2d, 0x49, 0x2d, 0x2e, 0x49, 0x49, 0x2c, 0x49, 0xd4, 0x4f, 0x2d, 0x4b, 0xcd, 0x2b, 0x29, 0xd6, 0x2b, 0x28, 0xca, 0x2f, 0xc9, 0x17, 0xe2, 0x4c, 0x2e, 0x2c, 0x2a, 0x8e, 0x07, 0xc9, 0x49, 0xc9, 0xa7, 0xe7, 0xe7, 0xa7, 0xe7, 0xa4, 0xea, 0x83, 0x25, 0x92, 0x4a, 0xd3, 0xf4, 0x4b, 0x32, 0x73, 0x53, 0x8b, 0x4b, 0x12, 0x73, 0x0b, 0x20, 0x6a, 0x95, 0x82, 0xb9, 0x04, 0x43, 0x52, 0x8b, 0x4b, 0x02, 0xa0, 0xf2, 0xae, 0x20, 0x73, 0x84, 0xf8, 0xb8, 0x98, 0x32, 0x53, 0x24, 0x18, 0x15, 0x18, 0x35, 0x38, 0x83, 0x98, 0x32, 0x53, 0x84, 0xf4, 0xb8, 0x58, 0xca, 0x33, 0x52, 0xf3, 0x24, 0x98, 0x15, 0x18, 0x35, 0xb8, 0x8d, 0xa4, 0xf4, 0x20, 0x86, 0xea, 0xc1, 0x0c, 0xd5, 0x0b, 0x81, 0x19, 0x1a, 0x04, 0x56, 0x97, 0xc4, 0x06, 0x96, 0x31, 0x06, 0x04, 0x00, 0x00, 0xff, 0xff, 0xff, 0x4e, 0x39, 0x0e, 0xa0, 0x00, 0x00, 0x00, } ================================================ FILE: components/cqrs/marshaler_protobuf_gogo.go ================================================ package cqrs import ( "fmt" "runtime/debug" stderrors "errors" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill/message" "github.com/gogo/protobuf/proto" "github.com/pkg/errors" stdproto "google.golang.org/protobuf/proto" ) // ProtobufMarshaler a protobuf marshaler using github.com/gogo/protobuf/proto (deprecated). // // DEPRECATED: Use ProtoMarshaler instead. This marshaler will not work with newer protobuf files. // IMPORTANT: This marshaler is backward and forward compatible with ProtoMarshaler. // ProtobufMarshaler from Watermill versions until v1.4.3 are not forward compatible with ProtoMarshaler. // Suggested migration steps: // 1. Update Watermill to v1.4.4 or newer, so all publishers and subscribers will be forward and backward compatible. // 2. Change all usages of ProtobufMarshaler to ProtoMarshaler. type ProtobufMarshaler struct { NewUUID func() string GenerateName func(v interface{}) string // DisableStdProtoFallback disables fallback to github.com/golang/protobuf/proto when github.com/gogo/protobuf/proto // because receiving a message that was marshaled with github.com/golang/protobuf/proto. // Fallback is enabled by default to enable migration to ProtoMarshaler. DisableStdProtoFallback bool } // Marshal marshals the given protobuf's message into watermill's Message. func (m ProtobufMarshaler) Marshal(v interface{}) (msg *message.Message, err error) { defer func() { // gogo proto can panic on unmarshal (for example, because it received a message from ProtoMarshaler with oneof) if r := recover(); r != nil { err = stderrors.Join(err, fmt.Errorf( "github.com/gogo/protobuf/proto panic (we recommend migrating marshaler to cqrs.ProtoMarshaler to avoid that): %v\n%s", r, string(debug.Stack()), )) } if err != nil && !m.DisableStdProtoFallback { _, isStdProtoMsg := v.(stdproto.Message) if isStdProtoMsg { msg, err = m.ToProtoMarshaler().Marshal(v) } } }() protoMsg, ok := v.(proto.Message) if !ok { return nil, errors.WithStack(NoProtoMessageError{v}) } b, err := proto.Marshal(protoMsg) if err != nil { return nil, err } msg = message.NewMessage( m.newUUID(), b, ) msg.Metadata.Set("name", m.Name(v)) return msg, nil } func (m ProtobufMarshaler) newUUID() string { if m.NewUUID != nil { return m.NewUUID() } // default return watermill.NewUUID() } // Unmarshal unmarshals given watermill's Message into protobuf's message. func (m ProtobufMarshaler) Unmarshal(msg *message.Message, v interface{}) (err error) { protoV, ok := v.(proto.Message) if !ok { return errors.WithStack(NoProtoMessageError{v}) } defer func() { // gogo proto can panic on unmarshal (for example, because it received a message from ProtoMarshaler with oneof) if r := recover(); r != nil { err = stderrors.Join(err, fmt.Errorf( "github.com/gogo/protobuf/proto panic (we recommend migrating marshaler to cqrs.ProtoMarshaler to avoid that): %v\n%s", r, string(debug.Stack()), )) } if err != nil && !m.DisableStdProtoFallback { err = m.ToProtoMarshaler().Unmarshal(msg, v) } }() return proto.Unmarshal(msg.Payload, protoV) } func (m ProtobufMarshaler) ToProtoMarshaler() ProtoMarshaler { return ProtoMarshaler{ NewUUID: m.NewUUID, GenerateName: m.GenerateName, } } // Name returns the command or event's name. func (m ProtobufMarshaler) Name(cmdOrEvent interface{}) string { if m.GenerateName != nil { return m.GenerateName(cmdOrEvent) } return FullyQualifiedStructName(cmdOrEvent) } // NameFromMessage returns the metadata name value for a given Message. func (m ProtobufMarshaler) NameFromMessage(msg *message.Message) string { return msg.Metadata.Get("name") } ================================================ FILE: components/cqrs/marshaler_protobuf_gogo_test.go ================================================ package cqrs_test import ( "testing" "github.com/ThreeDotsLabs/watermill/components/cqrs" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestProtobufMarshaler_with_fallback(t *testing.T) { marshaler := cqrs.ProtobufMarshaler{} assertProtoMarshalUnmarshal( t, marshaler, marshaler, newProtoTestComplexEvent(), &TestComplexProtobufEvent{}, "cqrs_test.TestComplexProtobufEvent", ) legacyEvent, _ := newProtoLegacyTestEvent() assertProtoMarshalUnmarshal( t, marshaler, marshaler, legacyEvent, &TestProtobufEvent{}, "cqrs_test.TestProtobufEvent", ) } func TestProtobufMarshaler_without_fallback_legacy_event(t *testing.T) { legacyEvent, _ := newProtoLegacyTestEvent() marshaler := cqrs.ProtobufMarshaler{ DisableStdProtoFallback: true, } assertProtoMarshalUnmarshal( t, marshaler, marshaler, legacyEvent, &TestProtobufEvent{}, "cqrs_test.TestProtobufEvent", ) } func TestProtobufMarshaler_Marshal_generated_name(t *testing.T) { marshaler := cqrs.ProtobufMarshaler{ NewUUID: func() string { return "foo" }, } msg, err := marshaler.Marshal(newProtoTestComplexEvent()) require.NoError(t, err) assert.Equal(t, msg.UUID, "foo") } func TestProtobufMarshaler_catch_panic(t *testing.T) { marshalerNoFallback := cqrs.ProtobufMarshaler{ DisableStdProtoFallback: true, } marshalerWithFallback := cqrs.ProtobufMarshaler{ DisableStdProtoFallback: false, } complexEvent := newProtoTestComplexEvent() msg, err := marshalerNoFallback.Marshal(complexEvent) assert.Nil(t, msg) assert.ErrorContains(t, err, "(we recommend migrating marshaler to cqrs.ProtoMarshaler to avoid that)") assert.ErrorContains(t, err, "invalid memory address or nil pointer dereference") assert.ErrorContains(t, err, "github.com/gogo/protobuf/proto panic") assert.ErrorContains(t, err, "runtime/debug.Stack()", "error should contain stack trace") // let's simulate situation when publishing service uses fallback and consuming service does not msg, err = marshalerWithFallback.Marshal(complexEvent) require.NoError(t, err) err = marshalerNoFallback.Unmarshal(msg, &TestComplexProtobufEvent{}) assert.ErrorContains(t, err, "(we recommend migrating marshaler to cqrs.ProtoMarshaler to avoid that)") assert.ErrorContains(t, err, "protobuf tag not enough fields in TestComplexProtobufEvent.state") assert.ErrorContains(t, err, "github.com/gogo/protobuf/proto panic") assert.ErrorContains(t, err, "runtime/debug.Stack()", "error should contain stack trace") // marshaler with fallback should handle this message err = marshalerWithFallback.Unmarshal(msg, &TestComplexProtobufEvent{}) require.NoError(t, err) } func TestProtobufMarshaler_compatible_with_ProtoMarshaler(t *testing.T) { legacyEvent, legacyEventRegenerated := newProtoLegacyTestEvent() complexEvent := newProtoTestComplexEvent() deprecatedMarshaler := cqrs.ProtobufMarshaler{} newMarshaler := cqrs.ProtoMarshaler{} t.Run("from_deprecated_to_new", func(t *testing.T) { assertProtoMarshalUnmarshal( t, deprecatedMarshaler, newMarshaler, complexEvent, &TestComplexProtobufEvent{}, "cqrs_test.TestComplexProtobufEvent", ) assertProtoMarshalUnmarshal( t, deprecatedMarshaler, newMarshaler, legacyEvent, &TestProtobufLegacyEvent{}, "cqrs_test.TestProtobufEvent", ) }) t.Run("from_new_to_deprecated", func(t *testing.T) { assertProtoMarshalUnmarshal( t, newMarshaler, deprecatedMarshaler, complexEvent, &TestComplexProtobufEvent{}, "cqrs_test.TestComplexProtobufEvent", ) assertProtoMarshalUnmarshal( t, newMarshaler, deprecatedMarshaler, legacyEventRegenerated, &TestProtobufEvent{}, "cqrs_test.TestProtobufLegacyEvent", ) }) } ================================================ FILE: components/cqrs/marshaler_protobuf_test.go ================================================ package cqrs_test import ( "encoding/json" "fmt" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/timestamppb" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill/components/cqrs" ) func TestProtoMarshaler(t *testing.T) { assertProtoMarshalUnmarshal( t, cqrs.ProtoMarshaler{}, cqrs.ProtoMarshaler{}, newProtoTestComplexEvent(), &TestComplexProtobufEvent{}, "cqrs_test.TestComplexProtobufEvent", ) } func TestProtoMarshaler_Marshal_generated_name(t *testing.T) { marshaler := cqrs.ProtoMarshaler{ NewUUID: func() string { return "foo" }, } msg, err := marshaler.Marshal(newProtoTestComplexEvent()) require.NoError(t, err) assert.Equal(t, msg.UUID, "foo") } // newProtoLegacyTestEvent returns the same event in two different protobuf versions func newProtoLegacyTestEvent() (*TestProtobufEvent, *TestProtobufLegacyEvent) { when := timestamppb.New(time.Now()) id := watermill.NewULID() legacy := &TestProtobufEvent{ Id: id, When: when, } regenerated := &TestProtobufLegacyEvent{ Id: id, When: when, } return legacy, regenerated } func newProtoTestComplexEvent() *TestComplexProtobufEvent { when := timestamppb.New(time.Now()) eventToMarshal := &TestComplexProtobufEvent{ Id: watermill.NewULID(), Data: []byte("data"), When: when, NestedMap: map[string]*SubEvent{ "foo": { Tags: []string{"tag1", "tag2"}, Flags: map[string]bool{"flag1": true, "flag2": false}, }, }, Events: []*SubEvent{ { Tags: []string{"tag1", "tag2"}, }, { Tags: []string{"tag3", "tag4"}, }, }, Result: &TestComplexProtobufEvent_Success{ Success: &SubEvent{ Tags: []string{"tag10"}, Flags: map[string]bool{"flag10": true}, }, }, } return eventToMarshal } func assertProtoMarshalUnmarshal[T1, T2 fmt.Stringer]( t *testing.T, marshaler cqrs.CommandEventMarshaler, unmarshaler cqrs.CommandEventMarshaler, eventToMarshal T1, eventToUnmarshal T2, expectedEventName string, ) { t.Helper() msg, err := marshaler.Marshal(eventToMarshal) require.NoError(t, err) err = unmarshaler.Unmarshal(msg, eventToUnmarshal) require.NoError(t, err) eventToMarshalJson, err := json.Marshal(eventToMarshal) require.NoError(t, err) eventToUnmarshalJson, err := json.Marshal(eventToUnmarshal) require.NoError(t, err) assert.JSONEq(t, string(eventToMarshalJson), string(eventToUnmarshalJson)) assert.Equal(t, expectedEventName, msg.Metadata.Get("name")) } ================================================ FILE: components/cqrs/name.go ================================================ package cqrs import ( "fmt" "strings" ) // FullyQualifiedStructName returns object name in format [package].[type name]. // For example, for the struct: // // package events // type UserCreated struct {} // // it will return "events.UserCreated". // // It ignores if the value is a pointer or not. func FullyQualifiedStructName(v interface{}) string { s := fmt.Sprintf("%T", v) s = strings.TrimLeft(s, "*") return s } // StructName returns struct name in format [type name]. // For example, for the struct: // // package events // type UserCreated struct {} // // it will return "UserCreated". // // It ignores if the value is a pointer or not. func StructName(v interface{}) string { segments := strings.Split(fmt.Sprintf("%T", v), ".") return segments[len(segments)-1] } type namedStruct interface { Name() string } // NamedStruct returns the name from a message implementing the following interface: // // type namedStruct interface { // Name() string // } // // It ignores if the value is a pointer or not. func NamedStruct(fallback func(v interface{}) string) func(v interface{}) string { return func(v interface{}) string { if v, ok := v.(namedStruct); ok { return v.Name() } return fallback(v) } } ================================================ FILE: components/cqrs/name_test.go ================================================ package cqrs_test import ( "testing" "github.com/stretchr/testify/assert" "github.com/ThreeDotsLabs/watermill/components/cqrs" ) func TestFullyQualifiedStructName(t *testing.T) { type Object struct{} assert.Equal(t, "cqrs_test.Object", cqrs.FullyQualifiedStructName(Object{})) assert.Equal(t, "cqrs_test.Object", cqrs.FullyQualifiedStructName(&Object{})) } func BenchmarkFullyQualifiedStructName(b *testing.B) { type Object struct{} o := Object{} for i := 0; i < b.N; i++ { cqrs.FullyQualifiedStructName(o) } } func TestStructName(t *testing.T) { type Object struct{} assert.Equal(t, "Object", cqrs.StructName(Object{})) assert.Equal(t, "Object", cqrs.StructName(&Object{})) } func TestNamedStruct(t *testing.T) { assert.Equal(t, "named object", cqrs.NamedStruct(cqrs.StructName)(namedObject{})) assert.Equal(t, "named object", cqrs.NamedStruct(cqrs.StructName)(&namedObject{})) // Test fallback type Object struct{} assert.Equal(t, "Object", cqrs.NamedStruct(cqrs.StructName)(Object{})) assert.Equal(t, "Object", cqrs.NamedStruct(cqrs.StructName)(&Object{})) } type namedObject struct{} func (namedObject) Name() string { return "named object" } ================================================ FILE: components/cqrs/object.go ================================================ package cqrs import ( "reflect" ) func isPointer(v interface{}) error { rv := reflect.ValueOf(v) if rv.Kind() != reflect.Ptr || rv.IsNil() { return NonPointerError{rv.Type()} } return nil } type NonPointerError struct { Type reflect.Type } func (e NonPointerError) Error() string { return "non-pointer command: " + e.Type.String() + ", handler.NewCommand() should return pointer to the command" } ================================================ FILE: components/cqrs/testdata/events.proto ================================================ syntax = "proto3"; package cqrs_test; option go_package = "./cqrs_test"; import "google/protobuf/timestamp.proto"; message TestProtobufLegacyEvent { string id = 1; google.protobuf.Timestamp when = 3; } enum Status { STATUS_UNSPECIFIED = 0; ACTIVE = 1; DELETED = 2; } message SubEvent { repeated string tags = 1; map flags = 2; } message TestComplexProtobufEvent { string id = 1; bytes data = 2; google.protobuf.Timestamp when = 3; map nested_map = 4; repeated SubEvent events = 5; oneof result { SubEvent success = 6; string error = 7; Status fallback = 8; } reserved 23 to 30; } ================================================ FILE: components/delay/delay.go ================================================ package delay import ( "context" "time" "github.com/ThreeDotsLabs/watermill/message" ) // Delay represents a message's delay. // It can be either a delay until a specific time or a delay for a specific duration. // The zero value of Delay is a zero delay. // // IMPORTANT: Delay doesn't work with all Pub/Subs! Using it won't have any effect on Pub/Subs that don't support it. // See the list of supported Pub/Subs in the documentation: https://watermill.io/advanced/delayed-messages/ type Delay struct { time time.Time duration time.Duration } func (d Delay) IsZero() bool { return d.time.IsZero() } // Until returns a delay of the given time. func Until(delayedUntil time.Time) Delay { return Delay{ time: delayedUntil, duration: delayedUntil.Sub(time.Now().UTC()), } } // For returns a delay of now plus the given duration. func For(delayedFor time.Duration) Delay { return Delay{ time: time.Now().UTC().Add(delayedFor), duration: delayedFor, } } type contextKey string var ( delayContextKey = contextKey("delay") ) // WithContext returns a new context with the given delay. // If used together with a publisher wrapped with NewPublisher, the delay will be applied to the message. // // IMPORTANT: Delay doesn't work with all Pub/Subs! Using it won't have any effect on Pub/Subs that don't support it. // See the list of supported Pub/Subs in the documentation: https://watermill.io/advanced/delayed-messages/ func WithContext(ctx context.Context, delay Delay) context.Context { return context.WithValue(ctx, delayContextKey, delay) } const ( DelayedUntilKey = "_watermill_delayed_until" DelayedForKey = "_watermill_delayed_for" ) // Message sets the delay metadata on the message. // // IMPORTANT: Delay doesn't work with all Pub/Subs! Using it won't have any effect on Pub/Subs that don't support it. // See the list of supported Pub/Subs in the documentation: https://watermill.io/advanced/delayed-messages/ func Message(msg *message.Message, delay Delay) { msg.Metadata.Set(DelayedUntilKey, delay.time.Format(time.RFC3339)) msg.Metadata.Set(DelayedForKey, delay.duration.String()) } ================================================ FILE: components/delay/publisher.go ================================================ package delay import ( "errors" "github.com/ThreeDotsLabs/watermill/message" ) type DefaultDelayGeneratorParams struct { Topic string Message *message.Message } // PublisherConfig is a configuration for the delay publisher. type PublisherConfig struct { // DefaultDelayGenerator is a function that generates the default delay for a message. // If the message doesn't have the delay metadata set, the default delay will be applied. DefaultDelayGenerator func(params DefaultDelayGeneratorParams) (Delay, error) // AllowNoDelay allows publishing messages without a delay set. // By default, the publisher returns an error when a message is published without a delay and no default delay generator is provided. AllowNoDelay bool } // NewPublisher wraps a publisher with a delay mechanism. // A message can be published with delay metadata set in the context by using the WithContext function. // If the message doesn't have the delay metadata set, the default delay will be applied, if provided. func NewPublisher(pub message.Publisher, config PublisherConfig) (message.Publisher, error) { return &publisher{ pub: pub, config: config, }, nil } type publisher struct { pub message.Publisher config PublisherConfig } func (p *publisher) Publish(topic string, messages ...*message.Message) error { for i := range messages { err := p.applyDelay(topic, messages[i]) if err != nil { return err } } return p.pub.Publish(topic, messages...) } func (p *publisher) Close() error { return p.pub.Close() } func (p *publisher) applyDelay(topic string, msg *message.Message) error { if msg.Metadata.Get(DelayedForKey) != "" { return nil } if msg.Context().Value(delayContextKey) != nil { delay := msg.Context().Value(delayContextKey).(Delay) Message(msg, delay) return nil } if p.config.DefaultDelayGenerator != nil { delay, err := p.config.DefaultDelayGenerator(DefaultDelayGeneratorParams{ Topic: topic, Message: msg, }) if err != nil { return err } Message(msg, delay) return nil } if !p.config.AllowNoDelay { return errors.New("message doesn't have a delay set") } return nil } ================================================ FILE: components/delay/publisher_test.go ================================================ package delay_test import ( "context" "fmt" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/ThreeDotsLabs/watermill/components/delay" "github.com/ThreeDotsLabs/watermill/message" "github.com/ThreeDotsLabs/watermill/pubsub/gochannel" ) func TestPublisher(t *testing.T) { pubSub := gochannel.NewGoChannel(gochannel.Config{}, nil) messages, err := pubSub.Subscribe(context.Background(), "test") require.NoError(t, err) pub, err := delay.NewPublisher(pubSub, delay.PublisherConfig{}) require.NoError(t, err) pubAllowNoDelay, err := delay.NewPublisher(pubSub, delay.PublisherConfig{ AllowNoDelay: true, }) require.NoError(t, err) defaultDelayPub, err := delay.NewPublisher(pubSub, delay.PublisherConfig{ DefaultDelayGenerator: func(params delay.DefaultDelayGeneratorParams) (delay.Delay, error) { return delay.For(1 * time.Second), nil }, }) require.NoError(t, err) testCases := []struct { name string publisher message.Publisher messageConstructor func(id string) *message.Message expectedError bool expectedDelay time.Duration }{ { name: "no delay", publisher: pub, messageConstructor: func(id string) *message.Message { return message.NewMessage(id, nil) }, expectedError: true, expectedDelay: 0, }, { name: "no delay but allowed", publisher: pubAllowNoDelay, messageConstructor: func(id string) *message.Message { return message.NewMessage(id, nil) }, expectedDelay: 0, }, { name: "default delay", publisher: defaultDelayPub, messageConstructor: func(id string) *message.Message { return message.NewMessage(id, nil) }, expectedDelay: 1 * time.Second, }, { name: "delay from metadata", publisher: pub, messageConstructor: func(id string) *message.Message { msg := message.NewMessage(id, nil) delay.Message(msg, delay.For(2*time.Second)) return msg }, expectedDelay: 2 * time.Second, }, { name: "default delay override with metadata", publisher: defaultDelayPub, messageConstructor: func(id string) *message.Message { msg := message.NewMessage(id, nil) delay.Message(msg, delay.For(2*time.Second)) return msg }, expectedDelay: 2 * time.Second, }, { name: "delay from context", publisher: pub, messageConstructor: func(id string) *message.Message { msg := message.NewMessage(id, nil) ctx := delay.WithContext(context.Background(), delay.For(3*time.Second)) msg.SetContext(ctx) return msg }, expectedDelay: 3 * time.Second, }, { name: "default delay override with context", publisher: defaultDelayPub, messageConstructor: func(id string) *message.Message { msg := message.NewMessage(id, nil) ctx := delay.WithContext(context.Background(), delay.For(3*time.Second)) msg.SetContext(ctx) return msg }, expectedDelay: 3 * time.Second, }, { name: "delay with until", publisher: pub, messageConstructor: func(id string) *message.Message { msg := message.NewMessage(id, nil) delay.Message(msg, delay.Until(time.Now().UTC().Add(4*time.Second))) return msg }, expectedDelay: 4 * time.Second, }, { name: "both metadata and context set", publisher: defaultDelayPub, messageConstructor: func(id string) *message.Message { msg := message.NewMessage(id, nil) delay.Message(msg, delay.For(5*time.Second)) ctx := delay.WithContext(context.Background(), delay.For(6*time.Second)) msg.SetContext(ctx) return msg }, expectedDelay: 5 * time.Second, }, } for i, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { id := fmt.Sprint(i) msg := testCase.messageConstructor(id) err = testCase.publisher.Publish("test", msg) if testCase.expectedError { require.Error(t, err) return } require.NoError(t, err) assertMessage(t, messages, id, testCase.expectedDelay) }) } } func assertMessage(t *testing.T, messages <-chan *message.Message, expectedID string, expectedDelay time.Duration) { t.Helper() select { case msg := <-messages: assert.Equal(t, expectedID, msg.UUID) if expectedDelay == 0 { assert.Empty(t, msg.Metadata.Get(delay.DelayedUntilKey)) assert.Empty(t, msg.Metadata.Get(delay.DelayedForKey)) } else { delayedFor, err := time.ParseDuration(msg.Metadata.Get(delay.DelayedForKey)) require.NoError(t, err) assert.Equal(t, expectedDelay, delayedFor.Round(time.Second)) delayedUntil, err := time.Parse(time.RFC3339, msg.Metadata.Get(delay.DelayedUntilKey)) require.NoError(t, err) assert.WithinDuration(t, time.Now().UTC().Add(expectedDelay), delayedUntil, 1*time.Second) } msg.Ack() case <-time.After(100 * time.Millisecond): require.Fail(t, "timeout") } } ================================================ FILE: components/fanin/fanin.go ================================================ package fanin import ( "context" "fmt" "slices" "time" "github.com/pkg/errors" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill/message" ) type Config struct { // SourceTopics contains topics on which FanIn subscribes. SourceTopics []string // TargetTopic determines the topic on which messages from SourceTopics are published. TargetTopic string // CloseTimeout determines how long router should work for handlers when closing. CloseTimeout time.Duration } // FanIn is a component that receives messages from 1..N topics from a subscriber and publishes them // on a specified topic in the publisher. In effect, messages are "multiplexed". type FanIn struct { router *message.Router config Config logger watermill.LoggerAdapter } func (c *Config) setDefaults() { if c.CloseTimeout == 0 { c.CloseTimeout = time.Second * 30 } } func (c *Config) Validate() error { if len(c.SourceTopics) == 0 { return errors.New("sourceTopics must not be empty") } if slices.Contains(c.SourceTopics, "") { return errors.New("sourceTopics must not be empty") } if c.TargetTopic == "" { return errors.New("targetTopic must not be empty") } if slices.Contains(c.SourceTopics, c.TargetTopic) { return errors.New("sourceTopics must not contain targetTopic") } return nil } // NewFanIn creates a new FanIn. func NewFanIn( subscriber message.Subscriber, publisher message.Publisher, config Config, logger watermill.LoggerAdapter, ) (*FanIn, error) { if subscriber == nil { return nil, errors.New("missing subscriber") } if publisher == nil { return nil, errors.New("missing publisher") } config.setDefaults() if err := config.Validate(); err != nil { return nil, err } if logger == nil { logger = watermill.NopLogger{} } routerConfig := message.RouterConfig{CloseTimeout: config.CloseTimeout} if err := routerConfig.Validate(); err != nil { return nil, errors.Wrap(err, "invalid router config") } router, err := message.NewRouter(routerConfig, logger) if err != nil { return nil, errors.Wrap(err, "cannot create a router") } for _, topic := range config.SourceTopics { router.AddHandler( fmt.Sprintf("fan_in_%s", topic), topic, subscriber, config.TargetTopic, publisher, func(msg *message.Message) ([]*message.Message, error) { return []*message.Message{msg}, nil }, ) } return &FanIn{ router: router, config: config, logger: logger, }, nil } // Run runs the FanIn. func (f *FanIn) Run(ctx context.Context) error { return f.router.Run(ctx) } // Running is closed when FanIn is running. func (f *FanIn) Running() chan struct{} { return f.router.Running() } // Close gracefully closes the FanIn func (f *FanIn) Close() error { return f.router.Close() } ================================================ FILE: components/fanin/fanin_test.go ================================================ package fanin_test import ( "context" "fmt" "sync" "testing" "time" "github.com/stretchr/testify/require" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill/components/fanin" "github.com/ThreeDotsLabs/watermill/message" "github.com/ThreeDotsLabs/watermill/pubsub/gochannel" ) func TestFanIn(t *testing.T) { const ( upstreamTopicPattern = "upstream-topic-%d" downstreamTopic = "downstream-topic" cancelAfter = time.Millisecond * 100 workersCount = 3 messagesCount = 10 upstreamTopicsCount = 5 ) var upstreamTopics []string for i := 1; i <= upstreamTopicsCount; i++ { topic := fmt.Sprintf(upstreamTopicPattern, i) upstreamTopics = append(upstreamTopics, topic) } logger := watermill.NopLogger{} pubsub := gochannel.NewGoChannel(gochannel.Config{}, watermill.NopLogger{}) fi, err := fanin.NewFanIn( pubsub, pubsub, fanin.Config{ SourceTopics: upstreamTopics, TargetTopic: downstreamTopic, }, logger, ) require.NoError(t, err) router, err := message.NewRouter(message.RouterConfig{}, logger) require.NoError(t, err) expectedNumberOfMessages := workersCount * messagesCount * upstreamTopicsCount receivedMessages := make(chan string, expectedNumberOfMessages) for i := 0; i < workersCount; i++ { router.AddConsumerHandler( fmt.Sprintf("worker-%v", i), downstreamTopic, pubsub, func(msg *message.Message) error { payload := string(msg.Payload) receivedMessages <- payload return nil }, ) } ctx, cancel := context.WithTimeout(context.Background(), cancelAfter) defer cancel() go func() { err := router.Run(ctx) require.NoError(t, err) }() go func() { err := fi.Run(ctx) require.NoError(t, err) }() <-router.Running() <-fi.Running() var wg sync.WaitGroup wg.Add(len(upstreamTopics) * messagesCount) for _, topic := range upstreamTopics { go func(topic string) { for i := 0; i < messagesCount; i++ { msg := message.NewMessage(watermill.NewUUID(), []byte(topic)) err := pubsub.Publish(topic, msg) require.NoError(t, err) wg.Done() } }(topic) } wg.Wait() counts := map[string]int{} loop: for { select { case msg := <-receivedMessages: counts[msg]++ case <-time.After(cancelAfter): close(receivedMessages) break loop } } sum := 0 require.Len(t, counts, upstreamTopicsCount) for _, count := range counts { require.Equal(t, workersCount*messagesCount, count) sum += count } require.Equal(t, expectedNumberOfMessages, sum) } func TestNewFanIn(t *testing.T) { pubsub := gochannel.NewGoChannel(gochannel.Config{}, nil) t.Run("error when subscriber nil", func(t *testing.T) { _, err := fanin.NewFanIn( nil, nil, fanin.Config{}, nil, ) require.EqualError(t, err, "missing subscriber") }) t.Run("error when publisher nil", func(t *testing.T) { _, err := fanin.NewFanIn( pubsub, nil, fanin.Config{}, nil, ) require.EqualError(t, err, "missing publisher") }) t.Run("error when sourceTopics empty", func(t *testing.T) { _, err := fanin.NewFanIn( pubsub, pubsub, fanin.Config{}, nil, ) require.EqualError(t, err, "sourceTopics must not be empty") }) t.Run("error when sourceTopics empty", func(t *testing.T) { _, err := fanin.NewFanIn( pubsub, pubsub, fanin.Config{ SourceTopics: []string{""}, }, nil, ) require.EqualError(t, err, "sourceTopics must not be empty") }) t.Run("error when targetTopic empty", func(t *testing.T) { _, err := fanin.NewFanIn( pubsub, pubsub, fanin.Config{ SourceTopics: []string{"topic"}, TargetTopic: "", }, nil, ) require.EqualError(t, err, "targetTopic must not be empty") }) t.Run("error when sourceTopics contains targetTopic", func(t *testing.T) { _, err := fanin.NewFanIn( pubsub, pubsub, fanin.Config{ SourceTopics: []string{"topic"}, TargetTopic: "topic", }, nil, ) require.EqualError(t, err, "sourceTopics must not contain targetTopic") }) t.Run("correct", func(t *testing.T) { _, err := fanin.NewFanIn( pubsub, pubsub, fanin.Config{ SourceTopics: []string{"topic"}, TargetTopic: "targetTopic", }, nil, ) require.NoError(t, err) }) } ================================================ FILE: components/forwarder/envelope.go ================================================ package forwarder import ( "encoding/json" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill/message" "github.com/pkg/errors" ) // messageEnvelope wraps Watermill message and contains destination topic. type messageEnvelope struct { DestinationTopic string `json:"destination_topic"` UUID string `json:"uuid"` Payload []byte `json:"payload"` Metadata map[string]string `json:"metadata"` } func newMessageEnvelope(destTopic string, msg *message.Message) (*messageEnvelope, error) { e := &messageEnvelope{ DestinationTopic: destTopic, UUID: msg.UUID, Payload: msg.Payload, Metadata: msg.Metadata, } if err := e.validate(); err != nil { return nil, errors.Wrap(err, "cannot create a message envelope") } return e, nil } func (e *messageEnvelope) validate() error { if e.DestinationTopic == "" { return errors.New("unknown destination topic") } return nil } func wrapMessageInEnvelope(destinationTopic string, msg *message.Message) (*message.Message, error) { envelope, err := newMessageEnvelope(destinationTopic, msg) if err != nil { return nil, errors.Wrap(err, "cannot envelope a message") } envelopedMessage, err := json.Marshal(envelope) if err != nil { return nil, errors.Wrap(err, "cannot marshal a message") } wrappedMsg := message.NewMessage(watermill.NewUUID(), envelopedMessage) wrappedMsg.SetContext(msg.Context()) return wrappedMsg, nil } func unwrapMessageFromEnvelope(msg *message.Message) (destinationTopic string, unwrappedMsg *message.Message, err error) { envelopedMsg := messageEnvelope{} if err := json.Unmarshal(msg.Payload, &envelopedMsg); err != nil { return "", nil, errors.Wrap(err, "cannot unmarshal message wrapped in an envelope") } if err := envelopedMsg.validate(); err != nil { return "", nil, errors.Wrap(err, "an unmarshalled message envelope is invalid") } watermillMessage := message.NewMessage(envelopedMsg.UUID, envelopedMsg.Payload) watermillMessage.Metadata = envelopedMsg.Metadata watermillMessage.SetContext(msg.Context()) return envelopedMsg.DestinationTopic, watermillMessage, nil } ================================================ FILE: components/forwarder/envelope_test.go ================================================ package forwarder import ( "context" "testing" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill/message" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) type contextKey string func TestEnvelope(t *testing.T) { expectedUUID := watermill.NewUUID() expectedPayload := message.Payload("msg content") expectedMetadata := message.Metadata{"key": "value"} expectedDestinationTopic := "dest_topic" ctx := context.WithValue(context.Background(), contextKey("key"), "value") msg := message.NewMessage(expectedUUID, expectedPayload) msg.Metadata = expectedMetadata msg.SetContext(ctx) wrappedMsg, err := wrapMessageInEnvelope(expectedDestinationTopic, msg) require.NoError(t, err) require.NotNil(t, wrappedMsg) v, ok := wrappedMsg.Context().Value(contextKey("key")).(string) require.True(t, ok) require.Equal(t, "value", v) destinationTopic, unwrappedMsg, err := unwrapMessageFromEnvelope(wrappedMsg) require.NoError(t, err) require.NotNil(t, unwrappedMsg) assert.Equal(t, expectedUUID, unwrappedMsg.UUID) assert.Equal(t, expectedPayload, unwrappedMsg.Payload) assert.Equal(t, expectedMetadata, unwrappedMsg.Metadata) assert.Equal(t, expectedDestinationTopic, destinationTopic) v, ok = unwrappedMsg.Context().Value(contextKey("key")).(string) require.True(t, ok) require.Equal(t, "value", v) } ================================================ FILE: components/forwarder/forwarder.go ================================================ package forwarder import ( "context" "time" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill/message" "github.com/pkg/errors" ) const defaultForwarderTopic = "forwarder_topic" type Config struct { // ForwarderTopic is a topic on which the forwarder will be listening to enveloped messages to forward. // Defaults to `forwarder_topic`. ForwarderTopic string // Middlewares are used to decorate forwarder's handler function. Middlewares []message.HandlerMiddleware // CloseTimeout determines how long router should work for handlers when closing. CloseTimeout time.Duration // AckWhenCannotUnwrap enables acking of messages which cannot be unwrapped from an envelope. AckWhenCannotUnwrap bool // Router is a router used by the forwarder. // If not provided, a new router will be created. // // If router is provided, it's not necessary to call `Forwarder.Run()` if the router is started with `router.Run()`. Router *message.Router } func (c *Config) setDefaults() { if c.CloseTimeout == 0 { c.CloseTimeout = time.Second * 30 } if c.ForwarderTopic == "" { c.ForwarderTopic = defaultForwarderTopic } } func (c *Config) Validate() error { if c.ForwarderTopic == "" { return errors.New("empty forwarder topic") } return nil } // Forwarder subscribes to the topic provided in the config and publishes them to the destination topic embedded in the enveloped message. type Forwarder struct { router *message.Router publisher message.Publisher logger watermill.LoggerAdapter config Config } // NewForwarder creates a forwarder which will subscribe to the topic provided in the config using the provided subscriber. // It will publish messages received on this subscription to the destination topic embedded in the enveloped message using the provided publisher. // // Provided subscriber and publisher can be from different Watermill Pub/Sub implementations, i.e. MySQL subscriber and Google Pub/Sub publisher. // // Note: Keep in mind that by default the forwarder will nack all messages which weren't sent using a decorated publisher. // You can change this behavior by passing a middleware which will ack them instead. func NewForwarder(subscriberIn message.Subscriber, publisherOut message.Publisher, logger watermill.LoggerAdapter, config Config) (*Forwarder, error) { config.setDefaults() routerConfig := message.RouterConfig{CloseTimeout: config.CloseTimeout} if err := routerConfig.Validate(); err != nil { return nil, errors.Wrap(err, "invalid router config") } var router *message.Router if config.Router != nil { router = config.Router } else { var err error router, err = message.NewRouter(routerConfig, logger) if err != nil { return nil, errors.Wrap(err, "cannot create a router") } } f := &Forwarder{router, publisherOut, logger, config} handler := router.AddConsumerHandler( "events_forwarder", config.ForwarderTopic, subscriberIn, f.forwardMessage, ) handler.AddMiddleware(config.Middlewares...) return f, nil } // Run runs forwarder's handler responsible for forwarding messages. // This call is blocking while the forwarder is running. // ctx will be propagated to the forwarder's subscription. // // To stop Run() you should call Close() on the forwarder. func (f *Forwarder) Run(ctx context.Context) error { return f.router.Run(ctx) } // Close stops forwarder's handler. func (f *Forwarder) Close() error { return f.router.Close() } // Running returns channel which is closed when the forwarder is running. func (f *Forwarder) Running() chan struct{} { return f.router.Running() } func (f *Forwarder) forwardMessage(msg *message.Message) error { destTopic, unwrappedMsg, err := unwrapMessageFromEnvelope(msg) if err != nil { f.logger.Error("Could not unwrap a message from an envelope", err, watermill.LogFields{ "uuid": msg.UUID, "payload": msg.Payload, "metadata": msg.Metadata, "acked": f.config.AckWhenCannotUnwrap, }) if f.config.AckWhenCannotUnwrap { return nil } return errors.Wrap(err, "cannot unwrap message from an envelope") } if err := f.publisher.Publish(destTopic, unwrappedMsg); err != nil { return errors.Wrap(err, "cannot publish a message") } return nil } ================================================ FILE: components/forwarder/forwarder_test.go ================================================ package forwarder_test import ( "context" "testing" "time" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill/components/forwarder" "github.com/ThreeDotsLabs/watermill/message" "github.com/ThreeDotsLabs/watermill/pubsub/gochannel" "github.com/stretchr/testify/suite" ) var ( logger = watermill.NewStdLogger(true, true) forwarderTopic = "forwarder_topic" outTopic = "out_topic" ) func TestForwarder(t *testing.T) { suite.Run(t, new(ForwarderSuite)) } // ForwarderSuite tests forwarding messages from PubSubIn to PubSubOut (which are GoChannel implementation underneath). type ForwarderSuite struct { suite.Suite ctx context.Context cancelCtx func() publisherIn PubSubInPublisher subscriberIn PubSubInSubscriber publisherOut PubSubOutPublisher subscriberOut PubSubOutSubscriber decoratedPublisherIn *forwarder.Publisher outMessagesCh <-chan *message.Message } func (s *ForwarderSuite) SetupTest() { // Create test context with a 5 seconds timeout so it will close any subscriptions/handlers running in the background // in case of too long test execution. s.ctx, s.cancelCtx = context.WithTimeout(context.Background(), time.Second*5) // Create a set of publisher and subscribers for both In and Out Pub/Subs. s.publisherIn, s.subscriberIn = newPubSubIn() s.publisherOut, s.subscriberOut = newPubSubOut() s.decoratedPublisherIn = forwarder.NewPublisher(s.publisherIn, forwarder.PublisherConfig{ForwarderTopic: forwarderTopic}) s.listenOnOutTopic() } func (s *ForwarderSuite) TearDownTest() { s.NoError(s.publisherIn.Close()) s.NoError(s.subscriberIn.Close()) s.NoError(s.publisherOut.Close()) s.NoError(s.subscriberOut.Close()) s.cancelCtx() } func (s *ForwarderSuite) TestForwarder_publish_using_decorated_publisher() { fwd := s.setupForwarder(forwarder.Config{ForwarderTopic: forwarderTopic}) defer func() { s.NoError(fwd.Close()) }() sentMsg := s.sampleMessage() err := s.decoratedPublisherIn.Publish(outTopic, sentMsg) s.Require().NoError(err) s.requireFirstMessage(sentMsg) } func (s *ForwarderSuite) TestForwarder_publish_using_non_decorated_publisher() { msgAckedDetectorMiddleware, msgAckedCh := s.setupMessageAckedDetectorMiddleware() fwd := s.setupForwarder(forwarder.Config{ ForwarderTopic: forwarderTopic, Middlewares: []message.HandlerMiddleware{msgAckedDetectorMiddleware}, }) defer func() { s.NoError(fwd.Close()) }() sentMsg := s.sampleMessage() err := s.publisherIn.Publish(forwarderTopic, sentMsg) s.Require().NoError(err) s.requireFirstAckingResult(msgAckedCh, false) } func (s *ForwarderSuite) TestForwarder_publish_using_non_decorated_publisher_acking_enabled() { msgAckedDetectorMiddleware, msgAckedCh := s.setupMessageAckedDetectorMiddleware() fwd := s.setupForwarder(forwarder.Config{ ForwarderTopic: forwarderTopic, Middlewares: []message.HandlerMiddleware{msgAckedDetectorMiddleware}, AckWhenCannotUnwrap: true, }) defer func() { s.NoError(fwd.Close()) }() sentMsg := s.sampleMessage() err := s.publisherIn.Publish(forwarderTopic, sentMsg) s.Require().NoError(err) s.requireFirstAckingResult(msgAckedCh, true) } type PubSubInPublisher struct { message.Publisher } type PubSubInSubscriber struct { message.Subscriber } type PubSubOutPublisher struct { message.Publisher } type PubSubOutSubscriber struct { message.Subscriber } func newPubSubIn() (PubSubInPublisher, PubSubInSubscriber) { channelPubSub := gochannel.NewGoChannel(gochannel.Config{}, logger) return PubSubInPublisher{channelPubSub}, PubSubInSubscriber{channelPubSub} } func newPubSubOut() (PubSubOutPublisher, PubSubOutSubscriber) { channelPubSub := gochannel.NewGoChannel(gochannel.Config{}, logger) return PubSubOutPublisher{channelPubSub}, PubSubOutSubscriber{channelPubSub} } func (s *ForwarderSuite) setupForwarder(config forwarder.Config) *forwarder.Forwarder { f, err := forwarder.NewForwarder(s.subscriberIn, s.publisherOut, logger, config) s.Require().NoError(err) go func() { s.Require().NoError(f.Run(s.ctx)) }() select { case <-f.Running(): case <-s.ctx.Done(): s.T().Fatal("forwarder not running") } return f } func (s *ForwarderSuite) listenOnOutTopic() { var err error s.outMessagesCh, err = s.subscriberOut.Subscribe(s.ctx, outTopic) s.Require().NoError(err) } func (s *ForwarderSuite) requireFirstMessage(expectedMessage *message.Message) { select { case receivedMessage := <-s.outMessagesCh: s.Require().NotNil(receivedMessage) s.Require().Truef(receivedMessage.Equals(expectedMessage), "received message: '%s', expected: '%s'", receivedMessage, expectedMessage) receivedMessage.Ack() case <-time.After(time.Second): s.T().Fatal("didn't receive any message after 1 sec") } } func (s *ForwarderSuite) setupMessageAckedDetectorMiddleware() (message.HandlerMiddleware, <-chan bool) { messageAckedCh := make(chan bool, 1) messageAckedDetector := func(handlerFunc message.HandlerFunc) message.HandlerFunc { return func(msg *message.Message) ([]*message.Message, error) { msgs, err := handlerFunc(msg) messageAckedCh <- err == nil // Always return nil as we don't want to nack the message in tests. return msgs, nil } } return messageAckedDetector, messageAckedCh } func (s *ForwarderSuite) requireFirstAckingResult(msgAckedCh <-chan bool, expected bool) { select { case msgAcked := <-msgAckedCh: s.Require().Equal(expected, msgAcked) case <-time.After(time.Second): s.T().Fatal("acking result not received after 1 sec") } } func (s *ForwarderSuite) sampleMessage() *message.Message { msg := message.NewMessage(watermill.NewUUID(), message.Payload("message payload")) msg.Metadata = message.Metadata{"key": "value"} return msg } ================================================ FILE: components/forwarder/publisher.go ================================================ package forwarder import ( "github.com/ThreeDotsLabs/watermill/message" "github.com/pkg/errors" ) type PublisherConfig struct { // ForwarderTopic is a topic which the forwarder is listening to. Publisher will send enveloped messages to this topic. // Defaults to `forwarder_topic`. ForwarderTopic string } func (c *PublisherConfig) setDefaults() { if c.ForwarderTopic == "" { c.ForwarderTopic = defaultForwarderTopic } } func (c *PublisherConfig) Validate() error { if c.ForwarderTopic == "" { return errors.New("empty forwarder topic") } return nil } // Publisher changes `Publish` method behavior so it wraps a sent message in an envelope // and sends it to the forwarder topic provided in the config. type Publisher struct { wrappedPublisher message.Publisher config PublisherConfig } func NewPublisher(publisher message.Publisher, config PublisherConfig) *Publisher { config.setDefaults() return &Publisher{ wrappedPublisher: publisher, config: config, } } func (p *Publisher) Publish(topic string, messages ...*message.Message) error { envelopedMessages := make([]*message.Message, 0, len(messages)) for _, msg := range messages { envelopedMsg, err := wrapMessageInEnvelope(topic, msg) if err != nil { return errors.Wrapf(err, "cannot wrap message, target topic: '%s', uuid: '%s'", topic, msg.UUID) } envelopedMessages = append(envelopedMessages, envelopedMsg) } if err := p.wrappedPublisher.Publish(p.config.ForwarderTopic, envelopedMessages...); err != nil { return errors.Wrapf(err, "cannot publish messages to forwarder topic: '%s'", p.config.ForwarderTopic) } return nil } func (p *Publisher) Close() error { return p.wrappedPublisher.Close() } ================================================ FILE: components/metrics/builder.go ================================================ package metrics import ( "github.com/ThreeDotsLabs/watermill/internal" "github.com/ThreeDotsLabs/watermill/message" "github.com/pkg/errors" "github.com/prometheus/client_golang/prometheus" ) type PrometheusMetricsBuilderConfig struct { Namespace string Subsystem string AdditionalLabels []MetricLabel } func NewPrometheusMetricsBuilderWithConfig(prometheusRegistry prometheus.Registerer, config PrometheusMetricsBuilderConfig) PrometheusMetricsBuilder { builder := PrometheusMetricsBuilder{ Namespace: config.Namespace, Subsystem: config.Subsystem, PrometheusRegistry: prometheusRegistry, additionalLabels: config.AdditionalLabels, } return builder } func NewPrometheusMetricsBuilder(prometheusRegistry prometheus.Registerer, namespace string, subsystem string) PrometheusMetricsBuilder { return NewPrometheusMetricsBuilderWithConfig(prometheusRegistry, PrometheusMetricsBuilderConfig{ Namespace: namespace, Subsystem: subsystem, }) } // PrometheusMetricsBuilder provides methods to decorate publishers, subscribers and handlers. type PrometheusMetricsBuilder struct { // PrometheusRegistry may be filled with a pre-existing Prometheus registry, or left empty for the default registry. PrometheusRegistry prometheus.Registerer Namespace string Subsystem string // PublishBuckets defines the histogram buckets for publish time histogram, defaulted if nil. PublishBuckets []float64 // HandlerBuckets defines the histogram buckets for handle execution time histogram, defaulted to watermill's default. HandlerBuckets []float64 additionalLabels []MetricLabel } // AddPrometheusRouterMetrics is a convenience function that acts on the message router to add the metrics middleware // to all its handlers. The handlers' publishers and subscribers are also decorated. // The default buckets are used for the handler execution time histogram (use your own provisioning // with NewRouterMiddlewareWithConfig if needed). func (b PrometheusMetricsBuilder) AddPrometheusRouterMetrics(r *message.Router) { r.AddPublisherDecorators(b.DecoratePublisher) r.AddSubscriberDecorators(b.DecorateSubscriber) r.AddMiddleware(b.NewRouterMiddleware().Middleware) } // DecoratePublisher wraps the underlying publisher with Prometheus metrics. func (b PrometheusMetricsBuilder) DecoratePublisher(pub message.Publisher) (message.Publisher, error) { var err error d := PublisherPrometheusMetricsDecorator{ pub: pub, publisherName: internal.StructName(pub), additionalLabels: b.additionalLabels, } d.publishTimeSeconds, err = b.registerHistogramVec(prometheus.NewHistogramVec( prometheus.HistogramOpts{ Namespace: b.Namespace, Subsystem: b.Subsystem, Name: "publish_time_seconds", Help: "The time that a publishing attempt (success or not) took in seconds", Buckets: b.PublishBuckets, }, toLabelsSlice(publisherLabelKeys, b.additionalLabels), )) if err != nil { return nil, errors.Wrap(err, "could not register publish time metric") } return d, nil } // DecorateSubscriber wraps the underlying subscriber with Prometheus metrics. func (b PrometheusMetricsBuilder) DecorateSubscriber(sub message.Subscriber) (message.Subscriber, error) { var err error d := &SubscriberPrometheusMetricsDecorator{ closing: make(chan struct{}), subscriberName: internal.StructName(sub), additionalLabels: b.additionalLabels, } d.subscriberMessagesReceivedTotal, err = b.registerCounterVec(prometheus.NewCounterVec( prometheus.CounterOpts{ Namespace: b.Namespace, Subsystem: b.Subsystem, Name: "subscriber_messages_received_total", Help: "The total number of messages received by the subscriber", }, toLabelsSlice(append(subscriberLabelKeys, labelAcked), b.additionalLabels), )) if err != nil { return nil, errors.Wrap(err, "could not register time to ack metric") } d.Subscriber, err = message.MessageTransformSubscriberDecorator(d.recordMetrics)(sub) if err != nil { return nil, errors.Wrap(err, "could not decorate subscriber with metrics decorator") } return d, nil } func (b PrometheusMetricsBuilder) register(c prometheus.Collector) (prometheus.Collector, error) { err := b.PrometheusRegistry.Register(c) if err == nil { return c, nil } if are, ok := err.(prometheus.AlreadyRegisteredError); ok { return are.ExistingCollector, nil } return nil, err } func (b PrometheusMetricsBuilder) registerCounterVec(c *prometheus.CounterVec) (*prometheus.CounterVec, error) { col, err := b.register(c) if err != nil { return nil, err } return col.(*prometheus.CounterVec), nil } func (b PrometheusMetricsBuilder) registerHistogramVec(h *prometheus.HistogramVec) (*prometheus.HistogramVec, error) { col, err := b.register(h) if err != nil { return nil, err } return col.(*prometheus.HistogramVec), nil } ================================================ FILE: components/metrics/ctx.go ================================================ package metrics import "context" type contextValue int const ( publishObserved contextValue = iota subscribeObserved ) // setPublishObservedToCtx is used to achieve metrics idempotency in case of double applied middleware func setPublishObservedToCtx(ctx context.Context) context.Context { return context.WithValue(ctx, publishObserved, true) } func publishAlreadyObserved(ctx context.Context) bool { return ctx.Value(publishObserved) != nil } // setSubscribeObservedToCtx is used to achieve metrics idempotency in case of double applied middleware func setSubscribeObservedToCtx(ctx context.Context) context.Context { return context.WithValue(ctx, subscribeObserved, true) } func subscribeAlreadyObserved(ctx context.Context) bool { return ctx.Value(subscribeObserved) != nil } ================================================ FILE: components/metrics/handler.go ================================================ package metrics import ( "time" "github.com/pkg/errors" "github.com/prometheus/client_golang/prometheus" "github.com/ThreeDotsLabs/watermill/message" ) var ( handlerLabelKeys = []string{ labelKeyHandlerName, labelSuccess, } // defaultHandlerExecutionTimeBuckets are one order of magnitude smaller than default buckets (5ms~10s), // because the handler execution times are typically shorter (µs~ms range). defaultHandlerExecutionTimeBuckets = []float64{ 0.0005, 0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, } ) // HandlerPrometheusMetricsMiddleware is a middleware that captures Prometheus metrics. type HandlerPrometheusMetricsMiddleware struct { handlerExecutionTimeSeconds *prometheus.HistogramVec additionalLabels []MetricLabel } // Middleware returns the middleware ready to be used with watermill's Router. func (m HandlerPrometheusMetricsMiddleware) Middleware(h message.HandlerFunc) message.HandlerFunc { return func(msg *message.Message) (msgs []*message.Message, err error) { now := time.Now() ctx := msg.Context() labels := prometheus.Labels{ labelKeyHandlerName: message.HandlerNameFromCtx(ctx), } for _, lb := range m.additionalLabels { labels[lb.Label] = lb.ComputeValueFn(ctx) } defer func() { if err != nil { labels[labelSuccess] = "false" } else { labels[labelSuccess] = "true" } m.handlerExecutionTimeSeconds.With(labels).Observe(time.Since(now).Seconds()) }() return h(msg) } } // NewRouterMiddleware returns new middleware. func (b PrometheusMetricsBuilder) NewRouterMiddleware() HandlerPrometheusMetricsMiddleware { var err error m := HandlerPrometheusMetricsMiddleware{ additionalLabels: b.additionalLabels, } if b.HandlerBuckets == nil { b.HandlerBuckets = defaultHandlerExecutionTimeBuckets } m.handlerExecutionTimeSeconds, err = b.registerHistogramVec(prometheus.NewHistogramVec( prometheus.HistogramOpts{ Namespace: b.Namespace, Subsystem: b.Subsystem, Name: "handler_execution_time_seconds", Help: "The total time elapsed while executing the handler function in seconds", Buckets: b.HandlerBuckets, }, toLabelsSlice(handlerLabelKeys, b.additionalLabels), )) if err != nil { panic(errors.Wrap(err, "could not register handler execution time metric")) } return m } ================================================ FILE: components/metrics/http.go ================================================ package metrics import ( "errors" "net/http" "github.com/go-chi/chi/v5" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" ) // CreateRegistryAndServeHTTP establishes an HTTP server that exposes the /metrics endpoint for Prometheus at the given address. // It returns a new prometheus registry (to register the metrics on) and a canceling function that ends the server. func CreateRegistryAndServeHTTP(addr string) (registry *prometheus.Registry, cancel func()) { registry = prometheus.NewRegistry() return registry, ServeHTTP(addr, registry) } // ServeHTTP establishes an HTTP server that exposes the /metrics endpoint for Prometheus at the given address. // It takes an existing Prometheus registry and returns a canceling function that ends the server. func ServeHTTP(addr string, registry *prometheus.Registry) (cancel func()) { router := chi.NewRouter() handler := promhttp.HandlerFor(registry, promhttp.HandlerOpts{}) router.Get("/metrics", func(w http.ResponseWriter, r *http.Request) { handler.ServeHTTP(w, r) }) server := http.Server{ Addr: addr, Handler: router, } go func() { err := server.ListenAndServe() if !errors.Is(err, http.ErrServerClosed) { panic(err) } }() return func() { _ = server.Close() } } ================================================ FILE: components/metrics/http_test.go ================================================ package metrics_test import ( "net/http" "testing" "time" "github.com/pkg/errors" "github.com/prometheus/client_golang/prometheus/collectors" "github.com/stretchr/testify/assert" "github.com/ThreeDotsLabs/watermill/components/metrics" ) func TestCreateRegistryAndServeHTTP_metrics_endpoint(t *testing.T) { reg, cancel := metrics.CreateRegistryAndServeHTTP(":8090") defer cancel() err := reg.Register(collectors.NewBuildInfoCollector()) if err != nil { t.Fatal(errors.Wrap(err, "registration of prometheus build info collector failed")) } waitServerReady(t, "http://localhost:8090") resp, err := http.DefaultClient.Get("http://localhost:8090/metrics") if resp != nil { defer resp.Body.Close() } if err != nil { t.Fatal(errors.Wrap(err, "call to metrics endpoint failed")) } assert.NotNil(t, resp) assert.Equal(t, http.StatusOK, resp.StatusCode) } func TestCreateRegistryAndServeHTTP_unknown_endpoint(t *testing.T) { reg, cancel := metrics.CreateRegistryAndServeHTTP(":8091") defer cancel() err := reg.Register(collectors.NewBuildInfoCollector()) if err != nil { t.Error(errors.Wrap(err, "registration of prometheus build info collector failed")) } waitServerReady(t, "http://localhost:8091") resp, err := http.DefaultClient.Get("http://localhost:8091/unknown") if resp != nil { defer resp.Body.Close() } if err != nil { t.Fatal(errors.Wrap(err, "call to unknown endpoint failed")) } assert.NotNil(t, resp) assert.Equal(t, http.StatusNotFound, resp.StatusCode) } // server might have small delay before being able to server traffic func waitServerReady(t *testing.T, addr string) { for i := 0; i < 50; i++ { _, err := http.DefaultClient.Get(addr) // assume server ready when no err anymore if err == nil { return } time.Sleep(100 * time.Millisecond) continue } } ================================================ FILE: components/metrics/labels.go ================================================ package metrics import ( "context" "github.com/ThreeDotsLabs/watermill/message" "github.com/prometheus/client_golang/prometheus" ) const ( labelKeyHandlerName = "handler_name" labelKeyPublisherName = "publisher_name" labelKeySubscriberName = "subscriber_name" labelSuccess = "success" labelAcked = "acked" labelValueNoHandler = "" ) var ( labelGetters = map[string]func(context.Context) string{ labelKeyHandlerName: message.HandlerNameFromCtx, labelKeyPublisherName: message.PublisherNameFromCtx, labelKeySubscriberName: message.SubscriberNameFromCtx, } ) func labelsFromCtx(ctx context.Context, labels ...string) prometheus.Labels { ctxLabels := map[string]string{} for _, l := range labels { k := l ctxLabels[l] = "" getter, ok := labelGetters[k] if !ok { continue } v := getter(ctx) if v != "" { ctxLabels[l] = v } } return ctxLabels } type LabelComputeValueFn func(msgCtx context.Context) string type MetricLabel struct { Label string ComputeValueFn LabelComputeValueFn } func toLabelsSlice(baseLabels []string, customs []MetricLabel) []string { labels := make([]string, len(baseLabels), len(baseLabels)+len(customs)) copy(labels, baseLabels) for _, label := range customs { //Check if the additional label is already in the base labels. We cannot have duplicate labels //If it's in the base, just skip it as the compute function is going to overwrite the default value contains := false for _, baseLabel := range baseLabels { if baseLabel == label.Label { contains = true break } } if !contains { labels = append(labels, label.Label) } } return labels } ================================================ FILE: components/metrics/publisher.go ================================================ package metrics import ( "time" "github.com/ThreeDotsLabs/watermill/message" "github.com/prometheus/client_golang/prometheus" ) var ( publisherLabelKeys = []string{ labelKeyHandlerName, labelKeyPublisherName, labelSuccess, } ) // PublisherPrometheusMetricsDecorator decorates a publisher to capture Prometheus metrics. type PublisherPrometheusMetricsDecorator struct { pub message.Publisher publisherName string publishTimeSeconds *prometheus.HistogramVec additionalLabels []MetricLabel } // Publish updates the relevant publisher metrics and calls the wrapped publisher's Publish. func (m PublisherPrometheusMetricsDecorator) Publish(topic string, messages ...*message.Message) (err error) { if len(messages) == 0 { return m.pub.Publish(topic) } // TODO: take ctx not only from first msg. Might require changing the signature of Publish, which is planned anyway. ctx := messages[0].Context() labels := labelsFromCtx(ctx, publisherLabelKeys...) if labels[labelKeyPublisherName] == "" { labels[labelKeyPublisherName] = m.publisherName } if labels[labelKeyHandlerName] == "" { labels[labelKeyHandlerName] = labelValueNoHandler } for _, lb := range m.additionalLabels { labels[lb.Label] = lb.ComputeValueFn(ctx) } start := time.Now() defer func() { if publishAlreadyObserved(ctx) { // decorator idempotency when applied decorator multiple times return } if err != nil { labels[labelSuccess] = "false" } else { labels[labelSuccess] = "true" } m.publishTimeSeconds.With(labels).Observe(time.Since(start).Seconds()) }() for _, msg := range messages { msg.SetContext(setPublishObservedToCtx(msg.Context())) } return m.pub.Publish(topic, messages...) } // Close decreases the total publisher count, closes the Prometheus HTTP server and calls wrapped Close. func (m PublisherPrometheusMetricsDecorator) Close() error { return m.pub.Close() } ================================================ FILE: components/metrics/subscriber.go ================================================ package metrics import ( "github.com/ThreeDotsLabs/watermill/message" "github.com/prometheus/client_golang/prometheus" ) var ( subscriberLabelKeys = []string{ labelKeyHandlerName, labelKeySubscriberName, } ) // SubscriberPrometheusMetricsDecorator decorates a subscriber to capture Prometheus metrics. type SubscriberPrometheusMetricsDecorator struct { message.Subscriber subscriberName string subscriberMessagesReceivedTotal *prometheus.CounterVec closing chan struct{} additionalLabels []MetricLabel } func (s SubscriberPrometheusMetricsDecorator) recordMetrics(msg *message.Message) { if msg == nil { return } ctx := msg.Context() labels := labelsFromCtx(ctx, subscriberLabelKeys...) if labels[labelKeySubscriberName] == "" { labels[labelKeySubscriberName] = s.subscriberName } if labels[labelKeyHandlerName] == "" { labels[labelKeyHandlerName] = labelValueNoHandler } for _, lb := range s.additionalLabels { labels[lb.Label] = lb.ComputeValueFn(ctx) } go func() { if subscribeAlreadyObserved(ctx) { // decorator idempotency when applied decorator multiple times return } select { case <-msg.Acked(): labels[labelAcked] = "acked" case <-msg.Nacked(): labels[labelAcked] = "nacked" } s.subscriberMessagesReceivedTotal.With(labels).Inc() }() msg.SetContext(setSubscribeObservedToCtx(msg.Context())) } ================================================ FILE: components/requestreply/backend_pubsub.go ================================================ package requestreply import ( "context" stdErrors "errors" "fmt" "time" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill/message" "github.com/pkg/errors" ) // PubSubBackend is a Backend that uses Pub/Sub to transport commands and replies. type PubSubBackend[Result any] struct { config PubSubBackendConfig marshaler BackendPubsubMarshaler[Result] } // NewPubSubBackend creates a new PubSubBackend. // // If you want to use backend together with `NewCommandHandler` (without result), you should pass `NoResult` or `struct{}` as Result type. func NewPubSubBackend[Result any]( config PubSubBackendConfig, marshaler BackendPubsubMarshaler[Result], ) (*PubSubBackend[Result], error) { config.setDefaults() if err := config.Validate(); err != nil { return nil, errors.Wrap(err, "invalid config") } if marshaler == nil { return nil, errors.New("marshaler cannot be nil") } return &PubSubBackend[Result]{ config: config, marshaler: marshaler, }, nil } type PubSubBackendSubscribeParams struct { Command any OperationID OperationID } type PubSubBackendSubscriberConstructorFn func(PubSubBackendSubscribeParams) (message.Subscriber, error) type PubSubBackendGenerateSubscribeTopicFn func(PubSubBackendSubscribeParams) (string, error) type PubSubBackendPublishParams struct { Command any CommandMessage *message.Message OperationID OperationID } type PubSubBackendGeneratePublishTopicFn func(PubSubBackendPublishParams) (string, error) type PubSubBackendOnCommandProcessedParams struct { HandleErr error PubSubBackendPublishParams } type PubSubBackendModifyNotificationMessageFn func(msg *message.Message, params PubSubBackendOnCommandProcessedParams) error type PubSubBackendOnListenForReplyFinishedFn func(ctx context.Context, params PubSubBackendSubscribeParams) type ReplyPublishErrorHandler func(replyTopic string, notificationMsg *message.Message, err error) error type PubSubBackendConfig struct { Publisher message.Publisher SubscriberConstructor PubSubBackendSubscriberConstructorFn GeneratePublishTopic PubSubBackendGeneratePublishTopicFn GenerateSubscribeTopic PubSubBackendGenerateSubscribeTopicFn Logger watermill.LoggerAdapter ListenForReplyTimeout *time.Duration ModifyNotificationMessage PubSubBackendModifyNotificationMessageFn OnListenForReplyFinished PubSubBackendOnListenForReplyFinishedFn // AckCommandErrors determines if the command should be acked or nacked when handler returns an error. // Command will be nacked by default when sending reply fails, you can control this behaviour with the // ReplyPublishErrorHandler config option. // You should use this option instead of cqrs.CommandProcessorConfig.AckCommandHandlingErrors, as it's aware // if error was returned by handler or sending reply failed. AckCommandErrors bool // ReplyPublishErrorHandler if not nil will be invoked when sending the reply fails. If it returns an error // the command will be nacked. ReplyPublishErrorHandler ReplyPublishErrorHandler } func (p *PubSubBackendConfig) setDefaults() { if p.Logger == nil { p.Logger = watermill.NopLogger{} } } func (p *PubSubBackendConfig) Validate() error { var err error if p.Publisher == nil { err = stdErrors.Join(err, errors.New("publisher cannot be nil")) } if p.SubscriberConstructor == nil { err = stdErrors.Join(err, errors.New("subscriber constructor cannot be nil")) } if p.GeneratePublishTopic == nil { err = stdErrors.Join(err, errors.New("GeneratePublishTopic cannot be nil")) } if p.GenerateSubscribeTopic == nil { err = stdErrors.Join(err, errors.New("GenerateSubscribeTopic cannot be nil")) } return err } func (p PubSubBackend[Result]) ListenForNotifications( ctx context.Context, params BackendListenForNotificationsParams, ) (<-chan Reply[Result], error) { start := time.Now() replyContext := PubSubBackendSubscribeParams(params) // this needs to be done before publishing the message to avoid race condition notificationsSubscriber, err := p.config.SubscriberConstructor(replyContext) if err != nil { return nil, errors.Wrap(err, "cannot create request/reply notifications subscriber") } replyNotificationTopic, err := p.config.GenerateSubscribeTopic(replyContext) if err != nil { return nil, errors.Wrap(err, "cannot generate request/reply notifications topic") } var cancel context.CancelFunc if p.config.ListenForReplyTimeout != nil { ctx, cancel = context.WithTimeout(ctx, *p.config.ListenForReplyTimeout) } else { ctx, cancel = context.WithCancel(ctx) } notifyMsgs, err := notificationsSubscriber.Subscribe(ctx, replyNotificationTopic) if err != nil { cancel() return nil, errors.Wrap(err, "cannot subscribe to request/reply notifications topic") } p.config.Logger.Debug( "Subscribed to request/reply notifications topic", watermill.LogFields{ "request_reply_topic": replyNotificationTopic, }, ) replyChan := make(chan Reply[Result], 1) go func() { defer func() { if p.config.OnListenForReplyFinished == nil { return } p.config.OnListenForReplyFinished(ctx, replyContext) }() defer close(replyChan) defer cancel() for { select { case <-ctx.Done(): replyChan <- Reply[Result]{ Error: ReplyTimeoutError{time.Since(start), ctx.Err()}, } return case notifyMsg, ok := <-notifyMsgs: if !ok { // subscriber is closed replyChan <- Reply[Result]{ Error: ReplyTimeoutError{time.Since(start), fmt.Errorf("subscriber closed")}, } return } resp, ok, unmarshalErr := p.handleNotifyMsg(notifyMsg, string(params.OperationID), p.marshaler) if unmarshalErr != nil { replyChan <- Reply[Result]{ Error: ReplyUnmarshalError{unmarshalErr}, } } else if ok { replyChan <- Reply[Result]{ HandlerResult: resp.HandlerResult, Error: resp.Error, NotificationMessage: notifyMsg, } } // we assume that more messages may arrive (in case of fan-out commands handling) - we don't exit yet } } }() return replyChan, nil } const OperationIDMetadataKey = "_watermill_requestreply_op_id" func (p PubSubBackend[Result]) OnCommandProcessed(ctx context.Context, params BackendOnCommandProcessedParams[Result]) error { p.config.Logger.Debug("Sending request reply", nil) notificationMsg, err := p.marshaler.MarshalReply(params) if err != nil { return errors.Wrap(err, "cannot marshal request reply notification") } notificationMsg.SetContext(ctx) operationID, err := operationIDFromMetadata(params.CommandMessage) if err != nil { return err } notificationMsg.Metadata.Set(OperationIDMetadataKey, string(operationID)) if p.config.ModifyNotificationMessage != nil { processedContext := PubSubBackendOnCommandProcessedParams{ HandleErr: params.HandleErr, PubSubBackendPublishParams: PubSubBackendPublishParams{ Command: params.Command, CommandMessage: params.CommandMessage, OperationID: operationID, }, } if err := p.config.ModifyNotificationMessage(notificationMsg, processedContext); err != nil { return errors.Wrap(err, "cannot modify notification message") } } replyTopic, err := p.config.GeneratePublishTopic(PubSubBackendPublishParams{ Command: params.Command, CommandMessage: params.CommandMessage, OperationID: operationID, }) if err != nil { return errors.Wrap(err, "cannot generate request/reply notify topic") } err = p.config.Publisher.Publish(replyTopic, notificationMsg) if err != nil { if p.config.ReplyPublishErrorHandler != nil { err = p.config.ReplyPublishErrorHandler(replyTopic, notificationMsg, err) } } if err != nil { return errors.Wrap(err, "cannot publish command executed message") } if p.config.AckCommandErrors { // we are ignoring handler error - message will be acked return nil } else { // if handler returned error, it will nack the message // if params.HandleErr is nil, message will be acked return params.HandleErr } } func operationIDFromMetadata(msg *message.Message) (OperationID, error) { operationID := msg.Metadata.Get(OperationIDMetadataKey) if operationID == "" { return "", errors.Errorf("cannot get notification ID from command message metadata, key: %s", OperationIDMetadataKey) } return OperationID(operationID), nil } func (p PubSubBackend[Result]) handleNotifyMsg( msg *message.Message, expectedCommandUuid string, marshaler BackendPubsubMarshaler[Result], ) (Reply[Result], bool, error) { defer msg.Ack() if msg.Metadata.Get(OperationIDMetadataKey) != expectedCommandUuid { p.config.Logger.Debug("Received notify message with different command UUID", nil) return Reply[Result]{}, false, nil } res, unmarshalErr := marshaler.UnmarshalReply(msg) return res, true, unmarshalErr } ================================================ FILE: components/requestreply/backend_pubsub_marshaler.go ================================================ package requestreply import ( "encoding/json" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill/message" "github.com/pkg/errors" ) type BackendPubsubMarshaler[Result any] interface { MarshalReply(params BackendOnCommandProcessedParams[Result]) (*message.Message, error) UnmarshalReply(msg *message.Message) (reply Reply[Result], err error) } const ( ErrorMetadataKey = "_watermill_requestreply_error" HasErrorMetadataKey = "_watermill_requestreply_has_error" ) type BackendPubsubJSONMarshaler[Result any] struct{} func (m BackendPubsubJSONMarshaler[Result]) MarshalReply( params BackendOnCommandProcessedParams[Result], ) (*message.Message, error) { msg := message.NewMessage(watermill.NewUUID(), nil) if params.HandleErr != nil { msg.Metadata.Set(ErrorMetadataKey, params.HandleErr.Error()) msg.Metadata.Set(HasErrorMetadataKey, "1") } else { msg.Metadata.Set(HasErrorMetadataKey, "0") } b, err := json.Marshal(params.HandlerResult) if err != nil { return nil, errors.Wrap(err, "cannot marshal reply") } msg.Payload = b return msg, nil } func (m BackendPubsubJSONMarshaler[Result]) UnmarshalReply(msg *message.Message) (Reply[Result], error) { reply := Reply[Result]{} if msg.Metadata.Get(HasErrorMetadataKey) == "1" { reply.Error = errors.New(msg.Metadata.Get(ErrorMetadataKey)) } var result Result if err := json.Unmarshal(msg.Payload, &result); err != nil { return Reply[Result]{}, errors.Wrap(err, "cannot unmarshal result") } reply.HandlerResult = result return reply, nil } ================================================ FILE: components/requestreply/command_bus.go ================================================ package requestreply import ( "context" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill/message" "github.com/pkg/errors" ) type CommandBus interface { SendWithModifiedMessage(ctx context.Context, cmd any, modify func(*message.Message) error) error } // SendWithReply sends command to the command bus and receives a replies of the command handler. // It returns a channel with replies, cancel function and error. // If more than one replies are sent, only the first which is received is returned. // // If you expect multiple replies, please use SendWithReplies instead. // // SendWithReply is blocking until the first reply is received or the context is canceled. // SendWithReply can be cancelled by cancelling context or // by exceeding the timeout set in the backend (if set). // // SendWithReply can listen for handlers with results (NewCommandHandlerWithResult) and without results (NewCommandHandler). // If you are listening for handlers without results, you should pass `NoResult` or `struct{}` as `Result` generic type: // // reply, err := requestreply.SendWithReply[requestreply.NoResult]( // context.Background(), // ts.CommandBus, // ts.RequestReplyBackend, // &TestCommand{ID: "1"}, // ) // // If `NewCommandHandlerWithResult` handler returns a specific type, you should pass it as `Result` generic type: // // reply, err := requestreply.SendWithReply[SomeTypeReturnedByHandler]( // context.Background(), // ts.CommandBus, // ts.RequestReplyBackend, // &TestCommand{ID: "1"}, // ) func SendWithReply[Result any]( ctx context.Context, c CommandBus, backend Backend[Result], cmd any, ) (Reply[Result], error) { replyCh, cancel, err := SendWithReplies[Result](ctx, c, backend, cmd) if err != nil { return Reply[Result]{}, errors.Wrap(err, "SendWithReplies failed") } defer cancel() select { case <-ctx.Done(): return Reply[Result]{}, errors.Wrap(ctx.Err(), "context closed") case reply := <-replyCh: return reply, nil } } // SendWithReplies sends command to the command bus and receives a replies of the command handler. // It returns a channel with replies, cancel function and error. // // SendWithReplies can be cancelled by calling cancel function or by cancelling context or // When SendWithReplies is canceled, the returned channel is closed as well. // by exceeding the timeout set in the backend (if set). // Warning: It's important to cancel the function, because it's listening for the replies in the background. // Lack of cancelling the function can lead to subscriber leak. // // SendWithReplies can listen for handlers with results (NewCommandHandlerWithResult) and without results (NewCommandHandler). // If you are listening for handlers without results, you should pass `NoResult` or `struct{}` as `Result` generic type: // // replyCh, cancel, err := requestreply.SendWithReplies[requestreply.NoResult]( // context.Background(), // ts.CommandBus, // ts.RequestReplyBackend, // &TestCommand{ID: "1"}, // ) // // If `NewCommandHandlerWithResult` handler returns a specific type, you should pass it as `Result` generic type: // // replyCh, cancel, err := requestreply.SendWithReplies[SomeTypeReturnedByHandler]( // context.Background(), // ts.CommandBus, // ts.RequestReplyBackend, // &TestCommand{ID: "1"}, // ) // // SendWithReplies will send the replies to the channel until the context is cancelled or the timeout is exceeded. // They are multiple cases when more than one reply can be sent: // - when the handler returns an error, and backend is configured to nack the message on error // (for the PubSubBackend, it depends on `PubSubBackendConfig.AckCommandErrors` option.), // - when you are using fan-out mechanism and commands are handled multiple times, func SendWithReplies[Result any]( ctx context.Context, c CommandBus, backend Backend[Result], cmd any, ) (replCh <-chan Reply[Result], cancel func(), err error) { ctx, cancel = context.WithCancel(ctx) defer func() { if err != nil { cancel() } }() operationID := watermill.NewUUID() replyChan, err := backend.ListenForNotifications(ctx, BackendListenForNotificationsParams{ Command: cmd, OperationID: OperationID(operationID), }) if err != nil { return nil, cancel, errors.Wrap(err, "cannot listen for reply") } if err := c.SendWithModifiedMessage(ctx, cmd, func(m *message.Message) error { m.Metadata.Set(OperationIDMetadataKey, operationID) return nil }); err != nil { return nil, cancel, errors.Wrap(err, "cannot send command") } return replyChan, cancel, nil } ================================================ FILE: components/requestreply/handler.go ================================================ package requestreply import ( "context" "github.com/ThreeDotsLabs/watermill/components/cqrs" "github.com/ThreeDotsLabs/watermill/message" "github.com/pkg/errors" ) // NewCommandHandler creates a new CommandHandler which supports the request-reply pattern. // The result handler is handler compatible with cqrs.CommandHandler. // // The logic if a command should be acked or not is based on the logic of the Backend. // For example, for the PubSubBackend, it depends on the `PubSubBackendConfig.AckCommandErrors` option. func NewCommandHandler[Command any]( handlerName string, backend Backend[struct{}], handleFunc func(ctx context.Context, cmd *Command) error, ) cqrs.CommandHandler { return cqrs.NewCommandHandler(handlerName, func(ctx context.Context, cmd *Command) error { handlerErr := handleFunc(ctx, cmd) originalMessage, err := originalCommandMsgFromCtx(ctx) if err != nil { return err } return backend.OnCommandProcessed(ctx, BackendOnCommandProcessedParams[struct{}]{ Command: cmd, CommandMessage: originalMessage, HandleErr: handlerErr, }) }) } // NewCommandHandlerWithResult creates a new CommandHandler which supports the request-reply pattern with a result. // The result handler is handler compatible with cqrs.CommandHandler. // // In addition to cqrs.CommandHandler, it also allows returning a result from the handler. // The result is passed to the Backend implementation and sent to the caller. // // The logic if a command should be acked or not is based on the logic of the Backend. // For example, for the PubSubBackend, it depends on the `PubSubBackendConfig.AckCommandErrors` option. // // The reply is sent to the caller, even if the handler returns an error. func NewCommandHandlerWithResult[Command any, Result any]( handlerName string, backend Backend[Result], handleFunc func(ctx context.Context, cmd *Command) (Result, error), ) cqrs.CommandHandler { return cqrs.NewCommandHandler(handlerName, func(ctx context.Context, cmd *Command) error { resp, handlerErr := handleFunc(ctx, cmd) originalMessage, err := originalCommandMsgFromCtx(ctx) if err != nil { return err } return backend.OnCommandProcessed(ctx, BackendOnCommandProcessedParams[Result]{ Command: cmd, CommandMessage: originalMessage, HandlerResult: resp, HandleErr: handlerErr, }) }) } func originalCommandMsgFromCtx(ctx context.Context) (*message.Message, error) { originalMessage := cqrs.OriginalMessageFromCtx(ctx) if originalMessage == nil { // This should not happen, as long as cqrs.CommandProcessor is used - but it's not mandatory. // In this case, it's enough to use cqrs.CtxWithOriginalMessage return nil, errors.New( "original message not found in context, did you pass context correctly everywhere? " + "did you use cqrs.CommandProcessor? " + "if you are using custom implementation, please call cqrs.CtxWithOriginalMessage on the context passed to the handler", ) } return originalMessage, nil } ================================================ FILE: components/requestreply/requestreply.go ================================================ package requestreply import ( "context" "fmt" "time" "github.com/ThreeDotsLabs/watermill/message" ) // NoResult is a result type for commands that don't have result. type NoResult = struct{} type Reply[Result any] struct { // HandlerResult contains the handler result. // It's preset only when NewCommandHandlerWithResult is used. If NewCommandHandler is used, HandlerResult is empty. // // Result is sent even if the handler returns an error. HandlerResult Result // Error contains the error returned by the command handler or the Backend when handling notification fails. // Handling the notification can fail, for example, when unmarshaling the message or if there's a timeout. // If listening for a reply times out or the context is canceled, the Error is ReplyTimeoutError. // // If an error from the handler is returned, CommandHandlerError is returned. // If processing was successful, Error is nil. Error error // NotificationMessage contains the notification message sent after the command is handled. // It's present only if the request/reply backend uses a Pub/Sub for notifications (for example, PubSubBackend). // // Warning: NotificationMessage is nil if a timeout occurs. NotificationMessage *message.Message } type Backend[Result any] interface { ListenForNotifications(ctx context.Context, params BackendListenForNotificationsParams) (<-chan Reply[Result], error) OnCommandProcessed(ctx context.Context, params BackendOnCommandProcessedParams[Result]) error } type BackendListenForNotificationsParams struct { Command any OperationID OperationID } type BackendOnCommandProcessedParams[Result any] struct { Command any CommandMessage *message.Message HandlerResult Result HandleErr error } // OperationID is a unique identifier of a command. // It correlates commands with replies between the bus and the handler. type OperationID string // ReplyTimeoutError is returned when the reply timeout is exceeded. type ReplyTimeoutError struct { Duration time.Duration Err error } func (e ReplyTimeoutError) Error() string { return fmt.Sprintf("reply timeout after %s: %s", e.Duration, e.Err) } type ReplyUnmarshalError struct { Err error } func (r ReplyUnmarshalError) Error() string { return fmt.Sprintf("cannot unmarshal reply: %s", r.Err) } func (r ReplyUnmarshalError) Unwrap() error { return r.Err } // CommandHandlerError is returned when the command handler returns an error. type CommandHandlerError struct { Err error } func (e CommandHandlerError) Error() string { return e.Err.Error() } func (e CommandHandlerError) Unwrap() error { return e.Err } ================================================ FILE: components/requestreply/requestreply_test.go ================================================ package requestreply_test import ( "context" "errors" "fmt" "sync" "testing" "time" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill/components/cqrs" "github.com/ThreeDotsLabs/watermill/components/requestreply" "github.com/ThreeDotsLabs/watermill/message" "github.com/ThreeDotsLabs/watermill/pubsub/gochannel" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) type TestServices[Result any] struct { Logger watermill.LoggerAdapter Marshaler cqrs.CommandEventMarshaler PubSub *gochannel.GoChannel Router *message.Router CommandBus *cqrs.CommandBus CommandProcessor *cqrs.CommandProcessor RequestReplyBackend *requestreply.PubSubBackend[Result] BackendConfig requestreply.PubSubBackendConfig } type TestServicesConfig struct { DoNotAckOnCommandErrors bool ListenForReplyTimeout *time.Duration AssertNotificationMessage func(t *testing.T, msg *message.Message) DoNotBlockPublishUntilSubscriberAck bool } func NewTestServices[Result any](t *testing.T, c TestServicesConfig) TestServices[Result] { t.Helper() logger := watermill.NewStdLogger(true, true) marshaler := cqrs.JSONMarshaler{} pubSub := gochannel.NewGoChannel( gochannel.Config{BlockPublishUntilSubscriberAck: false}, logger, ) backendConfig := requestreply.PubSubBackendConfig{ Publisher: pubSub, SubscriberConstructor: func(subscriberContext requestreply.PubSubBackendSubscribeParams) (message.Subscriber, error) { assert.NotEmpty(t, subscriberContext.OperationID) assert.NotEmpty(t, subscriberContext.Command) return pubSub, nil }, GenerateSubscribeTopic: func(subscriberContext requestreply.PubSubBackendSubscribeParams) (string, error) { assert.NotEmpty(t, subscriberContext.OperationID) assert.NotEmpty(t, subscriberContext.Command) return "reply", nil }, GeneratePublishTopic: func(subscriberContext requestreply.PubSubBackendPublishParams) (string, error) { assert.NotEmpty(t, subscriberContext.OperationID) assert.NotEmpty(t, subscriberContext.Command) assert.NotEmpty(t, subscriberContext.CommandMessage) return "reply", nil }, Logger: logger, ModifyNotificationMessage: func(msg *message.Message, params requestreply.PubSubBackendOnCommandProcessedParams) error { // to make it deterministic msg.UUID = "1" assert.NotEmpty(t, params.OperationID) assert.NotEmpty(t, params.Command) assert.NotEmpty(t, params.CommandMessage) // to ensure backward compatibility if c.AssertNotificationMessage != nil { c.AssertNotificationMessage(t, msg) } return nil }, AckCommandErrors: !c.DoNotAckOnCommandErrors, ListenForReplyTimeout: c.ListenForReplyTimeout, } backend, err := requestreply.NewPubSubBackend[Result]( backendConfig, requestreply.BackendPubsubJSONMarshaler[Result]{}, ) require.NoError(t, err) router, err := message.NewRouter(message.RouterConfig{}, logger) require.NoError(t, err) commandBus, err := cqrs.NewCommandBusWithConfig(pubSub, cqrs.CommandBusConfig{ GeneratePublishTopic: func(params cqrs.CommandBusGeneratePublishTopicParams) (string, error) { return "commands", nil }, Marshaler: marshaler, Logger: logger, }) require.NoError(t, err) commandProcessor, err := cqrs.NewCommandProcessorWithConfig(router, cqrs.CommandProcessorConfig{ GenerateSubscribeTopic: func(params cqrs.CommandProcessorGenerateSubscribeTopicParams) (string, error) { return "commands", nil }, SubscriberConstructor: func(params cqrs.CommandProcessorSubscriberConstructorParams) (message.Subscriber, error) { return pubSub, nil }, Marshaler: marshaler, Logger: logger, }) require.NoError(t, err) return TestServices[Result]{ Logger: logger, PubSub: gochannel.NewGoChannel( gochannel.Config{BlockPublishUntilSubscriberAck: !c.DoNotBlockPublishUntilSubscriberAck}, logger, ), Router: router, RequestReplyBackend: backend, CommandBus: commandBus, CommandProcessor: commandProcessor, Marshaler: marshaler, BackendConfig: backendConfig, } } func (ts TestServices[Result]) RunRouter() { go func() { err := ts.Router.Run(context.Background()) if err != nil { panic(err) } }() <-ts.Router.Running() } type TestCommand struct { ID string `json:"id"` } type TestCommand2 struct { ID string `json:"id"` } type TestCommandResult struct { ID string `json:"id"` } func TestRequestReply_without_result_no_error(t *testing.T) { ts := NewTestServices[requestreply.NoResult](t, TestServicesConfig{ AssertNotificationMessage: func(t *testing.T, msg *message.Message) { assert.NotEmpty(t, msg.Metadata.Get(requestreply.HasErrorMetadataKey)) }, }) err := ts.CommandProcessor.AddHandlers( requestreply.NewCommandHandler( "test_handler", ts.RequestReplyBackend, func(ctx context.Context, cmd *TestCommand) error { return nil }, ), ) require.NoError(t, err) ts.RunRouter() replyCh, cancel, err := requestreply.SendWithReplies[requestreply.NoResult]( context.Background(), ts.CommandBus, ts.RequestReplyBackend, &TestCommand{ID: "1"}, ) require.NoError(t, err) require.NotNil(t, replyCh) defer cancel() select { case reply := <-replyCh: assert.Empty(t, reply.HandlerResult) assert.NoError(t, reply.Error) assert.NotEmpty(t, reply.NotificationMessage.Metadata.Get(requestreply.OperationIDMetadataKey)) case <-time.After(time.Millisecond * 100): t.Fatal("timeout") } } func TestRequestReply_without_result_with_error(t *testing.T) { ts := NewTestServices[requestreply.NoResult](t, TestServicesConfig{ AssertNotificationMessage: func(t *testing.T, msg *message.Message) { assert.NotEmpty(t, msg.Metadata.Get(requestreply.HasErrorMetadataKey)) assert.NotEmpty(t, msg.Metadata.Get(requestreply.ErrorMetadataKey)) }, }) expectedErr := errors.New("some error") err := ts.CommandProcessor.AddHandlers( requestreply.NewCommandHandler( "test_handler", ts.RequestReplyBackend, func(ctx context.Context, cmd *TestCommand) error { return expectedErr }, ), ) require.NoError(t, err) ts.RunRouter() replyCh, cancel, err := requestreply.SendWithReplies[requestreply.NoResult]( context.Background(), ts.CommandBus, ts.RequestReplyBackend, &TestCommand{ID: "1"}, ) require.NoError(t, err) require.NotNil(t, replyCh) defer cancel() select { case reply := <-replyCh: assert.Empty(t, reply.HandlerResult) require.Error(t, reply.Error) assert.Equal(t, expectedErr.Error(), reply.Error.Error()) assert.NotEmpty(t, reply.NotificationMessage.Metadata.Get(requestreply.OperationIDMetadataKey)) case <-time.After(time.Millisecond * 100): t.Fatal("timeout") } } func TestRequestReply_with_result_no_error(t *testing.T) { ts := NewTestServices[TestCommandResult](t, TestServicesConfig{ AssertNotificationMessage: func(t *testing.T, msg *message.Message) { assert.NotEmpty(t, msg.Metadata.Get(requestreply.HasErrorMetadataKey)) }, }) expectedResult := TestCommandResult{ID: "123"} err := ts.CommandProcessor.AddHandlers( requestreply.NewCommandHandlerWithResult[TestCommand, TestCommandResult]( "test_handler", ts.RequestReplyBackend, func(ctx context.Context, cmd *TestCommand) (TestCommandResult, error) { return expectedResult, nil }, ), ) require.NoError(t, err) ts.RunRouter() replyCh, cancel, err := requestreply.SendWithReplies[TestCommandResult]( context.Background(), ts.CommandBus, ts.RequestReplyBackend, &TestCommand{ID: "1"}, ) require.NoError(t, err) require.NotNil(t, replyCh) defer cancel() select { case reply := <-replyCh: assert.EqualValues(t, expectedResult, reply.HandlerResult) assert.NoError(t, reply.Error) assert.NotEmpty(t, reply.NotificationMessage.Metadata.Get(requestreply.OperationIDMetadataKey)) case <-time.After(time.Millisecond * 100): t.Fatal("timeout") } } func TestRequestReply_with_result_with_error(t *testing.T) { ts := NewTestServices[TestCommandResult](t, TestServicesConfig{}) expectedResult := TestCommandResult{ID: "123"} expectedErr := errors.New("some error") err := ts.CommandProcessor.AddHandlers( requestreply.NewCommandHandlerWithResult[TestCommand, TestCommandResult]( "test_handler", ts.RequestReplyBackend, func(ctx context.Context, cmd *TestCommand) (TestCommandResult, error) { return expectedResult, expectedErr }, ), ) require.NoError(t, err) ts.RunRouter() replyCh, cancel, err := requestreply.SendWithReplies[TestCommandResult]( context.Background(), ts.CommandBus, ts.RequestReplyBackend, &TestCommand{ID: "1"}, ) require.NoError(t, err) require.NotNil(t, replyCh) defer cancel() select { case reply := <-replyCh: assert.EqualValues(t, TestCommandResult{ID: "123"}, reply.HandlerResult) require.Error(t, reply.Error) assert.Equal(t, expectedErr.Error(), reply.Error.Error()) assert.NotEmpty(t, reply.NotificationMessage.Metadata.Get(requestreply.OperationIDMetadataKey)) case <-time.After(time.Millisecond * 100): t.Fatal("timeout") } } func TestSendWithReply(t *testing.T) { ts := NewTestServices[TestCommandResult](t, TestServicesConfig{}) expectedResult := TestCommandResult{ID: "123"} expectedErr := errors.New("some error") err := ts.CommandProcessor.AddHandlers( requestreply.NewCommandHandlerWithResult[TestCommand, TestCommandResult]( "test_handler", ts.RequestReplyBackend, func(ctx context.Context, cmd *TestCommand) (TestCommandResult, error) { return expectedResult, expectedErr }, ), ) require.NoError(t, err) ts.RunRouter() reply, err := requestreply.SendWithReply[TestCommandResult]( context.Background(), ts.CommandBus, ts.RequestReplyBackend, &TestCommand{ID: "1"}, ) require.NoError(t, err) assert.EqualValues(t, TestCommandResult{ID: "123"}, reply.HandlerResult) require.Error(t, reply.Error) assert.Equal(t, expectedErr.Error(), reply.Error.Error()) assert.NotEmpty(t, reply.NotificationMessage.Metadata.Get(requestreply.OperationIDMetadataKey)) } func TestRequestReply_without_result_multiple_replies(t *testing.T) { ts := NewTestServices[TestCommandResult](t, TestServicesConfig{ DoNotAckOnCommandErrors: true, }) type toSend struct { ID string Err error } toSendCh := make(chan toSend, 1) toSendCh <- toSend{ID: "1", Err: fmt.Errorf("error 1")} err := ts.CommandProcessor.AddHandlers( requestreply.NewCommandHandlerWithResult[TestCommand, TestCommandResult]( "test_handler", ts.RequestReplyBackend, func(ctx context.Context, cmd *TestCommand) (TestCommandResult, error) { toSend := <-toSendCh return TestCommandResult{ID: toSend.ID}, toSend.Err }, ), ) require.NoError(t, err) ts.RunRouter() replyCh, cancel, err := requestreply.SendWithReplies[TestCommandResult]( context.Background(), ts.CommandBus, ts.RequestReplyBackend, &TestCommand{ID: "1"}, ) require.NoError(t, err) require.NotNil(t, replyCh) defer cancel() select { case reply := <-replyCh: assert.EqualValues(t, TestCommandResult{ID: "1"}, reply.HandlerResult) require.Error(t, reply.Error) assert.Equal(t, "error 1", reply.Error.Error()) assert.NotEmpty(t, reply.NotificationMessage.Metadata.Get(requestreply.OperationIDMetadataKey)) case <-time.After(time.Millisecond * 100): t.Fatal("timeout") } toSendCh <- toSend{ID: "2", Err: fmt.Errorf("error 2")} select { case reply := <-replyCh: assert.EqualValues(t, TestCommandResult{ID: "2"}, reply.HandlerResult) require.Error(t, reply.Error) assert.Equal(t, "error 2", reply.Error.Error()) assert.NotEmpty(t, reply.NotificationMessage.Metadata.Get(requestreply.OperationIDMetadataKey)) case <-time.After(time.Millisecond * 100): t.Fatal("timeout") } toSendCh <- toSend{ID: "3", Err: nil} select { case reply := <-replyCh: assert.EqualValues(t, TestCommandResult{ID: "3"}, reply.HandlerResult) require.NoError(t, reply.Error) assert.NotEmpty(t, reply.NotificationMessage.Metadata.Get(requestreply.OperationIDMetadataKey)) case <-time.After(time.Millisecond * 100): t.Fatal("timeout") } } func TestRequestReply_timeout(t *testing.T) { timeout := time.Millisecond * 10 ts := NewTestServices[requestreply.NoResult](t, TestServicesConfig{ ListenForReplyTimeout: &timeout, }) err := ts.CommandProcessor.AddHandlers( requestreply.NewCommandHandler[TestCommand]( "test_handler", ts.RequestReplyBackend, func(ctx context.Context, cmd *TestCommand) error { time.Sleep(time.Second) return nil }, ), ) require.NoError(t, err) ts.RunRouter() replyCh, cancel, err := requestreply.SendWithReplies[requestreply.NoResult]( context.Background(), ts.CommandBus, ts.RequestReplyBackend, &TestCommand{ID: "1"}, ) require.NoError(t, err) require.NotNil(t, replyCh) defer cancel() select { case reply := <-replyCh: assert.Empty(t, reply.HandlerResult) require.Error(t, reply.Error) require.IsType(t, requestreply.ReplyTimeoutError{}, reply.Error) replyTimeoutError := reply.Error.(requestreply.ReplyTimeoutError) assert.Equal(t, context.DeadlineExceeded, replyTimeoutError.Err) assert.NotEmpty(t, replyTimeoutError.Duration) case <-time.After(time.Millisecond * 100): t.Fatal("timeout") } } func TestRequestReply_context_cancellation(t *testing.T) { ts := NewTestServices[struct{}](t, TestServicesConfig{}) err := ts.CommandProcessor.AddHandlers( requestreply.NewCommandHandler[TestCommand]( "test_handler", ts.RequestReplyBackend, func(ctx context.Context, cmd *TestCommand) error { time.Sleep(time.Second) return nil }, ), ) require.NoError(t, err) ts.RunRouter() ctx, cancel := context.WithCancel(context.Background()) replyCh, _, err := requestreply.SendWithReplies[struct{}]( ctx, ts.CommandBus, ts.RequestReplyBackend, &TestCommand{ID: "1"}, ) require.NoError(t, err) require.NotNil(t, replyCh) cancel() select { case reply := <-replyCh: assert.Empty(t, reply.HandlerResult) require.Error(t, reply.Error) require.IsType(t, requestreply.ReplyTimeoutError{}, reply.Error) replyTimeoutError := reply.Error.(requestreply.ReplyTimeoutError) assert.Contains( t, // it depends on which switch will be executed first []string{"subscriber closed", context.Canceled.Error()}, replyTimeoutError.Err.Error(), ) assert.NotEmpty(t, replyTimeoutError.Duration) case <-time.After(time.Millisecond * 100): t.Fatal("timeout") } } func TestRequestReply_fn_cancellation(t *testing.T) { ts := NewTestServices[struct{}](t, TestServicesConfig{}) err := ts.CommandProcessor.AddHandlers( requestreply.NewCommandHandler[TestCommand]( "test_handler", ts.RequestReplyBackend, func(ctx context.Context, cmd *TestCommand) error { time.Sleep(time.Second) return nil }, ), ) require.NoError(t, err) ts.RunRouter() replyCh, cancel, err := requestreply.SendWithReplies[requestreply.NoResult]( context.Background(), ts.CommandBus, ts.RequestReplyBackend, &TestCommand{ID: "1"}, ) require.NoError(t, err) require.NotNil(t, replyCh) cancel() select { case reply := <-replyCh: assert.Empty(t, reply.HandlerResult) require.Error(t, reply.Error) require.IsType(t, requestreply.ReplyTimeoutError{}, reply.Error) replyTimeoutError := reply.Error.(requestreply.ReplyTimeoutError) assert.Contains( t, // it depends on which switch will be executed first []string{"subscriber closed", context.Canceled.Error()}, replyTimeoutError.Err.Error(), ) assert.NotEmpty(t, replyTimeoutError.Duration) case <-time.After(time.Millisecond * 100): t.Fatal("timeout") } } func TestRequestReply_parallel_different_handlers(t *testing.T) { ts := NewTestServices[TestCommandResult](t, TestServicesConfig{ DoNotAckOnCommandErrors: true, }) err := ts.CommandProcessor.AddHandlers( requestreply.NewCommandHandlerWithResult[TestCommand, TestCommandResult]( "test_handler_1", ts.RequestReplyBackend, func(ctx context.Context, cmd *TestCommand) (TestCommandResult, error) { return TestCommandResult{ID: cmd.ID}, fmt.Errorf("error 1 %s", cmd.ID) }, ), ) require.NoError(t, err) err = ts.CommandProcessor.AddHandlers( requestreply.NewCommandHandlerWithResult[TestCommand2, TestCommandResult]( "test_handler_2", ts.RequestReplyBackend, func(ctx context.Context, cmd *TestCommand2) (TestCommandResult, error) { return TestCommandResult{ID: cmd.ID}, fmt.Errorf("error 2 %s", cmd.ID) }, ), ) require.NoError(t, err) ts.RunRouter() start := make(chan struct{}) wg := sync.WaitGroup{} wg.Add(1) go func() { defer wg.Done() <-start cmd := TestCommand{ID: watermill.NewUUID()} replyCh, cancel, err := requestreply.SendWithReplies[TestCommandResult]( context.Background(), ts.CommandBus, ts.RequestReplyBackend, &cmd, ) require.NoError(t, err) require.NotNil(t, replyCh) defer cancel() i := 0 for reply := range replyCh { assert.EqualValues(t, TestCommandResult(cmd), reply.HandlerResult) require.Error(t, reply.Error) assert.Equal(t, fmt.Sprintf("error 1 %s", cmd.ID), reply.Error.Error()) i++ if i > 100 { return } } }() wg.Add(1) go func() { defer wg.Done() <-start cmd := TestCommand2{ID: watermill.NewUUID()} replyCh, cancel, err := requestreply.SendWithReplies[TestCommandResult]( context.Background(), ts.CommandBus, ts.RequestReplyBackend, &cmd, ) require.NoError(t, err) require.NotNil(t, replyCh) defer cancel() i := 0 for reply := range replyCh { assert.EqualValues(t, TestCommandResult(cmd), reply.HandlerResult) require.Error(t, reply.Error) assert.Equal(t, fmt.Sprintf("error 2 %s", cmd.ID), reply.Error.Error()) i++ if i > 100 { return } } }() // sync workers close(start) wg.Wait() } func TestRequestReply_parallel_same_handler(t *testing.T) { ts := NewTestServices[TestCommandResult](t, TestServicesConfig{ DoNotBlockPublishUntilSubscriberAck: true, }) err := ts.CommandProcessor.AddHandlers( requestreply.NewCommandHandlerWithResult[TestCommand, TestCommandResult]( "test_handler", ts.RequestReplyBackend, func(ctx context.Context, cmd *TestCommand) (TestCommandResult, error) { return TestCommandResult{ID: cmd.ID}, nil }, ), ) require.NoError(t, err) ts.RunRouter() count := 20 wg := sync.WaitGroup{} wg.Add(count) start := make(chan struct{}) for i := 0; i < count; i++ { go func() { defer wg.Done() <-start cmd := TestCommand{ID: uuid.NewString()} replyCh, cancel, err := requestreply.SendWithReplies[TestCommandResult]( context.Background(), ts.CommandBus, ts.RequestReplyBackend, &cmd, ) require.NoError(t, err) require.NotNil(t, replyCh) defer cancel() select { case reply := <-replyCh: assert.EqualValues(t, TestCommandResult(cmd), reply.HandlerResult) assert.NoError(t, reply.Error) case <-time.After(time.Millisecond * 100): t.Error("timeout") } }() } // sync workers close(start) wg.Wait() } func TestNewPubSubBackend_missing_values(t *testing.T) { t.Run("invalid_config", func(t *testing.T) { invalidConfig := requestreply.PubSubBackendConfig{} require.Error(t, invalidConfig.Validate()) backend, err := requestreply.NewPubSubBackend[requestreply.NoResult]( invalidConfig, requestreply.BackendPubsubJSONMarshaler[requestreply.NoResult]{}, ) assert.Error(t, err) assert.ErrorContains(t, err, "invalid config") assert.Nil(t, backend) }) t.Run("missing_marshaler", func(t *testing.T) { ts := NewTestServices[struct{}](t, TestServicesConfig{}) require.NoError(t, ts.BackendConfig.Validate()) backend, err := requestreply.NewPubSubBackend[requestreply.NoResult]( ts.BackendConfig, nil, ) assert.Error(t, err) assert.ErrorContains(t, err, "marshaler cannot be nil") assert.Nil(t, backend) }) } ================================================ FILE: components/requeuer/requeuer.go ================================================ package requeuer import ( "context" "errors" "fmt" "strconv" "time" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill/message" ) const RetriesKey = "_watermill_requeuer_retries" // Requeuer is a component that moves messages from one topic to another. // It can be used to requeue messages that failed to process. type Requeuer struct { config Config } // GeneratePublishTopicParams are the parameters passed to the GeneratePublishTopic function. type GeneratePublishTopicParams struct { Message *message.Message } // Config is the configuration for the Requeuer. type Config struct { // Subscriber is the subscriber to consume messages from. Required. Subscriber message.Subscriber // SubscribeTopic is the topic related to the Subscriber to consume messages from. Required. SubscribeTopic string // Publisher is the publisher to publish requeued messages to. Required. Publisher message.Publisher // GeneratePublishTopic is the topic related to the Publisher to publish the requeued message to. // For example, it could be a constant, or taken from the message's metadata. // Required. GeneratePublishTopic func(params GeneratePublishTopicParams) (string, error) // Delay is the duration to wait before requeuing the message. Optional. // The default is no delay. // // This can be useful to avoid requeuing messages too quickly, for example, to avoid // requeuing a message that failed to process due to a temporary issue. // // Avoid setting this to a very high value, as it will block the message processing. Delay time.Duration // Router is the custom router to run the requeue handler on. Optional. Router *message.Router } func (c *Config) setDefaults(logger watermill.LoggerAdapter) error { if c.Router == nil { router, err := message.NewRouter(message.RouterConfig{}, logger) if err != nil { return fmt.Errorf("could not create router: %w", err) } c.Router = router } return nil } func (c *Config) validate() error { if c.Subscriber == nil { return errors.New("subscriber is required") } if c.SubscribeTopic == "" { return errors.New("subscribe topic is required") } if c.Publisher == nil { return errors.New("publisher is required") } if c.GeneratePublishTopic == nil { return errors.New("generate publish topic is required") } return nil } // NewRequeuer creates a new Requeuer with the provided Config. // It's not started automatically. You need to call Run on the returned Requeuer. func NewRequeuer( config Config, logger watermill.LoggerAdapter, ) (*Requeuer, error) { if logger == nil { logger = watermill.NewStdLogger(false, false) } err := config.setDefaults(logger) if err != nil { return nil, err } err = config.validate() if err != nil { return nil, fmt.Errorf("invalid config: %w", err) } r := &Requeuer{ config: config, } config.Router.AddConsumerHandler( "requeuer", config.SubscribeTopic, config.Subscriber, r.handler, ) return r, nil } func (r *Requeuer) handler(msg *message.Message) error { if r.config.Delay > 0 { select { case <-msg.Context().Done(): return msg.Context().Err() case <-time.After(r.config.Delay): } } topic, err := r.config.GeneratePublishTopic(GeneratePublishTopicParams{Message: msg}) if err != nil { return err } retriesStr := msg.Metadata.Get(RetriesKey) retries, err := strconv.Atoi(retriesStr) if err != nil { retries = 0 } retries++ msg.Metadata.Set(RetriesKey, strconv.Itoa(retries)) err = r.config.Publisher.Publish(topic, msg) if err != nil { return err } return nil } // Run runs the Requeuer. func (r *Requeuer) Run(ctx context.Context) error { return r.config.Router.Run(ctx) } ================================================ FILE: components/requeuer/requeuer_test.go ================================================ package requeuer_test import ( "context" "errors" "fmt" "strconv" "sync" "testing" "time" "github.com/stretchr/testify/require" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill/components/requeuer" "github.com/ThreeDotsLabs/watermill/message" "github.com/ThreeDotsLabs/watermill/message/router/middleware" "github.com/ThreeDotsLabs/watermill/pubsub/gochannel" ) func TestRequeue(t *testing.T) { logger := watermill.NewStdLogger(false, false) pubSub := gochannel.NewGoChannel(gochannel.Config{}, logger) requeue, err := requeuer.NewRequeuer(requeuer.Config{ Subscriber: pubSub, SubscribeTopic: "requeue", Publisher: pubSub, GeneratePublishTopic: func(params requeuer.GeneratePublishTopicParams) (string, error) { return "test", nil }, Delay: time.Millisecond * 200, }, logger) require.NoError(t, err) go func() { err := requeue.Run(context.Background()) require.NoError(t, err) }() router, err := message.NewRouter(message.RouterConfig{}, logger) require.NoError(t, err) pq, err := middleware.PoisonQueue(pubSub, "requeue") require.NoError(t, err) router.AddMiddleware(pq) receivedMessages := make(chan int, 10) lock := sync.Mutex{} counter := 0 router.AddConsumerHandler( "test", "test", pubSub, func(msg *message.Message) error { i, err := strconv.Atoi(string(msg.Payload)) if err != nil { return err } lock.Lock() defer lock.Unlock() counter++ if counter < 10 && i%2 == 0 { return errors.New("error") } receivedMessages <- i return nil }, ) go func() { err := router.Run(context.Background()) require.NoError(t, err) }() time.Sleep(time.Second) for i := 0; i < 10; i++ { msg := message.NewMessage(watermill.NewUUID(), fmt.Append(nil, i)) err := pubSub.Publish("test", msg) require.NoError(t, err) } var received []int timeout := false for !timeout { select { case i := <-receivedMessages: received = append(received, i) case <-time.After(5 * time.Second): timeout = true break } } require.ElementsMatch(t, []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}, received) } ================================================ FILE: dev/consolidate-gomods/main.go ================================================ package main import ( "bufio" "fmt" "os" "path/filepath" "strings" ) // simple script to consolidate all gomods to one gomod // required for GolangCI linter func main() { bigFatGomod := "" for _, fileName := range getGomods() { dir := filepath.Dir(fileName) if dir == "." { continue } file, err := os.Open(fileName) if err != nil { panic(err) } fileMod := "" scanner := bufio.NewScanner(file) for scanner.Scan() { txt := scanner.Text() if strings.HasPrefix(txt, "go ") { continue } if strings.HasPrefix(txt, "module ") { continue } fileMod += txt + "\n" } if err := scanner.Err(); err != nil { panic(err) } if fileMod != "" { bigFatGomod += "// " + fileName + "\n" bigFatGomod += fileMod + "\n" } _ = file.Close() // gomod is stupid, and go vendor removes all deps that are not needed // (and they are not needed if they are already meet in sub go.mods) if err := os.Remove(fileName); err != nil { panic(err) } } fmt.Println(bigFatGomod) } func getGomods() []string { var fileList []string err := filepath.Walk(".", func(path string, f os.FileInfo, err error) error { if strings.Contains(path, "/vendor/") { return nil } if strings.Contains(path, "go.mod") { fileList = append(fileList, path) } return nil }) if err != nil { panic(err) } return fileList } ================================================ FILE: dev/coverage.sh ================================================ #!/bin/sh ######## # Source: https://gist.github.com/lwolf/3764a3b6cd08387e80aa6ca3b9534b8a # originally from https://github.com/mlafeldt/chef-runner/blob/v0.7.0/script/coverage ####### # Generate test coverage statistics for Go packages. # # Works around the fact that `go test -coverprofile` currently does not work # with multiple packages, see https://code.google.com/p/go/issues/detail?id=6909 # # Usage: script/coverage [--html|--coveralls] # # --html Additionally create HTML report and open it in browser # --coveralls Push coverage statistics to coveralls.io # set -e workdir=.cover profile="$workdir/cover.out" mode=count generate_cover_data() { rm -rf "$workdir" mkdir "$workdir" for pkg in "$@"; do f="$workdir/$(echo $pkg | tr / -).cover" go test -covermode="$mode" -coverprofile="$f" "$pkg" done echo "mode: $mode" >"$profile" grep -h -v "^mode:" "$workdir"/*.cover >>"$profile" } show_cover_report() { go tool cover -${1}="$profile" } push_to_coveralls() { echo "Pushing coverage statistics to coveralls.io" goveralls -coverprofile="$profile" } generate_cover_data $(go list ./... | grep -v /vendor/) show_cover_report func case "$1" in "") ;; --html) show_cover_report html ;; --coveralls) push_to_coveralls ;; *) echo >&2 "error: invalid option: $1"; exit 1 ;; esac ================================================ FILE: dev/prometheus.yml ================================================ # for Watermill development purposes. # there is one sample scrape target; add your own if needed. global: scrape_interval: 15s evaluation_interval: 15s scrape_configs: - job_name: 'prometheus' static_configs: - targets: ['localhost:9090'] - job_name: 'some_metrics_endpoint' static_configs: - targets: ['localhost:8080'] ================================================ FILE: dev/update-examples-deps/go.mod ================================================ module github.com/ThreeDotsLabs/watermill/dev/update-examples-deps go 1.25 toolchain go1.23.4 ================================================ FILE: dev/update-examples-deps/go.sum ================================================ ================================================ FILE: dev/update-examples-deps/main.go ================================================ package main import ( "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "regexp" "strings" "sync" ) var latestGoVersion string func main() { latestGoVersion = getLatestGoVersionFromWebsite() const workers = 5 wg := sync.WaitGroup{} wg.Add(workers) files := make(chan string) for i := 0; i < workers; i++ { go func() { for file := range files { dir := filepath.Dir(file) if dir == "." { continue } fmt.Println("update of", file, "@", dir) if err := replaceGoInDockerCompose(dir); err != nil { panic(err) } if err := updateDeps(dir, file); err != nil { panic(err) } if err := updateWatermill(dir, file); err != nil { panic(fmt.Sprintf("failed to update %s: %s", file, err)) } if err := goModTidy(dir, file); err != nil { panic(err) } } wg.Done() }() } for _, file := range getGomods() { files <- file } close(files) wg.Wait() } func getGomods() []string { var fileList []string err := filepath.Walk(".", func(path string, f os.FileInfo, err error) error { if strings.Contains(path, "go.mod") { fileList = append(fileList, path) } return nil }) if err != nil { panic(err) } return fileList } func getLatestGoVersionFromWebsite() string { resp, err := http.Get("https://go.dev/VERSION?m=text") if err != nil { panic(err) } defer resp.Body.Close() out, err := io.ReadAll(resp.Body) if err != nil { panic(err) } version := strings.Split(string(out), "\n")[0] version = strings.TrimPrefix(version, "go") // we only want the major.minor version version = strings.Split(version, ".")[0] + "." + strings.Split(version, ".")[1] return version } func goModTidy(dir string, file string) error { cmd := []string{"go", "mod", "tidy", "-go=" + latestGoVersion} fmt.Println("\nrunning", cmd, "in", dir) cmd2 := exec.Command(cmd[0], cmd[1:]...) cmd2.Dir = dir cmd2.Stderr = os.Stderr cmd2.Stdout = os.Stdout return cmd2.Run() } // replaceGoInDockerCompose replaces the go version in the Dockerfile // using Go (not sed) func replaceGoInDockerCompose(dir string) error { dockerComposeFile := filepath.Join(dir, "docker-compose.yml") b, err := os.ReadFile(dockerComposeFile) // return if not exist if os.IsNotExist(err) { return nil } if err != nil { return err } pattern := `golang:1\.[0-9]+(?:\.[0-9]+)?` re, err := regexp.Compile(pattern) if err != nil { return fmt.Errorf("failed to compile regex: %w", err) } newContent := re.ReplaceAllString(string(b), "golang:"+latestGoVersion) err = os.WriteFile(dockerComposeFile, []byte(newContent), 0644) if err != nil { return fmt.Errorf("failed to write updated docker-compose.yml: %w", err) } return nil } func updateWatermill(dir string, file string) error { c := []string{"go", "get", "-u", "github.com/ThreeDotsLabs/watermill@latest"} fmt.Println("\nrunning", c, "in", dir) cmd := exec.Command(c[0], c[1:]...) cmd.Dir = dir cmd.Stderr = os.Stderr cmd.Stdout = os.Stdout err := cmd.Run() return err } func updateDeps(dir string, file string) error { c := []string{"go", "get", "-u", "./..."} fmt.Println("\nrunning", c, "in", dir) cmd := exec.Command(c[0], c[1:]...) cmd.Dir = dir cmd.Stderr = os.Stderr cmd.Stdout = os.Stdout err := cmd.Run() return err } ================================================ FILE: dev/validate-examples/go.mod ================================================ module github.com/ThreeDotsLabs/watermill/dev/validate-examples go 1.25 require ( github.com/fatih/color v1.18.0 gopkg.in/yaml.v2 v2.4.0 ) require ( github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect golang.org/x/sys v0.35.0 // indirect ) ================================================ FILE: dev/validate-examples/go.sum ================================================ github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= ================================================ FILE: dev/validate-examples/main.go ================================================ package main import ( "bufio" "fmt" "io" "os" "os/exec" "path/filepath" "regexp" "strings" "time" "github.com/fatih/color" yaml "gopkg.in/yaml.v2" ) type Config struct { ValidationCmd string `yaml:"validation_cmd"` TeardownCmd string `yaml:"teardown_cmd"` Timeout int `yaml:"timeout"` ExpectedOutput string `yaml:"expected_output"` ExpectedOutputs []string `yaml:"expected_outputs"` } func (c *Config) LoadFrom(path string) error { file, err := os.ReadFile(path) if err != nil { return err } err = yaml.Unmarshal(file, c) if err != nil { return err } return nil } func main() { path := "../../_examples/" if len(os.Args) > 1 { path = filepath.Join(path, os.Args[1]) } walkErr := filepath.Walk(path, func(exampleConfig string, f os.FileInfo, _ error) error { if f == nil { return nil } matches, err := filepath.Match(".validate_example*.yml", f.Name()) if err != nil { return fmt.Errorf("could not match file, err: %w", err) } if !matches { return nil } exampleDirectory := filepath.Dir(exampleConfig) fmt.Printf("validating %s\n", exampleDirectory) err = validate(exampleConfig) if err != nil { return fmt.Errorf("validation for %s failed, err: %v", exampleDirectory, err) } return nil }) if walkErr != nil { panic(walkErr) } } func validate(path string) error { config := &Config{} err := config.LoadFrom(path) if err != nil { return fmt.Errorf("could not load config, err: %v", err) } dirName := filepath.Base(filepath.Dir(path)) expectedOutputs := config.ExpectedOutputs if config.ExpectedOutput != "" { expectedOutputs = append(expectedOutputs, config.ExpectedOutput) } fmt.Print("\n\n") fmt.Println("Validating example:", dirName) fmt.Println("Waiting for output: ", color.GreenString(fmt.Sprintf("%+q", expectedOutputs))) cmdAndArgs := strings.Fields(config.ValidationCmd) validationCmd := exec.Command(cmdAndArgs[0], cmdAndArgs[1:]...) validationCmd.Dir = filepath.Dir(path) defer func() { if config.TeardownCmd == "" { return } cmdAndArgs := strings.Fields(config.TeardownCmd) teardownCmd := exec.Command(cmdAndArgs[0], cmdAndArgs[1:]...) teardownCmd.Dir = filepath.Dir(path) _ = teardownCmd.Run() }() stdout, err := validationCmd.StdoutPipe() if err != nil { return fmt.Errorf("could not attach to stdout, err: %v", err) } stderr, err := validationCmd.StderrPipe() if err != nil { return fmt.Errorf("could not attach to stderr, err: %v", err) } fmt.Printf("running: %v\n", validationCmd.Args) err = validationCmd.Start() if err != nil { return fmt.Errorf("could not start validation, err: %v", err) } defer func() { err := validationCmd.Process.Kill() if err != nil { fmt.Printf("could not kill process in %s, err: %v\n", dirName, err) } }() success := make(chan bool) lines := make(chan string) go readLines(stdout, lines) go readLines(stderr, lines) outputsFound := map[int]struct{}{} go func() { for line := range lines { fmt.Printf("[%s] > %s\n", color.CyanString(dirName), line) for num, output := range expectedOutputs { ok, _ := regexp.MatchString(output, line) if ok { outputsFound[num] = struct{}{} } } if len(outputsFound) == len(expectedOutputs) { success <- true return } } }() select { case <-success: return nil case <-time.After(time.Duration(config.Timeout) * time.Second): return fmt.Errorf("validation command timed out") } } func readLines(reader io.Reader, output chan<- string) { scanner := bufio.NewScanner(reader) for scanner.Scan() { if scanner.Err() != nil { if scanner.Err() == io.EOF { return } continue } line := scanner.Text() output <- line } } ================================================ FILE: doc.go ================================================ // Watermill is a Golang library for working efficiently with message streams. // // It is intended for building event driven applications, // enabling event sourcing, RPC over messages, sagas // and basically whatever else comes to your mind. // // You can use conventional pub/sub implementations // like Kafka or RabbitMQ, but also HTTP or MySQL binlog if that fits your use case. // // Website with detailed documentation: https://watermill.io/ // // Getting started guide: https://watermill.io/learn/getting-started/ package watermill ================================================ FILE: docs/.npmignore ================================================ .env .netlify .hugo_build.lock node_modules public resources ================================================ FILE: docs/.npmrc ================================================ enable-pre-post-scripts=true auto-install-peers=true node-linker=hoisted prefer-symlinked-executables=false ================================================ FILE: docs/.prettierignore ================================================ *.html *.ico *.png *.jp*g *.toml *.*ignore *.svg *.xml LICENSE .npmrc .gitkeep *.woff* ================================================ FILE: docs/.prettierrc.yaml ================================================ # Default config tabWidth: 4 endOfLine: crlf singleQuote: true printWidth: 100000 trailingComma: none bracketSameLine: true quoteProps: consistent experimentalTernaries: true # Overridden config overrides: - files: ["*.md", "*.json", "*.yaml"] options: tabWidth: 2 singleQuote: false - files: ["*.scss"] options: singleQuote: false ================================================ FILE: docs/DEVELOP.md ================================================ ## How to Develop watermill.io docs? ### Building & running ```bash ./build.sh npm run dev ``` ### Useful resources - [Available shortcodes](https://getdoks.org/docs/basics/shortcodes/) - [Diagrams](https://getdoks.org/docs/built-ins/diagrams/) (we recommend [Mermaid](https://getdoks.org/docs/built-ins/diagrams/#mermaid)) - [Codeglocks](https://getdoks.org/docs/built-ins/code-blocks/) ================================================ FILE: docs/assets/images/.gitkeep ================================================ ================================================ FILE: docs/assets/js/custom.js ================================================ // Put your custom JS code here // a bit hacky way to force dark mode by default // it sets local storage item used by docs/node_modules/@thulite/doks-core/assets/js/color-mode.js if (!localStorage.getItem('theme')) { localStorage.setItem('theme', 'dark'); } import { render } from 'github-buttons'; let renderGitHubButton= () => { let oldButton = document.getElementById("github-button"); if (oldButton) { oldButton.remove(); } let options = { "href": "https://github.com/ThreeDotsLabs/watermill", "data-show-count": true, "data-size": "large", "data-color-scheme": localStorage.getItem('theme'), } render(options, function (el) { let menu = document.getElementById("offcanvasNavMain").querySelector(".offcanvas-body"); let searchToggle = document.getElementById("searchToggleDesktop"); el.setAttribute("id", "github-button"); el.classList.add("nav-link", "px-2", "mx-auto"); el.setAttribute("style", "margin-top: 12px;"); menu.insertBefore(el, searchToggle); }) } renderGitHubButton() ================================================ FILE: docs/assets/jsconfig.json ================================================ { "compilerOptions": { "baseUrl": ".", "paths": { "*": ["*", "..\\node_modules\\@thulite\\doks-core\\assets\\*"] } } } ================================================ FILE: docs/assets/scss/common/_custom.scss ================================================ // Put your custom SCSS code here * { -webkit-font-smoothing: antialiased; } h1, h2, h3, h4, h5, .navbar-brand { font-family: Quicksand, sans-serif; font-weight: 700; } [data-bs-theme="dark"] .only-light { display: none; } [data-bs-theme="light"] .only-dark { display: none; } .homepage-features { .row { max-width: 1400px; margin: 0 auto; } } // Improved text alignment and spacing .text-center.text-lg-start { @media (min-width: 992px) { padding-right: 2rem; } } // Learn page styling .learn-container { max-width: 1200px; margin: 0 auto; padding: 3rem 1rem; } .learn-header { text-align: center; margin-bottom: 4rem; } .learn-main-title { font-size: 3rem; font-weight: 700; margin-bottom: 1rem; color: var(--bs-body-color); } .learn-subtitle { font-size: 1.25rem; color: var(--bs-body-color-secondary); max-width: 600px; margin: 0 auto; line-height: 1.6; } .learn-section { margin-bottom: 4rem; } .learn-section:last-child { margin-bottom: 0; } .learn-section-title { text-align: center; font-size: 2rem; font-weight: 600; margin-bottom: 3rem; color: var(--bs-body-color); } .learn-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 2rem; justify-items: center; } .learn-card { display: flex; flex-direction: column; padding: 2rem; border-radius: 0.75rem; transition: all 0.3s ease; text-decoration: none; background: var(--bs-body-bg); border: 1px solid var(--bs-border-color); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); min-height: 280px; width: 100%; max-width: 350px; text-align: center; color: inherit; cursor: pointer; position: relative; overflow: hidden; } .learn-card:hover { transform: translateY(-2px); box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1); text-decoration: none; color: inherit; border-color: var(--bs-primary); } .learn-card:focus { outline: 2px solid var(--bs-primary); outline-offset: 2px; } .learn-card-icon { margin: 0 auto 0; color: var(--bs-primary); display: flex; justify-content: center; align-items: center; width: 100px; height: 100px; } .learn-card h3 { font-size: 1.25rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--bs-body-color); margin-top: 0; } .learn-card-secondary { display: block; font-size: 0.9rem; font-weight: 600; color: var(--bs-gray-600); margin-bottom: 1rem; } .learn-card p { font-size: 0.95rem; line-height: 1.6; margin-bottom: 2rem; flex-grow: 1; color: var(--bs-body-color-secondary); } // Dark theme support for learn cards [data-bs-theme="dark"] .learn-card { background: var(--bs-body-bg); border-color: var(--bs-border-color); } [data-bs-theme="dark"] .learn-card:hover { border-color: var(--bs-primary); } // Responsive grid for learn page @media (max-width: 767.98px) { .learn-container { padding: 2rem 1rem; } .learn-header { margin-bottom: 3rem; } .learn-main-title { font-size: 2.5rem; } .learn-subtitle { font-size: 1.1rem; } .learn-section-title { font-size: 1.5rem; margin-bottom: 2rem; } .learn-row { grid-template-columns: 1fr; gap: 1.5rem; } .learn-card { padding: 1.5rem; min-height: 240px; max-width: none; } } @media (min-width: 768px) and (max-width: 1199.98px) { .learn-row { grid-template-columns: repeat(2, 1fr); } .learn-section:first-child .learn-row { grid-template-columns: repeat(2, 1fr); } .learn-section:first-child .learn-row .learn-card:nth-child(3) { grid-column: 1 / -1; justify-self: center; } } // Quickstart page styling .quickstart-container { max-width: 800px; margin: 0 auto; padding: 3rem 1rem; } .quickstart-header { text-align: center; margin-bottom: 3rem; } .quickstart-main-title { font-size: 3rem; font-weight: 700; margin-bottom: 1rem; color: var(--bs-body-color); } .quickstart-subtitle { font-size: 1.25rem; color: var(--bs-body-color-secondary); max-width: 600px; margin: 0 auto; line-height: 1.6; } .quickstart-content { font-size: 1.1rem; line-height: 1.8; } // Hide footer banner on quickstart page too body.quickstart .event-driven-banner { display: none; } // Responsive styling for quickstart @media (max-width: 767.98px) { .quickstart-container { padding: 2rem 1rem; } .quickstart-header { margin-bottom: 2rem; } .quickstart-main-title { font-size: 2.5rem; } .quickstart-subtitle { font-size: 1.1rem; } } // Responsive improvements @media (max-width: 991.98px) { .display-5 { font-size: 2rem; } .text-center.text-lg-start { text-align: center !important; padding-right: 0; } } // Pub/Sub Logos Collage Styling .pubsub-logos-container { padding: 2rem 0; } .pubsub-logos-grid { display: flex; flex-wrap: wrap; gap: 2rem; max-width: 900px; margin: 0 auto; align-items: center; justify-content: center; } .pubsub-logo-item { display: flex; flex-direction: column; align-items: center; text-align: center; transition: all 0.3s ease; padding: 1rem; border-radius: 0.75rem; text-decoration: none; color: inherit; flex: 0 0 120px; img { width: 60px; height: 60px; margin-bottom: 0.75rem; transition: all 0.3s ease; } span { font-size: 0.9rem; font-weight: 600; color: var(--bs-body-color-secondary); transition: all 0.3s ease; } &:hover { background: rgba(var(--bs-primary-rgb), 0.05); text-decoration: none; color: inherit; img { } span { color: var(--bs-primary); } } &:focus { outline: 2px solid var(--bs-primary); outline-offset: 2px; text-decoration: none; color: inherit; } } // Dark theme support for pub/sub logos [data-bs-theme="dark"] .pubsub-logo-item { &:hover { background: rgba(var(--bs-primary-rgb), 0.1); } } // Responsive adjustments for pub/sub logos @media (max-width: 767.98px) { .pubsub-logos-grid { grid-template-columns: repeat(3, 1fr); gap: 1.5rem; max-width: 100%; } .pubsub-logo-item { padding: 0.75rem; img { width: 50px; height: 50px; margin-bottom: 0.5rem; } span { font-size: 0.8rem; } } } @media (max-width: 575.98px) { .pubsub-logos-grid { grid-template-columns: repeat(2, 1fr); gap: 1rem; } .pubsub-logo-item { img { width: 45px; height: 45px; } span { font-size: 0.75rem; } } } // Homepage code blocks styling .homepage-code-block { .frame { border-radius: 12px !important; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1), 0 2px 4px rgba(0, 0, 0, 0.06) !important; border: 1px solid var(--bs-border-color) !important; overflow: hidden; overflow-x: auto; // Allow horizontal scrolling when needed // Ensure child elements respect the border radius * { border-radius: inherit !important; } // Target the actual code container pre, code, .expressive-code-block { border-radius: 12px !important; border: none !important; } // Target any highlighted code elements .highlight { border-radius: 12px !important; border: none !important; } } } [data-bs-theme="light"] { .homepage-code-block .frame pre, .homepage-code-block .frame code, .homepage-code-block .frame .expressive-code-block { background: white !important; } } // Responsive design for homepage code blocks @media (max-width: 767.98px) { .homepage-code-block { margin-left: -45px; margin-right: -45px; } } // Dark theme support for homepage code blocks [data-bs-theme="dark"] .homepage-code-block { .frame { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3), 0 2px 4px rgba(0, 0, 0, 0.2) !important; border: 1px solid var(--bs-border-color) !important; } } // Modern gradient text styles .gradient-text-1 { background: linear-gradient(to right, #6366f1, #8b5cf6, #3b82f6); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; color: transparent; } // Homepage gradient section backgrounds .homepage-section-gradient { position: relative; &::before { content: ''; position: absolute; top: 0; left: 50%; transform: translateX(-50%); width: 100vw; height: 100%; border-top: 1px solid rgba(0, 0, 0, 0.08); pointer-events: none; z-index: -1; } } // Single gradient class for all sections .homepage-section-gradient { &::before { background: linear-gradient(180deg, rgba(79, 70, 229, 0.03) 0%, transparent 100% ); } } // Dark theme adjustments for gradients [data-bs-theme="dark"] { .homepage-section-gradient::before { background: linear-gradient(180deg, rgba(79, 70, 229, 0.06) 0%, transparent 100% ); border-top: 1px solid rgba(255, 255, 255, 0.08); } } [data-bs-theme="dark"], [data-bs-theme="light"] { /* Background */ .bg { color: #c9c9c9; background-color: #282c34; } /* PreWrapper */ .chroma { color: #c9c9c9; background-color: #282c34; } /* Other */ .chroma .x { } /* Error */ .chroma .err { color: #cf5967 } /* CodeLine */ .chroma .cl { } /* LineLink */ .chroma .lnlinks { outline: none; text-decoration: none; color: inherit } /* LineTableTD */ .chroma .lntd { vertical-align: top; padding: 0; margin: 0; border: 0; } /* LineTable */ .chroma .lntable { border-spacing: 0; padding: 0; margin: 0; border: 0; } /* LineHighlight */ .chroma .hl { background-color: #3d4148 } /* LineNumbersTable */ .chroma .lnt { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #7f7f7f } /* LineNumbers */ .chroma .ln { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #7f7f7f } /* Line */ .chroma .line { display: flex; } /* Keyword */ .chroma .k { color: #7fbaf5 } /* KeywordConstant */ .chroma .kc { color: #cf5967; } /* KeywordDeclaration */ .chroma .kd { color: #7fbaf5 } /* KeywordNamespace */ .chroma .kn { color: #bc74c4 } /* KeywordPseudo */ .chroma .kp { color: #bc74c4 } /* KeywordReserved */ .chroma .kr { color: #7fbaf5 } /* KeywordType */ .chroma .kt { color: #57c7ff; font-weight: bold } /* Name */ .chroma .n { } /* NameAttribute */ .chroma .na { color: #bc74c4 } /* NameBuiltin */ .chroma .nb { color: #7fbaf5 } /* NameBuiltinPseudo */ .chroma .bp { color: #7fbaf5 } /* NameClass */ .chroma .nc { color: #ecbe7b } /* NameConstant */ .chroma .no { color: #ecbe7b } /* NameDecorator */ .chroma .nd { color: #ecbe7b } /* NameEntity */ .chroma .ni { } /* NameException */ .chroma .ne { color: #cf5967 } /* NameFunction */ .chroma .nf { color: #57c7ff } /* NameFunctionMagic */ .chroma .fm { } /* NameLabel */ .chroma .nl { color: #cf5967 } /* NameNamespace */ .chroma .nn { } /* NameOther */ .chroma .nx { } /* NameProperty */ .chroma .py { } /* NameTag */ .chroma .nt { color: #bc74c4 } /* NameVariable */ .chroma .nv { color: #bc74c4; font-style: italic } /* NameVariableClass */ .chroma .vc { color: #57c7ff; font-weight: bold } /* NameVariableGlobal */ .chroma .vg { color: #ecbe7b } /* NameVariableInstance */ .chroma .vi { color: #57c7ff } /* NameVariableMagic */ .chroma .vm { } /* Literal */ .chroma .l { } /* LiteralDate */ .chroma .ld { color: #57c7ff } /* LiteralString */ .chroma .s { color: #82cc6a } /* LiteralStringAffix */ .chroma .sa { color: #82cc6a } /* LiteralStringBacktick */ .chroma .sb { color: #57c7ff } /* LiteralStringChar */ .chroma .sc { color: #57c7ff } /* LiteralStringDelimiter */ .chroma .dl { color: #82cc6a } /* LiteralStringDoc */ .chroma .sd { color: #82cc6a } /* LiteralStringDouble */ .chroma .s2 { color: #82cc6a } /* LiteralStringEscape */ .chroma .se { color: #56b6c2 } /* LiteralStringHeredoc */ .chroma .sh { color: #56b6c2 } /* LiteralStringInterpol */ .chroma .si { color: #82cc6a } /* LiteralStringOther */ .chroma .sx { color: #82cc6a } /* LiteralStringRegex */ .chroma .sr { color: #57c7ff } /* LiteralStringSingle */ .chroma .s1 { color: #82cc6a } /* LiteralStringSymbol */ .chroma .ss { color: #82cc6a } /* LiteralNumber */ .chroma .m { color: #56b6c2 } /* LiteralNumberBin */ .chroma .mb { color: #57c7ff } /* LiteralNumberFloat */ .chroma .mf { color: #56b6c2 } /* LiteralNumberHex */ .chroma .mh { color: #57c7ff } /* LiteralNumberInteger */ .chroma .mi { color: #56b6c2 } /* LiteralNumberIntegerLong */ .chroma .il { color: #56b6c2 } /* LiteralNumberOct */ .chroma .mo { color: #57c7ff } /* Operator */ .chroma .o { color: #bc74c4 } /* OperatorWord */ .chroma .ow { color: #bc74c4 } /* Punctuation */ .chroma .p { color: #56b6c2 } /* Comment */ .chroma .c { color: #3e4460 } /* CommentHashbang */ .chroma .ch { color: #3e4460; font-style: italic } /* CommentMultiline */ .chroma .cm { color: #3e4460 } /* CommentSingle */ .chroma .c1 { color: #3e4460 } /* CommentSpecial */ .chroma .cs { color: #bc74c4; font-style: italic } /* CommentPreproc */ .chroma .cp { color: #7fbaf5 } /* CommentPreprocFile */ .chroma .cpf { color: #7fbaf5 } /* Generic */ .chroma .g { } /* GenericDeleted */ .chroma .gd { color: #cf5967 } /* GenericEmph */ .chroma .ge { text-decoration: underline } /* GenericError */ .chroma .gr { color: #cf5967; font-weight: bold } /* GenericHeading */ .chroma .gh { color: #ecbe7b; font-weight: bold } /* GenericInserted */ .chroma .gi { color: #ecbe7b } /* GenericOutput */ .chroma .go { color: #43454f } /* GenericPrompt */ .chroma .gp { } /* GenericStrong */ .chroma .gs { color: #cf5967; font-weight: bold } /* GenericSubheading */ .chroma .gu { color: #cf5967; font-style: italic } /* GenericTraceback */ .chroma .gt { } /* GenericUnderline */ .chroma .gl { text-decoration: underline } /* TextWhitespace */ .chroma .w { } } ================================================ FILE: docs/assets/scss/common/_variables-custom.scss ================================================ // Put your custom SCSS variables here $font-family-sans-serif: "Heebo", "sans-serif", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; $container-max-widths: ( sm: 540px, md: 720px, lg: 960px, xl: 1240px, xxl: 1820px ); $primary: #4f46e5; ================================================ FILE: docs/assets/svgs/.gitkeep ================================================ ================================================ FILE: docs/build.sh ================================================ #!/bin/bash set -e -x cd "$(dirname "$0")" function cloneOrPull() { if [[ -d "$2" ]] then pushd $2 git pull popd else git clone --single-branch $1 $2 fi } if [[ "$1" == "--copy" ]]; then rm -rf content/src-link || true mkdir content/src-link/ cp -r ../message content/src-link/ cp -r ../pubsub content/src-link/ cp -r ../_examples content/src-link/ cp -r ../components content/src-link/ else declare -a files_to_link=( "_examples" "message/decorator.go" "message/message.go" "message/pubsub.go" "message/router.go" "message/router_context.go" "pubsub/gochannel/pubsub.go" "pubsub/gochannel/fanout.go" "components/cqrs/command_bus.go" "components/cqrs/command_processor.go" "components/cqrs/command_handler.go" "components/cqrs/event_bus.go" "components/cqrs/event_processor.go" "components/cqrs/event_processor_group.go" "components/cqrs/event_handler.go" "components/cqrs/marshaler.go" "components/cqrs/cqrs.go" "components/cqrs/marshaler.go" "components/delay/delay.go" "components/delay/publisher.go" "components/requeuer/requeuer.go" "components/metrics/builder.go" "components/metrics/http.go" "components/fanin/fanin.go" ) pushd ../ for i in "${files_to_link[@]}" do DIR=$(dirname "${i}") DEST_DIR="docs/content/src-link/${DIR}" mkdir -p "${DEST_DIR}" ln -sf "$PWD/${i}" "$PWD/${DEST_DIR}" done popd fi cloneOrPull "https://github.com/ThreeDotsLabs/watermill-amqp.git" content/src-link/watermill-amqp cloneOrPull "https://github.com/ThreeDotsLabs/watermill-googlecloud.git" content/src-link/watermill-googlecloud cloneOrPull "https://github.com/ThreeDotsLabs/watermill-http.git" content/src-link/watermill-http cloneOrPull "https://github.com/ThreeDotsLabs/watermill-io.git" content/src-link/watermill-io cloneOrPull "https://github.com/ThreeDotsLabs/watermill-kafka.git" content/src-link/watermill-kafka cloneOrPull "https://github.com/ThreeDotsLabs/watermill-nats.git" content/src-link/watermill-nats cloneOrPull "https://github.com/ThreeDotsLabs/watermill-sql.git" content/src-link/watermill-sql cloneOrPull "https://github.com/ThreeDotsLabs/watermill-firestore.git" content/src-link/watermill-firestore cloneOrPull "https://github.com/ThreeDotsLabs/watermill-bolt.git" content/src-link/watermill-bolt cloneOrPull "https://github.com/ThreeDotsLabs/watermill-redisstream.git" content/src-link/watermill-redisstream cloneOrPull "https://github.com/ThreeDotsLabs/watermill-aws.git" content/src-link/watermill-aws cloneOrPull "https://github.com/ThreeDotsLabs/watermill-sqlite.git" content/src-link/watermill-sqlite find content/src-link -name '*.md' -delete find content/src-link -name '*.html' -delete python3 ./extract_middleware_godocs.py > content/src-link/middleware-defs.md hugo --gc --minify ================================================ FILE: docs/config/_default/hugo.toml ================================================ title = "Watermill" baseurl = "http://localhost/" canonifyURLs = false disableAliases = true disableHugoGeneratorInject = true # disableKinds = ["taxonomy", "term"] enableEmoji = true enableGitInfo = false enableRobotsTXT = true languageCode = "en-US" paginate = 10 rssLimit = 10 summarylength = 20 # 70 (default) # Multilingual defaultContentLanguage = "en" disableLanguages = [] defaultContentLanguageInSubdir = false copyRight = "Three Dots Labs" [build.buildStats] enable = true [outputs] home = ["HTML", "RSS", "searchIndex"] section = ["HTML", "RSS", "SITEMAP"] [outputFormats.searchIndex] mediaType = "application/json" baseName = "search-index" isPlainText = true notAlternative = true # Add output format for section sitemap.xml [outputFormats.SITEMAP] mediaType = "application/xml" baseName = "sitemap" isHTML = false isPlainText = true noUgly = true rel = "sitemap" [sitemap] changefreq = "monthly" filename = "sitemap.xml" priority = 0.5 [caches] [caches.getjson] dir = ":cacheDir/:project" maxAge = -1 # "30m" [taxonomies] contributor = "contributors" category = "categories" tag = "tags" [minify.tdewolff.html] keepWhitespace = false [related] threshold = 80 includeNewer = true toLower = false [[related.indices]] name = "categories" weight = 100 [[related.indices]] name = "tags" weight = 80 [[related.indices]] name = "date" weight = 10 [imaging] anchor = "Center" bgColor = "#ffffff" hint = "photo" quality = 85 resampleFilter = "Lanczos" ================================================ FILE: docs/config/_default/languages.toml ================================================ [en] languageName = "English" contentDir = "content/en" weight = 10 [en.params] languageISO = "EN" languageTag = "en-US" footer = '' alertText = '' ================================================ FILE: docs/config/_default/markup.toml ================================================ defaultMarkdownHandler = "goldmark" [goldmark] [goldmark.extensions] linkify = true [goldmark.parser] autoHeadingID = true autoHeadingIDType = "github" [goldmark.parser.attribute] block = true title = true [goldmark.renderer] unsafe = true [highlight] anchorLineNos = false codeFences = true guessSyntax = false hl_Lines = '' hl_inline = false lineAnchors = '' lineNoStart = 1 lineNos = false lineNumbersInTable = false noClasses = false noHl = false style = 'vulcan' tabWidth = 2 [tableOfContents] endLevel = 3 ordered = false startLevel = 2 ================================================ FILE: docs/config/_default/menus/menus.en.toml ================================================ [[main]] name = "Learn" url = "/learn/" weight = 5 [[main]] name = "Docs" url = "/learn/getting-started/" weight = 10 [[main]] name = "Support" url = "/support" weight = 40 [[social]] name = "GitHub" pre = '' url = "https://github.com/ThreeDotsLabs/watermill" post = "v0.1.0" weight = 30 [[sidebar]] name = "Learn" pageRef = "/learn" weight = 5 [[sidebar]] name = "Basics" pageRef = "/docs" weight = 10 [[sidebar]] name = "Advanced Topics" pageRef = "/advanced" weight = 20 [[sidebar]] name = "Supported Pub/Subs" pageRef = "/pubsubs" weight = 30 [[sidebar]] name = "Development" pageRef = "/development" weight = 40 ================================================ FILE: docs/config/_default/module.toml ================================================ # mounts ## archetypes [[mounts]] source = "node_modules/@thulite/doks-core/archetypes" target = "archetypes" [[mounts]] source = "archetypes" target = "archetypes" ## assets [[mounts]] source = "node_modules/@thulite/core/assets" target = "assets" [[mounts]] source = "node_modules/@thulite/images/assets" target = "assets" [[mounts]] source = "node_modules/@thulite/doks-core/assets" target = "assets" [[mounts]] source = "node_modules/@tabler/icons/icons" target = "assets/svgs/tabler-icons" [[mounts]] source = "assets" target = "assets" ## content [[mounts]] source = "content" target = "content" ## data [[mounts]] source = "node_modules/@thulite/doks-core/data" target = "data" [[mounts]] source = "data" target = "data" ## i18n [[mounts]] source = "node_modules/@thulite/doks-core/i18n" target = "i18n" [[mounts]] source = "i18n" target = "i18n" ## layouts [[mounts]] source = "node_modules/@thulite/core/layouts" target = "layouts" [[mounts]] source = "node_modules/@thulite/seo/layouts" target = "layouts" [[mounts]] source = "node_modules/@thulite/images/layouts" target = "layouts" [[mounts]] source = "node_modules/@thulite/doks-core/layouts" target = "layouts" [[mounts]] source = "node_modules/@thulite/inline-svg/layouts" target = "layouts" [[mounts]] source = "layouts" target = "layouts" ## static [[mounts]] source = "node_modules/@thulite/doks-core/static" target = "static" [[mounts]] source = "static" target = "static" ================================================ FILE: docs/config/_default/params.toml ================================================ # Hugo title = "Watermill" description = "Building event-driven applications the easy way in Go." images = ["cover.png"] # mainSections mainSections = ["docs"] [social] twitter = "ThreeDotsLabs" # Doks (@thulite/doks-core) [doks] # Color mode - it should stay auto to render toggle button, # in practice we are using dark by default (see docs/assets/js/custom.js) colorMode = "auto" # auto (default), light or dark colorModeToggler = true # true (default) or false (this setting is only relevant when colorMode = auto) # Navbar navbarSticky = true # true (default) or false containerBreakpoint = "lg" # "", "sm", "md", "lg" (default), "xl", "xxl", or "fluid" ## Button navBarButton = false # false (default) or true navBarButtonUrl = "/docs/prologue/introduction/" navBarButtonText = "Get started" # FlexSearch flexSearch = true # true (default) or false searchExclKinds = [] # list of page kinds to exclude from search indexing (e.g. ["home", "taxonomy", "term"] ) searchExclTypes = [] # list of content types to exclude from search indexing (e.g. ["blog", "docs", "legal", "contributors", "categories"]) showSearch = [] # [] (all pages, default) or homepage (optionally) and list of sections (e.g. ["homepage", "blog", "guides"]) indexSummary = false # true or false (default); whether to index only the `.Summary` instead of the full `.Content`; limits the respective JSON field size and thus increases loading time ## Search results showDate = false # false (default) or true showSummary = true # true (default) or false searchLimit = 99 # 0 (no limit, default) or natural number # Global alert alert = false # false (default) or true alertDismissable = true # true (default) or false # Bootstrap bootstrapJavascript = false # false (default) or true # Nav sectionNav = ["learn", "docs", "advanced", "pubsubs", "development"] # ["docs"] (default) or list of sections (e.g. ["docs", "guides"]) toTopButton = false # false (default) or true breadcrumbTrail = false # false (default) or true headlineHash = true # true (default) or false scrollSpy = true # true (default) or false # Multilingual multilingualMode = false # false (default) or true showMissingLanguages = true # whether or not to show untranslated languages in the language menu; true (default) or false # Versioning docsVersioning = false # false (default) or true docsVersion = "1.0" # UX headerBar = false # true (default) or false backgroundDots = false # true (default) or false # Homepage sectionFooter = false # false (default) or true # Blog relatedPosts = false # false (default) or true imageList = true # true (default) or false imageSingle = true # true (default) or false # Repository editPage = true # false (default) or true lastMod = false # false (default) or true repoHost = "GitHub" # GitHub (default), Gitea, GitLab, Bitbucket, or BitbucketServer docsRepo = "https://github.com/ThreeDotsLabs/watermill" docsRepoBranch = "master" # main (default), master, or docsRepoSubPath = "/docs" # "" (none, default) or # SCSS colors # backGround = "yellowgreen" ## Dark theme # textDark = "#dee2e6" # "#dee2e6" (default), "#dee2e6" (original), or custom color # accentDark = "#5d2f86" # "#5d2f86" (default), "#5d2f86" (original), or custom color ## Light theme # textLight = "#1d2d35" # "#1d2d35" (default), "#1d2d35" (original), or custom color # accentLight = "#8ed6fb" # "#8ed6fb" (default), "#8ed6fb" (original), or custom color # [doks.menu] # [doks.menu.section] # auto = true # true (default) or false # collapsibleSidebar = true # true (default) or false # Debug [render_hooks.image] errorLevel = 'ignore' # ignore (default), warning, or error (fails the build) [render_hooks.link] errorLevel = 'ignore' # ignore (default), warning, or error (fails the build) highlightBroken = false # true or false (default) # Images (@thulite/images) [thulite_images] [thulite_images.defaults] decoding = "async" # sync, async, or auto (default) fetchpriority = "auto" # high, low, or auto (default) loading = "lazy" # eager or lazy (default) widths = [480, 576, 768, 1025, 1200, 1440] # [640, 768, 1024, 1366, 1600, 1920] for example sizes = "auto" # 100vw (default), 75vw, or auto for example process = "" # "fill 1600x900" or "fill 2100x900" for example lqip = "16x webp q20" # "16x webp q20" or "21x webp q20" for example # Inline SVG (@thulite/inline-svg) [inline_svg] iconSetDir = "tabler-icons" # "tabler-icons" (default) # SEO (@thulite/seo) [seo] [seo.title] separator = " | " suffix = "Watermill | Event-Driven in Go" [seo.favicons] sizes = [] icon = "favicon.png" # favicon.png (default) svgIcon = "favicon.svg" # favicon.svg (default) maskIcon = "mask-icon.svg" # mask-icon.svg (default) maskIconColor = "white" # white (default) [seo.schemas] type = "Organization" # Organization (default) or Person logo = "favicon-512x512.png" # Logo of Organization — favicon-512x512.png (default) name = "Three Dots Labs" # Name of Organization or Person sameAs = [] # E.g. ["https://github.com/thuliteio/thulite", "https://fosstodon.org/@thulite"] images = ["cover.png"] # ["cover.png"] (default) article = [] # Article sections newsArticle = [] # NewsArticle sections blogPosting = ["blog"] # BlogPosting sections product = [] # Product sections ================================================ FILE: docs/config/babel.config.js ================================================ module.exports = { presets: [ [ '@babel/preset-env', { targets: { browsers: [ // Best practice: https://github.com/babel/babel/issues/7789 '>=1%', 'not ie 11', 'not op_mini all' ] } } ] ] }; ================================================ FILE: docs/config/next/hugo.toml ================================================ # Overrides for next environment baseurl = "/" ================================================ FILE: docs/config/postcss.config.js ================================================ const autoprefixer = require('autoprefixer'); const purgecss = require('@fullhuman/postcss-purgecss'); const whitelister = require('purgecss-whitelister'); module.exports = { plugins: [ autoprefixer(), purgecss({ content: ['./hugo_stats.json'], extractors: [ { extractor: (content) => { const els = JSON.parse(content).htmlElements; return els.tags.concat(els.classes, els.ids); }, extensions: ['json'] } ], dynamicAttributes: [ 'aria-expanded', 'data-bs-popper', 'data-bs-target', 'data-bs-theme', 'data-dark-mode', 'data-global-alert', 'data-pane', // tabs.js 'data-popper-placement', 'data-sizes', 'data-toggle-tab', // tabs.js 'id', 'size', 'type' ], safelist: [ 'active', 'btn-clipboard', // clipboards.js 'clipboard', // clipboards.js 'disabled', 'hidden', 'modal-backdrop', // search-modal.js 'selected', // search-modal.js 'show', 'img-fluid', 'blur-up', 'lazyload', 'lazyloaded', 'alert-link', 'container-fw ', 'container-lg', 'container-fluid', 'offcanvas-backdrop', 'figcaption', 'dt', 'dd', 'showing', 'hiding', 'page-item', 'page-link', 'not-content', ...whitelister(['./assets/scss/**/*.scss', './node_modules/@thulite/doks-core/assets/scss/components/_code.scss', './node_modules/@thulite/doks-core/assets/scss/components/_expressive-code.scss', './node_modules/@thulite/doks-core/assets/scss/common/_syntax.scss']) ] }) ] }; ================================================ FILE: docs/config/production/hugo.toml ================================================ # Overrides for production environment ================================================ FILE: docs/content/_index.md ================================================ --- title: "Watermill" description: "Building event-driven applications the easy way in Go." lead: "Building event-driven applications the easy way in Go." date: 2023-09-07T16:33:54+02:00 lastmod: 2023-09-07T16:33:54+02:00 draft: false seo: title: "Watermill" # custom title (optional) description: "" # custom description (recommended) canonical: "" # custom canonical URL (optional) noindex: false # false (default) or true --- ================================================ FILE: docs/content/advanced/delayed-messages.md ================================================ +++ title = "Delayed Messages" description = "Receive messages with a delay" weight = -40 draft = false bref = "Receive messages with a delay" +++ Delaying events or commands is a common use case in many applications. For example, you may want to send the user a reminder after a few days of signing up. It's not a complex logic to implement, but you can leverage messages to use it out of the box. ## Delay Metadata Watermill's [`delay`](https://github.com/ThreeDotsLabs/watermill/tree/master/components/delay) package allows you to *add delay metadata* to messages. {{< callout "danger" >}} **The delay metadata does nothing by itself. You need to use a Pub/Sub implementation that supports it to make it work.** See below for supported Pub/Subs. {{< /callout >}} There are two APIs you can use. If you work with raw messages, use `delay.Message`: ```go msg := message.NewMessage(watermill.NewUUID(), []byte("hello")) delay.Message(msg, delay.For(time.Second * 10)) ``` If you use the CQRS component, use `delay.WithContext` instead (since you can't access the message directly): {{% load-snippet-partial file="src-link/_examples/real-world-examples/delayed-messages/main.go" first_line_contains="cmd := SendFeedbackForm" last_line_contains="return err" padding_after="1" %}} You can also use `delay.Until` instead of `delay.For` to specify `time.Time` instead of `time.Duration`. ## Supported Pub/Subs * [PostgreSQL](/pubsubs/sql/) * [MySQL](/pubsubs/sql/) ## Full Example See the [full example](https://github.com/ThreeDotsLabs/watermill/tree/master/_examples/real-world-examples/delayed-messages) in the Watermill repository. ================================================ FILE: docs/content/advanced/fanin.md ================================================ +++ title = "FanIn (merging topics)" description = "Merging two topics into one with the FanIn component" date = 2023-01-21T12:47:30+01:00 weight = -100 draft = false bref = "Merging two topics into one with the FanIn component" +++ ## FanIn component The FanIn component merges two topics into one. ### Configuring {{% load-snippet-partial file="src-link/components/fanin/fanin.go" first_line_contains="type Config struct {" last_line_contains="CloseTimeout time.Duration" padding_after="1" %}} ### Running You need to provide a Publisher and a Subscriber implementation for the FanIn component. You can find the list of supported Pub/Subs on [Supported Pub/Subs page](/pubsubs/). The Publisher and subscriber can be implemented by different message brokers (for example, you can merge a Kafka topic with a RabbitMQ topic). ```go logger := watermill.NewStdLogger(false, false) // create Publisher and Subscriber pub, err := // ... sub, err := // ... fi, err := fanin.NewFanIn( sub, pub, fanin.Config{ SourceTopics: upstreamTopics, TargetTopic: downstreamTopic, }, logger, ) if err != nil { panic(err) } if err := fi.Run(context.Background()); err != nil { panic(err) } ``` ### Controlling FanIn component The FanIn component can be stopped by cancelling the context passed to the `Run` method or by calling the `Close` method. {{% load-snippet-partial file="src-link/components/fanin/fanin.go" first_line_contains="func (f *FanIn) Run" last_line_contains=" Close() error" padding_after="2" %}} ================================================ FILE: docs/content/advanced/fanout.md ================================================ +++ title = "FanOut (multiplying messages)" description = "FanOut is a component that receives messages from the subscriber and passes them to all publishers." date = 2024-10-09T02:47:30+01:00 weight = -50 draft = false bref = "FanOut is a component that receives messages from the subscriber and passes them to all publishers." +++ ## FanOut component FanOut is a component that receives messages from a topic and passes them to all subscribers. In effect, messages are "multiplied". A typical use case for using FanOut is having one external subscription and multiple workers inside the process. ### Configuring {{% load-snippet-partial file="src-link/pubsub/gochannel/fanout.go" first_line_contains="// NewFanOut" last_line_contains=")" padding_after="0" %}} You need to call AddSubscription method for all topics that you want to listen to. This needs to be done *before* starting the FanOut. {{% load-snippet-partial file="src-link/pubsub/gochannel/fanout.go" first_line_contains="// AddSubscription" last_line_contains=")" padding_after="0" %}} ### Running {{% load-snippet-partial file="src-link/pubsub/gochannel/fanout.go" first_line_contains="// Run" last_line_contains=")" padding_after="0" %}} Then, use it as any other `message.Subscriber`. ================================================ FILE: docs/content/advanced/forwarder.md ================================================ +++ title = "Forwarder (the outbox pattern)" description = "Implement outbox pattern by publishing messages in transactional way" date = 2021-01-13T12:47:30+01:00 weight = -300 draft = false bref = "Emitting events along with storing data in a database in one transaction" +++ ## Publishing messages in transactions (and why we should care) While working with an event-driven application, you may in some point need to store an application state and publish a message telling the rest of the system about what's just happened. In a perfect scenario, you'd want to persist the application state and publish the message **in a transaction**, as not doing so might get you easily into troubles with data consistency. In order to commit both storing data and emitting an event in one transaction, you'd have to be able to publish messages to the same database you use for the data storage, or implement [2PC](https://martinfowler.com/articles/patterns-of-distributed-systems/two-phase-commit.html) on your own. If you don't want to change your message broker to a database, nor invent the wheel once again, you can make your life easier by using Watermill's [Forwarder component](https://github.com/ThreeDotsLabs/watermill/blob/master/components/forwarder/forwarder.go)! ## Forwarder component You can think of the Forwarder as a background running daemon which awaits messages that are published to a database, and makes sure they eventually reach a message broker. Watermill Forwarder component In order to make the Forwarder universal and usable transparently, it listens to a single topic on an intermediate database based Pub/Sub, where enveloped messages are sent with help of a decorated [Forwarder Publisher](https://github.com/ThreeDotsLabs/watermill/blob/9e04bfefbd6fef9f9ffa59956654277005fa2e8a/components/forwarder/publisher.go#L30). The Forwarder unwraps them, and sends to a specified destined topic on the message broker. Forwarder envelope ## Example Let's consider a following example: there's a command which responsibility is to run a lottery. It has to pick a random user that's registered in the system as a winner. While it does so, it should also persist the decision it made by storing a database entry associating a unique lottery ID with a picked user's ID. Additionally, as it's an event-driven system, it should emit a `LotteryConcluded` event, so that other components could react to that appropriately. To be precise - there will be component responsible for sending prizes to lottery winners. It will receive `LotteryConcluded` events, and using the lottery ID embedded in the event, verify who was the winner, checking with the database entry. In our case, the database is MySQL and the message broker is Google Pub/Sub, but it could be any two other technologies. Approaching to implementation of such a command, we could go various ways. Below we're going to cover three possible attempts, pointing their vulnerabilities. ### Publishing an event first, storing data next In this approach, the command is going to publish an event first and store data just after that. While in most of the cases that approach will probably work just fine, let's try to find out what could possibly go wrong. There are three basic actions that the command has to do: 1. Pick a random user `A` as a lottery winner. 2. Publish a `LotteryConcluded` event telling that lottery `B` has been concluded. 3. Store in the database that the lottery `B` has been won by the user `A`. Every of these steps could potentially fail, breaking the flow of our command. The first point wouldn't have huge repercussions in case of its failure - we would just return an error and consider the whole command failed. No data would be stored, no event would be emitted. We can simply rerun the command. In case the second point fails, we'll still have no event emitted and no data stored in the database. We can rerun the command and try once again. What's most interesting is what could happen in case the third point fails. We'd already have the event emitted after the second point, but no data would be stored eventually in the database. Other components would get a signal that the lottery had been concluded, but no winner would be associated to the lottery ID sent in the event. They wouldn't be able to verify who's the winner, so their action would have to be considered failed as well. We still can get out of this situation, but most probably it will require some manual action, i.e., rerunning the command with the lottery ID that the emitted event has. {{% load-snippet-partial file="src-link/_examples/real-world-examples/transactional-events-forwarder/main.go" first_line_contains="// 1. Publishes event" last_line_contains="// In case this fails" padding_after="9" %}} ### Storing data first, publishing an event next In the second approach, we're going to try address first approach's drawbacks. We won't leak our failure to outer components by not emitting an event in case we don't have the state persisted properly in database. That means we'll change the order of our actions to following: 1. Pick a random user `A` as a lottery winner. 2. Store in the database that the lottery `B` has been won by the user `A`. 3. Publish a `LotteryConcluded` event telling that lottery `B` has been concluded. Having two first actions failed, we have no repercussions, just as in the first approach. In case of failure of the 3rd point, we'd have data persisted in the database, but no event emitted. In this case, we wouldn't leak our failure outside the lottery component. Although, considering the expected system behavior, we'd have no prize sent to our winner, because no event would be delivered to the component responsible for this action. That probably can be fixed by some manual action as well, i.e., emitting the event manually. We still can do better. {{% load-snippet-partial file="src-link/_examples/real-world-examples/transactional-events-forwarder/main.go" first_line_contains="// 2. Persists data" last_line_contains="// In case this fails" padding_after="9" %}} ### Storing data and publishing an event in one transaction Let's imagine our command could do the 2nd, and the 3rd point at the same time. They would be committed atomically, meaning that any of them can't succeed having the other failed. This can be achieved by leveraging a transaction mechanism which happens to be implemented by most of the databases in today's world. One of them is MySQL used in our example. In order to commit both storing data and emitting an event in one transaction, we'd have to be able to publish our messages to MySQL. Because we don't want to change our message broker to be backed by MySQL in the whole system, we have to find a way to do that differently. There's a good news: Watermill provides all the tools straight away! In case the database you're using is one among MySQL, PostgreSQL (or any other SQL), Firestore or Bolt, you can publish messages to them. **Forwarder** component will help you with picking all the messages you publish to the database and forwarding them to a message broker of yours. Everything you have to do is to make sure that: 1. Your command uses a publisher working in a context of a database transaction (i.e. [SQL](https://github.com/ThreeDotsLabs/watermill-sql/blob/4f39bf82b6180ca2191c791e7cb220fff22b9255/pkg/sql/publisher.go#L53), [Firestore](https://github.com/ThreeDotsLabs/watermill-firestore/blob/b7bd31b3458884dc76076196cdc8942d18b5ab61/pkg/firestore/transactional.go#L14), [Bolt](https://github.com/ThreeDotsLabs/watermill-bolt/blob/0652f3602f6adbe4e3e39b97308fbed16dcbe29e/pkg/bolt/tx_publisher.go#L24)). 2. **Forwarder** component is running, using a database subscriber, and a message broker publisher. The command could look like following in this case: {{% load-snippet-partial file="src-link/_examples/real-world-examples/transactional-events-forwarder/main.go" first_line_contains="// 3. Persists data" last_line_contains="err = publisher.Publish(googleCloudEventTopic" padding_after="5" %}} In order to make the **Forwarder** component work in background for you and forward messages from MySQL to Google Pub/Sub, you'd have to set it up as follows: {{% load-snippet-partial file="src-link/_examples/real-world-examples/transactional-events-forwarder/main.go" first_line_contains="// Setup the Forwarder " last_line_contains="err := fwd.Run" padding_after="3" %}} If you wish to explore the example more, you can find it implemented [here](https://github.com/ThreeDotsLabs/watermill/tree/master/_examples/real-world-examples/transactional-events-forwarder/main.go). ================================================ FILE: docs/content/advanced/metrics.md ================================================ +++ title = "Metrics" description = "Monitor Watermill in realtime" date = 2019-02-12T21:00:00+01:00 weight = -400 draft = false bref = "Monitor Watermill in realtime using Prometheus" +++ Monitoring of Watermill may be performed by using decorators for publishers/subscribers and middlewares for handlers. We provide a default implementation using Prometheus, based on the official [Prometheus client](https://github.com/prometheus/client_golang) for Go. The `components/metrics` package exports `PrometheusMetricsBuilder`, which provides convenience functions to wrap publishers, subscribers and handlers so that they update the relevant Prometheus registry: {{% load-snippet-partial file="src-link/components/metrics/builder.go" first_line_contains="// PrometheusMetricsBuilder" last_line_contains="func (b PrometheusMetricsBuilder)" %}} ## Wrapping publishers, subscribers and handlers If you are using Watermill's [router](/docs/messages-router) (which is recommended in most cases), you can use a single convenience function `AddPrometheusRouterMetrics` to ensure that all the handlers added to this router are wrapped to update the Prometheus registry, together with their publishers and subscribers: {{% load-snippet-partial file="src-link/components/metrics/builder.go" first_line_contains="// AddPrometheusRouterMetrics" last_line_contains="AddMiddleware" padding_after="1" %}} Example use of `AddPrometheusRouterMetrics`: {{% load-snippet-partial file="src-link/_examples/basic/4-metrics/main.go" first_line_contains="// we leave the namespace" last_line_contains="metricsBuilder.AddPrometheusRouterMetrics" %}} In the snippet above, we have left the `namespace` and `subsystem` arguments empty. The Prometheus client library [uses these](https://godoc.org/github.com/prometheus/client_golang/prometheus#BuildFQName) to prefix the metric names. You may want to use namespace or subsystem, but be aware that this will impact the metric names and you will have to adjust the Grafana dashboard accordingly. The `PrometheusMetricsBuilder` allows for custom configuration of histogram buckets by setting the `PublishBuckets` or `HandlerBuckets` field. If `HandlerBuckets` is not provided, default watermill's values will be used, which are one order of magnitude smaller than default buckets (5ms~10s), because the handler execution times are typically shorter (µs~ms range). For `PublishBuckets`, the default values are the same as the default Prometheus buckets (5ms~10s). Standalone publishers and subscribers may also be decorated through the use of dedicated methods of `PrometheusMetricBuilder`: {{% load-snippet-partial file="src-link/_examples/basic/4-metrics/main.go" first_line_contains="subWithMetrics, err := " last_line_contains="pubWithMetrics, err := " padding_after="3" %}} ## Exposing the /metrics endpoint In accordance with how Prometheus works, the service needs to expose a HTTP endpoint for scraping. By convention, it is a GET endpoint, and its path is usually `/metrics`. To serve this endpoint, there are two convenience functions, one using a previously created Prometheus Registry, while the other also creates a new registry: {{% load-snippet-partial file="src-link/components/metrics/http.go" first_line_contains="// CreateRegistryAndServeHTTP" last_line_contains="func ServeHTTP(" %}} Here is an example of its use in practice: {{% load-snippet-partial file="src-link/_examples/basic/4-metrics/main.go" first_line_contains="prometheusRegistry, closeMetricsServer :=" last_line_contains="metricsBuilder.AddPrometheusRouterMetrics" %}} ## Example application To see how the metrics dashboard works in practice, you can check out the [metrics example](https://github.com/ThreeDotsLabs/watermill/tree/master/_examples/basic/4-metrics). Follow the instructions in the example's [README](https://github.com/ThreeDotsLabs/watermill/blob/master/_examples/basic/4-metrics/README.md) to make it run and add the Prometheus data source to Grafana. ## Grafana dashboard We have prepared a [Grafana dashboard](https://grafana.com/grafana/dashboards/9777-watermill/) to use with the metrics implementation described above. It provides basic information about the throughput, failure rates and publish/handler durations. If you want to check out the dashboard on your machine, you can use the [Example application](#example-application). To find out more about the metrics that are exported to Prometheus, see [Exported metrics](#exported-metrics). ### Importing the dashboard To import the Grafana dashboard, select Dashboard/Manage from the left menu, and then click on `+Import`. Enter the dashboard URL https://grafana.com/dashboards/9777 (or just the ID, 9777), and click on Load. ![Importing the dashboard](https://threedots.tech/watermill-io/grafana_import_dashboard.png) Then select your Prometheus data source that scrapes the `/metrics` endpoint. Click on `Import`, and you're done! ## Exported metrics Listed below are all the metrics that are registered on the Prometheus Registry by `PrometheusMetricsBuilder`. For more information on Prometheus metric types, please refer to [Prometheus docs](https://prometheus.io/docs/concepts/metric_types).
Object Metric Description Labels/Values
Subscriber subscriber_messages_received_total A Prometheus Counter.
Counts the number of messages obtained by the subscriber.
acked is either "acked" or "nacked".
handler_name is set if the subscriber operates within a handler; "<no handler>" otherwise.
subscriber_name identifies the subscriber. If it implements fmt.Stringer, it is the result of `String()`, package.structName otherwise.
Handler handler_execution_time_seconds A Prometheus Histogram.
Registers the execution time of the handler function wrapped by the middleware.
handler_name is the name of the handler.
success is either "true" or "false", depending on whether the wrapped handler function returned an error or not.
Publisher publish_time_seconds A Prometheus Histogram.
Registers the time of execution of the Publish function of the decorated publisher.
success is either "true" or "false", depending on whether the decorated publisher returned an error or not.
handler_name is set if the publisher operates within a handler; "<no handler>" otherwise.
publisher_name identifies the publisher. If it implements fmt.Stringer, it is the result of `String()`, package.structName otherwise.
Additionally, every metric has the `node` label, provided by Prometheus, with value corresponding to the instance that the metric comes from, and `job`, which is the job name specified in the [Prometheus configuration file](https://github.com/ThreeDotsLabs/watermill/blob/master/_examples/basic/4-metrics/prometheus.yml). **NOTE**: As described [above](#wrapping-publishers-subscribers-and-handlers), using non-empty `namespace` or `subsystem` will result in prefixed metric names. You might need to adjust for it, for example in the definitions of panels in the Grafana dashboard. ## Customization If you feel like some metric is missing, you can easily expand this basic implementation. The best way to do so is to use the prometheus registry that is used with the [ServeHTTP method](#exposing-the-metrics-endpoint) and register a metric according to [the documentation](https://godoc.org/github.com/prometheus/client_golang/prometheus) of the Prometheus client. An elegant way to update these metrics would be through the use of decorators: {{% load-snippet-partial file="src-link/message/decorator.go" first_line_contains="// MessageTransformSubscriberDecorator" last_line_contains="type messageTransformSubscriberDecorator" %}} and/or [router middlewares](/docs/messages-router/#middleware). A more simplistic approach would be to just update the metric that you want in the handler function. ================================================ FILE: docs/content/advanced/requeuing-after-error.md ================================================ +++ title = "Requeuing After Error" description = "How to requeue a message after it fails to process" weight = -20 draft = false bref = "How to requeue a message after it fails to process" +++ When a message fails to process (a nack is sent), it usually blocks other messages on the same topic (within the same consumer group or partition). Depending on your setup, it may be useful to requeue the failed message back to the tail of the queue. Consider this if: * You don't care about the order of messages. * Your system isn't resilient to blocked messages. ## Requeuer The `Requeuer` component is a wrapper on the `Router` that moves messages from one topic to another. {{% load-snippet-partial file="src-link/components/requeuer/requeuer.go" first_line_contains="type Config" last_line_contains="}" %}} A trivial usage can look like this. It requeues messages from one topic to the same topic after a delay. {{< callout "danger" >}} Using the delay this way is not recommended, as it blocks the entire requeue process for the given time. {{< /callout >}} ```go req, err := requeuer.NewRequeuer(requeuer.Config{ Subscriber: sub, SubscribeTopic: "topic", Publisher: pub, GeneratePublishTopic: func(params requeuer.GeneratePublishTopicParams) (string, error) { return "topic", nil }, Delay: time.Millisecond * 200, }, logger) if err != nil { return err } err := req.Run(context.Background()) if err != nil { return err } ``` A better way to use the `Requeuer` is to combine it with the `Poison` middleware. The middleware moves messages to a separate "poison" topic. Then, the requeuer moves them back to the original topic based on the metadata. You combine this with a Pub/Sub that [supports delayed messages](/advanced/delayed-messages/#supported-pubsubs). See the [full example based on PostgreSQL](https://github.com/ThreeDotsLabs/watermill/blob/master/_examples/real-world-examples/delayed-requeue/main.go). ================================================ FILE: docs/content/development/benchmark.md ================================================ +++ title = "Benchmark" description = "Watermill Benchmark" weight = 250 draft = false bref = "Watermill Benchmark" +++ You can find benchmarking tools and results in the [github.com/ThreeDotsLabs/watermill-benchmark](https://github.com/ThreeDotsLabs/watermill-benchmark) repository. **Note they are meant as rough estimations and should not be used to decide which Pub/Sub is the best pick.** Performance depends on many factors and configurations, and it's always best to test it in your environment. ================================================ FILE: docs/content/development/contributing.md ================================================ +++ title = "Contributing Guide" description = "Contribute to Watermill" weight = 100 bref = "Contribute to Watermill" +++ ## How can I help? We are always happy to help you in contributing to Watermill. If you have any ideas, please let us know on our [Discord server](https://watermill.io/support/). There are multiple ways in which you can help us. ### Existing issues You can pick one of the existing issues. Most of the issues should have an estimation (S - small, M - medium, L - large). - [Good first issues list](https://github.com/ThreeDotsLabs/watermill/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) - simple issues to begin with - [Help wanted issues list](https://github.com/ThreeDotsLabs/watermill/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22) - tasks that are already more or less clear, and you can start to implement them pretty quickly ### New Pub/Sub implementations If you have an idea to create a Pub/Sub based on some technology and it is not listed yet in our issues (because we don't know it, or it is just some crazy idea, like physical mail based Pub/Sub), feel free to add your own implementation. You can do it in your private repository and later, if you want, we can move it to `ThreeDotsLabs/watermill-[name]`. You will keep the maintainer permissions to the repository, and we'll invite you to a maintainers-only discord channel. When adding a new Pub/Sub implementation, you should start with [Implementing a new Pub/Sub]({{< ref "pub-sub-implementing" >}}). ### New ideas If you have any idea that is not covered in the issues list, please post a new issue describing it. It's recommended to discuss your idea on [Discord](https://discord.gg/QV6VFg4YQE)/GitHub before creating production-ready implementation - in some situations, it may save a lot of your time before implementing something that can be simplified or done more easily. :) In general, it's helpful to discuss a Proof of Concept to align with the idea. ## Local development Makefile and docker-compose (for Pub/Subs) are your friends. You can run all tests locally (they are running in CI in the same way). Useful commands: - `make up` - docker-compose up - `make test` - tests - `make test_short` - run short tests (useful to perform a very fast check after changes) - `make fmt` - do goimports ## Code standards - you should run `make fmt` - [CodeReviewComments](https://github.com/golang/go/wiki/CodeReviewComments) - [Effective Go](https://golang.org/doc/effective_go.html) - SOLID - code should be open for configuration and not coupled to any serialization method (for example: [AMQP marshaler](https://github.com/ThreeDotsLabs/watermill-amqp/blob/master/pkg/amqp/marshaler.go), [AMQP Config](https://github.com/ThreeDotsLabs/watermill-amqp/blob/master/pkg/amqp/config.go) ================================================ FILE: docs/content/development/pub-sub-implementing.md ================================================ +++ title = "Implementing a new Pub/Sub" description = "Bring Your Own Pub/Sub" date = 2018-12-05T12:48:34+01:00 weight = 200 draft = false bref = "Bring Your Own Pub/Sub" +++ ## The Pub/Sub interface To add support for a custom Pub/Sub, you have to implement both `message.Publisher` and `message.Subscriber` interfaces. {{% load-snippet-partial file="src-link/message/pubsub.go" first_line_contains="type Publisher interface" last_line_contains="type SubscribeInitializer" padding_after="0" %}} ## Testing Watermill provides [a set of test scenarios](https://github.com/ThreeDotsLabs/watermill/blob/master/pubsub/tests/test_pubsub.go) that any Pub/Sub implementation can use. Each test suite needs to declare what features it supports and how to construct a new Pub/Sub. These scenarios check both basic usage and more uncommon use cases. Stress tests are also included. ## TODO list Here are a few things you shouldn't forget about: 1. Logging (good messages and proper levels). 2. Replaceable and configurable messages marshaller. 3. `Close()` implementation for the publisher and subscriber that is: - idempotent - working correctly even when the publisher or the subscriber is blocked (for example, waiting for an Ack). - working correctly when the subscriber output channel is blocked (because nothing is listening on it). 4. `Ack()` **and** `Nack()` support for consumed messages. 5. Redelivery on `Nack()` for a consumed message. 6. Use [Universal Pub/Sub tests]({{< ref "/docs/pub-sub#universal-tests" >}}). For debugging tips, you should check [tests troubleshooting guide](/docs/troubleshooting/#debugging-pubsub-tests). 7. Performance optimizations. 8. GoDocs, [Markdown docs]({{< ref "/pubsubs" >}}) and [Getting Started examples](/learn/getting-started). We will also be thankful for submitting a [pull requests](https://github.com/ThreeDotsLabs/watermill/pulls) with the new Pub/Sub implementation. If anything is not clear, feel free to use any of our [support channels]({{< ref "/support" >}}) to reach us, we will be glad to help. ================================================ FILE: docs/content/development/releases.md ================================================ +++ title = "Releases" description = "Watermill Releases" weight = 300 bref = "Watermill Releases" +++ You can read about the historical Watermill releases in [the posts on the Three Dots Labs blog](https://threedots.tech/series/watermill-release-post/). ================================================ FILE: docs/content/docs/_index.md ================================================ --- title: "Docs" description: "" summary: "" date: 2023-09-07T16:12:03+02:00 lastmod: 2023-09-07T16:12:03+02:00 draft: false weight: 999 toc: true seo: title: "" # custom title (optional) description: "" # custom description (recommended) canonical: "" # custom canonical URL (optional) noindex: false # false (default) or true --- ================================================ FILE: docs/content/docs/articles.md ================================================ +++ title = "Articles" description = "In-depth articles mentioning Watermill" weight = 100 draft = false bref = "In-depth articles mentioning Watermill" +++ You can find more in-depth tips on Watermill in these articles: * [Distributed Transactions in Go: Read Before You Try](https://threedots.tech/post/distributed-transactions-in-go/) * [Live website updates with Go, SSE, and htmx](https://threedots.tech/post/live-website-updates-go-sse-htmx/) * [Using MySQL as a Pub/Sub](https://threedots.tech/post/when-sql-database-makes-great-pub-sub/) * [Creating local Go dev environment with Docker and live code reloading](https://threedots.tech/post/go-docker-dev-environment-with-go-modules-and-live-code-reloading/) ================================================ FILE: docs/content/docs/awesome.md ================================================ +++ title = "Awesome Watermill" description = "Selected unofficial libraries" weight = 200 draft = false bref = "Selected unofficial libraries" +++ Below is a list of libraries that are not maintained by Three Dots Labs, but you may find them useful. **Please note we can't provide support or guarantee they work correctly**. Do your own research. If you know another library or are an author of one, please [add it to the list](https://github.com/ThreeDotsLabs/watermill/edit/master/docs/content/docs/awesome.md). ## Examples * https://github.com/minghsu0107/golang-taipei-watermill-example * https://github.com/minghsu0107/Kafka-PubSub * https://github.com/pperaltaisern/go-example-financing ## Pub/Subs * AMQP 1.0 https://github.com/kahowell/watermill-amqp10 * Apache Pulsar https://github.com/AlexCuse/watermill-pulsar * Apache RocketMQ https://github.com/yflau/watermill-rocketmq * CockroachDB https://github.com/cockroachdb/watermill-crdb * Ensign https://github.com/rotationalio/watermill-ensign * GoogleCloud Pub/Sub HTTP Push https://github.com/dentech-floss/watermill-googlecloud-http * MongoDB https://github.com/cunyat/watermill-mongodb * MQTT https://github.com/perfect13/watermill-mqtt * NSQ https://github.com/chennqqi/watermill-nsq * Redis Zset https://github.com/stong1994/watermill-rediszset * SQLite https://github.com/davidroman0O/watermill-comfymill If you want to find out how to implement your own Pub/Sub adapter, check out [Implementing custom Pub/Sub](/development/pub-sub-implementing). ## Logging * logrus * https://github.com/ma-hartma/watermill-logrus-adapter * https://github.com/UNIwise/walrus * logur https://github.com/logur/integration-watermill * zap * https://github.com/garsue/watermillzap * https://github.com/pperaltaisern/watermillzap * zerolog * https://github.com/alexdrl/zerowater * https://github.com/bogatyr285/watermillzlog * https://github.com/vsvp21/zerolog-watermill-adapter ## Observability * OpenCensus * https://github.com/czeslavo/watermill-opencensus * https://github.com/sagikazarmark/ocwatermill * OpenTelemetry * https://github.com/voi-oss/watermill-opentelemetry * https://github.com/dentech-floss/watermill-opentelemetry-go-extra * https://github.com/nkonev/watermill-opentelemetry * AMQP https://github.com/hpcslag/otel-watermill-amqp * GoChannel https://github.com/hpcslag/watermill-otel-tracable-gochannel ## Other * https://github.com/asyncapi/go-watermill-template * https://github.com/goph/watermillx * https://github.com/voi-oss/protoc-gen-event ================================================ FILE: docs/content/docs/cqrs.md ================================================ +++ title = "CQRS Component" description = "Build CQRS and Event-Driven applications" date = 2019-02-12T12:47:30+01:00 weight = -400 draft = false bref = "Go CQRS implementation in Watermill" +++ The CQRS component is a high-level API that lets you work with Go structs instead of messages. Once you configure the EventBus and EventProcessor (or the command equivalents), publishing and handling events becomes very straightforward. ```go event := UserRegistered{ UserID: id, Email: email, JoinedAt: time.Now(), } err := eventBus.Publish(ctx, event) ``` ```go eventProcessor.AddHandlers( cqrs.NewEventHandler("SendWelcomeEmail", sendWelcomeEmail), ) func sendWelcomeEmail(ctx context.Context, event *UserRegistered) error { return emailService.Send(event.Email, "Welcome!") } ``` ## CQRS > CQRS means "Command-query responsibility segregation". We segregate the responsibility between commands (write requests) and queries (read requests). The write requests and the read requests are handled by different objects. > > That's it. We can further split up the data storage, having separate read and write stores. Once that happens, there may be many read stores, optimized for handling different types of queries or spanning many bounded contexts. Though separate read/write stores are often discussed in relation with CQRS, this is not CQRS itself. CQRS is just the first split of commands and queries. > > Source: [www.cqrs.nu FAQ](http://www.cqrs.nu/Faq/command-query-responsibility-segregation) CQRS Schema The `cqrs` component provides some useful abstractions built on top of Pub/Sub and Router that help to implement the CQRS pattern. You don't need to implement the entire CQRS. It's very common to use just the event part of this component to build event-driven applications. ### Building blocks #### Event The event represents something that already took place. Events are immutable. #### Event Bus {{% load-snippet-partial file="src-link/components/cqrs/event_bus.go" first_line_contains="// EventBus" last_line_contains="type EventBus" padding_after="0" %}} {{% load-snippet-partial file="src-link/components/cqrs/event_bus.go" first_line_contains="type EventBusConfig" last_line_contains="func (c *EventBusConfig) setDefaults()" padding_after="4" %}} #### Event Processor {{% load-snippet-partial file="src-link/components/cqrs/event_processor.go" first_line_contains="// EventProcessor" last_line_contains="type EventProcessor" padding_after="0" %}} {{% load-snippet-partial file="src-link/components/cqrs/event_processor.go" first_line_contains="type EventProcessorConfig" last_line_contains="func (c *EventProcessorConfig) setDefaults()" padding_after="4" %}} #### Event Group Processor {{% load-snippet-partial file="src-link/components/cqrs/event_processor_group.go" first_line_contains="// EventGroupProcessor" last_line_contains="type EventGroupProcessor" padding_after="0" %}} {{% load-snippet-partial file="src-link/components/cqrs/event_processor_group.go" first_line_contains="type EventGroupProcessorConfig" last_line_contains="func (c *EventGroupProcessorConfig) setDefaults()" padding_after="4" %}} Learn more in [Event Group Processor](#event-handler-groups). #### Event Handler {{% load-snippet-partial file="src-link/components/cqrs/event_handler.go" first_line_contains="// EventHandler" last_line_contains="type EventHandler" padding_after="0" %}} #### Command The command is a simple data structure, representing the request for executing some operation. #### Command Bus {{% load-snippet-partial file="src-link/components/cqrs/command_bus.go" first_line_contains="// CommandBus" last_line_contains="type CommandBus" padding_after="0" %}} {{% load-snippet-partial file="src-link/components/cqrs/command_bus.go" first_line_contains="type CommandBusConfig" last_line_contains="func (c *CommandBusConfig) setDefaults()" padding_after="4" %}} #### Command Processor {{% load-snippet-partial file="src-link/components/cqrs/command_processor.go" first_line_contains="// CommandProcessor" last_line_contains="type CommandProcessor" padding_after="0" %}} {{% load-snippet-partial file="src-link/components/cqrs/command_processor.go" first_line_contains="type CommandProcessorConfig" last_line_contains="func (c *CommandProcessorConfig) setDefaults()" padding_after="4" %}} #### Command Handler {{% load-snippet-partial file="src-link/components/cqrs/command_handler.go" first_line_contains="// CommandHandler" last_line_contains="type CommandHandler" padding_after="0" %}} #### Command and Event Marshaler {{% load-snippet-partial file="src-link/components/cqrs/marshaler.go" first_line_contains="// CommandEventMarshaler" last_line_contains="NameFromMessage(" padding_after="1" %}} #### Command and Event Marshaler Decorator Sometimes it's useful to add extra metadata to each command or event after marshaling it to a message. For example, you may want to add a partition key to each message using Kafka. You can use `CommandEventMarshalerDecorator` to extend a marshaler with an extra step. {{% load-snippet-partial file="src-link/components/cqrs/marshaler.go" first_line_contains="// CommandEventMarshalerDecorator" last_line_contains="}" padding_after="0" %}} ```go type Event interface { PartitionKey() string } // ... cqrsMarshaler := CommandEventMarshalerDecorator{ CommandEventMarshaler: cqrs.JSONMarshaler{}, DecorateFunc: func(v any, msg *message.Message) error { pm, ok := v.(Event) if !ok { return fmt.Errorf("%T does not implement Event and can't be marshaled", v) } partitionKey := pm.PartitionKey() if partitionKey == "" { return fmt.Errorf("PartitionKey is empty") } msg.Metadata.Set(PartitionKeyMetadataField, partitionKey) return nil }, } ``` ## Usage ### Example domain As an example, we will use a simple domain, that is responsible for handing room booking in a hotel. We will use **Event Storming** notation to show the model of this domain. Legend: - **blue** post-its are commands - **orange** post-its are events - **green** post-its are read models, asynchronously generated from events - **violet** post-its are policies, which are triggered by events and produce commands - **pink** post its are hot-spots; we mark places where problems often occur ![CQRS Event Storming](https://threedots.tech/watermill-io/cqrs-example-storming.png) The domain is simple: - A Guest is able to **book a room**. - **Whenever a room is booked, we order a beer** for the guest (because we love our guests). - We know that sometimes there are **not enough beers**. - We generate a **financial report** based on the bookings. ### Sending a command For the beginning, we need to simulate the guest's action. {{% load-snippet-partial file="src-link/_examples/basic/5-cqrs-protobuf/main.go" first_line_contains="bookRoomCmd := &BookRoom{" last_line_contains="panic(err)" padding_after="1" %}} ### Command handler `BookRoomHandler` will handle our command. {{% load-snippet-partial file="src-link/_examples/basic/5-cqrs-protobuf/main.go" first_line_contains="// BookRoomHandler is a command handler" last_line_contains="// OrderBeerOnRoomBooked is an event handler" padding_after="0" %}} ### Event handler As mentioned before, we want to order a beer every time when a room is booked (*"Whenever a Room is booked"* post-it). We do it by using the `OrderBeer` command. {{% load-snippet-partial file="src-link/_examples/basic/5-cqrs-protobuf/main.go" first_line_contains="// OrderBeerOnRoomBooked is an event handler" last_line_contains="// OrderBeerHandler is a command handler" padding_after="0" %}} `OrderBeerHandler` is very similar to `BookRoomHandler`. The only difference is, that it sometimes returns an error when there are not enough beers, which causes redelivery of the command. You can find the entire implementation in the [example source code](https://github.com/ThreeDotsLabs/watermill/tree/master/_examples/basic/5-cqrs-protobuf/?utm_source=cqrs_doc). ### Event Handler groups By default, each event handler has a separate subscriber instance. It works fine, if just one event type is sent to the topic. In the scenario, when we have multiple event types on one topic, you have two options: 1. You can set `EventConfig.AckOnUnknownEvent` to true - it will acknowledge all events that are not handled by handler, 2. You can use Event Handler groups mechanism. **Key differences between `EventProcessor` and `EventGroupProcessor`:** 1. `EventProcessor`: - Each handler has its own subscriber instance - One handler per event type - Simple one-to-one matching of events to handlers 2. `EventGroupProcessor`: - Group of handlers share a single subscriber instance (and one consumer group, if such mechanism is supported -- allows to maintain order of events), - One handler group can support multiple event types, - When message arrives to the topic, Watermill will match it to the handler in the group based on event type Group Handlers **Event Handler groups are helpful when you have multiple event types on one topic and you want to maintain order of events.** Thanks to using one subscriber instance and consumer group, events will be processed in the order they were sent. {{< callout context="note" title="Note" icon="outline/info-circle" >}} It's supported to have multiple handlers for the same event type in one group, but we recommend to not do that. Please keep in mind that those handlers will be processed within the same message. If first handler succeeds and the second fails, the message will be re-delivered and the first will be re-executed. {{< /callout >}} To use event groups, you need to set `GenerateHandlerGroupSubscribeTopic` and `GroupSubscriberConstructor` options in [`EventConfig`](#event-config). After that, you can use `AddHandlersGroup` on [`EventProcessor`](#event-processor). {{% load-snippet-partial file="src-link/_examples/basic/6-cqrs-ordered-events/main.go" first_line_contains="eventProcessor.AddHandlersGroup(" last_line_contains="if err != nil {" padding_after="0" %}} Both `GenerateHandlerGroupSubscribeTopic` and `GroupSubscriberConstructor` receives information about group name in function arguments. You can see a fully working example with event groups in our [examples](https://github.com/ThreeDotsLabs/watermill/tree/master/_examples/basic/6-cqrs-ordered-events/). ### Generic handlers Since Watermill v1.3 it's possible to use generic handlers for commands and events. It's useful when you have a lot of commands/events and you don't want to create a handler for each of them. {{% load-snippet-partial file="src-link/_examples/basic/6-cqrs-ordered-events/main.go" first_line_contains="cqrs.NewGroupEventHandler" last_line_contains=")," padding_after="0" %}} Under the hood, it creates EventHandler or CommandHandler implementation. It's available for all kind of handlers. {{% load-snippet-partial file="src-link/components/cqrs/command_handler.go" first_line_contains="// NewCommandHandler" last_line_contains="func NewCommandHandler" padding_after="0" %}} {{% load-snippet-partial file="src-link/components/cqrs/event_handler.go" first_line_contains="// NewEventHandler" last_line_contains="func NewEventHandler" padding_after="0" %}} {{% load-snippet-partial file="src-link/components/cqrs/event_handler.go" first_line_contains="// NewGroupEventHandler" last_line_contains="func NewGroupEventHandler" padding_after="0" %}} ### Building a read model with the event handler {{% load-snippet-partial file="src-link/_examples/basic/5-cqrs-protobuf/main.go" first_line_contains="// BookingsFinancialReport is a read model" last_line_contains="func main() {" padding_after="0" %}} ### Wiring it up We have all the blocks to build our CQRS application. We will use the AMQP (RabbitMQ) as our message broker: [AMQP]({{< ref "/pubsubs/amqp" >}}). Under the hood, CQRS is using Watermill's message router. If you are not familiar with it and want to learn how it works, you should check [Getting Started guide]({{< ref "getting-started" >}}). It will also show you how to use some standard messaging patterns, like metrics, poison queue, throttling, correlation and other tools used by every message-driven application. Those come built-in with Watermill. Let's go back to the CQRS. As you already know, CQRS is built from multiple components, like Command or Event buses, handlers, processors, etc. {{% load-snippet-partial file="src-link/_examples/basic/5-cqrs-protobuf/main.go" first_line_contains="main() {" last_line_contains="err := router.Run(" padding_after="3" %}} And that's all. We have a working CQRS application. ### What's next? As mentioned before, if you are not familiar with Watermill, we highly recommend reading [Getting Started guide]({{< ref "getting-started" >}}). ================================================ FILE: docs/content/docs/message/.validate_example.yml ================================================ validation_cmd: "go run receiving-ack.go" timeout: 30 expected_output: "ack received" ================================================ FILE: docs/content/docs/message/go.mod ================================================ module receiving-ack.go require github.com/ThreeDotsLabs/watermill v1.5.1 require ( github.com/google/uuid v1.6.0 // indirect github.com/lithammer/shortuuid/v3 v3.0.7 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/pkg/errors v0.9.1 // indirect ) go 1.25 ================================================ FILE: docs/content/docs/message/go.sum ================================================ github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY= github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892LARrKAdki6WtXS4= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: docs/content/docs/message/receiving-ack.go ================================================ package main import ( "log" "time" "github.com/ThreeDotsLabs/watermill/message" ) func main() { msg := message.NewMessage("1", []byte("foo")) go func() { time.Sleep(time.Millisecond * 10) msg.Ack() }() select { case <-msg.Acked(): log.Print("ack received") case <-msg.Nacked(): log.Print("nack received") } } ================================================ FILE: docs/content/docs/message.md ================================================ +++ title = "Message" description = "Message is one of core parts of Watermill" date = 2018-12-05T12:42:40+01:00 weight = -1000 draft = false bref = "Message is one of core parts of Watermill" +++ Message is one of core parts of Watermill. Messages are emitted by [*Publishers*]({{< ref "/docs/pub-sub#publisher" >}}) and received by [*Subscribers*]({{< ref "/docs/pub-sub#subscriber" >}}). When a message is processed, you should send an [`Ack()`]({{< ref "#ack" >}}) or a [`Nack()`]({{< ref "#ack" >}}) when the processing failed. `Acks` and `Nacks` are processed by Subscribers (in default implementations, the subscribers are waiting for an `Ack` or a `Nack`). {{% load-snippet-partial file="src-link/message/message.go" first_line_contains="type Message struct {" last_line_contains="ctx context.Context" padding_after="2" %}} ## Ack #### Sending `Ack` {{% load-snippet-partial file="src-link/message/message.go" first_line_contains="// Ack" last_line_contains="func (m *Message) Ack() bool {" padding_after="0" %}} ## Nack {{% load-snippet-partial file="src-link/message/message.go" first_line_contains="// Nack" last_line_contains="func (m *Message) Nack() bool {" padding_after="0" %}} #### Receiving `Ack/Nack` {{% load-snippet-partial file="docs/message/receiving-ack.go" first_line_contains="select {" last_line_contains="}" padding_after="0" %}} ## Context Message contains the standard library context, just like an HTTP request. This context is used to propagate cancellation and deadlines when publishing and processing messages. You can set the context using the `SetContext` method or the `NewMessageWithContext` constructor. {{% load-snippet-partial file="src-link/message/message.go" first_line_contains="// NewMessageWithContext" last_line_contains="}" %}} {{% load-snippet-partial file="src-link/message/message.go" first_line_contains="// Context" last_line_contains="func (m *Message) SetContext" padding_after="2" %}} ================================================ FILE: docs/content/docs/messages-router.md ================================================ +++ title = "Router" description = "The Magic Glue of Watermill" date = 2018-12-05T12:48:04+01:00 weight = -850 draft = false bref = "The Magic Glue of Watermill" toc = true +++ [*Publishers and Subscribers*]({{< ref "/docs/pub-sub" >}}) are rather low-level parts of Watermill. In production use, you'd usually want to use a high-level interface and features like [correlation, metrics, poison queue, retrying, throttling, etc.]({{< ref "/docs/messages-router#middleware" >}}). You also might not want to send an Ack when processing was successful. Sometimes, you'd like to send a message after processing of another message finishes. To handle these requirements, there is a component named **Router**. Watermill Router ## Configuration {{% load-snippet-partial file="src-link/message/router.go" first_line_contains="type RouterConfig struct {" last_line_contains="RouterConfig) Validate()" padding_after="2" %}} ## Handler At the beginning you need to implement `HandlerFunc`: {{% load-snippet-partial file="src-link/message/router.go" first_line_contains="// HandlerFunc is" last_line_contains="type HandlerFunc func" padding_after="1" %}} Next, you have to add a new handler with `Router.AddHandler`: {{% load-snippet-partial file="src-link/message/router.go" first_line_contains="// AddHandler" last_line_contains=") {" padding_after="0" %}} See an example usage from [Getting Started]({{< ref "/learn/getting-started#using-messages-router" >}}): {{% load-snippet-partial file="src-link/_examples/basic/3-router/main.go" first_line_contains="// AddHandler returns a handler" last_line_contains="return h(message)" padding_after="3" %}} ## Consumer handler Not every handler will produce new messages. You can add this kind of handler by using `Router.AddConsumerHandler`: {{% load-snippet-partial file="src-link/message/router.go" first_line_contains="// AddConsumerHandler" last_line_contains=") {" padding_after="0" %}} ## Ack By default, `msg.Ack()` is called when `HandlerFunc` doesn't return an error. If an error is returned, `msg.Nack()` will be called. Because of this, you don't have to call `msg.Ack()` or `msg.Nack()` after a message is processed (you can if you want, of course). ## Producing messages When returning multiple messages from a handler, be aware that most Publisher implementations don't support [atomic publishing of messages]({{< ref "/docs/pub-sub#publishing-multiple-messages" >}}). It may end up producing only some messages and sending `msg.Nack()` if the broker or the storage are not available. If it is an issue, consider publishing just one message with each handler. ## Running the Router To run the Router, you need to call `Run()`. {{% load-snippet-partial file="src-link/message/router.go" first_line_contains="// Run" last_line_contains="func (r *Router) Run(ctx context.Context) (err error) {" padding_after="0" %}} ### Ensuring that the Router is running It can be useful to know if the router is running. You can use the `Running()` method for this. {{% load-snippet-partial file="src-link/message/router.go" first_line_contains="// Running" last_line_contains="func (r *Router) Running()" padding_after="0" %}} You can also use `IsRunning` function, that returns bool: {{% load-snippet-partial file="src-link/message/router.go" first_line_contains="// IsRunning" last_line_contains="func (r *Router) IsRunning()" padding_after="0" %}} ### Closing the Router To close the Router, you need to call `Close()`. {{% load-snippet-partial file="src-link/message/router.go" first_line_contains="// Close gracefully" last_line_contains="func (r *Router) Close()" padding_after="1" %}} `Close()` will close all publishers and subscribers, and wait for all handlers to finish. `Close()` will wait for a timeout configured in `RouterConfig.CloseTimeout`. If the timeout is reached, `Close()` will return an error. ## Adding handler after the router has started You can add a new handler while the router is already running. To do that, you need to call `AddConsumerHandler` or `AddHandler` and call `RunHandlers`. {{% load-snippet-partial file="src-link/message/router.go" first_line_contains="// RunHandlers" last_line_contains="func (r *Router) RunHandlers" padding_after="0" %}} ## Stopping running handler It is possible to stop **just one running handler** by calling `Stop()`. Please keep in mind, that router will be closed when there are no running handlers. {{% load-snippet-partial file="src-link/message/router.go" first_line_contains="// Stop" last_line_contains="func (h *Handler) Stop()" padding_after="0" %}} ## Execution models *Subscribers* can consume either one message at a time or multiple messages in parallel. * **Single stream of messages** is the simplest approach and it means that until a `msg.Ack()` is called, the subscriber will not receive any new messages. * **Multiple message streams** are supported only by some subscribers. By subscribing to multiple topic partitions at once, several messages can be consumed in parallel, even previous messages that were not acked (for example, the Kafka subscriber works like this). Router handles this model by running concurrent `HandlerFunc`s, one for each partition. See the chosen Pub/Sub documentation for supported execution models. ## Middleware {{% load-snippet-partial file="src-link/message/router.go" first_line_contains="// HandlerMiddleware" last_line_contains="type HandlerMiddleware" padding_after="1" %}} A full list of standard middleware can be found in [Middleware]({{< ref "/docs/middlewares" >}}). ## Plugin {{% load-snippet-partial file="src-link/message/router.go" first_line_contains="// RouterPlugin" last_line_contains="type RouterPlugin" padding_after="1" %}} A full list of standard plugins can be found in [message/router/plugin](https://github.com/ThreeDotsLabs/watermill/tree/master/message/router/plugin). ## Context Each message received by handler holds some useful values in the `context`: {{% load-snippet-partial file="src-link/message/router_context.go" first_line_contains="// HandlerNameFromCtx" last_line_contains="func PublishTopicFromCtx" padding_after="2" %}} ================================================ FILE: docs/content/docs/middlewares.md ================================================ +++ title = "Middleware" description = "Add generic functionalities to your handlers in an unobtrusive way" date = 2019-06-01T19:00:00+01:00 weight = -500 draft = false bref = "Add functionality to handlers" +++ ## Introduction Middleware wrap handlers with functionality that is important, but not relevant for the primary handler's logic. Examples include retrying the handler after an error was returned, or recovering from panic in the handler and capturing the stacktrace. Middleware wrap the handler function like this: {{% load-snippet-partial file="src-link/message/router.go" first_line_contains="// HandlerMiddleware" last_line_contains="type HandlerMiddleware" %}} ## Usage Middleware can be executed for all as well as for a specific handler in a router. When middleware is added directly to a router it will be executed for all handlers provided for a router. If a middleware should be executed only for a specific handler, it needs to be added to handler in the router. Example usage is shown below: {{% load-snippet-partial file="src-link/_examples/basic/3-router/main.go" first_line_contains="router, err := message.NewRouter(message.RouterConfig{}, logger)" last_line_contains="// Now that all handlers are registered, we're running the Router." padding_after="1" %}} ## Available middleware Below are the middleware provided by Watermill and ready to use. You can also easily implement your own. For example, if you'd like to store every received message in some kind of log, it's the best way to do it. {{% readfile file="/content/src-link/middleware-defs.md" %}} ================================================ FILE: docs/content/docs/pub-sub.md ================================================ +++ title = "Publisher & Subscriber" description = "Publishers and Subscribers" date = 2018-12-05T12:47:30+01:00 weight = -900 draft = false bref = "Publishers and Subscribers" +++ ## Publisher {{% load-snippet-partial file="src-link/message/pubsub.go" first_line_contains="Publisher interface {" last_line_contains="Close() error" padding_after="1" %}} ### Publishing multiple messages Most publishers implementations don't support atomic publishing of messages. This means that if publishing one of the messages fails, the next messages won't be published. ### Async publish Publish can be synchronous or asynchronous - it depends on the implementation. #### `Close()` `Close` should flush unsent messages if the publisher is asynchronous. **It is important to not forget to close the subscriber**. Otherwise you may lose some of the messages. ## Subscriber {{% load-snippet-partial file="src-link/message/pubsub.go" first_line_contains="Subscriber interface {" last_line_contains="Close() error" padding_after="1" %}} ### Ack/Nack mechanism It is the *Subscriber's* responsibility to handle an `Ack` and a `Nack` from a message. A proper implementation should wait for an `Ack` or a `Nack` before consuming the next message. **Important Subscriber's implementation notice**: Ack/offset to message's storage/broker **must** be sent after Ack from Watermill's message. Otherwise there is a chance to lose messages if the process dies before the messages have been processed. #### `Close()` `Close` closes all subscriptions with their output channels and flushes offsets, etc. when needed. ## At-least-once delivery Watermill is built with [at-least-once delivery](http://www.cloudcomputingpatterns.org/at_least_once_delivery/) semantics. That means when some error occurs when processing a message and an Ack cannot be sent, the message will be redelivered. You need to keep it in mind and build your application to be [idempotent](http://www.cloudcomputingpatterns.org/idempotent_processor/) or implement a deduplication mechanism. Unfortunately, it's not possible to create a universal [*middleware*]({{< ref "/docs/messages-router#middleware" >}}) for deduplication, so we encourage you to build your own. ## Universal tests Every Pub/Sub is similar in most aspects. To avoid implementing separate tests for every Pub/Sub, we've created a test suite which should be passed by any Pub/Sub implementation. These tests can be found in `pubsub/tests/test_pubsub.go`. ## Built-in implementations To check available Pub/Sub implementations, see [Supported Pub/Subs]({{< ref "/pubsubs" >}}). ## Implementing custom Pub/Sub See [Implementing custom Pub/Sub]({{< ref "/development/pub-sub-implementing" >}}) for instructions on how to introduce support for a new Pub/Sub. We will also be thankful for submitting [pull requests](https://github.com/ThreeDotsLabs/watermill/pulls) with the new Pub/Sub implementations. You can also request a new Pub/Sub implementation by submitting a [new issue](https://github.com/ThreeDotsLabs/watermill/issues). ================================================ FILE: docs/content/docs/snippets/amqp-consumer-groups/.validate_example.yml ================================================ validation_cmd: "docker compose up" teardown_cmd: "docker compose down" timeout: 120 expected_output: "payload: Hello, world!" ================================================ FILE: docs/content/docs/snippets/amqp-consumer-groups/docker-compose.yml ================================================ services: server: image: golang:1.25 restart: unless-stopped depends_on: - rabbitmq volumes: - .:/app - $GOPATH/pkg/mod:/go/pkg/mod working_dir: /app command: go run main.go rabbitmq: image: rabbitmq:3.7 restart: unless-stopped ================================================ FILE: docs/content/docs/snippets/amqp-consumer-groups/go.mod ================================================ module main.go require ( github.com/ThreeDotsLabs/watermill v1.5.1 github.com/ThreeDotsLabs/watermill-amqp/v2 v2.1.3 ) require ( github.com/cenkalti/backoff/v3 v3.2.2 // indirect github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/lithammer/shortuuid/v3 v3.0.7 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/rabbitmq/amqp091-go v1.10.0 // indirect ) go 1.25 ================================================ FILE: docs/content/docs/snippets/amqp-consumer-groups/go.sum ================================================ github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY= github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892LARrKAdki6WtXS4= github.com/ThreeDotsLabs/watermill-amqp/v2 v2.1.3 h1:fkhmiBtaLn+rz5lbkPD1h8tXHfKy3gX0vMtGmxNtAsk= github.com/ThreeDotsLabs/watermill-amqp/v2 v2.1.3/go.mod h1:xy2qXKcJpgrJURRT6YwgRyGL3qIi6/sOHrDI0MO/r5I= github.com/cenkalti/backoff/v3 v3.2.2 h1:cfUAAO3yvKMYKPrvhDuHSwQnhZNk/RMHKdZqKTxfm6M= github.com/cenkalti/backoff/v3 v3.2.2/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4rc0ij+ULvLYs= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw= github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: docs/content/docs/snippets/amqp-consumer-groups/main.go ================================================ package main import ( "context" "log" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill-amqp/v2/pkg/amqp" "github.com/ThreeDotsLabs/watermill/message" ) var amqpURI = "amqp://guest:guest@rabbitmq:5672/" func createSubscriber(queueSuffix string) *amqp.Subscriber { subscriber, err := amqp.NewSubscriber( // This config is based on this example: https://www.rabbitmq.com/tutorials/tutorial-three-go.html // to create just a simple queue, you can use NewDurableQueueConfig or create your own config. amqp.NewDurablePubSubConfig( amqpURI, // Rabbit's queue name in this example is based on Watermill's topic passed to Subscribe // plus provided suffix. // // Exchange is Rabbit's "fanout", so when subscribing with suffix other than "test_consumer_group", // it will also receive all messages. It will work like separate consumer groups in Kafka. amqp.GenerateQueueNameTopicNameWithSuffix(queueSuffix), ), watermill.NewStdLogger(false, false), ) if err != nil { panic(err) } return subscriber } func main() { subscriber1 := createSubscriber("test_consumer_group_1") messages1, err := subscriber1.Subscribe(context.Background(), "example.topic") if err != nil { panic(err) } go process("subscriber_1", messages1) subscriber2 := createSubscriber("test_consumer_group_2") messages2, err := subscriber2.Subscribe(context.Background(), "example.topic") if err != nil { panic(err) } // subscriber2 will receive all messages independently from subscriber1 go process("subscriber_2", messages2) publisher, err := amqp.NewPublisher( amqp.NewDurablePubSubConfig( amqpURI, nil, // generateQueueName is not used with publisher ), watermill.NewStdLogger(false, false), ) if err != nil { panic(err) } publishMessages(publisher) } func publishMessages(publisher message.Publisher) { for { msg := message.NewMessage(watermill.NewUUID(), []byte("Hello, world!")) if err := publisher.Publish("example.topic", msg); err != nil { panic(err) } } } func process(subscriber string, messages <-chan *message.Message) { for msg := range messages { log.Printf("[%s] received message: %s, payload: %s", subscriber, msg.UUID, string(msg.Payload)) msg.Ack() } } ================================================ FILE: docs/content/docs/snippets/tail-log-file/go.mod ================================================ module github.com/ThreeDotsLabs/watermill/docs/content/docs/snippets/tail-log-file go 1.25 require ( github.com/ThreeDotsLabs/watermill v1.5.1 github.com/ThreeDotsLabs/watermill-io v1.1.2 ) require ( github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/lithammer/shortuuid/v3 v3.0.7 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/pkg/errors v0.9.1 // indirect ) ================================================ FILE: docs/content/docs/snippets/tail-log-file/go.sum ================================================ github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY= github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892LARrKAdki6WtXS4= github.com/ThreeDotsLabs/watermill-io v1.1.2 h1:t3wismmE++6HbB+fDnSdvLtSKs0yYaSXRtLmyUcRyTk= github.com/ThreeDotsLabs/watermill-io v1.1.2/go.mod h1:DF6rhoPWBOeWRW/1wWjNfLkke8rZsB5BUzBox/L6fRI= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: docs/content/docs/snippets/tail-log-file/main.go ================================================ package main import ( "context" "fmt" "os" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill-io/pkg/io" "github.com/ThreeDotsLabs/watermill/message" ) // this will `tail -f` a log file and publish an alert if a line fulfils some criterion func main() { // if an alert is raised, the offending line will be published on this // this would be set to an actual publisher var alertPublisher message.Publisher if len(os.Args) < 2 { panic( fmt.Errorf("usage: %s /path/to/file.log", os.Args[0]), ) } logFile, err := os.OpenFile(os.Args[1], os.O_RDONLY, 0444) if err != nil { panic(err) } sub, err := io.NewSubscriber(logFile, io.SubscriberConfig{ UnmarshalFunc: io.PayloadUnmarshalFunc, }, watermill.NewStdLogger(true, false)) if err != nil { panic(err) } // for io.Subscriber, topic does not matter lines, err := sub.Subscribe(context.Background(), "") if err != nil { panic(err) } for line := range lines { if criterion(string(line.Payload)) { _ = alertPublisher.Publish("alerts", line) } } } func criterion(line string) bool { // decide whether an action needs to be taken return false } ================================================ FILE: docs/content/docs/troubleshooting.md ================================================ +++ title = "Troubleshooting" description = "When something goes wrong" weight = -90 draft = false bref = "When something goes wrong" +++ ## Logging In most cases, you will find the answer to your problem in the logs. Watermill offers a significant amount of logs on different severity levels. If you are using `StdLoggerAdapter`, just change `debug`, and `trace` options to true: ```bash logger := watermill.NewStdLogger(true, true) ```` ## Debugging Pub/Sub tests ### Running a single test ```bash make up go test -v ./... -run TestPublishSubscribe/TestContinueAfterSubscribeClose ``` ### grep is your friend Each executed test case has a unique UUID. It's used in the topic's name. Thanks to that, you can easily grep the output of the test. It gives you detailed information about the test execution. ```bash > go test -v ./... > test.out > less test.out // ... --- PASS: TestPublishSubscribe (0.00s) --- PASS: TestPublishSubscribe/TestPublishSubscribe (2.38s) --- PASS: TestPublishSubscribe/TestPublishSubscribe/81eeb56c-3336-4eb9-a0ac-13abda6f38ff (2.38s) ``` ```bash cat test.out | grep 81eeb56c-3336-4eb9-a0ac-13abda6f38ff | less [watermill] 2020/08/18 14:51:46.283366 subscriber.go:300: level=TRACE msg="Msg acked" message_uuid=5c920330-5075-4870-8d86-9013771eee78 provider=google_cloud_pubsub subscription_name=topic_81eeb56c-3336-4eb9-a0ac-13abda6f38ff topic=topic_81eeb56c-3336-4eb9-a0ac-13abda6f38ff [watermill] 2020/08/18 14:51:46.283405 subscriber.go:300: level=TRACE msg="Msg acked" message_uuid=46e04a08-994e-4c04-afff-7fd42fd67f95 provider=google_cloud_pubsub subscription_name=topic_81eeb56c-3336-4eb9-a0ac-13abda6f38ff topic=topic_81eeb56c-3336-4eb9-a0ac-13abda6f38ff 2020/08/18 14:51:46 all messages (100/100) received in bulk read after 110.04155ms of 45s (test ID: 81eeb56c-3336-4eb9-a0ac-13abda6f38ff) [watermill] 2020/08/18 14:51:46.284569 subscriber.go:186: level=DEBUG msg="Closing message consumer" provider=google_cloud_pubsub subscription_name=topic_81eeb56c-3336-4eb9-a0ac-13abda6f38ff topic=topic_81eeb56c-3336-4eb9-a0ac-13abda6f38ff [watermill] 2020/08/18 14:51:46.284828 subscriber.go:300: level=TRACE msg="Msg acked" message_uuid=2f409208-d4d2-46f6-b6b9-afb1aea0e59f provider=google_cloud_pubsub subscription_name=topic_81eeb56c-3336-4eb9-a0ac-13abda6f38ff topic=topic_81eeb56c-3336-4eb9-a0ac-13abda6f38ff --- PASS: TestPublishSubscribe/TestPublishSubscribe/81eeb56c-3336-4eb9-a0ac-13abda6f38ff (2.38s) ``` ## I have a deadlock When running locally, you can send a `SIGQUIT` to the running process: - `CTRL + \` on Linux - `kill -s SIGQUIT [pid]` on other UNIX systems This will kill the process and print all goroutines along with lines on which they have stopped. ```bash SIGQUIT: quit PC=0x45e7c3 m=0 sigcode=128 goroutine 1 [runnable]: github.com/ThreeDotsLabs/watermill/pubsub/gochannel.(*GoChannel).sendMessage(0xc000024100, 0x7c5250, 0xd, 0xc000872d70, 0x0, 0x0) /home/example/go/src/github.com/ThreeDotsLabs/watermill/pubsub/gochannel/pubsub.go:83 +0x36a github.com/ThreeDotsLabs/watermill/pubsub/gochannel.(*GoChannel).Publish(0xc000024100, 0x7c5250, 0xd, 0xc000098530, 0x1, 0x1, 0x0, 0x0) /home/example/go/src/github.com/ThreeDotsLabs/watermill/pubsub/gochannel/pubsub.go:53 +0x6d main.publishMessages(0x7fdf7a317000, 0xc000024100) /home/example/go/src/github.com/ThreeDotsLabs/watermill/docs/src-link/_examples/pubsubs/go-channel/main.go:43 +0x1ec main.main() /home/example/go/src/github.com/ThreeDotsLabs/watermill/docs/src-link/_examples/pubsubs/go-channel/main.go:36 +0x20a // ... ``` When running in production and you don't want to kill the entire process, a better idea is to use [pprof](https://golang.org/pkg/net/http/pprof/). You can visit [http://localhost:6060/debug/pprof/goroutine?debug=1](http://localhost:6060/debug/pprof/goroutine?debug=1) on your local machine to see all goroutines status. ```bash goroutine profile: total 5 1 @ 0x41024c 0x6a8311 0x6a9bcb 0x6a948d 0x7028bc 0x70260a 0x42f187 0x45c971 # 0x6a8310 github.com/ThreeDotsLabs/watermill.LogFields.Add+0xd0 /home/example/go/src/github.com/ThreeDotsLabs/watermill/log.go:15 # 0x6a9bca github.com/ThreeDotsLabs/watermill/pubsub/gochannel.(*GoChannel).sendMessage+0x6fa /home/example/go/src/github.com/ThreeDotsLabs/watermill/pubsub/gochannel/pubsub.go:75 # 0x6a948c github.com/ThreeDotsLabs/watermill/pubsub/gochannel.(*GoChannel).Publish+0x6c /home/example/go/src/github.com/ThreeDotsLabs/watermill/pubsub/gochannel/pubsub.go:53 # 0x7028bb main.publishMessages+0x1eb /home/example/go/src/github.com/ThreeDotsLabs/watermill/docs/src-link/_examples/pubsubs/go-channel/main.go:43 # 0x702609 main.main+0x209 /home/example/go/src/github.com/ThreeDotsLabs/watermill/docs/src-link/_examples/pubsubs/go-channel/main.go:36 # 0x42f186 runtime.main+0x206 /usr/lib/go/src/runtime/proc.go:201 // ... ``` ================================================ FILE: docs/content/learn/_index.md ================================================ --- title: "Learn Watermill" description: "Resources to help you learn and master Watermill for building event-driven applications in Go." date: 2024-09-15T10:00:00+00:00 lastmod: 2024-09-15T10:00:00+00:00 draft: false layout: learn hideBanner: true seo: title: "Learn Watermill - Go Event-Driven Applications" description: "Complete learning resources for Watermill - guides, examples, and community resources for building event-driven applications in Go." canonical: "" noindex: false learning_options: - title: "Watermill Quickstart" subtitle: "Code along" description: "Learn how to build an event-driven application in Go, coding in your own IDE." link: "/learn/quickstart/" new: true icon: '' - title: "Getting Started Guide" subtitle: "Read" description: "Start with a guide that covers core concepts of Watermill in a few minutes." link: "/learn/getting-started/" icon: '' icon: '' - title: "Examples" subtitle: "Try out" description: "Real-world examples and patterns for CQRS, the outbox pattern, SSE, and other use cases." link: "https://github.com/ThreeDotsLabs/watermill/tree/master/_examples" icon: ' ' deeper_options: - title: "Go Event-Driven" description: "An in-depth online training on Event-Driven Architecture." link: "https://threedots.tech/event-driven/?utm_source=watermill-learn" icon: '' - title: "Discord Community" description: "Join our Discord community to get help and discuss Watermill and related topics." link: "https://discord.gg/QV6VFg4YQE" external: true icon: '' - title: "GitHub Repository" description: "Read the source code, contribute, report issues, and stay up to date." link: "https://github.com/ThreeDotsLabs/watermill" external: true icon: '' --- ================================================ FILE: docs/content/learn/getting-started.md ================================================ +++ title = "Getting started" description = "Watermill up and running" draft = false bref = "Watermill up and running" weight = 10 +++ ## What is Watermill? Watermill is a Go library for working with messages the easy way. You can use it to build message-driven and event-driven applications with Pub/Subs like Kafka, RabbitMQ, PostgreSQL, and many more. Watermill comes with batteries included. It gives you tools used by every message-driven application. ## Why use Watermill? When you run an HTTP server, you don't deal directly with TCP sockets, parsing HTTP requests, or managing connections. Instead, you use a high-level library like `net/http` that handles all that complexity for you. **It's what Watermill aims to be for messages**. It provides all you need to build an application based on events or other asynchronous patterns. There are many different message queues, each with different features, client libraries, and APIs. Watermill hides all that complexity behind an API that is easy to use and understand. **Watermill is NOT a framework**. It's a lightweight library that's easy to plug in or remove from your project. ## Install ```bash go get -u github.com/ThreeDotsLabs/watermill ``` {{< callout context="note" title="Learn in practice" icon="outline/info-circle" >}} Docs too boring? Prefer learning by doing? [**Try the free hands-on training**]({{< ref "/learn/quickstart/" >}}) where you'll solve exercises to learn how to use Watermill in your projects. It'll guide you through the basics and a few advanced concepts like message ordering and the Outbox pattern. {{< /callout >}} ## One-Minute Background The idea behind event-driven applications is always the same: one part publishes messages, and another part subscribes to them. Watermill supports this behavior for multiple [publishers and subscribers]({{< ref "/pubsubs" >}}). ### Three APIs Watermill comes with three APIs for working with messages. They build on top of each other, each step providing a higher-level API. In this guide, we're going to start from the bottom and move up. It's good to know the fundamentals, even if you're going to use the high-level APIs.
Watermill components pyramid
## Publisher & Subscriber Most Pub/Sub libraries come with complex features. Watermill hides this complexity behind two interfaces: the `Publisher` and `Subscriber`. ```go type Publisher interface { Publish(topic string, messages ...*Message) error Close() error } type Subscriber interface { Subscribe(ctx context.Context, topic string) (<-chan *Message, error) Close() error } ``` ### Creating Messages **The core part of Watermill is the [Message]({{< ref "/docs/message" >}}).** It is what `http.Request` is for the `net/http` package. Most Watermill features work with this struct. Watermill doesn't enforce any message format. `NewMessage` expects a slice of bytes as the payload. You can use strings, JSON, protobuf, Avro, gob, or anything else that serializes to `[]byte`. The message UUID is optional but recommended for debugging. ```go msg := message.NewMessage(watermill.NewUUID(), []byte("Hello, world!")) ``` ### Publishing Messages `Publish` expects a topic and one or more `Message`s to be published. ```go err := publisher.Publish("example.topic", msg) if err != nil { panic(err) } ``` {{< tabs "publishing" >}} {{< tab "Go Channel" "go-channel" >}} {{% load-snippet-partial file="src-link/_examples/pubsubs/go-channel/main.go" first_line_contains="message.NewMessage" last_line_contains="publisher.Publish" padding_after="2" %}} {{< /tab >}} {{< tab "Kafka" "kafka" >}} {{% load-snippet-partial file="src-link/_examples/pubsubs/kafka/main.go" first_line_contains="message.NewMessage" last_line_contains="publisher.Publish" padding_after="2" %}} {{< /tab >}} {{< tab "NATS Streaming" "nats" >}} {{% load-snippet-partial file="src-link/_examples/pubsubs/nats-streaming/main.go" first_line_contains="message.NewMessage" last_line_contains="publisher.Publish" padding_after="2" %}} {{< /tab >}} {{< tab "Google Cloud Pub/Sub" "gcp" >}} {{% load-snippet-partial file="src-link/_examples/pubsubs/googlecloud/main.go" first_line_contains="message.NewMessage" last_line_contains="publisher.Publish" padding_after="2" %}} {{< /tab >}} {{< tab "RabbitMQ (AMQP)" "amqp" >}} {{% load-snippet-partial file="src-link/_examples/pubsubs/amqp/main.go" first_line_contains="message.NewMessage" last_line_contains="publisher.Publish" padding_after="2" %}} {{< /tab >}} {{< tab "SQL" "sql" >}} {{% load-snippet-partial file="src-link/_examples/pubsubs/sql/main.go" first_line_contains="message.NewMessage" last_line_contains="publisher.Publish" padding_after="2" %}} {{< /tab >}} {{< tab "AWS SQS" "aws-sqs" >}} {{% load-snippet-partial file="src-link/_examples/pubsubs/aws-sqs/main.go" first_line_contains="message.NewMessage" last_line_contains="publisher.Publish" padding_after="2" %}} {{< /tab >}} {{< tab "AWS SNS" "aws-sns" >}} {{% load-snippet-partial file="src-link/_examples/pubsubs/aws-sns/main.go" first_line_contains="message.NewMessage" last_line_contains="publisher.Publish" padding_after="2" %}} {{< /tab >}} {{< /tabs >}} ### Subscribing for Messages `Subscribe` expects a topic name and returns a channel of incoming messages. What _topic_ exactly means depends on the Pub/Sub implementation. Usually, it needs to match the topic name used by the publisher. Messages need to be acknowledged after processing by calling the `Ack()` method. ```go messages, err := subscriber.Subscribe(ctx, "example.topic") if err != nil { panic(err) } for msg := range messages { fmt.Printf("received message: %s, payload: %s\n", msg.UUID, string(msg.Payload)) msg.Ack() } ``` See detailed examples below for supported PubSubs. {{< tabs "getting-started" >}} {{< tab "Go Channel" "go-channel" >}} {{% load-snippet-partial file="src-link/_examples/pubsubs/go-channel/main.go" first_line_contains="package main" last_line_contains="process(messages)" %}} {{% load-snippet-partial file="src-link/_examples/pubsubs/go-channel/main.go" first_line_contains="func process" %}} {{< /tab >}} {{< tab "Kafka" "kafka" >}}
Running in Docker The easiest way to run Watermill locally with Kafka is by using Docker. {{% load-snippet file="src-link/_examples/pubsubs/kafka/docker-compose.yml" type="yaml" %}} The source should go to `main.go`. To run, execute the `docker-compose up` command. A more detailed explanation of how it works (and how to add live code reload) can be found in the [*Go Docker dev environment* article](https://threedots.tech/post/go-docker-dev-environment-with-go-modules-and-live-code-reloading/).
{{% load-snippet-partial file="src-link/_examples/pubsubs/kafka/main.go" first_line_contains="package main" last_line_contains="process(messages)" %}} {{% load-snippet-partial file="src-link/_examples/pubsubs/kafka/main.go" first_line_contains="func process" %}} {{< /tab >}} {{< tab "NATS Streaming" "nats" >}}
Running in Docker The easiest way to run Watermill locally with NATS is using Docker. {{% load-snippet file="src-link/_examples/pubsubs/nats-streaming/docker-compose.yml" type="yaml" %}} The source should go to `main.go`. To run, execute the `docker-compose up` command. A more detailed explanation of how it is working (and how to add live code reload) can be found in [*Go Docker dev environment* article](https://threedots.tech/post/go-docker-dev-environment-with-go-modules-and-live-code-reloading/).
{{% load-snippet-partial file="src-link/_examples/pubsubs/nats-streaming/main.go" first_line_contains="package main" last_line_contains="process(messages)" %}} {{% load-snippet-partial file="src-link/_examples/pubsubs/nats-streaming/main.go" first_line_contains="func process" %}} {{< /tabs >}} {{< tab "Google Cloud Pub/Sub" "gcp" >}}
Running in Docker You can run the Google Cloud Pub/Sub emulator locally for development. {{% load-snippet file="src-link/_examples/pubsubs/googlecloud/docker-compose.yml" type="yaml" %}} The source should go to `main.go`. To run, execute `docker-compose up`. A more detailed explanation of how it is working (and how to add live code reload) can be found in [*Go Docker dev environment* article](https://threedots.tech/post/go-docker-dev-environment-with-go-modules-and-live-code-reloading/).
{{% load-snippet-partial file="src-link/_examples/pubsubs/googlecloud/main.go" first_line_contains="package main" last_line_contains="process(messages)" %}} {{% load-snippet-partial file="src-link/_examples/pubsubs/googlecloud/main.go" first_line_contains="func process" %}} {{< /tab >}} {{< tab "RabbitMQ (AMQP)" "amqp" >}}
Running in Docker {{% load-snippet file="src-link/_examples/pubsubs/amqp/docker-compose.yml" type="yaml" %}} The source should go to `main.go`. To run, execute `docker-compose up`. A more detailed explanation of how it is working (and how to add live code reload) can be found in [*Go Docker dev environment* article](https://threedots.tech/post/go-docker-dev-environment-with-go-modules-and-live-code-reloading/).
{{% load-snippet-partial file="src-link/_examples/pubsubs/amqp/main.go" first_line_contains="package main" last_line_contains="process(messages)" %}} {{% load-snippet-partial file="src-link/_examples/pubsubs/amqp/main.go" first_line_contains="func process" %}} {{< /tab >}} {{< tab "SQL" "sql" >}}
Running in Docker {{% load-snippet file="src-link/_examples/pubsubs/sql/docker-compose.yml" type="yaml" %}} The source should go to `main.go`. To run, execute `docker-compose up`. A more detailed explanation of how it is working (and how to add live code reload) can be found in [*Go Docker dev environment* article](https://threedots.tech/post/go-docker-dev-environment-with-go-modules-and-live-code-reloading/).
{{% load-snippet-partial file="src-link/_examples/pubsubs/sql/main.go" first_line_contains="package main" last_line_contains="process(messages)" %}} {{% load-snippet-partial file="src-link/_examples/pubsubs/sql/main.go" first_line_contains="func process" %}} {{< /tab >}} {{< tab "AWS SQS" "aws-sqs" >}}
Running in Docker {{% load-snippet file="src-link/_examples/pubsubs/aws-sqs/docker-compose.yml" type="yaml" %}} The source should go to `main.go`. To run, execute `docker-compose up`. A more detailed explanation of how it is working (and how to add live code reload) can be found in [*Go Docker dev environment* article](https://threedots.tech/post/go-docker-dev-environment-with-go-modules-and-live-code-reloading/).
{{% load-snippet-partial file="src-link/_examples/pubsubs/aws-sqs/main.go" first_line_contains="package main" last_line_contains="process(messages)" %}} {{% load-snippet-partial file="src-link/_examples/pubsubs/aws-sqs/main.go" first_line_contains="func process" %}} {{< /tab >}} {{< tab "AWS SNS" "aws-sns" >}}
Running in Docker {{% load-snippet file="src-link/_examples/pubsubs/aws-sns/docker-compose.yml" type="yaml" %}} The source should go to `main.go`. To run, execute `docker-compose up`. A more detailed explanation of how it is working (and how to add live code reload) can be found in [*Go Docker dev environment* article](https://threedots.tech/post/go-docker-dev-environment-with-go-modules-and-live-code-reloading/).
{{% load-snippet-partial file="src-link/_examples/pubsubs/aws-sns/main.go" first_line_contains="package main" last_line_contains="go process(" padding_after="1" %}} {{% load-snippet-partial file="src-link/_examples/pubsubs/aws-sns/main.go" first_line_contains="func process" %}} {{< /tab >}} {{< /tabs >}} ## Router [*Publishers and subscribers*]({{< ref "/docs/pub-sub" >}}) are the low-level parts of Watermill. For most cases, you want to use a high-level API: the [*Router*]({{< ref "/docs/messages-router" >}}) component. ### Router configuration Start with configuring the router and adding plugins and middlewares. A middleware is a function executed for each incoming message. You can use one of the existing ones for things like [correlation, metrics, poison queue, retrying, throttling, etc.]({{< ref "/docs/messages-router#middleware" >}}). You can also create your own. {{% load-snippet-partial file="src-link/_examples/basic/3-router/main.go" first_line_contains="message.NewRouter" last_line_contains="middleware.Recoverer," padding_after="1" %}} ### Handlers Set up handlers that the router uses. Each handler independently handles incoming messages. A handler listens to messages from the given subscriber and topic. Any messages returned from the handler function will be published to the given publisher and topic. {{% load-snippet-partial file="src-link/_examples/basic/3-router/main.go" first_line_contains="AddHandler returns" last_line_contains=")" padding_after="0" %}} *Note: the example above uses one `pubSub` argument for both the subscriber and publisher. It's because we use the `GoChannel` implementation, which is a simple in-memory Pub/Sub.* Alternatively, if you don't plan to publish messages from within the handler, you can use the simpler `AddConsumerHandler` method. {{% load-snippet-partial file="src-link/_examples/basic/3-router/main.go" first_line_contains="AddConsumerHandler" last_line_contains=")" padding_after="0" %}} You can use two types of *handler functions*: 1. a function `func(msg *message.Message) ([]*message.Message, error)` 2. a struct method `func (c structHandler) Handler(msg *message.Message) ([]*message.Message, error)` Use the first one if your handler is a function without any dependencies. The second option is useful when your handler requires dependencies such as a database handle or a logger. {{% load-snippet-partial file="src-link/_examples/basic/3-router/main.go" first_line_contains="func printMessages" last_line_contains="return message.Messages{msg}, nil" padding_after="3" %}} Finally, run the router. {{% load-snippet-partial file="src-link/_examples/basic/3-router/main.go" first_line_contains="router.Run" last_line_contains="}" padding_after="0" %}} The complete example's source can be found at [/_examples/basic/3-router/main.go](https://github.com/ThreeDotsLabs/watermill/blob/master/_examples/basic/3-router/main.go). ## Logging To see Watermill's logs, pass any logger that implements the [LoggerAdapter](https://github.com/ThreeDotsLabs/watermill/blob/master/log.go). For experimental development, you can use `NewStdLogger`. Watermill provides ready-to-use `slog` adapter. You can create it with [`watermill.NewSlogLogger`](https://github.com/ThreeDotsLabs/watermill/blob/master/slog.go). You can also map Watermill's log levels to `slog` levels with [`watermill.NewSlogLoggerWithLevelMapping`](https://github.com/ThreeDotsLabs/watermill/blob/master/slog.go). ## What's next? See the [CQRS component](/docs/cqrs) for the generic high-level API. For more details, see [documentation topics]({{< ref "/docs" >}}). [The Outbox Pattern](/advanced/forwarder/) is a key pattern to know in event-driven applications. We recommend checking the examples below to see how Watermill works in practice. You can also try the [free hands-on training]({{< ref "/learn/quickstart/" >}}) to learn how to use Watermill in practice. ## Examples Check out the [examples](https://github.com/ThreeDotsLabs/watermill/tree/master/_examples) that will show you how to start using Watermill. The recommended entry point is [Your first Watermill application](https://github.com/ThreeDotsLabs/watermill/tree/master/_examples/basic/1-your-first-app). It contains the entire environment in `docker-compose.yml`, including Go and Kafka, which you can run with one command. After that, you can see the [Realtime feed](https://github.com/ThreeDotsLabs/watermill/tree/master/_examples/basic/2-realtime-feed) example. It uses more middlewares and contains two handlers. For a different subscriber implementation (**HTTP**), see the [receiving-webhooks](https://github.com/ThreeDotsLabs/watermill/tree/master/_examples/real-world-examples/receiving-webhooks) example. It is a straightforward application that saves webhooks to Kafka. You can find the complete list of examples in the [README](https://github.com/ThreeDotsLabs/watermill#examples). ## Support If anything is not clear, feel free to use any of our [support channels]({{< ref "/support" >}}); we will be glad to help. ================================================ FILE: docs/content/learn/quickstart.md ================================================ --- title: "Watermill Quickstart" description: "Learn how to use Watermill in your project with a hands-on training." draft: false toc: false weight: 20 hideBanner: true seo: title: "Watermill Quickstart - Hands-on Training" description: "Learn how to use Watermill in your project with a hands-on training covering Publisher/Subscriber, Router, CQRS, Kafka, and more." canonical: "" noindex: false --- {{< callout context="note" title="Just released 🎉" >}}

Check our new, interactive quickstart that you can finish in your IDE. Zero Docker setup, zero coding in browser.

{{< /callout >}} Watermill Quickstart is a hands-on training that will teach you how to use Watermill, a Go library for building event-driven applications, in your project. Basic Go knowledge is all you need to get started. You'll learn the basics of Watermill and a few more advanced concepts: * The 3 core APIs: Publisher/Subscriber, Router, and CQRS. * Working with Kafka. * Topic topology, middleware, consumer groups. * Message ordering. * The Outbox pattern (with PostgreSQL). * Switching the Pub/Sub (Redis).
Start the Watermill Quickstart
================================================ FILE: docs/content/pubsubs/_index.md ================================================ +++ title = "Supported Pub/Subs" bref = "Watermill supports these Pub/Sub adapters out of the box:" +++ ================================================ FILE: docs/content/pubsubs/amqp.md ================================================ +++ title = "RabbitMQ (AMQP)" description = "The most widely deployed open source message broker" date = 2019-07-06T22:30:00+02:00 bref = "The most widely deployed open source message broker" weight = 100 +++ > RabbitMQ is the most widely deployed open source message broker. We are providing Pub/Sub implementation based on [github.com/rabbitmq/amqp091-go](https://github.com/rabbitmq/amqp091-go) official library. {{% load-snippet-partial file="src-link/watermill-amqp/pkg/amqp/doc.go" first_line_contains="// AMQP" last_line_contains="package amqp" padding_after="0" %}} You can find a fully functional example with RabbitMQ in the [Watermill examples](https://github.com/ThreeDotsLabs/watermill/tree/master/_examples/pubsubs/amqp). ## Installation ```bash go get github.com/ThreeDotsLabs/watermill-amqp/v3 ``` ### Characteristics | Feature | Implements | Note | | ------- | ---------- | ---- | | ConsumerGroups | yes* | there are no literal consumer groups in AMQP, but we can achieve similar behaviour with `GenerateQueueNameTopicNameWithSuffix`. For more details please check [AMQP "Consumer Groups" section](#amqp-consumer-groups) | | ExactlyOnceDelivery | no | | | GuaranteedOrder | yes | yes, please check https://www.rabbitmq.com/semantics.html#ordering | | Persistent | yes* | when using `NewDurablePubSubConfig` or `NewDurableQueueConfig` | ### Configuration Our AMQP is shipped with some pre-created configurations: {{% load-snippet-partial file="src-link/watermill-amqp/pkg/amqp/config.go" first_line_contains="// NewDurablePubSubConfig" last_line_contains="type Config struct {" %}} For detailed configuration description, please check [watermill-amqp/pkg/amqp/config.go](https://github.com/ThreeDotsLabs/watermill-amqp/tree/master/pkg/amqp/config.go) #### TLS Config TLS config can be passed to `Config.TLSConfig`. #### Connecting {{% load-snippet-partial file="src-link/_examples/pubsubs/amqp/main.go" first_line_contains="publisher, err :=" last_line_contains="panic(err)" padding_after="1" %}} {{% load-snippet-partial file="src-link/_examples/pubsubs/amqp/main.go" first_line_contains="subscriber, err :=" last_line_contains="panic(err)" padding_after="1" %}} ### Publishing {{% load-snippet-partial file="src-link/watermill-amqp/pkg/amqp/publisher.go" first_line_contains="// Publish" last_line_contains="func (p *Publisher) Publish" %}} ### Subscribing {{% load-snippet-partial file="src-link/watermill-amqp/pkg/amqp/subscriber.go" first_line_contains="// Subscribe" last_line_contains="func (s *Subscriber) Subscribe" %}} ### Marshaler Marshaler is responsible for mapping AMQP's messages to Watermill's messages. Marshaller can be changed via the Configuration. If you need to customize thing in `amqp.Delivery`, you can do it `PostprocessPublishing` function. {{% load-snippet-partial file="src-link/watermill-amqp/pkg/amqp/marshaler.go" first_line_contains="// Marshaler" last_line_contains="func (d DefaultMarshaler)" padding_after="0" %}} ### AMQP "Consumer Groups" AMQP doesn't provide mechanism like Kafka's "consumer groups". You can still achieve similar behaviour with `GenerateQueueNameTopicNameWithSuffix` and `NewDurablePubSubConfig`. {{% load-snippet-partial file="docs/snippets/amqp-consumer-groups/main.go" first_line_contains="func createSubscriber(" last_line_contains="go process(\"subscriber_2\", messages2)" %}} In this example both `pubSub1` and `pubSub2` will receive some messages independently. ### AMQP `TopologyBuilder` {{% load-snippet-partial file="src-link/watermill-amqp/pkg/amqp/topology_builder.go" first_line_contains="// TopologyBuilder" last_line_contains="}" padding_after="0" %}} ================================================ FILE: docs/content/pubsubs/aws.md ================================================ +++ title = "Amazon AWS SNS/SQS" description = "AWS SQS and SNS are fully-managed message queuing and Pub/Sub-like services that make it easy to decouple and scale microservices, distributed systems, and serverless applications." date = 2024-10-19T15:30:00+02:00 bref = "AWS SQS and SNS are fully-managed message queuing and Pub/Sub-like services that make it easy to decouple and scale microservices, distributed systems, and serverless applications." weight = 10 +++ AWS SQS and SNS are fully-managed message queuing and Pub/Sub-like services that make it easy to decouple and scale microservices, distributed systems, and serverless applications. Watermill provides a simple way to use AWS SQS and SNS with Go. It handles all the AWS SDK internals and provides a simple API to publish and subscribe messages. Official Documentation: - [SQS](https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/welcome.html) - [SNS](https://docs.aws.amazon.com/sns/latest/dg/welcome.html) You can find a fully functional example with AWS SNS in the Watermill examples: - [SNS](https://github.com/ThreeDotsLabs/watermill/tree/master/_examples/pubsubs/aws-sns) - [SQS](https://github.com/ThreeDotsLabs/watermill/tree/master/_examples/pubsubs/aws-sqs) ## Installation ```bash go get github.com/ThreeDotsLabs/watermill-aws ``` ## SQS vs SNS While both SQS and SNS are messaging services provided by AWS, they serve different purposes and are best suited for different scenarios in your Watermill applications. ### How SNS is connected with SQS To use SNS as a Pub/Sub (to have multiple subscribers receiving the same message), you need to create an SNS topic and subscribe to SQS queues. When a message is published to the SNS topic, it will be delivered to all subscribed SQS queues. We implemented this logic in the `watermill-aws` package out of the box. When you subscribe to an SNS topic, Watermill AWS creates an SQS queue and subscribes to it. [![](https://mermaid.ink/img/pako:eNptkU1uwyAQRq-CWLWScwEW2TjbVnboru4Cw9hB5ccZoFIV5e7FxVRKUjaM5j0-jZgLlV4BZTTAOYGTcNBiRmEHR_JZBEYt9SJcJB0RgfBXTro0Gh1OgI_OijfrzS9a_mP0xchXnyABeXcfj1ZbU3gag0Q9AhaxqN1uv8-U1VGIhRDEDKHgjFahzwIHpyot0Hi_lKqtUueNIZPH-5ie77LSMnKEmNDd5rQFdehlblf2FJ7vwg9gIMLt2zV5w0ew_usPkwm9Jef1Y4qZx6cNtYBWaJW3dFnbA40nsDBQlksl8HOgg7tmT6To-beTlEVM0NC0KBHrRimbhAm5C0pHjy9l7b_bbyj6NJ824_oDU0CtBA?type=png)](https://mermaid.live/edit#pako:eNptkU1uwyAQRq-CWLWScwEW2TjbVnboru4Cw9hB5ccZoFIV5e7FxVRKUjaM5j0-jZgLlV4BZTTAOYGTcNBiRmEHR_JZBEYt9SJcJB0RgfBXTro0Gh1OgI_OijfrzS9a_mP0xchXnyABeXcfj1ZbU3gag0Q9AhaxqN1uv8-U1VGIhRDEDKHgjFahzwIHpyot0Hi_lKqtUueNIZPH-5ie77LSMnKEmNDd5rQFdehlblf2FJ7vwg9gIMLt2zV5w0ew_usPkwm9Jef1Y4qZx6cNtYBWaJW3dFnbA40nsDBQlksl8HOgg7tmT6To-beTlEVM0NC0KBHrRimbhAm5C0pHjy9l7b_bbyj6NJ824_oDU0CtBA) We can say, that a single SQS queue acts as a consumer group or subscription in other Pub/Sub implementations. The mechanism is detailed in [AWS documentation](https://docs.aws.amazon.com/sns/latest/dg/subscribe-sqs-queue-to-sns-topic.html). ### How to choose between SQS and SNS #### SQS (Simple Queue Service) - Use when you need a simple message queue with a single consumer. - Great for task queues or background job processing. - Supports exactly-once processing (with FIFO queues) and guaranteed order (mostly). Example use case: Processing user uploads in the background. [![](https://mermaid.ink/img/pako:eNplkT1uwzAMRq8icGoB5wIasjhrASdevdASHQvVj0NJAYogd69c2wmaaCL4HilI3w1U0AQSIl0yeUUHg2dG13lRzoScjDIT-iQagVG0x1Y0ubcmjsTvzoxX65gp07tRb7zNfVRs-nnNojW7_b4QuV0gHMWIZ4oLtiFMS1U_xGCtGAK_mIXtilJLcaKU2W_4OV1Qw0GV9sY-4ufL8gNZSvR_dt684hO5cH1gMXBw4vJ8M3kNFThih0aX773N7Q7SSI46kKXUyN8ddP5ePMwptD9egUycqYI8aUxbFCAHtLF0SZsU-GvJ6y-2Cjjk87ga91_dnpaW?type=png)](https://mermaid.live/edit#pako:eNplkT1uwzAMRq8icGoB5wIasjhrASdevdASHQvVj0NJAYogd69c2wmaaCL4HilI3w1U0AQSIl0yeUUHg2dG13lRzoScjDIT-iQagVG0x1Y0ubcmjsTvzoxX65gp07tRb7zNfVRs-nnNojW7_b4QuV0gHMWIZ4oLtiFMS1U_xGCtGAK_mIXtilJLcaKU2W_4OV1Qw0GV9sY-4ufL8gNZSvR_dt684hO5cH1gMXBw4vJ8M3kNFThih0aX773N7Q7SSI46kKXUyN8ddP5ePMwptD9egUycqYI8aUxbFCAHtLF0SZsU-GvJ6y-2Cjjk87ga91_dnpaW) #### SNS (Simple Notification Service) - Use when you need to broadcast messages to multiple subscribers. - Perfect for implementing pub/sub patterns. - Useful for event-driven architectures. - Supports multiple types of subscribers (SQS, Lambda, HTTP/S, email, SMS, etc.). Example use case: Notifying multiple services about a new user registration. Our SNS implementation in Watermill automatically creates and manages SQS queues for each subscriber, simplifying the process of using SNS with multiple SQS queues. Remember, you can use both in the same application where appropriate. For instance, you might use SNS to broadcast events and SQS to process specific tasks triggered by those events. To learn how SNS and SQS work together, see the [How SNS is connected with SQS](#how-sns-is-connected-with-sqs) section. ## SQS ### Characteristics | Feature | Implements | Note | |---------------------|------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | ConsumerGroups | no | it's a queue, for consumer groups-like functionality use [SNS](#sns) | | ExactlyOnceDelivery | no | [yes](https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/FIFO-queues-exactly-once-processing.html) | | GuaranteedOrder | yes\* | from [AWS Docs](https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/standard-queues.html): _"(...) due to the highly distributed architecture, more than one copy of a message might be delivered, and messages may occasionally arrive out of order. Despite this, standard queues make a best-effort attempt to maintain the order in which messages are sent."_ | | Persistent | yes | | ### Required permissions - `"sqs:ReceiveMessage"` - `"sqs:DeleteMessage"` - `"sqs:GetQueueUrl"` - `"sqs:CreateQueue"` - `"sqs:GetQueueAttributes"` - `"sqs:SendMessage"` - `"sqs:ChangeMessageVisibility"` [todo - verify] ### SQS Configuration {{% load-snippet-partial file="src-link/watermill-aws/sqs/config.go" first_line_contains="type SubscriberConfig struct " last_line_contains="type GenerateCreateQueueInputFunc" %}} ### Resolving Queue URL In the Watermill model, we are normalizing the AWS queue url to `topic` used in the `Publish` and `Subscribe` methods. To give you flexibility of what you want to use as a topic in Watermill, you can customize resolving the queue URL. {{% load-snippet-partial file="src-link/watermill-aws/sqs/url_resolver.go" first_line_contains="// QueueUrlResolver" last_line_contains="GenerateQueueUrlResolver" %}} You can implement your own `QueueUrlResolver` or use one of the provided resolvers. By default, `GetQueueUrlByNameUrlResolver` resolver is used: {{% load-snippet-partial file="src-link/watermill-aws/sqs/url_resolver.go" first_line_contains="// GetQueueUrlByNameUrlResolver " last_line_contains="NewGetQueueUrlByNameUrlResolver" %}} There are two more resolvers available: {{% load-snippet-partial file="src-link/watermill-aws/sqs/url_resolver.go" first_line_contains="// GenerateQueueUrlResolver" last_line_contains="}" %}} {{% load-snippet-partial file="src-link/watermill-aws/sqs/url_resolver.go" first_line_contains="// TransparentUrlResolver" last_line_contains="}" %}} ### Using with SQS emulator You may want to use [`goaws`](https://github.com/Admiral-Piett/goaws) or [`localstack`](https://hub.docker.com/r/localstack/localstack) for local development or testing. You can override the endpoint using the `OptFns` option in the `SubscriberConfig` or `PublisherConfig`. ```go package main import ( amazonsqs "github.com/aws/aws-sdk-go-v2/service/sqs" "github.com/ThreeDotsLabs/watermill-amazonsqs/sqs" ) func main() { // ... sqsOpts := []func(*amazonsqs.Options){ amazonsqs.WithEndpointResolverV2(sqs.OverrideEndpointResolver{ Endpoint: transport.Endpoint{ URI: *lo.Must(url.Parse("http://localstack:4566")), }, }), } sqsConfig := sqs.SubscriberConfig{ AWSConfig: cfg, OptFns: sqsOpts, } sub, err := sqs.NewSubscriber(sqsConfig, logger) if err != nil { panic(fmt.Errorf("unable to create new subscriber: %w", err)) } // ... } ``` ## SNS ### Characteristics | Feature | Implements | Note | |---------------------|------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | ConsumerGroups | yes | yes | | ExactlyOnceDelivery | no | [yes](https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/FIFO-queues-exactly-once-processing.html) | | GuaranteedOrder | yes\* | from [AWS Docs](https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/standard-queues.html): _"(...) due to the highly distributed architecture, more than one copy of a message might be delivered, and messages may occasionally arrive out of order. Despite this, standard queues make a best-effort attempt to maintain the order in which messages are sent."_ | | Persistent | yes | | ### Required permissions - `sns:Subscribe` - `sns:ConfirmSubscription` - `sns:Receive` - `sns:Unsubscribe` and all permissions required for SQS: - `sqs:ReceiveMessage` - `sqs:DeleteMessage` - `sqs:GetQueueUrl` - `sqs:CreateQueue` - `sqs:GetQueueAttributes` - `sqs:SendMessage` - `sqs:ChangeMessageVisibility` - `sqs:SetQueueAttributes` Additionally, if `sns.SubscriberConfig.DoNotSetQueueAccessPolicy` is not enabled, you should have the following: - `sqs:SetQueueAttributes` ### SNS Configuration {{% load-snippet-partial file="src-link/watermill-aws/sns/config.go" first_line_contains="type SubscriberConfig struct " last_line_contains="type GenerateSqsQueueNameFn" %}} Additionally, because SNS Subscriber uses SQS queues as "subscriptions", you need to pass [SQS configuration](#sqs-configuration) as well. ### Resolving Queue URL In the Watermill model, we normalise AWS Topic ARN to the `topic` used in the `Publish` and `Subscribe` methods. {{% load-snippet-partial file="src-link/watermill-aws/sns/topic.go" first_line_contains="// TopicResolver" last_line_contains="}" %}} We are providing two out-of-the-box resolvers: {{% load-snippet-partial file="src-link/watermill-aws/sns/topic.go" first_line_contains="// TransparentTopicResolver" last_line_contains="}" %}} {{% load-snippet-partial file="src-link/watermill-aws/sns/topic.go" first_line_contains="// GenerateArnTopicResolver" last_line_contains="}" %}} ### Using with SNS emulator You may want to use [`goaws`](https://github.com/Admiral-Piett/goaws) or [`localstack`](https://hub.docker.com/r/localstack/localstack) for local development or testing. You can override the endpoint using the `OptFns` option in the `SubscriberConfig` or `PublisherConfig`. ```go package main import ( amazonsns "github.com/aws/aws-sdk-go-v2/service/sns" "github.com/ThreeDotsLabs/watermill-amazonsns/sns" ) func main() { // ... snsOpts := []func(*amazonsns.Options){ amazonsns.WithEndpointResolverV2(sns.OverrideEndpointResolver{ Endpoint: transport.Endpoint{ URI: *lo.Must(url.Parse("http://localstack:4566")), }, }), } snsConfig := sns.SubscriberConfig{ AWSConfig: cfg, OptFns: snsOpts, } sub, err := sns.NewSubscriber(snsConfig, sqsConfig, logger) if err != nil { panic(fmt.Errorf("unable to create new subscriber: %w", err)) } // ... } ``` ================================================ FILE: docs/content/pubsubs/bolt.md ================================================ +++ title = "Bolt Pub/Sub" description = "A pure Go key/value store" date = 2021-11-19T00:00:00+02:00 bref = "A pure Go key/value store" weight = 20 +++ Bolt is a pure Go key/value store which provides a simple, fast, and reliable database for projects that don't require a full database server such as Postgres or MySQL. Bolt backed Pub/Sub is good for simple applications which don't need a more advanced Pub/Sub system with external dependencies or already use Bolt and want to publish messages in transaction when saving other data. Bolt documentation: https://github.com/etcd-io/bbolt ## Installation ```bash go get github.com/ThreeDotsLabs/watermill-bolt ``` ### Characteristics | Feature | Implements | Note | | ------------------- | ---------- | ---- | | ConsumerGroups | no | | | ExactlyOnceDelivery | no | | | GuaranteedOrder | no | | | Persistent | yes | | ### Configuration {{% load-snippet-partial file="src-link/watermill-bolt/pkg/bolt/bolt.go" first_line_contains="type CommonConfig struct " last_line_equals="}" %}} {{% load-snippet-partial file="src-link/watermill-bolt/pkg/bolt/bolt.go" first_line_contains="type PublisherConfig struct " last_line_equals="}" %}} {{% load-snippet-partial file="src-link/watermill-bolt/pkg/bolt/bolt.go" first_line_contains="type SubscriberConfig struct " last_line_equals="}" %}} #### Subscription name To receive messages published to a topic, you must create a subscription to that topic. Only messages published to the topic after the subscription is created will be received by the subscriber. A topic can have multiple subscriptions, but a given subscription belongs to a single topic. In Watermill, the subscription is created automatically during calling `Subscribe()`. Subscription name is generated by calling the function set as `SubscriberConfig.GenerateSubscriptionName`. By default, it is the topic name with the string `_sub` appended to it. #### Marshaler Watermill's messages cannot be directly saved in Bolt which operates on byte slices. Marshaller converts the messages to and from byte slices. The default implementation marshals messages as JSON, a format which is human-readable for easier debugging. The performance should be enough for most applications unless a very large messages are used within your system. If that is the case you may want to consider implementing a more efficient marshaler. {{% load-snippet-partial file="src-link/watermill-bolt/pkg/bolt/marshaler.go" first_line_contains="// Marshaler" last_line_equals="}" %}} ================================================ FILE: docs/content/pubsubs/firestore.md ================================================ +++ title = "Firestore Pub/Sub" description = "A scalable document database from Google" date = 2021-07-29T15:30:00+02:00 bref = "A scalable document database from Google" weight = 30 +++ Cloud Firestore is a cloud-hosted, NoSQL database from Google. This Pub/Sub comes with two publishers. To publish messages in a transaction use the `TransactionalPublisher`. If you do not want to publish messages in transaction use the normal `Publisher`. Using Firestore as a Pub/Sub instead of using a dedicated Pub/Sub system can be useful to publish messages in transaction while at the same time saving other data in Firestore. Thanks to that the data and the messages can be consistently persisted. If the messages and the data weren't being published transactionally you could end up in situations where messages were emitted even though the data wasn't saved or messages weren't emitted even though the data was saved. After transactionally publishing messages in Firestore you can then subscribe to them and relay them to a different Pub/Sub system. Godoc: Firestore documentation: ## Installation ```bash go get github.com/ThreeDotsLabs/watermill-firestore ``` ### Characteristics | Feature | Implements | Note | | ------- | ---------- | ---- | | ConsumerGroups | yes | | | ExactlyOnceDelivery | no | | | GuaranteedOrder | no | | | Persistent | yes | | ### Configuration #### Publisher configuration {{% load-snippet-partial file="src-link/watermill-firestore/pkg/firestore/publisher.go" first_line_contains="type PublisherConfig struct {" last_line_equals="}" %}} #### Subscriber configuration {{% load-snippet-partial file="src-link/watermill-firestore/pkg/firestore/subscriber.go" first_line_contains="type SubscriberConfig struct {" last_line_equals="}" %}} #### Subscription name To receive messages published to a topic, you must create a subscription to that topic. Only messages published to the topic after the subscription is created will be received by the subscribers. A topic can have multiple subscriptions, but a given subscription belongs to a single topic. In Watermill, the subscription is created automatically during calling `Subscribe()`. Subscription name is generated by function passed to `SubscriberConfig.GenerateSubscriptionName`. By default, it is just the topic name with a suffix `_sub` appended to it. If you want to consume messages from a topic with multiple subscribers processing the incoming messages in a different way, you should use a custom function to generate unique subscription names for each subscriber. ### Marshaler Watermill's messages cannot be stored directly in Firestore. The marshaler is responsible for converting them to a type which can be stored by Firestore. The default implementation should be enough for most applications so it is unlikely that you need to implement your own marshaler. {{% load-snippet-partial file="src-link/watermill-firestore/pkg/firestore/marshaler.go" first_line_contains="// Marshaler" last_line_equals="}" padding_after="0" %}} ================================================ FILE: docs/content/pubsubs/gochannel.md ================================================ +++ title = "Go Channel" description = "A Pub/Sub implemented on Golang goroutines and channels" date = 2019-07-06T22:30:00+02:00 bref = "A Pub/Sub implemented on Golang goroutines and channels" weight = 40 +++ {{% load-snippet-partial file="src-link/pubsub/gochannel/pubsub.go" first_line_contains="// GoChannel" last_line_contains="type GoChannel struct {" %}} You can find a fully functional example with Go Channels in the [Watermill examples](https://github.com/ThreeDotsLabs/watermill/tree/master/_examples/pubsubs/go-channel). ### Characteristics | Feature | Implements | Note | | ------- | ---------- | ---- | | ConsumerGroups | no | | | ExactlyOnceDelivery | yes | | | GuaranteedOrder | yes | | | Persistent | no| | ### Configuration You can inject configuration via the constructor. {{% load-snippet-partial file="src-link/pubsub/gochannel/pubsub.go" first_line_contains="func NewGoChannel" last_line_contains="logger:" %}} ### Publishing {{% load-snippet-partial file="src-link/pubsub/gochannel/pubsub.go" first_line_contains="// Publish" last_line_contains="func (g *GoChannel) Publish" %}} ### Subscribing {{% load-snippet-partial file="src-link/pubsub/gochannel/pubsub.go" first_line_contains="// Subscribe" last_line_contains="func (g *GoChannel) Subscribe" %}} ### Marshaler No marshaling is needed when sending messages within the process. ================================================ FILE: docs/content/pubsubs/googlecloud.md ================================================ +++ title = "Google Cloud Pub/Sub" description = "The fully-managed real-time messaging service from Google" date = 2019-07-06T22:30:00+02:00 bref = "The fully-managed real-time messaging service from Google" weight = 50 +++ Cloud Pub/Sub brings the flexibility and reliability of enterprise message-oriented middleware to the cloud. At the same time, Cloud Pub/Sub is a scalable, durable event ingestion and delivery system that serves as a foundation for modern stream analytics pipelines. By providing many-to-many, asynchronous messaging that decouples senders and receivers, it allows for secure and highly available communication among independently written applications. Cloud Pub/Sub delivers low-latency, durable messaging that helps developers quickly integrate systems hosted on the Google Cloud Platform and externally. Official Documentation: [https://cloud.google.com/pubsub/docs/](https://cloud.google.com/pubsub/docs/overview) You can find a fully functional example with Google Cloud Pub/Sub in the [Watermill examples](https://github.com/ThreeDotsLabs/watermill/tree/master/_examples/pubsubs/googlecloud). ## Installation ```bash go get github.com/ThreeDotsLabs/watermill-googlecloud/v2 ``` ### Characteristics | Feature | Implements | Note | | ------- | ---------- | ---- | | ConsumerGroups | yes | multiple subscribers within the same Subscription name | | ExactlyOnceDelivery | no | | | GuaranteedOrder | no | | | Persistent | yes* | maximum retention time is 7 days | ### Configuration {{% load-snippet-partial file="src-link/watermill-googlecloud/pkg/googlecloud/publisher.go" first_line_contains="type PublisherConfig struct " last_line_contains="func NewPublisher" %}} {{% load-snippet-partial file="src-link/watermill-googlecloud/pkg/googlecloud/subscriber.go" first_line_contains="type SubscriberConfig struct {" last_line_contains="func NewSubscriber(" %}} #### Subscription name To receive messages published to a topic, you must create a subscription to that topic. Only messages published to the topic after the subscription is created are available to subscriber applications. The subscription connects the topic to a subscriber application that receives and processes messages published to the topic. A topic can have multiple subscriptions, but a given subscription belongs to a single topic. In Watermill, the subscription is created automatically during calling `Subscribe()`. Subscription name is generated by function passed to `SubscriberConfig.GenerateSubscriptionName`. By default, it is just the topic name (`TopicSubscriptionName`). When you want to consume messages from a topic with multiple subscribers, you should use `TopicSubscriptionNameWithSuffix` or your custom function to generate the subscription name. ### Connecting Watermill will connect to the instance of Google Cloud Pub/Sub indicated by the environment variables. For production setup, set the `GOOGLE_APPLICATION_CREDENTIALS` env, as described in [the official Google Cloud Pub/Sub docs](https://cloud.google.com/pubsub/docs/quickstart-client-libraries#pubsub-client-libraries-go). Note that you won't need to install the Cloud SDK, as Watermill will take care of the administrative tasks (creating topics/subscriptions) with the default settings and proper permissions. For development, you can use a Docker image with the emulator and the `PUBSUB_EMULATOR_HOST` env ([check out the Getting Started guide]({{< ref "getting-started#subscribing_gcloud" >}})). {{% load-snippet-partial file="src-link/_examples/pubsubs/googlecloud/main.go" first_line_contains="publisher, err :=" last_line_contains="panic(err)" padding_after="1" %}} {{% load-snippet-partial file="src-link/_examples/pubsubs/googlecloud/main.go" first_line_contains="subscriber, err :=" last_line_contains="panic(err)" padding_after="1" %}} ### Publishing {{% load-snippet-partial file="src-link/watermill-googlecloud/pkg/googlecloud/publisher.go" first_line_contains="// Publish" last_line_contains="func (p *Publisher) Publish" %}} ### Subscribing {{% load-snippet-partial file="src-link/watermill-googlecloud/pkg/googlecloud/subscriber.go" first_line_contains="// Subscribe " last_line_contains="func (s *Subscriber) Subscribe" %}} ### Marshaler Watermill's messages cannot be directly sent to Google Cloud Pub/Sub - they need to be marshaled. You can implement your marshaler or use the default implementation. {{% load-snippet-partial file="src-link/watermill-googlecloud/pkg/googlecloud/marshaler.go" first_line_contains="// Marshaler" last_line_contains="type DefaultMarshalerUnmarshaler " padding_after="0" %}} ================================================ FILE: docs/content/pubsubs/http.md ================================================ +++ title = "HTTP" description = "Call and listen to webhooks asynchronously" date = 2019-07-06T22:30:00+02:00 bref = "Call and listen to webhooks asynchronously" weight = 60 +++ The HTTP subscriber listens to HTTP requests (for example - webhooks) and outputs them as messages. You can then post them to any Publisher. Here is an example with [sending HTTP messages to Kafka](https://github.com/ThreeDotsLabs/watermill/blob/master/_examples/real-world-examples/receiving-webhooks/main.go). The HTTP publisher sends HTTP requests as specified in its configuration. Here is an example with [transforming Kafka messages into HTTP webhook requests](https://github.com/ThreeDotsLabs/watermill/tree/master/_examples/real-world-examples/sending-webhooks). ## Installation ```bash go get github.com/ThreeDotsLabs/watermill-http/v2 ``` ### Characteristics | Feature | Implements | Note | | ------- | ---------- | ---- | | ConsumerGroups | no | | | ExactlyOnceDelivery | yes | | | GuaranteedOrder | yes | | | Persistent | no| | ### Subscriber configuration Subscriber configuration is done via the config struct passed to the constructor: {{% load-snippet-partial file="src-link/watermill-http/pkg/http/subscriber.go" first_line_contains="type SubscriberConfig struct" last_line_contains="}" %}} You can use the `Router` config option to `SubscriberConfig` to pass your own `chi.Router` (see [chi](https://github.com/go-chi/chi)). This may be helpful if you'd like to add your own HTTP handlers (e.g. a health check endpoint). ### Publisher configuration Publisher configuration is done via the config struct passed to the constructor: {{% load-snippet-partial file="src-link/watermill-http/pkg/http/publisher.go" first_line_contains="type PublisherConfig struct" last_line_contains="}" %}} How the message topic and body translate into the URL, method, headers, and payload of the HTTP request is highly configurable through the use of `MarshalMessageFunc`. Use the provided `DefaultMarshalMessageFunc` to send POST requests to a specific url: {{% load-snippet-partial file="src-link/watermill-http/pkg/http/publisher.go" first_line_contains="// MarshalMessageFunc" last_line_contains="return req, nil" padding_after="2" %}} You can pass your own `http.Client` to execute the requests or use Golang's default client. ### Running To run HTTP subscriber you need to run `StartHTTPServer()`. It needs to be run after `Subscribe()`. When using with the router, you should wait for the router to start. ```go <-r.Running() httpSubscriber.StartHTTPServer() ``` ### Subscribing {{% load-snippet-partial file="src-link/watermill-http/pkg/http/subscriber.go" first_line_contains="// Subscribe adds" last_line_contains="func (s *Subscriber) Subscribe" %}} #### Custom HTTP status codes To specify a custom HTTP status code, which will returned as response, you can use following call during message handling: ```go // msg is a *message.Message http.SetResponseStatusCode(msg, http.StatusForbidden) msg.Nack() ``` ================================================ FILE: docs/content/pubsubs/io.md ================================================ +++ title = "io.Writer/io.Reader" description = "Pub/Sub implemented as Go stdlib's most loved interfaces" date = 2019-07-06T22:30:00+02:00 bref = "Pub/Sub implemented as Go stdlib's most loved interfaces" weight = 70 +++ This is an experimental Pub/Sub implementation that leverages the [standard library's](https://golang.org/pkg/io/) `io.Writer` and `io.Reader` interfaces as sources of Publisher and Subscriber, respectively. Note that these aren't full-fledged Pub/Subs like Kafka, RabbitMQ, or the likes, but given the ubiquity of implementations of `Writer` and `Reader` they may come in handy, for uses like: * Writing messages to file or stdout * Subscribing for data on a file or stdin and packaging it as messages * Interfacing with third-party libraries that implement `io.Writer` or `io.Reader`, like [github.com/colinmarc/hdfs](https://github.com/colinmarc/hdfs) or [github.com/mholt/archiver](https://github.com/mholt/archiver). ## Installation ```bash go get github.com/ThreeDotsLabs/watermill-io ``` ### Characteristics This is a very bare-bones implementation for now, so no extra features are supported. However, it is still sufficient for applications like a [CLI producer/consumer](https://github.com/ThreeDotsLabs/watermill/tree/master/tools/mill). | Feature | Implements | Note | | ------- | ---------- | ---- | | ConsumerGroups | no | | | ExactlyOnceDelivery | no | | | GuaranteedOrder | no | | | Persistent | no | | ### Configuration The publisher configuration is relatively simple. {{% load-snippet-partial file="src-link/watermill-io/pkg/io/publisher.go" first_line_contains="type PublisherConfig struct" last_line_contains="// Publisher" %}} The subscriber may work in two modes – either perform buffered reads of constant size from the io.Reader, or split the byte stream into messages using a delimiter byte. The reading will continue even if the reads come up empty, but they will not be sent out as messages. The time to wait after an empty read is configured through the `PollInterval` parameter. As soon as a non-empty input is read, it will be packaged as a message and sent out. {{% load-snippet-partial file="src-link/watermill-io/pkg/io/subscriber.go" first_line_contains="type SubscriberConfig struct" last_line_contains="// Subscriber" %}} The continuous reading may be used, for example, to emulate the behaviour of a `tail -f` command, like in this snippet: {{% load-snippet-partial file="docs/snippets/tail-log-file/main.go" first_line_contains="// this will" last_line_contains="return false" padding_after="1" %}} ### Marshaling/Unmarshaling The MarshalFunc is an important part of `io.Publisher`, because it fully controls the format in the underlying `io.Writer` will obtain the messages. Correspondingly, the UnmarshalFunc regulates how the bytes read by the `io.Reader` will be interpreted as Watermill messages. {{% load-snippet-partial file="src-link/watermill-io/pkg/io/marshal.go" first_line_contains="// MarshalMessageFunc" last_line_contains="// PayloadMarshalFunc" %}} {{% load-snippet-partial file="src-link/watermill-io/pkg/io/marshal.go" first_line_contains="// UnmarshalMessageFunc" last_line_contains="// PayloadUnmarshalFunc" %}} The package comes with some predefined marshal and unmarshal functions, but you might want to write your own marshaler/unmarshaler to work with the specific implementation of `io.Writer/io.Reader` that you are working with. ### Topic For the Publisher/Subscriber implementation itself, the topic has no meaning. It is difficult to interpret the meaning of topic in the general context of `io.Writer` and `io.Reader` interfaces. However, the topic is passed as a parameter to the marshal/unmarshal functions, so the adaptations to particular `Writer/Reader` implementation may take it into account. ================================================ FILE: docs/content/pubsubs/kafka.md ================================================ +++ title = "Kafka" description = "A distributed streaming platform from Apache" date = 2019-07-06T22:30:00+02:00 bref = "A distributed streaming platform from Apache" weight = 80 +++ Apache Kafka is one of the most popular Pub/Subs. We are providing Pub/Sub implementation based on [IBM Sarama](https://github.com/IBM/sarama). You can find a fully functional example with Kafka in the [Watermill examples](https://github.com/ThreeDotsLabs/watermill/tree/master/_examples/pubsubs/kafka). ## Installation ```bash go get github.com/ThreeDotsLabs/watermill-kafka/v3 ``` ### Characteristics | Feature | Implements | Note | | ------- | ---------- | ---- | | ConsumerGroups | yes | | | ExactlyOnceDelivery | no | in theory can be achieved with [Transactions](https://www.confluent.io/blog/transactions-apache-kafka/), currently no support for any Golang client | | GuaranteedOrder | yes | require [partition key usage](#partitioning) | | Persistent | yes| | ### Configuration {{% load-snippet-partial file="src-link/watermill-kafka/pkg/kafka/subscriber.go" first_line_contains="type SubscriberConfig struct" last_line_contains="// Subscribe" %}} #### Passing custom `Sarama` config You can pass [custom config](https://github.com/Shopify/sarama/blob/master/config.go#L20) parameters via `overwriteSaramaConfig *sarama.Config` in `NewSubscriber` and `NewPublisher`. When `nil` is passed, default config is used (`DefaultSaramaSubscriberConfig`). {{% load-snippet-partial file="src-link/watermill-kafka/pkg/kafka/subscriber.go" first_line_contains="// DefaultSaramaSubscriberConfig" last_line_contains="return config" padding_after="1" %}} ### Connecting #### Publisher {{% load-snippet-partial file="src-link/watermill-kafka/pkg/kafka/publisher.go" first_line_contains="// NewPublisher" last_line_contains="(*Publisher, error)" padding_after="0" %}} Example: {{% load-snippet-partial file="src-link/_examples/pubsubs/kafka/main.go" first_line_contains="publisher, err := kafka.NewPublisher" last_line_contains="panic(err)" padding_after="1" %}} #### Subscriber {{% load-snippet-partial file="src-link/watermill-kafka/pkg/kafka/subscriber.go" first_line_contains="// NewSubscriber" last_line_contains="(*Subscriber, error)" padding_after="0" %}} Example: {{% load-snippet-partial file="src-link/_examples/pubsubs/kafka/main.go" first_line_contains="saramaSubscriberConfig :=" last_line_contains="panic(err)" padding_after="1" %}} ### Publishing {{% load-snippet-partial file="src-link/watermill-kafka/pkg/kafka/publisher.go" first_line_contains="// Publish" last_line_contains="func (p *Publisher) Publish" %}} ### Subscribing {{% load-snippet-partial file="src-link/watermill-kafka/pkg/kafka/subscriber.go" first_line_contains="// Subscribe" last_line_contains="func (s *Subscriber) Subscribe" %}} ### Marshaler Watermill's messages cannot be directly sent to Kafka - they need to be marshaled. You can implement your marshaler or use default implementation. {{% load-snippet-partial file="src-link/watermill-kafka/pkg/kafka/marshaler.go" first_line_contains="// Marshaler" last_line_contains="func (DefaultMarshaler)" padding_after="0" %}} ### Partitioning Our Publisher has support for the partitioning mechanism. It can be done with special Marshaler implementation: {{% load-snippet-partial file="src-link/watermill-kafka/pkg/kafka/marshaler.go" first_line_contains="type kafkaJsonWithPartitioning" last_line_contains="func (j kafkaJsonWithPartitioning) Marshal" padding_after="0" %}} When using, you need to pass your function to generate partition key. It's a good idea to pass this partition key with metadata to not unmarshal entire message. ```go marshaler := kafka.NewWithPartitioningMarshaler(func(topic string, msg *message.Message) (string, error) { return msg.Metadata.Get("partition"), nil }) ``` Please note that in the example above, if the `partition` key is missing from the message metadata, an empty string `""` will be used as the partitioning key. This will cause all such messages to be routed to the same partition, which may not be the desired behavior and could lead to uneven load distribution. ================================================ FILE: docs/content/pubsubs/nats.md ================================================ +++ title = "NATS Jetstream" description = "A simple, secure and high performance open source messaging system" date = 2022-02-03T10:30:00+05:00 bref = "A simple, secure and high performance open source messaging system" weight = 90 +++ NATS Jetstream is a data streaming system powered by NATS, and written in the Go programming language. As of v2.0.2 this middleware will contain a beta implementation in `pkg/jetstream` based on the [nats.go Jetstream package](https://github.com/nats-io/nats.go/tree/main/jetstream). This implementation is considered experimental tracking with the upstream client though we target a stable watermill API by v2.1. For production use it is recommended to use the pubsub implementations in `pkg/nats` with Jetstream enabled. You can find a fully functional example with NATS JetStream in the [Watermill examples](https://github.com/ThreeDotsLabs/watermill/tree/master/_examples/pubsubs/nats-jetstream). ## Installation ```bash go get github.com/ThreeDotsLabs/watermill-nats/v2 ``` ### Characteristics | Feature | Implements | Note | |---------------------|------------|-----------------------------------------------------------------------------------------------------------------------| | ConsumerGroups | yes | you need to set `QueueGroupPrefix` name or provide an optional calculator | | ExactlyOnceDelivery | yes | you need to ensure 'AckAsync' has default false value and set 'TrackMsgId' to true on the Jetstream configuration | | GuaranteedOrder | no | [with the redelivery feature, order can't be guaranteed](https://github.com/nats-io/nats-streaming-server/issues/187) | | Persistent | yes | | ### Configuration Configuration is done through PublisherConfig and SubscriberConfig types. These share a common JetStreamConfig. To use the experimental nats-core support, set Disabled=true. {{% load-snippet-partial file="src-link/watermill-nats/pkg/nats/jetstream.go" first_line_contains="// JetStreamConfig contains" last_line_contains="type DurableCalculator =" %}} PublisherConfig: {{% load-snippet-partial file="src-link/watermill-nats/pkg/nats/publisher.go" first_line_contains="type PublisherConfig struct" last_line_contains="type Publisher struct {" %}} Subscriber Config: {{% load-snippet-partial file="src-link/watermill-nats/pkg/nats/subscriber.go" first_line_contains="type SubscriberConfig struct" last_line_contains="type Subscriber struct" %}} ### Connecting By default NATS client will try to connect to `localhost:4222`. If you are using different hostname or port you should specify using the URL property of `SubscriberConfig` and `PublisherConfig`. {{% load-snippet-partial file="src-link/watermill-nats/pkg/nats/publisher.go" first_line_contains="// NewPublisher" last_line_contains="func NewPublisher" %}} Example: {{% load-snippet-partial file="src-link/_examples/pubsubs/nats-jetstream/main.go" first_line_contains="publisher, err :=" last_line_contains="panic(err)" padding_after="1" %}} {{% load-snippet-partial file="src-link/watermill-nats/pkg/nats/subscriber.go" first_line_contains="// NewSubscriber" last_line_contains="func NewSubscriber" %}} Example: {{% load-snippet-partial file="src-link/_examples/pubsubs/nats-jetstream/main.go" first_line_contains="subscriber, err :=" last_line_contains="panic(err)" padding_after="1" %}} You can also use `NewSubscriberWithNatsConn` and `NewPublisherWithNatsConn` to use a custom `*nats.Conn`. ### Publishing {{% load-snippet-partial file="src-link/watermill-nats/pkg/nats/publisher.go" first_line_contains="// Publish publishes" last_line_contains="func (p *Publisher) Publish" %}} ### Subscribing {{% load-snippet-partial file="src-link/watermill-nats/pkg/nats/subscriber.go" first_line_contains="// Subscribe " last_line_contains="func (s *Subscriber) Subscribe" %}} ### Marshaler NATS provides a header passing mechanism that allows conveying the watermill message details as metadata. This is done by default with only the binary payload sent in the message body. The header `_watermill_message_uuid` is reserved. Other builtin marshalers are based on Golang's [`gob`](https://golang.org/pkg/encoding/gob/) and [`json`](https://golang.org/packages/encoding/json) packages. {{% load-snippet-partial file="src-link/watermill-nats/pkg/nats/marshaler.go" first_line_contains="type Marshaler " last_line_contains="func defaultNatsMsg" padding_after="0" %}} When you have your own format of the messages, you can implement your own Marshaler, which will serialize messages in your format. An example protobuf implementation with tests and benchmarks can be found [here](https://github.com/ThreeDotsLabs/watermill-nats/tree/master/_examples/marshalers/protobuf/) When needed, you can bypass both [UUID]({{< ref "message#message" >}}) and [Metadata]({{< ref "message#message" >}}) and send just a `message.Payload`, but some standard [middlewares]({{< ref "messages-router#middleware" >}}) may be not working. ## Core-Nats This package also includes limited support for connecting to [core-nats](https://docs.nats.io/nats-concepts/core-nats). While core-nats does not support many of the streaming features needed for a perfect fit with watermill and most acks end up implemented as no-ops, in environments with a mix of jetstream and core-nats messaging in play it can be nice to use watermill consistently on the application side. ================================================ FILE: docs/content/pubsubs/redisstream.md ================================================ +++ title = "Redis Stream" description = "A fast, open source, in-memory, key-value data store" date = 2023-02-01T22:30:00+08:00 bref = "A fast, open source, in-memory, key-value data store" weight = 110 +++ Redis is the open source, in-memory data store used by millions of developers. Redis stream is a data structure that acts like an append-only log in Redis. We are providing Pub/Sub implementation based on [redis/go-redis](https://github.com/redis/go-redis). You can find a fully functional example with Redis Stream in the [Watermill examples](https://github.com/ThreeDotsLabs/watermill/tree/master/_examples/pubsubs/redisstream). ## Installation ```bash go get github.com/ThreeDotsLabs/watermill-redisstream ``` ### Characteristics | Feature | Implements | Note | | ------- | ---------- | ---- | | ConsumerGroups | yes | | | ExactlyOnceDelivery | no | | | GuaranteedOrder | no | | | Persistent | yes | | | FanOut | yes | use XREAD to fan out messages when there is no consumer group | ### Configuration {{% load-snippet-partial file="src-link/watermill-redisstream/pkg/redisstream/publisher.go" first_line_contains="type PublisherConfig struct" last_line_contains="// Publish publishes message to redis stream" %}} {{% load-snippet-partial file="src-link/watermill-redisstream/pkg/redisstream/subscriber.go" first_line_contains="type SubscriberConfig struct" last_line_contains="func (s *Subscriber) Subscribe" %}} #### Passing `redis.UniversalClient` You need to configure and pass your own go-redis client via `Client redis.UniversalClient` in `NewSubscriber` and `NewPublisher`. The client can be either `redis.Client` or `redis.ClusterClient`. #### Publisher {{% load-snippet-partial file="src-link/watermill-redisstream/pkg/redisstream/publisher.go" first_line_contains="// NewPublisher" last_line_contains="(*Publisher, error)" padding_after="0" %}} Example: {{% load-snippet-partial file="src-link/_examples/pubsubs/redisstream/main.go" first_line_contains="pubClient := redis.NewClient" last_line_contains="panic(err)" padding_after="1" %}} #### Subscriber {{% load-snippet-partial file="src-link/watermill-redisstream/pkg/redisstream/subscriber.go" first_line_contains="// NewSubscriber" last_line_contains="(*Subscriber, error)" padding_after="0" %}} Example: {{% load-snippet-partial file="src-link/_examples/pubsubs/redisstream/main.go" first_line_contains="subClient := redis.NewClient" last_line_contains="panic(err)" padding_after="1" %}} ### Publishing {{% load-snippet-partial file="src-link/watermill-redisstream/pkg/redisstream/publisher.go" first_line_contains="// Publish" last_line_contains="func (p *Publisher) Publish" %}} ### Subscribing {{% load-snippet-partial file="src-link/watermill-redisstream/pkg/redisstream/subscriber.go" first_line_contains="func (s *Subscriber) Subscribe" last_line_contains="func (s *Subscriber) Subscribe" %}} ### Marshaler Watermill's messages cannot be directly sent to Redis - they need to be marshaled. You can implement your marshaler or use default implementation. The default implementation uses [MessagePack](https://msgpack.org/index.html) for efficient serialization. {{% load-snippet-partial file="src-link/watermill-redisstream/pkg/redisstream/marshaller.go" first_line_contains="const UUIDHeaderKey" last_line_contains="type DefaultMarshallerUnmarshaller" padding_after="0" %}} ================================================ FILE: docs/content/pubsubs/sql.md ================================================ +++ title = "SQL (PostgreSQL, MySQL)" description = "Pub/Sub based on MySQL or PostgreSQL." date = 2019-07-06T22:30:00+02:00 bref = "Pub/Sub based on MySQL or PostgreSQL." weight = 120 +++ SQL Pub/Sub executes queries on any SQL database, using it like a messaging system. At the moment, **MySQL** and **PostgreSQL** are supported. It be useful for projects that are not using any specialized message queue at the moment, but have access to a SQL database. If you are looking for SQLite Pub/Sub, check out the [SQLite Pub/Sub](/pubsubs/sqlite/) documentation. The SQL subscriber runs a `SELECT` query within short periods, remembering the position of the last record. If it finds any new records, they are returned. One handy use case is consuming events from a database table, that can be later published on some kind of message queue. The SQL publisher simply inserts consumed messages into the chosen table. A common approach would be to use it as a persistent log of events that were published on a queue with short message expiration time. SQL Pub/Sub is also a good choice for implementing Outbox pattern with [Forwarder](/docs/forwarder/) component. See also the [SQL example](https://github.com/ThreeDotsLabs/watermill/tree/master/_examples/pubsubs/sql). ## Installation ```bash go get github.com/ThreeDotsLabs/watermill-sql/v4 ``` ### Characteristics | Feature | Implements | Note | |---------------------|------------|-------------------------------------------------------------------------------| | ConsumerGroups | yes | See `ConsumerGroup` in `SubscriberConfig` (not supported by the queue schema) | | ExactlyOnceDelivery | yes | | GuaranteedOrder | yes | | | Persistent | yes | | ### Schema SQL Pub/Sub uses user-defined schema to handle select and insert queries. You need to implement `SchemaAdapter` and pass it to `SubscriberConfig` or `PublisherConfig`. {{% load-snippet-partial file="src-link/watermill-sql/pkg/sql/schema_adapter_mysql.go" first_line_contains="// DefaultMySQLSchema" last_line_contains="type DefaultMySQLSchema" %}} There is a default schema provided for each supported engine (`DefaultMySQLSchema` and `DefaultPostgreSQLSchema`). It supports the most common use case (storing events in a table). You can base your schema on one of these, extending only chosen methods. #### Extending schema Consider an example project, where you're fine with using the default schema, but would like to use `BINARY(16)` for storing the `uuid` column, instead of `VARCHAR(36)`. In that case, you have to define two methods: * `SchemaInitializingQueries` that creates the table. * `UnmarshalMessage` method that produces a `Message` from the database record. Note that you don't have to use the initialization queries provided by Watermill. They will be run only if you set the `InitializeSchema` field to `true` in the config. Otherwise, you can use your own solution for database migrations. {{% load-snippet-partial file="src-link/watermill-sql/pkg/sql/schema_adapter_mysql.go" first_line_contains="// DefaultMySQLSchema" last_line_contains="type DefaultMySQLSchema" %}} ### Configuration {{% load-snippet-partial file="src-link/watermill-sql/pkg/sql/publisher.go" first_line_contains="type PublisherConfig struct" last_line_contains="}" %}} {{% load-snippet-partial file="src-link/watermill-sql/pkg/sql/subscriber.go" first_line_contains="type SubscriberConfig struct" last_line_contains="}" %}} ## Publishing {{% load-snippet-partial file="src-link/watermill-sql/pkg/sql/publisher.go" first_line_contains="func NewPublisher" last_line_contains="func NewPublisher" %}} Example: {{% load-snippet-partial file="src-link/_examples/pubsubs/sql/main.go" first_line_contains="publisher, err :=" last_line_contains="panic(err)" padding_after="1" %}} {{% load-snippet-partial file="src-link/watermill-sql/pkg/sql/publisher.go" first_line_contains="// Publish " last_line_contains="func (p *Publisher) Publish" %}} ### Transactions If you need to publish messages within a database transaction, you have to pass a `*sql.Tx` in the `NewPublisher` constructor. You have to create one publisher for each transaction. Example: {{% load-snippet-partial file="src-link/_examples/real-world-examples/transactional-events/main.go" first_line_contains="func simulateEvents" last_line_contains="return pub.Publish(" padding_after="3" %}} ## Subscribing To create a subscriber, you need to pass not only proper schema adapter, but also an offsets adapter. * For MySQL schema use `DefaultMySQLOffsetsAdapter` * For PostgreSQL schema use `DefaultPostgreSQLOffsetsAdapter` {{% load-snippet-partial file="src-link/watermill-sql/pkg/sql/subscriber.go" first_line_contains="func NewSubscriber" last_line_contains="func NewSubscriber" %}} Example: {{% load-snippet-partial file="src-link/_examples/pubsubs/sql/main.go" first_line_contains="subscriber, err :=" last_line_contains="panic(err)" padding_after="1" %}} {{% load-snippet-partial file="src-link/watermill-sql/pkg/sql/subscriber.go" first_line_contains="func (s *Subscriber) Subscribe" last_line_contains="func (s *Subscriber) Subscribe" %}} ## Offsets Adapter The logic for storing offsets of messages is provided by the `OffsetsAdapter`. If your schema uses auto-incremented integer as the row ID, it should work out of the box with default offset adapters. {{% load-snippet-partial file="src-link/watermill-sql/pkg/sql/offsets_adapter.go" first_line_contains="type OffsetsAdapter" %}} ## Queue Instead of the default Pub/Sub schema, you can use the *queue* schema and offsets adapters. It's a simpler schema that doesn't support consumer groups. However, it has other advantages. It lets you specify a custom `WHERE` clause for getting the messages. You can use it to filter messages by some condition in the payload or in the metadata. Additionally, you can choose to delete messages from the table after they are acknowledged. Thanks to this, the table doesn't grow in size with time. This schema is supported by both PostgreSQL and MySQL. The example below is based on PostgreSQL, but the same approach can be used with MySQL. {{% load-snippet-partial file="src-link/watermill-sql/pkg/sql/queue_schema_adapter_postgresql.go" first_line_contains="// PostgreSQLQueueSchema" last_line_contains="}" %}} {{% load-snippet-partial file="src-link/watermill-sql/pkg/sql/queue_offsets_adapter_postgresql.go" first_line_contains="// PostgreSQLQueueOffsetsAdapter" last_line_contains="}" %}} ## Caveats ### Using last processed transaction ID in PostgreSQL to ensure no messages are lost In some cases, PostgreSQL `SERIAL` is not incremental. The `SERIAL` value is generated while the transaction is in progress, not when it is committed. If transactions are committed in a different order than they were started, message offsets based on `SERIAL` values will not be incremental. To keep storing acknowledgment information efficient, Watermill keeps only the last message's acknowledgment information. To ensure no messages are missed when a message order is not kept, Watermill also uses the transaction ID to ensure no message is lost. For more details, see [Watermill#311](https://github.com/ThreeDotsLabs/watermill/issues/311). It is important to note that very long-running transactions may result in delayed message delivery. For instance, if a transaction is running for an hour, no messages will be delivered until the transaction is committed. While we do not recommend the use of such long transactions, **if they are necessary, we advise the use of the [Queue schema adapter](#queue), which does not depend on the transaction ID.** You have nothing to worry about if you don't have such long transactions. If you are migrating your data to a new database, you may need to set `last_processed_transaction_id` in your offsets table. ================================================ FILE: docs/content/pubsubs/sqlite.md ================================================ +++ title = "SQLite" description = "A lightweight, file-based SQL database engine" date = 2025-05-08T11:30:00+02:00 bref = "A lightweight, file-based SQL database engine" weight = 121 +++ **Beta Version Warning: this Pub/Sub is stable, but it has not been widely tested in production environments. It may be sensitive to certain edge cases and combinations of configuration parameters.** SQLite is a C-language library that implements a small, fast, self-contained, high-reliability, full-featured SQL database engine. Our SQLite Pub/Sub implementation provides two **CGO-free** driver variants optimized for different use cases. Both drivers use pure Go implementations of SQLite, enabling cross-compilation and avoiding CGO dependencies while maintaining full SQLite functionality. SQLite Pub/Subs provide the easiest way to publish and process events durably, since you do not have to set up or manage a separate database. The database is just a file on disk. Some cloud compute providers offer distributed SQLite clusters, which can provide both durability and unmatched read performance. Tuned SQLite is [~35% faster](https://sqlite.org/fasterthanfs.html) than the Linux file system. You can find a fully functional example with SQLite in the [Watermill examples](https://github.com/ThreeDotsLabs/watermill/tree/master/_examples/pubsubs/sqlite). ## ModernC vs ZombieZen Driver The vanilla **ModernC driver** is compatible with the Golang standard library SQL package and works without CGO. It has fewer dependencies than the ZombieZen variant and uses the `modernc.org/sqlite` pure Go SQLite implementation. Most users should pick this driver for full compatibility with the standard library. The advanced **ZombieZen driver** abandons the standard Golang library SQL conventions in favor of [the more orthogonal API and higher performance potential](https://crawshaw.io/blog/go-and-sqlite). Under the hood, it also uses the ModernC SQLite3 implementation and does not need CGO. Advanced SQLite users might prefer this driver for its performance benefits. It is about **6 times faster** than the ModernC variant. It is currently more stable due to lower level control. It is faster than even the CGO SQLite variants on standard library interfaces, and with some tuning should become the absolute speed champion of persistent message brokers over time. ### Characteristics | Feature | Implements | Note | |---------------------|------------|---------------------------------------------------| | ConsumerGroups | yes | See `ConsumerGroupMatcher` in `SubscriberOptions` | | ExactlyOnceDelivery | no | | | GuaranteedOrder | yes | | | Persistent | yes | | Like the [BoltDB](../bolt/) implementation, the SQLite drivers are imbeddable into your application. They do not require any additional infrastructure other than a mounted persistent disk. Their advantage over the BoltDB Pub/Sub is supporting consumer groups and guaranteed order. ## Vanilla ModernC Driver ### Installation ```bash go get github.com/ThreeDotsLabs/watermill-sqlite/wmsqlitemodernc@latest ``` ### Usage {{% load-snippet-partial file="src-link/_examples/pubsubs/sqlite/main.go" first_line_contains="import (" last_line_contains="}" padding_after="1" %}} ### Configuration {{% load-snippet-partial file="src-link/watermill-sqlite/wmsqlitemodernc/publisher.go" first_line_contains="type PublisherOptions struct" last_line_contains="}" %}} {{% load-snippet-partial file="src-link/watermill-sqlite/wmsqlitemodernc/subscriber.go" first_line_contains="type SubscriberOptions struct" last_line_contains="}" %}} ### Publishing {{% load-snippet-partial file="src-link/watermill-sqlite/wmsqlitemodernc/publisher.go" first_line_contains="// NewPublisher" last_line_contains="func NewPublisher" %}} {{% load-snippet-partial file="src-link/watermill-sqlite/wmsqlitemodernc/publisher.go" first_line_contains="// Publish " last_line_contains="func (p *publisher) Publish" %}} Example: {{% load-snippet-partial file="src-link/_examples/pubsubs/sqlite/main.go" first_line_contains="publisher, err := wmsqlitemodernc.NewPublisher(" last_line_contains="panic(err)" padding_after="1" %}} {{% load-snippet-partial file="src-link/_examples/pubsubs/sqlite/main.go" first_line_contains="func publishMessages(" last_line_contains="panic(err)" padding_after="1" %}} #### Publishing in transaction {{% load-snippet-partial file="src-link/_examples/pubsubs/sqlite/transaction.go" first_line_contains="import (" padding_after="1" %}} ### Subscribing {{% load-snippet-partial file="src-link/watermill-sqlite/wmsqlitemodernc/subscriber.go" first_line_contains="// NewSubscriber" last_line_contains="func NewSubscriber" %}} Example: {{% load-snippet-partial file="src-link/_examples/pubsubs/sqlite/main.go" first_line_contains="subscriber, err := wmsqlitemodernc.NewSubscriber(" last_line_contains="panic(err)" padding_after="1" %}} {{% load-snippet-partial file="src-link/watermill-sqlite/wmsqlitemodernc/subscriber.go" first_line_contains="// Subscribe " last_line_contains="func (s *subscriber) Subscribe" %}} ## Advanced ZombieZen Driver ### Installation ```bash go get -u github.com/ThreeDotsLabs/watermill-sqlite/wmsqlitezombiezen@latest ``` ### Usage {{% load-snippet-partial file="src-link/_examples/pubsubs/sqlite-zombiezen/main.go" first_line_contains="import (" last_line_contains="}" padding_after="1" %}} ### Configuration {{% load-snippet-partial file="src-link/watermill-sqlite/wmsqlitezombiezen/publisher.go" first_line_contains="type PublisherOptions struct" last_line_contains="}" %}} {{% load-snippet-partial file="src-link/watermill-sqlite/wmsqlitezombiezen/subscriber.go" first_line_contains="type SubscriberOptions struct" last_line_contains="}" %}} ### Publishing {{% load-snippet-partial file="src-link/watermill-sqlite/wmsqlitezombiezen/publisher.go" first_line_contains="// NewPublisher" last_line_contains="func NewPublisher" %}} {{% load-snippet-partial file="src-link/watermill-sqlite/wmsqlitezombiezen/publisher.go" first_line_contains="// Publish " last_line_contains="func (p *publisher) Publish" %}} Example: {{% load-snippet-partial file="src-link/_examples/pubsubs/sqlite-zombiezen/main.go" first_line_contains="publisher, err := wmsqlitezombiezen.NewPublisher(" last_line_contains="panic(err)" padding_after="1" %}} {{% load-snippet-partial file="src-link/_examples/pubsubs/sqlite-zombiezen/main.go" first_line_contains="func publishMessages(" last_line_contains="panic(err)" padding_after="1" %}} #### Publishing in transaction {{% load-snippet-partial file="src-link/_examples/pubsubs/sqlite-zombiezen/transaction.go" first_line_contains="import (" padding_after="1" %}} ### Subscribing {{% load-snippet-partial file="src-link/watermill-sqlite/wmsqlitezombiezen/subscriber.go" first_line_contains="// NewSubscriber" last_line_contains="func NewSubscriber" %}} Example: {{% load-snippet-partial file="src-link/_examples/pubsubs/sqlite-zombiezen/main.go" first_line_contains="subscriber, err := wmsqlitezombiezen.NewSubscriber(" last_line_contains="panic(err)" padding_after="1" %}} {{% load-snippet-partial file="src-link/watermill-sqlite/wmsqlitezombiezen/subscriber.go" first_line_contains="// Subscribe " last_line_contains="func (s *subscriber) Subscribe" %}} ## Marshaler Watermill's messages are stored in SQLite using JSON serialization. Both drivers use the same marshaling approach - messages are automatically marshaled to and from JSON format when publishing and subscribing. The default marshaler handles: - Message payload (stored as JSON blob) - Message metadata (stored as JSON object) - Message UUID (stored as TEXT) - Timestamps for ordering and consumer group management Both drivers automatically handle message marshaling and unmarshaling, so no custom marshaler configuration is typically required. ## Caveats SQLite3 does not support querying `FOR UPDATE`, which is used for row locking when subscribers in the same consumer group read an event batch in official Watermill SQL PubSub implementations. Current architectural decision is to lock a consumer group offset using `unixepoch()+lockTimeout` time stamp. While one consumed message is processing per group, the offset lock time is extended by `lockTimeout` periodically by `time.Ticker`. If the subscriber is unable to finish the consumer group batch, other subscribers will take over the lock as soon as the grace period runs out. A time lock fulfills the role of a traditional database network timeout that terminates transactions when its client disconnects. All the normal SQLite limitations apply to Watermill. The connections are file handles. Create new connections for concurrent processing. If you must share a connection, protect it with a mutual exclusion lock. If you are writing within a transaction, create a connection for that transaction only. ================================================ FILE: docs/content/support.md ================================================ +++ title = "Support" description = "" +++ ### Community Support Join us on the `#watermill` channel on the [Three Dots Labs discord](https://discord.gg/QV6VFg4YQE). ### Professional Support For enterprise support, please contact us by e-mail: contact@threedotslabs.com You can also use the [contact form on our website](https://threedots.tech/contact/?utm_source=watermill-docs). ================================================ FILE: docs/extract_middleware_godocs.py ================================================ #!/usr/bin/python3 import os import re class MiddlewareSourceFile: def __init__(self, filepath: str): self._definitions = [] self._filepath = filepath with open(filepath, 'r') as f: self._src = f.readlines() self.name = os.path.basename(self._filepath) for (line_no, line) in enumerate(self._src): # function or struct definitions if line.startswith('func') or re.match(r'^type \S+? struct', line): # with godocs if line_no > 0 and (self._src[line_no - 1]).startswith('//'): self.add_func_with_godoc(line_no - 1) def add_func_with_godoc(self, line_no: int): func_def = '' # find the first line of godoc while True: if line_no - 1 > 0 and self._src[line_no - 1].startswith('//'): line_no -= 1 else: break while True: line_content = self._src[line_no] func_def += line_content line_no += 1 # go fmt, I believe in you if line_content == '}\n': break self._definitions.append(func_def) def format(self) -> str: if not self._definitions: return None middleware_name = self.name.strip('.go').replace('_', ' ') middleware_name = capitalize(middleware_name) s = '### {}\n\n'.format(middleware_name) for func_def in self._definitions: s += '```go\n{}```\n'.format(func_def) return s def capitalize(s: str) -> str: words = s.split() for i, word in enumerate(words): words[i] = word[0].upper() + word[1:] return ' '.join(words) if __name__ == '__main__': go_sources = [] for root, dirs, files in os.walk('../message/router/middleware'): go_sources = [MiddlewareSourceFile(os.path.join(root, f)) for f in files if f.endswith('.go') and not f.endswith('_test.go')] for src_file in go_sources: formatted = src_file.format() if formatted: print(formatted) print('\n') ================================================ FILE: docs/layouts/_default/_markup/render-link.html ================================================ {{ .Text | safeHTML }} ================================================ FILE: docs/layouts/_default/learn.html ================================================ {{ define "main" }}

Learn Watermill

How do you like to learn?

{{ range $index, $option := .Params.learning_options }}
{{ .icon | safeHTML }}

{{ .title }}

{{ .subtitle }}

{{ .description }}

{{ end }}

Dive Deeper

{{ range .Params.deeper_options }}
{{ .icon | safeHTML }}

{{ .title }}

{{ .description }}

{{ end }}
{{ end }} ================================================ FILE: docs/layouts/_default/quickstart.html ================================================ {{ define "main" }}

{{ .Title }}

{{ .Params.description }}

{{ .Content }}
{{ end }} ================================================ FILE: docs/layouts/index.html ================================================ {{ define "main" }}
Logo

{{ .Title }}

{{ .Params.lead | safeHTML }}

Get Started See on GitHub {{ .Content }}

Publish Events

Work with Go structs.
Decouple your services with asynchronous processing.

{{ highlight `event := UserRegistered{ UserID: id, Email: email, JoinedAt: time.Now(), } err := eventBus.Publish(ctx, event)` "go" "" }}

Handle Events

Focus on the business logic.
Watermill handles routing, serialization and low-level details.

{{ highlight `eventProcessor.AddHandlers( cqrs.NewEventHandler("SendWelcomeEmail", sendWelcomeEmail), ) func sendWelcomeEmail(ctx context.Context, event *UserRegistered) error { return emailService.Send(event.Email, "Welcome!") }` "go" "" }}

Simple API you already know

Use the familiar concepts, similar to an HTTP Router with support for middleware.

{{ highlight `router.AddMiddleware( middleware.Recoverer, middleware.CorrelationID, middleware.Timeout(30 * time.Second), )` "go" "" }}

Use your favorite Pub/Sub

Work with Kafka, RabbitMQ, PostgreSQL, Redis, and more Pub/Subs with the same API. Switch providers without changing your application code.

AWS SQS/SNS AWS SNS/SQS BoltDB BoltDB Google Firestore Firestore Go Channel Go Channel Google Cloud Pub/Sub Google Cloud Pub/Sub HTTP HTTP I/O I/O Apache Kafka Kafka MySQL MySQL NATS NATS PostgreSQL PostgreSQL RabbitMQ RabbitMQ Redis Redis SQLite SQLite

Library ≠ Framework

Use any architecture you want. No vendor lock-in.

{{ highlight `func (h *Handler) RegisterUser(w http.ResponseWriter, r *http.Request) { user := createUser(r) // Your existing business logic err := h.userRepo.Save(user) if err != nil { // ... } // Publish an event using Watermill err := h.eventBus.Publish(r.Context(), &UserRegistered{...}) // ... }` "go" "" }}
Watermill Logo

Start building

Get Started
{{ end }} {{ define "sidebar-prefooter" }} {{ if site.Params.doks.backgroundDots -}}
{{ end -}} {{ end }} {{ define "sidebar-footer" }} {{ if site.Params.doks.sectionFooter -}}

Start building with Doks today

{{ i18n "get-started" }}
{{ end -}} {{ end }} ================================================ FILE: docs/layouts/partials/footer/footer.html ================================================ {{- if not (or .IsHome .Params.HideBanner) -}}

Check our online hands-on training
{{- end -}}
Three Dots Labs Three Dots Labs

© Three Dots Labs 2014 — {{ dateFormat "2006" now }}

Watermill is open-source software and is not backed by venture capital.
We are an independent, bootstrapped company.

================================================ FILE: docs/layouts/partials/footer/script-footer-custom.html ================================================ {{/* Put your custom tags here */}} {{/* EXAMPLE - only load script for production {{ if eq (hugo.Environment) "production" -}} {{ partial "footer/esbuild" (dict "src" "js/instantpage.js" "load" "async" "transpile" false) -}} {{ end -}} */}} {{/* EXAMPLE - only load script for a page type e.g. contact or gallery {{ if eq .Type "gallery" -}} {{ partial "footer/esbuild" (dict "src" "js/gallery.js" "load" "async" "transpile" false) -}} {{ end -}} */}} ================================================ FILE: docs/layouts/partials/head/custom-head.html ================================================ {{ $pf:= "Heebo:wght@400;600" }} {{ $sf:= "Quicksand:wght@700" }} ================================================ FILE: docs/layouts/partials/head/resource-hints.html ================================================ ================================================ FILE: docs/layouts/partials/head/script-header.html ================================================ ================================================ FILE: docs/layouts/partials/header/header.html ================================================ {{ if site.Params.doks.alert -}} {{ partial "header/alert.html" . }} {{ end -}} {{ if site.Params.doks.navbarSticky -}}
{{ end -}} {{ if site.Params.doks.headerBar -}}
{{ end -}}
{{ with site.Params.doks.containerBreakpoint -}}
{{ else -}}
{{ end -}} {{ .Site.Title }} {{ partial "main/showFlexSearch" . }} {{ $showFlexSearch := .Scratch.Get "showFlexSearch" -}} {{ if $showFlexSearch -}} {{ end -}} {{ if (in site.Params.doks.sectionNav .Section) -}}
{{ if site.Params.doks.headerBar -}}
{{ end -}}
{{ .Section | humanize }}
{{ partial "sidebar/section-menu.html" . }}
{{ end -}}
{{ if site.Params.doks.headerBar -}}
{{ end -}}
{{ site.Title }}
    {{- $current := . -}} {{- $section := $current.Section -}} {{ range .Site.Menus.main -}} {{- $active := or ($current.IsMenuCurrent "main" .) ($current.HasMenuCurrent "main" .) -}} {{- $active = or $active (eq .Name $current.Title) -}} {{- $active = or $active (and (eq .Name ($section | humanize)) (eq $current.Section $section)) -}} {{- $active = or $active (and (eq .Name "Blog") (eq $current.Section "blog" "authors")) -}} {{ if .HasChildren -}}
  • {{ .Name -}}
      {{ range .Children -}} {{- $active = eq .Name $current.Title -}}
    • {{ .Name }}
    • {{ end -}}
  • {{ else -}}
  • {{ .Name }}{{ .Post | safeHTML }}
  • {{ end -}} {{ end -}}
{{ partial "main/showFlexSearch" . }} {{ $showFlexSearch := .Scratch.Get "showFlexSearch" -}} {{ if $showFlexSearch -}} {{ end -}} {{ if eq site.Params.doks.multilingualMode true -}}
  • {{ .Site.Language.LanguageName }}

  • {{ if site.Params.doks.showMissingLanguages -}} {{ $translatedLangs := slice -}} {{ range .Translations -}} {{ $translatedLangs = $translatedLangs | append .Lang }} {{- end }} {{ range site.Languages -}} {{ if and (ne $.Lang .Lang) (not (in $.Params.skipTranslations .Lang)) -}} {{ $isTranslated := in $translatedLangs .Lang -}}
  • {{ .LanguageName }}
  • {{- end }} {{- end }} {{ else -}} {{ range .Translations -}}
  • {{ .Language.LanguageName }}
  • {{- end }} {{- end }}
{{ end -}} {{ if eq site.Params.doks.docsVersioning true -}}
  • Latest ({{ site.Params.doks.docsVersion }}.x)

  • v0.2.x
  • v0.1.x

  • All versions
{{ end -}} {{ if and (eq site.Params.doks.colorMode "auto") site.Params.doks.colorModeToggler -}} {{ end -}} {{ if .Site.Menus.social -}}
    {{ range .Site.Menus.social -}}
  • {{ .Pre | safeHTML }}{{ .Name | safeHTML }}
  • {{ end -}}
{{ end -}} {{ if site.Params.doks.navBarButton -}} {{ site.Params.doks.navBarButtonText }} {{ end -}}
{{ if site.Params.doks.navBarButton -}} {{ site.Params.doks.navBarButtonText }} {{ end -}}
{{ if site.Params.doks.navbarSticky -}}
{{ end -}} {{ if site.Params.doks.flexSearch -}} {{ partial "header/search-modal" . }} {{ end -}} ================================================ FILE: docs/layouts/partials/main/edit-page.html ================================================ {{ $parts := slice site.Params.doks.docsRepo }} {{ if (eq site.Params.doks.repoHost "GitHub") }} {{ $parts = $parts | append "edit" site.Params.doks.docsRepoBranch }} {{ else if (eq site.Params.doks.repoHost "Gitea") }} {{ $parts = $parts | append "_edit" site.Params.doks.docsRepoBranch }} {{ else if (eq site.Params.doks.repoHost "GitLab") }} {{ $parts = $parts | append "-/blob" site.Params.doks.docsRepoBranch }} {{ else if (eq site.Params.doks.repoHost "Bitbucket") }} {{ $parts = $parts | append "src" site.Params.doks.docsRepoBranch }} {{ else if (eq site.Params.doks.repoHost "BitbucketServer") }} {{ $parts = $parts | append "browse" site.Params.doks.docsRepoBranch }} {{ end }} {{ if isset .Site.Params "docsRepoSubPath" }} {{ if not (eq site.Params.doks.docsRepoSubPath "") }} {{ $parts = $parts | append site.Params.doks.docsRepoSubPath }} {{ end }} {{ end }} {{ $filePath := replace .File.Path "\\" "/" }} {{ $lang := "" }} {{ if site.Params.doks.multilingualMode }} {{ $lang = .Lang }} {{ end }} {{ $parts = $parts | append "docs/content" $filePath }} {{ $url := delimit $parts "/" }}
Help us improve this page
================================================ FILE: docs/layouts/partials/private/has-headings.html ================================================ {{ $hasHeadings := false }} {{ if (isset .Fragments "Headings") }} {{ $hasHeadings = gt (len .Fragments.Headings) 0 }} {{ end }} {{ $hasHeadings }} ================================================ FILE: docs/layouts/partials/seo/opengraph.html ================================================ {{/* Based on: https://github.com/gohugoio/hugo/blob/master/tpl/tplimpl/embedded/templates/opengraph.html */}} {{ $imagePermalink := (printf "https://academy-api.threedots.tech/ssr/image.png?%s" (collections.Querify "url" .Permalink) ) }} {{- if .IsPage }} {{- $iso8601 := "2006-01-02T15:04:05-07:00" -}} {{ with .PublishDate }}{{ end }} {{ with .Lastmod }}{{ end }} {{- end -}} {{- with .Params.audio }}{{ end }} {{- with .Params.locale }}{{ end }} {{- with .Site.Params.title }}{{ end }} {{- with .Params.videos }}{{- range . }} {{ end }}{{ end }} {{- /* If it is part of a series, link to related articles */}} {{- $permalink := .Permalink }} {{- $siteSeries := .Site.Taxonomies.series }} {{- if $siteSeries }} {{ with .Params.series }}{{- range $name := . }} {{- $series := index $siteSeries ($name | urlize) }} {{- range $page := first 6 $series.Pages }} {{- if ne $page.Permalink $permalink }}{{ end }} {{- end }} {{ end }}{{ end }} {{- end }} {{- /* Deprecate site.Social.facebook_admin in favor of site.Params.social.facebook_admin */}} {{- $facebookAdmin := "" }} {{- with site.Params.social }} {{- if reflect.IsMap . }} {{- $facebookAdmin = .facebook_admin }} {{- end }} {{- else }} {{- with site.Social.facebook_admin }} {{- $facebookAdmin = . }} {{- warnf "The social key in site configuration is deprecated. Use params.social.facebook_admin instead." }} {{- end }} {{- end }} {{- /* Facebook Page Admin ID for Domain Insights */}} {{ with $facebookAdmin }}{{ end }} ================================================ FILE: docs/layouts/partials/seo/twitter.html ================================================ {{/* Based on: https://github.com/gohugoio/hugo/blob/master/tpl/tplimpl/embedded/templates/twitter_cards.html */}} {{ $imagePermalink := (printf "https://academy-api.threedots.tech/ssr/image.png?%s" (collections.Querify "url" .Permalink) ) }} {{- /* Deprecate site.Social.twitter in favor of site.Params.social.twitter */}} {{- $twitterSite := "" }} {{- with site.Params.social }} {{- if reflect.IsMap . }} {{- $twitterSite = .twitter }} {{- end }} {{- else }} {{- with site.Social.twitter }} {{- $twitterSite = . }} {{- warnf "The social key in site configuration is deprecated. Use params.social.twitter instead." }} {{- end }} {{- end }} {{- with $twitterSite }} {{- $content := . }} {{- if not (strings.HasPrefix . "@") }} {{- $content = printf "@%v" $twitterSite }} {{- end }} {{- end }} ================================================ FILE: docs/layouts/partials/sidebar/section-menu.html ================================================ {{- with site.Menus.sidebar }} {{ partial "sidebar/render-section-menu.html" (dict "currentPage" $ "nodes" .) }} {{- else }} {{- with (.Site.GetPage "section" .Section).Sections }} {{ partial "sidebar/render-section-menu.html" (dict "currentPage" $ "nodes" .) }} {{- end }} {{- end }} ================================================ FILE: docs/layouts/shortcodes/load-snippet-partial.html ================================================ {{ $file := (.Get "file") }} {{ $content := readFile $file }} {{ $first_line_contains := (.Get "first_line_contains") }} {{ $last_line_contains := (.Get "last_line_contains") }} {{ $last_line_equals := (.Get "last_line_equals") }} {{ $show_line := false }} {{/*if true, first or last line was not found*/}} {{ $first_line_found := false}} {{ $last_line_found := false}} {{ $padding_after := (.Get "padding_after" | default "0" | int) }} {{ $first_line_num := 0 }} {{ $last_line_num := 0 }} {{ $linkFile := $file }} {{ $repo := "watermill" }} {{ if in $file "src-link/watermill-" }} {{ $repo = index (findRE "watermill-[a-z]+" $linkFile) 0 }} {{ $linkFile = replace $linkFile $repo "" }} {{ $linkFile = replace $linkFile "src-link//" "" }} {{ else if in $linkFile "src-link/" }} {{ $linkFile = replace $linkFile "src-link/" "" }} {{ else }} {{ $linkFile = print "docs/content/" $linkFile }} {{ end }} {{ $lines := slice }} {{ range $elem_key, $elem_val := split $content "\n" }} {{ $line_num := (add $elem_key 1) }} {{ if and (not $first_line_found) (in $elem_val $first_line_contains) }} {{ if ne $elem_key 0 }} {{ $lines = $lines | append "// ..." }} {{ end }} {{ $show_line = true }} {{ $first_line_found = true}} {{ $first_line_num = $line_num }} {{ end }} {{ if $show_line }} {{ $lines = $lines | append $elem_val }} {{ end }} {{ if and ($first_line_found) (in $elem_val $last_line_contains) (ne $last_line_contains "") }} {{ $last_line_found = true }} {{ end }} {{ if and ($first_line_found) (eq $elem_val $last_line_equals) (ne $last_line_equals "") }} {{ $last_line_found = true }} {{ end }} {{ if and $last_line_found $show_line }} {{ if gt $padding_after 0 }} {{ $padding_after = sub $padding_after 1}} {{ else }} {{ $lines = $lines | append "// ..." }} {{ $show_line = false }} {{ $last_line_num = $line_num }} {{ end }} {{ end }} {{ end }} {{/* Calculate minimum indentation (common whitespace) */}} {{ $min_indent := 999999 }} {{ range $lines }} {{ if and (ne . "// ...") (ne (trim . " \t") "") }} {{ $leading := 0 }} {{ range $i, $char := split . "" }} {{ if or (eq $char " ") (eq $char "\t") }} {{ $leading = add $leading 1 }} {{ else }} {{ break }} {{ end }} {{ end }} {{ if lt $leading $min_indent }} {{ $min_indent = $leading }} {{ end }} {{ end }} {{ end }} {{/* Remove common leading whitespace from all lines */}} {{ $normalized_lines := slice }} {{ range $lines }} {{ if or (eq . "// ...") (eq (trim . " \t") "") }} {{ $normalized_lines = $normalized_lines | append . }} {{ else if gt $min_indent 0 }} {{ $normalized_lines = $normalized_lines | append (substr . $min_indent) }} {{ else }} {{ $normalized_lines = $normalized_lines | append . }} {{ end }} {{ end }}
{{ transform.Highlight (delimit $normalized_lines "\n" | safeHTML) (.Get "type" | default "go") }}
Full source: [github.com/ThreeDotsLabs/{{ $repo }}/{{ $linkFile }}](https://github.com/ThreeDotsLabs/{{ $repo }}/tree/master/{{ $linkFile }}{{ if ne $first_line_num 0 }}#L{{ $first_line_num }}{{ end }}) {{if not $first_line_found }} {{ errorf "`first_line_contains` %s not found in %s snippet" $first_line_contains $file }} {{end}} {{if and (not $last_line_found) (ne $last_line_contains "") }} {{ errorf "`last_line_contains` %s not found in %s snippet" $last_line_contains $file }} {{end}} ================================================ FILE: docs/layouts/shortcodes/load-snippet.html ================================================ {{ $file := (.Get "file") }} {{ $content := readFile $file }} {{ $start_line := (.Get "start_line") | default "0" }} {{ $end_line := (.Get "end_line") | default "0" }} {{ $has_start_line := (ne $start_line "0") }} {{ $has_end_line := (ne $end_line "0") }} {{ $lines := slice }} {{ $linkFile := $file }} {{ $repo := "watermill" }} {{ if in $file "src-link/watermill-" }} {{ $repo = index (findRE "watermill-[a-z]+" $linkFile) 0 }} {{ $linkFile = replace $linkFile $repo "" }} {{ $linkFile = replace $linkFile "src-link//" "" }} {{ else if in $linkFile "src-link/" }} {{ $linkFile = replace $linkFile "src-link/" "" }} {{ else }} {{ $linkFile = print "docs/content/" $linkFile }} {{ end }} {{ range $elem_key, $elem_val := split $content "\n" }} {{if and (or (not $has_start_line) (ge (add $elem_key 1) ($start_line | int))) (or (not $has_end_line) (le (add $elem_key 1) ($end_line | int)))}} {{ $lines = $lines | append $elem_val }} {{ end }} {{ end }}
{{ transform.Highlight (delimit $lines "\n" | safeHTML) (.Get "type" | default "go") }}
Full source: [{{ $linkFile }}](https://github.com/ThreeDotsLabs/watermill/tree/master/{{ $linkFile }}) ================================================ FILE: docs/layouts/shortcodes/readfile.html ================================================ {{$file := .Get "file"}} {{- if eq (.Get "markdown") "true" -}} {{- $file | readFile | markdownify -}} {{- else -}} {{ $file | readFile | safeHTML }} {{- end -}} ================================================ FILE: docs/layouts/shortcodes/tab.html ================================================ {{ if .Parent -}} {{ $name := .Get 0 }} {{ $slug := .Get 1 }} {{ $group := printf "tabs-%s" (.Parent.Get 0) }} {{ if not (.Parent.Scratch.Get $group) }} {{ .Parent.Scratch.Set $group slice }} {{ end }} {{ .Parent.Scratch.Add $group (dict "Name" $name "Slug" $slug "Content" .Inner) }} {{ else -}} {{ errorf "%q: 'tab' shortcode must be inside 'tabs' shortcode" .Page.Path }} {{ end -}} ================================================ FILE: docs/layouts/shortcodes/tabs.html ================================================ {{ if .Inner }}{{ end }} {{ $id := .Get 0 }} {{ $group := printf "tabs-%s" $id }}
{{ range $index, $tab := .Scratch.Get $group -}} {{ end -}}
{{ range $index, $tab := .Scratch.Get $group -}}
{{ .Content | $.Page.RenderString -}}
{{ end -}}
================================================ FILE: docs/package.json ================================================ { "name": "watermill-docs", "version": "0.0.0", "description": "Doks theme", "author": "Thulite", "license": "MIT", "scripts": { "create": "hugo new", "dev": "hugo server --disableFastRender --noHTTPCache", "format": "prettier **/** -w -c", "build": "hugo --minify --gc -b ${URL}", "build:branch": "hugo --minify --gc -b ${DEPLOY_URL}", "preview": "vite preview --outDir public" }, "dependencies": { "@tabler/icons": "^3.12.0", "@thulite/doks-core": "^1.7.0", "@thulite/images": "^3.3.0", "@thulite/inline-svg": "^1.1.0", "@thulite/seo": "^2.4.0", "github-buttons": "^2.29.1", "thulite": "^2.5.0" }, "devDependencies": { "prettier": "^3.3.3", "vite": "^5.4.12" }, "engines": { "node": ">=20.11.0" } } ================================================ FILE: docs/resources/_gen/assets/scss/app.scss_901a6e181e810c5c7347a10d84f037ab.content ================================================ @charset "UTF-8"; /* Bluish cyan */ /* Gray */ /* Yellow */ /* Khaki */ /* Purple */ /* Vermilion */ :root[data-bs-theme="light"], [data-bs-theme="light"] ::backdrop { --sl-color-white: hsl(224, 10%, 10%); --sl-color-gray-1: hsl(224, 14%, 16%); --sl-color-gray-2: hsl(224, 10%, 23%); --sl-color-gray-3: hsl(224, 7%, 36%); --sl-color-gray-4: hsl(224, 6%, 56%); --sl-color-gray-5: hsl(224, 6%, 77%); --sl-color-gray-6: hsl(224, 20%, 94%); --sl-color-gray-7: hsl(224, 19%, 97%); --sl-color-black: hsl(0, 0%, 100%); } :root, ::backdrop { --sl-color-white: hsl(0, 0%, 100%); --sl-color-gray-1: hsl(224, 20%, 94%); --sl-color-gray-2: hsl(224, 6%, 77%); --sl-color-gray-3: hsl(224, 6%, 56%); --sl-color-gray-4: hsl(224, 7%, 36%); --sl-color-gray-5: hsl(224, 10%, 23%); --sl-color-gray-6: hsl(224, 14%, 16%); --sl-color-black: hsl(224, 10%, 10%); --sl-hue-orange: 41; --sl-color-orange-low: hsl(var(--sl-hue-orange), 39%, 22%); --sl-color-orange: hsl(var(--sl-hue-orange), 82%, 63%); --sl-color-orange-high: hsl(var(--sl-hue-orange), 82%, 87%); --sl-hue-green: 101; --sl-color-green-low: hsl(var(--sl-hue-green), 39%, 22%); --sl-color-green: hsl(var(--sl-hue-green), 82%, 63%); --sl-color-green-high: hsl(var(--sl-hue-green), 82%, 80%); --sl-hue-blue: 234; --sl-color-blue-low: hsl(var(--sl-hue-blue), 54%, 20%); --sl-color-blue: hsl(var(--sl-hue-blue), 100%, 60%); --sl-color-blue-high: hsl(var(--sl-hue-blue), 100%, 87%); --sl-hue-purple: 281; --sl-color-purple-low: hsl(var(--sl-hue-purple), 39%, 22%); --sl-color-purple: hsl(var(--sl-hue-purple), 82%, 63%); --sl-color-purple-high: hsl(var(--sl-hue-purple), 82%, 89%); --sl-hue-red: 339; --sl-color-red-low: hsl(var(--sl-hue-red), 39%, 22%); --sl-color-red: hsl(var(--sl-hue-red), 82%, 63%); --sl-color-red-high: hsl(var(--sl-hue-red), 82%, 87%); --sl-color-accent-low: hsl(224, 54%, 20%); --sl-color-accent: hsl(224, 100%, 60%); --sl-color-accent-high: hsl(224, 100%, 85%); --sl-color-text: var(--sl-color-gray-2); --sl-color-text-accent: var(--sl-color-accent-high); --sl-color-text-invert: var(--sl-color-accent-low); --sl-color-bg: var(--sl-color-black); --sl-color-bg-nav: var(--sl-color-gray-6); --sl-color-bg-sidebar: var(--sl-color-gray-6); --sl-color-bg-inline-code: var(--sl-color-gray-5); --sl-color-hairline-light: var(--sl-color-gray-5); --sl-color-hairline: var(--sl-color-gray-6); --sl-color-hairline-shade: var(--sl-color-black); --sl-color-backdrop-overlay: hsla(223, 13%, 10%, 0.66); --sl-shadow-sm: 0px 1px 1px hsla(0, 0%, 0%, 0.12), 0px 2px 1px hsla(0, 0%, 0%, 0.24); --sl-shadow-md: 0px 8px 4px hsla(0, 0%, 0%, 0.08), 0px 5px 2px hsla(0, 0%, 0%, 0.08), 0px 3px 2px hsla(0, 0%, 0%, 0.12), 0px 1px 1px hsla(0, 0%, 0%, 0.15); --sl-shadow-lg: 0px 25px 7px hsla(0, 0%, 0%, 0.03), 0px 16px 6px hsla(0, 0%, 0%, 0.1), 0px 9px 5px hsla(223, 13%, 10%, 0.33), 0px 4px 4px hsla(0, 0%, 0%, 0.75), 0px 4px 2px hsla(0, 0%, 0%, 0.25); --sl-text-xs: 0.8125rem; --sl-text-sm: 0.875rem; --sl-text-base: 1rem; --sl-text-lg: 1.125rem; --sl-text-xl: 1.25rem; --sl-text-2xl: 1.5rem; --sl-text-3xl: 1.8125rem; --sl-text-4xl: 2.1875rem; --sl-text-5xl: 2.625rem; --sl-text-6xl: 4rem; --sl-text-body: var(--sl-text-base); --sl-text-body-sm: var(--sl-text-xs); --sl-text-code: var(--sl-text-sm); --sl-text-code-sm: var(--sl-text-xs); --sl-text-h1: var(--sl-text-4xl); --sl-text-h2: var(--sl-text-3xl); --sl-text-h3: var(--sl-text-2xl); --sl-text-h4: var(--sl-text-xl); --sl-text-h5: var(--sl-text-lg); --sl-line-height: 1.8; --sl-line-height-headings: 1.2; --sl-font-system: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; --sl-font-system-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; --__sl-font: var(--sl-font, ""), var(--sl-font-system); --__sl-font-mono: var(--sl-font-mono, ""), var(--sl-font-system-mono); --sl-nav-height: 3.5rem; --sl-nav-pad-x: 1rem; --sl-nav-pad-y: 0.75rem; --sl-mobile-toc-height: 3rem; --sl-sidebar-width: 18.75rem; --sl-sidebar-pad-x: 1rem; --sl-content-width: 45rem; --sl-content-pad-x: 1rem; --sl-menu-button-size: 2rem; --sl-nav-gap: var(--sl-content-pad-x); --sl-outline-offset-inside: -0.1875rem; --sl-z-index-toc: 4; --sl-z-index-menu: 5; --sl-z-index-navbar: 10; --sl-z-index-skiplink: 20; } :root { --purple-hsl: 255, 60%, 60%; --overlay-blurple: hsla(var(--purple-hsl), 0.2); } :root { --ec-brdRad: 0px; --ec-brdWd: 1px; --ec-brdCol: color-mix(in srgb, var(--sl-color-gray-5), transparent 25%); --ec-codeFontFml: var(--__sl-font-mono); --ec-codeFontSize: var(--sl-text-code); --ec-codeFontWg: 400; --ec-codeLineHt: var(--sl-line-height); --ec-codePadBlk: 0.75rem; --ec-codePadInl: 1rem; --ec-codeBg: #011627; --ec-codeFg: #d6deeb; --ec-codeSelBg: #1d3b53; --ec-uiFontFml: var(--__sl-font); --ec-uiFontSize: 0.9rem; --ec-uiFontWg: 400; --ec-uiLineHt: 1.65; --ec-uiPadBlk: 0.25rem; --ec-uiPadInl: 1rem; --ec-uiSelBg: #234d708c; --ec-uiSelFg: #ffffff; --ec-focusBrd: #122d42; --ec-sbThumbCol: #ffffff17; --ec-sbThumbHoverCol: #ffffff49; --ec-tm-lineMarkerAccentMarg: 0rem; --ec-tm-lineMarkerAccentWd: 0.15rem; --ec-tm-lineDiffIndMargLeft: 0.25rem; --ec-tm-inlMarkerBrdWd: 1.5px; --ec-tm-inlMarkerBrdRad: 0.2rem; --ec-tm-inlMarkerPad: 0.15rem; --ec-tm-insDiffIndContent: "+"; --ec-tm-delDiffIndContent: "-"; --ec-tm-markBg: #ffffff17; --ec-tm-markBrdCol: #ffffff40; --ec-tm-insBg: #1e571599; --ec-tm-insBrdCol: #487f3bd0; --ec-tm-insDiffIndCol: #79b169d0; --ec-tm-delBg: #862d2799; --ec-tm-delBrdCol: #b4554bd0; --ec-tm-delDiffIndCol: #ed8779d0; --ec-frm-shdCol: #011627; --ec-frm-frameBoxShdCssVal: none; --ec-frm-edActTabBg: var(--sl-color-gray-6); --ec-frm-edActTabFg: var(--sl-color-text); --ec-frm-edActTabBrdCol: transparent; --ec-frm-edActTabIndHt: 1px; --ec-frm-edActTabIndTopCol: var(--sl-color-accent-high); --ec-frm-edActTabIndBtmCol: transparent; --ec-frm-edTabsMargInlStart: 0; --ec-frm-edTabsMargBlkStart: 0; --ec-frm-edTabBrdRad: 0px; --ec-frm-edTabBarBg: var(--sl-color-black); --ec-frm-edTabBarBrdCol: color-mix(in srgb, var(--sl-color-gray-5), transparent 25%); --ec-frm-edTabBarBrdBtmCol: color-mix(in srgb, var(--sl-color-gray-5), transparent 25%); --ec-frm-edBg: var(--sl-color-gray-6); --ec-frm-trmTtbDotsFg: color-mix(in srgb, var(--sl-color-gray-5), transparent 25%); --ec-frm-trmTtbDotsOpa: 0.75; --ec-frm-trmTtbBg: var(--sl-color-black); --ec-frm-trmTtbFg: var(--sl-color-text); --ec-frm-trmTtbBrdBtmCol: color-mix(in srgb, var(--sl-color-gray-5), transparent 25%); --ec-frm-trmBg: var(--sl-color-gray-6); --ec-frm-inlBtnFg: var(--sl-color-text); --ec-frm-inlBtnBg: var(--sl-color-text); --ec-frm-inlBtnBgIdleOpa: 0; --ec-frm-inlBtnBgHoverOrFocusOpa: 0.2; --ec-frm-inlBtnBgActOpa: 0.3; --ec-frm-inlBtnBrd: var(--sl-color-text); --ec-frm-inlBtnBrdOpa: 0.4; --ec-frm-tooltipSuccessBg: #158744; --ec-frm-tooltipSuccessFg: white; } :root, [data-bs-theme="light"] { --bs-blue: #3347ff; --bs-indigo: #6610f2; --bs-purple: #bd53ee; --bs-pink: #d63384; --bs-red: #ee5389; --bs-orange: #fd7e14; --bs-yellow: #eebd53; --bs-green: #84ee53; --bs-teal: #20c997; --bs-cyan: #0dcaf0; --bs-black: #000; --bs-white: #fff; --bs-gray: #6c757d; --bs-gray-dark: #343a40; --bs-gray-100: #f8f9fa; --bs-gray-200: #e9ecef; --bs-gray-300: #dee2e6; --bs-gray-400: #ced4da; --bs-gray-500: #adb5bd; --bs-gray-600: #6c757d; --bs-gray-700: #495057; --bs-gray-800: #343a40; --bs-gray-900: #212529; --bs-primary: #4f46e5; --bs-secondary: #6c757d; --bs-success: #84ee53; --bs-info: #3347ff; --bs-warning: #eebd53; --bs-danger: #ee5389; --bs-light: #f8f9fa; --bs-dark: #212529; --bs-primary-rgb: 79, 70, 229; --bs-secondary-rgb: 108, 117, 125; --bs-success-rgb: 132.2821, 238.017, 83.283; --bs-info-rgb: 51, 71.4, 255; --bs-warning-rgb: 238.017, 189.0179, 83.283; --bs-danger-rgb: 238.017, 83.283, 137.4399; --bs-light-rgb: 248, 249, 250; --bs-dark-rgb: 33, 37, 41; --bs-primary-text-emphasis: #201c5c; --bs-secondary-text-emphasis: #2b2f32; --bs-success-text-emphasis: #355f21; --bs-info-text-emphasis: #141d66; --bs-warning-text-emphasis: #5f4c21; --bs-danger-text-emphasis: #5f2137; --bs-light-text-emphasis: #495057; --bs-dark-text-emphasis: #495057; --bs-primary-bg-subtle: #dcdafa; --bs-secondary-bg-subtle: #e2e3e5; --bs-success-bg-subtle: #e6fcdd; --bs-info-bg-subtle: #d6daff; --bs-warning-bg-subtle: #fcf2dd; --bs-danger-bg-subtle: #fcdde7; --bs-light-bg-subtle: #fcfcfd; --bs-dark-bg-subtle: #ced4da; --bs-primary-border-subtle: #b9b5f5; --bs-secondary-border-subtle: #c4c8cb; --bs-success-border-subtle: #cef8ba; --bs-info-border-subtle: #adb6ff; --bs-warning-border-subtle: #f8e5ba; --bs-danger-border-subtle: #f8bad0; --bs-light-border-subtle: #e9ecef; --bs-dark-border-subtle: #adb5bd; --bs-white-rgb: 255, 255, 255; --bs-black-rgb: 0, 0, 0; --bs-font-sans-serif: "Heebo", "sans-serif", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; --bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; --bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0)); --bs-body-font-family: var(--bs-font-sans-serif); --bs-body-font-size: 1rem; --bs-body-font-weight: 400; --bs-body-line-height: 1.5; --bs-body-color: #1d2d35; --bs-body-color-rgb: 29, 45, 53; --bs-body-bg: #fff; --bs-body-bg-rgb: 255, 255, 255; --bs-emphasis-color: #000; --bs-emphasis-color-rgb: 0, 0, 0; --bs-secondary-color: rgba(29, 45, 53, 0.75); --bs-secondary-color-rgb: 29, 45, 53; --bs-secondary-bg: #e9ecef; --bs-secondary-bg-rgb: 233, 236, 239; --bs-tertiary-color: rgba(29, 45, 53, 0.5); --bs-tertiary-color-rgb: 29, 45, 53; --bs-tertiary-bg: #f8f9fa; --bs-tertiary-bg-rgb: 248, 249, 250; --bs-heading-color: inherit; --bs-link-color: #4f46e5; --bs-link-color-rgb: 79, 70, 229; --bs-link-decoration: none; --bs-link-hover-color: #3f38b7; --bs-link-hover-color-rgb: 63, 56, 183; --bs-link-hover-decoration: underline; --bs-code-color: #d63384; --bs-highlight-color: #1d2d35; --bs-highlight-bg: #fcf2dd; --bs-border-width: 1px; --bs-border-style: solid; --bs-border-color: #dee2e6; --bs-border-color-translucent: rgba(0, 0, 0, 0.175); --bs-border-radius: 0.375rem; --bs-border-radius-sm: 0.25rem; --bs-border-radius-lg: 0.5rem; --bs-border-radius-xl: 1rem; --bs-border-radius-xxl: 2rem; --bs-border-radius-2xl: var(--bs-border-radius-xxl); --bs-border-radius-pill: 50rem; --bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); --bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); --bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175); --bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075); --bs-focus-ring-width: 0.25rem; --bs-focus-ring-opacity: 0.25; --bs-focus-ring-color: rgba(79, 70, 229, 0.25); --bs-form-valid-color: #84ee53; --bs-form-valid-border-color: #84ee53; --bs-form-invalid-color: #ee5389; --bs-form-invalid-border-color: #ee5389; } [data-bs-theme="dark"] { color-scheme: dark; --bs-body-color: #c1c3c8; --bs-body-color-rgb: 192.831, 194.7078, 199.869; --bs-body-bg: #17181c; --bs-body-bg-rgb: 22.95, 24.31, 28.05; --bs-emphasis-color: #fff; --bs-emphasis-color-rgb: 255, 255, 255; --bs-secondary-color: rgba(193, 195, 200, 0.75); --bs-secondary-color-rgb: 192.831, 194.7078, 199.869; --bs-secondary-bg: #343a40; --bs-secondary-bg-rgb: 52, 58, 64; --bs-tertiary-color: rgba(193, 195, 200, 0.5); --bs-tertiary-color-rgb: 192.831, 194.7078, 199.869; --bs-tertiary-bg: #2b3035; --bs-tertiary-bg-rgb: 43, 48, 53; --bs-primary-text-emphasis: #9590ef; --bs-secondary-text-emphasis: #a7acb1; --bs-success-text-emphasis: #b5f598; --bs-info-text-emphasis: #8591ff; --bs-warning-text-emphasis: #f5d798; --bs-danger-text-emphasis: #f598b8; --bs-light-text-emphasis: #f8f9fa; --bs-dark-text-emphasis: #dee2e6; --bs-primary-bg-subtle: #100e2e; --bs-secondary-bg-subtle: #161719; --bs-success-bg-subtle: #1a3011; --bs-info-bg-subtle: #0a0e33; --bs-warning-bg-subtle: #302611; --bs-danger-bg-subtle: #30111b; --bs-light-bg-subtle: #23262f; --bs-dark-bg-subtle: #1a1d20; --bs-primary-border-subtle: #2f2a89; --bs-secondary-border-subtle: #41464b; --bs-success-border-subtle: #4f8f32; --bs-info-border-subtle: #1f2b99; --bs-warning-border-subtle: #8f7132; --bs-danger-border-subtle: #8f3252; --bs-light-border-subtle: #353841; --bs-dark-border-subtle: #343a40; --bs-heading-color: white; --bs-link-color: #b3c7ff; --bs-link-hover-color: #c2d2ff; --bs-link-color-rgb: 178.5, 198.9, 255; --bs-link-hover-color-rgb: 194, 210, 255; --bs-code-color: #e685b5; --bs-highlight-color: #c1c3c8; --bs-highlight-bg: #5f4c21; --bs-border-color: #495057; --bs-border-color-translucent: rgba(255, 255, 255, 0.15); --bs-form-valid-color: #b5f598; --bs-form-valid-border-color: #b5f598; --bs-form-invalid-color: #f598b8; --bs-form-invalid-border-color: #f598b8; } *, *::before, *::after { box-sizing: border-box; } @media (prefers-reduced-motion: no-preference) { :root { scroll-behavior: smooth; } } body { margin: 0; font-family: var(--bs-body-font-family); font-size: var(--bs-body-font-size); font-weight: var(--bs-body-font-weight); line-height: var(--bs-body-line-height); color: var(--bs-body-color); text-align: var(--bs-body-text-align); background-color: var(--bs-body-bg); -webkit-text-size-adjust: 100%; -webkit-tap-highlight-color: rgba(0, 0, 0, 0); } hr { margin: 1rem 0; color: inherit; border: 0; border-top: var(--bs-border-width) solid; opacity: 0.25; } h6, .h6, h5, .h5, h4, .h4, h3, .h3, h2, .h2, h1, .h1 { margin-top: 0; margin-bottom: 0.5rem; font-weight: 700; line-height: 1.2; color: var(--bs-heading-color); } h1, .h1 { font-size: calc(1.375rem + 1.5vw); } @media (min-width: 1200px) { h1, .h1 { font-size: 2.5rem; } } h2, .h2 { font-size: calc(1.325rem + 0.9vw); } @media (min-width: 1200px) { h2, .h2 { font-size: 2rem; } } h3, .h3 { font-size: calc(1.3rem + 0.6vw); } @media (min-width: 1200px) { h3, .h3 { font-size: 1.75rem; } } h4, .h4 { font-size: calc(1.275rem + 0.3vw); } @media (min-width: 1200px) { h4, .h4 { font-size: 1.5rem; } } h5, .h5 { font-size: 1.25rem; } h6, .h6 { font-size: 1rem; } p { margin-top: 0; margin-bottom: 1rem; } abbr[title] { text-decoration: underline dotted; cursor: help; text-decoration-skip-ink: none; } address { margin-bottom: 1rem; font-style: normal; line-height: inherit; } ol, ul { padding-left: 2rem; } ol, ul, dl { margin-top: 0; margin-bottom: 1rem; } ol ol, ul ul, ol ul, ul ol { margin-bottom: 0; } dt { font-weight: 700; } dd { margin-bottom: .5rem; margin-left: 0; } blockquote { margin: 0 0 1rem; } b, strong { font-weight: bolder; } small, .small { font-size: 0.875em; } mark, .mark { padding: 0.1875em; color: var(--bs-highlight-color); background-color: var(--bs-highlight-bg); } sub, sup { position: relative; font-size: 0.75em; line-height: 0; vertical-align: baseline; } sub { bottom: -.25em; } sup { top: -.5em; } a { color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1)); text-decoration: none; } a:hover { --bs-link-color-rgb: var(--bs-link-hover-color-rgb); text-decoration: underline; } a:not([href]):not([class]), a:not([href]):not([class]):hover { color: inherit; text-decoration: none; } pre, code, kbd, samp { font-family: var(--bs-font-monospace); font-size: 1em; } pre { display: block; margin-top: 0; margin-bottom: 1rem; overflow: auto; font-size: 0.875em; } pre code { font-size: inherit; color: inherit; word-break: normal; } code { font-size: 0.875em; color: var(--bs-code-color); word-wrap: break-word; } a > code { color: inherit; } kbd { padding: 0.1875rem 0.375rem; font-size: 0.875em; color: var(--bs-body-bg); background-color: var(--bs-body-color); border-radius: 0.25rem; } kbd kbd { padding: 0; font-size: 1em; } figure { margin: 0 0 1rem; } img, svg { vertical-align: middle; } table { caption-side: bottom; border-collapse: collapse; } caption { padding-top: 0.5rem; padding-bottom: 0.5rem; color: var(--bs-secondary-color); text-align: left; } th { text-align: inherit; text-align: -webkit-match-parent; } thead, tbody, tfoot, tr, td, th { border-color: inherit; border-style: solid; border-width: 0; } label { display: inline-block; } button { border-radius: 0; } button:focus:not(:focus-visible) { outline: 0; } input, button, select, optgroup, textarea { margin: 0; font-family: inherit; font-size: inherit; line-height: inherit; } button, select { text-transform: none; } [role="button"] { cursor: pointer; } select { word-wrap: normal; } select:disabled { opacity: 1; } [list]:not([type="date"]):not([type="datetime-local"]):not([type="month"]):not([type="week"]):not([type="time"])::-webkit-calendar-picker-indicator { display: none !important; } button, [type="button"], [type="reset"], [type="submit"] { -webkit-appearance: button; } button:not(:disabled), [type="button"]:not(:disabled), [type="reset"]:not(:disabled), [type="submit"]:not(:disabled) { cursor: pointer; } ::-moz-focus-inner { padding: 0; border-style: none; } textarea { resize: vertical; } fieldset { min-width: 0; padding: 0; margin: 0; border: 0; } legend { float: left; width: 100%; padding: 0; margin-bottom: 0.5rem; font-size: calc(1.275rem + 0.3vw); line-height: inherit; } @media (min-width: 1200px) { legend { font-size: 1.5rem; } } legend + * { clear: left; } ::-webkit-datetime-edit-fields-wrapper, ::-webkit-datetime-edit-text, ::-webkit-datetime-edit-minute, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-year-field { padding: 0; } ::-webkit-inner-spin-button { height: auto; } [type="search"] { -webkit-appearance: textfield; outline-offset: -2px; } /* rtl:raw: [type="tel"], [type="url"], [type="email"], [type="number"] { direction: ltr; } */ ::-webkit-search-decoration { -webkit-appearance: none; } ::-webkit-color-swatch-wrapper { padding: 0; } ::file-selector-button { font: inherit; -webkit-appearance: button; } output { display: inline-block; } iframe { border: 0; } summary { display: list-item; cursor: pointer; } progress { vertical-align: baseline; } [hidden] { display: none !important; } .lead { font-size: 1.25rem; font-weight: 400; } .display-1 { font-size: calc(1.625rem + 4.5vw); font-weight: 300; line-height: 1.2; } @media (min-width: 1200px) { .display-1 { font-size: 5rem; } } .display-2 { font-size: calc(1.575rem + 3.9vw); font-weight: 300; line-height: 1.2; } @media (min-width: 1200px) { .display-2 { font-size: 4.5rem; } } .display-3 { font-size: calc(1.525rem + 3.3vw); font-weight: 300; line-height: 1.2; } @media (min-width: 1200px) { .display-3 { font-size: 4rem; } } .display-4 { font-size: calc(1.475rem + 2.7vw); font-weight: 300; line-height: 1.2; } @media (min-width: 1200px) { .display-4 { font-size: 3.5rem; } } .display-5 { font-size: calc(1.425rem + 2.1vw); font-weight: 300; line-height: 1.2; } @media (min-width: 1200px) { .display-5 { font-size: 3rem; } } .display-6 { font-size: calc(1.375rem + 1.5vw); font-weight: 300; line-height: 1.2; } @media (min-width: 1200px) { .display-6 { font-size: 2.5rem; } } .list-unstyled, ul.list-star li, ul.list-package li, ul.list-speech-balloon li, ul.list-books li, ul.list-toolbox li, .comment-list { padding-left: 0; list-style: none; } .list-inline { padding-left: 0; list-style: none; } .list-inline-item { display: inline-block; } .list-inline-item:not(:last-child) { margin-right: 0.5rem; } .initialism { font-size: 0.875em; text-transform: uppercase; } .blockquote { margin-bottom: 1rem; font-size: 1.25rem; } .blockquote > :last-child { margin-bottom: 0; } .blockquote-footer { margin-top: -1rem; margin-bottom: 1rem; font-size: 0.875em; color: #6c757d; } .blockquote-footer::before { content: "\2014\00A0"; } .img-fluid { max-width: 100%; height: auto; } .img-thumbnail { padding: 0.25rem; background-color: var(--bs-body-bg); border: var(--bs-border-width) solid var(--bs-border-color); border-radius: var(--bs-border-radius); max-width: 100%; height: auto; } .figure { display: inline-block; } .figure-img { margin-bottom: 0.5rem; line-height: 1; } .figure-caption { font-size: 0.875em; color: var(--bs-secondary-color); } .container, .container-fluid, .container-xxl, .container-xl, .container-lg, .container-md, .container-sm { --bs-gutter-x: 3rem; --bs-gutter-y: 0; width: 100%; padding-right: calc(var(--bs-gutter-x) * .5); padding-left: calc(var(--bs-gutter-x) * .5); margin-right: auto; margin-left: auto; } @media (min-width: 576px) { .container-sm, .container { max-width: 540px; } } @media (min-width: 768px) { .container-md, .container-sm, .container { max-width: 720px; } } @media (min-width: 992px) { .container-lg, .container-md, .container-sm, .container { max-width: 960px; } } @media (min-width: 1200px) { .container-xl, .container-lg, .container-md, .container-sm, .container { max-width: 1240px; } } @media (min-width: 1400px) { .container-xxl, .container-xl, .container-lg, .container-md, .container-sm, .container { max-width: 1820px; } } :root { --bs-breakpoint-xs: 0; --bs-breakpoint-sm: 576px; --bs-breakpoint-md: 768px; --bs-breakpoint-lg: 992px; --bs-breakpoint-xl: 1200px; --bs-breakpoint-xxl: 1400px; } .row { --bs-gutter-x: 3rem; --bs-gutter-y: 0; display: flex; flex-wrap: wrap; margin-top: calc(-1 * var(--bs-gutter-y)); margin-right: calc(-.5 * var(--bs-gutter-x)); margin-left: calc(-.5 * var(--bs-gutter-x)); } .row > * { flex-shrink: 0; width: 100%; max-width: 100%; padding-right: calc(var(--bs-gutter-x) * .5); padding-left: calc(var(--bs-gutter-x) * .5); margin-top: var(--bs-gutter-y); } .col { flex: 1 0 0%; } .row-cols-auto > * { flex: 0 0 auto; width: auto; } .row-cols-1 > * { flex: 0 0 auto; width: 100%; } .row-cols-2 > * { flex: 0 0 auto; width: 50%; } .row-cols-3 > * { flex: 0 0 auto; width: 33.33333333%; } .row-cols-4 > * { flex: 0 0 auto; width: 25%; } .row-cols-5 > * { flex: 0 0 auto; width: 20%; } .row-cols-6 > * { flex: 0 0 auto; width: 16.66666667%; } .col-auto { flex: 0 0 auto; width: auto; } .col-1 { flex: 0 0 auto; width: 6.25%; } .col-2 { flex: 0 0 auto; width: 12.5%; } .col-3 { flex: 0 0 auto; width: 18.75%; } .col-4 { flex: 0 0 auto; width: 25%; } .col-5 { flex: 0 0 auto; width: 31.25%; } .col-6 { flex: 0 0 auto; width: 37.5%; } .col-7 { flex: 0 0 auto; width: 43.75%; } .col-8 { flex: 0 0 auto; width: 50%; } .col-9 { flex: 0 0 auto; width: 56.25%; } .col-10 { flex: 0 0 auto; width: 62.5%; } .col-11 { flex: 0 0 auto; width: 68.75%; } .col-12 { flex: 0 0 auto; width: 75%; } .col-13 { flex: 0 0 auto; width: 81.25%; } .col-14 { flex: 0 0 auto; width: 87.5%; } .col-15 { flex: 0 0 auto; width: 93.75%; } .col-16 { flex: 0 0 auto; width: 100%; } .offset-1 { margin-left: 6.25%; } .offset-2 { margin-left: 12.5%; } .offset-3 { margin-left: 18.75%; } .offset-4 { margin-left: 25%; } .offset-5 { margin-left: 31.25%; } .offset-6 { margin-left: 37.5%; } .offset-7 { margin-left: 43.75%; } .offset-8 { margin-left: 50%; } .offset-9 { margin-left: 56.25%; } .offset-10 { margin-left: 62.5%; } .offset-11 { margin-left: 68.75%; } .offset-12 { margin-left: 75%; } .offset-13 { margin-left: 81.25%; } .offset-14 { margin-left: 87.5%; } .offset-15 { margin-left: 93.75%; } .g-0, .gx-0 { --bs-gutter-x: 0; } .g-0, .gy-0 { --bs-gutter-y: 0; } .g-1, .gx-1 { --bs-gutter-x: 0.25rem; } .g-1, .gy-1 { --bs-gutter-y: 0.25rem; } .g-2, .gx-2 { --bs-gutter-x: 0.5rem; } .g-2, .gy-2 { --bs-gutter-y: 0.5rem; } .g-3, .gx-3 { --bs-gutter-x: 1rem; } .g-3, .gy-3 { --bs-gutter-y: 1rem; } .g-4, .gx-4 { --bs-gutter-x: 1.5rem; } .g-4, .gy-4 { --bs-gutter-y: 1.5rem; } .g-5, .gx-5 { --bs-gutter-x: 3rem; } .g-5, .gy-5 { --bs-gutter-y: 3rem; } @media (min-width: 576px) { .col-sm { flex: 1 0 0%; } .row-cols-sm-auto > * { flex: 0 0 auto; width: auto; } .row-cols-sm-1 > * { flex: 0 0 auto; width: 100%; } .row-cols-sm-2 > * { flex: 0 0 auto; width: 50%; } .row-cols-sm-3 > * { flex: 0 0 auto; width: 33.33333333%; } .row-cols-sm-4 > * { flex: 0 0 auto; width: 25%; } .row-cols-sm-5 > * { flex: 0 0 auto; width: 20%; } .row-cols-sm-6 > * { flex: 0 0 auto; width: 16.66666667%; } .col-sm-auto { flex: 0 0 auto; width: auto; } .col-sm-1 { flex: 0 0 auto; width: 6.25%; } .col-sm-2 { flex: 0 0 auto; width: 12.5%; } .col-sm-3 { flex: 0 0 auto; width: 18.75%; } .col-sm-4 { flex: 0 0 auto; width: 25%; } .col-sm-5 { flex: 0 0 auto; width: 31.25%; } .col-sm-6 { flex: 0 0 auto; width: 37.5%; } .col-sm-7 { flex: 0 0 auto; width: 43.75%; } .col-sm-8 { flex: 0 0 auto; width: 50%; } .col-sm-9 { flex: 0 0 auto; width: 56.25%; } .col-sm-10 { flex: 0 0 auto; width: 62.5%; } .col-sm-11 { flex: 0 0 auto; width: 68.75%; } .col-sm-12 { flex: 0 0 auto; width: 75%; } .col-sm-13 { flex: 0 0 auto; width: 81.25%; } .col-sm-14 { flex: 0 0 auto; width: 87.5%; } .col-sm-15 { flex: 0 0 auto; width: 93.75%; } .col-sm-16 { flex: 0 0 auto; width: 100%; } .offset-sm-0 { margin-left: 0; } .offset-sm-1 { margin-left: 6.25%; } .offset-sm-2 { margin-left: 12.5%; } .offset-sm-3 { margin-left: 18.75%; } .offset-sm-4 { margin-left: 25%; } .offset-sm-5 { margin-left: 31.25%; } .offset-sm-6 { margin-left: 37.5%; } .offset-sm-7 { margin-left: 43.75%; } .offset-sm-8 { margin-left: 50%; } .offset-sm-9 { margin-left: 56.25%; } .offset-sm-10 { margin-left: 62.5%; } .offset-sm-11 { margin-left: 68.75%; } .offset-sm-12 { margin-left: 75%; } .offset-sm-13 { margin-left: 81.25%; } .offset-sm-14 { margin-left: 87.5%; } .offset-sm-15 { margin-left: 93.75%; } .g-sm-0, .gx-sm-0 { --bs-gutter-x: 0; } .g-sm-0, .gy-sm-0 { --bs-gutter-y: 0; } .g-sm-1, .gx-sm-1 { --bs-gutter-x: 0.25rem; } .g-sm-1, .gy-sm-1 { --bs-gutter-y: 0.25rem; } .g-sm-2, .gx-sm-2 { --bs-gutter-x: 0.5rem; } .g-sm-2, .gy-sm-2 { --bs-gutter-y: 0.5rem; } .g-sm-3, .gx-sm-3 { --bs-gutter-x: 1rem; } .g-sm-3, .gy-sm-3 { --bs-gutter-y: 1rem; } .g-sm-4, .gx-sm-4 { --bs-gutter-x: 1.5rem; } .g-sm-4, .gy-sm-4 { --bs-gutter-y: 1.5rem; } .g-sm-5, .gx-sm-5 { --bs-gutter-x: 3rem; } .g-sm-5, .gy-sm-5 { --bs-gutter-y: 3rem; } } @media (min-width: 768px) { .col-md { flex: 1 0 0%; } .row-cols-md-auto > * { flex: 0 0 auto; width: auto; } .row-cols-md-1 > * { flex: 0 0 auto; width: 100%; } .row-cols-md-2 > * { flex: 0 0 auto; width: 50%; } .row-cols-md-3 > * { flex: 0 0 auto; width: 33.33333333%; } .row-cols-md-4 > * { flex: 0 0 auto; width: 25%; } .row-cols-md-5 > * { flex: 0 0 auto; width: 20%; } .row-cols-md-6 > * { flex: 0 0 auto; width: 16.66666667%; } .col-md-auto { flex: 0 0 auto; width: auto; } .col-md-1 { flex: 0 0 auto; width: 6.25%; } .col-md-2 { flex: 0 0 auto; width: 12.5%; } .col-md-3 { flex: 0 0 auto; width: 18.75%; } .col-md-4 { flex: 0 0 auto; width: 25%; } .col-md-5 { flex: 0 0 auto; width: 31.25%; } .col-md-6 { flex: 0 0 auto; width: 37.5%; } .col-md-7 { flex: 0 0 auto; width: 43.75%; } .col-md-8 { flex: 0 0 auto; width: 50%; } .col-md-9 { flex: 0 0 auto; width: 56.25%; } .col-md-10 { flex: 0 0 auto; width: 62.5%; } .col-md-11 { flex: 0 0 auto; width: 68.75%; } .col-md-12 { flex: 0 0 auto; width: 75%; } .col-md-13 { flex: 0 0 auto; width: 81.25%; } .col-md-14 { flex: 0 0 auto; width: 87.5%; } .col-md-15 { flex: 0 0 auto; width: 93.75%; } .col-md-16 { flex: 0 0 auto; width: 100%; } .offset-md-0 { margin-left: 0; } .offset-md-1 { margin-left: 6.25%; } .offset-md-2 { margin-left: 12.5%; } .offset-md-3 { margin-left: 18.75%; } .offset-md-4 { margin-left: 25%; } .offset-md-5 { margin-left: 31.25%; } .offset-md-6 { margin-left: 37.5%; } .offset-md-7 { margin-left: 43.75%; } .offset-md-8 { margin-left: 50%; } .offset-md-9 { margin-left: 56.25%; } .offset-md-10 { margin-left: 62.5%; } .offset-md-11 { margin-left: 68.75%; } .offset-md-12 { margin-left: 75%; } .offset-md-13 { margin-left: 81.25%; } .offset-md-14 { margin-left: 87.5%; } .offset-md-15 { margin-left: 93.75%; } .g-md-0, .gx-md-0 { --bs-gutter-x: 0; } .g-md-0, .gy-md-0 { --bs-gutter-y: 0; } .g-md-1, .gx-md-1 { --bs-gutter-x: 0.25rem; } .g-md-1, .gy-md-1 { --bs-gutter-y: 0.25rem; } .g-md-2, .gx-md-2 { --bs-gutter-x: 0.5rem; } .g-md-2, .gy-md-2 { --bs-gutter-y: 0.5rem; } .g-md-3, .gx-md-3 { --bs-gutter-x: 1rem; } .g-md-3, .gy-md-3 { --bs-gutter-y: 1rem; } .g-md-4, .gx-md-4 { --bs-gutter-x: 1.5rem; } .g-md-4, .gy-md-4 { --bs-gutter-y: 1.5rem; } .g-md-5, .gx-md-5 { --bs-gutter-x: 3rem; } .g-md-5, .gy-md-5 { --bs-gutter-y: 3rem; } } @media (min-width: 992px) { .col-lg { flex: 1 0 0%; } .row-cols-lg-auto > * { flex: 0 0 auto; width: auto; } .row-cols-lg-1 > * { flex: 0 0 auto; width: 100%; } .row-cols-lg-2 > * { flex: 0 0 auto; width: 50%; } .row-cols-lg-3 > * { flex: 0 0 auto; width: 33.33333333%; } .row-cols-lg-4 > * { flex: 0 0 auto; width: 25%; } .row-cols-lg-5 > * { flex: 0 0 auto; width: 20%; } .row-cols-lg-6 > * { flex: 0 0 auto; width: 16.66666667%; } .col-lg-auto { flex: 0 0 auto; width: auto; } .col-lg-1 { flex: 0 0 auto; width: 6.25%; } .col-lg-2 { flex: 0 0 auto; width: 12.5%; } .col-lg-3 { flex: 0 0 auto; width: 18.75%; } .col-lg-4 { flex: 0 0 auto; width: 25%; } .col-lg-5 { flex: 0 0 auto; width: 31.25%; } .col-lg-6 { flex: 0 0 auto; width: 37.5%; } .col-lg-7 { flex: 0 0 auto; width: 43.75%; } .col-lg-8 { flex: 0 0 auto; width: 50%; } .col-lg-9 { flex: 0 0 auto; width: 56.25%; } .col-lg-10 { flex: 0 0 auto; width: 62.5%; } .col-lg-11 { flex: 0 0 auto; width: 68.75%; } .col-lg-12 { flex: 0 0 auto; width: 75%; } .col-lg-13 { flex: 0 0 auto; width: 81.25%; } .col-lg-14 { flex: 0 0 auto; width: 87.5%; } .col-lg-15 { flex: 0 0 auto; width: 93.75%; } .col-lg-16 { flex: 0 0 auto; width: 100%; } .offset-lg-0 { margin-left: 0; } .offset-lg-1 { margin-left: 6.25%; } .offset-lg-2 { margin-left: 12.5%; } .offset-lg-3 { margin-left: 18.75%; } .offset-lg-4 { margin-left: 25%; } .offset-lg-5 { margin-left: 31.25%; } .offset-lg-6 { margin-left: 37.5%; } .offset-lg-7 { margin-left: 43.75%; } .offset-lg-8 { margin-left: 50%; } .offset-lg-9 { margin-left: 56.25%; } .offset-lg-10 { margin-left: 62.5%; } .offset-lg-11 { margin-left: 68.75%; } .offset-lg-12 { margin-left: 75%; } .offset-lg-13 { margin-left: 81.25%; } .offset-lg-14 { margin-left: 87.5%; } .offset-lg-15 { margin-left: 93.75%; } .g-lg-0, .gx-lg-0 { --bs-gutter-x: 0; } .g-lg-0, .gy-lg-0 { --bs-gutter-y: 0; } .g-lg-1, .gx-lg-1 { --bs-gutter-x: 0.25rem; } .g-lg-1, .gy-lg-1 { --bs-gutter-y: 0.25rem; } .g-lg-2, .gx-lg-2 { --bs-gutter-x: 0.5rem; } .g-lg-2, .gy-lg-2 { --bs-gutter-y: 0.5rem; } .g-lg-3, .gx-lg-3 { --bs-gutter-x: 1rem; } .g-lg-3, .gy-lg-3 { --bs-gutter-y: 1rem; } .g-lg-4, .gx-lg-4 { --bs-gutter-x: 1.5rem; } .g-lg-4, .gy-lg-4 { --bs-gutter-y: 1.5rem; } .g-lg-5, .gx-lg-5 { --bs-gutter-x: 3rem; } .g-lg-5, .gy-lg-5 { --bs-gutter-y: 3rem; } } @media (min-width: 1200px) { .col-xl { flex: 1 0 0%; } .row-cols-xl-auto > * { flex: 0 0 auto; width: auto; } .row-cols-xl-1 > * { flex: 0 0 auto; width: 100%; } .row-cols-xl-2 > * { flex: 0 0 auto; width: 50%; } .row-cols-xl-3 > * { flex: 0 0 auto; width: 33.33333333%; } .row-cols-xl-4 > * { flex: 0 0 auto; width: 25%; } .row-cols-xl-5 > * { flex: 0 0 auto; width: 20%; } .row-cols-xl-6 > * { flex: 0 0 auto; width: 16.66666667%; } .col-xl-auto { flex: 0 0 auto; width: auto; } .col-xl-1 { flex: 0 0 auto; width: 6.25%; } .col-xl-2 { flex: 0 0 auto; width: 12.5%; } .col-xl-3 { flex: 0 0 auto; width: 18.75%; } .col-xl-4 { flex: 0 0 auto; width: 25%; } .col-xl-5 { flex: 0 0 auto; width: 31.25%; } .col-xl-6 { flex: 0 0 auto; width: 37.5%; } .col-xl-7 { flex: 0 0 auto; width: 43.75%; } .col-xl-8 { flex: 0 0 auto; width: 50%; } .col-xl-9 { flex: 0 0 auto; width: 56.25%; } .col-xl-10 { flex: 0 0 auto; width: 62.5%; } .col-xl-11 { flex: 0 0 auto; width: 68.75%; } .col-xl-12 { flex: 0 0 auto; width: 75%; } .col-xl-13 { flex: 0 0 auto; width: 81.25%; } .col-xl-14 { flex: 0 0 auto; width: 87.5%; } .col-xl-15 { flex: 0 0 auto; width: 93.75%; } .col-xl-16 { flex: 0 0 auto; width: 100%; } .offset-xl-0 { margin-left: 0; } .offset-xl-1 { margin-left: 6.25%; } .offset-xl-2 { margin-left: 12.5%; } .offset-xl-3 { margin-left: 18.75%; } .offset-xl-4 { margin-left: 25%; } .offset-xl-5 { margin-left: 31.25%; } .offset-xl-6 { margin-left: 37.5%; } .offset-xl-7 { margin-left: 43.75%; } .offset-xl-8 { margin-left: 50%; } .offset-xl-9 { margin-left: 56.25%; } .offset-xl-10 { margin-left: 62.5%; } .offset-xl-11 { margin-left: 68.75%; } .offset-xl-12 { margin-left: 75%; } .offset-xl-13 { margin-left: 81.25%; } .offset-xl-14 { margin-left: 87.5%; } .offset-xl-15 { margin-left: 93.75%; } .g-xl-0, .gx-xl-0 { --bs-gutter-x: 0; } .g-xl-0, .gy-xl-0 { --bs-gutter-y: 0; } .g-xl-1, .gx-xl-1 { --bs-gutter-x: 0.25rem; } .g-xl-1, .gy-xl-1 { --bs-gutter-y: 0.25rem; } .g-xl-2, .gx-xl-2 { --bs-gutter-x: 0.5rem; } .g-xl-2, .gy-xl-2 { --bs-gutter-y: 0.5rem; } .g-xl-3, .gx-xl-3 { --bs-gutter-x: 1rem; } .g-xl-3, .gy-xl-3 { --bs-gutter-y: 1rem; } .g-xl-4, .gx-xl-4 { --bs-gutter-x: 1.5rem; } .g-xl-4, .gy-xl-4 { --bs-gutter-y: 1.5rem; } .g-xl-5, .gx-xl-5 { --bs-gutter-x: 3rem; } .g-xl-5, .gy-xl-5 { --bs-gutter-y: 3rem; } } @media (min-width: 1400px) { .col-xxl { flex: 1 0 0%; } .row-cols-xxl-auto > * { flex: 0 0 auto; width: auto; } .row-cols-xxl-1 > * { flex: 0 0 auto; width: 100%; } .row-cols-xxl-2 > * { flex: 0 0 auto; width: 50%; } .row-cols-xxl-3 > * { flex: 0 0 auto; width: 33.33333333%; } .row-cols-xxl-4 > * { flex: 0 0 auto; width: 25%; } .row-cols-xxl-5 > * { flex: 0 0 auto; width: 20%; } .row-cols-xxl-6 > * { flex: 0 0 auto; width: 16.66666667%; } .col-xxl-auto { flex: 0 0 auto; width: auto; } .col-xxl-1 { flex: 0 0 auto; width: 6.25%; } .col-xxl-2 { flex: 0 0 auto; width: 12.5%; } .col-xxl-3 { flex: 0 0 auto; width: 18.75%; } .col-xxl-4 { flex: 0 0 auto; width: 25%; } .col-xxl-5 { flex: 0 0 auto; width: 31.25%; } .col-xxl-6 { flex: 0 0 auto; width: 37.5%; } .col-xxl-7 { flex: 0 0 auto; width: 43.75%; } .col-xxl-8 { flex: 0 0 auto; width: 50%; } .col-xxl-9 { flex: 0 0 auto; width: 56.25%; } .col-xxl-10 { flex: 0 0 auto; width: 62.5%; } .col-xxl-11 { flex: 0 0 auto; width: 68.75%; } .col-xxl-12 { flex: 0 0 auto; width: 75%; } .col-xxl-13 { flex: 0 0 auto; width: 81.25%; } .col-xxl-14 { flex: 0 0 auto; width: 87.5%; } .col-xxl-15 { flex: 0 0 auto; width: 93.75%; } .col-xxl-16 { flex: 0 0 auto; width: 100%; } .offset-xxl-0 { margin-left: 0; } .offset-xxl-1 { margin-left: 6.25%; } .offset-xxl-2 { margin-left: 12.5%; } .offset-xxl-3 { margin-left: 18.75%; } .offset-xxl-4 { margin-left: 25%; } .offset-xxl-5 { margin-left: 31.25%; } .offset-xxl-6 { margin-left: 37.5%; } .offset-xxl-7 { margin-left: 43.75%; } .offset-xxl-8 { margin-left: 50%; } .offset-xxl-9 { margin-left: 56.25%; } .offset-xxl-10 { margin-left: 62.5%; } .offset-xxl-11 { margin-left: 68.75%; } .offset-xxl-12 { margin-left: 75%; } .offset-xxl-13 { margin-left: 81.25%; } .offset-xxl-14 { margin-left: 87.5%; } .offset-xxl-15 { margin-left: 93.75%; } .g-xxl-0, .gx-xxl-0 { --bs-gutter-x: 0; } .g-xxl-0, .gy-xxl-0 { --bs-gutter-y: 0; } .g-xxl-1, .gx-xxl-1 { --bs-gutter-x: 0.25rem; } .g-xxl-1, .gy-xxl-1 { --bs-gutter-y: 0.25rem; } .g-xxl-2, .gx-xxl-2 { --bs-gutter-x: 0.5rem; } .g-xxl-2, .gy-xxl-2 { --bs-gutter-y: 0.5rem; } .g-xxl-3, .gx-xxl-3 { --bs-gutter-x: 1rem; } .g-xxl-3, .gy-xxl-3 { --bs-gutter-y: 1rem; } .g-xxl-4, .gx-xxl-4 { --bs-gutter-x: 1.5rem; } .g-xxl-4, .gy-xxl-4 { --bs-gutter-y: 1.5rem; } .g-xxl-5, .gx-xxl-5 { --bs-gutter-x: 3rem; } .g-xxl-5, .gy-xxl-5 { --bs-gutter-y: 3rem; } } .clearfix::after { display: block; clear: both; content: ""; } .text-bg-primary { color: #fff !important; background-color: RGBA(var(--bs-primary-rgb), var(--bs-bg-opacity, 1)) !important; } .text-bg-secondary { color: #fff !important; background-color: RGBA(var(--bs-secondary-rgb), var(--bs-bg-opacity, 1)) !important; } .text-bg-success { color: #000 !important; background-color: RGBA(var(--bs-success-rgb), var(--bs-bg-opacity, 1)) !important; } .text-bg-info { color: #fff !important; background-color: RGBA(var(--bs-info-rgb), var(--bs-bg-opacity, 1)) !important; } .text-bg-warning { color: #000 !important; background-color: RGBA(var(--bs-warning-rgb), var(--bs-bg-opacity, 1)) !important; } .text-bg-danger { color: #000 !important; background-color: RGBA(var(--bs-danger-rgb), var(--bs-bg-opacity, 1)) !important; } .text-bg-light { color: #000 !important; background-color: RGBA(var(--bs-light-rgb), var(--bs-bg-opacity, 1)) !important; } .text-bg-dark { color: #fff !important; background-color: RGBA(var(--bs-dark-rgb), var(--bs-bg-opacity, 1)) !important; } .link-primary { color: RGBA(var(--bs-primary-rgb), var(--bs-link-opacity, 1)) !important; text-decoration-color: RGBA(var(--bs-primary-rgb), var(--bs-link-underline-opacity, 1)) !important; } .link-primary:hover, .link-primary:focus { color: RGBA(63, 56, 183, var(--bs-link-opacity, 1)) !important; text-decoration-color: RGBA(63, 56, 183, var(--bs-link-underline-opacity, 1)) !important; } .link-secondary { color: RGBA(var(--bs-secondary-rgb), var(--bs-link-opacity, 1)) !important; text-decoration-color: RGBA(var(--bs-secondary-rgb), var(--bs-link-underline-opacity, 1)) !important; } .link-secondary:hover, .link-secondary:focus { color: RGBA(86, 94, 100, var(--bs-link-opacity, 1)) !important; text-decoration-color: RGBA(86, 94, 100, var(--bs-link-underline-opacity, 1)) !important; } .link-success { color: RGBA(var(--bs-success-rgb), var(--bs-link-opacity, 1)) !important; text-decoration-color: RGBA(var(--bs-success-rgb), var(--bs-link-underline-opacity, 1)) !important; } .link-success:hover, .link-success:focus { color: RGBA(157, 241, 118, var(--bs-link-opacity, 1)) !important; text-decoration-color: RGBA(157, 241, 118, var(--bs-link-underline-opacity, 1)) !important; } .link-info { color: RGBA(var(--bs-info-rgb), var(--bs-link-opacity, 1)) !important; text-decoration-color: RGBA(var(--bs-info-rgb), var(--bs-link-underline-opacity, 1)) !important; } .link-info:hover, .link-info:focus { color: RGBA(41, 57, 204, var(--bs-link-opacity, 1)) !important; text-decoration-color: RGBA(41, 57, 204, var(--bs-link-underline-opacity, 1)) !important; } .link-warning { color: RGBA(var(--bs-warning-rgb), var(--bs-link-opacity, 1)) !important; text-decoration-color: RGBA(var(--bs-warning-rgb), var(--bs-link-underline-opacity, 1)) !important; } .link-warning:hover, .link-warning:focus { color: RGBA(241, 202, 118, var(--bs-link-opacity, 1)) !important; text-decoration-color: RGBA(241, 202, 118, var(--bs-link-underline-opacity, 1)) !important; } .link-danger { color: RGBA(var(--bs-danger-rgb), var(--bs-link-opacity, 1)) !important; text-decoration-color: RGBA(var(--bs-danger-rgb), var(--bs-link-underline-opacity, 1)) !important; } .link-danger:hover, .link-danger:focus { color: RGBA(241, 118, 161, var(--bs-link-opacity, 1)) !important; text-decoration-color: RGBA(241, 118, 161, var(--bs-link-underline-opacity, 1)) !important; } .link-light { color: RGBA(var(--bs-light-rgb), var(--bs-link-opacity, 1)) !important; text-decoration-color: RGBA(var(--bs-light-rgb), var(--bs-link-underline-opacity, 1)) !important; } .link-light:hover, .link-light:focus { color: RGBA(249, 250, 251, var(--bs-link-opacity, 1)) !important; text-decoration-color: RGBA(249, 250, 251, var(--bs-link-underline-opacity, 1)) !important; } .link-dark { color: RGBA(var(--bs-dark-rgb), var(--bs-link-opacity, 1)) !important; text-decoration-color: RGBA(var(--bs-dark-rgb), var(--bs-link-underline-opacity, 1)) !important; } .link-dark:hover, .link-dark:focus { color: RGBA(26, 30, 33, var(--bs-link-opacity, 1)) !important; text-decoration-color: RGBA(26, 30, 33, var(--bs-link-underline-opacity, 1)) !important; } .link-body-emphasis { color: RGBA(var(--bs-emphasis-color-rgb), var(--bs-link-opacity, 1)) !important; text-decoration-color: RGBA(var(--bs-emphasis-color-rgb), var(--bs-link-underline-opacity, 1)) !important; } .link-body-emphasis:hover, .link-body-emphasis:focus { color: RGBA(var(--bs-emphasis-color-rgb), var(--bs-link-opacity, 0.75)) !important; text-decoration-color: RGBA(var(--bs-emphasis-color-rgb), var(--bs-link-underline-opacity, 0.75)) !important; } .focus-ring:focus { outline: 0; box-shadow: var(--bs-focus-ring-x, 0) var(--bs-focus-ring-y, 0) var(--bs-focus-ring-blur, 0) var(--bs-focus-ring-width) var(--bs-focus-ring-color); } .icon-link { display: inline-flex; gap: 0.375rem; align-items: center; text-decoration-color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 0.5)); text-underline-offset: 0.25em; backface-visibility: hidden; } .icon-link > .bi { flex-shrink: 0; width: 1em; height: 1em; fill: currentcolor; transition: 0.2s ease-in-out transform; } @media (prefers-reduced-motion: reduce) { .icon-link > .bi { transition: none; } } .icon-link-hover:hover > .bi, .icon-link-hover:focus-visible > .bi { transform: var(--bs-icon-link-transform, translate3d(0.25em, 0, 0)); } .ratio { position: relative; width: 100%; } .ratio::before { display: block; padding-top: var(--bs-aspect-ratio); content: ""; } .ratio > * { position: absolute; top: 0; left: 0; width: 100%; height: 100%; } .ratio-1x1 { --bs-aspect-ratio: 100%; } .ratio-4x3 { --bs-aspect-ratio: calc(3 / 4 * 100%); } .ratio-16x9 { --bs-aspect-ratio: calc(9 / 16 * 100%); } .ratio-21x9 { --bs-aspect-ratio: calc(9 / 21 * 100%); } .fixed-top { position: fixed; top: 0; right: 0; left: 0; z-index: 1030; } .fixed-bottom { position: fixed; right: 0; bottom: 0; left: 0; z-index: 1030; } .sticky-top { position: sticky; top: 0; z-index: 1020; } .sticky-bottom { position: sticky; bottom: 0; z-index: 1020; } @media (min-width: 576px) { .sticky-sm-top { position: sticky; top: 0; z-index: 1020; } .sticky-sm-bottom { position: sticky; bottom: 0; z-index: 1020; } } @media (min-width: 768px) { .sticky-md-top { position: sticky; top: 0; z-index: 1020; } .sticky-md-bottom { position: sticky; bottom: 0; z-index: 1020; } } @media (min-width: 992px) { .sticky-lg-top { position: sticky; top: 0; z-index: 1020; } .sticky-lg-bottom { position: sticky; bottom: 0; z-index: 1020; } } @media (min-width: 1200px) { .sticky-xl-top { position: sticky; top: 0; z-index: 1020; } .sticky-xl-bottom { position: sticky; bottom: 0; z-index: 1020; } } @media (min-width: 1400px) { .sticky-xxl-top { position: sticky; top: 0; z-index: 1020; } .sticky-xxl-bottom { position: sticky; bottom: 0; z-index: 1020; } } .hstack { display: flex; flex-direction: row; align-items: center; align-self: stretch; } .vstack { display: flex; flex: 1 1 auto; flex-direction: column; align-self: stretch; } .visually-hidden, .visually-hidden-focusable:not(:focus):not(:focus-within) { width: 1px !important; height: 1px !important; padding: 0 !important; margin: -1px !important; overflow: hidden !important; clip: rect(0, 0, 0, 0) !important; white-space: nowrap !important; border: 0 !important; } .visually-hidden:not(caption), .visually-hidden-focusable:not(:focus):not(:focus-within):not(caption) { position: absolute !important; } .stretched-link::after { position: absolute; top: 0; right: 0; bottom: 0; left: 0; z-index: 1; content: ""; } .text-truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .vr { display: inline-block; align-self: stretch; width: var(--bs-border-width); min-height: 1em; background-color: currentcolor; opacity: 0.25; } .table, table { --bs-table-color-type: initial; --bs-table-bg-type: initial; --bs-table-color-state: initial; --bs-table-bg-state: initial; --bs-table-color: var(--bs-emphasis-color); --bs-table-bg: var(--bs-body-bg); --bs-table-border-color: var(--bs-border-color); --bs-table-accent-bg: transparent; --bs-table-striped-color: var(--bs-emphasis-color); --bs-table-striped-bg: rgba(var(--bs-emphasis-color-rgb), 0.05); --bs-table-active-color: var(--bs-emphasis-color); --bs-table-active-bg: rgba(var(--bs-emphasis-color-rgb), 0.1); --bs-table-hover-color: var(--bs-emphasis-color); --bs-table-hover-bg: rgba(var(--bs-emphasis-color-rgb), 0.075); width: 100%; margin-bottom: 1rem; vertical-align: top; border-color: var(--bs-table-border-color); } .table > :not(caption) > * > *, table > :not(caption) > * > * { padding: 0.5rem 0.5rem; color: var(--bs-table-color-state, var(--bs-table-color-type, var(--bs-table-color))); background-color: var(--bs-table-bg); border-bottom-width: var(--bs-border-width); box-shadow: inset 0 0 0 9999px var(--bs-table-bg-state, var(--bs-table-bg-type, var(--bs-table-accent-bg))); } .table > tbody, table > tbody { vertical-align: inherit; } .table > thead, table > thead { vertical-align: bottom; } .table-group-divider { border-top: calc(var(--bs-border-width) * 2) solid currentcolor; } .caption-top { caption-side: top; } .table-sm > :not(caption) > * > * { padding: 0.25rem 0.25rem; } .table-bordered > :not(caption) > * { border-width: var(--bs-border-width) 0; } .table-bordered > :not(caption) > * > * { border-width: 0 var(--bs-border-width); } .table-borderless > :not(caption) > * > * { border-bottom-width: 0; } .table-borderless > :not(:first-child) { border-top-width: 0; } .table-striped > tbody > tr:nth-of-type(odd) > * { --bs-table-color-type: var(--bs-table-striped-color); --bs-table-bg-type: var(--bs-table-striped-bg); } .table-striped-columns > :not(caption) > tr > :nth-child(even) { --bs-table-color-type: var(--bs-table-striped-color); --bs-table-bg-type: var(--bs-table-striped-bg); } .table-active { --bs-table-color-state: var(--bs-table-active-color); --bs-table-bg-state: var(--bs-table-active-bg); } .table-hover > tbody > tr:hover > * { --bs-table-color-state: var(--bs-table-hover-color); --bs-table-bg-state: var(--bs-table-hover-bg); } .table-primary { --bs-table-color: #000; --bs-table-bg: #dcdafa; --bs-table-border-color: #b0aec8; --bs-table-striped-bg: #d1cfee; --bs-table-striped-color: #000; --bs-table-active-bg: #c6c4e1; --bs-table-active-color: #000; --bs-table-hover-bg: #cccae7; --bs-table-hover-color: #000; color: var(--bs-table-color); border-color: var(--bs-table-border-color); } .table-secondary { --bs-table-color: #000; --bs-table-bg: #e2e3e5; --bs-table-border-color: #b5b6b7; --bs-table-striped-bg: #d7d8da; --bs-table-striped-color: #000; --bs-table-active-bg: #cbccce; --bs-table-active-color: #000; --bs-table-hover-bg: #d1d2d4; --bs-table-hover-color: #000; color: var(--bs-table-color); border-color: var(--bs-table-border-color); } .table-success { --bs-table-color: #000; --bs-table-bg: #e6fcdd; --bs-table-border-color: #b8cab1; --bs-table-striped-bg: #dbefd2; --bs-table-striped-color: #000; --bs-table-active-bg: #cfe3c7; --bs-table-active-color: #000; --bs-table-hover-bg: #d5e9cc; --bs-table-hover-color: #000; color: var(--bs-table-color); border-color: var(--bs-table-border-color); } .table-info { --bs-table-color: #000; --bs-table-bg: #d6daff; --bs-table-border-color: #abaecc; --bs-table-striped-bg: #cbcff2; --bs-table-striped-color: #000; --bs-table-active-bg: #c1c4e6; --bs-table-active-color: #000; --bs-table-hover-bg: #c6caec; --bs-table-hover-color: #000; color: var(--bs-table-color); border-color: var(--bs-table-border-color); } .table-warning { --bs-table-color: #000; --bs-table-bg: #fcf2dd; --bs-table-border-color: #cac2b1; --bs-table-striped-bg: #efe6d2; --bs-table-striped-color: #000; --bs-table-active-bg: #e3dac7; --bs-table-active-color: #000; --bs-table-hover-bg: #e9e0cc; --bs-table-hover-color: #000; color: var(--bs-table-color); border-color: var(--bs-table-border-color); } .table-danger { --bs-table-color: #000; --bs-table-bg: #fcdde7; --bs-table-border-color: #cab1b9; --bs-table-striped-bg: #efd2db; --bs-table-striped-color: #000; --bs-table-active-bg: #e3c7d0; --bs-table-active-color: #000; --bs-table-hover-bg: #e9ccd6; --bs-table-hover-color: #000; color: var(--bs-table-color); border-color: var(--bs-table-border-color); } .table-light { --bs-table-color: #000; --bs-table-bg: #f8f9fa; --bs-table-border-color: #c6c7c8; --bs-table-striped-bg: #ecedee; --bs-table-striped-color: #000; --bs-table-active-bg: #dfe0e1; --bs-table-active-color: #000; --bs-table-hover-bg: #e5e6e7; --bs-table-hover-color: #000; color: var(--bs-table-color); border-color: var(--bs-table-border-color); } .table-dark, [data-bs-theme="dark"] table { --bs-table-color: #fff; --bs-table-bg: #212529; --bs-table-border-color: #4d5154; --bs-table-striped-bg: #2c3034; --bs-table-striped-color: #fff; --bs-table-active-bg: #373b3e; --bs-table-active-color: #fff; --bs-table-hover-bg: #323539; --bs-table-hover-color: #fff; color: var(--bs-table-color); border-color: var(--bs-table-border-color); } .table-responsive { overflow-x: auto; -webkit-overflow-scrolling: touch; } @media (max-width: 575.98px) { .table-responsive-sm { overflow-x: auto; -webkit-overflow-scrolling: touch; } } @media (max-width: 767.98px) { .table-responsive-md { overflow-x: auto; -webkit-overflow-scrolling: touch; } } @media (max-width: 991.98px) { .table-responsive-lg { overflow-x: auto; -webkit-overflow-scrolling: touch; } } @media (max-width: 1199.98px) { .table-responsive-xl { overflow-x: auto; -webkit-overflow-scrolling: touch; } } @media (max-width: 1399.98px) { .table-responsive-xxl { overflow-x: auto; -webkit-overflow-scrolling: touch; } } .form-label { margin-bottom: 0.5rem; } .col-form-label { padding-top: calc(0.375rem + var(--bs-border-width)); padding-bottom: calc(0.375rem + var(--bs-border-width)); margin-bottom: 0; font-size: inherit; line-height: 1.5; } .col-form-label-lg { padding-top: calc(0.5rem + var(--bs-border-width)); padding-bottom: calc(0.5rem + var(--bs-border-width)); font-size: 1.25rem; } .col-form-label-sm { padding-top: calc(0.25rem + var(--bs-border-width)); padding-bottom: calc(0.25rem + var(--bs-border-width)); font-size: 0.875rem; } .form-text { margin-top: 0.25rem; font-size: 0.875em; color: var(--bs-secondary-color); } .form-control, .search-form .search-field, .comment-form input[type="text"], .comment-form input[type="email"], .comment-form input[type="url"], .comment-form textarea { display: block; width: 100%; padding: 0.375rem 0.75rem; font-size: 1rem; font-weight: 400; line-height: 1.5; color: var(--bs-body-color); appearance: none; background-color: var(--bs-body-bg); background-clip: padding-box; border: var(--bs-border-width) solid var(--bs-border-color); border-radius: var(--bs-border-radius); transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; } @media (prefers-reduced-motion: reduce) { .form-control, .search-form .search-field, .comment-form input[type="text"], .comment-form input[type="email"], .comment-form input[type="url"], .comment-form textarea { transition: none; } } .form-control[type="file"], .search-form [type="file"].search-field, .comment-form input[type="file"][type="text"], .comment-form input[type="file"][type="email"], .comment-form input[type="file"][type="url"], .comment-form textarea[type="file"] { overflow: hidden; } .form-control[type="file"]:not(:disabled):not([readonly]), .search-form [type="file"].search-field:not(:disabled):not([readonly]), .comment-form input[type="file"][type="text"]:not(:disabled):not([readonly]), .comment-form input[type="file"][type="email"]:not(:disabled):not([readonly]), .comment-form input[type="file"][type="url"]:not(:disabled):not([readonly]), .comment-form textarea[type="file"]:not(:disabled):not([readonly]) { cursor: pointer; } .form-control:focus, .search-form .search-field:focus, .comment-form input[type="text"]:focus, .comment-form input[type="email"]:focus, .comment-form input[type="url"]:focus, .comment-form textarea:focus { color: var(--bs-body-color); background-color: var(--bs-body-bg); border-color: #a7a3f2; outline: 0; box-shadow: none; } .form-control::-webkit-date-and-time-value, .search-form .search-field::-webkit-date-and-time-value, .comment-form input[type="text"]::-webkit-date-and-time-value, .comment-form input[type="email"]::-webkit-date-and-time-value, .comment-form input[type="url"]::-webkit-date-and-time-value, .comment-form textarea::-webkit-date-and-time-value { min-width: 85px; height: 1.5em; margin: 0; } .form-control::-webkit-datetime-edit, .search-form .search-field::-webkit-datetime-edit, .comment-form input[type="text"]::-webkit-datetime-edit, .comment-form input[type="email"]::-webkit-datetime-edit, .comment-form input[type="url"]::-webkit-datetime-edit, .comment-form textarea::-webkit-datetime-edit { display: block; padding: 0; } .form-control::placeholder, .search-form .search-field::placeholder, .comment-form input[type="text"]::placeholder, .comment-form input[type="email"]::placeholder, .comment-form input[type="url"]::placeholder, .comment-form textarea::placeholder { color: var(--bs-secondary-color); opacity: 1; } .form-control:disabled, .search-form .search-field:disabled, .comment-form input[type="text"]:disabled, .comment-form input[type="email"]:disabled, .comment-form input[type="url"]:disabled, .comment-form textarea:disabled { background-color: var(--bs-secondary-bg); opacity: 1; } .form-control::file-selector-button, .search-form .search-field::file-selector-button, .comment-form input[type="text"]::file-selector-button, .comment-form input[type="email"]::file-selector-button, .comment-form input[type="url"]::file-selector-button, .comment-form textarea::file-selector-button { padding: 0.375rem 0.75rem; margin: -0.375rem -0.75rem; margin-inline-end: 0.75rem; color: var(--bs-body-color); background-color: var(--bs-tertiary-bg); pointer-events: none; border-color: inherit; border-style: solid; border-width: 0; border-inline-end-width: var(--bs-border-width); border-radius: 0; transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; } @media (prefers-reduced-motion: reduce) { .form-control::file-selector-button, .search-form .search-field::file-selector-button, .comment-form input[type="text"]::file-selector-button, .comment-form input[type="email"]::file-selector-button, .comment-form input[type="url"]::file-selector-button, .comment-form textarea::file-selector-button { transition: none; } } .form-control:hover:not(:disabled):not([readonly])::file-selector-button, .search-form .search-field:hover:not(:disabled):not([readonly])::file-selector-button, .comment-form input[type="text"]:hover:not(:disabled):not([readonly])::file-selector-button, .comment-form input[type="email"]:hover:not(:disabled):not([readonly])::file-selector-button, .comment-form input[type="url"]:hover:not(:disabled):not([readonly])::file-selector-button, .comment-form textarea:hover:not(:disabled):not([readonly])::file-selector-button { background-color: var(--bs-secondary-bg); } .form-control-plaintext { display: block; width: 100%; padding: 0.375rem 0; margin-bottom: 0; line-height: 1.5; color: var(--bs-body-color); background-color: transparent; border: solid transparent; border-width: var(--bs-border-width) 0; } .form-control-plaintext:focus { outline: 0; } .form-control-plaintext.form-control-sm, .form-control-plaintext.form-control-lg { padding-right: 0; padding-left: 0; } .form-control-sm { min-height: calc(1.5em + 0.5rem + calc(var(--bs-border-width) * 2)); padding: 0.25rem 0.5rem; font-size: 0.875rem; border-radius: var(--bs-border-radius-sm); } .form-control-sm::file-selector-button { padding: 0.25rem 0.5rem; margin: -0.25rem -0.5rem; margin-inline-end: 0.5rem; } .form-control-lg { min-height: calc(1.5em + 1rem + calc(var(--bs-border-width) * 2)); padding: 0.5rem 1rem; font-size: 1.25rem; border-radius: var(--bs-border-radius-lg); } .form-control-lg::file-selector-button { padding: 0.5rem 1rem; margin: -0.5rem -1rem; margin-inline-end: 1rem; } textarea.form-control, .search-form textarea.search-field { min-height: calc(1.5em + 0.75rem + calc(var(--bs-border-width) * 2)); } textarea.form-control-sm { min-height: calc(1.5em + 0.5rem + calc(var(--bs-border-width) * 2)); } textarea.form-control-lg { min-height: calc(1.5em + 1rem + calc(var(--bs-border-width) * 2)); } .form-control-color { width: 3rem; height: calc(1.5em + 0.75rem + calc(var(--bs-border-width) * 2)); padding: 0.375rem; } .form-control-color:not(:disabled):not([readonly]) { cursor: pointer; } .form-control-color::-moz-color-swatch { border: 0 !important; border-radius: var(--bs-border-radius); } .form-control-color::-webkit-color-swatch { border: 0 !important; border-radius: var(--bs-border-radius); } .form-control-color.form-control-sm { height: calc(1.5em + 0.5rem + calc(var(--bs-border-width) * 2)); } .form-control-color.form-control-lg { height: calc(1.5em + 1rem + calc(var(--bs-border-width) * 2)); } .form-select { --bs-form-select-bg-img: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e"); display: block; width: 100%; padding: 0.375rem 2.25rem 0.375rem 0.75rem; font-size: 1rem; font-weight: 400; line-height: 1.5; color: var(--bs-body-color); appearance: none; background-color: var(--bs-body-bg); background-image: var(--bs-form-select-bg-img), var(--bs-form-select-bg-icon, none); background-repeat: no-repeat; background-position: right 0.75rem center; background-size: 16px 12px; border: var(--bs-border-width) solid var(--bs-border-color); border-radius: var(--bs-border-radius); transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; } @media (prefers-reduced-motion: reduce) { .form-select { transition: none; } } .form-select:focus { border-color: #a7a3f2; outline: 0; box-shadow: 0 0 0 0 rgba(79, 70, 229, 0.25); } .form-select[multiple], .form-select[size]:not([size="1"]) { padding-right: 0.75rem; background-image: none; } .form-select:disabled { background-color: var(--bs-secondary-bg); } .form-select:-moz-focusring { color: transparent; text-shadow: 0 0 0 var(--bs-body-color); } .form-select-sm { padding-top: 0.25rem; padding-bottom: 0.25rem; padding-left: 0.5rem; font-size: 0.875rem; border-radius: var(--bs-border-radius-sm); } .form-select-lg { padding-top: 0.5rem; padding-bottom: 0.5rem; padding-left: 1rem; font-size: 1.25rem; border-radius: var(--bs-border-radius-lg); } [data-bs-theme="dark"] .form-select { --bs-form-select-bg-img: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23c1c3c8' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e"); } .form-check { display: block; min-height: 1.5rem; padding-left: 1.5em; margin-bottom: 0.125rem; } .form-check .form-check-input, .form-check li input[type="checkbox"], li .form-check input[type="checkbox"] { float: left; margin-left: -1.5em; } .form-check-reverse { padding-right: 1.5em; padding-left: 0; text-align: right; } .form-check-reverse .form-check-input, .form-check-reverse li input[type="checkbox"], li .form-check-reverse input[type="checkbox"] { float: right; margin-right: -1.5em; margin-left: 0; } .form-check-input, li input[type="checkbox"] { --bs-form-check-bg: var(--bs-body-bg); flex-shrink: 0; width: 1em; height: 1em; margin-top: 0.25em; vertical-align: top; appearance: none; background-color: var(--bs-form-check-bg); background-image: var(--bs-form-check-bg-image); background-repeat: no-repeat; background-position: center; background-size: contain; border: var(--bs-border-width) solid var(--bs-border-color); print-color-adjust: exact; } .form-check-input[type="checkbox"], li input[type="checkbox"] { border-radius: 0.25em; } .form-check-input[type="radio"], li input[type="radio"][type="checkbox"] { border-radius: 50%; } .form-check-input:active, li input[type="checkbox"]:active { filter: brightness(90%); } .form-check-input:focus, li input[type="checkbox"]:focus { border-color: #a7a3f2; outline: 0; box-shadow: 0 0 0 0.25rem rgba(79, 70, 229, 0.25); } .form-check-input:checked, li input[type="checkbox"]:checked { background-color: #4f46e5; border-color: #4f46e5; } .form-check-input:checked[type="checkbox"], li input:checked[type="checkbox"] { --bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/%3e%3c/svg%3e"); } .form-check-input:checked[type="radio"], li input[type="checkbox"]:checked[type="radio"] { --bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e"); } .form-check-input[type="checkbox"]:indeterminate, li input[type="checkbox"]:indeterminate { background-color: #4f46e5; border-color: #4f46e5; --bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e"); } .form-check-input:disabled, li input[type="checkbox"]:disabled { pointer-events: none; filter: none; opacity: 0.5; } .form-check-input[disabled] ~ .form-check-label, li input[disabled][type="checkbox"] ~ .form-check-label, .form-check-input:disabled ~ .form-check-label, li input[type="checkbox"]:disabled ~ .form-check-label { cursor: default; opacity: 0.5; } .form-switch { padding-left: 2.5em; } .form-switch .form-check-input, .form-switch li input[type="checkbox"], li .form-switch input[type="checkbox"] { --bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280, 0, 0, 0.25%29'/%3e%3c/svg%3e"); width: 2em; margin-left: -2.5em; background-image: var(--bs-form-switch-bg); background-position: left center; border-radius: 2em; transition: background-position 0.15s ease-in-out; } @media (prefers-reduced-motion: reduce) { .form-switch .form-check-input, .form-switch li input[type="checkbox"], li .form-switch input[type="checkbox"] { transition: none; } } .form-switch .form-check-input:focus, .form-switch li input[type="checkbox"]:focus, li .form-switch input[type="checkbox"]:focus { --bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23a7a3f2'/%3e%3c/svg%3e"); } .form-switch .form-check-input:checked, .form-switch li input[type="checkbox"]:checked, li .form-switch input[type="checkbox"]:checked { background-position: right center; --bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e"); } .form-switch.form-check-reverse { padding-right: 2.5em; padding-left: 0; } .form-switch.form-check-reverse .form-check-input, .form-switch.form-check-reverse li input[type="checkbox"], li .form-switch.form-check-reverse input[type="checkbox"] { margin-right: -2.5em; margin-left: 0; } .form-check-inline { display: inline-block; margin-right: 1rem; } .btn-check { position: absolute; clip: rect(0, 0, 0, 0); pointer-events: none; } .btn-check[disabled] + .btn, .search-form .btn-check[disabled] + .search-submit, .comment-form .btn-check[disabled] + input[type="submit"], .btn-check:disabled + .btn, .search-form .btn-check:disabled + .search-submit, .comment-form .btn-check:disabled + input[type="submit"] { pointer-events: none; filter: none; opacity: 0.65; } [data-bs-theme="dark"] .form-switch .form-check-input:not(:checked):not(:focus), [data-bs-theme="dark"] .form-switch li input[type="checkbox"]:not(:checked):not(:focus), li [data-bs-theme="dark"] .form-switch input[type="checkbox"]:not(:checked):not(:focus) { --bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%28255, 255, 255, 0.25%29'/%3e%3c/svg%3e"); } .form-range { width: 100%; height: 1rem; padding: 0; appearance: none; background-color: transparent; } .form-range:focus { outline: 0; } .form-range:focus::-webkit-slider-thumb { box-shadow: 0 0 0 1px #fff, none; } .form-range:focus::-moz-range-thumb { box-shadow: 0 0 0 1px #fff, none; } .form-range::-moz-focus-outer { border: 0; } .form-range::-webkit-slider-thumb { width: 1rem; height: 1rem; margin-top: -0.25rem; appearance: none; background-color: #4f46e5; border: 0; border-radius: 1rem; transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; } @media (prefers-reduced-motion: reduce) { .form-range::-webkit-slider-thumb { transition: none; } } .form-range::-webkit-slider-thumb:active { background-color: #cac8f7; } .form-range::-webkit-slider-runnable-track { width: 100%; height: 0.5rem; color: transparent; cursor: pointer; background-color: var(--bs-secondary-bg); border-color: transparent; border-radius: 1rem; } .form-range::-moz-range-thumb { width: 1rem; height: 1rem; appearance: none; background-color: #4f46e5; border: 0; border-radius: 1rem; transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; } @media (prefers-reduced-motion: reduce) { .form-range::-moz-range-thumb { transition: none; } } .form-range::-moz-range-thumb:active { background-color: #cac8f7; } .form-range::-moz-range-track { width: 100%; height: 0.5rem; color: transparent; cursor: pointer; background-color: var(--bs-secondary-bg); border-color: transparent; border-radius: 1rem; } .form-range:disabled { pointer-events: none; } .form-range:disabled::-webkit-slider-thumb { background-color: var(--bs-secondary-color); } .form-range:disabled::-moz-range-thumb { background-color: var(--bs-secondary-color); } .form-floating { position: relative; } .form-floating > .form-control, .search-form .form-floating > .search-field, .comment-form .form-floating > input[type="text"], .comment-form .form-floating > input[type="email"], .comment-form .form-floating > input[type="url"], .comment-form .form-floating > textarea, .form-floating > .form-control-plaintext, .form-floating > .form-select { height: calc(3.5rem + calc(var(--bs-border-width) * 2)); min-height: calc(3.5rem + calc(var(--bs-border-width) * 2)); line-height: 1.25; } .form-floating > label { position: absolute; top: 0; left: 0; z-index: 2; height: 100%; padding: 1rem 0.75rem; overflow: hidden; text-align: start; text-overflow: ellipsis; white-space: nowrap; pointer-events: none; border: var(--bs-border-width) solid transparent; transform-origin: 0 0; transition: opacity 0.1s ease-in-out, transform 0.1s ease-in-out; } @media (prefers-reduced-motion: reduce) { .form-floating > label { transition: none; } } .form-floating > .form-control, .search-form .form-floating > .search-field, .comment-form .form-floating > input[type="text"], .comment-form .form-floating > input[type="email"], .comment-form .form-floating > input[type="url"], .comment-form .form-floating > textarea, .form-floating > .form-control-plaintext { padding: 1rem 0.75rem; } .form-floating > .form-control::placeholder, .search-form .form-floating > .search-field::placeholder, .comment-form .form-floating > input[type="text"]::placeholder, .comment-form .form-floating > input[type="email"]::placeholder, .comment-form .form-floating > input[type="url"]::placeholder, .comment-form .form-floating > textarea::placeholder, .form-floating > .form-control-plaintext::placeholder { color: transparent; } .form-floating > .form-control:focus, .search-form .form-floating > .search-field:focus, .comment-form .form-floating > input[type="text"]:focus, .comment-form .form-floating > input[type="email"]:focus, .comment-form .form-floating > input[type="url"]:focus, .comment-form .form-floating > textarea:focus, .form-floating > .form-control:not(:placeholder-shown), .search-form .form-floating > .search-field:not(:placeholder-shown), .comment-form .form-floating > input[type="text"]:not(:placeholder-shown), .comment-form .form-floating > input[type="email"]:not(:placeholder-shown), .comment-form .form-floating > input[type="url"]:not(:placeholder-shown), .comment-form .form-floating > textarea:not(:placeholder-shown), .form-floating > .form-control-plaintext:focus, .form-floating > .form-control-plaintext:not(:placeholder-shown) { padding-top: 1.625rem; padding-bottom: 0.625rem; } .form-floating > .form-control:-webkit-autofill, .search-form .form-floating > .search-field:-webkit-autofill, .comment-form .form-floating > input[type="text"]:-webkit-autofill, .comment-form .form-floating > input[type="email"]:-webkit-autofill, .comment-form .form-floating > input[type="url"]:-webkit-autofill, .comment-form .form-floating > textarea:-webkit-autofill, .form-floating > .form-control-plaintext:-webkit-autofill { padding-top: 1.625rem; padding-bottom: 0.625rem; } .form-floating > .form-select { padding-top: 1.625rem; padding-bottom: 0.625rem; } .form-floating > .form-control:focus ~ label, .search-form .form-floating > .search-field:focus ~ label, .comment-form .form-floating > input[type="text"]:focus ~ label, .comment-form .form-floating > input[type="email"]:focus ~ label, .comment-form .form-floating > input[type="url"]:focus ~ label, .comment-form .form-floating > textarea:focus ~ label, .form-floating > .form-control:not(:placeholder-shown) ~ label, .search-form .form-floating > .search-field:not(:placeholder-shown) ~ label, .comment-form .form-floating > input[type="text"]:not(:placeholder-shown) ~ label, .comment-form .form-floating > input[type="email"]:not(:placeholder-shown) ~ label, .comment-form .form-floating > input[type="url"]:not(:placeholder-shown) ~ label, .comment-form .form-floating > textarea:not(:placeholder-shown) ~ label, .form-floating > .form-control-plaintext ~ label, .form-floating > .form-select ~ label { color: rgba(var(--bs-body-color-rgb), 0.65); transform: scale(0.85) translateY(-0.5rem) translateX(0.15rem); } .form-floating > .form-control:focus ~ label::after, .search-form .form-floating > .search-field:focus ~ label::after, .comment-form .form-floating > input[type="text"]:focus ~ label::after, .comment-form .form-floating > input[type="email"]:focus ~ label::after, .comment-form .form-floating > input[type="url"]:focus ~ label::after, .comment-form .form-floating > textarea:focus ~ label::after, .form-floating > .form-control:not(:placeholder-shown) ~ label::after, .search-form .form-floating > .search-field:not(:placeholder-shown) ~ label::after, .comment-form .form-floating > input[type="text"]:not(:placeholder-shown) ~ label::after, .comment-form .form-floating > input[type="email"]:not(:placeholder-shown) ~ label::after, .comment-form .form-floating > input[type="url"]:not(:placeholder-shown) ~ label::after, .comment-form .form-floating > textarea:not(:placeholder-shown) ~ label::after, .form-floating > .form-control-plaintext ~ label::after, .form-floating > .form-select ~ label::after { position: absolute; inset: 1rem 0.375rem; z-index: -1; height: 1.5em; content: ""; background-color: var(--bs-body-bg); border-radius: var(--bs-border-radius); } .form-floating > .form-control:-webkit-autofill ~ label, .search-form .form-floating > .search-field:-webkit-autofill ~ label, .comment-form .form-floating > input[type="text"]:-webkit-autofill ~ label, .comment-form .form-floating > input[type="email"]:-webkit-autofill ~ label, .comment-form .form-floating > input[type="url"]:-webkit-autofill ~ label, .comment-form .form-floating > textarea:-webkit-autofill ~ label { color: rgba(var(--bs-body-color-rgb), 0.65); transform: scale(0.85) translateY(-0.5rem) translateX(0.15rem); } .form-floating > .form-control-plaintext ~ label { border-width: var(--bs-border-width) 0; } .form-floating > :disabled ~ label, .form-floating > .form-control:disabled ~ label { color: #6c757d; } .form-floating > :disabled ~ label::after, .form-floating > .form-control:disabled ~ label::after { background-color: var(--bs-secondary-bg); } .input-group { position: relative; display: flex; flex-wrap: wrap; align-items: stretch; width: 100%; } .input-group > .form-control, .search-form .input-group > .search-field, .comment-form .input-group > input[type="text"], .comment-form .input-group > input[type="email"], .comment-form .input-group > input[type="url"], .comment-form .input-group > textarea, .input-group > .form-select, .input-group > .form-floating { position: relative; flex: 1 1 auto; width: 1%; min-width: 0; } .input-group > .form-control:focus, .search-form .input-group > .search-field:focus, .comment-form .input-group > input[type="text"]:focus, .comment-form .input-group > input[type="email"]:focus, .comment-form .input-group > input[type="url"]:focus, .comment-form .input-group > textarea:focus, .input-group > .form-select:focus, .input-group > .form-floating:focus-within { z-index: 5; } .input-group .btn, .input-group .search-form .search-submit, .search-form .input-group .search-submit, .input-group .comment-form input[type="submit"], .comment-form .input-group input[type="submit"] { position: relative; z-index: 2; } .input-group .btn:focus, .input-group .search-form .search-submit:focus, .search-form .input-group .search-submit:focus, .input-group .comment-form input[type="submit"]:focus, .comment-form .input-group input[type="submit"]:focus { z-index: 5; } .input-group-text { display: flex; align-items: center; padding: 0.375rem 0.75rem; font-size: 1rem; font-weight: 400; line-height: 1.5; color: var(--bs-body-color); text-align: center; white-space: nowrap; background-color: var(--bs-tertiary-bg); border: var(--bs-border-width) solid var(--bs-border-color); border-radius: var(--bs-border-radius); } .input-group-lg > .form-control, .search-form .input-group-lg > .search-field, .comment-form .input-group-lg > input[type="text"], .comment-form .input-group-lg > input[type="email"], .comment-form .input-group-lg > input[type="url"], .comment-form .input-group-lg > textarea, .input-group-lg > .form-select, .input-group-lg > .input-group-text, .input-group-lg > .btn, .search-form .input-group-lg > .search-submit, .comment-form .input-group-lg > input[type="submit"] { padding: 0.5rem 1rem; font-size: 1.25rem; border-radius: var(--bs-border-radius-lg); } .input-group-sm > .form-control, .search-form .input-group-sm > .search-field, .comment-form .input-group-sm > input[type="text"], .comment-form .input-group-sm > input[type="email"], .comment-form .input-group-sm > input[type="url"], .comment-form .input-group-sm > textarea, .input-group-sm > .form-select, .input-group-sm > .input-group-text, .input-group-sm > .btn, .search-form .input-group-sm > .search-submit, .comment-form .input-group-sm > input[type="submit"] { padding: 0.25rem 0.5rem; font-size: 0.875rem; border-radius: var(--bs-border-radius-sm); } .input-group-lg > .form-select, .input-group-sm > .form-select { padding-right: 3rem; } .input-group:not(.has-validation) > :not(:last-child):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating), .input-group:not(.has-validation) > .dropdown-toggle:nth-last-child(n + 3), .input-group:not(.has-validation) > .form-floating:not(:last-child) > .form-control, .search-form .input-group:not(.has-validation) > .form-floating:not(:last-child) > .search-field, .comment-form .input-group:not(.has-validation) > .form-floating:not(:last-child) > input[type="text"], .comment-form .input-group:not(.has-validation) > .form-floating:not(:last-child) > input[type="email"], .comment-form .input-group:not(.has-validation) > .form-floating:not(:last-child) > input[type="url"], .comment-form .input-group:not(.has-validation) > .form-floating:not(:last-child) > textarea, .input-group:not(.has-validation) > .form-floating:not(:last-child) > .form-select { border-top-right-radius: 0; border-bottom-right-radius: 0; } .input-group.has-validation > :nth-last-child(n + 3):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating), .input-group.has-validation > .dropdown-toggle:nth-last-child(n + 4), .input-group.has-validation > .form-floating:nth-last-child(n + 3) > .form-control, .search-form .input-group.has-validation > .form-floating:nth-last-child(n + 3) > .search-field, .comment-form .input-group.has-validation > .form-floating:nth-last-child(n + 3) > input[type="text"], .comment-form .input-group.has-validation > .form-floating:nth-last-child(n + 3) > input[type="email"], .comment-form .input-group.has-validation > .form-floating:nth-last-child(n + 3) > input[type="url"], .comment-form .input-group.has-validation > .form-floating:nth-last-child(n + 3) > textarea, .input-group.has-validation > .form-floating:nth-last-child(n + 3) > .form-select { border-top-right-radius: 0; border-bottom-right-radius: 0; } .input-group > :not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback) { margin-left: calc(var(--bs-border-width) * -1); border-top-left-radius: 0; border-bottom-left-radius: 0; } .input-group > .form-floating:not(:first-child) > .form-control, .search-form .input-group > .form-floating:not(:first-child) > .search-field, .comment-form .input-group > .form-floating:not(:first-child) > input[type="text"], .comment-form .input-group > .form-floating:not(:first-child) > input[type="email"], .comment-form .input-group > .form-floating:not(:first-child) > input[type="url"], .comment-form .input-group > .form-floating:not(:first-child) > textarea, .input-group > .form-floating:not(:first-child) > .form-select { border-top-left-radius: 0; border-bottom-left-radius: 0; } .valid-feedback { display: none; width: 100%; margin-top: 0.25rem; font-size: 0.875em; color: var(--bs-form-valid-color); } .valid-tooltip { position: absolute; top: 100%; z-index: 5; display: none; max-width: 100%; padding: 0.25rem 0.5rem; margin-top: .1rem; font-size: 0.875rem; color: #fff; background-color: var(--bs-success); border-radius: var(--bs-border-radius); } .was-validated :valid ~ .valid-feedback, .was-validated :valid ~ .valid-tooltip, .is-valid ~ .valid-feedback, .is-valid ~ .valid-tooltip { display: block; } .was-validated .form-control:valid, .was-validated .search-form .search-field:valid, .search-form .was-validated .search-field:valid, .was-validated .comment-form input[type="text"]:valid, .comment-form .was-validated input[type="text"]:valid, .was-validated .comment-form input[type="email"]:valid, .comment-form .was-validated input[type="email"]:valid, .was-validated .comment-form input[type="url"]:valid, .comment-form .was-validated input[type="url"]:valid, .was-validated .comment-form textarea:valid, .comment-form .was-validated textarea:valid, .form-control.is-valid, .search-form .is-valid.search-field, .comment-form input.is-valid[type="text"], .comment-form input.is-valid[type="email"], .comment-form input.is-valid[type="url"], .comment-form textarea.is-valid { border-color: var(--bs-form-valid-border-color); padding-right: calc(1.5em + 0.75rem); background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%2384ee53' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e"); background-repeat: no-repeat; background-position: right calc(0.375em + 0.1875rem) center; background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); } .was-validated .form-control:valid:focus, .was-validated .search-form .search-field:valid:focus, .search-form .was-validated .search-field:valid:focus, .was-validated .comment-form input[type="text"]:valid:focus, .comment-form .was-validated input[type="text"]:valid:focus, .was-validated .comment-form input[type="email"]:valid:focus, .comment-form .was-validated input[type="email"]:valid:focus, .was-validated .comment-form input[type="url"]:valid:focus, .comment-form .was-validated input[type="url"]:valid:focus, .was-validated .comment-form textarea:valid:focus, .comment-form .was-validated textarea:valid:focus, .form-control.is-valid:focus, .search-form .is-valid.search-field:focus, .comment-form input.is-valid[type="text"]:focus, .comment-form input.is-valid[type="email"]:focus, .comment-form input.is-valid[type="url"]:focus, .comment-form textarea.is-valid:focus { border-color: var(--bs-form-valid-border-color); box-shadow: 0 0 0 0 rgba(var(--bs-success-rgb), 0.25); } .was-validated textarea.form-control:valid, .was-validated .search-form textarea.search-field:valid, .search-form .was-validated textarea.search-field:valid, textarea.form-control.is-valid, .search-form textarea.is-valid.search-field { padding-right: calc(1.5em + 0.75rem); background-position: top calc(0.375em + 0.1875rem) right calc(0.375em + 0.1875rem); } .was-validated .form-select:valid, .form-select.is-valid { border-color: var(--bs-form-valid-border-color); } .was-validated .form-select:valid:not([multiple]):not([size]), .was-validated .form-select:valid:not([multiple])[size="1"], .form-select.is-valid:not([multiple]):not([size]), .form-select.is-valid:not([multiple])[size="1"] { --bs-form-select-bg-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%2384ee53' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e"); padding-right: 4.125rem; background-position: right 0.75rem center, center right 2.25rem; background-size: 16px 12px, calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); } .was-validated .form-select:valid:focus, .form-select.is-valid:focus { border-color: var(--bs-form-valid-border-color); box-shadow: 0 0 0 0 rgba(var(--bs-success-rgb), 0.25); } .was-validated .form-control-color:valid, .form-control-color.is-valid { width: calc(3rem + calc(1.5em + 0.75rem)); } .was-validated .form-check-input:valid, .was-validated li input[type="checkbox"]:valid, li .was-validated input[type="checkbox"]:valid, .form-check-input.is-valid, li input.is-valid[type="checkbox"] { border-color: var(--bs-form-valid-border-color); } .was-validated .form-check-input:valid:checked, .was-validated li input[type="checkbox"]:valid:checked, li .was-validated input[type="checkbox"]:valid:checked, .form-check-input.is-valid:checked, li input.is-valid[type="checkbox"]:checked { background-color: var(--bs-form-valid-color); } .was-validated .form-check-input:valid:focus, .was-validated li input[type="checkbox"]:valid:focus, li .was-validated input[type="checkbox"]:valid:focus, .form-check-input.is-valid:focus, li input.is-valid[type="checkbox"]:focus { box-shadow: 0 0 0 0 rgba(var(--bs-success-rgb), 0.25); } .was-validated .form-check-input:valid ~ .form-check-label, .was-validated li input[type="checkbox"]:valid ~ .form-check-label, li .was-validated input[type="checkbox"]:valid ~ .form-check-label, .form-check-input.is-valid ~ .form-check-label, li input.is-valid[type="checkbox"] ~ .form-check-label { color: var(--bs-form-valid-color); } .form-check-inline .form-check-input ~ .valid-feedback, .form-check-inline li input[type="checkbox"] ~ .valid-feedback, li .form-check-inline input[type="checkbox"] ~ .valid-feedback { margin-left: .5em; } .was-validated .input-group > .form-control:not(:focus):valid, .was-validated .search-form .input-group > .search-field:not(:focus):valid, .search-form .was-validated .input-group > .search-field:not(:focus):valid, .was-validated .comment-form .input-group > input[type="text"]:not(:focus):valid, .comment-form .was-validated .input-group > input[type="text"]:not(:focus):valid, .was-validated .comment-form .input-group > input[type="email"]:not(:focus):valid, .comment-form .was-validated .input-group > input[type="email"]:not(:focus):valid, .was-validated .comment-form .input-group > input[type="url"]:not(:focus):valid, .comment-form .was-validated .input-group > input[type="url"]:not(:focus):valid, .was-validated .comment-form .input-group > textarea:not(:focus):valid, .comment-form .was-validated .input-group > textarea:not(:focus):valid, .input-group > .form-control:not(:focus).is-valid, .search-form .input-group > .search-field:not(:focus).is-valid, .comment-form .input-group > input[type="text"]:not(:focus).is-valid, .comment-form .input-group > input[type="email"]:not(:focus).is-valid, .comment-form .input-group > input[type="url"]:not(:focus).is-valid, .comment-form .input-group > textarea:not(:focus).is-valid, .was-validated .input-group > .form-select:not(:focus):valid, .input-group > .form-select:not(:focus).is-valid, .was-validated .input-group > .form-floating:not(:focus-within):valid, .input-group > .form-floating:not(:focus-within).is-valid { z-index: 3; } .invalid-feedback { display: none; width: 100%; margin-top: 0.25rem; font-size: 0.875em; color: var(--bs-form-invalid-color); } .invalid-tooltip { position: absolute; top: 100%; z-index: 5; display: none; max-width: 100%; padding: 0.25rem 0.5rem; margin-top: .1rem; font-size: 0.875rem; color: #fff; background-color: var(--bs-danger); border-radius: var(--bs-border-radius); } .was-validated :invalid ~ .invalid-feedback, .was-validated :invalid ~ .invalid-tooltip, .is-invalid ~ .invalid-feedback, .is-invalid ~ .invalid-tooltip { display: block; } .was-validated .form-control:invalid, .was-validated .search-form .search-field:invalid, .search-form .was-validated .search-field:invalid, .was-validated .comment-form input[type="text"]:invalid, .comment-form .was-validated input[type="text"]:invalid, .was-validated .comment-form input[type="email"]:invalid, .comment-form .was-validated input[type="email"]:invalid, .was-validated .comment-form input[type="url"]:invalid, .comment-form .was-validated input[type="url"]:invalid, .was-validated .comment-form textarea:invalid, .comment-form .was-validated textarea:invalid, .form-control.is-invalid, .search-form .is-invalid.search-field, .comment-form input.is-invalid[type="text"], .comment-form input.is-invalid[type="email"], .comment-form input.is-invalid[type="url"], .comment-form textarea.is-invalid { border-color: var(--bs-form-invalid-border-color); padding-right: calc(1.5em + 0.75rem); background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23ee5389'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23ee5389' stroke='none'/%3e%3c/svg%3e"); background-repeat: no-repeat; background-position: right calc(0.375em + 0.1875rem) center; background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); } .was-validated .form-control:invalid:focus, .was-validated .search-form .search-field:invalid:focus, .search-form .was-validated .search-field:invalid:focus, .was-validated .comment-form input[type="text"]:invalid:focus, .comment-form .was-validated input[type="text"]:invalid:focus, .was-validated .comment-form input[type="email"]:invalid:focus, .comment-form .was-validated input[type="email"]:invalid:focus, .was-validated .comment-form input[type="url"]:invalid:focus, .comment-form .was-validated input[type="url"]:invalid:focus, .was-validated .comment-form textarea:invalid:focus, .comment-form .was-validated textarea:invalid:focus, .form-control.is-invalid:focus, .search-form .is-invalid.search-field:focus, .comment-form input.is-invalid[type="text"]:focus, .comment-form input.is-invalid[type="email"]:focus, .comment-form input.is-invalid[type="url"]:focus, .comment-form textarea.is-invalid:focus { border-color: var(--bs-form-invalid-border-color); box-shadow: 0 0 0 0 rgba(var(--bs-danger-rgb), 0.25); } .was-validated textarea.form-control:invalid, .was-validated .search-form textarea.search-field:invalid, .search-form .was-validated textarea.search-field:invalid, textarea.form-control.is-invalid, .search-form textarea.is-invalid.search-field { padding-right: calc(1.5em + 0.75rem); background-position: top calc(0.375em + 0.1875rem) right calc(0.375em + 0.1875rem); } .was-validated .form-select:invalid, .form-select.is-invalid { border-color: var(--bs-form-invalid-border-color); } .was-validated .form-select:invalid:not([multiple]):not([size]), .was-validated .form-select:invalid:not([multiple])[size="1"], .form-select.is-invalid:not([multiple]):not([size]), .form-select.is-invalid:not([multiple])[size="1"] { --bs-form-select-bg-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23ee5389'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23ee5389' stroke='none'/%3e%3c/svg%3e"); padding-right: 4.125rem; background-position: right 0.75rem center, center right 2.25rem; background-size: 16px 12px, calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); } .was-validated .form-select:invalid:focus, .form-select.is-invalid:focus { border-color: var(--bs-form-invalid-border-color); box-shadow: 0 0 0 0 rgba(var(--bs-danger-rgb), 0.25); } .was-validated .form-control-color:invalid, .form-control-color.is-invalid { width: calc(3rem + calc(1.5em + 0.75rem)); } .was-validated .form-check-input:invalid, .was-validated li input[type="checkbox"]:invalid, li .was-validated input[type="checkbox"]:invalid, .form-check-input.is-invalid, li input.is-invalid[type="checkbox"] { border-color: var(--bs-form-invalid-border-color); } .was-validated .form-check-input:invalid:checked, .was-validated li input[type="checkbox"]:invalid:checked, li .was-validated input[type="checkbox"]:invalid:checked, .form-check-input.is-invalid:checked, li input.is-invalid[type="checkbox"]:checked { background-color: var(--bs-form-invalid-color); } .was-validated .form-check-input:invalid:focus, .was-validated li input[type="checkbox"]:invalid:focus, li .was-validated input[type="checkbox"]:invalid:focus, .form-check-input.is-invalid:focus, li input.is-invalid[type="checkbox"]:focus { box-shadow: 0 0 0 0 rgba(var(--bs-danger-rgb), 0.25); } .was-validated .form-check-input:invalid ~ .form-check-label, .was-validated li input[type="checkbox"]:invalid ~ .form-check-label, li .was-validated input[type="checkbox"]:invalid ~ .form-check-label, .form-check-input.is-invalid ~ .form-check-label, li input.is-invalid[type="checkbox"] ~ .form-check-label { color: var(--bs-form-invalid-color); } .form-check-inline .form-check-input ~ .invalid-feedback, .form-check-inline li input[type="checkbox"] ~ .invalid-feedback, li .form-check-inline input[type="checkbox"] ~ .invalid-feedback { margin-left: .5em; } .was-validated .input-group > .form-control:not(:focus):invalid, .was-validated .search-form .input-group > .search-field:not(:focus):invalid, .search-form .was-validated .input-group > .search-field:not(:focus):invalid, .was-validated .comment-form .input-group > input[type="text"]:not(:focus):invalid, .comment-form .was-validated .input-group > input[type="text"]:not(:focus):invalid, .was-validated .comment-form .input-group > input[type="email"]:not(:focus):invalid, .comment-form .was-validated .input-group > input[type="email"]:not(:focus):invalid, .was-validated .comment-form .input-group > input[type="url"]:not(:focus):invalid, .comment-form .was-validated .input-group > input[type="url"]:not(:focus):invalid, .was-validated .comment-form .input-group > textarea:not(:focus):invalid, .comment-form .was-validated .input-group > textarea:not(:focus):invalid, .input-group > .form-control:not(:focus).is-invalid, .search-form .input-group > .search-field:not(:focus).is-invalid, .comment-form .input-group > input[type="text"]:not(:focus).is-invalid, .comment-form .input-group > input[type="email"]:not(:focus).is-invalid, .comment-form .input-group > input[type="url"]:not(:focus).is-invalid, .comment-form .input-group > textarea:not(:focus).is-invalid, .was-validated .input-group > .form-select:not(:focus):invalid, .input-group > .form-select:not(:focus).is-invalid, .was-validated .input-group > .form-floating:not(:focus-within):invalid, .input-group > .form-floating:not(:focus-within).is-invalid { z-index: 4; } .btn, .search-form .search-submit, .comment-form input[type="submit"] { --bs-btn-padding-x: 0.75rem; --bs-btn-padding-y: 0.375rem; --bs-btn-font-family: ; --bs-btn-font-size: 1rem; --bs-btn-font-weight: 400; --bs-btn-line-height: 1.5; --bs-btn-color: var(--bs-body-color); --bs-btn-bg: transparent; --bs-btn-border-width: var(--bs-border-width); --bs-btn-border-color: transparent; --bs-btn-border-radius: var(--bs-border-radius); --bs-btn-hover-border-color: transparent; --bs-btn-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), 0 1px 1px rgba(0, 0, 0, 0.075); --bs-btn-disabled-opacity: 0.65; --bs-btn-focus-box-shadow: 0 0 0 0 rgba(var(--bs-btn-focus-shadow-rgb), .5); display: inline-block; padding: var(--bs-btn-padding-y) var(--bs-btn-padding-x); font-family: var(--bs-btn-font-family); font-size: var(--bs-btn-font-size); font-weight: var(--bs-btn-font-weight); line-height: var(--bs-btn-line-height); color: var(--bs-btn-color); text-align: center; vertical-align: middle; cursor: pointer; user-select: none; border: var(--bs-btn-border-width) solid var(--bs-btn-border-color); border-radius: var(--bs-btn-border-radius); background-color: var(--bs-btn-bg); transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; } @media (prefers-reduced-motion: reduce) { .btn, .search-form .search-submit, .comment-form input[type="submit"] { transition: none; } } .btn:hover, .search-form .search-submit:hover, .comment-form input[type="submit"]:hover { color: var(--bs-btn-hover-color); text-decoration: none; background-color: var(--bs-btn-hover-bg); border-color: var(--bs-btn-hover-border-color); } .btn-check + .btn:hover, .search-form .btn-check + .search-submit:hover, .comment-form .btn-check + input[type="submit"]:hover { color: var(--bs-btn-color); background-color: var(--bs-btn-bg); border-color: var(--bs-btn-border-color); } .btn:focus-visible, .search-form .search-submit:focus-visible, .comment-form input[type="submit"]:focus-visible { color: var(--bs-btn-hover-color); background-color: var(--bs-btn-hover-bg); border-color: var(--bs-btn-hover-border-color); outline: 0; box-shadow: var(--bs-btn-focus-box-shadow); } .btn-check:focus-visible + .btn, .search-form .btn-check:focus-visible + .search-submit, .comment-form .btn-check:focus-visible + input[type="submit"] { border-color: var(--bs-btn-hover-border-color); outline: 0; box-shadow: var(--bs-btn-focus-box-shadow); } .btn-check:checked + .btn, .search-form .btn-check:checked + .search-submit, .comment-form .btn-check:checked + input[type="submit"], :not(.btn-check) + .btn:active, .search-form :not(.btn-check) + .search-submit:active, .comment-form :not(.btn-check) + input[type="submit"]:active, .btn:first-child:active, .search-form .search-submit:first-child:active, .comment-form input[type="submit"]:first-child:active, .btn.active, .search-form .active.search-submit, .comment-form input.active[type="submit"], .btn.show, .search-form .show.search-submit, .comment-form input.show[type="submit"] { color: var(--bs-btn-active-color); background-color: var(--bs-btn-active-bg); border-color: var(--bs-btn-active-border-color); } .btn-check:checked + .btn:focus-visible, .search-form .btn-check:checked + .search-submit:focus-visible, .comment-form .btn-check:checked + input[type="submit"]:focus-visible, :not(.btn-check) + .btn:active:focus-visible, .search-form :not(.btn-check) + .search-submit:active:focus-visible, .comment-form :not(.btn-check) + input[type="submit"]:active:focus-visible, .btn:first-child:active:focus-visible, .search-form .search-submit:first-child:active:focus-visible, .comment-form input[type="submit"]:first-child:active:focus-visible, .btn.active:focus-visible, .search-form .active.search-submit:focus-visible, .comment-form input.active[type="submit"]:focus-visible, .btn.show:focus-visible, .search-form .show.search-submit:focus-visible, .comment-form input.show[type="submit"]:focus-visible { box-shadow: var(--bs-btn-focus-box-shadow); } .btn-check:checked:focus-visible + .btn, .search-form .btn-check:checked:focus-visible + .search-submit, .comment-form .btn-check:checked:focus-visible + input[type="submit"] { box-shadow: var(--bs-btn-focus-box-shadow); } .btn:disabled, .search-form .search-submit:disabled, .comment-form input[type="submit"]:disabled, .btn.disabled, .search-form .disabled.search-submit, .comment-form input.disabled[type="submit"], fieldset:disabled .btn, fieldset:disabled .search-form .search-submit, .search-form fieldset:disabled .search-submit, fieldset:disabled .comment-form input[type="submit"], .comment-form fieldset:disabled input[type="submit"] { color: var(--bs-btn-disabled-color); pointer-events: none; background-color: var(--bs-btn-disabled-bg); border-color: var(--bs-btn-disabled-border-color); opacity: var(--bs-btn-disabled-opacity); } .btn-primary { --bs-btn-color: #fff; --bs-btn-bg: #4f46e5; --bs-btn-border-color: #4f46e5; --bs-btn-hover-color: #fff; --bs-btn-hover-bg: #433cc3; --bs-btn-hover-border-color: #3f38b7; --bs-btn-focus-shadow-rgb: 105, 98, 233; --bs-btn-active-color: #fff; --bs-btn-active-bg: #3f38b7; --bs-btn-active-border-color: #3b35ac; --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); --bs-btn-disabled-color: #fff; --bs-btn-disabled-bg: #4f46e5; --bs-btn-disabled-border-color: #4f46e5; } .btn-secondary, .search-form .search-submit, .comment-form input[type="submit"] { --bs-btn-color: #fff; --bs-btn-bg: #6c757d; --bs-btn-border-color: #6c757d; --bs-btn-hover-color: #fff; --bs-btn-hover-bg: #5c636a; --bs-btn-hover-border-color: #565e64; --bs-btn-focus-shadow-rgb: 130, 138, 145; --bs-btn-active-color: #fff; --bs-btn-active-bg: #565e64; --bs-btn-active-border-color: #51585e; --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); --bs-btn-disabled-color: #fff; --bs-btn-disabled-bg: #6c757d; --bs-btn-disabled-border-color: #6c757d; } .btn-success { --bs-btn-color: #000; --bs-btn-bg: #84ee53; --bs-btn-border-color: #84ee53; --bs-btn-hover-color: #000; --bs-btn-hover-bg: #97f16d; --bs-btn-hover-border-color: #91f064; --bs-btn-focus-shadow-rgb: 112, 202, 71; --bs-btn-active-color: #000; --bs-btn-active-bg: #9df176; --bs-btn-active-border-color: #91f064; --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); --bs-btn-disabled-color: #000; --bs-btn-disabled-bg: #84ee53; --bs-btn-disabled-border-color: #84ee53; } .btn-info { --bs-btn-color: #fff; --bs-btn-bg: #3347ff; --bs-btn-border-color: #3347ff; --bs-btn-hover-color: #fff; --bs-btn-hover-bg: #2b3dd9; --bs-btn-hover-border-color: #2939cc; --bs-btn-focus-shadow-rgb: 82, 99, 255; --bs-btn-active-color: #fff; --bs-btn-active-bg: #2939cc; --bs-btn-active-border-color: #2636bf; --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); --bs-btn-disabled-color: #fff; --bs-btn-disabled-bg: #3347ff; --bs-btn-disabled-border-color: #3347ff; } .btn-warning { --bs-btn-color: #000; --bs-btn-bg: #eebd53; --bs-btn-border-color: #eebd53; --bs-btn-hover-color: #000; --bs-btn-hover-bg: #f1c76d; --bs-btn-hover-border-color: #f0c464; --bs-btn-focus-shadow-rgb: 202, 161, 71; --bs-btn-active-color: #000; --bs-btn-active-bg: #f1ca76; --bs-btn-active-border-color: #f0c464; --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); --bs-btn-disabled-color: #000; --bs-btn-disabled-bg: #eebd53; --bs-btn-disabled-border-color: #eebd53; } .btn-danger { --bs-btn-color: #000; --bs-btn-bg: #ee5389; --bs-btn-border-color: #ee5389; --bs-btn-hover-color: #000; --bs-btn-hover-bg: #f16d9b; --bs-btn-hover-border-color: #f06495; --bs-btn-focus-shadow-rgb: 202, 71, 117; --bs-btn-active-color: #000; --bs-btn-active-bg: #f176a1; --bs-btn-active-border-color: #f06495; --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); --bs-btn-disabled-color: #000; --bs-btn-disabled-bg: #ee5389; --bs-btn-disabled-border-color: #ee5389; } .btn-light { --bs-btn-color: #000; --bs-btn-bg: #f8f9fa; --bs-btn-border-color: #f8f9fa; --bs-btn-hover-color: #000; --bs-btn-hover-bg: #d3d4d5; --bs-btn-hover-border-color: #c6c7c8; --bs-btn-focus-shadow-rgb: 211, 212, 213; --bs-btn-active-color: #000; --bs-btn-active-bg: #c6c7c8; --bs-btn-active-border-color: #babbbc; --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); --bs-btn-disabled-color: #000; --bs-btn-disabled-bg: #f8f9fa; --bs-btn-disabled-border-color: #f8f9fa; } .btn-dark { --bs-btn-color: #fff; --bs-btn-bg: #212529; --bs-btn-border-color: #212529; --bs-btn-hover-color: #fff; --bs-btn-hover-bg: #424649; --bs-btn-hover-border-color: #373b3e; --bs-btn-focus-shadow-rgb: 66, 70, 73; --bs-btn-active-color: #fff; --bs-btn-active-bg: #4d5154; --bs-btn-active-border-color: #373b3e; --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); --bs-btn-disabled-color: #fff; --bs-btn-disabled-bg: #212529; --bs-btn-disabled-border-color: #212529; } .btn-outline-primary { --bs-btn-color: #4f46e5; --bs-btn-border-color: #4f46e5; --bs-btn-hover-color: #fff; --bs-btn-hover-bg: #4f46e5; --bs-btn-hover-border-color: #4f46e5; --bs-btn-focus-shadow-rgb: 79, 70, 229; --bs-btn-active-color: #fff; --bs-btn-active-bg: #4f46e5; --bs-btn-active-border-color: #4f46e5; --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); --bs-btn-disabled-color: #4f46e5; --bs-btn-disabled-bg: transparent; --bs-btn-disabled-border-color: #4f46e5; --bs-gradient: none; } .btn-outline-secondary { --bs-btn-color: #6c757d; --bs-btn-border-color: #6c757d; --bs-btn-hover-color: #fff; --bs-btn-hover-bg: #6c757d; --bs-btn-hover-border-color: #6c757d; --bs-btn-focus-shadow-rgb: 108, 117, 125; --bs-btn-active-color: #fff; --bs-btn-active-bg: #6c757d; --bs-btn-active-border-color: #6c757d; --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); --bs-btn-disabled-color: #6c757d; --bs-btn-disabled-bg: transparent; --bs-btn-disabled-border-color: #6c757d; --bs-gradient: none; } .btn-outline-success { --bs-btn-color: #84ee53; --bs-btn-border-color: #84ee53; --bs-btn-hover-color: #000; --bs-btn-hover-bg: #84ee53; --bs-btn-hover-border-color: #84ee53; --bs-btn-focus-shadow-rgb: 132.2821, 238.017, 83.283; --bs-btn-active-color: #000; --bs-btn-active-bg: #84ee53; --bs-btn-active-border-color: #84ee53; --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); --bs-btn-disabled-color: #84ee53; --bs-btn-disabled-bg: transparent; --bs-btn-disabled-border-color: #84ee53; --bs-gradient: none; } .btn-outline-info { --bs-btn-color: #3347ff; --bs-btn-border-color: #3347ff; --bs-btn-hover-color: #fff; --bs-btn-hover-bg: #3347ff; --bs-btn-hover-border-color: #3347ff; --bs-btn-focus-shadow-rgb: 51, 71.4, 255; --bs-btn-active-color: #fff; --bs-btn-active-bg: #3347ff; --bs-btn-active-border-color: #3347ff; --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); --bs-btn-disabled-color: #3347ff; --bs-btn-disabled-bg: transparent; --bs-btn-disabled-border-color: #3347ff; --bs-gradient: none; } .btn-outline-warning { --bs-btn-color: #eebd53; --bs-btn-border-color: #eebd53; --bs-btn-hover-color: #000; --bs-btn-hover-bg: #eebd53; --bs-btn-hover-border-color: #eebd53; --bs-btn-focus-shadow-rgb: 238.017, 189.0179, 83.283; --bs-btn-active-color: #000; --bs-btn-active-bg: #eebd53; --bs-btn-active-border-color: #eebd53; --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); --bs-btn-disabled-color: #eebd53; --bs-btn-disabled-bg: transparent; --bs-btn-disabled-border-color: #eebd53; --bs-gradient: none; } .btn-outline-danger { --bs-btn-color: #ee5389; --bs-btn-border-color: #ee5389; --bs-btn-hover-color: #000; --bs-btn-hover-bg: #ee5389; --bs-btn-hover-border-color: #ee5389; --bs-btn-focus-shadow-rgb: 238.017, 83.283, 137.4399; --bs-btn-active-color: #000; --bs-btn-active-bg: #ee5389; --bs-btn-active-border-color: #ee5389; --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); --bs-btn-disabled-color: #ee5389; --bs-btn-disabled-bg: transparent; --bs-btn-disabled-border-color: #ee5389; --bs-gradient: none; } .btn-outline-light { --bs-btn-color: #f8f9fa; --bs-btn-border-color: #f8f9fa; --bs-btn-hover-color: #000; --bs-btn-hover-bg: #f8f9fa; --bs-btn-hover-border-color: #f8f9fa; --bs-btn-focus-shadow-rgb: 248, 249, 250; --bs-btn-active-color: #000; --bs-btn-active-bg: #f8f9fa; --bs-btn-active-border-color: #f8f9fa; --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); --bs-btn-disabled-color: #f8f9fa; --bs-btn-disabled-bg: transparent; --bs-btn-disabled-border-color: #f8f9fa; --bs-gradient: none; } .btn-outline-dark { --bs-btn-color: #212529; --bs-btn-border-color: #212529; --bs-btn-hover-color: #fff; --bs-btn-hover-bg: #212529; --bs-btn-hover-border-color: #212529; --bs-btn-focus-shadow-rgb: 33, 37, 41; --bs-btn-active-color: #fff; --bs-btn-active-bg: #212529; --bs-btn-active-border-color: #212529; --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); --bs-btn-disabled-color: #212529; --bs-btn-disabled-bg: transparent; --bs-btn-disabled-border-color: #212529; --bs-gradient: none; } .btn-link { --bs-btn-font-weight: 400; --bs-btn-color: var(--bs-link-color); --bs-btn-bg: transparent; --bs-btn-border-color: transparent; --bs-btn-hover-color: var(--bs-link-hover-color); --bs-btn-hover-border-color: transparent; --bs-btn-active-color: var(--bs-link-hover-color); --bs-btn-active-border-color: transparent; --bs-btn-disabled-color: #6c757d; --bs-btn-disabled-border-color: transparent; --bs-btn-box-shadow: 0 0 0 #000; --bs-btn-focus-shadow-rgb: 105, 98, 233; text-decoration: none; } .btn-link:hover, .btn-link:focus-visible { text-decoration: underline; } .btn-link:focus-visible { color: var(--bs-btn-color); } .btn-link:hover { color: var(--bs-btn-hover-color); } .btn-lg, .btn-group-lg > .btn, .search-form .btn-group-lg > .search-submit, .comment-form .btn-group-lg > input[type="submit"] { --bs-btn-padding-y: 0.5rem; --bs-btn-padding-x: 1rem; --bs-btn-font-size: 1.25rem; --bs-btn-border-radius: var(--bs-border-radius-lg); } .btn-sm, .btn-group-sm > .btn, .search-form .btn-group-sm > .search-submit, .comment-form .btn-group-sm > input[type="submit"] { --bs-btn-padding-y: 0.25rem; --bs-btn-padding-x: 0.5rem; --bs-btn-font-size: 0.875rem; --bs-btn-border-radius: var(--bs-border-radius-sm); } .fade { transition: opacity 0.15s linear; } @media (prefers-reduced-motion: reduce) { .fade { transition: none; } } .fade:not(.show) { opacity: 0; } .collapse:not(.show) { display: none; } .collapsing { height: 0; overflow: hidden; transition: height 0.35s ease; } @media (prefers-reduced-motion: reduce) { .collapsing { transition: none; } } .collapsing.collapse-horizontal { width: 0; height: auto; transition: width 0.35s ease; } @media (prefers-reduced-motion: reduce) { .collapsing.collapse-horizontal { transition: none; } } .dropup, .dropend, .dropdown, .dropstart, .dropup-center, .dropdown-center { position: relative; } .dropdown-toggle { white-space: nowrap; } .dropdown-toggle::after { display: inline-block; margin-left: 0.255em; vertical-align: 0.255em; content: ""; border-top: 0.3em solid; border-right: 0.3em solid transparent; border-bottom: 0; border-left: 0.3em solid transparent; } .dropdown-toggle:empty::after { margin-left: 0; } .dropdown-menu { --bs-dropdown-zindex: 1000; --bs-dropdown-min-width: 10rem; --bs-dropdown-padding-x: 0; --bs-dropdown-padding-y: 0.5rem; --bs-dropdown-spacer: 0.125rem; --bs-dropdown-font-size: 1rem; --bs-dropdown-color: var(--bs-body-color); --bs-dropdown-bg: var(--bs-body-bg); --bs-dropdown-border-color: var(--bs-border-color-translucent); --bs-dropdown-border-radius: var(--bs-border-radius); --bs-dropdown-border-width: var(--bs-border-width); --bs-dropdown-inner-border-radius: calc(var(--bs-border-radius) - var(--bs-border-width)); --bs-dropdown-divider-bg: var(--bs-border-color-translucent); --bs-dropdown-divider-margin-y: 0.5rem; --bs-dropdown-box-shadow: var(--bs-box-shadow); --bs-dropdown-link-color: var(--bs-body-color); --bs-dropdown-link-hover-color: var(--bs-body-color); --bs-dropdown-link-hover-bg: var(--bs-tertiary-bg); --bs-dropdown-link-active-color: #fff; --bs-dropdown-link-active-bg: #4f46e5; --bs-dropdown-link-disabled-color: var(--bs-tertiary-color); --bs-dropdown-item-padding-x: 1rem; --bs-dropdown-item-padding-y: 0.25rem; --bs-dropdown-header-color: #6c757d; --bs-dropdown-header-padding-x: 1rem; --bs-dropdown-header-padding-y: 0.5rem; position: absolute; z-index: var(--bs-dropdown-zindex); display: none; min-width: var(--bs-dropdown-min-width); padding: var(--bs-dropdown-padding-y) var(--bs-dropdown-padding-x); margin: 0; font-size: var(--bs-dropdown-font-size); color: var(--bs-dropdown-color); text-align: left; list-style: none; background-color: var(--bs-dropdown-bg); background-clip: padding-box; border: var(--bs-dropdown-border-width) solid var(--bs-dropdown-border-color); border-radius: var(--bs-dropdown-border-radius); } .dropdown-menu[data-bs-popper] { top: 100%; left: 0; margin-top: var(--bs-dropdown-spacer); } .dropdown-menu-start { --bs-position: start; } .dropdown-menu-start[data-bs-popper] { right: auto; left: 0; } .dropdown-menu-end { --bs-position: end; } .dropdown-menu-end[data-bs-popper] { right: 0; left: auto; } @media (min-width: 576px) { .dropdown-menu-sm-start { --bs-position: start; } .dropdown-menu-sm-start[data-bs-popper] { right: auto; left: 0; } .dropdown-menu-sm-end { --bs-position: end; } .dropdown-menu-sm-end[data-bs-popper] { right: 0; left: auto; } } @media (min-width: 768px) { .dropdown-menu-md-start { --bs-position: start; } .dropdown-menu-md-start[data-bs-popper] { right: auto; left: 0; } .dropdown-menu-md-end { --bs-position: end; } .dropdown-menu-md-end[data-bs-popper] { right: 0; left: auto; } } @media (min-width: 992px) { .dropdown-menu-lg-start { --bs-position: start; } .dropdown-menu-lg-start[data-bs-popper] { right: auto; left: 0; } .dropdown-menu-lg-end { --bs-position: end; } .dropdown-menu-lg-end[data-bs-popper] { right: 0; left: auto; } } @media (min-width: 1200px) { .dropdown-menu-xl-start { --bs-position: start; } .dropdown-menu-xl-start[data-bs-popper] { right: auto; left: 0; } .dropdown-menu-xl-end { --bs-position: end; } .dropdown-menu-xl-end[data-bs-popper] { right: 0; left: auto; } } @media (min-width: 1400px) { .dropdown-menu-xxl-start { --bs-position: start; } .dropdown-menu-xxl-start[data-bs-popper] { right: auto; left: 0; } .dropdown-menu-xxl-end { --bs-position: end; } .dropdown-menu-xxl-end[data-bs-popper] { right: 0; left: auto; } } .dropup .dropdown-menu[data-bs-popper] { top: auto; bottom: 100%; margin-top: 0; margin-bottom: var(--bs-dropdown-spacer); } .dropup .dropdown-toggle::after { display: inline-block; margin-left: 0.255em; vertical-align: 0.255em; content: ""; border-top: 0; border-right: 0.3em solid transparent; border-bottom: 0.3em solid; border-left: 0.3em solid transparent; } .dropup .dropdown-toggle:empty::after { margin-left: 0; } .dropend .dropdown-menu[data-bs-popper] { top: 0; right: auto; left: 100%; margin-top: 0; margin-left: var(--bs-dropdown-spacer); } .dropend .dropdown-toggle::after { display: inline-block; margin-left: 0.255em; vertical-align: 0.255em; content: ""; border-top: 0.3em solid transparent; border-right: 0; border-bottom: 0.3em solid transparent; border-left: 0.3em solid; } .dropend .dropdown-toggle:empty::after { margin-left: 0; } .dropend .dropdown-toggle::after { vertical-align: 0; } .dropstart .dropdown-menu[data-bs-popper] { top: 0; right: 100%; left: auto; margin-top: 0; margin-right: var(--bs-dropdown-spacer); } .dropstart .dropdown-toggle::after { display: inline-block; margin-left: 0.255em; vertical-align: 0.255em; content: ""; } .dropstart .dropdown-toggle::after { display: none; } .dropstart .dropdown-toggle::before { display: inline-block; margin-right: 0.255em; vertical-align: 0.255em; content: ""; border-top: 0.3em solid transparent; border-right: 0.3em solid; border-bottom: 0.3em solid transparent; } .dropstart .dropdown-toggle:empty::after { margin-left: 0; } .dropstart .dropdown-toggle::before { vertical-align: 0; } .dropdown-divider { height: 0; margin: var(--bs-dropdown-divider-margin-y) 0; overflow: hidden; border-top: 1px solid var(--bs-dropdown-divider-bg); opacity: 1; } .dropdown-item { display: block; width: 100%; padding: var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x); clear: both; font-weight: 400; color: var(--bs-dropdown-link-color); text-align: inherit; white-space: nowrap; background-color: transparent; border: 0; border-radius: var(--bs-dropdown-item-border-radius, 0); } .dropdown-item:hover, .dropdown-item:focus { color: var(--bs-dropdown-link-hover-color); text-decoration: none; background-color: var(--bs-dropdown-link-hover-bg); } .dropdown-item.active, .dropdown-item:active { color: var(--bs-dropdown-link-active-color); text-decoration: none; background-color: var(--bs-dropdown-link-active-bg); } .dropdown-item.disabled, .dropdown-item:disabled { color: var(--bs-dropdown-link-disabled-color); pointer-events: none; background-color: transparent; } .dropdown-menu.show { display: block; } .dropdown-header { display: block; padding: var(--bs-dropdown-header-padding-y) var(--bs-dropdown-header-padding-x); margin-bottom: 0; font-size: 0.875rem; color: var(--bs-dropdown-header-color); white-space: nowrap; } .dropdown-item-text { display: block; padding: var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x); color: var(--bs-dropdown-link-color); } .dropdown-menu-dark { --bs-dropdown-color: #dee2e6; --bs-dropdown-bg: #343a40; --bs-dropdown-border-color: var(--bs-border-color-translucent); --bs-dropdown-box-shadow: ; --bs-dropdown-link-color: #dee2e6; --bs-dropdown-link-hover-color: #fff; --bs-dropdown-divider-bg: var(--bs-border-color-translucent); --bs-dropdown-link-hover-bg: rgba(255, 255, 255, 0.15); --bs-dropdown-link-active-color: #fff; --bs-dropdown-link-active-bg: #4f46e5; --bs-dropdown-link-disabled-color: #adb5bd; --bs-dropdown-header-color: #adb5bd; } .btn-group, .btn-group-vertical { position: relative; display: inline-flex; vertical-align: middle; } .btn-group > .btn, .search-form .btn-group > .search-submit, .comment-form .btn-group > input[type="submit"], .btn-group-vertical > .btn, .search-form .btn-group-vertical > .search-submit, .comment-form .btn-group-vertical > input[type="submit"] { position: relative; flex: 1 1 auto; } .btn-group > .btn-check:checked + .btn, .search-form .btn-group > .btn-check:checked + .search-submit, .comment-form .btn-group > .btn-check:checked + input[type="submit"], .btn-group > .btn-check:focus + .btn, .search-form .btn-group > .btn-check:focus + .search-submit, .comment-form .btn-group > .btn-check:focus + input[type="submit"], .btn-group > .btn:hover, .search-form .btn-group > .search-submit:hover, .comment-form .btn-group > input[type="submit"]:hover, .btn-group > .btn:focus, .search-form .btn-group > .search-submit:focus, .comment-form .btn-group > input[type="submit"]:focus, .btn-group > .btn:active, .search-form .btn-group > .search-submit:active, .comment-form .btn-group > input[type="submit"]:active, .btn-group > .btn.active, .search-form .btn-group > .active.search-submit, .comment-form .btn-group > input.active[type="submit"], .btn-group-vertical > .btn-check:checked + .btn, .search-form .btn-group-vertical > .btn-check:checked + .search-submit, .comment-form .btn-group-vertical > .btn-check:checked + input[type="submit"], .btn-group-vertical > .btn-check:focus + .btn, .search-form .btn-group-vertical > .btn-check:focus + .search-submit, .comment-form .btn-group-vertical > .btn-check:focus + input[type="submit"], .btn-group-vertical > .btn:hover, .search-form .btn-group-vertical > .search-submit:hover, .comment-form .btn-group-vertical > input[type="submit"]:hover, .btn-group-vertical > .btn:focus, .search-form .btn-group-vertical > .search-submit:focus, .comment-form .btn-group-vertical > input[type="submit"]:focus, .btn-group-vertical > .btn:active, .search-form .btn-group-vertical > .search-submit:active, .comment-form .btn-group-vertical > input[type="submit"]:active, .btn-group-vertical > .btn.active, .search-form .btn-group-vertical > .active.search-submit, .comment-form .btn-group-vertical > input.active[type="submit"] { z-index: 1; } .btn-toolbar { display: flex; flex-wrap: wrap; justify-content: flex-start; } .btn-toolbar .input-group { width: auto; } .btn-group { border-radius: var(--bs-border-radius); } .btn-group > :not(.btn-check:first-child) + .btn, .search-form .btn-group > :not(.btn-check:first-child) + .search-submit, .comment-form .btn-group > :not(.btn-check:first-child) + input[type="submit"], .btn-group > .btn-group:not(:first-child) { margin-left: calc(var(--bs-border-width) * -1); } .btn-group > .btn:not(:last-child):not(.dropdown-toggle), .search-form .btn-group > .search-submit:not(:last-child):not(.dropdown-toggle), .comment-form .btn-group > input[type="submit"]:not(:last-child):not(.dropdown-toggle), .btn-group > .btn.dropdown-toggle-split:first-child, .search-form .btn-group > .dropdown-toggle-split.search-submit:first-child, .comment-form .btn-group > input.dropdown-toggle-split[type="submit"]:first-child, .btn-group > .btn-group:not(:last-child) > .btn, .search-form .btn-group > .btn-group:not(:last-child) > .search-submit, .comment-form .btn-group > .btn-group:not(:last-child) > input[type="submit"] { border-top-right-radius: 0; border-bottom-right-radius: 0; } .btn-group > .btn:nth-child(n + 3), .search-form .btn-group > .search-submit:nth-child(n + 3), .comment-form .btn-group > input[type="submit"]:nth-child(n + 3), .btn-group > :not(.btn-check) + .btn, .search-form .btn-group > :not(.btn-check) + .search-submit, .comment-form .btn-group > :not(.btn-check) + input[type="submit"], .btn-group > .btn-group:not(:first-child) > .btn, .search-form .btn-group > .btn-group:not(:first-child) > .search-submit, .comment-form .btn-group > .btn-group:not(:first-child) > input[type="submit"] { border-top-left-radius: 0; border-bottom-left-radius: 0; } .dropdown-toggle-split { padding-right: 0.5625rem; padding-left: 0.5625rem; } .dropdown-toggle-split::after, .dropup .dropdown-toggle-split::after, .dropend .dropdown-toggle-split::after { margin-left: 0; } .dropstart .dropdown-toggle-split::before { margin-right: 0; } .btn-sm + .dropdown-toggle-split, .btn-group-sm > .btn + .dropdown-toggle-split, .search-form .btn-group-sm > .search-submit + .dropdown-toggle-split, .comment-form .btn-group-sm > input[type="submit"] + .dropdown-toggle-split { padding-right: 0.375rem; padding-left: 0.375rem; } .btn-lg + .dropdown-toggle-split, .btn-group-lg > .btn + .dropdown-toggle-split, .search-form .btn-group-lg > .search-submit + .dropdown-toggle-split, .comment-form .btn-group-lg > input[type="submit"] + .dropdown-toggle-split { padding-right: 0.75rem; padding-left: 0.75rem; } .btn-group-vertical { flex-direction: column; align-items: flex-start; justify-content: center; } .btn-group-vertical > .btn, .search-form .btn-group-vertical > .search-submit, .comment-form .btn-group-vertical > input[type="submit"], .btn-group-vertical > .btn-group { width: 100%; } .btn-group-vertical > .btn:not(:first-child), .search-form .btn-group-vertical > .search-submit:not(:first-child), .comment-form .btn-group-vertical > input[type="submit"]:not(:first-child), .btn-group-vertical > .btn-group:not(:first-child) { margin-top: calc(var(--bs-border-width) * -1); } .btn-group-vertical > .btn:not(:last-child):not(.dropdown-toggle), .search-form .btn-group-vertical > .search-submit:not(:last-child):not(.dropdown-toggle), .comment-form .btn-group-vertical > input[type="submit"]:not(:last-child):not(.dropdown-toggle), .btn-group-vertical > .btn-group:not(:last-child) > .btn, .search-form .btn-group-vertical > .btn-group:not(:last-child) > .search-submit, .comment-form .btn-group-vertical > .btn-group:not(:last-child) > input[type="submit"] { border-bottom-right-radius: 0; border-bottom-left-radius: 0; } .btn-group-vertical > .btn ~ .btn, .search-form .btn-group-vertical > .search-submit ~ .btn, .search-form .btn-group-vertical > .btn ~ .search-submit, .search-form .btn-group-vertical > .search-submit ~ .search-submit, .comment-form .btn-group-vertical > input[type="submit"] ~ .btn, .comment-form .search-form .btn-group-vertical > input[type="submit"] ~ .search-submit, .search-form .comment-form .btn-group-vertical > input[type="submit"] ~ .search-submit, .comment-form .btn-group-vertical > .btn ~ input[type="submit"], .comment-form .search-form .btn-group-vertical > .search-submit ~ input[type="submit"], .search-form .comment-form .btn-group-vertical > .search-submit ~ input[type="submit"], .comment-form .btn-group-vertical > input[type="submit"] ~ input[type="submit"], .btn-group-vertical > .btn-group:not(:first-child) > .btn, .search-form .btn-group-vertical > .btn-group:not(:first-child) > .search-submit, .comment-form .btn-group-vertical > .btn-group:not(:first-child) > input[type="submit"] { border-top-left-radius: 0; border-top-right-radius: 0; } .nav { --bs-nav-link-padding-x: 1rem; --bs-nav-link-padding-y: 0.5rem; --bs-nav-link-font-weight: ; --bs-nav-link-color: var(--bs-link-color); --bs-nav-link-hover-color: var(--bs-link-hover-color); --bs-nav-link-disabled-color: var(--bs-secondary-color); display: flex; flex-wrap: wrap; padding-left: 0; margin-bottom: 0; list-style: none; } .nav-link, .banner .nav a { display: block; padding: var(--bs-nav-link-padding-y) var(--bs-nav-link-padding-x); font-size: var(--bs-nav-link-font-size); font-weight: var(--bs-nav-link-font-weight); color: var(--bs-nav-link-color); background: none; border: 0; transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out; } @media (prefers-reduced-motion: reduce) { .nav-link, .banner .nav a { transition: none; } } .nav-link:hover, .banner .nav a:hover, .nav-link:focus, .banner .nav a:focus { color: var(--bs-nav-link-hover-color); text-decoration: none; } .nav-link:focus-visible, .banner .nav a:focus-visible { outline: 0; box-shadow: 0 0 0 0.25rem rgba(79, 70, 229, 0.25); } .nav-link.disabled, .banner .nav a.disabled, .nav-link:disabled, .banner .nav a:disabled { color: var(--bs-nav-link-disabled-color); pointer-events: none; cursor: default; } .nav-tabs { --bs-nav-tabs-border-width: var(--bs-border-width); --bs-nav-tabs-border-color: var(--bs-border-color); --bs-nav-tabs-border-radius: var(--bs-border-radius); --bs-nav-tabs-link-hover-border-color: var(--bs-secondary-bg) var(--bs-secondary-bg) var(--bs-border-color); --bs-nav-tabs-link-active-color: var(--bs-emphasis-color); --bs-nav-tabs-link-active-bg: var(--bs-body-bg); --bs-nav-tabs-link-active-border-color: var(--bs-border-color) var(--bs-border-color) var(--bs-body-bg); border-bottom: var(--bs-nav-tabs-border-width) solid var(--bs-nav-tabs-border-color); } .nav-tabs .nav-link, .nav-tabs .banner .nav a, .banner .nav .nav-tabs a { margin-bottom: calc(-1 * var(--bs-nav-tabs-border-width)); border: var(--bs-nav-tabs-border-width) solid transparent; border-top-left-radius: var(--bs-nav-tabs-border-radius); border-top-right-radius: var(--bs-nav-tabs-border-radius); } .nav-tabs .nav-link:hover, .nav-tabs .banner .nav a:hover, .banner .nav .nav-tabs a:hover, .nav-tabs .nav-link:focus, .nav-tabs .banner .nav a:focus, .banner .nav .nav-tabs a:focus { isolation: isolate; border-color: var(--bs-nav-tabs-link-hover-border-color); } .nav-tabs .nav-link.active, .nav-tabs .banner .nav a.active, .banner .nav .nav-tabs a.active, .nav-tabs .nav-item.show .nav-link, .nav-tabs .nav-item.show .banner .nav a, .banner .nav .nav-tabs .nav-item.show a, .nav-tabs .banner .nav li.show .nav-link, .nav-tabs .banner .nav li.show a, .banner .nav .nav-tabs li.show .nav-link, .banner .nav .nav-tabs li.show a { color: var(--bs-nav-tabs-link-active-color); background-color: var(--bs-nav-tabs-link-active-bg); border-color: var(--bs-nav-tabs-link-active-border-color); } .nav-tabs .dropdown-menu { margin-top: calc(-1 * var(--bs-nav-tabs-border-width)); border-top-left-radius: 0; border-top-right-radius: 0; } .nav-pills { --bs-nav-pills-border-radius: var(--bs-border-radius); --bs-nav-pills-link-active-color: #fff; --bs-nav-pills-link-active-bg: #4f46e5; } .nav-pills .nav-link, .nav-pills .banner .nav a, .banner .nav .nav-pills a { border-radius: var(--bs-nav-pills-border-radius); } .nav-pills .nav-link.active, .nav-pills .banner .nav a.active, .banner .nav .nav-pills a.active, .nav-pills .show > .nav-link, .nav-pills .banner .nav .show > a, .banner .nav .nav-pills .show > a { color: var(--bs-nav-pills-link-active-color); background-color: var(--bs-nav-pills-link-active-bg); } .nav-underline { --bs-nav-underline-gap: 1rem; --bs-nav-underline-border-width: 0.125rem; --bs-nav-underline-link-active-color: var(--bs-emphasis-color); gap: var(--bs-nav-underline-gap); } .nav-underline .nav-link, .nav-underline .banner .nav a, .banner .nav .nav-underline a { padding-right: 0; padding-left: 0; border-bottom: var(--bs-nav-underline-border-width) solid transparent; } .nav-underline .nav-link:hover, .nav-underline .banner .nav a:hover, .banner .nav .nav-underline a:hover, .nav-underline .nav-link:focus, .nav-underline .banner .nav a:focus, .banner .nav .nav-underline a:focus { border-bottom-color: currentcolor; } .nav-underline .nav-link.active, .nav-underline .banner .nav a.active, .banner .nav .nav-underline a.active, .nav-underline .show > .nav-link, .nav-underline .banner .nav .show > a, .banner .nav .nav-underline .show > a { font-weight: 700; color: var(--bs-nav-underline-link-active-color); border-bottom-color: currentcolor; } .nav-fill > .nav-link, .banner .nav .nav-fill > a, .nav-fill .nav-item, .nav-fill .banner .nav li, .banner .nav .nav-fill li { flex: 1 1 auto; text-align: center; } .nav-justified > .nav-link, .banner .nav .nav-justified > a, .nav-justified .nav-item, .nav-justified .banner .nav li, .banner .nav .nav-justified li { flex-basis: 0; flex-grow: 1; text-align: center; } .nav-fill .nav-item .nav-link, .nav-fill .nav-item .banner .nav a, .banner .nav .nav-fill .nav-item a, .nav-fill .banner .nav li .nav-link, .nav-fill .banner .nav li a, .banner .nav .nav-fill li .nav-link, .banner .nav .nav-fill li a, .nav-justified .nav-item .nav-link, .nav-justified .nav-item .banner .nav a, .banner .nav .nav-justified .nav-item a, .nav-justified .banner .nav li .nav-link, .nav-justified .banner .nav li a, .banner .nav .nav-justified li .nav-link, .banner .nav .nav-justified li a { width: 100%; } .tab-content > .tab-pane { display: none; } .tab-content > .active { display: block; } .navbar { --bs-navbar-padding-x: 0; --bs-navbar-padding-y: 0.5rem; --bs-navbar-color: rgba(var(--bs-emphasis-color-rgb), 0.65); --bs-navbar-hover-color: rgba(var(--bs-emphasis-color-rgb), 0.8); --bs-navbar-disabled-color: rgba(var(--bs-emphasis-color-rgb), 0.3); --bs-navbar-active-color: rgba(var(--bs-emphasis-color-rgb), 1); --bs-navbar-brand-padding-y: 0.3125rem; --bs-navbar-brand-margin-end: 1rem; --bs-navbar-brand-font-size: 1.25rem; --bs-navbar-brand-color: rgba(var(--bs-emphasis-color-rgb), 1); --bs-navbar-brand-hover-color: rgba(var(--bs-emphasis-color-rgb), 1); --bs-navbar-nav-link-padding-x: 0.5rem; --bs-navbar-toggler-padding-y: 0.25rem; --bs-navbar-toggler-padding-x: 0.75rem; --bs-navbar-toggler-font-size: 1.25rem; --bs-navbar-toggler-icon-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%2829, 45, 53, 0.75%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e"); --bs-navbar-toggler-border-color: rgba(var(--bs-emphasis-color-rgb), 0.15); --bs-navbar-toggler-border-radius: var(--bs-border-radius); --bs-navbar-toggler-focus-width: 0; --bs-navbar-toggler-transition: box-shadow 0.15s ease-in-out; position: relative; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; padding: var(--bs-navbar-padding-y) var(--bs-navbar-padding-x); } .navbar > .container, .navbar > .container-fluid, .navbar > .container-sm, .navbar > .container-md, .navbar > .container-lg, .navbar > .container-xl, .navbar > .container-xxl { display: flex; flex-wrap: inherit; align-items: center; justify-content: space-between; } .navbar-brand { padding-top: var(--bs-navbar-brand-padding-y); padding-bottom: var(--bs-navbar-brand-padding-y); margin-right: var(--bs-navbar-brand-margin-end); font-size: var(--bs-navbar-brand-font-size); color: var(--bs-navbar-brand-color); white-space: nowrap; } .navbar-brand:hover, .navbar-brand:focus { color: var(--bs-navbar-brand-hover-color); text-decoration: none; } .navbar-nav { --bs-nav-link-padding-x: 0; --bs-nav-link-padding-y: 0.5rem; --bs-nav-link-font-weight: ; --bs-nav-link-color: var(--bs-navbar-color); --bs-nav-link-hover-color: var(--bs-navbar-hover-color); --bs-nav-link-disabled-color: var(--bs-navbar-disabled-color); display: flex; flex-direction: column; padding-left: 0; margin-bottom: 0; list-style: none; } .navbar-nav .nav-link.active, .navbar-nav .banner .nav a.active, .banner .nav .navbar-nav a.active, .navbar-nav .nav-link.show, .navbar-nav .banner .nav a.show, .banner .nav .navbar-nav a.show { color: var(--bs-navbar-active-color); } .navbar-nav .dropdown-menu { position: static; } .navbar-text { padding-top: 0.5rem; padding-bottom: 0.5rem; color: var(--bs-navbar-color); } .navbar-text a, .navbar-text a:hover, .navbar-text a:focus { color: var(--bs-navbar-active-color); } .navbar-collapse { flex-basis: 100%; flex-grow: 1; align-items: center; } .navbar-toggler { padding: var(--bs-navbar-toggler-padding-y) var(--bs-navbar-toggler-padding-x); font-size: var(--bs-navbar-toggler-font-size); line-height: 1; color: var(--bs-navbar-color); background-color: transparent; border: var(--bs-border-width) solid var(--bs-navbar-toggler-border-color); border-radius: var(--bs-navbar-toggler-border-radius); transition: var(--bs-navbar-toggler-transition); } @media (prefers-reduced-motion: reduce) { .navbar-toggler { transition: none; } } .navbar-toggler:hover { text-decoration: none; } .navbar-toggler:focus { text-decoration: none; outline: 0; box-shadow: 0 0 0 var(--bs-navbar-toggler-focus-width); } .navbar-toggler-icon { display: inline-block; width: 1.5em; height: 1.5em; vertical-align: middle; background-image: var(--bs-navbar-toggler-icon-bg); background-repeat: no-repeat; background-position: center; background-size: 100%; } .navbar-nav-scroll { max-height: var(--bs-scroll-height, 75vh); overflow-y: auto; } @media (min-width: 576px) { .navbar-expand-sm { flex-wrap: nowrap; justify-content: flex-start; } .navbar-expand-sm .navbar-nav { flex-direction: row; } .navbar-expand-sm .navbar-nav .dropdown-menu { position: absolute; } .navbar-expand-sm .navbar-nav .nav-link, .navbar-expand-sm .navbar-nav .banner .nav a, .banner .nav .navbar-expand-sm .navbar-nav a { padding-right: var(--bs-navbar-nav-link-padding-x); padding-left: var(--bs-navbar-nav-link-padding-x); } .navbar-expand-sm .navbar-nav-scroll { overflow: visible; } .navbar-expand-sm .navbar-collapse { display: flex !important; flex-basis: auto; } .navbar-expand-sm .navbar-toggler { display: none; } .navbar-expand-sm .offcanvas { position: static; z-index: auto; flex-grow: 1; width: auto !important; height: auto !important; visibility: visible !important; background-color: transparent !important; border: 0 !important; transform: none !important; transition: none; } .navbar-expand-sm .offcanvas .offcanvas-header { display: none; } .navbar-expand-sm .offcanvas .offcanvas-body { display: flex; flex-grow: 0; padding: 0; overflow-y: visible; } } @media (min-width: 768px) { .navbar-expand-md { flex-wrap: nowrap; justify-content: flex-start; } .navbar-expand-md .navbar-nav { flex-direction: row; } .navbar-expand-md .navbar-nav .dropdown-menu { position: absolute; } .navbar-expand-md .navbar-nav .nav-link, .navbar-expand-md .navbar-nav .banner .nav a, .banner .nav .navbar-expand-md .navbar-nav a { padding-right: var(--bs-navbar-nav-link-padding-x); padding-left: var(--bs-navbar-nav-link-padding-x); } .navbar-expand-md .navbar-nav-scroll { overflow: visible; } .navbar-expand-md .navbar-collapse { display: flex !important; flex-basis: auto; } .navbar-expand-md .navbar-toggler { display: none; } .navbar-expand-md .offcanvas { position: static; z-index: auto; flex-grow: 1; width: auto !important; height: auto !important; visibility: visible !important; background-color: transparent !important; border: 0 !important; transform: none !important; transition: none; } .navbar-expand-md .offcanvas .offcanvas-header { display: none; } .navbar-expand-md .offcanvas .offcanvas-body { display: flex; flex-grow: 0; padding: 0; overflow-y: visible; } } @media (min-width: 992px) { .navbar-expand-lg { flex-wrap: nowrap; justify-content: flex-start; } .navbar-expand-lg .navbar-nav { flex-direction: row; } .navbar-expand-lg .navbar-nav .dropdown-menu { position: absolute; } .navbar-expand-lg .navbar-nav .nav-link, .navbar-expand-lg .navbar-nav .banner .nav a, .banner .nav .navbar-expand-lg .navbar-nav a { padding-right: var(--bs-navbar-nav-link-padding-x); padding-left: var(--bs-navbar-nav-link-padding-x); } .navbar-expand-lg .navbar-nav-scroll { overflow: visible; } .navbar-expand-lg .navbar-collapse { display: flex !important; flex-basis: auto; } .navbar-expand-lg .navbar-toggler { display: none; } .navbar-expand-lg .offcanvas { position: static; z-index: auto; flex-grow: 1; width: auto !important; height: auto !important; visibility: visible !important; background-color: transparent !important; border: 0 !important; transform: none !important; transition: none; } .navbar-expand-lg .offcanvas .offcanvas-header { display: none; } .navbar-expand-lg .offcanvas .offcanvas-body { display: flex; flex-grow: 0; padding: 0; overflow-y: visible; } } @media (min-width: 1200px) { .navbar-expand-xl { flex-wrap: nowrap; justify-content: flex-start; } .navbar-expand-xl .navbar-nav { flex-direction: row; } .navbar-expand-xl .navbar-nav .dropdown-menu { position: absolute; } .navbar-expand-xl .navbar-nav .nav-link, .navbar-expand-xl .navbar-nav .banner .nav a, .banner .nav .navbar-expand-xl .navbar-nav a { padding-right: var(--bs-navbar-nav-link-padding-x); padding-left: var(--bs-navbar-nav-link-padding-x); } .navbar-expand-xl .navbar-nav-scroll { overflow: visible; } .navbar-expand-xl .navbar-collapse { display: flex !important; flex-basis: auto; } .navbar-expand-xl .navbar-toggler { display: none; } .navbar-expand-xl .offcanvas { position: static; z-index: auto; flex-grow: 1; width: auto !important; height: auto !important; visibility: visible !important; background-color: transparent !important; border: 0 !important; transform: none !important; transition: none; } .navbar-expand-xl .offcanvas .offcanvas-header { display: none; } .navbar-expand-xl .offcanvas .offcanvas-body { display: flex; flex-grow: 0; padding: 0; overflow-y: visible; } } @media (min-width: 1400px) { .navbar-expand-xxl { flex-wrap: nowrap; justify-content: flex-start; } .navbar-expand-xxl .navbar-nav { flex-direction: row; } .navbar-expand-xxl .navbar-nav .dropdown-menu { position: absolute; } .navbar-expand-xxl .navbar-nav .nav-link, .navbar-expand-xxl .navbar-nav .banner .nav a, .banner .nav .navbar-expand-xxl .navbar-nav a { padding-right: var(--bs-navbar-nav-link-padding-x); padding-left: var(--bs-navbar-nav-link-padding-x); } .navbar-expand-xxl .navbar-nav-scroll { overflow: visible; } .navbar-expand-xxl .navbar-collapse { display: flex !important; flex-basis: auto; } .navbar-expand-xxl .navbar-toggler { display: none; } .navbar-expand-xxl .offcanvas { position: static; z-index: auto; flex-grow: 1; width: auto !important; height: auto !important; visibility: visible !important; background-color: transparent !important; border: 0 !important; transform: none !important; transition: none; } .navbar-expand-xxl .offcanvas .offcanvas-header { display: none; } .navbar-expand-xxl .offcanvas .offcanvas-body { display: flex; flex-grow: 0; padding: 0; overflow-y: visible; } } .navbar-expand { flex-wrap: nowrap; justify-content: flex-start; } .navbar-expand .navbar-nav { flex-direction: row; } .navbar-expand .navbar-nav .dropdown-menu { position: absolute; } .navbar-expand .navbar-nav .nav-link, .navbar-expand .navbar-nav .banner .nav a, .banner .nav .navbar-expand .navbar-nav a { padding-right: var(--bs-navbar-nav-link-padding-x); padding-left: var(--bs-navbar-nav-link-padding-x); } .navbar-expand .navbar-nav-scroll { overflow: visible; } .navbar-expand .navbar-collapse { display: flex !important; flex-basis: auto; } .navbar-expand .navbar-toggler { display: none; } .navbar-expand .offcanvas { position: static; z-index: auto; flex-grow: 1; width: auto !important; height: auto !important; visibility: visible !important; background-color: transparent !important; border: 0 !important; transform: none !important; transition: none; } .navbar-expand .offcanvas .offcanvas-header { display: none; } .navbar-expand .offcanvas .offcanvas-body { display: flex; flex-grow: 0; padding: 0; overflow-y: visible; } .navbar-dark, .navbar[data-bs-theme="dark"] { --bs-navbar-color: #c1c3c8; --bs-navbar-hover-color: #b3c7ff; --bs-navbar-disabled-color: rgba(255, 255, 255, 0.25); --bs-navbar-active-color: #b3c7ff; --bs-navbar-brand-color: #b3c7ff; --bs-navbar-brand-hover-color: #b3c7ff; --bs-navbar-toggler-border-color: rgba(255, 255, 255, 0.1); --bs-navbar-toggler-icon-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='%23c1c3c8' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e"); } [data-bs-theme="dark"] .navbar-toggler-icon { --bs-navbar-toggler-icon-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='%23c1c3c8' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e"); } .card { --bs-card-spacer-y: 1rem; --bs-card-spacer-x: 1rem; --bs-card-title-spacer-y: 0.5rem; --bs-card-title-color: ; --bs-card-subtitle-color: ; --bs-card-border-width: var(--bs-border-width); --bs-card-border-color: #e9ecef; --bs-card-border-radius: var(--bs-border-radius); --bs-card-box-shadow: ; --bs-card-inner-border-radius: calc(var(--bs-border-radius) - (var(--bs-border-width))); --bs-card-cap-padding-y: 0.5rem; --bs-card-cap-padding-x: 1rem; --bs-card-cap-bg: rgba(var(--bs-body-color-rgb), 0.03); --bs-card-cap-color: ; --bs-card-height: ; --bs-card-color: ; --bs-card-bg: var(--bs-body-bg); --bs-card-img-overlay-padding: 1rem; --bs-card-group-margin: 1.5rem; position: relative; display: flex; flex-direction: column; min-width: 0; height: var(--bs-card-height); color: var(--bs-body-color); word-wrap: break-word; background-color: var(--bs-card-bg); background-clip: border-box; border: var(--bs-card-border-width) solid var(--bs-card-border-color); border-radius: var(--bs-card-border-radius); } .card > hr { margin-right: 0; margin-left: 0; } .card > .list-group { border-top: inherit; border-bottom: inherit; } .card > .list-group:first-child { border-top-width: 0; border-top-left-radius: var(--bs-card-inner-border-radius); border-top-right-radius: var(--bs-card-inner-border-radius); } .card > .list-group:last-child { border-bottom-width: 0; border-bottom-right-radius: var(--bs-card-inner-border-radius); border-bottom-left-radius: var(--bs-card-inner-border-radius); } .card > .card-header + .list-group, .card > .list-group + .card-footer { border-top: 0; } .card-body { flex: 1 1 auto; padding: var(--bs-card-spacer-y) var(--bs-card-spacer-x); color: var(--bs-card-color); } .card-title { margin-bottom: var(--bs-card-title-spacer-y); color: var(--bs-card-title-color); } .card-subtitle { margin-top: calc(-.5 * var(--bs-card-title-spacer-y)); margin-bottom: 0; color: var(--bs-card-subtitle-color); } .card-text:last-child { margin-bottom: 0; } .card-link:hover { text-decoration: none; } .card-link + .card-link { margin-left: var(--bs-card-spacer-x); } .card-header { padding: var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x); margin-bottom: 0; color: var(--bs-card-cap-color); background-color: var(--bs-card-cap-bg); border-bottom: var(--bs-card-border-width) solid var(--bs-card-border-color); } .card-header:first-child { border-radius: var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius) 0 0; } .card-footer { padding: var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x); color: var(--bs-card-cap-color); background-color: var(--bs-card-cap-bg); border-top: var(--bs-card-border-width) solid var(--bs-card-border-color); } .card-footer:last-child { border-radius: 0 0 var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius); } .card-header-tabs { margin-right: calc(-.5 * var(--bs-card-cap-padding-x)); margin-bottom: calc(-1 * var(--bs-card-cap-padding-y)); margin-left: calc(-.5 * var(--bs-card-cap-padding-x)); border-bottom: 0; } .card-header-tabs .nav-link.active, .card-header-tabs .banner .nav a.active, .banner .nav .card-header-tabs a.active { background-color: var(--bs-card-bg); border-bottom-color: var(--bs-card-bg); } .card-header-pills { margin-right: calc(-.5 * var(--bs-card-cap-padding-x)); margin-left: calc(-.5 * var(--bs-card-cap-padding-x)); } .card-img-overlay { position: absolute; top: 0; right: 0; bottom: 0; left: 0; padding: var(--bs-card-img-overlay-padding); border-radius: var(--bs-card-inner-border-radius); } .card-img, .card-img-top, .card-img-bottom { width: 100%; } .card-img, .card-img-top { border-top-left-radius: var(--bs-card-inner-border-radius); border-top-right-radius: var(--bs-card-inner-border-radius); } .card-img, .card-img-bottom { border-bottom-right-radius: var(--bs-card-inner-border-radius); border-bottom-left-radius: var(--bs-card-inner-border-radius); } .card-group > .card { margin-bottom: var(--bs-card-group-margin); } @media (min-width: 576px) { .card-group { display: flex; flex-flow: row wrap; } .card-group > .card { flex: 1 0 0%; margin-bottom: 0; } .card-group > .card + .card { margin-left: 0; border-left: 0; } .card-group > .card:not(:last-child) { border-top-right-radius: 0; border-bottom-right-radius: 0; } .card-group > .card:not(:last-child) .card-img-top, .card-group > .card:not(:last-child) .card-header { border-top-right-radius: 0; } .card-group > .card:not(:last-child) .card-img-bottom, .card-group > .card:not(:last-child) .card-footer { border-bottom-right-radius: 0; } .card-group > .card:not(:first-child) { border-top-left-radius: 0; border-bottom-left-radius: 0; } .card-group > .card:not(:first-child) .card-img-top, .card-group > .card:not(:first-child) .card-header { border-top-left-radius: 0; } .card-group > .card:not(:first-child) .card-img-bottom, .card-group > .card:not(:first-child) .card-footer { border-bottom-left-radius: 0; } } .accordion { --bs-accordion-color: var(--bs-body-color); --bs-accordion-bg: var(--bs-body-bg); --bs-accordion-transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out, border-radius 0.15s ease; --bs-accordion-border-color: var(--bs-border-color); --bs-accordion-border-width: var(--bs-border-width); --bs-accordion-border-radius: var(--bs-border-radius); --bs-accordion-inner-border-radius: calc(var(--bs-border-radius) - (var(--bs-border-width))); --bs-accordion-btn-padding-x: 1.25rem; --bs-accordion-btn-padding-y: 1rem; --bs-accordion-btn-color: var(--bs-body-color); --bs-accordion-btn-bg: var(--bs-accordion-bg); --bs-accordion-btn-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%231d2d35' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='M2 5L8 11L14 5'/%3e%3c/svg%3e"); --bs-accordion-btn-icon-width: 1.25rem; --bs-accordion-btn-icon-transform: rotate(-180deg); --bs-accordion-btn-icon-transition: transform 0.2s ease-in-out; --bs-accordion-btn-active-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23201c5c' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='M2 5L8 11L14 5'/%3e%3c/svg%3e"); --bs-accordion-btn-focus-box-shadow: none; --bs-accordion-body-padding-x: 1.25rem; --bs-accordion-body-padding-y: 1rem; --bs-accordion-active-color: var(--bs-primary-text-emphasis); --bs-accordion-active-bg: var(--bs-primary-bg-subtle); } .accordion-button { position: relative; display: flex; align-items: center; width: 100%; padding: var(--bs-accordion-btn-padding-y) var(--bs-accordion-btn-padding-x); font-size: 1rem; color: var(--bs-accordion-btn-color); text-align: left; background-color: var(--bs-accordion-btn-bg); border: 0; border-radius: 0; overflow-anchor: none; transition: var(--bs-accordion-transition); } @media (prefers-reduced-motion: reduce) { .accordion-button { transition: none; } } .accordion-button:not(.collapsed) { color: var(--bs-accordion-active-color); background-color: var(--bs-accordion-active-bg); box-shadow: inset 0 calc(-1 * var(--bs-accordion-border-width)) 0 var(--bs-accordion-border-color); } .accordion-button:not(.collapsed)::after { background-image: var(--bs-accordion-btn-active-icon); transform: var(--bs-accordion-btn-icon-transform); } .accordion-button::after { flex-shrink: 0; width: var(--bs-accordion-btn-icon-width); height: var(--bs-accordion-btn-icon-width); margin-left: auto; content: ""; background-image: var(--bs-accordion-btn-icon); background-repeat: no-repeat; background-size: var(--bs-accordion-btn-icon-width); transition: var(--bs-accordion-btn-icon-transition); } @media (prefers-reduced-motion: reduce) { .accordion-button::after { transition: none; } } .accordion-button:hover { z-index: 2; } .accordion-button:focus { z-index: 3; outline: 0; box-shadow: var(--bs-accordion-btn-focus-box-shadow); } .accordion-header { margin-bottom: 0; } .accordion-item { color: var(--bs-accordion-color); background-color: var(--bs-accordion-bg); border: var(--bs-accordion-border-width) solid var(--bs-accordion-border-color); } .accordion-item:first-of-type { border-top-left-radius: var(--bs-accordion-border-radius); border-top-right-radius: var(--bs-accordion-border-radius); } .accordion-item:first-of-type > .accordion-header .accordion-button { border-top-left-radius: var(--bs-accordion-inner-border-radius); border-top-right-radius: var(--bs-accordion-inner-border-radius); } .accordion-item:not(:first-of-type) { border-top: 0; } .accordion-item:last-of-type { border-bottom-right-radius: var(--bs-accordion-border-radius); border-bottom-left-radius: var(--bs-accordion-border-radius); } .accordion-item:last-of-type > .accordion-header .accordion-button.collapsed { border-bottom-right-radius: var(--bs-accordion-inner-border-radius); border-bottom-left-radius: var(--bs-accordion-inner-border-radius); } .accordion-item:last-of-type > .accordion-collapse { border-bottom-right-radius: var(--bs-accordion-border-radius); border-bottom-left-radius: var(--bs-accordion-border-radius); } .accordion-body { padding: var(--bs-accordion-body-padding-y) var(--bs-accordion-body-padding-x); } .accordion-flush > .accordion-item { border-right: 0; border-left: 0; border-radius: 0; } .accordion-flush > .accordion-item:first-child { border-top: 0; } .accordion-flush > .accordion-item:last-child { border-bottom: 0; } .accordion-flush > .accordion-item > .accordion-header .accordion-button, .accordion-flush > .accordion-item > .accordion-header .accordion-button.collapsed { border-radius: 0; } .accordion-flush > .accordion-item > .accordion-collapse { border-radius: 0; } [data-bs-theme="dark"] .accordion-button::after { --bs-accordion-btn-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%239590ef'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e"); --bs-accordion-btn-active-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%239590ef'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e"); } .breadcrumb { --bs-breadcrumb-padding-x: 0; --bs-breadcrumb-padding-y: 0; --bs-breadcrumb-margin-bottom: 1rem; --bs-breadcrumb-bg: ; --bs-breadcrumb-border-radius: ; --bs-breadcrumb-divider-color: var(--bs-secondary-color); --bs-breadcrumb-item-padding-x: 0.5rem; --bs-breadcrumb-item-active-color: var(--bs-secondary-color); display: flex; flex-wrap: wrap; padding: var(--bs-breadcrumb-padding-y) var(--bs-breadcrumb-padding-x); margin-bottom: var(--bs-breadcrumb-margin-bottom); font-size: var(--bs-breadcrumb-font-size); list-style: none; background-color: var(--bs-breadcrumb-bg); border-radius: var(--bs-breadcrumb-border-radius); } .breadcrumb-item + .breadcrumb-item { padding-left: var(--bs-breadcrumb-item-padding-x); } .breadcrumb-item + .breadcrumb-item::before { float: left; padding-right: var(--bs-breadcrumb-item-padding-x); color: var(--bs-breadcrumb-divider-color); content: var(--bs-breadcrumb-divider, "/") /* rtl: var(--bs-breadcrumb-divider, "/") */; } .breadcrumb-item.active { color: var(--bs-breadcrumb-item-active-color); } .pagination { --bs-pagination-padding-x: 0.75rem; --bs-pagination-padding-y: 0.375rem; --bs-pagination-font-size: 1rem; --bs-pagination-color: var(--bs-link-color); --bs-pagination-bg: var(--bs-body-bg); --bs-pagination-border-width: var(--bs-border-width); --bs-pagination-border-color: var(--bs-border-color); --bs-pagination-border-radius: var(--bs-border-radius); --bs-pagination-hover-color: var(--bs-link-hover-color); --bs-pagination-hover-bg: var(--bs-tertiary-bg); --bs-pagination-hover-border-color: var(--bs-border-color); --bs-pagination-focus-color: var(--bs-link-hover-color); --bs-pagination-focus-bg: var(--bs-secondary-bg); --bs-pagination-focus-box-shadow: 0 0 0 0.25rem rgba(79, 70, 229, 0.25); --bs-pagination-active-color: #fff; --bs-pagination-active-bg: #4f46e5; --bs-pagination-active-border-color: #4f46e5; --bs-pagination-disabled-color: var(--bs-secondary-color); --bs-pagination-disabled-bg: var(--bs-secondary-bg); --bs-pagination-disabled-border-color: var(--bs-border-color); display: flex; padding-left: 0; list-style: none; } .page-link { position: relative; display: block; padding: var(--bs-pagination-padding-y) var(--bs-pagination-padding-x); font-size: var(--bs-pagination-font-size); color: var(--bs-pagination-color); background-color: var(--bs-pagination-bg); border: var(--bs-pagination-border-width) solid var(--bs-pagination-border-color); transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; } @media (prefers-reduced-motion: reduce) { .page-link { transition: none; } } .page-link:hover { z-index: 2; color: var(--bs-pagination-hover-color); text-decoration: none; background-color: var(--bs-pagination-hover-bg); border-color: var(--bs-pagination-hover-border-color); } .page-link:focus { z-index: 3; color: var(--bs-pagination-focus-color); background-color: var(--bs-pagination-focus-bg); outline: 0; box-shadow: var(--bs-pagination-focus-box-shadow); } .page-link.active, .active > .page-link { z-index: 3; color: var(--bs-pagination-active-color); background-color: var(--bs-pagination-active-bg); border-color: var(--bs-pagination-active-border-color); } .page-link.disabled, .disabled > .page-link { color: var(--bs-pagination-disabled-color); pointer-events: none; background-color: var(--bs-pagination-disabled-bg); border-color: var(--bs-pagination-disabled-border-color); } .page-item:not(:first-child) .page-link { margin-left: calc(var(--bs-border-width) * -1); } .page-item:first-child .page-link { border-top-left-radius: var(--bs-pagination-border-radius); border-bottom-left-radius: var(--bs-pagination-border-radius); } .page-item:last-child .page-link { border-top-right-radius: var(--bs-pagination-border-radius); border-bottom-right-radius: var(--bs-pagination-border-radius); } .pagination-lg { --bs-pagination-padding-x: 1.5rem; --bs-pagination-padding-y: 0.75rem; --bs-pagination-font-size: 1.25rem; --bs-pagination-border-radius: var(--bs-border-radius-lg); } .pagination-sm { --bs-pagination-padding-x: 0.5rem; --bs-pagination-padding-y: 0.25rem; --bs-pagination-font-size: 0.875rem; --bs-pagination-border-radius: var(--bs-border-radius-sm); } .badge { --bs-badge-padding-x: 0.65em; --bs-badge-padding-y: 0.35em; --bs-badge-font-size: 0.75em; --bs-badge-font-weight: 700; --bs-badge-color: #fff; --bs-badge-border-radius: var(--bs-border-radius); display: inline-block; padding: var(--bs-badge-padding-y) var(--bs-badge-padding-x); font-size: var(--bs-badge-font-size); font-weight: var(--bs-badge-font-weight); line-height: 1; color: var(--bs-badge-color); text-align: center; white-space: nowrap; vertical-align: baseline; border-radius: var(--bs-badge-border-radius); } .badge:empty { display: none; } .btn .badge, .search-form .search-submit .badge, .comment-form input[type="submit"] .badge { position: relative; top: -1px; } .alert { --bs-alert-bg: transparent; --bs-alert-padding-x: 1.5rem; --bs-alert-padding-y: 1rem; --bs-alert-margin-bottom: 0; --bs-alert-color: inherit; --bs-alert-border-color: transparent; --bs-alert-border: 0 solid var(--bs-alert-border-color); --bs-alert-border-radius: 0; --bs-alert-link-color: inherit; position: relative; padding: var(--bs-alert-padding-y) var(--bs-alert-padding-x); margin-bottom: var(--bs-alert-margin-bottom); color: var(--bs-alert-color); background-color: var(--bs-alert-bg); border: var(--bs-alert-border); border-radius: var(--bs-alert-border-radius); } .alert-heading { color: inherit; } .alert-link { font-weight: 700; color: var(--bs-alert-link-color); } .alert-dismissible { padding-right: 4.5rem; } .alert-dismissible .btn-close { position: absolute; top: 0; right: 0; z-index: 2; padding: 1.25rem 1.5rem; } .alert-primary { --bs-alert-color: var(--bs-primary-text-emphasis); --bs-alert-bg: var(--bs-primary-bg-subtle); --bs-alert-border-color: var(--bs-primary-border-subtle); --bs-alert-link-color: var(--bs-primary-text-emphasis); } .alert-secondary { --bs-alert-color: var(--bs-secondary-text-emphasis); --bs-alert-bg: var(--bs-secondary-bg-subtle); --bs-alert-border-color: var(--bs-secondary-border-subtle); --bs-alert-link-color: var(--bs-secondary-text-emphasis); } .alert-success { --bs-alert-color: var(--bs-success-text-emphasis); --bs-alert-bg: var(--bs-success-bg-subtle); --bs-alert-border-color: var(--bs-success-border-subtle); --bs-alert-link-color: var(--bs-success-text-emphasis); } .alert-info { --bs-alert-color: var(--bs-info-text-emphasis); --bs-alert-bg: var(--bs-info-bg-subtle); --bs-alert-border-color: var(--bs-info-border-subtle); --bs-alert-link-color: var(--bs-info-text-emphasis); } .alert-warning { --bs-alert-color: var(--bs-warning-text-emphasis); --bs-alert-bg: var(--bs-warning-bg-subtle); --bs-alert-border-color: var(--bs-warning-border-subtle); --bs-alert-link-color: var(--bs-warning-text-emphasis); } .alert-danger { --bs-alert-color: var(--bs-danger-text-emphasis); --bs-alert-bg: var(--bs-danger-bg-subtle); --bs-alert-border-color: var(--bs-danger-border-subtle); --bs-alert-link-color: var(--bs-danger-text-emphasis); } .alert-light { --bs-alert-color: var(--bs-light-text-emphasis); --bs-alert-bg: var(--bs-light-bg-subtle); --bs-alert-border-color: var(--bs-light-border-subtle); --bs-alert-link-color: var(--bs-light-text-emphasis); } .alert-dark { --bs-alert-color: var(--bs-dark-text-emphasis); --bs-alert-bg: var(--bs-dark-bg-subtle); --bs-alert-border-color: var(--bs-dark-border-subtle); --bs-alert-link-color: var(--bs-dark-text-emphasis); } @keyframes progress-bar-stripes { 0% { background-position-x: 1rem; } } .progress, .progress-stacked { --bs-progress-height: 1rem; --bs-progress-font-size: 0.75rem; --bs-progress-bg: var(--bs-secondary-bg); --bs-progress-border-radius: var(--bs-border-radius); --bs-progress-box-shadow: var(--bs-box-shadow-inset); --bs-progress-bar-color: #fff; --bs-progress-bar-bg: #4f46e5; --bs-progress-bar-transition: width 0.6s ease; display: flex; height: var(--bs-progress-height); overflow: hidden; font-size: var(--bs-progress-font-size); background-color: var(--bs-progress-bg); border-radius: var(--bs-progress-border-radius); } .progress-bar { display: flex; flex-direction: column; justify-content: center; overflow: hidden; color: var(--bs-progress-bar-color); text-align: center; white-space: nowrap; background-color: var(--bs-progress-bar-bg); transition: var(--bs-progress-bar-transition); } @media (prefers-reduced-motion: reduce) { .progress-bar { transition: none; } } .progress-bar-striped { background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); background-size: var(--bs-progress-height) var(--bs-progress-height); } .progress-stacked > .progress { overflow: visible; } .progress-stacked > .progress > .progress-bar { width: 100%; } .progress-bar-animated { animation: 1s linear infinite progress-bar-stripes; } @media (prefers-reduced-motion: reduce) { .progress-bar-animated { animation: none; } } .list-group { --bs-list-group-color: var(--bs-body-color); --bs-list-group-bg: var(--bs-body-bg); --bs-list-group-border-color: var(--bs-border-color); --bs-list-group-border-width: var(--bs-border-width); --bs-list-group-border-radius: var(--bs-border-radius); --bs-list-group-item-padding-x: 1rem; --bs-list-group-item-padding-y: 0.5rem; --bs-list-group-action-color: var(--bs-secondary-color); --bs-list-group-action-hover-color: var(--bs-emphasis-color); --bs-list-group-action-hover-bg: var(--bs-tertiary-bg); --bs-list-group-action-active-color: var(--bs-body-color); --bs-list-group-action-active-bg: var(--bs-secondary-bg); --bs-list-group-disabled-color: var(--bs-secondary-color); --bs-list-group-disabled-bg: var(--bs-body-bg); --bs-list-group-active-color: #fff; --bs-list-group-active-bg: #4f46e5; --bs-list-group-active-border-color: #4f46e5; display: flex; flex-direction: column; padding-left: 0; margin-bottom: 0; border-radius: var(--bs-list-group-border-radius); } .list-group-numbered { list-style-type: none; counter-reset: section; } .list-group-numbered > .list-group-item::before { content: counters(section, ".") ". "; counter-increment: section; } .list-group-item-action { width: 100%; color: var(--bs-list-group-action-color); text-align: inherit; } .list-group-item-action:hover, .list-group-item-action:focus { z-index: 1; color: var(--bs-list-group-action-hover-color); text-decoration: none; background-color: var(--bs-list-group-action-hover-bg); } .list-group-item-action:active { color: var(--bs-list-group-action-active-color); background-color: var(--bs-list-group-action-active-bg); } .list-group-item { position: relative; display: block; padding: var(--bs-list-group-item-padding-y) var(--bs-list-group-item-padding-x); color: var(--bs-list-group-color); background-color: var(--bs-list-group-bg); border: var(--bs-list-group-border-width) solid var(--bs-list-group-border-color); } .list-group-item:first-child { border-top-left-radius: inherit; border-top-right-radius: inherit; } .list-group-item:last-child { border-bottom-right-radius: inherit; border-bottom-left-radius: inherit; } .list-group-item.disabled, .list-group-item:disabled { color: var(--bs-list-group-disabled-color); pointer-events: none; background-color: var(--bs-list-group-disabled-bg); } .list-group-item.active { z-index: 2; color: var(--bs-list-group-active-color); background-color: var(--bs-list-group-active-bg); border-color: var(--bs-list-group-active-border-color); } .list-group-item + .list-group-item { border-top-width: 0; } .list-group-item + .list-group-item.active { margin-top: calc(-1 * var(--bs-list-group-border-width)); border-top-width: var(--bs-list-group-border-width); } .list-group-horizontal { flex-direction: row; } .list-group-horizontal > .list-group-item:first-child:not(:last-child) { border-bottom-left-radius: var(--bs-list-group-border-radius); border-top-right-radius: 0; } .list-group-horizontal > .list-group-item:last-child:not(:first-child) { border-top-right-radius: var(--bs-list-group-border-radius); border-bottom-left-radius: 0; } .list-group-horizontal > .list-group-item.active { margin-top: 0; } .list-group-horizontal > .list-group-item + .list-group-item { border-top-width: var(--bs-list-group-border-width); border-left-width: 0; } .list-group-horizontal > .list-group-item + .list-group-item.active { margin-left: calc(-1 * var(--bs-list-group-border-width)); border-left-width: var(--bs-list-group-border-width); } @media (min-width: 576px) { .list-group-horizontal-sm { flex-direction: row; } .list-group-horizontal-sm > .list-group-item:first-child:not(:last-child) { border-bottom-left-radius: var(--bs-list-group-border-radius); border-top-right-radius: 0; } .list-group-horizontal-sm > .list-group-item:last-child:not(:first-child) { border-top-right-radius: var(--bs-list-group-border-radius); border-bottom-left-radius: 0; } .list-group-horizontal-sm > .list-group-item.active { margin-top: 0; } .list-group-horizontal-sm > .list-group-item + .list-group-item { border-top-width: var(--bs-list-group-border-width); border-left-width: 0; } .list-group-horizontal-sm > .list-group-item + .list-group-item.active { margin-left: calc(-1 * var(--bs-list-group-border-width)); border-left-width: var(--bs-list-group-border-width); } } @media (min-width: 768px) { .list-group-horizontal-md { flex-direction: row; } .list-group-horizontal-md > .list-group-item:first-child:not(:last-child) { border-bottom-left-radius: var(--bs-list-group-border-radius); border-top-right-radius: 0; } .list-group-horizontal-md > .list-group-item:last-child:not(:first-child) { border-top-right-radius: var(--bs-list-group-border-radius); border-bottom-left-radius: 0; } .list-group-horizontal-md > .list-group-item.active { margin-top: 0; } .list-group-horizontal-md > .list-group-item + .list-group-item { border-top-width: var(--bs-list-group-border-width); border-left-width: 0; } .list-group-horizontal-md > .list-group-item + .list-group-item.active { margin-left: calc(-1 * var(--bs-list-group-border-width)); border-left-width: var(--bs-list-group-border-width); } } @media (min-width: 992px) { .list-group-horizontal-lg { flex-direction: row; } .list-group-horizontal-lg > .list-group-item:first-child:not(:last-child) { border-bottom-left-radius: var(--bs-list-group-border-radius); border-top-right-radius: 0; } .list-group-horizontal-lg > .list-group-item:last-child:not(:first-child) { border-top-right-radius: var(--bs-list-group-border-radius); border-bottom-left-radius: 0; } .list-group-horizontal-lg > .list-group-item.active { margin-top: 0; } .list-group-horizontal-lg > .list-group-item + .list-group-item { border-top-width: var(--bs-list-group-border-width); border-left-width: 0; } .list-group-horizontal-lg > .list-group-item + .list-group-item.active { margin-left: calc(-1 * var(--bs-list-group-border-width)); border-left-width: var(--bs-list-group-border-width); } } @media (min-width: 1200px) { .list-group-horizontal-xl { flex-direction: row; } .list-group-horizontal-xl > .list-group-item:first-child:not(:last-child) { border-bottom-left-radius: var(--bs-list-group-border-radius); border-top-right-radius: 0; } .list-group-horizontal-xl > .list-group-item:last-child:not(:first-child) { border-top-right-radius: var(--bs-list-group-border-radius); border-bottom-left-radius: 0; } .list-group-horizontal-xl > .list-group-item.active { margin-top: 0; } .list-group-horizontal-xl > .list-group-item + .list-group-item { border-top-width: var(--bs-list-group-border-width); border-left-width: 0; } .list-group-horizontal-xl > .list-group-item + .list-group-item.active { margin-left: calc(-1 * var(--bs-list-group-border-width)); border-left-width: var(--bs-list-group-border-width); } } @media (min-width: 1400px) { .list-group-horizontal-xxl { flex-direction: row; } .list-group-horizontal-xxl > .list-group-item:first-child:not(:last-child) { border-bottom-left-radius: var(--bs-list-group-border-radius); border-top-right-radius: 0; } .list-group-horizontal-xxl > .list-group-item:last-child:not(:first-child) { border-top-right-radius: var(--bs-list-group-border-radius); border-bottom-left-radius: 0; } .list-group-horizontal-xxl > .list-group-item.active { margin-top: 0; } .list-group-horizontal-xxl > .list-group-item + .list-group-item { border-top-width: var(--bs-list-group-border-width); border-left-width: 0; } .list-group-horizontal-xxl > .list-group-item + .list-group-item.active { margin-left: calc(-1 * var(--bs-list-group-border-width)); border-left-width: var(--bs-list-group-border-width); } } .list-group-flush { border-radius: 0; } .list-group-flush > .list-group-item { border-width: 0 0 var(--bs-list-group-border-width); } .list-group-flush > .list-group-item:last-child { border-bottom-width: 0; } .list-group-item-primary { --bs-list-group-color: var(--bs-primary-text-emphasis); --bs-list-group-bg: var(--bs-primary-bg-subtle); --bs-list-group-border-color: var(--bs-primary-border-subtle); --bs-list-group-action-hover-color: var(--bs-emphasis-color); --bs-list-group-action-hover-bg: var(--bs-primary-border-subtle); --bs-list-group-action-active-color: var(--bs-emphasis-color); --bs-list-group-action-active-bg: var(--bs-primary-border-subtle); --bs-list-group-active-color: var(--bs-primary-bg-subtle); --bs-list-group-active-bg: var(--bs-primary-text-emphasis); --bs-list-group-active-border-color: var(--bs-primary-text-emphasis); } .list-group-item-secondary { --bs-list-group-color: var(--bs-secondary-text-emphasis); --bs-list-group-bg: var(--bs-secondary-bg-subtle); --bs-list-group-border-color: var(--bs-secondary-border-subtle); --bs-list-group-action-hover-color: var(--bs-emphasis-color); --bs-list-group-action-hover-bg: var(--bs-secondary-border-subtle); --bs-list-group-action-active-color: var(--bs-emphasis-color); --bs-list-group-action-active-bg: var(--bs-secondary-border-subtle); --bs-list-group-active-color: var(--bs-secondary-bg-subtle); --bs-list-group-active-bg: var(--bs-secondary-text-emphasis); --bs-list-group-active-border-color: var(--bs-secondary-text-emphasis); } .list-group-item-success { --bs-list-group-color: var(--bs-success-text-emphasis); --bs-list-group-bg: var(--bs-success-bg-subtle); --bs-list-group-border-color: var(--bs-success-border-subtle); --bs-list-group-action-hover-color: var(--bs-emphasis-color); --bs-list-group-action-hover-bg: var(--bs-success-border-subtle); --bs-list-group-action-active-color: var(--bs-emphasis-color); --bs-list-group-action-active-bg: var(--bs-success-border-subtle); --bs-list-group-active-color: var(--bs-success-bg-subtle); --bs-list-group-active-bg: var(--bs-success-text-emphasis); --bs-list-group-active-border-color: var(--bs-success-text-emphasis); } .list-group-item-info { --bs-list-group-color: var(--bs-info-text-emphasis); --bs-list-group-bg: var(--bs-info-bg-subtle); --bs-list-group-border-color: var(--bs-info-border-subtle); --bs-list-group-action-hover-color: var(--bs-emphasis-color); --bs-list-group-action-hover-bg: var(--bs-info-border-subtle); --bs-list-group-action-active-color: var(--bs-emphasis-color); --bs-list-group-action-active-bg: var(--bs-info-border-subtle); --bs-list-group-active-color: var(--bs-info-bg-subtle); --bs-list-group-active-bg: var(--bs-info-text-emphasis); --bs-list-group-active-border-color: var(--bs-info-text-emphasis); } .list-group-item-warning { --bs-list-group-color: var(--bs-warning-text-emphasis); --bs-list-group-bg: var(--bs-warning-bg-subtle); --bs-list-group-border-color: var(--bs-warning-border-subtle); --bs-list-group-action-hover-color: var(--bs-emphasis-color); --bs-list-group-action-hover-bg: var(--bs-warning-border-subtle); --bs-list-group-action-active-color: var(--bs-emphasis-color); --bs-list-group-action-active-bg: var(--bs-warning-border-subtle); --bs-list-group-active-color: var(--bs-warning-bg-subtle); --bs-list-group-active-bg: var(--bs-warning-text-emphasis); --bs-list-group-active-border-color: var(--bs-warning-text-emphasis); } .list-group-item-danger { --bs-list-group-color: var(--bs-danger-text-emphasis); --bs-list-group-bg: var(--bs-danger-bg-subtle); --bs-list-group-border-color: var(--bs-danger-border-subtle); --bs-list-group-action-hover-color: var(--bs-emphasis-color); --bs-list-group-action-hover-bg: var(--bs-danger-border-subtle); --bs-list-group-action-active-color: var(--bs-emphasis-color); --bs-list-group-action-active-bg: var(--bs-danger-border-subtle); --bs-list-group-active-color: var(--bs-danger-bg-subtle); --bs-list-group-active-bg: var(--bs-danger-text-emphasis); --bs-list-group-active-border-color: var(--bs-danger-text-emphasis); } .list-group-item-light { --bs-list-group-color: var(--bs-light-text-emphasis); --bs-list-group-bg: var(--bs-light-bg-subtle); --bs-list-group-border-color: var(--bs-light-border-subtle); --bs-list-group-action-hover-color: var(--bs-emphasis-color); --bs-list-group-action-hover-bg: var(--bs-light-border-subtle); --bs-list-group-action-active-color: var(--bs-emphasis-color); --bs-list-group-action-active-bg: var(--bs-light-border-subtle); --bs-list-group-active-color: var(--bs-light-bg-subtle); --bs-list-group-active-bg: var(--bs-light-text-emphasis); --bs-list-group-active-border-color: var(--bs-light-text-emphasis); } .list-group-item-dark { --bs-list-group-color: var(--bs-dark-text-emphasis); --bs-list-group-bg: var(--bs-dark-bg-subtle); --bs-list-group-border-color: var(--bs-dark-border-subtle); --bs-list-group-action-hover-color: var(--bs-emphasis-color); --bs-list-group-action-hover-bg: var(--bs-dark-border-subtle); --bs-list-group-action-active-color: var(--bs-emphasis-color); --bs-list-group-action-active-bg: var(--bs-dark-border-subtle); --bs-list-group-active-color: var(--bs-dark-bg-subtle); --bs-list-group-active-bg: var(--bs-dark-text-emphasis); --bs-list-group-active-border-color: var(--bs-dark-text-emphasis); } .btn-close { --bs-btn-close-color: #000; --bs-btn-close-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414z'/%3e%3c/svg%3e"); --bs-btn-close-opacity: 0.5; --bs-btn-close-hover-opacity: 0.75; --bs-btn-close-focus-shadow: 0 0 0 0.25rem rgba(79, 70, 229, 0.25); --bs-btn-close-focus-opacity: 1; --bs-btn-close-disabled-opacity: 0.25; --bs-btn-close-white-filter: invert(1) grayscale(100%) brightness(200%); box-sizing: content-box; width: 1em; height: 1em; padding: 0.25em 0.25em; color: var(--bs-btn-close-color); background: transparent var(--bs-btn-close-bg) center/1em auto no-repeat; border: 0; border-radius: 0.375rem; opacity: var(--bs-btn-close-opacity); } .btn-close:hover { color: var(--bs-btn-close-color); text-decoration: none; opacity: var(--bs-btn-close-hover-opacity); } .btn-close:focus { outline: 0; box-shadow: var(--bs-btn-close-focus-shadow); opacity: var(--bs-btn-close-focus-opacity); } .btn-close:disabled, .btn-close.disabled { pointer-events: none; user-select: none; opacity: var(--bs-btn-close-disabled-opacity); } .btn-close-white { filter: var(--bs-btn-close-white-filter); } [data-bs-theme="dark"] .btn-close { filter: var(--bs-btn-close-white-filter); } .toast { --bs-toast-zindex: 1090; --bs-toast-padding-x: 0.75rem; --bs-toast-padding-y: 0.5rem; --bs-toast-spacing: 3rem; --bs-toast-max-width: 350px; --bs-toast-font-size: 0.875rem; --bs-toast-color: ; --bs-toast-bg: rgba(var(--bs-body-bg-rgb), 0.85); --bs-toast-border-width: var(--bs-border-width); --bs-toast-border-color: var(--bs-border-color-translucent); --bs-toast-border-radius: var(--bs-border-radius); --bs-toast-box-shadow: var(--bs-box-shadow); --bs-toast-header-color: var(--bs-secondary-color); --bs-toast-header-bg: rgba(var(--bs-body-bg-rgb), 0.85); --bs-toast-header-border-color: var(--bs-border-color-translucent); width: var(--bs-toast-max-width); max-width: 100%; font-size: var(--bs-toast-font-size); color: var(--bs-toast-color); pointer-events: auto; background-color: var(--bs-toast-bg); background-clip: padding-box; border: var(--bs-toast-border-width) solid var(--bs-toast-border-color); box-shadow: var(--bs-toast-box-shadow); border-radius: var(--bs-toast-border-radius); } .toast.showing { opacity: 0; } .toast:not(.show) { display: none; } .toast-container { --bs-toast-zindex: 1090; position: absolute; z-index: var(--bs-toast-zindex); width: max-content; max-width: 100%; pointer-events: none; } .toast-container > :not(:last-child) { margin-bottom: var(--bs-toast-spacing); } .toast-header { display: flex; align-items: center; padding: var(--bs-toast-padding-y) var(--bs-toast-padding-x); color: var(--bs-toast-header-color); background-color: var(--bs-toast-header-bg); background-clip: padding-box; border-bottom: var(--bs-toast-border-width) solid var(--bs-toast-header-border-color); border-top-left-radius: calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width)); border-top-right-radius: calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width)); } .toast-header .btn-close { margin-right: calc(-.5 * var(--bs-toast-padding-x)); margin-left: var(--bs-toast-padding-x); } .toast-body { padding: var(--bs-toast-padding-x); word-wrap: break-word; } .modal { --bs-modal-zindex: 1055; --bs-modal-width: 500px; --bs-modal-padding: 1rem; --bs-modal-margin: 0.5rem; --bs-modal-color: ; --bs-modal-bg: var(--bs-body-bg); --bs-modal-border-color: var(--bs-border-color-translucent); --bs-modal-border-width: var(--bs-border-width); --bs-modal-border-radius: var(--bs-border-radius-lg); --bs-modal-box-shadow: var(--bs-box-shadow-sm); --bs-modal-inner-border-radius: calc(var(--bs-border-radius-lg) - (var(--bs-border-width))); --bs-modal-header-padding-x: 1rem; --bs-modal-header-padding-y: 1rem; --bs-modal-header-padding: 1rem 1rem; --bs-modal-header-border-color: var(--bs-border-color); --bs-modal-header-border-width: var(--bs-border-width); --bs-modal-title-line-height: 1.5; --bs-modal-footer-gap: 0.5rem; --bs-modal-footer-bg: ; --bs-modal-footer-border-color: var(--bs-border-color); --bs-modal-footer-border-width: var(--bs-border-width); position: fixed; top: 0; left: 0; z-index: var(--bs-modal-zindex); display: none; width: 100%; height: 100%; overflow-x: hidden; overflow-y: auto; outline: 0; } .modal-dialog { position: relative; width: auto; margin: var(--bs-modal-margin); pointer-events: none; } .modal.fade .modal-dialog { transition: transform 0.3s ease-out; transform: translate(0, -50px); } @media (prefers-reduced-motion: reduce) { .modal.fade .modal-dialog { transition: none; } } .modal.show .modal-dialog { transform: none; } .modal.modal-static .modal-dialog { transform: scale(1.02); } .modal-dialog-scrollable { height: calc(100% - var(--bs-modal-margin) * 2); } .modal-dialog-scrollable .modal-content { max-height: 100%; overflow: hidden; } .modal-dialog-scrollable .modal-body { overflow-y: auto; } .modal-dialog-centered { display: flex; align-items: center; min-height: calc(100% - var(--bs-modal-margin) * 2); } .modal-content { position: relative; display: flex; flex-direction: column; width: 100%; color: var(--bs-modal-color); pointer-events: auto; background-color: var(--bs-modal-bg); background-clip: padding-box; border: var(--bs-modal-border-width) solid var(--bs-modal-border-color); border-radius: var(--bs-modal-border-radius); outline: 0; } .modal-backdrop { --bs-backdrop-zindex: 1050; --bs-backdrop-bg: #000; --bs-backdrop-opacity: 0.5; position: fixed; top: 0; left: 0; z-index: var(--bs-backdrop-zindex); width: 100vw; height: 100vh; background-color: var(--bs-backdrop-bg); } .modal-backdrop.fade { opacity: 0; } .modal-backdrop.show { opacity: var(--bs-backdrop-opacity); } .modal-header { display: flex; flex-shrink: 0; align-items: center; padding: var(--bs-modal-header-padding); border-bottom: var(--bs-modal-header-border-width) solid var(--bs-modal-header-border-color); border-top-left-radius: var(--bs-modal-inner-border-radius); border-top-right-radius: var(--bs-modal-inner-border-radius); } .modal-header .btn-close { padding: calc(var(--bs-modal-header-padding-y) * .5) calc(var(--bs-modal-header-padding-x) * .5); margin: calc(-.5 * var(--bs-modal-header-padding-y)) calc(-.5 * var(--bs-modal-header-padding-x)) calc(-.5 * var(--bs-modal-header-padding-y)) auto; } .modal-title { margin-bottom: 0; line-height: var(--bs-modal-title-line-height); } .modal-body { position: relative; flex: 1 1 auto; padding: var(--bs-modal-padding); } .modal-footer { display: flex; flex-shrink: 0; flex-wrap: wrap; align-items: center; justify-content: flex-end; padding: calc(var(--bs-modal-padding) - var(--bs-modal-footer-gap) * .5); background-color: var(--bs-modal-footer-bg); border-top: var(--bs-modal-footer-border-width) solid var(--bs-modal-footer-border-color); border-bottom-right-radius: var(--bs-modal-inner-border-radius); border-bottom-left-radius: var(--bs-modal-inner-border-radius); } .modal-footer > * { margin: calc(var(--bs-modal-footer-gap) * .5); } @media (min-width: 576px) { .modal { --bs-modal-margin: 1.75rem; --bs-modal-box-shadow: var(--bs-box-shadow); } .modal-dialog { max-width: var(--bs-modal-width); margin-right: auto; margin-left: auto; } .modal-sm { --bs-modal-width: 300px; } } @media (min-width: 992px) { .modal-lg, .modal-xl { --bs-modal-width: 800px; } } @media (min-width: 1200px) { .modal-xl { --bs-modal-width: 1140px; } } .modal-fullscreen { width: 100vw; max-width: none; height: 100%; margin: 0; } .modal-fullscreen .modal-content { height: 100%; border: 0; border-radius: 0; } .modal-fullscreen .modal-header, .modal-fullscreen .modal-footer { border-radius: 0; } .modal-fullscreen .modal-body { overflow-y: auto; } @media (max-width: 575.98px) { .modal-fullscreen-sm-down { width: 100vw; max-width: none; height: 100%; margin: 0; } .modal-fullscreen-sm-down .modal-content { height: 100%; border: 0; border-radius: 0; } .modal-fullscreen-sm-down .modal-header, .modal-fullscreen-sm-down .modal-footer { border-radius: 0; } .modal-fullscreen-sm-down .modal-body { overflow-y: auto; } } @media (max-width: 767.98px) { .modal-fullscreen-md-down { width: 100vw; max-width: none; height: 100%; margin: 0; } .modal-fullscreen-md-down .modal-content { height: 100%; border: 0; border-radius: 0; } .modal-fullscreen-md-down .modal-header, .modal-fullscreen-md-down .modal-footer { border-radius: 0; } .modal-fullscreen-md-down .modal-body { overflow-y: auto; } } @media (max-width: 991.98px) { .modal-fullscreen-lg-down { width: 100vw; max-width: none; height: 100%; margin: 0; } .modal-fullscreen-lg-down .modal-content { height: 100%; border: 0; border-radius: 0; } .modal-fullscreen-lg-down .modal-header, .modal-fullscreen-lg-down .modal-footer { border-radius: 0; } .modal-fullscreen-lg-down .modal-body { overflow-y: auto; } } @media (max-width: 1199.98px) { .modal-fullscreen-xl-down { width: 100vw; max-width: none; height: 100%; margin: 0; } .modal-fullscreen-xl-down .modal-content { height: 100%; border: 0; border-radius: 0; } .modal-fullscreen-xl-down .modal-header, .modal-fullscreen-xl-down .modal-footer { border-radius: 0; } .modal-fullscreen-xl-down .modal-body { overflow-y: auto; } } @media (max-width: 1399.98px) { .modal-fullscreen-xxl-down { width: 100vw; max-width: none; height: 100%; margin: 0; } .modal-fullscreen-xxl-down .modal-content { height: 100%; border: 0; border-radius: 0; } .modal-fullscreen-xxl-down .modal-header, .modal-fullscreen-xxl-down .modal-footer { border-radius: 0; } .modal-fullscreen-xxl-down .modal-body { overflow-y: auto; } } .tooltip { --bs-tooltip-zindex: 1080; --bs-tooltip-max-width: 200px; --bs-tooltip-padding-x: 0.5rem; --bs-tooltip-padding-y: 0.25rem; --bs-tooltip-margin: ; --bs-tooltip-font-size: 0.875rem; --bs-tooltip-color: var(--bs-body-bg); --bs-tooltip-bg: var(--bs-emphasis-color); --bs-tooltip-border-radius: var(--bs-border-radius); --bs-tooltip-opacity: 0.9; --bs-tooltip-arrow-width: 0.8rem; --bs-tooltip-arrow-height: 0.4rem; z-index: var(--bs-tooltip-zindex); display: block; margin: var(--bs-tooltip-margin); font-family: var(--bs-font-sans-serif); font-style: normal; font-weight: 400; line-height: 1.5; text-align: left; text-align: start; text-decoration: none; text-shadow: none; text-transform: none; letter-spacing: normal; word-break: normal; white-space: normal; word-spacing: normal; line-break: auto; font-size: var(--bs-tooltip-font-size); word-wrap: break-word; opacity: 0; } .tooltip.show { opacity: var(--bs-tooltip-opacity); } .tooltip .tooltip-arrow { display: block; width: var(--bs-tooltip-arrow-width); height: var(--bs-tooltip-arrow-height); } .tooltip .tooltip-arrow::before { position: absolute; content: ""; border-color: transparent; border-style: solid; } .bs-tooltip-top .tooltip-arrow, .bs-tooltip-auto[data-popper-placement^="top"] .tooltip-arrow { bottom: calc(-1 * var(--bs-tooltip-arrow-height)); } .bs-tooltip-top .tooltip-arrow::before, .bs-tooltip-auto[data-popper-placement^="top"] .tooltip-arrow::before { top: -1px; border-width: var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * .5) 0; border-top-color: var(--bs-tooltip-bg); } /* rtl:begin:ignore */ .bs-tooltip-end .tooltip-arrow, .bs-tooltip-auto[data-popper-placement^="right"] .tooltip-arrow { left: calc(-1 * var(--bs-tooltip-arrow-height)); width: var(--bs-tooltip-arrow-height); height: var(--bs-tooltip-arrow-width); } .bs-tooltip-end .tooltip-arrow::before, .bs-tooltip-auto[data-popper-placement^="right"] .tooltip-arrow::before { right: -1px; border-width: calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * .5) 0; border-right-color: var(--bs-tooltip-bg); } /* rtl:end:ignore */ .bs-tooltip-bottom .tooltip-arrow, .bs-tooltip-auto[data-popper-placement^="bottom"] .tooltip-arrow { top: calc(-1 * var(--bs-tooltip-arrow-height)); } .bs-tooltip-bottom .tooltip-arrow::before, .bs-tooltip-auto[data-popper-placement^="bottom"] .tooltip-arrow::before { bottom: -1px; border-width: 0 calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height); border-bottom-color: var(--bs-tooltip-bg); } /* rtl:begin:ignore */ .bs-tooltip-start .tooltip-arrow, .bs-tooltip-auto[data-popper-placement^="left"] .tooltip-arrow { right: calc(-1 * var(--bs-tooltip-arrow-height)); width: var(--bs-tooltip-arrow-height); height: var(--bs-tooltip-arrow-width); } .bs-tooltip-start .tooltip-arrow::before, .bs-tooltip-auto[data-popper-placement^="left"] .tooltip-arrow::before { left: -1px; border-width: calc(var(--bs-tooltip-arrow-width) * .5) 0 calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height); border-left-color: var(--bs-tooltip-bg); } /* rtl:end:ignore */ .tooltip-inner { max-width: var(--bs-tooltip-max-width); padding: var(--bs-tooltip-padding-y) var(--bs-tooltip-padding-x); color: var(--bs-tooltip-color); text-align: center; background-color: var(--bs-tooltip-bg); border-radius: var(--bs-tooltip-border-radius); } .popover { --bs-popover-zindex: 1070; --bs-popover-max-width: 276px; --bs-popover-font-size: 0.875rem; --bs-popover-bg: var(--bs-body-bg); --bs-popover-border-width: var(--bs-border-width); --bs-popover-border-color: var(--bs-border-color-translucent); --bs-popover-border-radius: var(--bs-border-radius-lg); --bs-popover-inner-border-radius: calc(var(--bs-border-radius-lg) - var(--bs-border-width)); --bs-popover-box-shadow: var(--bs-box-shadow); --bs-popover-header-padding-x: 1rem; --bs-popover-header-padding-y: 0.5rem; --bs-popover-header-font-size: 1rem; --bs-popover-header-color: inherit; --bs-popover-header-bg: var(--bs-secondary-bg); --bs-popover-body-padding-x: 1rem; --bs-popover-body-padding-y: 1rem; --bs-popover-body-color: var(--bs-body-color); --bs-popover-arrow-width: 1rem; --bs-popover-arrow-height: 0.5rem; --bs-popover-arrow-border: var(--bs-popover-border-color); z-index: var(--bs-popover-zindex); display: block; max-width: var(--bs-popover-max-width); font-family: var(--bs-font-sans-serif); font-style: normal; font-weight: 400; line-height: 1.5; text-align: left; text-align: start; text-decoration: none; text-shadow: none; text-transform: none; letter-spacing: normal; word-break: normal; white-space: normal; word-spacing: normal; line-break: auto; font-size: var(--bs-popover-font-size); word-wrap: break-word; background-color: var(--bs-popover-bg); background-clip: padding-box; border: var(--bs-popover-border-width) solid var(--bs-popover-border-color); border-radius: var(--bs-popover-border-radius); } .popover .popover-arrow { display: block; width: var(--bs-popover-arrow-width); height: var(--bs-popover-arrow-height); } .popover .popover-arrow::before, .popover .popover-arrow::after { position: absolute; display: block; content: ""; border-color: transparent; border-style: solid; border-width: 0; } .bs-popover-top > .popover-arrow, .bs-popover-auto[data-popper-placement^="top"] > .popover-arrow { bottom: calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width)); } .bs-popover-top > .popover-arrow::before, .bs-popover-auto[data-popper-placement^="top"] > .popover-arrow::before, .bs-popover-top > .popover-arrow::after, .bs-popover-auto[data-popper-placement^="top"] > .popover-arrow::after { border-width: var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * .5) 0; } .bs-popover-top > .popover-arrow::before, .bs-popover-auto[data-popper-placement^="top"] > .popover-arrow::before { bottom: 0; border-top-color: var(--bs-popover-arrow-border); } .bs-popover-top > .popover-arrow::after, .bs-popover-auto[data-popper-placement^="top"] > .popover-arrow::after { bottom: var(--bs-popover-border-width); border-top-color: var(--bs-popover-bg); } /* rtl:begin:ignore */ .bs-popover-end > .popover-arrow, .bs-popover-auto[data-popper-placement^="right"] > .popover-arrow { left: calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width)); width: var(--bs-popover-arrow-height); height: var(--bs-popover-arrow-width); } .bs-popover-end > .popover-arrow::before, .bs-popover-auto[data-popper-placement^="right"] > .popover-arrow::before, .bs-popover-end > .popover-arrow::after, .bs-popover-auto[data-popper-placement^="right"] > .popover-arrow::after { border-width: calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * .5) 0; } .bs-popover-end > .popover-arrow::before, .bs-popover-auto[data-popper-placement^="right"] > .popover-arrow::before { left: 0; border-right-color: var(--bs-popover-arrow-border); } .bs-popover-end > .popover-arrow::after, .bs-popover-auto[data-popper-placement^="right"] > .popover-arrow::after { left: var(--bs-popover-border-width); border-right-color: var(--bs-popover-bg); } /* rtl:end:ignore */ .bs-popover-bottom > .popover-arrow, .bs-popover-auto[data-popper-placement^="bottom"] > .popover-arrow { top: calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width)); } .bs-popover-bottom > .popover-arrow::before, .bs-popover-auto[data-popper-placement^="bottom"] > .popover-arrow::before, .bs-popover-bottom > .popover-arrow::after, .bs-popover-auto[data-popper-placement^="bottom"] > .popover-arrow::after { border-width: 0 calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height); } .bs-popover-bottom > .popover-arrow::before, .bs-popover-auto[data-popper-placement^="bottom"] > .popover-arrow::before { top: 0; border-bottom-color: var(--bs-popover-arrow-border); } .bs-popover-bottom > .popover-arrow::after, .bs-popover-auto[data-popper-placement^="bottom"] > .popover-arrow::after { top: var(--bs-popover-border-width); border-bottom-color: var(--bs-popover-bg); } .bs-popover-bottom .popover-header::before, .bs-popover-auto[data-popper-placement^="bottom"] .popover-header::before { position: absolute; top: 0; left: 50%; display: block; width: var(--bs-popover-arrow-width); margin-left: calc(-.5 * var(--bs-popover-arrow-width)); content: ""; border-bottom: var(--bs-popover-border-width) solid var(--bs-popover-header-bg); } /* rtl:begin:ignore */ .bs-popover-start > .popover-arrow, .bs-popover-auto[data-popper-placement^="left"] > .popover-arrow { right: calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width)); width: var(--bs-popover-arrow-height); height: var(--bs-popover-arrow-width); } .bs-popover-start > .popover-arrow::before, .bs-popover-auto[data-popper-placement^="left"] > .popover-arrow::before, .bs-popover-start > .popover-arrow::after, .bs-popover-auto[data-popper-placement^="left"] > .popover-arrow::after { border-width: calc(var(--bs-popover-arrow-width) * .5) 0 calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height); } .bs-popover-start > .popover-arrow::before, .bs-popover-auto[data-popper-placement^="left"] > .popover-arrow::before { right: 0; border-left-color: var(--bs-popover-arrow-border); } .bs-popover-start > .popover-arrow::after, .bs-popover-auto[data-popper-placement^="left"] > .popover-arrow::after { right: var(--bs-popover-border-width); border-left-color: var(--bs-popover-bg); } /* rtl:end:ignore */ .popover-header { padding: var(--bs-popover-header-padding-y) var(--bs-popover-header-padding-x); margin-bottom: 0; font-size: var(--bs-popover-header-font-size); color: var(--bs-popover-header-color); background-color: var(--bs-popover-header-bg); border-bottom: var(--bs-popover-border-width) solid var(--bs-popover-border-color); border-top-left-radius: var(--bs-popover-inner-border-radius); border-top-right-radius: var(--bs-popover-inner-border-radius); } .popover-header:empty { display: none; } .popover-body { padding: var(--bs-popover-body-padding-y) var(--bs-popover-body-padding-x); color: var(--bs-popover-body-color); } .carousel { position: relative; } .carousel.pointer-event { touch-action: pan-y; } .carousel-inner { position: relative; width: 100%; overflow: hidden; } .carousel-inner::after { display: block; clear: both; content: ""; } .carousel-item { position: relative; display: none; float: left; width: 100%; margin-right: -100%; backface-visibility: hidden; transition: transform 0.6s ease-in-out; } @media (prefers-reduced-motion: reduce) { .carousel-item { transition: none; } } .carousel-item.active, .carousel-item-next, .carousel-item-prev { display: block; } .carousel-item-next:not(.carousel-item-start), .active.carousel-item-end { transform: translateX(100%); } .carousel-item-prev:not(.carousel-item-end), .active.carousel-item-start { transform: translateX(-100%); } .carousel-fade .carousel-item { opacity: 0; transition-property: opacity; transform: none; } .carousel-fade .carousel-item.active, .carousel-fade .carousel-item-next.carousel-item-start, .carousel-fade .carousel-item-prev.carousel-item-end { z-index: 1; opacity: 1; } .carousel-fade .active.carousel-item-start, .carousel-fade .active.carousel-item-end { z-index: 0; opacity: 0; transition: opacity 0s 0.6s; } @media (prefers-reduced-motion: reduce) { .carousel-fade .active.carousel-item-start, .carousel-fade .active.carousel-item-end { transition: none; } } .carousel-control-prev, .carousel-control-next { position: absolute; top: 0; bottom: 0; z-index: 1; display: flex; align-items: center; justify-content: center; width: 15%; padding: 0; color: #fff; text-align: center; background: none; border: 0; opacity: 0.5; transition: opacity 0.15s ease; } @media (prefers-reduced-motion: reduce) { .carousel-control-prev, .carousel-control-next { transition: none; } } .carousel-control-prev:hover, .carousel-control-prev:focus, .carousel-control-next:hover, .carousel-control-next:focus { color: #fff; text-decoration: none; outline: 0; opacity: 0.9; } .carousel-control-prev { left: 0; } .carousel-control-next { right: 0; } .carousel-control-prev-icon, .carousel-control-next-icon { display: inline-block; width: 2rem; height: 2rem; background-repeat: no-repeat; background-position: 50%; background-size: 100% 100%; } .carousel-control-prev-icon { background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z'/%3e%3c/svg%3e") /*rtl:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e")*/; } .carousel-control-next-icon { background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e") /*rtl:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z'/%3e%3c/svg%3e")*/; } .carousel-indicators { position: absolute; right: 0; bottom: 0; left: 0; z-index: 2; display: flex; justify-content: center; padding: 0; margin-right: 15%; margin-bottom: 1rem; margin-left: 15%; } .carousel-indicators [data-bs-target] { box-sizing: content-box; flex: 0 1 auto; width: 30px; height: 3px; padding: 0; margin-right: 3px; margin-left: 3px; text-indent: -999px; cursor: pointer; background-color: #fff; background-clip: padding-box; border: 0; border-top: 10px solid transparent; border-bottom: 10px solid transparent; opacity: 0.5; transition: opacity 0.6s ease; } @media (prefers-reduced-motion: reduce) { .carousel-indicators [data-bs-target] { transition: none; } } .carousel-indicators .active { opacity: 1; } .carousel-caption { position: absolute; right: 15%; bottom: 1.25rem; left: 15%; padding-top: 1.25rem; padding-bottom: 1.25rem; color: #fff; text-align: center; } .carousel-dark .carousel-control-prev-icon, .carousel-dark .carousel-control-next-icon { filter: invert(1) grayscale(100); } .carousel-dark .carousel-indicators [data-bs-target] { background-color: #000; } .carousel-dark .carousel-caption { color: #000; } [data-bs-theme="dark"] .carousel .carousel-control-prev-icon, [data-bs-theme="dark"] .carousel .carousel-control-next-icon, [data-bs-theme="dark"].carousel .carousel-control-prev-icon, [data-bs-theme="dark"].carousel .carousel-control-next-icon { filter: invert(1) grayscale(100); } [data-bs-theme="dark"] .carousel .carousel-indicators [data-bs-target], [data-bs-theme="dark"].carousel .carousel-indicators [data-bs-target] { background-color: #000; } [data-bs-theme="dark"] .carousel .carousel-caption, [data-bs-theme="dark"].carousel .carousel-caption { color: #000; } .spinner-grow, .spinner-border { display: inline-block; width: var(--bs-spinner-width); height: var(--bs-spinner-height); vertical-align: var(--bs-spinner-vertical-align); border-radius: 50%; animation: var(--bs-spinner-animation-speed) linear infinite var(--bs-spinner-animation-name); } @keyframes spinner-border { to { transform: rotate(360deg) /* rtl:ignore */; } } .spinner-border { --bs-spinner-width: 2rem; --bs-spinner-height: 2rem; --bs-spinner-vertical-align: -0.125em; --bs-spinner-border-width: 0.25em; --bs-spinner-animation-speed: 0.75s; --bs-spinner-animation-name: spinner-border; border: var(--bs-spinner-border-width) solid currentcolor; border-right-color: transparent; } .spinner-border-sm { --bs-spinner-width: 1rem; --bs-spinner-height: 1rem; --bs-spinner-border-width: 0.2em; } @keyframes spinner-grow { 0% { transform: scale(0); } 50% { opacity: 1; transform: none; } } .spinner-grow { --bs-spinner-width: 2rem; --bs-spinner-height: 2rem; --bs-spinner-vertical-align: -0.125em; --bs-spinner-animation-speed: 0.75s; --bs-spinner-animation-name: spinner-grow; background-color: currentcolor; opacity: 0; } .spinner-grow-sm { --bs-spinner-width: 1rem; --bs-spinner-height: 1rem; } @media (prefers-reduced-motion: reduce) { .spinner-border, .spinner-grow { --bs-spinner-animation-speed: 1.5s; } } .offcanvas, .offcanvas-xxl, .offcanvas-xl, .offcanvas-lg, .offcanvas-md, .offcanvas-sm { --bs-offcanvas-zindex: 1045; --bs-offcanvas-width: 332px; --bs-offcanvas-height: 30vh; --bs-offcanvas-padding-x: 1rem; --bs-offcanvas-padding-y: 1rem; --bs-offcanvas-color: var(--bs-body-color); --bs-offcanvas-bg: var(--bs-body-bg); --bs-offcanvas-border-width: var(--bs-border-width); --bs-offcanvas-border-color: var(--bs-border-color-translucent); --bs-offcanvas-box-shadow: var(--bs-box-shadow-sm); --bs-offcanvas-transition: transform 0.3s ease-in-out; --bs-offcanvas-title-line-height: 1.5; } @media (max-width: 575.98px) { .offcanvas-sm { position: fixed; bottom: 0; z-index: var(--bs-offcanvas-zindex); display: flex; flex-direction: column; max-width: 100%; color: var(--bs-offcanvas-color); visibility: hidden; background-color: var(--bs-offcanvas-bg); background-clip: padding-box; outline: 0; transition: var(--bs-offcanvas-transition); } } @media (max-width: 575.98px) and (prefers-reduced-motion: reduce) { .offcanvas-sm { transition: none; } } @media (max-width: 575.98px) { .offcanvas-sm.offcanvas-start { top: 0; left: 0; width: var(--bs-offcanvas-width); border-right: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color); transform: translateX(-100%); } .offcanvas-sm.offcanvas-end { top: 0; right: 0; width: var(--bs-offcanvas-width); border-left: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color); transform: translateX(100%); } .offcanvas-sm.offcanvas-top { top: 0; right: 0; left: 0; height: var(--bs-offcanvas-height); max-height: 100%; border-bottom: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color); transform: translateY(-100%); } .offcanvas-sm.offcanvas-bottom { right: 0; left: 0; height: var(--bs-offcanvas-height); max-height: 100%; border-top: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color); transform: translateY(100%); } .offcanvas-sm.showing, .offcanvas-sm.show:not(.hiding) { transform: none; } .offcanvas-sm.showing, .offcanvas-sm.hiding, .offcanvas-sm.show { visibility: visible; } } @media (min-width: 576px) { .offcanvas-sm { --bs-offcanvas-height: auto; --bs-offcanvas-border-width: 0; background-color: transparent !important; } .offcanvas-sm .offcanvas-header { display: none; } .offcanvas-sm .offcanvas-body { display: flex; flex-grow: 0; padding: 0; overflow-y: visible; background-color: transparent !important; } } @media (max-width: 767.98px) { .offcanvas-md { position: fixed; bottom: 0; z-index: var(--bs-offcanvas-zindex); display: flex; flex-direction: column; max-width: 100%; color: var(--bs-offcanvas-color); visibility: hidden; background-color: var(--bs-offcanvas-bg); background-clip: padding-box; outline: 0; transition: var(--bs-offcanvas-transition); } } @media (max-width: 767.98px) and (prefers-reduced-motion: reduce) { .offcanvas-md { transition: none; } } @media (max-width: 767.98px) { .offcanvas-md.offcanvas-start { top: 0; left: 0; width: var(--bs-offcanvas-width); border-right: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color); transform: translateX(-100%); } .offcanvas-md.offcanvas-end { top: 0; right: 0; width: var(--bs-offcanvas-width); border-left: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color); transform: translateX(100%); } .offcanvas-md.offcanvas-top { top: 0; right: 0; left: 0; height: var(--bs-offcanvas-height); max-height: 100%; border-bottom: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color); transform: translateY(-100%); } .offcanvas-md.offcanvas-bottom { right: 0; left: 0; height: var(--bs-offcanvas-height); max-height: 100%; border-top: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color); transform: translateY(100%); } .offcanvas-md.showing, .offcanvas-md.show:not(.hiding) { transform: none; } .offcanvas-md.showing, .offcanvas-md.hiding, .offcanvas-md.show { visibility: visible; } } @media (min-width: 768px) { .offcanvas-md { --bs-offcanvas-height: auto; --bs-offcanvas-border-width: 0; background-color: transparent !important; } .offcanvas-md .offcanvas-header { display: none; } .offcanvas-md .offcanvas-body { display: flex; flex-grow: 0; padding: 0; overflow-y: visible; background-color: transparent !important; } } @media (max-width: 991.98px) { .offcanvas-lg { position: fixed; bottom: 0; z-index: var(--bs-offcanvas-zindex); display: flex; flex-direction: column; max-width: 100%; color: var(--bs-offcanvas-color); visibility: hidden; background-color: var(--bs-offcanvas-bg); background-clip: padding-box; outline: 0; transition: var(--bs-offcanvas-transition); } } @media (max-width: 991.98px) and (prefers-reduced-motion: reduce) { .offcanvas-lg { transition: none; } } @media (max-width: 991.98px) { .offcanvas-lg.offcanvas-start { top: 0; left: 0; width: var(--bs-offcanvas-width); border-right: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color); transform: translateX(-100%); } .offcanvas-lg.offcanvas-end { top: 0; right: 0; width: var(--bs-offcanvas-width); border-left: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color); transform: translateX(100%); } .offcanvas-lg.offcanvas-top { top: 0; right: 0; left: 0; height: var(--bs-offcanvas-height); max-height: 100%; border-bottom: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color); transform: translateY(-100%); } .offcanvas-lg.offcanvas-bottom { right: 0; left: 0; height: var(--bs-offcanvas-height); max-height: 100%; border-top: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color); transform: translateY(100%); } .offcanvas-lg.showing, .offcanvas-lg.show:not(.hiding) { transform: none; } .offcanvas-lg.showing, .offcanvas-lg.hiding, .offcanvas-lg.show { visibility: visible; } } @media (min-width: 992px) { .offcanvas-lg { --bs-offcanvas-height: auto; --bs-offcanvas-border-width: 0; background-color: transparent !important; } .offcanvas-lg .offcanvas-header { display: none; } .offcanvas-lg .offcanvas-body { display: flex; flex-grow: 0; padding: 0; overflow-y: visible; background-color: transparent !important; } } @media (max-width: 1199.98px) { .offcanvas-xl { position: fixed; bottom: 0; z-index: var(--bs-offcanvas-zindex); display: flex; flex-direction: column; max-width: 100%; color: var(--bs-offcanvas-color); visibility: hidden; background-color: var(--bs-offcanvas-bg); background-clip: padding-box; outline: 0; transition: var(--bs-offcanvas-transition); } } @media (max-width: 1199.98px) and (prefers-reduced-motion: reduce) { .offcanvas-xl { transition: none; } } @media (max-width: 1199.98px) { .offcanvas-xl.offcanvas-start { top: 0; left: 0; width: var(--bs-offcanvas-width); border-right: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color); transform: translateX(-100%); } .offcanvas-xl.offcanvas-end { top: 0; right: 0; width: var(--bs-offcanvas-width); border-left: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color); transform: translateX(100%); } .offcanvas-xl.offcanvas-top { top: 0; right: 0; left: 0; height: var(--bs-offcanvas-height); max-height: 100%; border-bottom: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color); transform: translateY(-100%); } .offcanvas-xl.offcanvas-bottom { right: 0; left: 0; height: var(--bs-offcanvas-height); max-height: 100%; border-top: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color); transform: translateY(100%); } .offcanvas-xl.showing, .offcanvas-xl.show:not(.hiding) { transform: none; } .offcanvas-xl.showing, .offcanvas-xl.hiding, .offcanvas-xl.show { visibility: visible; } } @media (min-width: 1200px) { .offcanvas-xl { --bs-offcanvas-height: auto; --bs-offcanvas-border-width: 0; background-color: transparent !important; } .offcanvas-xl .offcanvas-header { display: none; } .offcanvas-xl .offcanvas-body { display: flex; flex-grow: 0; padding: 0; overflow-y: visible; background-color: transparent !important; } } @media (max-width: 1399.98px) { .offcanvas-xxl { position: fixed; bottom: 0; z-index: var(--bs-offcanvas-zindex); display: flex; flex-direction: column; max-width: 100%; color: var(--bs-offcanvas-color); visibility: hidden; background-color: var(--bs-offcanvas-bg); background-clip: padding-box; outline: 0; transition: var(--bs-offcanvas-transition); } } @media (max-width: 1399.98px) and (prefers-reduced-motion: reduce) { .offcanvas-xxl { transition: none; } } @media (max-width: 1399.98px) { .offcanvas-xxl.offcanvas-start { top: 0; left: 0; width: var(--bs-offcanvas-width); border-right: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color); transform: translateX(-100%); } .offcanvas-xxl.offcanvas-end { top: 0; right: 0; width: var(--bs-offcanvas-width); border-left: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color); transform: translateX(100%); } .offcanvas-xxl.offcanvas-top { top: 0; right: 0; left: 0; height: var(--bs-offcanvas-height); max-height: 100%; border-bottom: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color); transform: translateY(-100%); } .offcanvas-xxl.offcanvas-bottom { right: 0; left: 0; height: var(--bs-offcanvas-height); max-height: 100%; border-top: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color); transform: translateY(100%); } .offcanvas-xxl.showing, .offcanvas-xxl.show:not(.hiding) { transform: none; } .offcanvas-xxl.showing, .offcanvas-xxl.hiding, .offcanvas-xxl.show { visibility: visible; } } @media (min-width: 1400px) { .offcanvas-xxl { --bs-offcanvas-height: auto; --bs-offcanvas-border-width: 0; background-color: transparent !important; } .offcanvas-xxl .offcanvas-header { display: none; } .offcanvas-xxl .offcanvas-body { display: flex; flex-grow: 0; padding: 0; overflow-y: visible; background-color: transparent !important; } } .offcanvas { position: fixed; bottom: 0; z-index: var(--bs-offcanvas-zindex); display: flex; flex-direction: column; max-width: 100%; color: var(--bs-offcanvas-color); visibility: hidden; background-color: var(--bs-offcanvas-bg); background-clip: padding-box; outline: 0; transition: var(--bs-offcanvas-transition); } @media (prefers-reduced-motion: reduce) { .offcanvas { transition: none; } } .offcanvas.offcanvas-start { top: 0; left: 0; width: var(--bs-offcanvas-width); border-right: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color); transform: translateX(-100%); } .offcanvas.offcanvas-end { top: 0; right: 0; width: var(--bs-offcanvas-width); border-left: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color); transform: translateX(100%); } .offcanvas.offcanvas-top { top: 0; right: 0; left: 0; height: var(--bs-offcanvas-height); max-height: 100%; border-bottom: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color); transform: translateY(-100%); } .offcanvas.offcanvas-bottom { right: 0; left: 0; height: var(--bs-offcanvas-height); max-height: 100%; border-top: var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color); transform: translateY(100%); } .offcanvas.showing, .offcanvas.show:not(.hiding) { transform: none; } .offcanvas.showing, .offcanvas.hiding, .offcanvas.show { visibility: visible; } .offcanvas-backdrop { position: fixed; top: 0; left: 0; z-index: 1040; width: 100vw; height: 100vh; background-color: #000; } .offcanvas-backdrop.fade { opacity: 0; } .offcanvas-backdrop.show { opacity: 0.5; } .offcanvas-header { display: flex; align-items: center; padding: var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x); } .offcanvas-header .btn-close { padding: calc(var(--bs-offcanvas-padding-y) * .5) calc(var(--bs-offcanvas-padding-x) * .5); margin: calc(-.5 * var(--bs-offcanvas-padding-y)) calc(-.5 * var(--bs-offcanvas-padding-x)) calc(-.5 * var(--bs-offcanvas-padding-y)) auto; } .offcanvas-title { margin-bottom: 0; line-height: var(--bs-offcanvas-title-line-height); } .offcanvas-body { flex-grow: 1; padding: var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x); overflow-y: auto; } .placeholder { display: inline-block; min-height: 1em; vertical-align: middle; cursor: wait; background-color: currentcolor; opacity: 0.5; } .placeholder.btn::before, .search-form .placeholder.search-submit::before, .comment-form input.placeholder[type="submit"]::before { display: inline-block; content: ""; } .placeholder-xs { min-height: .6em; } .placeholder-sm { min-height: .8em; } .placeholder-lg { min-height: 1.2em; } .placeholder-glow .placeholder { animation: placeholder-glow 2s ease-in-out infinite; } @keyframes placeholder-glow { 50% { opacity: 0.2; } } .placeholder-wave { mask-image: linear-gradient(130deg, #000 55%, rgba(0, 0, 0, 0.8) 75%, #000 95%); mask-size: 200% 100%; animation: placeholder-wave 2s linear infinite; } @keyframes placeholder-wave { 100% { mask-position: -200% 0%; } } .align-baseline { vertical-align: baseline !important; } .align-top { vertical-align: top !important; } .align-middle { vertical-align: middle !important; } .align-bottom { vertical-align: bottom !important; } .align-text-bottom { vertical-align: text-bottom !important; } .align-text-top { vertical-align: text-top !important; } .float-start { float: left !important; } .float-end { float: right !important; } .float-none { float: none !important; } .object-fit-contain { object-fit: contain !important; } .object-fit-cover { object-fit: cover !important; } .object-fit-fill { object-fit: fill !important; } .object-fit-scale { object-fit: scale-down !important; } .object-fit-none { object-fit: none !important; } .opacity-0 { opacity: 0 !important; } .opacity-25 { opacity: 0.25 !important; } .opacity-50 { opacity: 0.5 !important; } .opacity-75 { opacity: 0.75 !important; } .opacity-100 { opacity: 1 !important; } .overflow-auto { overflow: auto !important; } .overflow-hidden { overflow: hidden !important; } .overflow-visible { overflow: visible !important; } .overflow-scroll { overflow: scroll !important; } .overflow-x-auto { overflow-x: auto !important; } .overflow-x-hidden { overflow-x: hidden !important; } .overflow-x-visible { overflow-x: visible !important; } .overflow-x-scroll { overflow-x: scroll !important; } .overflow-y-auto { overflow-y: auto !important; } .overflow-y-hidden { overflow-y: hidden !important; } .overflow-y-visible { overflow-y: visible !important; } .overflow-y-scroll { overflow-y: scroll !important; } .d-inline { display: inline !important; } .d-inline-block { display: inline-block !important; } .d-block { display: block !important; } .d-grid { display: grid !important; } .d-inline-grid { display: inline-grid !important; } .d-table { display: table !important; } .d-table-row { display: table-row !important; } .d-table-cell { display: table-cell !important; } .d-flex { display: flex !important; } .d-inline-flex { display: inline-flex !important; } .d-none { display: none !important; } .shadow { box-shadow: var(--bs-box-shadow) !important; } .shadow-sm { box-shadow: var(--bs-box-shadow-sm) !important; } .shadow-lg { box-shadow: var(--bs-box-shadow-lg) !important; } .shadow-none { box-shadow: none !important; } .focus-ring-primary { --bs-focus-ring-color: rgba(var(--bs-primary-rgb), var(--bs-focus-ring-opacity)); } .focus-ring-secondary { --bs-focus-ring-color: rgba(var(--bs-secondary-rgb), var(--bs-focus-ring-opacity)); } .focus-ring-success { --bs-focus-ring-color: rgba(var(--bs-success-rgb), var(--bs-focus-ring-opacity)); } .focus-ring-info { --bs-focus-ring-color: rgba(var(--bs-info-rgb), var(--bs-focus-ring-opacity)); } .focus-ring-warning { --bs-focus-ring-color: rgba(var(--bs-warning-rgb), var(--bs-focus-ring-opacity)); } .focus-ring-danger { --bs-focus-ring-color: rgba(var(--bs-danger-rgb), var(--bs-focus-ring-opacity)); } .focus-ring-light { --bs-focus-ring-color: rgba(var(--bs-light-rgb), var(--bs-focus-ring-opacity)); } .focus-ring-dark { --bs-focus-ring-color: rgba(var(--bs-dark-rgb), var(--bs-focus-ring-opacity)); } .position-static { position: static !important; } .position-relative { position: relative !important; } .position-absolute { position: absolute !important; } .position-fixed { position: fixed !important; } .position-sticky { position: sticky !important; } .top-0 { top: 0 !important; } .top-50 { top: 50% !important; } .top-100 { top: 100% !important; } .bottom-0 { bottom: 0 !important; } .bottom-50 { bottom: 50% !important; } .bottom-100 { bottom: 100% !important; } .start-0 { left: 0 !important; } .start-50 { left: 50% !important; } .start-100 { left: 100% !important; } .end-0 { right: 0 !important; } .end-50 { right: 50% !important; } .end-100 { right: 100% !important; } .translate-middle { transform: translate(-50%, -50%) !important; } .translate-middle-x { transform: translateX(-50%) !important; } .translate-middle-y { transform: translateY(-50%) !important; } .border { border: var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important; } .border-0 { border: 0 !important; } .border-top { border-top: var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important; } .border-top-0 { border-top: 0 !important; } .border-end { border-right: var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important; } .border-end-0 { border-right: 0 !important; } .border-bottom { border-bottom: var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important; } .border-bottom-0 { border-bottom: 0 !important; } .border-start { border-left: var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important; } .border-start-0 { border-left: 0 !important; } .border-primary { --bs-border-opacity: 1; border-color: rgba(var(--bs-primary-rgb), var(--bs-border-opacity)) !important; } .border-secondary { --bs-border-opacity: 1; border-color: rgba(var(--bs-secondary-rgb), var(--bs-border-opacity)) !important; } .border-success { --bs-border-opacity: 1; border-color: rgba(var(--bs-success-rgb), var(--bs-border-opacity)) !important; } .border-info { --bs-border-opacity: 1; border-color: rgba(var(--bs-info-rgb), var(--bs-border-opacity)) !important; } .border-warning { --bs-border-opacity: 1; border-color: rgba(var(--bs-warning-rgb), var(--bs-border-opacity)) !important; } .border-danger { --bs-border-opacity: 1; border-color: rgba(var(--bs-danger-rgb), var(--bs-border-opacity)) !important; } .border-light { --bs-border-opacity: 1; border-color: rgba(var(--bs-light-rgb), var(--bs-border-opacity)) !important; } .border-dark { --bs-border-opacity: 1; border-color: rgba(var(--bs-dark-rgb), var(--bs-border-opacity)) !important; } .border-black { --bs-border-opacity: 1; border-color: rgba(var(--bs-black-rgb), var(--bs-border-opacity)) !important; } .border-white { --bs-border-opacity: 1; border-color: rgba(var(--bs-white-rgb), var(--bs-border-opacity)) !important; } .border-primary-subtle { border-color: var(--bs-primary-border-subtle) !important; } .border-secondary-subtle { border-color: var(--bs-secondary-border-subtle) !important; } .border-success-subtle { border-color: var(--bs-success-border-subtle) !important; } .border-info-subtle { border-color: var(--bs-info-border-subtle) !important; } .border-warning-subtle { border-color: var(--bs-warning-border-subtle) !important; } .border-danger-subtle { border-color: var(--bs-danger-border-subtle) !important; } .border-light-subtle { border-color: var(--bs-light-border-subtle) !important; } .border-dark-subtle { border-color: var(--bs-dark-border-subtle) !important; } .border-1 { border-width: 1px !important; } .border-2 { border-width: 2px !important; } .border-3 { border-width: 3px !important; } .border-4 { border-width: 4px !important; } .border-5 { border-width: 5px !important; } .border-opacity-10 { --bs-border-opacity: 0.1; } .border-opacity-25 { --bs-border-opacity: 0.25; } .border-opacity-50 { --bs-border-opacity: 0.5; } .border-opacity-75 { --bs-border-opacity: 0.75; } .border-opacity-100 { --bs-border-opacity: 1; } .w-25 { width: 25% !important; } .w-50 { width: 50% !important; } .w-75 { width: 75% !important; } .w-100 { width: 100% !important; } .w-auto { width: auto !important; } .mw-100 { max-width: 100% !important; } .vw-100 { width: 100vw !important; } .min-vw-100 { min-width: 100vw !important; } .h-25 { height: 25% !important; } .h-50 { height: 50% !important; } .h-75 { height: 75% !important; } .h-100 { height: 100% !important; } .h-auto { height: auto !important; } .mh-100 { max-height: 100% !important; } .vh-100 { height: 100vh !important; } .min-vh-100 { min-height: 100vh !important; } .flex-fill { flex: 1 1 auto !important; } .flex-row { flex-direction: row !important; } .flex-column { flex-direction: column !important; } .flex-row-reverse { flex-direction: row-reverse !important; } .flex-column-reverse { flex-direction: column-reverse !important; } .flex-grow-0 { flex-grow: 0 !important; } .flex-grow-1 { flex-grow: 1 !important; } .flex-shrink-0 { flex-shrink: 0 !important; } .flex-shrink-1 { flex-shrink: 1 !important; } .flex-wrap { flex-wrap: wrap !important; } .flex-nowrap { flex-wrap: nowrap !important; } .flex-wrap-reverse { flex-wrap: wrap-reverse !important; } .justify-content-start { justify-content: flex-start !important; } .justify-content-end { justify-content: flex-end !important; } .justify-content-center { justify-content: center !important; } .justify-content-between { justify-content: space-between !important; } .justify-content-around { justify-content: space-around !important; } .justify-content-evenly { justify-content: space-evenly !important; } .align-items-start { align-items: flex-start !important; } .align-items-end { align-items: flex-end !important; } .align-items-center { align-items: center !important; } .align-items-baseline { align-items: baseline !important; } .align-items-stretch { align-items: stretch !important; } .align-content-start { align-content: flex-start !important; } .align-content-end { align-content: flex-end !important; } .align-content-center { align-content: center !important; } .align-content-between { align-content: space-between !important; } .align-content-around { align-content: space-around !important; } .align-content-stretch { align-content: stretch !important; } .align-self-auto { align-self: auto !important; } .align-self-start { align-self: flex-start !important; } .align-self-end { align-self: flex-end !important; } .align-self-center { align-self: center !important; } .align-self-baseline { align-self: baseline !important; } .align-self-stretch { align-self: stretch !important; } .order-first { order: -1 !important; } .order-0 { order: 0 !important; } .order-1 { order: 1 !important; } .order-2 { order: 2 !important; } .order-3 { order: 3 !important; } .order-4 { order: 4 !important; } .order-5 { order: 5 !important; } .order-last { order: 6 !important; } .m-0 { margin: 0 !important; } .m-1 { margin: 0.25rem !important; } .m-2 { margin: 0.5rem !important; } .m-3 { margin: 1rem !important; } .m-4 { margin: 1.5rem !important; } .m-5 { margin: 3rem !important; } .m-auto { margin: auto !important; } .mx-0 { margin-right: 0 !important; margin-left: 0 !important; } .mx-1 { margin-right: 0.25rem !important; margin-left: 0.25rem !important; } .mx-2 { margin-right: 0.5rem !important; margin-left: 0.5rem !important; } .mx-3 { margin-right: 1rem !important; margin-left: 1rem !important; } .mx-4 { margin-right: 1.5rem !important; margin-left: 1.5rem !important; } .mx-5 { margin-right: 3rem !important; margin-left: 3rem !important; } .mx-auto { margin-right: auto !important; margin-left: auto !important; } .my-0 { margin-top: 0 !important; margin-bottom: 0 !important; } .my-1 { margin-top: 0.25rem !important; margin-bottom: 0.25rem !important; } .my-2 { margin-top: 0.5rem !important; margin-bottom: 0.5rem !important; } .my-3 { margin-top: 1rem !important; margin-bottom: 1rem !important; } .my-4 { margin-top: 1.5rem !important; margin-bottom: 1.5rem !important; } .my-5 { margin-top: 3rem !important; margin-bottom: 3rem !important; } .my-auto { margin-top: auto !important; margin-bottom: auto !important; } .mt-0 { margin-top: 0 !important; } .mt-1 { margin-top: 0.25rem !important; } .mt-2 { margin-top: 0.5rem !important; } .mt-3 { margin-top: 1rem !important; } .mt-4 { margin-top: 1.5rem !important; } .mt-5 { margin-top: 3rem !important; } .mt-auto { margin-top: auto !important; } .me-0 { margin-right: 0 !important; } .me-1 { margin-right: 0.25rem !important; } .me-2 { margin-right: 0.5rem !important; } .me-3 { margin-right: 1rem !important; } .me-4 { margin-right: 1.5rem !important; } .me-5 { margin-right: 3rem !important; } .me-auto { margin-right: auto !important; } .mb-0 { margin-bottom: 0 !important; } .mb-1 { margin-bottom: 0.25rem !important; } .mb-2 { margin-bottom: 0.5rem !important; } .mb-3 { margin-bottom: 1rem !important; } .mb-4 { margin-bottom: 1.5rem !important; } .mb-5 { margin-bottom: 3rem !important; } .mb-auto { margin-bottom: auto !important; } .ms-0 { margin-left: 0 !important; } .ms-1 { margin-left: 0.25rem !important; } .ms-2 { margin-left: 0.5rem !important; } .ms-3 { margin-left: 1rem !important; } .ms-4 { margin-left: 1.5rem !important; } .ms-5 { margin-left: 3rem !important; } .ms-auto { margin-left: auto !important; } .m-n1 { margin: -0.25rem !important; } .m-n2 { margin: -0.5rem !important; } .m-n3 { margin: -1rem !important; } .m-n4 { margin: -1.5rem !important; } .m-n5 { margin: -3rem !important; } .mx-n1 { margin-right: -0.25rem !important; margin-left: -0.25rem !important; } .mx-n2 { margin-right: -0.5rem !important; margin-left: -0.5rem !important; } .mx-n3 { margin-right: -1rem !important; margin-left: -1rem !important; } .mx-n4 { margin-right: -1.5rem !important; margin-left: -1.5rem !important; } .mx-n5 { margin-right: -3rem !important; margin-left: -3rem !important; } .my-n1 { margin-top: -0.25rem !important; margin-bottom: -0.25rem !important; } .my-n2 { margin-top: -0.5rem !important; margin-bottom: -0.5rem !important; } .my-n3 { margin-top: -1rem !important; margin-bottom: -1rem !important; } .my-n4 { margin-top: -1.5rem !important; margin-bottom: -1.5rem !important; } .my-n5 { margin-top: -3rem !important; margin-bottom: -3rem !important; } .mt-n1 { margin-top: -0.25rem !important; } .mt-n2 { margin-top: -0.5rem !important; } .mt-n3 { margin-top: -1rem !important; } .mt-n4 { margin-top: -1.5rem !important; } .mt-n5 { margin-top: -3rem !important; } .me-n1 { margin-right: -0.25rem !important; } .me-n2 { margin-right: -0.5rem !important; } .me-n3 { margin-right: -1rem !important; } .me-n4 { margin-right: -1.5rem !important; } .me-n5 { margin-right: -3rem !important; } .mb-n1 { margin-bottom: -0.25rem !important; } .mb-n2 { margin-bottom: -0.5rem !important; } .mb-n3 { margin-bottom: -1rem !important; } .mb-n4 { margin-bottom: -1.5rem !important; } .mb-n5 { margin-bottom: -3rem !important; } .ms-n1 { margin-left: -0.25rem !important; } .ms-n2 { margin-left: -0.5rem !important; } .ms-n3 { margin-left: -1rem !important; } .ms-n4 { margin-left: -1.5rem !important; } .ms-n5 { margin-left: -3rem !important; } .p-0 { padding: 0 !important; } .p-1 { padding: 0.25rem !important; } .p-2 { padding: 0.5rem !important; } .p-3 { padding: 1rem !important; } .p-4 { padding: 1.5rem !important; } .p-5 { padding: 3rem !important; } .px-0 { padding-right: 0 !important; padding-left: 0 !important; } .px-1 { padding-right: 0.25rem !important; padding-left: 0.25rem !important; } .px-2 { padding-right: 0.5rem !important; padding-left: 0.5rem !important; } .px-3 { padding-right: 1rem !important; padding-left: 1rem !important; } .px-4 { padding-right: 1.5rem !important; padding-left: 1.5rem !important; } .px-5 { padding-right: 3rem !important; padding-left: 3rem !important; } .py-0 { padding-top: 0 !important; padding-bottom: 0 !important; } .py-1 { padding-top: 0.25rem !important; padding-bottom: 0.25rem !important; } .py-2 { padding-top: 0.5rem !important; padding-bottom: 0.5rem !important; } .py-3 { padding-top: 1rem !important; padding-bottom: 1rem !important; } .py-4 { padding-top: 1.5rem !important; padding-bottom: 1.5rem !important; } .py-5 { padding-top: 3rem !important; padding-bottom: 3rem !important; } .pt-0 { padding-top: 0 !important; } .pt-1 { padding-top: 0.25rem !important; } .pt-2 { padding-top: 0.5rem !important; } .pt-3 { padding-top: 1rem !important; } .pt-4 { padding-top: 1.5rem !important; } .pt-5 { padding-top: 3rem !important; } .pe-0 { padding-right: 0 !important; } .pe-1 { padding-right: 0.25rem !important; } .pe-2 { padding-right: 0.5rem !important; } .pe-3 { padding-right: 1rem !important; } .pe-4 { padding-right: 1.5rem !important; } .pe-5 { padding-right: 3rem !important; } .pb-0 { padding-bottom: 0 !important; } .pb-1 { padding-bottom: 0.25rem !important; } .pb-2 { padding-bottom: 0.5rem !important; } .pb-3 { padding-bottom: 1rem !important; } .pb-4 { padding-bottom: 1.5rem !important; } .pb-5 { padding-bottom: 3rem !important; } .ps-0 { padding-left: 0 !important; } .ps-1 { padding-left: 0.25rem !important; } .ps-2 { padding-left: 0.5rem !important; } .ps-3 { padding-left: 1rem !important; } .ps-4 { padding-left: 1.5rem !important; } .ps-5 { padding-left: 3rem !important; } .gap-0 { gap: 0 !important; } .gap-1 { gap: 0.25rem !important; } .gap-2 { gap: 0.5rem !important; } .gap-3 { gap: 1rem !important; } .gap-4 { gap: 1.5rem !important; } .gap-5 { gap: 3rem !important; } .row-gap-0 { row-gap: 0 !important; } .row-gap-1 { row-gap: 0.25rem !important; } .row-gap-2 { row-gap: 0.5rem !important; } .row-gap-3 { row-gap: 1rem !important; } .row-gap-4 { row-gap: 1.5rem !important; } .row-gap-5 { row-gap: 3rem !important; } .column-gap-0 { column-gap: 0 !important; } .column-gap-1 { column-gap: 0.25rem !important; } .column-gap-2 { column-gap: 0.5rem !important; } .column-gap-3 { column-gap: 1rem !important; } .column-gap-4 { column-gap: 1.5rem !important; } .column-gap-5 { column-gap: 3rem !important; } .font-monospace { font-family: var(--bs-font-monospace) !important; } .fs-1 { font-size: calc(1.375rem + 1.5vw) !important; } .fs-2 { font-size: calc(1.325rem + 0.9vw) !important; } .fs-3 { font-size: calc(1.3rem + 0.6vw) !important; } .fs-4 { font-size: calc(1.275rem + 0.3vw) !important; } .fs-5 { font-size: 1.25rem !important; } .fs-6 { font-size: 1rem !important; } .fst-italic { font-style: italic !important; } .fst-normal { font-style: normal !important; } .fw-lighter { font-weight: lighter !important; } .fw-light { font-weight: 300 !important; } .fw-normal { font-weight: 400 !important; } .fw-medium { font-weight: 500 !important; } .fw-semibold { font-weight: 600 !important; } .fw-bold { font-weight: 700 !important; } .fw-bolder { font-weight: bolder !important; } .lh-1 { line-height: 1 !important; } .lh-sm { line-height: 1.25 !important; } .lh-base { line-height: 1.5 !important; } .lh-lg { line-height: 2 !important; } .text-start { text-align: left !important; } .text-end { text-align: right !important; } .text-center { text-align: center !important; } .text-decoration-none { text-decoration: none !important; } .text-decoration-underline { text-decoration: underline !important; } .text-decoration-line-through { text-decoration: line-through !important; } .text-lowercase { text-transform: lowercase !important; } .text-uppercase { text-transform: uppercase !important; } .text-capitalize { text-transform: capitalize !important; } .text-wrap { white-space: normal !important; } .text-nowrap { white-space: nowrap !important; } /* rtl:begin:remove */ .text-break { word-wrap: break-word !important; word-break: break-word !important; } /* rtl:end:remove */ .text-primary { --bs-text-opacity: 1; color: rgba(var(--bs-primary-rgb), var(--bs-text-opacity)) !important; } .text-secondary { --bs-text-opacity: 1; color: rgba(var(--bs-secondary-rgb), var(--bs-text-opacity)) !important; } .text-success { --bs-text-opacity: 1; color: rgba(var(--bs-success-rgb), var(--bs-text-opacity)) !important; } .text-info { --bs-text-opacity: 1; color: rgba(var(--bs-info-rgb), var(--bs-text-opacity)) !important; } .text-warning { --bs-text-opacity: 1; color: rgba(var(--bs-warning-rgb), var(--bs-text-opacity)) !important; } .text-danger { --bs-text-opacity: 1; color: rgba(var(--bs-danger-rgb), var(--bs-text-opacity)) !important; } .text-light { --bs-text-opacity: 1; color: rgba(var(--bs-light-rgb), var(--bs-text-opacity)) !important; } .text-dark { --bs-text-opacity: 1; color: rgba(var(--bs-dark-rgb), var(--bs-text-opacity)) !important; } .text-black { --bs-text-opacity: 1; color: rgba(var(--bs-black-rgb), var(--bs-text-opacity)) !important; } .text-white { --bs-text-opacity: 1; color: rgba(var(--bs-white-rgb), var(--bs-text-opacity)) !important; } .text-body { --bs-text-opacity: 1; color: rgba(var(--bs-body-color-rgb), var(--bs-text-opacity)) !important; } .text-muted { --bs-text-opacity: 1; color: var(--bs-secondary-color) !important; } .text-black-50 { --bs-text-opacity: 1; color: rgba(0, 0, 0, 0.5) !important; } .text-white-50 { --bs-text-opacity: 1; color: rgba(255, 255, 255, 0.5) !important; } .text-body-secondary { --bs-text-opacity: 1; color: var(--bs-secondary-color) !important; } .text-body-tertiary { --bs-text-opacity: 1; color: var(--bs-tertiary-color) !important; } .text-body-emphasis { --bs-text-opacity: 1; color: var(--bs-emphasis-color) !important; } .text-reset { --bs-text-opacity: 1; color: inherit !important; } .text-opacity-25 { --bs-text-opacity: 0.25; } .text-opacity-50 { --bs-text-opacity: 0.5; } .text-opacity-75 { --bs-text-opacity: 0.75; } .text-opacity-100 { --bs-text-opacity: 1; } .text-primary-emphasis { color: var(--bs-primary-text-emphasis) !important; } .text-secondary-emphasis { color: var(--bs-secondary-text-emphasis) !important; } .text-success-emphasis { color: var(--bs-success-text-emphasis) !important; } .text-info-emphasis { color: var(--bs-info-text-emphasis) !important; } .text-warning-emphasis { color: var(--bs-warning-text-emphasis) !important; } .text-danger-emphasis { color: var(--bs-danger-text-emphasis) !important; } .text-light-emphasis { color: var(--bs-light-text-emphasis) !important; } .text-dark-emphasis { color: var(--bs-dark-text-emphasis) !important; } .link-opacity-10 { --bs-link-opacity: 0.1; } .link-opacity-10-hover:hover { --bs-link-opacity: 0.1; } .link-opacity-25 { --bs-link-opacity: 0.25; } .link-opacity-25-hover:hover { --bs-link-opacity: 0.25; } .link-opacity-50 { --bs-link-opacity: 0.5; } .link-opacity-50-hover:hover { --bs-link-opacity: 0.5; } .link-opacity-75 { --bs-link-opacity: 0.75; } .link-opacity-75-hover:hover { --bs-link-opacity: 0.75; } .link-opacity-100 { --bs-link-opacity: 1; } .link-opacity-100-hover:hover { --bs-link-opacity: 1; } .link-offset-1 { text-underline-offset: 0.125em !important; } .link-offset-1-hover:hover { text-underline-offset: 0.125em !important; } .link-offset-2 { text-underline-offset: 0.25em !important; } .link-offset-2-hover:hover { text-underline-offset: 0.25em !important; } .link-offset-3 { text-underline-offset: 0.375em !important; } .link-offset-3-hover:hover { text-underline-offset: 0.375em !important; } .link-underline-primary { --bs-link-underline-opacity: 1; text-decoration-color: rgba(var(--bs-primary-rgb), var(--bs-link-underline-opacity)) !important; } .link-underline-secondary { --bs-link-underline-opacity: 1; text-decoration-color: rgba(var(--bs-secondary-rgb), var(--bs-link-underline-opacity)) !important; } .link-underline-success { --bs-link-underline-opacity: 1; text-decoration-color: rgba(var(--bs-success-rgb), var(--bs-link-underline-opacity)) !important; } .link-underline-info { --bs-link-underline-opacity: 1; text-decoration-color: rgba(var(--bs-info-rgb), var(--bs-link-underline-opacity)) !important; } .link-underline-warning { --bs-link-underline-opacity: 1; text-decoration-color: rgba(var(--bs-warning-rgb), var(--bs-link-underline-opacity)) !important; } .link-underline-danger { --bs-link-underline-opacity: 1; text-decoration-color: rgba(var(--bs-danger-rgb), var(--bs-link-underline-opacity)) !important; } .link-underline-light { --bs-link-underline-opacity: 1; text-decoration-color: rgba(var(--bs-light-rgb), var(--bs-link-underline-opacity)) !important; } .link-underline-dark { --bs-link-underline-opacity: 1; text-decoration-color: rgba(var(--bs-dark-rgb), var(--bs-link-underline-opacity)) !important; } .link-underline { --bs-link-underline-opacity: 1; text-decoration-color: rgba(var(--bs-link-color-rgb), var(--bs-link-underline-opacity, 1)) !important; } .link-underline-opacity-0 { --bs-link-underline-opacity: 0; } .link-underline-opacity-0-hover:hover { --bs-link-underline-opacity: 0; } .link-underline-opacity-10 { --bs-link-underline-opacity: 0.1; } .link-underline-opacity-10-hover:hover { --bs-link-underline-opacity: 0.1; } .link-underline-opacity-25 { --bs-link-underline-opacity: 0.25; } .link-underline-opacity-25-hover:hover { --bs-link-underline-opacity: 0.25; } .link-underline-opacity-50 { --bs-link-underline-opacity: 0.5; } .link-underline-opacity-50-hover:hover { --bs-link-underline-opacity: 0.5; } .link-underline-opacity-75 { --bs-link-underline-opacity: 0.75; } .link-underline-opacity-75-hover:hover { --bs-link-underline-opacity: 0.75; } .link-underline-opacity-100 { --bs-link-underline-opacity: 1; } .link-underline-opacity-100-hover:hover { --bs-link-underline-opacity: 1; } .bg-primary { --bs-bg-opacity: 1; background-color: rgba(var(--bs-primary-rgb), var(--bs-bg-opacity)) !important; } .bg-secondary { --bs-bg-opacity: 1; background-color: rgba(var(--bs-secondary-rgb), var(--bs-bg-opacity)) !important; } .bg-success { --bs-bg-opacity: 1; background-color: rgba(var(--bs-success-rgb), var(--bs-bg-opacity)) !important; } .bg-info { --bs-bg-opacity: 1; background-color: rgba(var(--bs-info-rgb), var(--bs-bg-opacity)) !important; } .bg-warning { --bs-bg-opacity: 1; background-color: rgba(var(--bs-warning-rgb), var(--bs-bg-opacity)) !important; } .bg-danger { --bs-bg-opacity: 1; background-color: rgba(var(--bs-danger-rgb), var(--bs-bg-opacity)) !important; } .bg-light { --bs-bg-opacity: 1; background-color: rgba(var(--bs-light-rgb), var(--bs-bg-opacity)) !important; } .bg-dark { --bs-bg-opacity: 1; background-color: rgba(var(--bs-dark-rgb), var(--bs-bg-opacity)) !important; } .bg-black { --bs-bg-opacity: 1; background-color: rgba(var(--bs-black-rgb), var(--bs-bg-opacity)) !important; } .bg-white { --bs-bg-opacity: 1; background-color: rgba(var(--bs-white-rgb), var(--bs-bg-opacity)) !important; } .bg-body { --bs-bg-opacity: 1; background-color: rgba(var(--bs-body-bg-rgb), var(--bs-bg-opacity)) !important; } .bg-transparent { --bs-bg-opacity: 1; background-color: transparent !important; } .bg-body-secondary { --bs-bg-opacity: 1; background-color: rgba(var(--bs-secondary-bg-rgb), var(--bs-bg-opacity)) !important; } .bg-body-tertiary { --bs-bg-opacity: 1; background-color: rgba(var(--bs-tertiary-bg-rgb), var(--bs-bg-opacity)) !important; } .bg-opacity-10 { --bs-bg-opacity: 0.1; } .bg-opacity-25 { --bs-bg-opacity: 0.25; } .bg-opacity-50 { --bs-bg-opacity: 0.5; } .bg-opacity-75 { --bs-bg-opacity: 0.75; } .bg-opacity-100 { --bs-bg-opacity: 1; } .bg-primary-subtle { background-color: var(--bs-primary-bg-subtle) !important; } .bg-secondary-subtle { background-color: var(--bs-secondary-bg-subtle) !important; } .bg-success-subtle { background-color: var(--bs-success-bg-subtle) !important; } .bg-info-subtle { background-color: var(--bs-info-bg-subtle) !important; } .bg-warning-subtle { background-color: var(--bs-warning-bg-subtle) !important; } .bg-danger-subtle { background-color: var(--bs-danger-bg-subtle) !important; } .bg-light-subtle { background-color: var(--bs-light-bg-subtle) !important; } .bg-dark-subtle { background-color: var(--bs-dark-bg-subtle) !important; } .bg-gradient { background-image: var(--bs-gradient) !important; } .user-select-all { user-select: all !important; } .user-select-auto { user-select: auto !important; } .user-select-none { user-select: none !important; } .pe-none { pointer-events: none !important; } .pe-auto { pointer-events: auto !important; } .rounded { border-radius: var(--bs-border-radius) !important; } .rounded-0 { border-radius: 0 !important; } .rounded-1 { border-radius: var(--bs-border-radius-sm) !important; } .rounded-2 { border-radius: var(--bs-border-radius) !important; } .rounded-3 { border-radius: var(--bs-border-radius-lg) !important; } .rounded-4 { border-radius: var(--bs-border-radius-xl) !important; } .rounded-5 { border-radius: var(--bs-border-radius-xxl) !important; } .rounded-circle { border-radius: 50% !important; } .rounded-pill { border-radius: var(--bs-border-radius-pill) !important; } .rounded-top { border-top-left-radius: var(--bs-border-radius) !important; border-top-right-radius: var(--bs-border-radius) !important; } .rounded-top-0 { border-top-left-radius: 0 !important; border-top-right-radius: 0 !important; } .rounded-top-1 { border-top-left-radius: var(--bs-border-radius-sm) !important; border-top-right-radius: var(--bs-border-radius-sm) !important; } .rounded-top-2 { border-top-left-radius: var(--bs-border-radius) !important; border-top-right-radius: var(--bs-border-radius) !important; } .rounded-top-3 { border-top-left-radius: var(--bs-border-radius-lg) !important; border-top-right-radius: var(--bs-border-radius-lg) !important; } .rounded-top-4 { border-top-left-radius: var(--bs-border-radius-xl) !important; border-top-right-radius: var(--bs-border-radius-xl) !important; } .rounded-top-5 { border-top-left-radius: var(--bs-border-radius-xxl) !important; border-top-right-radius: var(--bs-border-radius-xxl) !important; } .rounded-top-circle { border-top-left-radius: 50% !important; border-top-right-radius: 50% !important; } .rounded-top-pill { border-top-left-radius: var(--bs-border-radius-pill) !important; border-top-right-radius: var(--bs-border-radius-pill) !important; } .rounded-end { border-top-right-radius: var(--bs-border-radius) !important; border-bottom-right-radius: var(--bs-border-radius) !important; } .rounded-end-0 { border-top-right-radius: 0 !important; border-bottom-right-radius: 0 !important; } .rounded-end-1 { border-top-right-radius: var(--bs-border-radius-sm) !important; border-bottom-right-radius: var(--bs-border-radius-sm) !important; } .rounded-end-2 { border-top-right-radius: var(--bs-border-radius) !important; border-bottom-right-radius: var(--bs-border-radius) !important; } .rounded-end-3 { border-top-right-radius: var(--bs-border-radius-lg) !important; border-bottom-right-radius: var(--bs-border-radius-lg) !important; } .rounded-end-4 { border-top-right-radius: var(--bs-border-radius-xl) !important; border-bottom-right-radius: var(--bs-border-radius-xl) !important; } .rounded-end-5 { border-top-right-radius: var(--bs-border-radius-xxl) !important; border-bottom-right-radius: var(--bs-border-radius-xxl) !important; } .rounded-end-circle { border-top-right-radius: 50% !important; border-bottom-right-radius: 50% !important; } .rounded-end-pill { border-top-right-radius: var(--bs-border-radius-pill) !important; border-bottom-right-radius: var(--bs-border-radius-pill) !important; } .rounded-bottom { border-bottom-right-radius: var(--bs-border-radius) !important; border-bottom-left-radius: var(--bs-border-radius) !important; } .rounded-bottom-0 { border-bottom-right-radius: 0 !important; border-bottom-left-radius: 0 !important; } .rounded-bottom-1 { border-bottom-right-radius: var(--bs-border-radius-sm) !important; border-bottom-left-radius: var(--bs-border-radius-sm) !important; } .rounded-bottom-2 { border-bottom-right-radius: var(--bs-border-radius) !important; border-bottom-left-radius: var(--bs-border-radius) !important; } .rounded-bottom-3 { border-bottom-right-radius: var(--bs-border-radius-lg) !important; border-bottom-left-radius: var(--bs-border-radius-lg) !important; } .rounded-bottom-4 { border-bottom-right-radius: var(--bs-border-radius-xl) !important; border-bottom-left-radius: var(--bs-border-radius-xl) !important; } .rounded-bottom-5 { border-bottom-right-radius: var(--bs-border-radius-xxl) !important; border-bottom-left-radius: var(--bs-border-radius-xxl) !important; } .rounded-bottom-circle { border-bottom-right-radius: 50% !important; border-bottom-left-radius: 50% !important; } .rounded-bottom-pill { border-bottom-right-radius: var(--bs-border-radius-pill) !important; border-bottom-left-radius: var(--bs-border-radius-pill) !important; } .rounded-start { border-bottom-left-radius: var(--bs-border-radius) !important; border-top-left-radius: var(--bs-border-radius) !important; } .rounded-start-0 { border-bottom-left-radius: 0 !important; border-top-left-radius: 0 !important; } .rounded-start-1 { border-bottom-left-radius: var(--bs-border-radius-sm) !important; border-top-left-radius: var(--bs-border-radius-sm) !important; } .rounded-start-2 { border-bottom-left-radius: var(--bs-border-radius) !important; border-top-left-radius: var(--bs-border-radius) !important; } .rounded-start-3 { border-bottom-left-radius: var(--bs-border-radius-lg) !important; border-top-left-radius: var(--bs-border-radius-lg) !important; } .rounded-start-4 { border-bottom-left-radius: var(--bs-border-radius-xl) !important; border-top-left-radius: var(--bs-border-radius-xl) !important; } .rounded-start-5 { border-bottom-left-radius: var(--bs-border-radius-xxl) !important; border-top-left-radius: var(--bs-border-radius-xxl) !important; } .rounded-start-circle { border-bottom-left-radius: 50% !important; border-top-left-radius: 50% !important; } .rounded-start-pill { border-bottom-left-radius: var(--bs-border-radius-pill) !important; border-top-left-radius: var(--bs-border-radius-pill) !important; } .visible { visibility: visible !important; } .invisible { visibility: hidden !important; } .z-n1 { z-index: -1 !important; } .z-0 { z-index: 0 !important; } .z-1 { z-index: 1 !important; } .z-2 { z-index: 2 !important; } .z-3 { z-index: 3 !important; } @media (min-width: 576px) { .float-sm-start { float: left !important; } .float-sm-end { float: right !important; } .float-sm-none { float: none !important; } .object-fit-sm-contain { object-fit: contain !important; } .object-fit-sm-cover { object-fit: cover !important; } .object-fit-sm-fill { object-fit: fill !important; } .object-fit-sm-scale { object-fit: scale-down !important; } .object-fit-sm-none { object-fit: none !important; } .d-sm-inline { display: inline !important; } .d-sm-inline-block { display: inline-block !important; } .d-sm-block { display: block !important; } .d-sm-grid { display: grid !important; } .d-sm-inline-grid { display: inline-grid !important; } .d-sm-table { display: table !important; } .d-sm-table-row { display: table-row !important; } .d-sm-table-cell { display: table-cell !important; } .d-sm-flex { display: flex !important; } .d-sm-inline-flex { display: inline-flex !important; } .d-sm-none { display: none !important; } .flex-sm-fill { flex: 1 1 auto !important; } .flex-sm-row { flex-direction: row !important; } .flex-sm-column { flex-direction: column !important; } .flex-sm-row-reverse { flex-direction: row-reverse !important; } .flex-sm-column-reverse { flex-direction: column-reverse !important; } .flex-sm-grow-0 { flex-grow: 0 !important; } .flex-sm-grow-1 { flex-grow: 1 !important; } .flex-sm-shrink-0 { flex-shrink: 0 !important; } .flex-sm-shrink-1 { flex-shrink: 1 !important; } .flex-sm-wrap { flex-wrap: wrap !important; } .flex-sm-nowrap { flex-wrap: nowrap !important; } .flex-sm-wrap-reverse { flex-wrap: wrap-reverse !important; } .justify-content-sm-start { justify-content: flex-start !important; } .justify-content-sm-end { justify-content: flex-end !important; } .justify-content-sm-center { justify-content: center !important; } .justify-content-sm-between { justify-content: space-between !important; } .justify-content-sm-around { justify-content: space-around !important; } .justify-content-sm-evenly { justify-content: space-evenly !important; } .align-items-sm-start { align-items: flex-start !important; } .align-items-sm-end { align-items: flex-end !important; } .align-items-sm-center { align-items: center !important; } .align-items-sm-baseline { align-items: baseline !important; } .align-items-sm-stretch { align-items: stretch !important; } .align-content-sm-start { align-content: flex-start !important; } .align-content-sm-end { align-content: flex-end !important; } .align-content-sm-center { align-content: center !important; } .align-content-sm-between { align-content: space-between !important; } .align-content-sm-around { align-content: space-around !important; } .align-content-sm-stretch { align-content: stretch !important; } .align-self-sm-auto { align-self: auto !important; } .align-self-sm-start { align-self: flex-start !important; } .align-self-sm-end { align-self: flex-end !important; } .align-self-sm-center { align-self: center !important; } .align-self-sm-baseline { align-self: baseline !important; } .align-self-sm-stretch { align-self: stretch !important; } .order-sm-first { order: -1 !important; } .order-sm-0 { order: 0 !important; } .order-sm-1 { order: 1 !important; } .order-sm-2 { order: 2 !important; } .order-sm-3 { order: 3 !important; } .order-sm-4 { order: 4 !important; } .order-sm-5 { order: 5 !important; } .order-sm-last { order: 6 !important; } .m-sm-0 { margin: 0 !important; } .m-sm-1 { margin: 0.25rem !important; } .m-sm-2 { margin: 0.5rem !important; } .m-sm-3 { margin: 1rem !important; } .m-sm-4 { margin: 1.5rem !important; } .m-sm-5 { margin: 3rem !important; } .m-sm-auto { margin: auto !important; } .mx-sm-0 { margin-right: 0 !important; margin-left: 0 !important; } .mx-sm-1 { margin-right: 0.25rem !important; margin-left: 0.25rem !important; } .mx-sm-2 { margin-right: 0.5rem !important; margin-left: 0.5rem !important; } .mx-sm-3 { margin-right: 1rem !important; margin-left: 1rem !important; } .mx-sm-4 { margin-right: 1.5rem !important; margin-left: 1.5rem !important; } .mx-sm-5 { margin-right: 3rem !important; margin-left: 3rem !important; } .mx-sm-auto { margin-right: auto !important; margin-left: auto !important; } .my-sm-0 { margin-top: 0 !important; margin-bottom: 0 !important; } .my-sm-1 { margin-top: 0.25rem !important; margin-bottom: 0.25rem !important; } .my-sm-2 { margin-top: 0.5rem !important; margin-bottom: 0.5rem !important; } .my-sm-3 { margin-top: 1rem !important; margin-bottom: 1rem !important; } .my-sm-4 { margin-top: 1.5rem !important; margin-bottom: 1.5rem !important; } .my-sm-5 { margin-top: 3rem !important; margin-bottom: 3rem !important; } .my-sm-auto { margin-top: auto !important; margin-bottom: auto !important; } .mt-sm-0 { margin-top: 0 !important; } .mt-sm-1 { margin-top: 0.25rem !important; } .mt-sm-2 { margin-top: 0.5rem !important; } .mt-sm-3 { margin-top: 1rem !important; } .mt-sm-4 { margin-top: 1.5rem !important; } .mt-sm-5 { margin-top: 3rem !important; } .mt-sm-auto { margin-top: auto !important; } .me-sm-0 { margin-right: 0 !important; } .me-sm-1 { margin-right: 0.25rem !important; } .me-sm-2 { margin-right: 0.5rem !important; } .me-sm-3 { margin-right: 1rem !important; } .me-sm-4 { margin-right: 1.5rem !important; } .me-sm-5 { margin-right: 3rem !important; } .me-sm-auto { margin-right: auto !important; } .mb-sm-0 { margin-bottom: 0 !important; } .mb-sm-1 { margin-bottom: 0.25rem !important; } .mb-sm-2 { margin-bottom: 0.5rem !important; } .mb-sm-3 { margin-bottom: 1rem !important; } .mb-sm-4 { margin-bottom: 1.5rem !important; } .mb-sm-5 { margin-bottom: 3rem !important; } .mb-sm-auto { margin-bottom: auto !important; } .ms-sm-0 { margin-left: 0 !important; } .ms-sm-1 { margin-left: 0.25rem !important; } .ms-sm-2 { margin-left: 0.5rem !important; } .ms-sm-3 { margin-left: 1rem !important; } .ms-sm-4 { margin-left: 1.5rem !important; } .ms-sm-5 { margin-left: 3rem !important; } .ms-sm-auto { margin-left: auto !important; } .m-sm-n1 { margin: -0.25rem !important; } .m-sm-n2 { margin: -0.5rem !important; } .m-sm-n3 { margin: -1rem !important; } .m-sm-n4 { margin: -1.5rem !important; } .m-sm-n5 { margin: -3rem !important; } .mx-sm-n1 { margin-right: -0.25rem !important; margin-left: -0.25rem !important; } .mx-sm-n2 { margin-right: -0.5rem !important; margin-left: -0.5rem !important; } .mx-sm-n3 { margin-right: -1rem !important; margin-left: -1rem !important; } .mx-sm-n4 { margin-right: -1.5rem !important; margin-left: -1.5rem !important; } .mx-sm-n5 { margin-right: -3rem !important; margin-left: -3rem !important; } .my-sm-n1 { margin-top: -0.25rem !important; margin-bottom: -0.25rem !important; } .my-sm-n2 { margin-top: -0.5rem !important; margin-bottom: -0.5rem !important; } .my-sm-n3 { margin-top: -1rem !important; margin-bottom: -1rem !important; } .my-sm-n4 { margin-top: -1.5rem !important; margin-bottom: -1.5rem !important; } .my-sm-n5 { margin-top: -3rem !important; margin-bottom: -3rem !important; } .mt-sm-n1 { margin-top: -0.25rem !important; } .mt-sm-n2 { margin-top: -0.5rem !important; } .mt-sm-n3 { margin-top: -1rem !important; } .mt-sm-n4 { margin-top: -1.5rem !important; } .mt-sm-n5 { margin-top: -3rem !important; } .me-sm-n1 { margin-right: -0.25rem !important; } .me-sm-n2 { margin-right: -0.5rem !important; } .me-sm-n3 { margin-right: -1rem !important; } .me-sm-n4 { margin-right: -1.5rem !important; } .me-sm-n5 { margin-right: -3rem !important; } .mb-sm-n1 { margin-bottom: -0.25rem !important; } .mb-sm-n2 { margin-bottom: -0.5rem !important; } .mb-sm-n3 { margin-bottom: -1rem !important; } .mb-sm-n4 { margin-bottom: -1.5rem !important; } .mb-sm-n5 { margin-bottom: -3rem !important; } .ms-sm-n1 { margin-left: -0.25rem !important; } .ms-sm-n2 { margin-left: -0.5rem !important; } .ms-sm-n3 { margin-left: -1rem !important; } .ms-sm-n4 { margin-left: -1.5rem !important; } .ms-sm-n5 { margin-left: -3rem !important; } .p-sm-0 { padding: 0 !important; } .p-sm-1 { padding: 0.25rem !important; } .p-sm-2 { padding: 0.5rem !important; } .p-sm-3 { padding: 1rem !important; } .p-sm-4 { padding: 1.5rem !important; } .p-sm-5 { padding: 3rem !important; } .px-sm-0 { padding-right: 0 !important; padding-left: 0 !important; } .px-sm-1 { padding-right: 0.25rem !important; padding-left: 0.25rem !important; } .px-sm-2 { padding-right: 0.5rem !important; padding-left: 0.5rem !important; } .px-sm-3 { padding-right: 1rem !important; padding-left: 1rem !important; } .px-sm-4 { padding-right: 1.5rem !important; padding-left: 1.5rem !important; } .px-sm-5 { padding-right: 3rem !important; padding-left: 3rem !important; } .py-sm-0 { padding-top: 0 !important; padding-bottom: 0 !important; } .py-sm-1 { padding-top: 0.25rem !important; padding-bottom: 0.25rem !important; } .py-sm-2 { padding-top: 0.5rem !important; padding-bottom: 0.5rem !important; } .py-sm-3 { padding-top: 1rem !important; padding-bottom: 1rem !important; } .py-sm-4 { padding-top: 1.5rem !important; padding-bottom: 1.5rem !important; } .py-sm-5 { padding-top: 3rem !important; padding-bottom: 3rem !important; } .pt-sm-0 { padding-top: 0 !important; } .pt-sm-1 { padding-top: 0.25rem !important; } .pt-sm-2 { padding-top: 0.5rem !important; } .pt-sm-3 { padding-top: 1rem !important; } .pt-sm-4 { padding-top: 1.5rem !important; } .pt-sm-5 { padding-top: 3rem !important; } .pe-sm-0 { padding-right: 0 !important; } .pe-sm-1 { padding-right: 0.25rem !important; } .pe-sm-2 { padding-right: 0.5rem !important; } .pe-sm-3 { padding-right: 1rem !important; } .pe-sm-4 { padding-right: 1.5rem !important; } .pe-sm-5 { padding-right: 3rem !important; } .pb-sm-0 { padding-bottom: 0 !important; } .pb-sm-1 { padding-bottom: 0.25rem !important; } .pb-sm-2 { padding-bottom: 0.5rem !important; } .pb-sm-3 { padding-bottom: 1rem !important; } .pb-sm-4 { padding-bottom: 1.5rem !important; } .pb-sm-5 { padding-bottom: 3rem !important; } .ps-sm-0 { padding-left: 0 !important; } .ps-sm-1 { padding-left: 0.25rem !important; } .ps-sm-2 { padding-left: 0.5rem !important; } .ps-sm-3 { padding-left: 1rem !important; } .ps-sm-4 { padding-left: 1.5rem !important; } .ps-sm-5 { padding-left: 3rem !important; } .gap-sm-0 { gap: 0 !important; } .gap-sm-1 { gap: 0.25rem !important; } .gap-sm-2 { gap: 0.5rem !important; } .gap-sm-3 { gap: 1rem !important; } .gap-sm-4 { gap: 1.5rem !important; } .gap-sm-5 { gap: 3rem !important; } .row-gap-sm-0 { row-gap: 0 !important; } .row-gap-sm-1 { row-gap: 0.25rem !important; } .row-gap-sm-2 { row-gap: 0.5rem !important; } .row-gap-sm-3 { row-gap: 1rem !important; } .row-gap-sm-4 { row-gap: 1.5rem !important; } .row-gap-sm-5 { row-gap: 3rem !important; } .column-gap-sm-0 { column-gap: 0 !important; } .column-gap-sm-1 { column-gap: 0.25rem !important; } .column-gap-sm-2 { column-gap: 0.5rem !important; } .column-gap-sm-3 { column-gap: 1rem !important; } .column-gap-sm-4 { column-gap: 1.5rem !important; } .column-gap-sm-5 { column-gap: 3rem !important; } .text-sm-start { text-align: left !important; } .text-sm-end { text-align: right !important; } .text-sm-center { text-align: center !important; } } @media (min-width: 768px) { .float-md-start { float: left !important; } .float-md-end { float: right !important; } .float-md-none { float: none !important; } .object-fit-md-contain { object-fit: contain !important; } .object-fit-md-cover { object-fit: cover !important; } .object-fit-md-fill { object-fit: fill !important; } .object-fit-md-scale { object-fit: scale-down !important; } .object-fit-md-none { object-fit: none !important; } .d-md-inline { display: inline !important; } .d-md-inline-block { display: inline-block !important; } .d-md-block { display: block !important; } .d-md-grid { display: grid !important; } .d-md-inline-grid { display: inline-grid !important; } .d-md-table { display: table !important; } .d-md-table-row { display: table-row !important; } .d-md-table-cell { display: table-cell !important; } .d-md-flex { display: flex !important; } .d-md-inline-flex { display: inline-flex !important; } .d-md-none { display: none !important; } .flex-md-fill { flex: 1 1 auto !important; } .flex-md-row { flex-direction: row !important; } .flex-md-column { flex-direction: column !important; } .flex-md-row-reverse { flex-direction: row-reverse !important; } .flex-md-column-reverse { flex-direction: column-reverse !important; } .flex-md-grow-0 { flex-grow: 0 !important; } .flex-md-grow-1 { flex-grow: 1 !important; } .flex-md-shrink-0 { flex-shrink: 0 !important; } .flex-md-shrink-1 { flex-shrink: 1 !important; } .flex-md-wrap { flex-wrap: wrap !important; } .flex-md-nowrap { flex-wrap: nowrap !important; } .flex-md-wrap-reverse { flex-wrap: wrap-reverse !important; } .justify-content-md-start { justify-content: flex-start !important; } .justify-content-md-end { justify-content: flex-end !important; } .justify-content-md-center { justify-content: center !important; } .justify-content-md-between { justify-content: space-between !important; } .justify-content-md-around { justify-content: space-around !important; } .justify-content-md-evenly { justify-content: space-evenly !important; } .align-items-md-start { align-items: flex-start !important; } .align-items-md-end { align-items: flex-end !important; } .align-items-md-center { align-items: center !important; } .align-items-md-baseline { align-items: baseline !important; } .align-items-md-stretch { align-items: stretch !important; } .align-content-md-start { align-content: flex-start !important; } .align-content-md-end { align-content: flex-end !important; } .align-content-md-center { align-content: center !important; } .align-content-md-between { align-content: space-between !important; } .align-content-md-around { align-content: space-around !important; } .align-content-md-stretch { align-content: stretch !important; } .align-self-md-auto { align-self: auto !important; } .align-self-md-start { align-self: flex-start !important; } .align-self-md-end { align-self: flex-end !important; } .align-self-md-center { align-self: center !important; } .align-self-md-baseline { align-self: baseline !important; } .align-self-md-stretch { align-self: stretch !important; } .order-md-first { order: -1 !important; } .order-md-0 { order: 0 !important; } .order-md-1 { order: 1 !important; } .order-md-2 { order: 2 !important; } .order-md-3 { order: 3 !important; } .order-md-4 { order: 4 !important; } .order-md-5 { order: 5 !important; } .order-md-last { order: 6 !important; } .m-md-0 { margin: 0 !important; } .m-md-1 { margin: 0.25rem !important; } .m-md-2 { margin: 0.5rem !important; } .m-md-3 { margin: 1rem !important; } .m-md-4 { margin: 1.5rem !important; } .m-md-5 { margin: 3rem !important; } .m-md-auto { margin: auto !important; } .mx-md-0 { margin-right: 0 !important; margin-left: 0 !important; } .mx-md-1 { margin-right: 0.25rem !important; margin-left: 0.25rem !important; } .mx-md-2 { margin-right: 0.5rem !important; margin-left: 0.5rem !important; } .mx-md-3 { margin-right: 1rem !important; margin-left: 1rem !important; } .mx-md-4 { margin-right: 1.5rem !important; margin-left: 1.5rem !important; } .mx-md-5 { margin-right: 3rem !important; margin-left: 3rem !important; } .mx-md-auto { margin-right: auto !important; margin-left: auto !important; } .my-md-0 { margin-top: 0 !important; margin-bottom: 0 !important; } .my-md-1 { margin-top: 0.25rem !important; margin-bottom: 0.25rem !important; } .my-md-2 { margin-top: 0.5rem !important; margin-bottom: 0.5rem !important; } .my-md-3 { margin-top: 1rem !important; margin-bottom: 1rem !important; } .my-md-4 { margin-top: 1.5rem !important; margin-bottom: 1.5rem !important; } .my-md-5 { margin-top: 3rem !important; margin-bottom: 3rem !important; } .my-md-auto { margin-top: auto !important; margin-bottom: auto !important; } .mt-md-0 { margin-top: 0 !important; } .mt-md-1 { margin-top: 0.25rem !important; } .mt-md-2 { margin-top: 0.5rem !important; } .mt-md-3 { margin-top: 1rem !important; } .mt-md-4 { margin-top: 1.5rem !important; } .mt-md-5 { margin-top: 3rem !important; } .mt-md-auto { margin-top: auto !important; } .me-md-0 { margin-right: 0 !important; } .me-md-1 { margin-right: 0.25rem !important; } .me-md-2 { margin-right: 0.5rem !important; } .me-md-3 { margin-right: 1rem !important; } .me-md-4 { margin-right: 1.5rem !important; } .me-md-5 { margin-right: 3rem !important; } .me-md-auto { margin-right: auto !important; } .mb-md-0 { margin-bottom: 0 !important; } .mb-md-1 { margin-bottom: 0.25rem !important; } .mb-md-2 { margin-bottom: 0.5rem !important; } .mb-md-3 { margin-bottom: 1rem !important; } .mb-md-4 { margin-bottom: 1.5rem !important; } .mb-md-5 { margin-bottom: 3rem !important; } .mb-md-auto { margin-bottom: auto !important; } .ms-md-0 { margin-left: 0 !important; } .ms-md-1 { margin-left: 0.25rem !important; } .ms-md-2 { margin-left: 0.5rem !important; } .ms-md-3 { margin-left: 1rem !important; } .ms-md-4 { margin-left: 1.5rem !important; } .ms-md-5 { margin-left: 3rem !important; } .ms-md-auto { margin-left: auto !important; } .m-md-n1 { margin: -0.25rem !important; } .m-md-n2 { margin: -0.5rem !important; } .m-md-n3 { margin: -1rem !important; } .m-md-n4 { margin: -1.5rem !important; } .m-md-n5 { margin: -3rem !important; } .mx-md-n1 { margin-right: -0.25rem !important; margin-left: -0.25rem !important; } .mx-md-n2 { margin-right: -0.5rem !important; margin-left: -0.5rem !important; } .mx-md-n3 { margin-right: -1rem !important; margin-left: -1rem !important; } .mx-md-n4 { margin-right: -1.5rem !important; margin-left: -1.5rem !important; } .mx-md-n5 { margin-right: -3rem !important; margin-left: -3rem !important; } .my-md-n1 { margin-top: -0.25rem !important; margin-bottom: -0.25rem !important; } .my-md-n2 { margin-top: -0.5rem !important; margin-bottom: -0.5rem !important; } .my-md-n3 { margin-top: -1rem !important; margin-bottom: -1rem !important; } .my-md-n4 { margin-top: -1.5rem !important; margin-bottom: -1.5rem !important; } .my-md-n5 { margin-top: -3rem !important; margin-bottom: -3rem !important; } .mt-md-n1 { margin-top: -0.25rem !important; } .mt-md-n2 { margin-top: -0.5rem !important; } .mt-md-n3 { margin-top: -1rem !important; } .mt-md-n4 { margin-top: -1.5rem !important; } .mt-md-n5 { margin-top: -3rem !important; } .me-md-n1 { margin-right: -0.25rem !important; } .me-md-n2 { margin-right: -0.5rem !important; } .me-md-n3 { margin-right: -1rem !important; } .me-md-n4 { margin-right: -1.5rem !important; } .me-md-n5 { margin-right: -3rem !important; } .mb-md-n1 { margin-bottom: -0.25rem !important; } .mb-md-n2 { margin-bottom: -0.5rem !important; } .mb-md-n3 { margin-bottom: -1rem !important; } .mb-md-n4 { margin-bottom: -1.5rem !important; } .mb-md-n5 { margin-bottom: -3rem !important; } .ms-md-n1 { margin-left: -0.25rem !important; } .ms-md-n2 { margin-left: -0.5rem !important; } .ms-md-n3 { margin-left: -1rem !important; } .ms-md-n4 { margin-left: -1.5rem !important; } .ms-md-n5 { margin-left: -3rem !important; } .p-md-0 { padding: 0 !important; } .p-md-1 { padding: 0.25rem !important; } .p-md-2 { padding: 0.5rem !important; } .p-md-3 { padding: 1rem !important; } .p-md-4 { padding: 1.5rem !important; } .p-md-5 { padding: 3rem !important; } .px-md-0 { padding-right: 0 !important; padding-left: 0 !important; } .px-md-1 { padding-right: 0.25rem !important; padding-left: 0.25rem !important; } .px-md-2 { padding-right: 0.5rem !important; padding-left: 0.5rem !important; } .px-md-3 { padding-right: 1rem !important; padding-left: 1rem !important; } .px-md-4 { padding-right: 1.5rem !important; padding-left: 1.5rem !important; } .px-md-5 { padding-right: 3rem !important; padding-left: 3rem !important; } .py-md-0 { padding-top: 0 !important; padding-bottom: 0 !important; } .py-md-1 { padding-top: 0.25rem !important; padding-bottom: 0.25rem !important; } .py-md-2 { padding-top: 0.5rem !important; padding-bottom: 0.5rem !important; } .py-md-3 { padding-top: 1rem !important; padding-bottom: 1rem !important; } .py-md-4 { padding-top: 1.5rem !important; padding-bottom: 1.5rem !important; } .py-md-5 { padding-top: 3rem !important; padding-bottom: 3rem !important; } .pt-md-0 { padding-top: 0 !important; } .pt-md-1 { padding-top: 0.25rem !important; } .pt-md-2 { padding-top: 0.5rem !important; } .pt-md-3 { padding-top: 1rem !important; } .pt-md-4 { padding-top: 1.5rem !important; } .pt-md-5 { padding-top: 3rem !important; } .pe-md-0 { padding-right: 0 !important; } .pe-md-1 { padding-right: 0.25rem !important; } .pe-md-2 { padding-right: 0.5rem !important; } .pe-md-3 { padding-right: 1rem !important; } .pe-md-4 { padding-right: 1.5rem !important; } .pe-md-5 { padding-right: 3rem !important; } .pb-md-0 { padding-bottom: 0 !important; } .pb-md-1 { padding-bottom: 0.25rem !important; } .pb-md-2 { padding-bottom: 0.5rem !important; } .pb-md-3 { padding-bottom: 1rem !important; } .pb-md-4 { padding-bottom: 1.5rem !important; } .pb-md-5 { padding-bottom: 3rem !important; } .ps-md-0 { padding-left: 0 !important; } .ps-md-1 { padding-left: 0.25rem !important; } .ps-md-2 { padding-left: 0.5rem !important; } .ps-md-3 { padding-left: 1rem !important; } .ps-md-4 { padding-left: 1.5rem !important; } .ps-md-5 { padding-left: 3rem !important; } .gap-md-0 { gap: 0 !important; } .gap-md-1 { gap: 0.25rem !important; } .gap-md-2 { gap: 0.5rem !important; } .gap-md-3 { gap: 1rem !important; } .gap-md-4 { gap: 1.5rem !important; } .gap-md-5 { gap: 3rem !important; } .row-gap-md-0 { row-gap: 0 !important; } .row-gap-md-1 { row-gap: 0.25rem !important; } .row-gap-md-2 { row-gap: 0.5rem !important; } .row-gap-md-3 { row-gap: 1rem !important; } .row-gap-md-4 { row-gap: 1.5rem !important; } .row-gap-md-5 { row-gap: 3rem !important; } .column-gap-md-0 { column-gap: 0 !important; } .column-gap-md-1 { column-gap: 0.25rem !important; } .column-gap-md-2 { column-gap: 0.5rem !important; } .column-gap-md-3 { column-gap: 1rem !important; } .column-gap-md-4 { column-gap: 1.5rem !important; } .column-gap-md-5 { column-gap: 3rem !important; } .text-md-start { text-align: left !important; } .text-md-end { text-align: right !important; } .text-md-center { text-align: center !important; } } @media (min-width: 992px) { .float-lg-start { float: left !important; } .float-lg-end { float: right !important; } .float-lg-none { float: none !important; } .object-fit-lg-contain { object-fit: contain !important; } .object-fit-lg-cover { object-fit: cover !important; } .object-fit-lg-fill { object-fit: fill !important; } .object-fit-lg-scale { object-fit: scale-down !important; } .object-fit-lg-none { object-fit: none !important; } .d-lg-inline { display: inline !important; } .d-lg-inline-block { display: inline-block !important; } .d-lg-block { display: block !important; } .d-lg-grid { display: grid !important; } .d-lg-inline-grid { display: inline-grid !important; } .d-lg-table { display: table !important; } .d-lg-table-row { display: table-row !important; } .d-lg-table-cell { display: table-cell !important; } .d-lg-flex { display: flex !important; } .d-lg-inline-flex { display: inline-flex !important; } .d-lg-none { display: none !important; } .flex-lg-fill { flex: 1 1 auto !important; } .flex-lg-row { flex-direction: row !important; } .flex-lg-column { flex-direction: column !important; } .flex-lg-row-reverse { flex-direction: row-reverse !important; } .flex-lg-column-reverse { flex-direction: column-reverse !important; } .flex-lg-grow-0 { flex-grow: 0 !important; } .flex-lg-grow-1 { flex-grow: 1 !important; } .flex-lg-shrink-0 { flex-shrink: 0 !important; } .flex-lg-shrink-1 { flex-shrink: 1 !important; } .flex-lg-wrap { flex-wrap: wrap !important; } .flex-lg-nowrap { flex-wrap: nowrap !important; } .flex-lg-wrap-reverse { flex-wrap: wrap-reverse !important; } .justify-content-lg-start { justify-content: flex-start !important; } .justify-content-lg-end { justify-content: flex-end !important; } .justify-content-lg-center { justify-content: center !important; } .justify-content-lg-between { justify-content: space-between !important; } .justify-content-lg-around { justify-content: space-around !important; } .justify-content-lg-evenly { justify-content: space-evenly !important; } .align-items-lg-start { align-items: flex-start !important; } .align-items-lg-end { align-items: flex-end !important; } .align-items-lg-center { align-items: center !important; } .align-items-lg-baseline { align-items: baseline !important; } .align-items-lg-stretch { align-items: stretch !important; } .align-content-lg-start { align-content: flex-start !important; } .align-content-lg-end { align-content: flex-end !important; } .align-content-lg-center { align-content: center !important; } .align-content-lg-between { align-content: space-between !important; } .align-content-lg-around { align-content: space-around !important; } .align-content-lg-stretch { align-content: stretch !important; } .align-self-lg-auto { align-self: auto !important; } .align-self-lg-start { align-self: flex-start !important; } .align-self-lg-end { align-self: flex-end !important; } .align-self-lg-center { align-self: center !important; } .align-self-lg-baseline { align-self: baseline !important; } .align-self-lg-stretch { align-self: stretch !important; } .order-lg-first { order: -1 !important; } .order-lg-0 { order: 0 !important; } .order-lg-1 { order: 1 !important; } .order-lg-2 { order: 2 !important; } .order-lg-3 { order: 3 !important; } .order-lg-4 { order: 4 !important; } .order-lg-5 { order: 5 !important; } .order-lg-last { order: 6 !important; } .m-lg-0 { margin: 0 !important; } .m-lg-1 { margin: 0.25rem !important; } .m-lg-2 { margin: 0.5rem !important; } .m-lg-3 { margin: 1rem !important; } .m-lg-4 { margin: 1.5rem !important; } .m-lg-5 { margin: 3rem !important; } .m-lg-auto { margin: auto !important; } .mx-lg-0 { margin-right: 0 !important; margin-left: 0 !important; } .mx-lg-1 { margin-right: 0.25rem !important; margin-left: 0.25rem !important; } .mx-lg-2 { margin-right: 0.5rem !important; margin-left: 0.5rem !important; } .mx-lg-3 { margin-right: 1rem !important; margin-left: 1rem !important; } .mx-lg-4 { margin-right: 1.5rem !important; margin-left: 1.5rem !important; } .mx-lg-5 { margin-right: 3rem !important; margin-left: 3rem !important; } .mx-lg-auto { margin-right: auto !important; margin-left: auto !important; } .my-lg-0 { margin-top: 0 !important; margin-bottom: 0 !important; } .my-lg-1 { margin-top: 0.25rem !important; margin-bottom: 0.25rem !important; } .my-lg-2 { margin-top: 0.5rem !important; margin-bottom: 0.5rem !important; } .my-lg-3 { margin-top: 1rem !important; margin-bottom: 1rem !important; } .my-lg-4 { margin-top: 1.5rem !important; margin-bottom: 1.5rem !important; } .my-lg-5 { margin-top: 3rem !important; margin-bottom: 3rem !important; } .my-lg-auto { margin-top: auto !important; margin-bottom: auto !important; } .mt-lg-0 { margin-top: 0 !important; } .mt-lg-1 { margin-top: 0.25rem !important; } .mt-lg-2 { margin-top: 0.5rem !important; } .mt-lg-3 { margin-top: 1rem !important; } .mt-lg-4 { margin-top: 1.5rem !important; } .mt-lg-5 { margin-top: 3rem !important; } .mt-lg-auto { margin-top: auto !important; } .me-lg-0 { margin-right: 0 !important; } .me-lg-1 { margin-right: 0.25rem !important; } .me-lg-2 { margin-right: 0.5rem !important; } .me-lg-3 { margin-right: 1rem !important; } .me-lg-4 { margin-right: 1.5rem !important; } .me-lg-5 { margin-right: 3rem !important; } .me-lg-auto { margin-right: auto !important; } .mb-lg-0 { margin-bottom: 0 !important; } .mb-lg-1 { margin-bottom: 0.25rem !important; } .mb-lg-2 { margin-bottom: 0.5rem !important; } .mb-lg-3 { margin-bottom: 1rem !important; } .mb-lg-4 { margin-bottom: 1.5rem !important; } .mb-lg-5 { margin-bottom: 3rem !important; } .mb-lg-auto { margin-bottom: auto !important; } .ms-lg-0 { margin-left: 0 !important; } .ms-lg-1 { margin-left: 0.25rem !important; } .ms-lg-2 { margin-left: 0.5rem !important; } .ms-lg-3 { margin-left: 1rem !important; } .ms-lg-4 { margin-left: 1.5rem !important; } .ms-lg-5 { margin-left: 3rem !important; } .ms-lg-auto { margin-left: auto !important; } .m-lg-n1 { margin: -0.25rem !important; } .m-lg-n2 { margin: -0.5rem !important; } .m-lg-n3 { margin: -1rem !important; } .m-lg-n4 { margin: -1.5rem !important; } .m-lg-n5 { margin: -3rem !important; } .mx-lg-n1 { margin-right: -0.25rem !important; margin-left: -0.25rem !important; } .mx-lg-n2 { margin-right: -0.5rem !important; margin-left: -0.5rem !important; } .mx-lg-n3 { margin-right: -1rem !important; margin-left: -1rem !important; } .mx-lg-n4 { margin-right: -1.5rem !important; margin-left: -1.5rem !important; } .mx-lg-n5 { margin-right: -3rem !important; margin-left: -3rem !important; } .my-lg-n1 { margin-top: -0.25rem !important; margin-bottom: -0.25rem !important; } .my-lg-n2 { margin-top: -0.5rem !important; margin-bottom: -0.5rem !important; } .my-lg-n3 { margin-top: -1rem !important; margin-bottom: -1rem !important; } .my-lg-n4 { margin-top: -1.5rem !important; margin-bottom: -1.5rem !important; } .my-lg-n5 { margin-top: -3rem !important; margin-bottom: -3rem !important; } .mt-lg-n1 { margin-top: -0.25rem !important; } .mt-lg-n2 { margin-top: -0.5rem !important; } .mt-lg-n3 { margin-top: -1rem !important; } .mt-lg-n4 { margin-top: -1.5rem !important; } .mt-lg-n5 { margin-top: -3rem !important; } .me-lg-n1 { margin-right: -0.25rem !important; } .me-lg-n2 { margin-right: -0.5rem !important; } .me-lg-n3 { margin-right: -1rem !important; } .me-lg-n4 { margin-right: -1.5rem !important; } .me-lg-n5 { margin-right: -3rem !important; } .mb-lg-n1 { margin-bottom: -0.25rem !important; } .mb-lg-n2 { margin-bottom: -0.5rem !important; } .mb-lg-n3 { margin-bottom: -1rem !important; } .mb-lg-n4 { margin-bottom: -1.5rem !important; } .mb-lg-n5 { margin-bottom: -3rem !important; } .ms-lg-n1 { margin-left: -0.25rem !important; } .ms-lg-n2 { margin-left: -0.5rem !important; } .ms-lg-n3 { margin-left: -1rem !important; } .ms-lg-n4 { margin-left: -1.5rem !important; } .ms-lg-n5 { margin-left: -3rem !important; } .p-lg-0 { padding: 0 !important; } .p-lg-1 { padding: 0.25rem !important; } .p-lg-2 { padding: 0.5rem !important; } .p-lg-3 { padding: 1rem !important; } .p-lg-4 { padding: 1.5rem !important; } .p-lg-5 { padding: 3rem !important; } .px-lg-0 { padding-right: 0 !important; padding-left: 0 !important; } .px-lg-1 { padding-right: 0.25rem !important; padding-left: 0.25rem !important; } .px-lg-2 { padding-right: 0.5rem !important; padding-left: 0.5rem !important; } .px-lg-3 { padding-right: 1rem !important; padding-left: 1rem !important; } .px-lg-4 { padding-right: 1.5rem !important; padding-left: 1.5rem !important; } .px-lg-5 { padding-right: 3rem !important; padding-left: 3rem !important; } .py-lg-0 { padding-top: 0 !important; padding-bottom: 0 !important; } .py-lg-1 { padding-top: 0.25rem !important; padding-bottom: 0.25rem !important; } .py-lg-2 { padding-top: 0.5rem !important; padding-bottom: 0.5rem !important; } .py-lg-3 { padding-top: 1rem !important; padding-bottom: 1rem !important; } .py-lg-4 { padding-top: 1.5rem !important; padding-bottom: 1.5rem !important; } .py-lg-5 { padding-top: 3rem !important; padding-bottom: 3rem !important; } .pt-lg-0 { padding-top: 0 !important; } .pt-lg-1 { padding-top: 0.25rem !important; } .pt-lg-2 { padding-top: 0.5rem !important; } .pt-lg-3 { padding-top: 1rem !important; } .pt-lg-4 { padding-top: 1.5rem !important; } .pt-lg-5 { padding-top: 3rem !important; } .pe-lg-0 { padding-right: 0 !important; } .pe-lg-1 { padding-right: 0.25rem !important; } .pe-lg-2 { padding-right: 0.5rem !important; } .pe-lg-3 { padding-right: 1rem !important; } .pe-lg-4 { padding-right: 1.5rem !important; } .pe-lg-5 { padding-right: 3rem !important; } .pb-lg-0 { padding-bottom: 0 !important; } .pb-lg-1 { padding-bottom: 0.25rem !important; } .pb-lg-2 { padding-bottom: 0.5rem !important; } .pb-lg-3 { padding-bottom: 1rem !important; } .pb-lg-4 { padding-bottom: 1.5rem !important; } .pb-lg-5 { padding-bottom: 3rem !important; } .ps-lg-0 { padding-left: 0 !important; } .ps-lg-1 { padding-left: 0.25rem !important; } .ps-lg-2 { padding-left: 0.5rem !important; } .ps-lg-3 { padding-left: 1rem !important; } .ps-lg-4 { padding-left: 1.5rem !important; } .ps-lg-5 { padding-left: 3rem !important; } .gap-lg-0 { gap: 0 !important; } .gap-lg-1 { gap: 0.25rem !important; } .gap-lg-2 { gap: 0.5rem !important; } .gap-lg-3 { gap: 1rem !important; } .gap-lg-4 { gap: 1.5rem !important; } .gap-lg-5 { gap: 3rem !important; } .row-gap-lg-0 { row-gap: 0 !important; } .row-gap-lg-1 { row-gap: 0.25rem !important; } .row-gap-lg-2 { row-gap: 0.5rem !important; } .row-gap-lg-3 { row-gap: 1rem !important; } .row-gap-lg-4 { row-gap: 1.5rem !important; } .row-gap-lg-5 { row-gap: 3rem !important; } .column-gap-lg-0 { column-gap: 0 !important; } .column-gap-lg-1 { column-gap: 0.25rem !important; } .column-gap-lg-2 { column-gap: 0.5rem !important; } .column-gap-lg-3 { column-gap: 1rem !important; } .column-gap-lg-4 { column-gap: 1.5rem !important; } .column-gap-lg-5 { column-gap: 3rem !important; } .text-lg-start { text-align: left !important; } .text-lg-end { text-align: right !important; } .text-lg-center { text-align: center !important; } } @media (min-width: 1200px) { .float-xl-start { float: left !important; } .float-xl-end { float: right !important; } .float-xl-none { float: none !important; } .object-fit-xl-contain { object-fit: contain !important; } .object-fit-xl-cover { object-fit: cover !important; } .object-fit-xl-fill { object-fit: fill !important; } .object-fit-xl-scale { object-fit: scale-down !important; } .object-fit-xl-none { object-fit: none !important; } .d-xl-inline { display: inline !important; } .d-xl-inline-block { display: inline-block !important; } .d-xl-block { display: block !important; } .d-xl-grid { display: grid !important; } .d-xl-inline-grid { display: inline-grid !important; } .d-xl-table { display: table !important; } .d-xl-table-row { display: table-row !important; } .d-xl-table-cell { display: table-cell !important; } .d-xl-flex { display: flex !important; } .d-xl-inline-flex { display: inline-flex !important; } .d-xl-none { display: none !important; } .flex-xl-fill { flex: 1 1 auto !important; } .flex-xl-row { flex-direction: row !important; } .flex-xl-column { flex-direction: column !important; } .flex-xl-row-reverse { flex-direction: row-reverse !important; } .flex-xl-column-reverse { flex-direction: column-reverse !important; } .flex-xl-grow-0 { flex-grow: 0 !important; } .flex-xl-grow-1 { flex-grow: 1 !important; } .flex-xl-shrink-0 { flex-shrink: 0 !important; } .flex-xl-shrink-1 { flex-shrink: 1 !important; } .flex-xl-wrap { flex-wrap: wrap !important; } .flex-xl-nowrap { flex-wrap: nowrap !important; } .flex-xl-wrap-reverse { flex-wrap: wrap-reverse !important; } .justify-content-xl-start { justify-content: flex-start !important; } .justify-content-xl-end { justify-content: flex-end !important; } .justify-content-xl-center { justify-content: center !important; } .justify-content-xl-between { justify-content: space-between !important; } .justify-content-xl-around { justify-content: space-around !important; } .justify-content-xl-evenly { justify-content: space-evenly !important; } .align-items-xl-start { align-items: flex-start !important; } .align-items-xl-end { align-items: flex-end !important; } .align-items-xl-center { align-items: center !important; } .align-items-xl-baseline { align-items: baseline !important; } .align-items-xl-stretch { align-items: stretch !important; } .align-content-xl-start { align-content: flex-start !important; } .align-content-xl-end { align-content: flex-end !important; } .align-content-xl-center { align-content: center !important; } .align-content-xl-between { align-content: space-between !important; } .align-content-xl-around { align-content: space-around !important; } .align-content-xl-stretch { align-content: stretch !important; } .align-self-xl-auto { align-self: auto !important; } .align-self-xl-start { align-self: flex-start !important; } .align-self-xl-end { align-self: flex-end !important; } .align-self-xl-center { align-self: center !important; } .align-self-xl-baseline { align-self: baseline !important; } .align-self-xl-stretch { align-self: stretch !important; } .order-xl-first { order: -1 !important; } .order-xl-0 { order: 0 !important; } .order-xl-1 { order: 1 !important; } .order-xl-2 { order: 2 !important; } .order-xl-3 { order: 3 !important; } .order-xl-4 { order: 4 !important; } .order-xl-5 { order: 5 !important; } .order-xl-last { order: 6 !important; } .m-xl-0 { margin: 0 !important; } .m-xl-1 { margin: 0.25rem !important; } .m-xl-2 { margin: 0.5rem !important; } .m-xl-3 { margin: 1rem !important; } .m-xl-4 { margin: 1.5rem !important; } .m-xl-5 { margin: 3rem !important; } .m-xl-auto { margin: auto !important; } .mx-xl-0 { margin-right: 0 !important; margin-left: 0 !important; } .mx-xl-1 { margin-right: 0.25rem !important; margin-left: 0.25rem !important; } .mx-xl-2 { margin-right: 0.5rem !important; margin-left: 0.5rem !important; } .mx-xl-3 { margin-right: 1rem !important; margin-left: 1rem !important; } .mx-xl-4 { margin-right: 1.5rem !important; margin-left: 1.5rem !important; } .mx-xl-5 { margin-right: 3rem !important; margin-left: 3rem !important; } .mx-xl-auto { margin-right: auto !important; margin-left: auto !important; } .my-xl-0 { margin-top: 0 !important; margin-bottom: 0 !important; } .my-xl-1 { margin-top: 0.25rem !important; margin-bottom: 0.25rem !important; } .my-xl-2 { margin-top: 0.5rem !important; margin-bottom: 0.5rem !important; } .my-xl-3 { margin-top: 1rem !important; margin-bottom: 1rem !important; } .my-xl-4 { margin-top: 1.5rem !important; margin-bottom: 1.5rem !important; } .my-xl-5 { margin-top: 3rem !important; margin-bottom: 3rem !important; } .my-xl-auto { margin-top: auto !important; margin-bottom: auto !important; } .mt-xl-0 { margin-top: 0 !important; } .mt-xl-1 { margin-top: 0.25rem !important; } .mt-xl-2 { margin-top: 0.5rem !important; } .mt-xl-3 { margin-top: 1rem !important; } .mt-xl-4 { margin-top: 1.5rem !important; } .mt-xl-5 { margin-top: 3rem !important; } .mt-xl-auto { margin-top: auto !important; } .me-xl-0 { margin-right: 0 !important; } .me-xl-1 { margin-right: 0.25rem !important; } .me-xl-2 { margin-right: 0.5rem !important; } .me-xl-3 { margin-right: 1rem !important; } .me-xl-4 { margin-right: 1.5rem !important; } .me-xl-5 { margin-right: 3rem !important; } .me-xl-auto { margin-right: auto !important; } .mb-xl-0 { margin-bottom: 0 !important; } .mb-xl-1 { margin-bottom: 0.25rem !important; } .mb-xl-2 { margin-bottom: 0.5rem !important; } .mb-xl-3 { margin-bottom: 1rem !important; } .mb-xl-4 { margin-bottom: 1.5rem !important; } .mb-xl-5 { margin-bottom: 3rem !important; } .mb-xl-auto { margin-bottom: auto !important; } .ms-xl-0 { margin-left: 0 !important; } .ms-xl-1 { margin-left: 0.25rem !important; } .ms-xl-2 { margin-left: 0.5rem !important; } .ms-xl-3 { margin-left: 1rem !important; } .ms-xl-4 { margin-left: 1.5rem !important; } .ms-xl-5 { margin-left: 3rem !important; } .ms-xl-auto { margin-left: auto !important; } .m-xl-n1 { margin: -0.25rem !important; } .m-xl-n2 { margin: -0.5rem !important; } .m-xl-n3 { margin: -1rem !important; } .m-xl-n4 { margin: -1.5rem !important; } .m-xl-n5 { margin: -3rem !important; } .mx-xl-n1 { margin-right: -0.25rem !important; margin-left: -0.25rem !important; } .mx-xl-n2 { margin-right: -0.5rem !important; margin-left: -0.5rem !important; } .mx-xl-n3 { margin-right: -1rem !important; margin-left: -1rem !important; } .mx-xl-n4 { margin-right: -1.5rem !important; margin-left: -1.5rem !important; } .mx-xl-n5 { margin-right: -3rem !important; margin-left: -3rem !important; } .my-xl-n1 { margin-top: -0.25rem !important; margin-bottom: -0.25rem !important; } .my-xl-n2 { margin-top: -0.5rem !important; margin-bottom: -0.5rem !important; } .my-xl-n3 { margin-top: -1rem !important; margin-bottom: -1rem !important; } .my-xl-n4 { margin-top: -1.5rem !important; margin-bottom: -1.5rem !important; } .my-xl-n5 { margin-top: -3rem !important; margin-bottom: -3rem !important; } .mt-xl-n1 { margin-top: -0.25rem !important; } .mt-xl-n2 { margin-top: -0.5rem !important; } .mt-xl-n3 { margin-top: -1rem !important; } .mt-xl-n4 { margin-top: -1.5rem !important; } .mt-xl-n5 { margin-top: -3rem !important; } .me-xl-n1 { margin-right: -0.25rem !important; } .me-xl-n2 { margin-right: -0.5rem !important; } .me-xl-n3 { margin-right: -1rem !important; } .me-xl-n4 { margin-right: -1.5rem !important; } .me-xl-n5 { margin-right: -3rem !important; } .mb-xl-n1 { margin-bottom: -0.25rem !important; } .mb-xl-n2 { margin-bottom: -0.5rem !important; } .mb-xl-n3 { margin-bottom: -1rem !important; } .mb-xl-n4 { margin-bottom: -1.5rem !important; } .mb-xl-n5 { margin-bottom: -3rem !important; } .ms-xl-n1 { margin-left: -0.25rem !important; } .ms-xl-n2 { margin-left: -0.5rem !important; } .ms-xl-n3 { margin-left: -1rem !important; } .ms-xl-n4 { margin-left: -1.5rem !important; } .ms-xl-n5 { margin-left: -3rem !important; } .p-xl-0 { padding: 0 !important; } .p-xl-1 { padding: 0.25rem !important; } .p-xl-2 { padding: 0.5rem !important; } .p-xl-3 { padding: 1rem !important; } .p-xl-4 { padding: 1.5rem !important; } .p-xl-5 { padding: 3rem !important; } .px-xl-0 { padding-right: 0 !important; padding-left: 0 !important; } .px-xl-1 { padding-right: 0.25rem !important; padding-left: 0.25rem !important; } .px-xl-2 { padding-right: 0.5rem !important; padding-left: 0.5rem !important; } .px-xl-3 { padding-right: 1rem !important; padding-left: 1rem !important; } .px-xl-4 { padding-right: 1.5rem !important; padding-left: 1.5rem !important; } .px-xl-5 { padding-right: 3rem !important; padding-left: 3rem !important; } .py-xl-0 { padding-top: 0 !important; padding-bottom: 0 !important; } .py-xl-1 { padding-top: 0.25rem !important; padding-bottom: 0.25rem !important; } .py-xl-2 { padding-top: 0.5rem !important; padding-bottom: 0.5rem !important; } .py-xl-3 { padding-top: 1rem !important; padding-bottom: 1rem !important; } .py-xl-4 { padding-top: 1.5rem !important; padding-bottom: 1.5rem !important; } .py-xl-5 { padding-top: 3rem !important; padding-bottom: 3rem !important; } .pt-xl-0 { padding-top: 0 !important; } .pt-xl-1 { padding-top: 0.25rem !important; } .pt-xl-2 { padding-top: 0.5rem !important; } .pt-xl-3 { padding-top: 1rem !important; } .pt-xl-4 { padding-top: 1.5rem !important; } .pt-xl-5 { padding-top: 3rem !important; } .pe-xl-0 { padding-right: 0 !important; } .pe-xl-1 { padding-right: 0.25rem !important; } .pe-xl-2 { padding-right: 0.5rem !important; } .pe-xl-3 { padding-right: 1rem !important; } .pe-xl-4 { padding-right: 1.5rem !important; } .pe-xl-5 { padding-right: 3rem !important; } .pb-xl-0 { padding-bottom: 0 !important; } .pb-xl-1 { padding-bottom: 0.25rem !important; } .pb-xl-2 { padding-bottom: 0.5rem !important; } .pb-xl-3 { padding-bottom: 1rem !important; } .pb-xl-4 { padding-bottom: 1.5rem !important; } .pb-xl-5 { padding-bottom: 3rem !important; } .ps-xl-0 { padding-left: 0 !important; } .ps-xl-1 { padding-left: 0.25rem !important; } .ps-xl-2 { padding-left: 0.5rem !important; } .ps-xl-3 { padding-left: 1rem !important; } .ps-xl-4 { padding-left: 1.5rem !important; } .ps-xl-5 { padding-left: 3rem !important; } .gap-xl-0 { gap: 0 !important; } .gap-xl-1 { gap: 0.25rem !important; } .gap-xl-2 { gap: 0.5rem !important; } .gap-xl-3 { gap: 1rem !important; } .gap-xl-4 { gap: 1.5rem !important; } .gap-xl-5 { gap: 3rem !important; } .row-gap-xl-0 { row-gap: 0 !important; } .row-gap-xl-1 { row-gap: 0.25rem !important; } .row-gap-xl-2 { row-gap: 0.5rem !important; } .row-gap-xl-3 { row-gap: 1rem !important; } .row-gap-xl-4 { row-gap: 1.5rem !important; } .row-gap-xl-5 { row-gap: 3rem !important; } .column-gap-xl-0 { column-gap: 0 !important; } .column-gap-xl-1 { column-gap: 0.25rem !important; } .column-gap-xl-2 { column-gap: 0.5rem !important; } .column-gap-xl-3 { column-gap: 1rem !important; } .column-gap-xl-4 { column-gap: 1.5rem !important; } .column-gap-xl-5 { column-gap: 3rem !important; } .text-xl-start { text-align: left !important; } .text-xl-end { text-align: right !important; } .text-xl-center { text-align: center !important; } } @media (min-width: 1400px) { .float-xxl-start { float: left !important; } .float-xxl-end { float: right !important; } .float-xxl-none { float: none !important; } .object-fit-xxl-contain { object-fit: contain !important; } .object-fit-xxl-cover { object-fit: cover !important; } .object-fit-xxl-fill { object-fit: fill !important; } .object-fit-xxl-scale { object-fit: scale-down !important; } .object-fit-xxl-none { object-fit: none !important; } .d-xxl-inline { display: inline !important; } .d-xxl-inline-block { display: inline-block !important; } .d-xxl-block { display: block !important; } .d-xxl-grid { display: grid !important; } .d-xxl-inline-grid { display: inline-grid !important; } .d-xxl-table { display: table !important; } .d-xxl-table-row { display: table-row !important; } .d-xxl-table-cell { display: table-cell !important; } .d-xxl-flex { display: flex !important; } .d-xxl-inline-flex { display: inline-flex !important; } .d-xxl-none { display: none !important; } .flex-xxl-fill { flex: 1 1 auto !important; } .flex-xxl-row { flex-direction: row !important; } .flex-xxl-column { flex-direction: column !important; } .flex-xxl-row-reverse { flex-direction: row-reverse !important; } .flex-xxl-column-reverse { flex-direction: column-reverse !important; } .flex-xxl-grow-0 { flex-grow: 0 !important; } .flex-xxl-grow-1 { flex-grow: 1 !important; } .flex-xxl-shrink-0 { flex-shrink: 0 !important; } .flex-xxl-shrink-1 { flex-shrink: 1 !important; } .flex-xxl-wrap { flex-wrap: wrap !important; } .flex-xxl-nowrap { flex-wrap: nowrap !important; } .flex-xxl-wrap-reverse { flex-wrap: wrap-reverse !important; } .justify-content-xxl-start { justify-content: flex-start !important; } .justify-content-xxl-end { justify-content: flex-end !important; } .justify-content-xxl-center { justify-content: center !important; } .justify-content-xxl-between { justify-content: space-between !important; } .justify-content-xxl-around { justify-content: space-around !important; } .justify-content-xxl-evenly { justify-content: space-evenly !important; } .align-items-xxl-start { align-items: flex-start !important; } .align-items-xxl-end { align-items: flex-end !important; } .align-items-xxl-center { align-items: center !important; } .align-items-xxl-baseline { align-items: baseline !important; } .align-items-xxl-stretch { align-items: stretch !important; } .align-content-xxl-start { align-content: flex-start !important; } .align-content-xxl-end { align-content: flex-end !important; } .align-content-xxl-center { align-content: center !important; } .align-content-xxl-between { align-content: space-between !important; } .align-content-xxl-around { align-content: space-around !important; } .align-content-xxl-stretch { align-content: stretch !important; } .align-self-xxl-auto { align-self: auto !important; } .align-self-xxl-start { align-self: flex-start !important; } .align-self-xxl-end { align-self: flex-end !important; } .align-self-xxl-center { align-self: center !important; } .align-self-xxl-baseline { align-self: baseline !important; } .align-self-xxl-stretch { align-self: stretch !important; } .order-xxl-first { order: -1 !important; } .order-xxl-0 { order: 0 !important; } .order-xxl-1 { order: 1 !important; } .order-xxl-2 { order: 2 !important; } .order-xxl-3 { order: 3 !important; } .order-xxl-4 { order: 4 !important; } .order-xxl-5 { order: 5 !important; } .order-xxl-last { order: 6 !important; } .m-xxl-0 { margin: 0 !important; } .m-xxl-1 { margin: 0.25rem !important; } .m-xxl-2 { margin: 0.5rem !important; } .m-xxl-3 { margin: 1rem !important; } .m-xxl-4 { margin: 1.5rem !important; } .m-xxl-5 { margin: 3rem !important; } .m-xxl-auto { margin: auto !important; } .mx-xxl-0 { margin-right: 0 !important; margin-left: 0 !important; } .mx-xxl-1 { margin-right: 0.25rem !important; margin-left: 0.25rem !important; } .mx-xxl-2 { margin-right: 0.5rem !important; margin-left: 0.5rem !important; } .mx-xxl-3 { margin-right: 1rem !important; margin-left: 1rem !important; } .mx-xxl-4 { margin-right: 1.5rem !important; margin-left: 1.5rem !important; } .mx-xxl-5 { margin-right: 3rem !important; margin-left: 3rem !important; } .mx-xxl-auto { margin-right: auto !important; margin-left: auto !important; } .my-xxl-0 { margin-top: 0 !important; margin-bottom: 0 !important; } .my-xxl-1 { margin-top: 0.25rem !important; margin-bottom: 0.25rem !important; } .my-xxl-2 { margin-top: 0.5rem !important; margin-bottom: 0.5rem !important; } .my-xxl-3 { margin-top: 1rem !important; margin-bottom: 1rem !important; } .my-xxl-4 { margin-top: 1.5rem !important; margin-bottom: 1.5rem !important; } .my-xxl-5 { margin-top: 3rem !important; margin-bottom: 3rem !important; } .my-xxl-auto { margin-top: auto !important; margin-bottom: auto !important; } .mt-xxl-0 { margin-top: 0 !important; } .mt-xxl-1 { margin-top: 0.25rem !important; } .mt-xxl-2 { margin-top: 0.5rem !important; } .mt-xxl-3 { margin-top: 1rem !important; } .mt-xxl-4 { margin-top: 1.5rem !important; } .mt-xxl-5 { margin-top: 3rem !important; } .mt-xxl-auto { margin-top: auto !important; } .me-xxl-0 { margin-right: 0 !important; } .me-xxl-1 { margin-right: 0.25rem !important; } .me-xxl-2 { margin-right: 0.5rem !important; } .me-xxl-3 { margin-right: 1rem !important; } .me-xxl-4 { margin-right: 1.5rem !important; } .me-xxl-5 { margin-right: 3rem !important; } .me-xxl-auto { margin-right: auto !important; } .mb-xxl-0 { margin-bottom: 0 !important; } .mb-xxl-1 { margin-bottom: 0.25rem !important; } .mb-xxl-2 { margin-bottom: 0.5rem !important; } .mb-xxl-3 { margin-bottom: 1rem !important; } .mb-xxl-4 { margin-bottom: 1.5rem !important; } .mb-xxl-5 { margin-bottom: 3rem !important; } .mb-xxl-auto { margin-bottom: auto !important; } .ms-xxl-0 { margin-left: 0 !important; } .ms-xxl-1 { margin-left: 0.25rem !important; } .ms-xxl-2 { margin-left: 0.5rem !important; } .ms-xxl-3 { margin-left: 1rem !important; } .ms-xxl-4 { margin-left: 1.5rem !important; } .ms-xxl-5 { margin-left: 3rem !important; } .ms-xxl-auto { margin-left: auto !important; } .m-xxl-n1 { margin: -0.25rem !important; } .m-xxl-n2 { margin: -0.5rem !important; } .m-xxl-n3 { margin: -1rem !important; } .m-xxl-n4 { margin: -1.5rem !important; } .m-xxl-n5 { margin: -3rem !important; } .mx-xxl-n1 { margin-right: -0.25rem !important; margin-left: -0.25rem !important; } .mx-xxl-n2 { margin-right: -0.5rem !important; margin-left: -0.5rem !important; } .mx-xxl-n3 { margin-right: -1rem !important; margin-left: -1rem !important; } .mx-xxl-n4 { margin-right: -1.5rem !important; margin-left: -1.5rem !important; } .mx-xxl-n5 { margin-right: -3rem !important; margin-left: -3rem !important; } .my-xxl-n1 { margin-top: -0.25rem !important; margin-bottom: -0.25rem !important; } .my-xxl-n2 { margin-top: -0.5rem !important; margin-bottom: -0.5rem !important; } .my-xxl-n3 { margin-top: -1rem !important; margin-bottom: -1rem !important; } .my-xxl-n4 { margin-top: -1.5rem !important; margin-bottom: -1.5rem !important; } .my-xxl-n5 { margin-top: -3rem !important; margin-bottom: -3rem !important; } .mt-xxl-n1 { margin-top: -0.25rem !important; } .mt-xxl-n2 { margin-top: -0.5rem !important; } .mt-xxl-n3 { margin-top: -1rem !important; } .mt-xxl-n4 { margin-top: -1.5rem !important; } .mt-xxl-n5 { margin-top: -3rem !important; } .me-xxl-n1 { margin-right: -0.25rem !important; } .me-xxl-n2 { margin-right: -0.5rem !important; } .me-xxl-n3 { margin-right: -1rem !important; } .me-xxl-n4 { margin-right: -1.5rem !important; } .me-xxl-n5 { margin-right: -3rem !important; } .mb-xxl-n1 { margin-bottom: -0.25rem !important; } .mb-xxl-n2 { margin-bottom: -0.5rem !important; } .mb-xxl-n3 { margin-bottom: -1rem !important; } .mb-xxl-n4 { margin-bottom: -1.5rem !important; } .mb-xxl-n5 { margin-bottom: -3rem !important; } .ms-xxl-n1 { margin-left: -0.25rem !important; } .ms-xxl-n2 { margin-left: -0.5rem !important; } .ms-xxl-n3 { margin-left: -1rem !important; } .ms-xxl-n4 { margin-left: -1.5rem !important; } .ms-xxl-n5 { margin-left: -3rem !important; } .p-xxl-0 { padding: 0 !important; } .p-xxl-1 { padding: 0.25rem !important; } .p-xxl-2 { padding: 0.5rem !important; } .p-xxl-3 { padding: 1rem !important; } .p-xxl-4 { padding: 1.5rem !important; } .p-xxl-5 { padding: 3rem !important; } .px-xxl-0 { padding-right: 0 !important; padding-left: 0 !important; } .px-xxl-1 { padding-right: 0.25rem !important; padding-left: 0.25rem !important; } .px-xxl-2 { padding-right: 0.5rem !important; padding-left: 0.5rem !important; } .px-xxl-3 { padding-right: 1rem !important; padding-left: 1rem !important; } .px-xxl-4 { padding-right: 1.5rem !important; padding-left: 1.5rem !important; } .px-xxl-5 { padding-right: 3rem !important; padding-left: 3rem !important; } .py-xxl-0 { padding-top: 0 !important; padding-bottom: 0 !important; } .py-xxl-1 { padding-top: 0.25rem !important; padding-bottom: 0.25rem !important; } .py-xxl-2 { padding-top: 0.5rem !important; padding-bottom: 0.5rem !important; } .py-xxl-3 { padding-top: 1rem !important; padding-bottom: 1rem !important; } .py-xxl-4 { padding-top: 1.5rem !important; padding-bottom: 1.5rem !important; } .py-xxl-5 { padding-top: 3rem !important; padding-bottom: 3rem !important; } .pt-xxl-0 { padding-top: 0 !important; } .pt-xxl-1 { padding-top: 0.25rem !important; } .pt-xxl-2 { padding-top: 0.5rem !important; } .pt-xxl-3 { padding-top: 1rem !important; } .pt-xxl-4 { padding-top: 1.5rem !important; } .pt-xxl-5 { padding-top: 3rem !important; } .pe-xxl-0 { padding-right: 0 !important; } .pe-xxl-1 { padding-right: 0.25rem !important; } .pe-xxl-2 { padding-right: 0.5rem !important; } .pe-xxl-3 { padding-right: 1rem !important; } .pe-xxl-4 { padding-right: 1.5rem !important; } .pe-xxl-5 { padding-right: 3rem !important; } .pb-xxl-0 { padding-bottom: 0 !important; } .pb-xxl-1 { padding-bottom: 0.25rem !important; } .pb-xxl-2 { padding-bottom: 0.5rem !important; } .pb-xxl-3 { padding-bottom: 1rem !important; } .pb-xxl-4 { padding-bottom: 1.5rem !important; } .pb-xxl-5 { padding-bottom: 3rem !important; } .ps-xxl-0 { padding-left: 0 !important; } .ps-xxl-1 { padding-left: 0.25rem !important; } .ps-xxl-2 { padding-left: 0.5rem !important; } .ps-xxl-3 { padding-left: 1rem !important; } .ps-xxl-4 { padding-left: 1.5rem !important; } .ps-xxl-5 { padding-left: 3rem !important; } .gap-xxl-0 { gap: 0 !important; } .gap-xxl-1 { gap: 0.25rem !important; } .gap-xxl-2 { gap: 0.5rem !important; } .gap-xxl-3 { gap: 1rem !important; } .gap-xxl-4 { gap: 1.5rem !important; } .gap-xxl-5 { gap: 3rem !important; } .row-gap-xxl-0 { row-gap: 0 !important; } .row-gap-xxl-1 { row-gap: 0.25rem !important; } .row-gap-xxl-2 { row-gap: 0.5rem !important; } .row-gap-xxl-3 { row-gap: 1rem !important; } .row-gap-xxl-4 { row-gap: 1.5rem !important; } .row-gap-xxl-5 { row-gap: 3rem !important; } .column-gap-xxl-0 { column-gap: 0 !important; } .column-gap-xxl-1 { column-gap: 0.25rem !important; } .column-gap-xxl-2 { column-gap: 0.5rem !important; } .column-gap-xxl-3 { column-gap: 1rem !important; } .column-gap-xxl-4 { column-gap: 1.5rem !important; } .column-gap-xxl-5 { column-gap: 3rem !important; } .text-xxl-start { text-align: left !important; } .text-xxl-end { text-align: right !important; } .text-xxl-center { text-align: center !important; } } @media (min-width: 1200px) { .fs-1 { font-size: 2.5rem !important; } .fs-2 { font-size: 2rem !important; } .fs-3 { font-size: 1.75rem !important; } .fs-4 { font-size: 1.5rem !important; } } @media print { .d-print-inline { display: inline !important; } .d-print-inline-block { display: inline-block !important; } .d-print-block { display: block !important; } .d-print-grid { display: grid !important; } .d-print-inline-grid { display: inline-grid !important; } .d-print-table { display: table !important; } .d-print-table-row { display: table-row !important; } .d-print-table-cell { display: table-cell !important; } .d-print-flex { display: flex !important; } .d-print-inline-flex { display: inline-flex !important; } .d-print-none { display: none !important; } } /* jost-regular - latin */ @font-face { font-family: Jost; font-style: normal; font-weight: 400; font-display: swap; src: local("Jost Regular Regular"), local("Jost-Regular"), local("Jost* Book"), local("Jost-Book"), url("fonts/vendor/jost/jost-v4-latin-regular.woff2") format("woff2"), url("fonts/vendor/jost/jost-v4-latin-regular.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ } /* jost-500 - latin */ @font-face { font-family: Jost; font-style: normal; font-weight: 500; font-display: swap; src: local("Jost Regular Medium"), local("JostRoman-Medium"), local("Jost* Medium"), local("Jost-Medium"), url("fonts/vendor/jost/jost-v4-latin-500.woff2") format("woff2"), url("fonts/vendor/jost/jost-v4-latin-500.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ } /* jost-700 - latin */ @font-face { font-family: Jost; font-style: normal; font-weight: 700; font-display: swap; src: local("Jost Regular Bold"), local("JostRoman-Bold"), local("Jost* Bold"), local("Jost-Bold"), url("fonts/vendor/jost/jost-v4-latin-700.woff2") format("woff2"), url("fonts/vendor/jost/jost-v4-latin-700.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ } /* jost-italic - latin */ @font-face { font-family: Jost; font-style: italic; font-weight: 400; font-display: swap; src: local("Jost Italic Italic"), local("Jost-Italic"), local("Jost* BookItalic"), local("Jost-BookItalic"), url("fonts/vendor/jost/jost-v4-latin-italic.woff2") format("woff2"), url("fonts/vendor/jost/jost-v4-latin-italic.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ } /* jost-500italic - latin */ @font-face { font-family: Jost; font-style: italic; font-weight: 500; font-display: swap; src: local("Jost Italic Medium Italic"), local("JostItalic-Medium"), local("Jost* Medium Italic"), local("Jost-MediumItalic"), url("fonts/vendor/jost/jost-v4-latin-500italic.woff2") format("woff2"), url("fonts/vendor/jost/jost-v4-latin-500italic.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ } /* jost-700italic - latin */ @font-face { font-family: Jost; font-style: italic; font-weight: 700; font-display: swap; src: local("Jost Italic Bold Italic"), local("JostItalic-Bold"), local("Jost* Bold Italic"), local("Jost-BoldItalic"), url("fonts/vendor/jost/jost-v4-latin-700italic.woff2") format("woff2"), url("fonts/vendor/jost/jost-v4-latin-700italic.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ } /* Show the sun icon if the bs theme is dark */ html[data-bs-theme="dark"] .icon-tabler-sun { display: block; } html[data-bs-theme="dark"] .icon-tabler-moon { display: none; } /* Show the moon icon if the bs theme is light */ html[data-bs-theme="light"] .icon-tabler-sun { display: none; } html[data-bs-theme="light"] .icon-tabler-moon { display: block; } /* .section:not(body.section) { padding-top: 5rem; padding-bottom: 5rem; } .section-lg { padding-top: 7rem; padding-bottom: 7rem; } */ /* .highlight .chroma { padding: 1rem; border-radius: var(--bs-border-radius); } */ .privacy .content, .terms .content, .about .content, .contributors .content, .blog .content, .page .content, .error404 .content, .docs.list .content, .tutorial.list .content, .showcase.list .content, .categories.list .content, .tags.list .content, .list.section .content { padding-top: 1rem; padding-bottom: 3rem; } .content img { max-width: 100%; } h6, .h6, h5, .h5, h4, .h4, h3, .h3, h2, .h2, h1, .h1 { margin-top: 2rem; margin-bottom: 1rem; } /* body.docs, body.blog { padding-top: 0; padding-bottom: 0; } */ @media (min-width: 768px) { body { font-size: 1.125rem; /* padding-top: 4rem !important; */ } h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6 { margin-bottom: 1.125rem; } } .home h1, .home .h1 { /* font-size: calc(1.375rem + 1.5vw); */ font-size: calc(1.875rem + 1.5vw); margin-top: -1rem; } a:hover, a:focus { text-decoration: underline; } .docs-navigation .card { transition: transform 0.3s; } .docs-navigation .card:hover { transform: scale(1.025); } a.btn:hover, .search-form a.search-submit:hover, a.btn:focus, .search-form a.search-submit:focus { text-decoration: none; } .section { padding-top: 5rem; padding-bottom: 5rem; } body.section { padding-top: 0; padding-bottom: 0; } .section-md { padding-top: 3rem; padding-bottom: 3rem; } .section-sm { padding-top: 1rem; padding-bottom: 1rem; } /* .section svg { display: inline-block; width: 2rem; height: 2rem; vertical-align: text-top; } */ /* body { padding-top: 3.5625rem; } */ .docs-sidebar { order: 2; } @media (min-width: 992px) { .docs-sidebar { order: 0; border-right: 1px solid #e9ecef; } @supports (position: -webkit-sticky) or (position: sticky) { .docs-sidebar { position: -webkit-sticky; position: sticky; top: 4.25rem; z-index: 1000; height: calc(100vh - 4.25rem); } .docs-sidebar-offset { top: 4.5rem; height: calc(100vh - 4.5rem); } .docs-sidebar-top { position: static; } } } @media (min-width: 1200px) { .docs-sidebar { flex: 0 1 320px; } } .docs-links { padding-bottom: 5rem; } @media (min-width: 992px) { @supports (position: -webkit-sticky) or (position: sticky) { .docs-links { max-height: calc(100vh - 4rem); overflow-y: scroll; } } } @media (min-width: 992px) { .docs-links { display: block; width: auto; margin-right: -1.5rem; padding-bottom: 4rem; } } .docs-toc { order: 2; } @supports (position: -webkit-sticky) or (position: sticky) { .docs-toc { position: -webkit-sticky; position: sticky; top: 4.25rem; height: calc(100vh - 4.25rem); overflow-y: auto; } .docs-toc-offset { top: 4.5rem; height: calc(100vh - 4.5rem); } .docs-toc-top { position: static; } } .docs-content { padding-bottom: 3rem; order: 1; } .docs-navigation { border-top: 1px solid #e9ecef; margin-top: 2rem; margin-bottom: 0; padding-top: 2rem; } .docs-navigation a { font-size: 0.9rem; } @media (min-width: 992px) { .docs-navigation { margin-bottom: -1rem; } .docs-navigation a { font-size: 1rem; } } .docs-navigation a:hover, .docs-navigation a:focus { text-decoration: none; } .navbar a:hover, .navbar a:focus { text-decoration: none; } #TableOfContents ul, #toc ul { padding-left: 0; list-style: none; } #toc a.active { color: #4f46e5; font-weight: 500; } .section-features { padding-top: 2rem; } .bg-dots { background-image: radial-gradient(#dee2e6 15%, transparent 15%); background-position: 0 0; background-size: 1rem 1rem; -webkit-mask: linear-gradient(to top, #fff, transparent); mask: linear-gradient(to top, #fff, transparent); width: 100%; height: 11rem; margin-top: -10rem; z-index: -1; } .bg-dots-md { margin-top: -11rem; } .bg-dots-lg { margin-top: -12rem; } .gradient-text { background-color: #4f46e5; background-image: linear-gradient(90deg, #4f46e5, #b3c7ff 50%, var(--sl-color-blue)); background-size: 100%; background-repeat: repeat; -webkit-background-clip: text; -moz-background-clip: text; -webkit-text-fill-color: transparent; -moz-text-fill-color: transparent; } .katex { font-size: 1.125rem; } .card-bar { border-top: 4px solid; border-image-source: linear-gradient(90deg, #4f46e5, #b3c7ff 50%, var(--sl-color-blue)); border-image-slice: 1; } .modal-backdrop { background-color: #fff; } .modal-backdrop.show { opacity: 0.7; } @media (min-width: 768px) { .modal-backdrop.show { opacity: 0; } } sup[id] { scroll-margin-top: 4.5rem; } div.footnotes { font-size: 0.875rem; } a.footnote-backref { text-decoration: none; } li input[type="checkbox"] { margin: 0.25rem; border: 1px solid #ced4da; } li input[type="checkbox"]:disabled { pointer-events: none; filter: none; opacity: 1; } li input[type="checkbox"]:checked { background-color: #5d2f86; border-color: #5d2f86; } [data-bs-theme="dark"] li input[type="checkbox"] { border: 1px solid #6c757d; } [data-bs-theme="dark"] li input[type="checkbox"]:checked { background-color: #b3c7ff; border-color: #b3c7ff; --bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%231d2d35' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/%3e%3c/svg%3e"); } .content .svg-inline { margin-bottom: 1.5rem; } .content .svg-inline:not(.svg-inline-custom) { height: 1.875rem; width: 1.875rem; stroke-width: 1.5; } /* .content .alert .icon { stroke-width: 1; margin-bottom: 0; margin-right: 0.5rem; } */ .logo-netlify-large-fullcolor-darkmode { display: none; } [data-bs-theme="dark"] .logo-netlify-large-fullcolor-lightmode { display: none; } [data-bs-theme="dark"] .logo-netlify-large-fullcolor-darkmode { display: block; } .svg-lightmode { display: block; } .svg-darkmode { display: none; } .svg-monochrome path { fill: #1d2d35; } [data-bs-theme="dark"] .svg-lightmode { display: none; } [data-bs-theme="dark"] .svg-darkmode { display: block; } [data-bs-theme="dark"] .netlify-logo path, [data-bs-theme="dark"] .netlify-monogram path { fill: #fff; } [data-bs-theme="dark"] .svg-monochrome path { fill: var(--sl-color-gray-1); } hr { border-color: #808080; } [data-bs-theme="dark"] hr { border-color: var(--sl-color-gray-3); } .container-fw { min-width: 0; } .card-nav { column-gap: 1rem; } .card-nav .card { margin: 0.5rem 0; } .card-nav .card:hover { border: 1px solid #d9d9d9; background-color: var(--sl-color-gray-7); } [data-bs-theme="dark"] .card-nav .card { border: 1px solid #353841; } [data-bs-theme="dark"] .card-nav .card:hover { border: 1px solid #888c96; background-color: var(--sl-color-gray-6); } .highlight > .chroma { border: 1px solid color-mix(in srgb, var(--sl-color-gray-5), transparent 25%); } /* Background */ .bg { background-color: var(--sl-color-gray-7); } /* PreWrapper */ .chroma { background-color: var(--sl-color-gray-7); } /* Other */ /* Error */ .chroma .err { color: inherit; } /* CodeLine */ /* LineLink */ .chroma .lnlinks { outline: none; text-decoration: none; color: inherit; } /* LineTableTD */ .chroma .lntd { vertical-align: top; padding: 0; margin: 0; border: 0; } /* LineTable */ .chroma .lntable { border-spacing: 0; padding: 0; margin: 0; border: 0; } /* LineHighlight */ .chroma .hl { background-color: #0000001a; } .chroma .hl { border-inline-start: 0.15rem solid #00000055; margin-left: -1rem; margin-right: -1rem; padding-left: 1rem; padding-right: 1rem; } .chroma .hl .ln { margin-left: -0.15rem; } /* LineNumbersTable */ .chroma .lnt { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em; color: #7f7f7f; } /* LineNumbers */ .chroma .ln { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em; color: #7f7f7f; } /* Line */ .chroma .line { display: flex; } /* Keyword */ .chroma .k { color: #000000; font-weight: bold; } /* KeywordConstant */ .chroma .kc { color: #000000; font-weight: bold; } /* KeywordDeclaration */ .chroma .kd { color: #000000; font-weight: bold; } /* KeywordNamespace */ .chroma .kn { color: #000000; font-weight: bold; } /* KeywordPseudo */ .chroma .kp { color: #000000; font-weight: bold; } /* KeywordReserved */ .chroma .kr { color: #000000; font-weight: bold; } /* KeywordType */ .chroma .kt { color: #445588; font-weight: bold; } /* Name */ /* NameAttribute */ .chroma .na { color: #008080; } /* NameBuiltin */ .chroma .nb { color: #0086b3; } /* NameBuiltinPseudo */ .chroma .bp { color: #999999; } /* NameClass */ .chroma .nc { color: #445588; font-weight: bold; } /* NameConstant */ .chroma .no { color: #008080; } /* NameDecorator */ .chroma .nd { color: #3c5d5d; font-weight: bold; } /* NameEntity */ .chroma .ni { color: #800080; } /* NameException */ .chroma .ne { color: #990000; font-weight: bold; } /* NameFunction */ .chroma .nf { color: #990000; font-weight: bold; } /* NameFunctionMagic */ /* NameLabel */ .chroma .nl { color: #990000; font-weight: bold; } /* NameNamespace */ .chroma .nn { color: #555555; } /* NameOther */ /* NameProperty */ /* NameTag */ .chroma .nt { color: #000080; } /* NameVariable */ .chroma .nv { color: #008080; } /* NameVariableClass */ .chroma .vc { color: #008080; } /* NameVariableGlobal */ .chroma .vg { color: #008080; } /* NameVariableInstance */ .chroma .vi { color: #008080; } /* NameVariableMagic */ /* Literal */ /* LiteralDate */ /* LiteralString */ .chroma .s { color: #dd1144; } /* LiteralStringAffix */ .chroma .sa { color: #dd1144; } /* LiteralStringBacktick */ .chroma .sb { color: #dd1144; } /* LiteralStringChar */ .chroma .sc { color: #dd1144; } /* LiteralStringDelimiter */ .chroma .dl { color: #dd1144; } /* LiteralStringDoc */ .chroma .sd { color: #dd1144; } /* LiteralStringDouble */ .chroma .s2 { color: #dd1144; } /* LiteralStringEscape */ .chroma .se { color: #dd1144; } /* LiteralStringHeredoc */ .chroma .sh { color: #dd1144; } /* LiteralStringInterpol */ .chroma .si { color: #dd1144; } /* LiteralStringOther */ .chroma .sx { color: #dd1144; } /* LiteralStringRegex */ .chroma .sr { color: #009926; } /* LiteralStringSingle */ .chroma .s1 { color: #dd1144; } /* LiteralStringSymbol */ .chroma .ss { color: #990073; } /* LiteralNumber */ .chroma .m { color: #009999; } /* LiteralNumberBin */ .chroma .mb { color: #009999; } /* LiteralNumberFloat */ .chroma .mf { color: #009999; } /* LiteralNumberHex */ .chroma .mh { color: #009999; } /* LiteralNumberInteger */ .chroma .mi { color: #009999; } /* LiteralNumberIntegerLong */ .chroma .il { color: #009999; } /* LiteralNumberOct */ .chroma .mo { color: #009999; } /* Operator */ .chroma .o { color: #000000; font-weight: bold; } /* OperatorWord */ .chroma .ow { color: #000000; font-weight: bold; } /* Punctuation */ /* Comment */ .chroma .c { color: #999988; font-style: italic; } /* CommentHashbang */ .chroma .ch { color: #999988; font-style: italic; } /* CommentMultiline */ .chroma .cm { color: #999988; font-style: italic; } /* CommentSingle */ .chroma .c1 { color: #999988; font-style: italic; } /* CommentSpecial */ .chroma .cs { color: #999999; font-weight: bold; font-style: italic; } /* CommentPreproc */ .chroma .cp { color: #999999; font-weight: bold; font-style: italic; } /* CommentPreprocFile */ .chroma .cpf { color: #999999; font-weight: bold; font-style: italic; } /* Generic */ /* GenericDeleted */ .chroma .gd { color: #000000; background-color: #ffdddd; } /* GenericEmph */ .chroma .ge { color: inherit; font-style: italic; } /* GenericError */ .chroma .gr { color: #aa0000; } /* GenericHeading */ .chroma .gh { color: #999999; } /* GenericInserted */ .chroma .gi { color: #000000; background-color: #ddffdd; } /* GenericOutput */ .chroma .go { color: #888888; } /* GenericPrompt */ .chroma .gp { color: #555555; } /* GenericStrong */ .chroma .gs { font-weight: bold; } /* GenericSubheading */ .chroma .gu { color: #aaaaaa; } /* GenericTraceback */ .chroma .gt { color: #aa0000; } /* GenericUnderline */ .chroma .gl { text-decoration: underline; } /* TextWhitespace */ .chroma .w { color: #bbbbbb; } [data-bs-theme="dark"] { /* Background */ /* PreWrapper */ /* Other */ /* Error */ /* CodeLine */ /* LineLink */ /* LineTableTD */ /* LineTable */ /* LineHighlight */ /* LineNumbersTable */ /* LineNumbers */ /* Line */ /* Keyword */ /* KeywordConstant */ /* KeywordDeclaration */ /* KeywordNamespace */ /* KeywordPseudo */ /* KeywordReserved */ /* KeywordType */ /* Name */ /* NameAttribute */ /* NameBuiltin */ /* NameBuiltinPseudo */ /* NameClass */ /* NameConstant */ /* NameDecorator */ /* NameEntity */ /* NameException */ /* NameFunction */ /* NameFunctionMagic */ /* NameLabel */ /* NameNamespace */ /* NameOther */ /* NameProperty */ /* NameTag */ /* NameVariable */ /* NameVariableClass */ /* NameVariableGlobal */ /* NameVariableInstance */ /* NameVariableMagic */ /* Literal */ /* LiteralDate */ /* LiteralString */ /* LiteralStringAffix */ /* LiteralStringBacktick */ /* LiteralStringChar */ /* LiteralStringDelimiter */ /* LiteralStringDoc */ /* LiteralStringDouble */ /* LiteralStringEscape */ /* LiteralStringHeredoc */ /* LiteralStringInterpol */ /* LiteralStringOther */ /* LiteralStringRegex */ /* LiteralStringSingle */ /* LiteralStringSymbol */ /* LiteralNumber */ /* LiteralNumberBin */ /* LiteralNumberFloat */ /* LiteralNumberHex */ /* LiteralNumberInteger */ /* LiteralNumberIntegerLong */ /* LiteralNumberOct */ /* Operator */ /* OperatorWord */ /* Punctuation */ /* Comment */ /* CommentHashbang */ /* CommentMultiline */ /* CommentSingle */ /* CommentSpecial */ /* CommentPreproc */ /* CommentPreprocFile */ /* Generic */ /* GenericDeleted */ /* GenericEmph */ /* GenericError */ /* GenericHeading */ /* GenericInserted */ /* GenericOutput */ /* GenericPrompt */ /* GenericStrong */ /* GenericSubheading */ /* GenericTraceback */ /* GenericUnderline */ /* TextWhitespace */ } [data-bs-theme="dark"] .highlight > .chroma { border: 1px solid color-mix(in srgb, var(--sl-color-gray-5), transparent 25%); } [data-bs-theme="dark"] .bg { color: #c9d1d9; background-color: var(--sl-color-gray-6); } [data-bs-theme="dark"] .chroma { color: #c9d1d9; background-color: var(--sl-color-gray-6); } [data-bs-theme="dark"] .chroma .err { color: inherit; } [data-bs-theme="dark"] .chroma .lnlinks { outline: none; text-decoration: none; color: inherit; } [data-bs-theme="dark"] .chroma .lntd { vertical-align: top; padding: 0; margin: 0; border: 0; } [data-bs-theme="dark"] .chroma .lntable { border-spacing: 0; padding: 0; margin: 0; border: 0; } [data-bs-theme="dark"] .chroma .hl { background-color: #ffffff17; } [data-bs-theme="dark"] .chroma .hl { border-inline-start: 0.15rem solid #ffffff40; margin-left: -1rem; margin-right: -1rem; padding-left: 1rem; padding-right: 1rem; } [data-bs-theme="dark"] .chroma .hl .ln { margin-left: -0.15rem; } [data-bs-theme="dark"] .chroma .lnt { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em; color: #64686c; } [data-bs-theme="dark"] .chroma .ln { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em; color: #6e7681; } [data-bs-theme="dark"] .chroma .line { display: flex; } [data-bs-theme="dark"] .chroma .k { color: #ff7b72; } [data-bs-theme="dark"] .chroma .kc { color: #79c0ff; } [data-bs-theme="dark"] .chroma .kd { color: #ff7b72; } [data-bs-theme="dark"] .chroma .kn { color: #ff7b72; } [data-bs-theme="dark"] .chroma .kp { color: #79c0ff; } [data-bs-theme="dark"] .chroma .kr { color: #ff7b72; } [data-bs-theme="dark"] .chroma .kt { color: #ff7b72; } [data-bs-theme="dark"] .chroma .na { color: #d2a8ff; } [data-bs-theme="dark"] .chroma .nc { color: #f0883e; font-weight: bold; } [data-bs-theme="dark"] .chroma .no { color: #79c0ff; font-weight: bold; } [data-bs-theme="dark"] .chroma .nd { color: #d2a8ff; font-weight: bold; } [data-bs-theme="dark"] .chroma .ni { color: #ffa657; } [data-bs-theme="dark"] .chroma .ne { color: #f0883e; font-weight: bold; } [data-bs-theme="dark"] .chroma .nf { color: #d2a8ff; font-weight: bold; } [data-bs-theme="dark"] .chroma .nl { color: #79c0ff; font-weight: bold; } [data-bs-theme="dark"] .chroma .nn { color: #ff7b72; } [data-bs-theme="dark"] .chroma .py { color: #79c0ff; } [data-bs-theme="dark"] .chroma .nt { color: #7ee787; } [data-bs-theme="dark"] .chroma .nv { color: #79c0ff; } [data-bs-theme="dark"] .chroma .l { color: #a5d6ff; } [data-bs-theme="dark"] .chroma .ld { color: #79c0ff; } [data-bs-theme="dark"] .chroma .s { color: #a5d6ff; } [data-bs-theme="dark"] .chroma .sa { color: #79c0ff; } [data-bs-theme="dark"] .chroma .sb { color: #a5d6ff; } [data-bs-theme="dark"] .chroma .sc { color: #a5d6ff; } [data-bs-theme="dark"] .chroma .dl { color: #79c0ff; } [data-bs-theme="dark"] .chroma .sd { color: #a5d6ff; } [data-bs-theme="dark"] .chroma .s2 { color: #a5d6ff; } [data-bs-theme="dark"] .chroma .se { color: #79c0ff; } [data-bs-theme="dark"] .chroma .sh { color: #79c0ff; } [data-bs-theme="dark"] .chroma .si { color: #a5d6ff; } [data-bs-theme="dark"] .chroma .sx { color: #a5d6ff; } [data-bs-theme="dark"] .chroma .sr { color: #79c0ff; } [data-bs-theme="dark"] .chroma .s1 { color: #a5d6ff; } [data-bs-theme="dark"] .chroma .ss { color: #a5d6ff; } [data-bs-theme="dark"] .chroma .m { color: #a5d6ff; } [data-bs-theme="dark"] .chroma .mb { color: #a5d6ff; } [data-bs-theme="dark"] .chroma .mf { color: #a5d6ff; } [data-bs-theme="dark"] .chroma .mh { color: #a5d6ff; } [data-bs-theme="dark"] .chroma .mi { color: #a5d6ff; } [data-bs-theme="dark"] .chroma .il { color: #a5d6ff; } [data-bs-theme="dark"] .chroma .mo { color: #a5d6ff; } [data-bs-theme="dark"] .chroma .o { color: inherit; font-weight: bold; } [data-bs-theme="dark"] .chroma .ow { color: #ff7b72; font-weight: bold; } [data-bs-theme="dark"] .chroma .c { color: #8b949e; font-style: italic; } [data-bs-theme="dark"] .chroma .ch { color: #8b949e; font-style: italic; } [data-bs-theme="dark"] .chroma .cm { color: #8b949e; font-style: italic; } [data-bs-theme="dark"] .chroma .c1 { color: #8b949e; font-style: italic; } [data-bs-theme="dark"] .chroma .cs { color: #8b949e; font-weight: bold; font-style: italic; } [data-bs-theme="dark"] .chroma .cp { color: #8b949e; font-weight: bold; font-style: italic; } [data-bs-theme="dark"] .chroma .cpf { color: #8b949e; font-weight: bold; font-style: italic; } [data-bs-theme="dark"] .chroma .gd { color: #ffa198; background-color: #490202; } [data-bs-theme="dark"] .chroma .ge { font-style: italic; } [data-bs-theme="dark"] .chroma .gr { color: #ffa198; } [data-bs-theme="dark"] .chroma .gh { color: #79c0ff; font-weight: bold; } [data-bs-theme="dark"] .chroma .gi { color: #56d364; background-color: #0f5323; } [data-bs-theme="dark"] .chroma .go { color: #8b949e; } [data-bs-theme="dark"] .chroma .gp { color: #8b949e; } [data-bs-theme="dark"] .chroma .gs { font-weight: bold; } [data-bs-theme="dark"] .chroma .gu { color: #79c0ff; } [data-bs-theme="dark"] .chroma .gt { color: #ff7b72; } [data-bs-theme="dark"] .chroma .gl { text-decoration: underline; } [data-bs-theme="dark"] .chroma .w { color: #6e7681; } /** Theme styles */ [data-bs-theme="dark"] { /* .dropdown-menu { @extend .dropdown-menu-dark; } */ /* .navbar-light .navbar-brand { color: $navbar-dark-color !important; } */ /* .navbar-form::after { color: $gray-600; border: 1px solid $gray-800; } */ /* pre code::-webkit-scrollbar-thumb { background: $gray-400; } code:not(.hljs) { background: $body-overlay-dark; color: $body-color-dark; } pre code:hover { scrollbar-width: thin; scrollbar-color: $border-dark transparent; } pre code::-webkit-scrollbar-thumb:hover { background: $gray-500; } */ /* .dropdown-toggle:focus, .doks-sidebar-toggle:focus { box-shadow: 0 0 0 0.2rem $focus-color-dark; } */ /* @include media-breakpoint-up(md) { .alert-dismissible .btn-close { background-size: 1.25rem; } } */ /* .btn-close:focus { box-shadow: 0 0 0 0.2rem $focus-color-dark; } */ } [data-bs-theme="dark"] h1, [data-bs-theme="dark"] .h1, [data-bs-theme="dark"] h2, [data-bs-theme="dark"] .h2, [data-bs-theme="dark"] h3, [data-bs-theme="dark"] .h3, [data-bs-theme="dark"] h4, [data-bs-theme="dark"] .h4 { color: white; } [data-bs-theme="dark"] body { background: #17181c; color: #c1c3c8; } [data-bs-theme="dark"] a { color: #b3c7ff; } [data-bs-theme="dark"] .callout a { color: inherit; } [data-bs-theme="dark"] a.text- { color: #c1c3c8 !important; } [data-bs-theme="dark"] .btn-primary { --bs-btn-color: #000; --bs-btn-bg: #b3c7ff; --bs-btn-border-color: #b3c7ff; --bs-btn-hover-color: #000; --bs-btn-hover-bg: #becfff; --bs-btn-hover-border-color: #bacdff; --bs-btn-focus-shadow-rgb: 152, 169, 217; --bs-btn-active-color: #000; --bs-btn-active-bg: #c2d2ff; --bs-btn-active-border-color: #bacdff; --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); --bs-btn-disabled-color: #000; --bs-btn-disabled-bg: #b3c7ff; --bs-btn-disabled-border-color: #b3c7ff; color: #17181c; } [data-bs-theme="dark"] .btn-outline-primary { --bs-btn-color: #b3c7ff; --bs-btn-border-color: #b3c7ff; --bs-btn-hover-color: #b3c7ff; --bs-btn-hover-bg: #b3c7ff; --bs-btn-hover-border-color: #b3c7ff; --bs-btn-focus-shadow-rgb: 178.5, 198.9, 255; --bs-btn-active-color: #000; --bs-btn-active-bg: #b3c7ff; --bs-btn-active-border-color: #b3c7ff; --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); --bs-btn-disabled-color: #b3c7ff; --bs-btn-disabled-bg: transparent; --bs-btn-disabled-border-color: #b3c7ff; --bs-gradient: none; color: #b3c7ff; } [data-bs-theme="dark"] .btn-outline-primary:hover { color: #17181c; } [data-bs-theme="dark"] .btn-doks-light { color: #c1c3c8; } [data-bs-theme="dark"] .show > .btn-doks-light, [data-bs-theme="dark"] .btn-doks-light:hover, [data-bs-theme="dark"] .btn-doks-light:active { color: #b3c7ff; } [data-bs-theme="dark"] .btn-menu svg { color: #c1c3c8; } [data-bs-theme="dark"] .doks-sidebar-toggle { color: #c1c3c8; } [data-bs-theme="dark"] .btn-menu:hover, [data-bs-theme="dark"] .btn-doks-light:hover, [data-bs-theme="dark"] .doks-sidebar-toggle:hover { background: transparent; } [data-bs-theme="dark"] .navbar, [data-bs-theme="dark"] .doks-subnavbar { background-color: rgba(23, 24, 28, 0.95); border-bottom: 1px solid #23262f; } [data-bs-theme="dark"] body.home .navbar { border-bottom: 0; } [data-bs-theme="dark"] .offcanvas-header { border-bottom: 1px solid #343a40; } [data-bs-theme="dark"] .offcanvas .nav-link, [data-bs-theme="dark"] .offcanvas .banner .nav a, .banner .nav [data-bs-theme="dark"] .offcanvas a { color: #c1c3c8; } [data-bs-theme="dark"] .offcanvas .nav-link:hover, [data-bs-theme="dark"] .offcanvas .banner .nav a:hover, .banner .nav [data-bs-theme="dark"] .offcanvas a:hover, [data-bs-theme="dark"] .offcanvas .nav-link:focus, [data-bs-theme="dark"] .offcanvas .banner .nav a:focus, .banner .nav [data-bs-theme="dark"] .offcanvas a:focus { color: #b3c7ff; } [data-bs-theme="dark"] .offcanvas .nav-link.active, [data-bs-theme="dark"] .offcanvas .banner .nav a.active, .banner .nav [data-bs-theme="dark"] .offcanvas a.active { color: #b3c7ff; } [data-bs-theme="dark"] .navbar-light .navbar-nav .nav-link, [data-bs-theme="dark"] .navbar-light .navbar-nav .banner .nav a, .banner .nav [data-bs-theme="dark"] .navbar-light .navbar-nav a { color: #c1c3c8; } [data-bs-theme="dark"] .navbar-light .navbar-nav .nav-link:hover, [data-bs-theme="dark"] .navbar-light .navbar-nav .banner .nav a:hover, .banner .nav [data-bs-theme="dark"] .navbar-light .navbar-nav a:hover, [data-bs-theme="dark"] .navbar-light .navbar-nav .nav-link:focus, [data-bs-theme="dark"] .navbar-light .navbar-nav .banner .nav a:focus, .banner .nav [data-bs-theme="dark"] .navbar-light .navbar-nav a:focus { color: #b3c7ff; } [data-bs-theme="dark"] .navbar-light .navbar-nav .nav-link.disabled, [data-bs-theme="dark"] .navbar-light .navbar-nav .banner .nav a.disabled, .banner .nav [data-bs-theme="dark"] .navbar-light .navbar-nav a.disabled { color: rgba(255, 255, 255, 0.25); } [data-bs-theme="dark"] .navbar-light .navbar-nav .show > .nav-link, [data-bs-theme="dark"] .navbar-light .navbar-nav .banner .nav .show > a, .banner .nav [data-bs-theme="dark"] .navbar-light .navbar-nav .show > a, [data-bs-theme="dark"] .navbar-light .navbar-nav .active > .nav-link, [data-bs-theme="dark"] .navbar-light .navbar-nav .banner .nav .active > a, .banner .nav [data-bs-theme="dark"] .navbar-light .navbar-nav .active > a, [data-bs-theme="dark"] .navbar-light .navbar-nav .nav-link.show, [data-bs-theme="dark"] .navbar-light .navbar-nav .banner .nav a.show, .banner .nav [data-bs-theme="dark"] .navbar-light .navbar-nav a.show, [data-bs-theme="dark"] .navbar-light .navbar-nav .nav-link.active, [data-bs-theme="dark"] .navbar-light .navbar-nav .banner .nav a.active, .banner .nav [data-bs-theme="dark"] .navbar-light .navbar-nav a.active { color: #b3c7ff; } [data-bs-theme="dark"] .navbar-light .navbar-text { color: #c1c3c8; } [data-bs-theme="dark"] .alert-primary a { color: #17181c; } [data-bs-theme="dark"] .alert-doks { background: #23262f; color: #c1c3c8; } [data-bs-theme="dark"] .alert-doks a { color: #b3c7ff; } [data-bs-theme="dark"] .page-links a { color: #c1c3c8; } [data-bs-theme="dark"] .btn-toggle-nav a { color: #c1c3c8; } [data-bs-theme="dark"] .showcase-meta a { color: #c1c3c8; } [data-bs-theme="dark"] .showcase-meta a:hover, [data-bs-theme="dark"] .showcase-meta a:focus { color: #b3c7ff; } [data-bs-theme="dark"] .docs-link:hover, [data-bs-theme="dark"] .docs-link.active, [data-bs-theme="dark"] .page-links a:hover { text-decoration: none; color: #b3c7ff; } [data-bs-theme="dark"] .btn-toggle { color: #c1c3c8; background-color: transparent; border: 0; } [data-bs-theme="dark"] .btn-toggle:hover, [data-bs-theme="dark"] .btn-toggle:focus { color: #c1c3c8; } [data-bs-theme="dark"] .btn-toggle::before { width: 1.25em; line-height: 0; content: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='rgba%28222, 226, 230, 0.75%29' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M5 14l6-6-6-6'/%3e%3c/svg%3e"); transition: transform 0.35s ease; transform-origin: 0.5em 50%; margin-bottom: 0.125rem; } [data-bs-theme="dark"] .btn-toggle[aria-expanded="true"] { color: #c1c3c8; } [data-bs-theme="dark"] .btn-toggle[aria-expanded="true"]::before { transform: rotate(90deg); } [data-bs-theme="dark"] .btn-toggle-nav a:hover, [data-bs-theme="dark"] .btn-toggle-nav a:focus { color: #b3c7ff; } [data-bs-theme="dark"] .btn-toggle-nav a.active { color: #b3c7ff; } [data-bs-theme="dark"] .navbar-light .navbar-text a { color: #b3c7ff; } [data-bs-theme="dark"] .docs-links h3.sidebar-link a, [data-bs-theme="dark"] .docs-links .sidebar-link.h3 a, [data-bs-theme="dark"] .page-links h3.sidebar-link a, [data-bs-theme="dark"] .page-links .sidebar-link.h3 a { color: #c1c3c8; } [data-bs-theme="dark"] .navbar-light .navbar-text a:hover, [data-bs-theme="dark"] .navbar-light .navbar-text a:focus { color: #b3c7ff; } [data-bs-theme="dark"] .navbar .btn-link { color: #c1c3c8; } [data-bs-theme="dark"] .content .btn-link { color: #b3c7ff; } [data-bs-theme="dark"] .content .btn-link:hover { color: #b3c7ff; } [data-bs-theme="dark"] .content img[src^="https://latex.codecogs.com/svg.latex"] { filter: invert(1); } [data-bs-theme="dark"] .navbar .btn-link:hover { color: #b3c7ff; } [data-bs-theme="dark"] .navbar .btn-link:active { color: #b3c7ff; } [data-bs-theme="dark"] .form-control.is-search, [data-bs-theme="dark"] .search-form .is-search.search-field, .search-form [data-bs-theme="dark"] .is-search.search-field, [data-bs-theme="dark"] .comment-form input.is-search[type="text"], .comment-form [data-bs-theme="dark"] input.is-search[type="text"], [data-bs-theme="dark"] .comment-form input.is-search[type="email"], .comment-form [data-bs-theme="dark"] input.is-search[type="email"], [data-bs-theme="dark"] .comment-form input.is-search[type="url"], .comment-form [data-bs-theme="dark"] input.is-search[type="url"], [data-bs-theme="dark"] .comment-form textarea.is-search, .comment-form [data-bs-theme="dark"] textarea.is-search { background: #23262f; border: 1px solid transparent; color: #dee2e6; /* background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 24 24' fill='none' stroke='%236c757d' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-search'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'%3E%3C/line%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right calc(0.375em + 0.1875rem) center; background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); */ } [data-bs-theme="dark"] .form-control.is-search:focus, [data-bs-theme="dark"] .search-form .is-search.search-field:focus, .search-form [data-bs-theme="dark"] .is-search.search-field:focus, [data-bs-theme="dark"] .comment-form input.is-search[type="text"]:focus, .comment-form [data-bs-theme="dark"] input.is-search[type="text"]:focus, [data-bs-theme="dark"] .comment-form input.is-search[type="email"]:focus, .comment-form [data-bs-theme="dark"] input.is-search[type="email"]:focus, [data-bs-theme="dark"] .comment-form input.is-search[type="url"]:focus, .comment-form [data-bs-theme="dark"] input.is-search[type="url"]:focus, [data-bs-theme="dark"] .comment-form textarea.is-search:focus, .comment-form [data-bs-theme="dark"] textarea.is-search:focus { border: 1px solid #b3c7ff; } [data-bs-theme="dark"] .doks-search::after { color: #dee2e6; border: 1px solid #495057; } [data-bs-theme="dark"] .text-dark { color: #c1c3c8 !important; } [data-bs-theme="dark"] .form-control, [data-bs-theme="dark"] .search-form .search-field, .search-form [data-bs-theme="dark"] .search-field, [data-bs-theme="dark"] .comment-form input[type="text"], .comment-form [data-bs-theme="dark"] input[type="text"], [data-bs-theme="dark"] .comment-form input[type="email"], .comment-form [data-bs-theme="dark"] input[type="email"], [data-bs-theme="dark"] .comment-form input[type="url"], .comment-form [data-bs-theme="dark"] input[type="url"], [data-bs-theme="dark"] .comment-form textarea, .comment-form [data-bs-theme="dark"] textarea { color: #dee2e6; } [data-bs-theme="dark"] .form-control::placeholder, [data-bs-theme="dark"] .search-form .search-field::placeholder, .search-form [data-bs-theme="dark"] .search-field::placeholder, [data-bs-theme="dark"] .comment-form input[type="text"]::placeholder, .comment-form [data-bs-theme="dark"] input[type="text"]::placeholder, [data-bs-theme="dark"] .comment-form input[type="email"]::placeholder, .comment-form [data-bs-theme="dark"] input[type="email"]::placeholder, [data-bs-theme="dark"] .comment-form input[type="url"]::placeholder, .comment-form [data-bs-theme="dark"] input[type="url"]::placeholder, [data-bs-theme="dark"] .comment-form textarea::placeholder, .comment-form [data-bs-theme="dark"] textarea::placeholder { color: #ced4da; opacity: 1; } [data-bs-theme="dark"] .border-top { border-top: 1px solid #23262f !important; } @media (min-width: 992px) { [data-bs-theme="dark"] .docs-sidebar { order: 0; border-right: 1px solid #23262f; } } [data-bs-theme="dark"] .docs-navigation { border-top: 1px solid #23262f; } [data-bs-theme="dark"] blockquote { border-left: 3px solid #23262f; } [data-bs-theme="dark"] .footer { border-top: 1px solid #23262f; } [data-bs-theme="dark"] .docs-links, [data-bs-theme="dark"] .docs-toc { scrollbar-width: thin; scrollbar-color: #17181c #17181c; } [data-bs-theme="dark"] .docs-links::-webkit-scrollbar, [data-bs-theme="dark"] .docs-toc::-webkit-scrollbar { width: 5px; } [data-bs-theme="dark"] .docs-links::-webkit-scrollbar-track, [data-bs-theme="dark"] .docs-toc::-webkit-scrollbar-track { background: #17181c; } [data-bs-theme="dark"] .docs-links::-webkit-scrollbar-thumb, [data-bs-theme="dark"] .docs-toc::-webkit-scrollbar-thumb { background: #17181c; } [data-bs-theme="dark"] .docs-links:hover, [data-bs-theme="dark"] .docs-toc:hover { scrollbar-width: thin; scrollbar-color: #23262f #17181c; } [data-bs-theme="dark"] .docs-links:hover::-webkit-scrollbar-thumb, [data-bs-theme="dark"] .docs-toc:hover::-webkit-scrollbar-thumb { background: #23262f; } [data-bs-theme="dark"] .docs-links::-webkit-scrollbar-thumb:hover, [data-bs-theme="dark"] .docs-toc::-webkit-scrollbar-thumb:hover { background: #23262f; } [data-bs-theme="dark"] .docs-links h3:not(:first-child), [data-bs-theme="dark"] .docs-links .h3:not(:first-child) { border-top: 1px solid #23262f; } [data-bs-theme="dark"] a.docs-link { color: #c1c3c8; } [data-bs-theme="dark"] .page-links li:not(:first-child) { border-top: 1px dashed #23262f; } [data-bs-theme="dark"] .card { background: #17181c; border: 1px solid #23262f; } [data-bs-theme="dark"] .card.bg-light { background: #23262f !important; } [data-bs-theme="dark"] .navbar .menu-icon .navicon { background: #c1c3c8; } [data-bs-theme="dark"] .navbar .menu-icon .navicon::before, [data-bs-theme="dark"] .navbar .menu-icon .navicon::after { background: #c1c3c8; } [data-bs-theme="dark"] .logo-light { display: none !important; } [data-bs-theme="dark"] .logo-dark { display: inline-block !important; } [data-bs-theme="dark"] .bg-light { background: #141518 !important; } [data-bs-theme="dark"] .bg-dots { background-image: radial-gradient(#414349 15%, transparent 15%); } [data-bs-theme="dark"] .text-muted { color: #adafb6 !important; } [data-bs-theme="dark"] .alert-primary { background: #b3c7ff; color: #17181c; } [data-bs-theme="dark"] .figure-caption { color: #c1c3c8; } [data-bs-theme="dark"] .copy-status::after { content: "Copy"; display: block; color: #c1c3c8; } [data-bs-theme="dark"] .copy-status:hover::after { content: "Copy"; display: block; color: #b3c7ff; } [data-bs-theme="dark"] .copy-status:focus::after, [data-bs-theme="dark"] .copy-status:active::after { content: "Copied"; display: block; color: #b3c7ff; } [data-bs-theme="dark"] .offcanvas { background-color: #17181c; } [data-bs-theme="dark"] .alert-dismissible .btn-close { background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiNkZWUyZTYiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBjbGFzcz0iZmVhdGhlciBmZWF0aGVyLXgiPjxsaW5lIHgxPSIxOCIgeTE9IjYiIHgyPSI2IiB5Mj0iMTgiPjwvbGluZT48bGluZSB4MT0iNiIgeTE9IjYiIHgyPSIxOCIgeTI9IjE4Ij48L2xpbmU+PC9zdmc+"); background-size: 1.5rem; } [data-bs-theme="dark"] .dropdown-item { color: #17181c; } [data-bs-theme="dark"] hr.text-black-50 { color: #6c757d !important; } [data-bs-theme="dark"] .email-form .form-control, [data-bs-theme="dark"] .email-form .search-form .search-field, .search-form [data-bs-theme="dark"] .email-form .search-field, [data-bs-theme="dark"] .email-form .comment-form input[type="text"], .comment-form [data-bs-theme="dark"] .email-form input[type="text"], [data-bs-theme="dark"] .email-form .comment-form input[type="email"], .comment-form [data-bs-theme="dark"] .email-form input[type="email"], [data-bs-theme="dark"] .email-form .comment-form input[type="url"], .comment-form [data-bs-theme="dark"] .email-form input[type="url"], [data-bs-theme="dark"] .email-form .comment-form textarea, .comment-form [data-bs-theme="dark"] .email-form textarea { background: #23262f; border: 1px solid transparent; } [data-bs-theme="dark"] .email-form .form-control:focus, [data-bs-theme="dark"] .email-form .search-form .search-field:focus, .search-form [data-bs-theme="dark"] .email-form .search-field:focus, [data-bs-theme="dark"] .email-form .comment-form input[type="text"]:focus, .comment-form [data-bs-theme="dark"] .email-form input[type="text"]:focus, [data-bs-theme="dark"] .email-form .comment-form input[type="email"]:focus, .comment-form [data-bs-theme="dark"] .email-form input[type="email"]:focus, [data-bs-theme="dark"] .email-form .comment-form input[type="url"]:focus, .comment-form [data-bs-theme="dark"] .email-form input[type="url"]:focus, [data-bs-theme="dark"] .email-form .comment-form textarea:focus, .comment-form [data-bs-theme="dark"] .email-form textarea:focus { border: 1px solid #b3c7ff; } [data-bs-theme="dark"] .page-link { color: #b3c7ff; background-color: transparent; border: var(--bs-border-width) solid #23262f; } [data-bs-theme="dark"] .page-link:hover { color: #17181c; background-color: #c1c3c8; border-color: #c1c3c8; } [data-bs-theme="dark"] .page-link:focus { color: #17181c; background-color: #c1c3c8; } [data-bs-theme="dark"] .page-item.active .page-link { color: #17181c; background-color: #b3c7ff; border-color: #b3c7ff; } [data-bs-theme="dark"] .page-item.disabled .page-link { color: var(--bs-secondary-color); background-color: #23262f; border-color: #23262f; } [data-bs-theme="dark"] .dropdown-menu { background: #23262f; } [data-bs-theme="dark"] .dropdown-menu .dropdown-item { color: #c1c3c8; } [data-bs-theme="dark"] .dropdown-menu .dropdown-item.untranslated { color: #6c757d; text-decoration: line-through; } [data-bs-theme="dark"] .dropdown-menu .dropdown-item.untranslated:focus-visible, [data-bs-theme="dark"] .dropdown-menu .dropdown-item.untranslated:hover { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='icon icon-tabler icon-tabler-home' width='24' height='24' viewBox='0 0 24 24' stroke-width='2' stroke='%23b3c7ff' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'/%3E%3Cpath d='M5 12l-2 0l9 -9l9 9l-2 0' /%3E%3Cpath d='M5 12v7a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-7' /%3E%3Cpath d='M9 21v-6a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v6' /%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 1rem top 0.6rem; background-size: 0.9rem 0.9rem; text-decoration: unset; } [data-bs-theme="dark"] .dropdown-menu .dropdown-item:hover { color: #b3c7ff; background: #17181c; } [data-bs-theme="dark"] .dropdown-menu .dropdown-item.active, [data-bs-theme="dark"] .dropdown-menu .dropdown-item:focus { color: #b3c7ff; background: #17181c; } [data-bs-theme="dark"] .navbar .dropdown-item.current, [data-bs-theme="dark"] .doks-subnavbar .dropdown-item.current { background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23dee2e6' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e"); background-repeat: no-repeat; background-position: right 1rem top 0.6rem; background-size: 0.75rem 0.75rem; } [data-bs-theme="dark"] details { border: 1px solid #23262f; } [data-bs-theme="dark"] summary:hover { background: #23262f; } [data-bs-theme="dark"] details[open] > summary { border-bottom: 1px solid #23262f; } [data-bs-theme="dark"] details summary::after { content: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='rgba%28222, 226, 230, 0.75%29' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M5 14l6-6-6-6'/%3e%3c/svg%3e"); } [data-bs-theme="dark"] #toc a.active { color: #b3c7ff; } [data-bs-theme="dark"] .btn-light { color: #b3c7ff; background: #23262f; border: 1px solid #23262f; } [data-bs-theme="dark"] table th { color: white; } [data-bs-theme="dark"] .table-dark, [data-bs-theme="dark"] table, [data-bs-theme="dark"] [data-bs-theme="dark"] table { --bs-table-color: inherit; --bs-table-bg: $body-bg-dark; background: #17181c; border-color: #23262f; } .alert { font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.875rem; } .alert-icon { margin-right: 0.75rem; } .docs main .alert { margin: 2rem -1.5rem; } .alert .alert-link { text-decoration: underline; } .alert-doks { background: #fbf7f0; color: #1d2d35; } /* .alert-light { color: #215888; background: linear-gradient(-45deg, rgb(212, 245, 255), rgb(234, 250, 255), rgb(234, 250, 255), #d3f6ef); } .alert-light .alert-link { color: #215888; } */ .alert-white { background-color: rgba(255, 255, 255, 0.95); } .alert-primary { color: #fff; background-color: #4f46e5; } .alert a { text-decoration: underline; } .alert-primary .alert-link { color: #fff; } /* .alert-primary { color: #084298; background-color: #cfe2ff; border-color: #b6d4fe; } .alert-primary .alert-link { color: #06357a; } */ .alert-secondary { color: #41464b; background-color: #e2e3e5; border-color: #d3d6d8; } .alert-secondary .alert-link { color: #34383c; } .alert-success { color: #0f5132; background-color: #d1e7dd; border-color: #badbcc; } .alert-success .alert-link { color: #0c4128; } .alert-info { color: #055160; background-color: #cff4fc; border-color: #b6effb; } .alert-info .alert-link { color: #04414d; } .alert-warning { color: #664d03; background-color: #fff3cd; border-color: #ffecb5; } .alert-warning .alert-link { color: #523e02; } .alert-danger { color: #842029; background-color: #f8d7da; border-color: #f5c2c7; } .alert-danger .alert-link { color: #6a1a21; } .alert-light { color: #636464; background-color: #fefefe; border-color: #fdfdfe; } .alert-light .alert-link { color: #4f5050; } .alert-dark { color: #141619; background-color: #d3d3d4; border-color: #bcbebf; } .alert-dark .alert-link { color: #101214; } .alert .alert-link:hover, .alert .alert-link:focus { text-decoration: none; } .alert-text { margin-right: -3rem; font-size: 1rem; } .alert-dismissible .btn-close { position: absolute; top: 50%; transform: translateY(-50%); right: 1rem; z-index: 2; padding: 0.5rem; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-x'%3E%3Cline x1='18' y1='6' x2='6' y2='18'%3E%3C/line%3E%3Cline x1='6' y1='6' x2='18' y2='18'%3E%3C/line%3E%3C/svg%3E"); background-size: 1.5rem; filter: invert(1) grayscale(100%) brightness(200%); } .btn-close:focus, .btn-close:active { outline: none; box-shadow: none; } @media (min-width: 768px) { .alert-dismissible .btn-close { background-size: 1.5rem; } } [data-global-alert="closed"] #announcement { display: none; } .alert code { background: #f6ecdc; color: #1d2d35; padding: 0.25rem 0.5rem; } .navbar .btn-link { color: rgba(var(--bs-emphasis-color-rgb), 0.65); padding: 0.4375rem 0; } #mode { padding: 0.5rem; } .btn-link:focus { outline: 0; box-shadow: none; } #navigation { margin-left: 1.25rem; } @media (min-width: 992px) { #mode { margin-left: 0.5rem; margin-right: 0.25rem; } .navbar .btn-link { padding: 0.5625em 0.25rem 0.5rem 0.125rem; } } .navbar .btn-link:hover { color: rgba(var(--bs-emphasis-color-rgb), 0.8); } .navbar .btn-link:active { color: rgba(var(--bs-emphasis-color-rgb), 1); } body .toggle-dark { display: block; } body .toggle-light { display: none; } [data-dark-mode] body .toggle-light { display: block; } [data-dark-mode] body .toggle-dark { display: none; } .collapsible-sidebar { margin: 2.125rem 0; } .btn-toggle { display: inline-flex; align-items: center; padding: 0.25rem 0.5rem 0.25rem 0; font-weight: 700; font-size: 1rem; text-transform: uppercase; color: #1d2d35; background-color: transparent; border: 0; } .btn-toggle:hover, .btn-toggle:focus { color: #1d2d35; background-color: transparent; outline: 0; box-shadow: none; } .btn-toggle::before { width: 1.25em; line-height: 0; content: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='rgba%2829, 45, 53, 0.75%29' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M5 14l6-6-6-6'/%3e%3c/svg%3e"); transition: transform 0.35s ease; transform-origin: 0.5em 50%; margin-bottom: 0.125rem; } .btn-toggle[aria-expanded="true"] { color: #1d2d35; } .btn-toggle[aria-expanded="true"]::before { transform: rotate(90deg); } .btn-toggle-nav a { display: inline-flex; padding: 0.1875rem 0.5rem; margin-top: 0.125rem; margin-left: 1.25rem; text-decoration: none; } .btn-toggle-nav a:hover, .btn-toggle-nav a:focus { background-color: transparent; color: #4f46e5; } .btn-toggle-nav a.active { color: #4f46e5; } @media (max-width: 991.98px) { .dropdown-menu { width: 100%; position: static; } } /* @include media-breakpoint-up(lg) { .dropdown-menu { width: auto; } } */ .btn-dropdown { border: 0; } @media (max-width: 991.98px) { .btn-dropdown { width: 100%; text-align: left; padding-left: 0; padding-right: 0; } } .navbar .dropdown-item.current { font-weight: 600; background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23292b2c' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e"); background-repeat: no-repeat; background-position: right 1rem top 0.6rem; background-size: 0.75rem 0.75rem; } @media (max-width: 991.98px) { .navbar .dropdown-item.current { background-position: right 0.375rem top 0.6rem; } } .btn-close { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-x'%3E%3Cline x1='18' y1='6' x2='6' y2='18'%3E%3C/line%3E%3Cline x1='6' y1='6' x2='18' y2='18'%3E%3C/line%3E%3C/svg%3E"); background-size: 1.5rem; } .offcanvas-header .btn-close { margin-right: 0 !important; } .dropdown-toggle::after { display: none; } .dropdown-caret { margin-left: -0.1875rem; } .dropdown-menu .dropdown-item.untranslated { color: #6c757d; text-decoration: line-through; } .dropdown-menu .dropdown-item.untranslated:focus-visible, .dropdown-menu .dropdown-item.untranslated:hover { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='icon icon-tabler icon-tabler-home' width='24' height='24' viewBox='0 0 24 24' stroke-width='2' stroke='%234f46e5' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'/%3E%3Cpath d='M5 12l-2 0l9 -9l9 9l-2 0' /%3E%3Cpath d='M5 12v7a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-7' /%3E%3Cpath d='M9 21v-6a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v6' /%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 1rem top 0.6rem; background-size: 0.9rem 0.9rem; text-decoration: unset; } .dropdown-menu .dropdown-item:hover { color: #4f46e5; } .dropdown-menu span.dropdown-item.current:hover { color: unset; } .clipboard { position: relative; float: right; } .btn-clipboard { transition: opacity 0.25s ease-in-out; opacity: 0; position: absolute; right: 0.5rem; top: 0.5rem; line-height: 1; padding: 0.3125rem 0.3125rem 0.1875rem; background-color: transparent; border-color: transparent; } @media (max-width: 767.98px) { .btn-clipboard { position: absolute; right: -0.5rem; top: 0.5rem; } } .btn-clipboard::after { width: 22px; height: 22px; display: inline-block; content: ""; -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='icon icon-tabler icon-tabler-copy' width='22' height='22' viewBox='0 0 24 24' stroke-width='1' stroke='currentColor' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'%3E%3C/path%3E%3Cpath d='M8 8m0 2a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z'%3E%3C/path%3E%3Cpath d='M16 8v-2a2 2 0 0 0 -2 -2h-8a2 2 0 0 0 -2 2v8a2 2 0 0 0 2 2h2'%3E%3C/path%3E%3C/svg%3E") no-repeat 50% 50%; mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='icon icon-tabler icon-tabler-copy' width='22' height='22' viewBox='0 0 24 24' stroke-width='1' stroke='currentColor' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'%3E%3C/path%3E%3Cpath d='M8 8m0 2a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z'%3E%3C/path%3E%3Cpath d='M16 8v-2a2 2 0 0 0 -2 -2h-8a2 2 0 0 0 -2 2v8a2 2 0 0 0 2 2h2'%3E%3C/path%3E%3C/svg%3E") no-repeat 50% 50%; -webkit-mask-size: cover; mask-size: cover; background-color: #495057; } .btn-clipboard:hover { border-color: transparent; } .btn-clipboard:hover::after { width: 22px; height: 22px; display: inline-block; content: ""; -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='icon icon-tabler icon-tabler-copy' width='22' height='22' viewBox='0 0 24 24' stroke-width='1' stroke='currentColor' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'%3E%3C/path%3E%3Cpath d='M8 8m0 2a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z'%3E%3C/path%3E%3Cpath d='M16 8v-2a2 2 0 0 0 -2 -2h-8a2 2 0 0 0 -2 2v8a2 2 0 0 0 2 2h2'%3E%3C/path%3E%3C/svg%3E") no-repeat 50% 50%; mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='icon icon-tabler icon-tabler-copy' width='22' height='22' viewBox='0 0 24 24' stroke-width='1' stroke='currentColor' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'%3E%3C/path%3E%3Cpath d='M8 8m0 2a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z'%3E%3C/path%3E%3Cpath d='M16 8v-2a2 2 0 0 0 -2 -2h-8a2 2 0 0 0 -2 2v8a2 2 0 0 0 2 2h2'%3E%3C/path%3E%3C/svg%3E") no-repeat 50% 50%; -webkit-mask-size: cover; mask-size: cover; background-color: #212529; } .btn-clipboard:focus, .btn-clipboard:active { border-color: transparent !important; } .btn-clipboard:focus::after, .btn-clipboard:active::after { width: 22px; height: 22px; display: inline-block; content: ""; -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22' viewBox='0 0 24 24' stroke-width='1.25' stroke='currentColor' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'%3E%3C/path%3E%3Cpath d='M5 12l5 5l10 -10'%3E%3C/path%3E%3C/svg%3E") no-repeat 50% 50%; mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22' viewBox='0 0 24 24' stroke-width='1.25' stroke='currentColor' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'%3E%3C/path%3E%3Cpath d='M5 12l5 5l10 -10'%3E%3C/path%3E%3C/svg%3E") no-repeat 50% 50%; -webkit-mask-size: cover; mask-size: cover; background-color: #212529; } [data-bs-theme="dark"] .btn-clipboard { background-color: transparent; border-color: transparent; } [data-bs-theme="dark"] .btn-clipboard::after { background-color: #ced4da; } [data-bs-theme="dark"] .btn-clipboard:hover { border-color: transparent; } [data-bs-theme="dark"] .btn-clipboard:hover::after { background-color: #e9ecef; } [data-bs-theme="dark"] .btn-clipboard:focus, [data-bs-theme="dark"] .btn-clipboard:active { border-color: transparent; } [data-bs-theme="dark"] .btn-clipboard:focus::after, [data-bs-theme="dark"] .btn-clipboard:active::after { background-color: #e9ecef; } .highlight { position: relative; } @media (min-width: 768px) { .highlight:hover .btn-clipboard { opacity: 1; } } #toTop { opacity: 0; transition: opacity 0.3s ease-in-out; } #toTop.fade { opacity: 1; } .btn-cta { padding-left: 2rem; padding-right: 2rem; } .callout { --bs-link-color-rgb: var(--callout-link); --bs-code-color: var(--callout-code-color); color: var(--callout-color, inherit); background-color: var(--callout-bg, var(--bs-gray-100)); border-left: 0.25rem solid var(--callout-border, var(--bs-gray-300)); border-radius: 0; /* code { background: transparent; color: inherit; } */ } .callout a { text-decoration: underline; } .callout .highlight { background-color: rgba(0, 0, 0, 0.05); } .callout .callout-icon.svg-inline { flex-shrink: 0; height: calc(1.5 * 1.125rem); } .callout .callout-title { font-weight: 700; } .callout-content { min-width: 0; } .callout.callout-note { border-color: var(--sl-color-blue); background-color: var(--sl-color-blue-high); /* code:not(:where(.not-content *)) { background: tint-color($info, 80%); } */ } .callout.callout-note .callout-icon, .callout.callout-note .callout-title, .callout.callout-note .callout-body a { color: var(--sl-color-blue-low); } .callout.callout-note .callout-body, .callout.callout-note .callout-body a:hover, .callout.callout-note .callout-body a:active { color: var(--sl-color-white); } .callout.callout-tip { border-color: var(--sl-color-purple); background-color: var(--sl-color-purple-high); /* code:not(:where(.not-content *)) { background: tint-color($purple, 80%); } */ } .callout.callout-tip .callout-icon, .callout.callout-tip .callout-title, .callout.callout-tip .callout-body a { color: var(--sl-color-purple-low); } .callout.callout-tip .callout-body, .callout.callout-tip .callout-body a:hover, .callout.callout-tip .callout-body a:active { color: var(--sl-color-white); } .callout.callout-caution { border-color: var(--sl-color-orange); background-color: var(--sl-color-orange-high); /* code:not(:where(.not-content *)) { background: tint-color($yellow, 80%); } */ } .callout.callout-caution .callout-icon, .callout.callout-caution .callout-title, .callout.callout-caution .callout-body a { color: var(--sl-color-orange-low); } .callout.callout-caution .callout-body, .callout.callout-caution .callout-body a:hover, .callout.callout-caution .callout-body a:active { color: var(--sl-color-white); } .callout.callout-danger { border-color: var(--sl-color-red); background-color: var(--sl-color-red-high); /* code:not(:where(.not-content *)) { background: tint-color($red, 80%); } */ } .callout.callout-danger .callout-icon, .callout.callout-danger .callout-title, .callout.callout-danger .callout-body a { color: var(--sl-color-red-low); } .callout.callout-danger .callout-body, .callout.callout-danger .callout-body a:hover, .callout.callout-danger .callout-body a:active { color: var(--sl-color-white); } /* .callout.callout-light code { background: var(--sl-color-gray-1); } */ [data-bs-theme="dark"] .callout { color: var(--sl-color-gray-1); } [data-bs-theme="dark"] .callout.callout-note { border-color: var(--sl-color-blue); background-color: var(--sl-color-blue-low); } [data-bs-theme="dark"] .callout.callout-note .callout-icon, [data-bs-theme="dark"] .callout.callout-note .callout-title, [data-bs-theme="dark"] .callout.callout-note .callout-body a { color: var(--sl-color-blue-high); } [data-bs-theme="dark"] .callout.callout-note .callout-body, [data-bs-theme="dark"] .callout.callout-note .callout-body a:hover, [data-bs-theme="dark"] .callout.callout-note .callout-body a:active { color: var(--sl-color-white); } [data-bs-theme="dark"] .callout.callout-note code:not(:where(.not-content *)) { color: var(--ec-codeFg); } [data-bs-theme="dark"] .callout.callout-tip { border-color: var(--sl-color-purple); background-color: var(--sl-color-purple-low); } [data-bs-theme="dark"] .callout.callout-tip .callout-icon, [data-bs-theme="dark"] .callout.callout-tip .callout-title, [data-bs-theme="dark"] .callout.callout-tip .callout-body a { color: var(--sl-color-purple-high); } [data-bs-theme="dark"] .callout.callout-tip .callout-body, [data-bs-theme="dark"] .callout.callout-tip .callout-body a:hover, [data-bs-theme="dark"] .callout.callout-tip .callout-body a:active { color: var(--sl-color-white); } [data-bs-theme="dark"] .callout.callout-tip code:not(:where(.not-content *)) { color: var(--ec-codeFg); } [data-bs-theme="dark"] .callout.callout-caution { border-color: var(--sl-color-orange); background-color: var(--sl-color-orange-low); } [data-bs-theme="dark"] .callout.callout-caution .callout-icon, [data-bs-theme="dark"] .callout.callout-caution .callout-title, [data-bs-theme="dark"] .callout.callout-caution .callout-body a { color: var(--sl-color-orange-high); } [data-bs-theme="dark"] .callout.callout-caution .callout-body, [data-bs-theme="dark"] .callout.callout-caution .callout-body a:hover, [data-bs-theme="dark"] .callout.callout-caution .callout-body a:active { color: var(--sl-color-white); } [data-bs-theme="dark"] .callout.callout-caution code:not(:where(.not-content *)) { color: var(--ec-codeFg); } [data-bs-theme="dark"] .callout.callout-danger { border-color: var(--sl-color-red); background-color: var(--sl-color-red-low); } [data-bs-theme="dark"] .callout.callout-danger .callout-icon, [data-bs-theme="dark"] .callout.callout-danger .callout-title, [data-bs-theme="dark"] .callout.callout-danger .callout-body a { color: var(--sl-color-red-high); } [data-bs-theme="dark"] .callout.callout-danger .callout-body, [data-bs-theme="dark"] .callout.callout-danger .callout-body a:hover, [data-bs-theme="dark"] .callout.callout-danger .callout-body a:active { color: var(--sl-color-white); } [data-bs-theme="dark"] .callout.callout-danger code:not(:where(.not-content *)) { color: var(--ec-codeFg); } .expressive-code { font-family: var(--ec-uiFontFml); font-size: var(--ec-uiFontSize); line-height: var(--ec-uiLineHt); text-size-adjust: none; -webkit-text-size-adjust: none; margin: 1.5rem 0; } .expressive-code *:not(path) { all: revert; box-sizing: border-box; } .expressive-code pre { display: flex; margin: 0; padding: 0; border: var(--ec-brdWd) solid var(--ec-brdCol); border-radius: calc(var(--ec-brdRad) + var(--ec-brdWd)); background: var(--ec-codeBg); } .expressive-code pre:focus-visible { outline: 3px solid var(--ec-focusBrd); outline-offset: -3px; } .expressive-code pre > code { all: unset; display: block; flex: 1 0 100%; padding: var(--ec-codePadBlk) 0; color: var(--ec-codeFg); font-family: var(--ec-codeFontFml); font-size: var(--ec-codeFontSize); line-height: var(--ec-codeLineHt); } .expressive-code pre { overflow-x: auto; } .expressive-code pre::-webkit-scrollbar, .expressive-code pre::-webkit-scrollbar-track { background-color: inherit; border-radius: calc(var(--ec-brdRad) + var(--ec-brdWd)); border-top-left-radius: 0; border-top-right-radius: 0; } .expressive-code pre::-webkit-scrollbar-thumb { background-color: var(--ec-sbThumbCol); border: 4px solid transparent; background-clip: content-box; border-radius: 10px; } .expressive-code pre::-webkit-scrollbar-thumb:hover { background-color: var(--ec-sbThumbHoverCol); } .expressive-code .ec-line { padding-inline: var(--ec-codePadInl); padding-inline-end: calc(2rem + var(--ec-codePadInl)); direction: ltr; unicode-bidi: isolate; } .expressive-code .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border-width: 0; } .expressive-code .ec-line.mark { --tmLineBgCol: var(--ec-tm-markBg); --tmLineBrdCol: var(--ec-tm-markBrdCol); } .expressive-code .ec-line.ins { --tmLineBgCol: var(--ec-tm-insBg); --tmLineBrdCol: var(--ec-tm-insBrdCol); } .expressive-code .ec-line.ins::before { content: var(--ec-tm-insDiffIndContent); color: var(--ec-tm-insDiffIndCol); } .expressive-code .ec-line.del { --tmLineBgCol: var(--ec-tm-delBg); --tmLineBrdCol: var(--ec-tm-delBrdCol); } .expressive-code .ec-line.del::before { content: var(--ec-tm-delDiffIndContent); color: var(--ec-tm-delDiffIndCol); } .expressive-code .ec-line.mark, .expressive-code .ec-line.ins, .expressive-code .ec-line.del { position: relative; background: var(--tmLineBgCol); min-width: calc(100% - var(--ec-tm-lineMarkerAccentMarg)); margin-inline-start: var(--ec-tm-lineMarkerAccentMarg); border-inline-start: var(--ec-tm-lineMarkerAccentWd) solid var(--tmLineBrdCol); padding-inline-start: calc(var(--ec-codePadInl) - var(--ec-tm-lineMarkerAccentMarg) - var(--ec-tm-lineMarkerAccentWd)) !important; } .expressive-code .ec-line.mark::before, .expressive-code .ec-line.ins::before, .expressive-code .ec-line.del::before { position: absolute; left: var(--ec-tm-lineDiffIndMargLeft); } .expressive-code .ec-line mark, .expressive-code .ec-line .mark { --tmInlineBgCol: var(--ec-tm-markBg); --tmInlineBrdCol: var(--ec-tm-markBrdCol); } .expressive-code .ec-line ins { --tmInlineBgCol: var(--ec-tm-insBg); --tmInlineBrdCol: var(--ec-tm-insBrdCol); } .expressive-code .ec-line del { --tmInlineBgCol: var(--ec-tm-delBg); --tmInlineBrdCol: var(--ec-tm-delBrdCol); } .expressive-code .ec-line mark, .expressive-code .ec-line .mark, .expressive-code .ec-line ins, .expressive-code .ec-line del { all: unset; display: inline-block; position: relative; --tmBrdL: var(--ec-tm-inlMarkerBrdWd); --tmBrdR: var(--ec-tm-inlMarkerBrdWd); --tmRadL: var(--ec-tm-inlMarkerBrdRad); --tmRadR: var(--ec-tm-inlMarkerBrdRad); margin-inline: 0.025rem; padding-inline: var(--ec-tm-inlMarkerPad); border-radius: var(--tmRadL) var(--tmRadR) var(--tmRadR) var(--tmRadL); background: var(--tmInlineBgCol); background-clip: padding-box; } .expressive-code .ec-line mark.open-start, .expressive-code .ec-line .open-start.mark, .expressive-code .ec-line ins.open-start, .expressive-code .ec-line del.open-start { margin-inline-start: 0; padding-inline-start: 0; --tmBrdL: 0px; --tmRadL: 0; } .expressive-code .ec-line mark.open-end, .expressive-code .ec-line .open-end.mark, .expressive-code .ec-line ins.open-end, .expressive-code .ec-line del.open-end { margin-inline-end: 0; padding-inline-end: 0; --tmBrdR: 0px; --tmRadR: 0; } .expressive-code .ec-line mark::before, .expressive-code .ec-line .mark::before, .expressive-code .ec-line ins::before, .expressive-code .ec-line del::before { content: ""; position: absolute; pointer-events: none; display: inline-block; inset: 0; border-radius: var(--tmRadL) var(--tmRadR) var(--tmRadR) var(--tmRadL); border: var(--ec-tm-inlMarkerBrdWd) solid var(--tmInlineBrdCol); border-inline-width: var(--tmBrdL) var(--tmBrdR); } .expressive-code .frame { all: unset; position: relative; display: block; --header-border-radius: calc(var(--ec-brdRad) + var(--ec-brdWd)); --tab-border-radius: calc(var(--ec-frm-edTabBrdRad) + var(--ec-brdWd)); --button-spacing: 0.4rem; --code-background: var(--ec-frm-edBg); border-radius: var(--header-border-radius); box-shadow: var(--ec-frm-frameBoxShdCssVal); } .expressive-code .frame .header { display: none; z-index: 1; position: relative; border-radius: var(--header-border-radius) var(--header-border-radius) 0 0; } .expressive-code .frame.has-title pre, .expressive-code .frame.has-title code, .expressive-code .frame.is-terminal pre, .expressive-code .frame.is-terminal code { border-top: none; border-top-left-radius: 0; border-top-right-radius: 0; } .expressive-code .frame .title:empty:before { content: "\a0"; } .expressive-code .frame.has-title:not(.is-terminal) { --button-spacing: calc(1.9rem + 2 * (var(--ec-uiPadBlk) + var(--ec-frm-edActTabIndHt))); } .expressive-code .frame.has-title:not(.is-terminal) .title { position: relative; color: var(--ec-frm-edActTabFg); background: var(--ec-frm-edActTabBg); background-clip: padding-box; margin-block-start: var(--ec-frm-edTabsMargBlkStart); padding: calc(var(--ec-uiPadBlk) + var(--ec-frm-edActTabIndHt)) var(--ec-uiPadInl); border: var(--ec-brdWd) solid var(--ec-frm-edActTabBrdCol); border-radius: var(--tab-border-radius) var(--tab-border-radius) 0 0; border-bottom: none; overflow: hidden; } .expressive-code .frame.has-title:not(.is-terminal) .title::after { content: ""; position: absolute; pointer-events: none; inset: 0; border-top: var(--ec-frm-edActTabIndHt) solid var(--ec-frm-edActTabIndTopCol); border-bottom: var(--ec-frm-edActTabIndHt) solid var(--ec-frm-edActTabIndBtmCol); } .expressive-code .frame.has-title:not(.is-terminal) .header { display: flex; background: linear-gradient(to top, var(--ec-frm-edTabBarBrdBtmCol) var(--ec-brdWd), transparent var(--ec-brdWd)), linear-gradient(var(--ec-frm-edTabBarBg), var(--ec-frm-edTabBarBg)); background-repeat: no-repeat; padding-inline-start: var(--ec-frm-edTabsMargInlStart); } .expressive-code .frame.has-title:not(.is-terminal) .header::before { content: ""; position: absolute; pointer-events: none; inset: 0; border: var(--ec-brdWd) solid var(--ec-frm-edTabBarBrdCol); border-radius: inherit; border-bottom: none; } .expressive-code .frame.is-terminal { --button-spacing: calc(1.9rem + var(--ec-brdWd) + 2 * var(--ec-uiPadBlk)); --code-background: var(--ec-frm-trmBg); } .expressive-code .frame.is-terminal .header { display: flex; align-items: center; justify-content: center; padding-block: var(--ec-uiPadBlk); padding-block-end: calc(var(--ec-uiPadBlk) + var(--ec-brdWd)); position: relative; font-weight: 500; letter-spacing: 0.025ch; color: var(--ec-frm-trmTtbFg); background: var(--ec-frm-trmTtbBg); border: var(--ec-brdWd) solid var(--ec-brdCol); border-bottom: none; } .expressive-code .frame.is-terminal .header::before { content: ""; position: absolute; pointer-events: none; left: var(--ec-uiPadInl); width: 2.1rem; height: 0.56rem; line-height: 0; background-color: var(--ec-frm-trmTtbDotsFg); opacity: var(--ec-frm-trmTtbDotsOpa); -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 60 16' preserveAspectRatio='xMidYMid meet'%3E%3Ccircle cx='8' cy='8' r='8'/%3E%3Ccircle cx='30' cy='8' r='8'/%3E%3Ccircle cx='52' cy='8' r='8'/%3E%3C/svg%3E"); -webkit-mask-repeat: no-repeat; mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 60 16' preserveAspectRatio='xMidYMid meet'%3E%3Ccircle cx='8' cy='8' r='8'/%3E%3Ccircle cx='30' cy='8' r='8'/%3E%3Ccircle cx='52' cy='8' r='8'/%3E%3C/svg%3E"); mask-repeat: no-repeat; } .expressive-code .frame.is-terminal .header::after { content: ""; position: absolute; pointer-events: none; inset: 0; border-bottom: var(--ec-brdWd) solid var(--ec-frm-trmTtbBrdBtmCol); } .expressive-code .frame pre { background: var(--code-background); } .expressive-code .copy { display: flex; gap: 0.25rem; flex-direction: row; position: absolute; inset-block-start: calc(var(--ec-brdWd) + var(--button-spacing)); inset-inline-end: calc(var(--ec-brdWd) + var(--ec-uiPadInl) / 2); direction: ltr; unicode-bidi: isolate; } .expressive-code .copy button { position: relative; align-self: flex-end; margin: 0; padding: 0; border: none; border-radius: 0.2rem; z-index: 1; cursor: pointer; transition-property: opacity, background, border-color; transition-duration: 0.2s; transition-timing-function: cubic-bezier(0.25, 0.46, 0.45, 0.94); width: 2.5rem; height: 2.5rem; background: var(--code-background); opacity: 0.75; } .expressive-code .copy button div { position: absolute; inset: 0; border-radius: inherit; background: var(--ec-frm-inlBtnBg); opacity: var(--ec-frm-inlBtnBgIdleOpa); transition-property: inherit; transition-duration: inherit; transition-timing-function: inherit; } .expressive-code .copy button::before { content: ""; position: absolute; pointer-events: none; inset: 0; border-radius: inherit; border: var(--ec-brdWd) solid var(--ec-frm-inlBtnBrd); opacity: var(--ec-frm-inlBtnBrdOpa); } .expressive-code .copy button::after { content: ""; position: absolute; pointer-events: none; inset: 0; background-color: var(--ec-frm-inlBtnFg); -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.75'%3E%3Cpath d='M3 19a2 2 0 0 1-1-2V2a2 2 0 0 1 1-1h13a2 2 0 0 1 2 1'/%3E%3Crect x='6' y='5' width='16' height='18' rx='1.5' ry='1.5'/%3E%3C/svg%3E"); -webkit-mask-repeat: no-repeat; mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.75'%3E%3Cpath d='M3 19a2 2 0 0 1-1-2V2a2 2 0 0 1 1-1h13a2 2 0 0 1 2 1'/%3E%3Crect x='6' y='5' width='16' height='18' rx='1.5' ry='1.5'/%3E%3C/svg%3E"); mask-repeat: no-repeat; margin: 0.475rem; line-height: 0; } .expressive-code .copy button:focus::after, .expressive-code .copy button:active::after { display: inline-block; content: ""; -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22' viewBox='0 0 24 24' stroke-width='1.25' stroke='black' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'%3E%3C/path%3E%3Cpath d='M5 12l5 5l10 -10'%3E%3C/path%3E%3C/svg%3E") no-repeat 50% 50%; mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22' viewBox='0 0 24 24' stroke-width='1.25' stroke='black' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'%3E%3C/path%3E%3Cpath d='M5 12l5 5l10 -10'%3E%3C/path%3E%3C/svg%3E") no-repeat 50% 50%; -webkit-mask-size: cover; mask-size: cover; margin: 0.2375rem; } .expressive-code .copy button:hover, .expressive-code .copy button:focus:focus-visible { opacity: 1; } .expressive-code .copy button:hover div, .expressive-code .copy button:focus:focus-visible div { opacity: var(--ec-frm-inlBtnBgHoverOrFocusOpa); } .expressive-code .copy button:active { opacity: 1; } .expressive-code .copy button:active div { opacity: var(--ec-frm-inlBtnBgActOpa); } .expressive-code .copy .feedback { --tooltip-arrow-size: 0.35rem; --tooltip-bg: var(--ec-frm-tooltipSuccessBg); color: var(--ec-frm-tooltipSuccessFg); pointer-events: none; user-select: none; -webkit-user-select: none; position: relative; align-self: center; background-color: var(--tooltip-bg); z-index: 99; padding: 0.125rem 0.75rem; border-radius: 0.2rem; margin-inline-end: var(--tooltip-arrow-size); opacity: 0; transition-property: opacity, transform; transition-duration: 0.2s; transition-timing-function: ease-in-out; transform: translate3d(0, 0.25rem, 0); } .expressive-code .copy .feedback::after { content: ""; position: absolute; pointer-events: none; top: calc(50% - var(--tooltip-arrow-size)); inset-inline-end: calc(-2 * (var(--tooltip-arrow-size) - 0.5px)); border: var(--tooltip-arrow-size) solid transparent; border-inline-start-color: var(--tooltip-bg); } .expressive-code .copy .feedback.show { opacity: 1; transform: translate3d(0, 0, 0); } @media (hover: hover) { .expressive-code .copy button { opacity: 0; width: 2rem; height: 2rem; } .expressive-code .frame:hover .copy button:not(:hover), .expressive-code .frame:focus-within :focus-visible ~ .copy button:not(:hover), .expressive-code .frame .copy .feedback.show ~ button:not(:hover) { opacity: 0.75; } } :root { --ec-brdRad: 0px; --ec-brdWd: 1px; --ec-brdCol: color-mix(in srgb, var(--sl-color-gray-5), transparent 25%); --ec-codeFontFml: var(--__sl-font-mono); --ec-codeFontSize: var(--sl-text-code); --ec-codeFontWg: 400; --ec-codeLineHt: var(--sl-line-height); --ec-codePadBlk: 0; --ec-codePadInl: 1rem; --ec-codeBg: #011627; --ec-codeFg: #d6deeb; --ec-codeSelBg: #1d3b53; --ec-uiFontFml: var(--__sl-font); --ec-uiFontSize: 0.9rem; --ec-uiFontWg: 400; --ec-uiLineHt: 1.65; --ec-uiPadBlk: 0.25rem; --ec-uiPadInl: 1rem; --ec-uiSelBg: #234d708c; --ec-uiSelFg: #ffffff; --ec-focusBrd: #122d42; --ec-sbThumbCol: #ffffff17; --ec-sbThumbHoverCol: #ffffff49; --ec-tm-lineMarkerAccentMarg: 0rem; --ec-tm-lineMarkerAccentWd: 0.15rem; --ec-tm-lineDiffIndMargLeft: 0.25rem; --ec-tm-inlMarkerBrdWd: 1.5px; --ec-tm-inlMarkerBrdRad: 0.2rem; --ec-tm-inlMarkerPad: 0.15rem; --ec-tm-insDiffIndContent: "+"; --ec-tm-delDiffIndContent: "-"; --ec-tm-markBg: #ffffff17; --ec-tm-markBrdCol: #ffffff40; --ec-tm-insBg: #1e571599; --ec-tm-insBrdCol: #487f3bd0; --ec-tm-insDiffIndCol: #79b169d0; --ec-tm-delBg: #862d2799; --ec-tm-delBrdCol: #b4554bd0; --ec-tm-delDiffIndCol: #ed8779d0; --ec-frm-shdCol: #011627; --ec-frm-frameBoxShdCssVal: none; --ec-frm-edActTabBg: var(--sl-color-gray-6); --ec-frm-edActTabFg: var(--sl-color-text); --ec-frm-edActTabBrdCol: transparent; --ec-frm-edActTabIndHt: 1px; --ec-frm-edActTabIndTopCol: var(--sl-color-accent-high); --ec-frm-edActTabIndBtmCol: transparent; --ec-frm-edTabsMargInlStart: 0; --ec-frm-edTabsMargBlkStart: 0; --ec-frm-edTabBrdRad: 0px; --ec-frm-edTabBarBg: var(--sl-color-black); --ec-frm-edTabBarBrdCol: color-mix(in srgb, var(--sl-color-gray-5), transparent 25%); --ec-frm-edTabBarBrdBtmCol: color-mix(in srgb, var(--sl-color-gray-5), transparent 25%); --ec-frm-edBg: var(--sl-color-gray-6); --ec-frm-trmTtbDotsFg: color-mix(in srgb, var(--sl-color-gray-5), transparent 25%); --ec-frm-trmTtbDotsOpa: 0.75; --ec-frm-trmTtbBg: var(--sl-color-black); --ec-frm-trmTtbFg: var(--sl-color-text); --ec-frm-trmTtbBrdBtmCol: color-mix(in srgb, var(--sl-color-gray-5), transparent 25%); --ec-frm-trmBg: var(--sl-color-gray-6); --ec-frm-inlBtnFg: var(--sl-color-text); --ec-frm-inlBtnBg: var(--sl-color-text); --ec-frm-inlBtnBgIdleOpa: 0; --ec-frm-inlBtnBgHoverOrFocusOpa: 0.2; --ec-frm-inlBtnBgActOpa: 0.3; --ec-frm-inlBtnBrd: var(--sl-color-text); --ec-frm-inlBtnBrdOpa: 0.4; --ec-frm-tooltipSuccessBg: #158744; --ec-frm-tooltipSuccessFg: white; } .expressive-code .ec-line span[style^="--"]:not([class]) { color: var(0, inherit); font-style: var(0fs, inherit); font-weight: var(0fw, inherit); text-decoration: var(0td, inherit); } @media (prefers-color-scheme: light) { :root:not([data-bs-theme="dark"]) { --ec-codeBg: #fbfbfb; --ec-codeFg: #403f53; --ec-codeSelBg: #e0e0e0; --ec-uiSelBg: #d3e8f8; --ec-uiSelFg: #403f53; --ec-focusBrd: #93a1a1; --ec-sbThumbCol: #0000001a; --ec-sbThumbHoverCol: #0000005c; --ec-tm-markBg: #0000001a; --ec-tm-markBrdCol: #00000055; --ec-tm-insBg: #8ec77d99; --ec-tm-insDiffIndCol: #336a28d0; --ec-tm-delBg: #ff9c8e99; --ec-tm-delDiffIndCol: #9d4138d0; --ec-frm-shdCol: #d9d9d9; --ec-frm-edActTabBg: var(--sl-color-gray-7); --ec-frm-edActTabIndTopCol: #5d2f86; --ec-frm-edTabBarBg: var(--sl-color-gray-6); --ec-frm-edBg: var(--sl-color-gray-7); --ec-frm-trmTtbBg: var(--sl-color-gray-6); --ec-frm-trmBg: var(--sl-color-gray-7); --ec-frm-tooltipSuccessBg: #078662; } :root:not([data-bs-theme="dark"]) .expressive-code .ec-line span[style^="--"]:not([class]) { color: var(1, inherit); font-style: var(1fs, inherit); font-weight: var(1fw, inherit); text-decoration: var(1td, inherit); } } :root[data-bs-theme="light"] .expressive-code, .expressive-code[data-bs-theme="light"] { --ec-codeBg: #fbfbfb; --ec-codeFg: #403f53; --ec-codeSelBg: #e0e0e0; --ec-uiSelBg: #d3e8f8; --ec-uiSelFg: #403f53; --ec-focusBrd: #93a1a1; --ec-sbThumbCol: #0000001a; --ec-sbThumbHoverCol: #0000005c; --ec-tm-markBg: #0000001a; --ec-tm-markBrdCol: #00000055; --ec-tm-insBg: #8ec77d99; --ec-tm-insDiffIndCol: #336a28d0; --ec-tm-delBg: #ff9c8e99; --ec-tm-delDiffIndCol: #9d4138d0; --ec-frm-shdCol: #d9d9d9; --ec-frm-edActTabBg: var(--sl-color-gray-7); --ec-frm-edActTabIndTopCol: #5d2f86; --ec-frm-edTabBarBg: var(--sl-color-gray-6); --ec-frm-edBg: var(--sl-color-gray-7); --ec-frm-trmTtbBg: var(--sl-color-gray-6); --ec-frm-trmBg: var(--sl-color-gray-7); --ec-frm-tooltipSuccessBg: #078662; } :root[data-bs-theme="light"] .expressive-code .ec-line span[style^="--"]:not([class]), .expressive-code[data-bs-theme="light"] .ec-line span[style^="--"]:not([class]) { color: var(1, inherit); font-style: var(1fs, inherit); font-weight: var(1fw, inherit); text-decoration: var(1td, inherit); } pre, code, kbd, samp { font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.875rem; } code:not(:where(.not-content *)) { background-color: var(--sl-color-gray-6); margin-block: -0.125rem; padding: 0.125rem 0.375rem; color: inherit; } [data-bs-theme="dark"] code:not(:where(.not-content *)) { background-color: var(--sl-color-gray-5); } /* code { background: $db-khaki-100; // background: $db-gray-200; color: $db-bluishCyan-100; padding: 0.25rem 0.5rem; } pre { margin: 2rem 0; } pre code { display: block; overflow-x: auto; line-height: $line-height-base; padding: 1.25rem 1.5rem; tab-size: 4; scrollbar-width: thin; scrollbar-color: transparent transparent; } .hljs { padding: 1.5rem !important; } @include media-breakpoint-down(sm) { pre, code, kbd, samp { border-radius: 0; } pre { margin: 2rem -1.5rem; } } pre code::-webkit-scrollbar { height: 5px; } pre code::-webkit-scrollbar-thumb { background: $gray-400; } pre code:hover { scrollbar-width: thin; scrollbar-color: $gray-500 transparent; } pre code::-webkit-scrollbar-thumb:hover { background: $gray-500; } code.language-mermaid { background: none; } .line .ln { margin-right: 1rem; } .line.hl { color: var(--sl-color-blue); } @include color-mode(dark) { .line.hl { color: $yellow-100; } } */ .math-block { display: block; margin: 2rem 0; overflow-x: auto; } .math-inline { display: inline; } [data-bs-theme="dark"] .math-inline img, [data-bs-theme="dark"] .math-block img { filter: invert(1); } img.diagram { height: auto; width: 100%; margin: 1rem 0 2rem; } img.diagram-kroki-mermaid { background: #fff; } /* Applies when there are no line numbers, or when line numbers are inline. */ .highlight > pre { padding: 0.875rem 1rem; } /* Applies when line numbers are in a table cell. */ .highlight div { padding: 0; } /* Applies to all. */ .highlight > .chroma { overflow-x: auto; border: 1px solid color-mix(in srgb, var(--sl-color-gray-5), transparent 25%); /* add border-radius and box-shadow here */ } /* Applies when line numbers are inline */ .chroma .ln { padding: 0 0.5rem 0 0; } .chroma .hl { border-inline-start: 0.15rem solid #0005; margin-left: -1rem; margin-right: -1rem; padding-left: 1rem; padding-right: 1rem; } .chroma .hl .ln { margin-left: -0.15rem; } /* Applies when using an external style sheet */ .highlight .chroma .lntable .lnt, .highlight .chroma .lntable .hl { display: flex; } /* Applies when highlihting using table */ .chroma .lntd:first-child { padding: 0; } .chroma .lntd:first-child .lnt { padding-left: 1rem; } .chroma .lntd:nth-child(2) { padding: 0; } /* Applies when using an external style sheet */ .highlight .chroma .lntable .lntd + .lntd { width: 100%; } [data-bs-theme="dark"] .chroma .ln { padding: 0 0.5em 0 0; } /* LineTableTD */ .chroma .lntd pre { padding: 1rem 0; margin-bottom: 0; } .highlight > .chroma::-webkit-scrollbar, .highlight > .chroma::-webkit-scrollbar-track { background-color: inherit; border-radius: 1px; border-top-left-radius: 0; border-top-right-radius: 0; } .highlight > .chroma::-webkit-scrollbar-thumb { background-color: #dddee0; border: 4px solid transparent; background-clip: content-box; border-radius: 10px; } .highlight > .chroma::-webkit-scrollbar-thumb:hover { background-color: #9d9e9f; } [data-bs-theme="dark"] .highlight > .chroma::-webkit-scrollbar-thumb { background-color: #ffffff17; } [data-bs-theme="dark"] .highlight > .chroma::-webkit-scrollbar-thumb:hover { background-color: #ffffff49; } /* .chroma .hl { background-color: #0000001a } */ [data-bs-theme="dark"] { /* .chroma .hl { background-color: #ffffff17; } */ } [data-bs-theme="dark"] .highlight > .chroma { border: 1px solid color-mix(in srgb, var(--sl-color-gray-5), transparent 25%); } [data-bs-theme="dark"] .chroma .hl { border-inline-start: 0.15rem solid #ffffff40; margin-left: -1rem; margin-right: -1rem; padding-left: 1rem; padding-right: 1rem; } [data-bs-theme="dark"] .chroma .hl .ln { margin-left: -0.15rem; } .comment-list ol { list-style: none; } blockquote { margin-bottom: 1rem; font-size: 1.25rem; border-left: 3px solid #dee2e6; padding-left: 1rem; } details { display: block; position: relative; border: 1px solid #e9ecef; border-radius: 0.25rem; padding: 0.5rem 1rem 0; margin: 0.5rem 0; } /* details summary { &::marker { content: ""; } } */ summary { list-style: none; display: inline-block; width: calc(100% + 2rem); margin: -0.5rem -1rem 0; padding: 0.5rem 1rem; } summary::-webkit-details-marker { display: none; } summary:hover { background: #f8f9fa; } details summary::after { display: inline-block; content: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='rgba%2829, 45, 53, 0.75%29' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M5 14l6-6-6-6'/%3e%3c/svg%3e"); transition: transform 0.35s ease; transform-origin: center center; position: absolute; right: 1rem; } details[open] > summary::after { transform: rotate(90deg); } /* details summary > * { display: inline-block; } */ details[open] { padding: 0.5rem 1rem; } details[open] > summary { border-bottom: 1px solid #dee2e6; margin-bottom: 0.5rem; } details h2, details .h2, details h3, details .h3, details h4, details .h4 { margin: 1rem 0 0.5rem; } details p:last-child { margin-bottom: 0; } details ul, details ol { margin-bottom: 0; } details pre { margin: 0 0 1rem; } /** Search form */ .search-form label { font-weight: normal; } img { max-width: 100%; height: auto; } img[data-sizes="auto"] { display: block; } img, picture { font-size: 0; } figcaption { font-size: 1rem; margin-top: 0.5rem; font-style: italic; } .content .gitpod-mark-monochrome.icon { margin-bottom: 0.125rem; margin-right: 0.5rem; } .blur-up { filter: blur(5px); transition: filter 400ms; } .blur-up.lazyloaded { filter: unset; } .mermaid { margin: 1.5rem 0; padding: 1.5rem; } .mermaid svg { height: auto; } .search-form .form-control:focus, .search-form .comment-form input[type="text"]:focus, .comment-form .search-form input[type="text"]:focus, .search-form .comment-form input[type="email"]:focus, .comment-form .search-form input[type="email"]:focus, .search-form .comment-form input[type="url"]:focus, .comment-form .search-form input[type="url"]:focus, .search-form .comment-form textarea:focus, .comment-form .search-form textarea:focus, .search-form .search-field:focus { border: 2px solid #4f46e5; } [data-bs-theme="dark"] .search-form .form-control:focus, [data-bs-theme="dark"] .search-form .comment-form input[type="text"]:focus, .comment-form [data-bs-theme="dark"] .search-form input[type="text"]:focus, [data-bs-theme="dark"] .search-form .comment-form input[type="email"]:focus, .comment-form [data-bs-theme="dark"] .search-form input[type="email"]:focus, [data-bs-theme="dark"] .search-form .comment-form input[type="url"]:focus, .comment-form [data-bs-theme="dark"] .search-form input[type="url"]:focus, [data-bs-theme="dark"] .search-form .comment-form textarea:focus, .comment-form [data-bs-theme="dark"] .search-form textarea:focus, [data-bs-theme="dark"] .search-form .search-field:focus { border: 2px solid #b3c7ff; } [data-bs-theme="dark"] .search-form .btn-link { color: #b3c7ff; } .search-form .btn-link, .modal-body p.message, .modal-footer { font-size: 0.875rem; } .modal-body::-webkit-scrollbar { width: 0.25rem; } .modal-body::-webkit-scrollbar-track { background-color: #f1f1f1; } .modal-body::-webkit-scrollbar-thumb { background-color: #c1c1c1; } [data-bs-theme="dark"] .modal-body::-webkit-scrollbar-track { background-color: #424242; } [data-bs-theme="dark"] .modal-body::-webkit-scrollbar-thumb { background-color: #686868; } @media (min-width: 768px) { #searchModal .modal-dialog { max-height: 40rem; } } .search-result h2, .search-result .h2 { margin-top: 0; } .search-result a:focus { /* border-color: transparent; box-shadow: 0; */ outline: 0 none; } .search-result .content { margin-top: 0.5rem; padding-top: 0 !important; padding-bottom: 0 !important; } .search-result .card .content p { margin-bottom: 0; } .search-result .card .content a { position: relative; z-index: 1; } .search-result:hover .card, .search-result.selected .card { background-color: #4f46e5; color: #fff; } .search-result:hover .card .content a, .search-result.selected .card .content a { color: #fff; text-decoration: underline; } [data-bs-theme="dark"] .search-result:hover .card, [data-bs-theme="dark"] .search-result.selected .card { background-color: #b3c7ff; color: #23262f; } [data-bs-theme="dark"] .search-result:hover .card .content a, [data-bs-theme="dark"] .search-result.selected .card .content a { color: #23262f; text-decoration: underline; } [data-bs-theme="dark"] .search-result:hover .card h2, [data-bs-theme="dark"] .search-result:hover .card .h2, [data-bs-theme="dark"] .search-result.selected .card h2, [data-bs-theme="dark"] .search-result.selected .card .h2 { color: #17181c; } .search-result .submitted { font-size: 0.875rem; margin-top: 0.5rem; } .navbar-form { position: relative; } #suggestions { position: absolute; right: 0; margin-top: 0.5rem; width: calc(100vw - 3rem); max-width: calc(400px - 3rem); z-index: 1000; } @media (min-width: 768px) { #suggestions { right: -2rem; } } @media (min-width: 992px) { #suggestions { right: 0; } } #suggestions a, .suggestion__no-results { padding: 0.75rem; margin: 0 0.5rem; } #suggestions a { display: block; text-decoration: none; } #suggestions a:focus { background: #f8f9fa; outline: 0; } #suggestions div:not(:first-child) { border-top: 1px dashed #e9ecef; } #suggestions div:first-child { margin-top: 0.5rem; } #suggestions div:last-child { margin-bottom: 0.5rem; } #suggestions a:hover { background: #f8f9fa; } #suggestions span { display: flex; font-size: 1rem; } .suggestion__title { font-weight: 700; color: #b3c7ff; } .suggestion__description, .suggestion__no-results { color: #495057; } @media (min-width: 992px) { #suggestions { width: 31.125rem; max-width: 31.125rem; } #suggestions a { display: flex; } .suggestion__title { width: 9rem; padding-right: 1rem; border-right: 1px solid #e9ecef; display: inline-block; text-align: right; } .suggestion__description { width: 19rem; padding-left: 1rem; } } .section-nav { padding-top: 2rem; } .section-nav details { border: 0; padding: 0; margin: 0.5rem 0; } .section-nav details[open] { padding: 0; } .section-nav summary { width: 100%; padding: 0; margin: 0; font-weight: 700; } .section-nav summary:hover { background: none; } .section-nav details[open] > summary { border-bottom: 0; margin-bottom: 0; } .section-nav ul.list-nested details { padding-left: 1rem; margin-top: 0.5rem; } .section-nav ul.list-nested li { margin: 0; } .section-nav a { display: block; margin: 0.5rem 0; color: #1d2d35; font-size: 1rem; text-decoration: none; } .section-nav a:hover, .section-nav a:active { color: #4f46e5; } .section-nav li.active a { color: #4f46e5; font-weight: 500; } .section-nav ul.list-nested li a { padding-left: 1rem; } .section-nav ul.list-nested { border-left: 1px solid #e9ecef; } [data-bs-theme="dark"] .section-nav ul.list-nested { border-left: 1px solid #23262f; } [data-bs-theme="dark"] .section-nav a { color: #c1c3c8; } [data-bs-theme="dark"] .section-nav a:hover, [data-bs-theme="dark"] .section-nav a:active { color: var(--sl-color-text-accent); } [data-bs-theme="dark"] .section-nav li.active a { color: var(--sl-color-text-accent); font-weight: 500; } [data-bs-theme="dark"] .section-nav summary { color: #fff; } table { margin: 3rem 0; } .nav-tabs { border-bottom: 0.0625rem solid #d8dee4; margin-bottom: 1rem; } .nav-tabs .nav-link, .nav-tabs .banner .nav a, .banner .nav .nav-tabs a { margin-bottom: -0.0625rem !important; background: none; border: 0; border-top-left-radius: 0; border-top-right-radius: 0; color: inherit; } .nav-tabs .nav-link:hover, .nav-tabs .banner .nav a:hover, .banner .nav .nav-tabs a:hover, .nav-tabs .nav-link:focus, .nav-tabs .banner .nav a:focus, .banner .nav .nav-tabs a:focus { isolation: isolate; border-color: transparent; color: var(--bs-emphasis-color); } .nav-tabs .nav-link.active, .nav-tabs .banner .nav a.active, .banner .nav .nav-tabs a.active, .nav-tabs .nav-item.show .nav-link, .nav-tabs .nav-item.show .banner .nav a, .banner .nav .nav-tabs .nav-item.show a, .nav-tabs .banner .nav li.show .nav-link, .nav-tabs .banner .nav li.show a, .banner .nav .nav-tabs li.show .nav-link, .banner .nav .nav-tabs li.show a { background-color: transparent; border-color: transparent; border-bottom: 0.125rem solid #4f46e5; } [data-bs-theme="dark"] .nav-tabs { border-bottom: 0.0625rem solid #343a40; } [data-bs-theme="dark"] .nav-tabs .nav-link.active, [data-bs-theme="dark"] .nav-tabs .banner .nav a.active, .banner .nav [data-bs-theme="dark"] .nav-tabs a.active, [data-bs-theme="dark"] .nav-tabs .nav-item.show .nav-link, [data-bs-theme="dark"] .nav-tabs .nav-item.show .banner .nav a, .banner .nav [data-bs-theme="dark"] .nav-tabs .nav-item.show a, [data-bs-theme="dark"] .nav-tabs .banner .nav li.show .nav-link, [data-bs-theme="dark"] .nav-tabs .banner .nav li.show a, .banner .nav [data-bs-theme="dark"] .nav-tabs li.show .nav-link, .banner .nav [data-bs-theme="dark"] .nav-tabs li.show a { border-bottom: 0.125rem solid #b3c7ff; } .footer { border-top: 1px solid #e9ecef; padding-top: 1.125rem; padding-bottom: 1.125rem; } .footer ul { margin-bottom: 0; } .footer li { font-size: 0.875rem; margin-bottom: 0; } .footer .list-inline-item:not(:last-child) { margin-right: 1rem; } @media (max-width: 991.98px) { .footer .col-lg-8 { margin-top: 0.25rem; margin-bottom: 0.25rem; } } @media (min-width: 768px) { .footer li { font-size: 1rem; } } .fixed-bottom-right { position: fixed; right: 0; bottom: 0; z-index: 1000; } .navbar-text { margin-left: 1rem; } .navbar-brand { font-weight: 700; } .navbar-brand svg { margin-right: 0.25rem; } [data-bs-theme="dark"] .navbar-brand { color: inherit; } /* .navbar-light .navbar-brand, .navbar-light .navbar-brand:hover, .navbar-light .navbar-brand:active { color: $body-color; } .navbar-light .navbar-nav .active .nav-link { color: $primary; } */ .navbar { z-index: 1000; background-color: rgba(255, 255, 255, 0.95); border-bottom: 1px solid #e9ecef; /* margin-top: 4px; */ } @media (min-width: 992px) { .navbar { z-index: 1025; /* padding-top: 0.25rem; padding-bottom: 0.25rem; */ } } @media (min-width: 768px) { .navbar-brand { font-size: 1.375rem; } .navbar-text { margin-left: 1.25rem; } } /* .navbar-nav { flex-direction: row; } */ .nav-item, .banner .nav li { margin-left: 0; } @media (max-width: 991.98px) { .navbar .icon-tabler-chevron-down { display: block; float: right; transform: rotate(270deg); transition: transform 0.35s ease; } .navbar .dropdown-toggle[aria-expanded="true"] .icon-tabler-chevron-down { transform: rotate(360deg); } .navbar-nav .dropdown-menu { border: 0; } /* .navbar-nav .nav-item { border-bottom: 1px solid rgba(52, 56, 65, 0.5); font-family: $headings-font-family; padding-top: 0.75rem; padding-bottom: 0.75rem; } */ .navbar-nav .nav-link, .navbar-nav .banner .nav a, .banner .nav .navbar-nav a { font-weight: 400; } .navbar-nav .dropdown-item { font-weight: 300; } .dropdown-toggle svg { margin-top: 0.25rem; margin-left: 0; } } @media (min-width: 768px) { .nav-item, .banner .nav li { margin-left: 0.5rem; } } /* @include media-breakpoint-down(sm) { .nav-item:first-child { margin-left: 0; } } */ /* @include media-breakpoint-down(md) { .navbar .container { padding-left: 1.5rem; padding-right: 1.5rem; } } */ .break { flex-basis: 100%; height: 0; } span#doks-language-current { margin-left: 0.1rem; } button#doks-languages { margin: 0.25rem 0 0; } @media (min-width: 992px) { button#doks-languages { margin: 0.25rem 0.5rem 0 0.25rem; } } button#doks-versions { margin: 0.25rem 0 0; } @media (min-width: 992px) { button#doks-versions { margin: 0.25rem 0.5rem 0 0.25rem; } } @media (max-width: 575.98px) { .navbar .offcanvas.offcanvas-start, .navbar .offcanvas.offcanvas-end { width: 80vw; } } .offcanvas-header { border-bottom: 1px solid #dee2e6; padding-top: 1.0625rem; padding-bottom: 0.8125rem; } h5.offcanvas-title, .offcanvas-title.h5 { margin: 0; color: inherit; } .offcanvas .nav-link, .offcanvas .banner .nav a, .banner .nav .offcanvas a { color: #1d2d35; } /* .doks-subnavbar { background-color: rgba(255, 255, 255, 0.95); border-bottom: 1px solid $gray-200; } .doks-subnavbar .nav-link { padding: 0.5rem 1.5rem 0.5rem 0; } .doks-subnavbar .nav-link:first-child { padding: 0.5rem 1.5rem 0.5rem 0; } */ .offcanvas .nav-link:hover, .offcanvas .banner .nav a:hover, .banner .nav .offcanvas a:hover, .offcanvas .nav-link:focus, .offcanvas .banner .nav a:focus, .banner .nav .offcanvas a:focus { color: #4f46e5; } .offcanvas .nav-link.active, .offcanvas .banner .nav a.active, .banner .nav .offcanvas a.active { color: #4f46e5; } /* .navbar { background-color: rgba(255, 255, 255, 0.95); border-bottom: 1px solid $gray-200; margin-top: 4px; } */ .header-bar { border-top: 4px solid; border-image-source: linear-gradient(83.21deg, #ffe000 0%, #e55235 100%); border-image-slice: 1; } [data-bs-theme="dark"] .header-bar { border-top: 4px solid; border-image-source: linear-gradient(83.21deg, var(--sl-color-accent) 0%, var(--sl-color-green) 100%); border-image-slice: 1; } .offcanvas .header-bar { margin-bottom: -4px; } .home .navbar { border-bottom: 0; } /* .navbar-form { position: relative; margin-top: 0.25rem; } */ @media (min-width: 992px) { .navbar-brand { margin-right: 0.75rem !important; } .main-nav .nav-item:first-child .nav-link, .main-nav .banner .nav li:first-child .nav-link, .banner .nav .main-nav li:first-child .nav-link, .main-nav .nav-item:first-child .banner .nav a, .banner .nav .main-nav .nav-item:first-child a, .main-nav .banner .nav li:first-child a, .banner .nav .main-nav li:first-child a, .social-nav .nav-item:first-child .nav-link, .social-nav .banner .nav li:first-child .nav-link, .banner .nav .social-nav li:first-child .nav-link, .social-nav .nav-item:first-child .banner .nav a, .banner .nav .social-nav .nav-item:first-child a, .social-nav .banner .nav li:first-child a, .banner .nav .social-nav li:first-child a { padding-left: 0; } .main-nav .nav-item:last-child .nav-link, .main-nav .banner .nav li:last-child .nav-link, .banner .nav .main-nav li:last-child .nav-link, .main-nav .nav-item:last-child .banner .nav a, .banner .nav .main-nav .nav-item:last-child a, .main-nav .banner .nav li:last-child a, .banner .nav .main-nav li:last-child a, .social-nav .nav-item:last-child .nav-link, .social-nav .banner .nav li:last-child .nav-link, .banner .nav .social-nav li:last-child .nav-link, .social-nav .nav-item:last-child .banner .nav a, .banner .nav .social-nav .nav-item:last-child a, .social-nav .banner .nav li:last-child a, .banner .nav .social-nav li:last-child a { padding-right: 0; } /* .doks-search { max-width: 20rem; margin-top: 0.125rem; margin-bottom: 0.125rem; } */ /* .navbar-form { margin-top: 0; margin-left: 6rem; margin-right: 1.5rem; } */ } .form-control.is-search, .comment-form input.is-search[type="text"], .comment-form input.is-search[type="email"], .comment-form input.is-search[type="url"], .comment-form textarea.is-search, .search-form .is-search.search-field { padding-right: 4rem; border: 1px solid transparent; background: #f8f9fa; } @media (min-width: 768px) { .form-control.is-search, .comment-form input.is-search[type="text"], .comment-form input.is-search[type="email"], .comment-form input.is-search[type="url"], .comment-form textarea.is-search, .search-form .is-search.search-field { width: calc(100% + 2rem); } } @media (min-width: 992px) { .form-control.is-search, .comment-form input.is-search[type="text"], .comment-form input.is-search[type="email"], .comment-form input.is-search[type="url"], .comment-form textarea.is-search, .search-form .is-search.search-field { width: 100%; } } .form-control.is-search:focus, .comment-form input.is-search[type="text"]:focus, .comment-form input.is-search[type="email"]:focus, .comment-form input.is-search[type="url"]:focus, .comment-form textarea.is-search:focus, .search-form .is-search.search-field:focus { border: 1px solid #4f46e5; } /* .doks-search::after { position: absolute; top: 0.4625rem; right: 0.5375rem; display: flex; align-items: center; justify-content: center; height: 1.5rem; padding-right: 0.3125rem; padding-left: 0.3125rem; font-size: $font-size-base * 0.75; color: $gray-700; content: "Ctrl + /"; border: 1px solid $gray-300; border-radius: 0.25rem; @include media-breakpoint-up(md) { right: -1.4625rem; } @include media-breakpoint-up(lg) { right: 0.3125rem; } } */ /* @include media-breakpoint-up(lg) { .navbar-form { margin-left: 15rem; } } @include media-breakpoint-up(xl) { .navbar-form { margin-left: 30rem; } } */ /* .form-control.is-search { */ /* padding-right: calc(1.5em + 0.75rem); */ /* padding-right: 2.5rem; background: $gray-100; border: 0; */ /* background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 24 24' fill='none' stroke='%236c757d' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-search'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'%3E%3C/line%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right calc(0.375em + 0.1875rem) center; background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); */ /* } */ /* .navbar-form::after { position: absolute; top: 0.4625rem; right: 0.5375rem; display: flex; align-items: center; justify-content: center; height: 1.5rem; padding-right: 0.4375rem; padding-left: 0.4375rem; font-size: $font-size-base * 0.75; color: $gray-700; content: "/"; border: 1px solid $gray-300; border-radius: 0.25rem; } */ /*! purgecss start ignore */ /* .algolia-autocomplete { display: flex !important; } .algolia-autocomplete .ds-dropdown-menu { box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important; } @include media-breakpoint-down(sm) { .algolia-autocomplete .ds-dropdown-menu { max-width: 512px !important; min-width: 312px !important; width: auto !important; } .algolia-autocomplete .algolia-docsearch-suggestion .algolia-docsearch-suggestion--subcategory-column { font-weight: normal; } .algolia-autocomplete .algolia-docsearch-suggestion .algolia-docsearch-suggestion--subcategory-column::after { content: "/"; margin-right: 0.25rem; } } .algolia-autocomplete .algolia-docsearch-suggestion--category-header { color: $link-color-dark; } .algolia-autocomplete .algolia-docsearch-suggestion--title { margin-bottom: 0; } .algolia-autocomplete .algolia-docsearch-suggestion--highlight { padding: 0 0.05em; } .algolia-autocomplete .algolia-docsearch-footer { margin-top: 1rem; margin-right: 0.5rem; margin-bottom: 0.5rem; } */ /*! purgecss end ignore */ /* * Source: https://medium.com/creative-technology-concepts-code/responsive-mobile-dropdown-navigation-using-css-only-7218e4498a99 */ /* Style the menu icon for the dropdown */ .navbar .menu-icon { cursor: pointer; /* display: inline-block; */ /* float: right; */ padding: 1.125rem 0.625rem; margin: 0 0 0 -0.625rem; /* position: relative; */ user-select: none; } .navbar .menu-icon .navicon { background: rgba(var(--bs-emphasis-color-rgb), 0.65); display: block; height: 2px; position: relative; transition: background 0.2s ease-out; width: 18px; } .navbar .menu-icon .navicon::before, .navbar .menu-icon .navicon::after { background: rgba(var(--bs-emphasis-color-rgb), 0.65); content: ""; display: block; height: 100%; position: absolute; transition: all 0.2s ease-out; width: 100%; } .navbar .menu-icon .navicon::before { top: 5px; } .navbar .menu-icon .navicon::after { top: -5px; } /* Add the icon and menu animations when the checkbox is clicked */ .navbar .menu-btn { display: none; } .navbar .menu-btn:checked ~ .navbar-collapse { display: block; max-height: 100vh; } .navbar .menu-btn:checked ~ .menu-icon .navicon { background: transparent; } .navbar .menu-btn:checked ~ .menu-icon .navicon::before { transform: rotate(-45deg); } .navbar .menu-btn:checked ~ .menu-icon .navicon::after { transform: rotate(45deg); } .navbar .menu-btn:checked ~ .menu-icon:not(.steps) .navicon::before, .navbar .menu-btn:checked ~ .menu-icon:not(.steps) .navicon::after { top: 0; } .btn-menu { margin-left: 1rem; border: transparent; } .btn-doks-light { border: transparent; } .btn-menu, .doks-sidebar-toggle { padding-right: 0.25rem; padding-left: 0.25rem; margin-right: -0.5rem; } .btn-menu:hover, .btn-doks-light:hover, .doks-sidebar-toggle:hover { background: transparent; border: transparent; } .btn-menu:focus, .btn-doks-light:focus, .doks-sidebar-toggle:focus, .doks-mode-toggle:focus { outline: 0; border: transparent; } .doks-sidebar-toggle .doks-collapse, .doks-toc-toggle .doks-collapse { display: none; } .doks-sidebar-toggle:not(.collapsed) .doks-expand, .doks-toc-toggle:not(.collapsed) .doks-expand { display: none; } .doks-sidebar-toggle:not(.collapsed) .doks-collapse, .doks-toc-toggle:not(.collapsed) .doks-collapse { display: inline-block; } .navbar-light .navbar-brand, .navbar-light .navbar-brand:hover, .navbar-light .navbar-brand:active { color: #1d2d35; } .navbar-light .navbar-nav .active .nav-link, .navbar-light .navbar-nav .active .banner .nav a, .banner .nav .navbar-light .navbar-nav .active a { color: #4f46e5; } .dropdown-divider { border-top: 1px dashed #e9ecef; } .dropdown-item:hover { background: #f8f9fa; } .dropdown-item:active { color: inherit; } .social-link { padding-right: 0.375rem; padding-left: 0.375rem; } @media (max-width: 991.98px) { #buttonColorMode { margin: 0.5rem 0; } #socialMenu { margin: 0.5rem 0 0.5rem -0.25rem; } .navbar-nav { margin-top: 1rem; } .dropdown-menu { box-shadow: none !important; background: transparent !important; border-radius: 0 !important; padding: 0; margin-bottom: 0.25rem; } .dropdown-item { padding: 0.375rem 1rem 0.375rem 0; } .nav-item .nav-link, .banner .nav li .nav-link, .nav-item .banner .nav a, .banner .nav .nav-item a, .banner .nav li a { font-weight: 400; font-size: 1.125rem; } .btn-dropdown { font-weight: 400; font-size: 1.125rem; } } /* @include media-breakpoint-up(lg) { // Source: https://bootstrap-menu.com/detail-basic-hover.html .navbar .nav-item .dropdown-menu { display: none; } .navbar .nav-item:hover .dropdown-menu { display: block; } } */ .modal-backdrop, .offcanvas-backdrop { visibility: hidden; background: rgba(23, 24, 28, 0.5); opacity: 0; } [data-bs-theme="dark"] .modal-backdrop, [data-bs-theme="dark"] .offcanvas-backdrop { visibility: hidden; background: rgba(23, 24, 28, 0.5); opacity: 0; } .modal-backdrop.show, .offcanvas-backdrop.show { visibility: visible; opacity: 1; -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); } .showing, .hiding { -webkit-transition: none; transition: none; display: none; } .offcanvas-top.h-auto { bottom: initial; } .navbar > .container, .navbar > .container-fluid, .navbar > .container-sm, .navbar > .container-md, .navbar > .container-lg, .navbar > .container-xl, .navbar > .container-xxl { padding-right: 0.75rem; } .docs-content > h2[id]::before, .docs-content > [id].h2::before, .docs-content > h3[id]::before, .docs-content > [id].h3::before, .docs-content > h4[id]::before, .docs-content > [id].h4::before { display: block; height: 6rem; margin-top: -6rem; content: ""; } .docs-content ul, .docs-content ol { margin-bottom: 1rem; } .anchor { visibility: hidden; margin-left: 0.375rem; } .edit-page a, .last-modified a { color: var(--sl-color-gray-3); } h1:hover a, .h1:hover a, h2:hover a, .h2:hover a, h3:hover a, .h3:hover a, h4:hover a, .h4:hover a { visibility: visible; text-decoration: none; } .card-list { margin-top: 2.25rem; } .page-footer-meta { margin-top: 2rem; margin-bottom: 2rem; } .edit-page, .last-modified { font-size: 0.875rem; margin-top: 0.25rem; margin-bottom: 0.25rem; } @media (min-width: 768px) { .edit-page, .last-modified { font-size: 1rem; margin-top: 0.75rem; margin-bottom: 0.25rem; } } .edit-page a:hover, .last-modified a:hover { color: var(--sl-color-gray-4); text-decoration: none; } [data-bs-theme="dark"] .edit-page a:hover, [data-bs-theme="dark"] .last-modified a:hover { color: var(--sl-color-gray-2); } .edit-page svg, .last-modified svg { margin-right: 0.25rem; margin-bottom: 0.25rem; } p.meta { margin-top: 0.5rem; font-size: 1rem; } .breadcrumb { margin-top: 2.25rem; font-size: 1rem; } .toc-mobile { margin-top: 2rem; margin-bottom: 2rem; } .page-link:hover { text-decoration: none; } .row-about { padding-top: 5rem; padding-bottom: 5rem; } @media (min-width: 992px) { .row-about { padding-top: 7rem; padding-bottom: 7rem; } } .row-about h1, .row-about .h1 { margin-top: 1rem; } ul li { margin: 0.25rem 0; } .list-contributors { margin-left: 1.25rem; } .list-contributors li { margin: 0.25rem 0 0.25rem -1.5rem; padding: 0.25rem; background-color: #fff; border-radius: 50%; } [data-bs-theme="dark"] .list-contributors li { background-color: #212529; } ul.list-toolbox li { position: relative; margin: 0.25rem 0; } ul.list-toolbox li::before { background: none; content: "🧰"; height: 1rem; width: 1rem; position: absolute; left: -2rem; top: 0; } ul.list-books li { position: relative; margin: 0.25rem 0; } ul.list-books li::before { background: none; content: "📚"; height: 1rem; width: 1rem; position: absolute; left: -2rem; top: 0; } ul.list-speech-balloon li { position: relative; margin: 0.25rem 0; } ul.list-speech-balloon li::before { background: none; content: "💬"; height: 1rem; width: 1rem; position: absolute; left: -2rem; top: 0; } ul.list-package li { position: relative; margin: 0.25rem 0; } ul.list-package li::before { background: none; content: "📦"; height: 1rem; width: 1rem; position: absolute; left: -2rem; top: 0; } ul.list-star li { position: relative; margin: 0.25rem 0; } ul.list-star li::before { background: none; content: "⭐"; height: 1rem; width: 1rem; position: absolute; left: -2rem; top: 0; } .page-nav .card .icon-tabler-arrow-left { margin-right: 0.75rem; } .page-nav .card .icon-tabler-arrow-right { margin-left: 0.75rem; } .page-nav .card:hover { border: 1px solid #d9d9d9; } [data-bs-theme="dark"] .page-nav .card { border: 1px solid #353841; } [data-bs-theme="dark"] .page-nav .card:hover { border: 1px solid #888c96; } .container-fw { max-width: 1200px; } .container-fw .docs-toc { margin-left: 3rem; } .home .card, .contributors.list .card, .blog.list .card, .blog.single .card, .categories.list .card, .tags.list .card { margin-top: 2rem; margin-bottom: 2rem; transition: transform 0.3s; } .home .content .card:hover, .contributors.list .content .card:hover, .blog.list .content .card:hover, .blog.single .content .card:hover, .categories.list .content .card:hover, .tags.list .content .card:hover { transform: scale(1.025); } .contributors.list .card.card-terms:hover, .categories.list .card.card-terms:hover, .tags.list .card.card-terms:hover { transform: none; } .home .content .card-body, .contributors.list .content .card-body, .blog.list .content .card-body, .blog.single .content .card-body, .categories.list .content .card-body, .tags.list .content .card-body { padding: 0 2rem 1rem; } .contributors.list .card-terms .card-body, .categories.list .card-terms .card-body, .tags.list .card-terms .card-body { padding: 1rem; } .blog-header { text-align: center; margin-bottom: 2rem; } .blog-footer { text-align: center; } .related-posts { margin-top: 4rem; } h2.section-title, .section-title.h2 { margin-bottom: 1.25rem; } .img-post-single { margin-bottom: 2rem; } .pagination { display: flex; justify-content: center; } .page-item:first-child, .page-item:last-child, .page-item.disabled { display: none; } .page-item a { margin-left: 0.5rem; margin-right: 0.5rem; padding-left: 0.875rem; padding-right: 0.875rem; } .page-item a[aria-label="Previous"], .page-item a[aria-label="Next"] { border-radius: 50%; } .tag-list-single { margin-top: 3rem; margin-bottom: 1rem; } .section-related { padding-top: 1.5rem; padding-bottom: 1.5rem; } .contributor-image { text-align: center; margin-top: 2.5rem; } span.reading-time { margin-left: 2rem; } span.reading-time svg { margin-right: 0.3rem; vertical-align: -0.4rem; } .docs-links, .docs-toc { scrollbar-width: thin; scrollbar-color: #fff #fff; } .docs-links::-webkit-scrollbar, .docs-toc::-webkit-scrollbar { width: 5px; } .docs-links::-webkit-scrollbar-track, .docs-toc::-webkit-scrollbar-track { background: #fff; } .docs-links::-webkit-scrollbar-thumb, .docs-toc::-webkit-scrollbar-thumb { background: #fff; } .docs-links:hover, .docs-toc:hover { scrollbar-width: thin; scrollbar-color: #e9ecef #fff; } .docs-links:hover::-webkit-scrollbar-thumb, .docs-toc:hover::-webkit-scrollbar-thumb { background: #e9ecef; } .docs-links::-webkit-scrollbar-thumb:hover, .docs-toc::-webkit-scrollbar-thumb:hover { background: #e9ecef; } .docs-links h3, .docs-links .h3, .page-links h3, .page-links .h3 { font-size: 1.125rem; margin: 1.25rem 0 0.5rem; padding: 1.5rem 0 0; } @media (min-width: 992px) { .docs-links h3, .docs-links .h3, .page-links h3, .page-links .h3 { margin: 1.125rem 1.5rem 0.75rem 0; padding: 1.375rem 0 0; } } .docs-links h3:not(:first-child), .docs-links .h3:not(:first-child) { border-top: 1px solid #e9ecef; } a.docs-link { color: #1d2d35; display: block; padding: 0.125rem 0; font-size: 1rem; } .page-links li { margin-top: 0.375rem; padding-top: 0.375rem; } .page-links li ul li { border-top: none; padding-left: 1rem; margin-top: 0.125rem; padding-top: 0.125rem; } .page-links li:not(:first-child) { border-top: 1px dashed #e9ecef; } .page-links a { color: #1d2d35; display: block; padding: 0.125rem 0; font-size: 0.9375rem; text-decoration: none; } .docs-link:hover, .docs-link.active, .page-links a:hover, .page-links a.active { text-decoration: none; color: #4f46e5; } .nav-link.active, .banner .nav a.active, .dropdown-menu-main .dropdown-item.active, .docs-link.active { font-weight: 500; } .docs-links h3.sidebar-link, .docs-links .sidebar-link.h3, .page-links h3.sidebar-link, .page-links .sidebar-link.h3 { text-transform: none; font-size: 1.125rem; font-weight: normal; } .docs-links h3.sidebar-link a, .docs-links .sidebar-link.h3 a, .page-links h3.sidebar-link a, .page-links .sidebar-link.h3 a { color: #1d2d35; } .docs-links h3.sidebar-link a:hover, .docs-links .sidebar-link.h3 a:hover, .page-links h3.sidebar-link a:hover, .page-links .sidebar-link.h3 a:hover { text-decoration: underline; } /* body { background-color: {{ site.Params.doks.backGround }}; } */ * { -webkit-font-smoothing: antialiased; } h1, .h1, h2, .h2, h3, .h3, h4, .h4, h5, .h5, .navbar-brand { font-family: Quicksand, sans-serif; font-weight: 700; } [data-bs-theme="dark"] .only-light { display: none; } [data-bs-theme="light"] .only-dark { display: none; } .homepage-features .row { max-width: 1400px; margin: 0 auto; } @media (min-width: 992px) { .text-center.text-lg-start { padding-right: 2rem; } } .learn-container { max-width: 1200px; margin: 0 auto; padding: 3rem 1rem; } .learn-header { text-align: center; margin-bottom: 4rem; } .learn-main-title { font-size: 3rem; font-weight: 700; margin-bottom: 1rem; color: var(--bs-body-color); } .learn-subtitle { font-size: 1.25rem; color: var(--bs-body-color-secondary); max-width: 600px; margin: 0 auto; line-height: 1.6; } .learn-section { margin-bottom: 4rem; } .learn-section:last-child { margin-bottom: 0; } .learn-section-title { text-align: center; font-size: 2rem; font-weight: 600; margin-bottom: 3rem; color: var(--bs-body-color); } .learn-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 2rem; justify-items: center; } .learn-card { display: flex; flex-direction: column; padding: 2rem; border-radius: 0.75rem; transition: all 0.3s ease; text-decoration: none; background: var(--bs-body-bg); border: 1px solid var(--bs-border-color); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); min-height: 280px; width: 100%; max-width: 350px; text-align: center; color: inherit; cursor: pointer; position: relative; overflow: hidden; } .learn-card:hover { transform: translateY(-2px); box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1); text-decoration: none; color: inherit; border-color: var(--bs-primary); } .learn-card:focus { outline: 2px solid var(--bs-primary); outline-offset: 2px; } .learn-card-icon { margin: 0 auto 0; color: var(--bs-primary); display: flex; justify-content: center; align-items: center; width: 100px; height: 100px; } .learn-card h3, .learn-card .h3 { font-size: 1.25rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--bs-body-color); margin-top: 0; } .learn-card-secondary { display: block; font-size: 0.9rem; font-weight: 600; color: var(--bs-gray-600); margin-bottom: 1rem; } .learn-card p { font-size: 0.95rem; line-height: 1.6; margin-bottom: 2rem; flex-grow: 1; color: var(--bs-body-color-secondary); } [data-bs-theme="dark"] .learn-card { background: var(--bs-body-bg); border-color: var(--bs-border-color); } [data-bs-theme="dark"] .learn-card:hover { border-color: var(--bs-primary); } @media (max-width: 767.98px) { .learn-container { padding: 2rem 1rem; } .learn-header { margin-bottom: 3rem; } .learn-main-title { font-size: 2.5rem; } .learn-subtitle { font-size: 1.1rem; } .learn-section-title { font-size: 1.5rem; margin-bottom: 2rem; } .learn-row { grid-template-columns: 1fr; gap: 1.5rem; } .learn-card { padding: 1.5rem; min-height: 240px; max-width: none; } } @media (min-width: 768px) and (max-width: 1199.98px) { .learn-row { grid-template-columns: repeat(2, 1fr); } .learn-section:first-child .learn-row { grid-template-columns: repeat(2, 1fr); } .learn-section:first-child .learn-row .learn-card:nth-child(3) { grid-column: 1 / -1; justify-self: center; } } .quickstart-container { max-width: 800px; margin: 0 auto; padding: 3rem 1rem; } .quickstart-header { text-align: center; margin-bottom: 3rem; } .quickstart-main-title { font-size: 3rem; font-weight: 700; margin-bottom: 1rem; color: var(--bs-body-color); } .quickstart-subtitle { font-size: 1.25rem; color: var(--bs-body-color-secondary); max-width: 600px; margin: 0 auto; line-height: 1.6; } .quickstart-content { font-size: 1.1rem; line-height: 1.8; } body.quickstart .event-driven-banner { display: none; } @media (max-width: 767.98px) { .quickstart-container { padding: 2rem 1rem; } .quickstart-header { margin-bottom: 2rem; } .quickstart-main-title { font-size: 2.5rem; } .quickstart-subtitle { font-size: 1.1rem; } } @media (max-width: 991.98px) { .display-5 { font-size: 2rem; } .text-center.text-lg-start { text-align: center !important; padding-right: 0; } } .pubsub-logos-container { padding: 2rem 0; } .pubsub-logos-grid { display: flex; flex-wrap: wrap; gap: 2rem; max-width: 900px; margin: 0 auto; align-items: center; justify-content: center; } .pubsub-logo-item { display: flex; flex-direction: column; align-items: center; text-align: center; transition: all 0.3s ease; padding: 1rem; border-radius: 0.75rem; text-decoration: none; color: inherit; flex: 0 0 120px; } .pubsub-logo-item img { width: 60px; height: 60px; margin-bottom: 0.75rem; transition: all 0.3s ease; } .pubsub-logo-item span { font-size: 0.9rem; font-weight: 600; color: var(--bs-body-color-secondary); transition: all 0.3s ease; } .pubsub-logo-item:hover { background: rgba(var(--bs-primary-rgb), 0.05); text-decoration: none; color: inherit; } .pubsub-logo-item:hover span { color: var(--bs-primary); } .pubsub-logo-item:focus { outline: 2px solid var(--bs-primary); outline-offset: 2px; text-decoration: none; color: inherit; } [data-bs-theme="dark"] .pubsub-logo-item:hover { background: rgba(var(--bs-primary-rgb), 0.1); } @media (max-width: 767.98px) { .pubsub-logos-grid { grid-template-columns: repeat(3, 1fr); gap: 1.5rem; max-width: 100%; } .pubsub-logo-item { padding: 0.75rem; } .pubsub-logo-item img { width: 50px; height: 50px; margin-bottom: 0.5rem; } .pubsub-logo-item span { font-size: 0.8rem; } } @media (max-width: 575.98px) { .pubsub-logos-grid { grid-template-columns: repeat(2, 1fr); gap: 1rem; } .pubsub-logo-item img { width: 45px; height: 45px; } .pubsub-logo-item span { font-size: 0.75rem; } } .homepage-code-block .frame { border-radius: 12px !important; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1), 0 2px 4px rgba(0, 0, 0, 0.06) !important; border: 1px solid var(--bs-border-color) !important; overflow: hidden; overflow-x: auto; } .homepage-code-block .frame * { border-radius: inherit !important; } .homepage-code-block .frame pre, .homepage-code-block .frame code, .homepage-code-block .frame .expressive-code-block { border-radius: 12px !important; border: none !important; } .homepage-code-block .frame .highlight { border-radius: 12px !important; border: none !important; } [data-bs-theme="light"] .homepage-code-block .frame pre, [data-bs-theme="light"] .homepage-code-block .frame code, [data-bs-theme="light"] .homepage-code-block .frame .expressive-code-block { background: white !important; } @media (max-width: 767.98px) { .homepage-code-block { margin-left: -45px; margin-right: -45px; } } [data-bs-theme="dark"] .homepage-code-block .frame { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3), 0 2px 4px rgba(0, 0, 0, 0.2) !important; border: 1px solid var(--bs-border-color) !important; } .gradient-text-1 { background: linear-gradient(to right, #6366f1, #8b5cf6, #3b82f6); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; color: transparent; } .homepage-section-gradient { position: relative; } .homepage-section-gradient::before { content: ''; position: absolute; top: 0; left: 50%; transform: translateX(-50%); width: 100vw; height: 100%; border-top: 1px solid rgba(0, 0, 0, 0.08); pointer-events: none; z-index: -1; } .homepage-section-gradient::before { background: linear-gradient(180deg, rgba(79, 70, 229, 0.03) 0%, transparent 100%); } [data-bs-theme="dark"] .homepage-section-gradient::before { background: linear-gradient(180deg, rgba(79, 70, 229, 0.06) 0%, transparent 100%); border-top: 1px solid rgba(255, 255, 255, 0.08); } [data-bs-theme="dark"], [data-bs-theme="light"] { /* Background */ /* PreWrapper */ /* Other */ /* Error */ /* CodeLine */ /* LineLink */ /* LineTableTD */ /* LineTable */ /* LineHighlight */ /* LineNumbersTable */ /* LineNumbers */ /* Line */ /* Keyword */ /* KeywordConstant */ /* KeywordDeclaration */ /* KeywordNamespace */ /* KeywordPseudo */ /* KeywordReserved */ /* KeywordType */ /* Name */ /* NameAttribute */ /* NameBuiltin */ /* NameBuiltinPseudo */ /* NameClass */ /* NameConstant */ /* NameDecorator */ /* NameEntity */ /* NameException */ /* NameFunction */ /* NameFunctionMagic */ /* NameLabel */ /* NameNamespace */ /* NameOther */ /* NameProperty */ /* NameTag */ /* NameVariable */ /* NameVariableClass */ /* NameVariableGlobal */ /* NameVariableInstance */ /* NameVariableMagic */ /* Literal */ /* LiteralDate */ /* LiteralString */ /* LiteralStringAffix */ /* LiteralStringBacktick */ /* LiteralStringChar */ /* LiteralStringDelimiter */ /* LiteralStringDoc */ /* LiteralStringDouble */ /* LiteralStringEscape */ /* LiteralStringHeredoc */ /* LiteralStringInterpol */ /* LiteralStringOther */ /* LiteralStringRegex */ /* LiteralStringSingle */ /* LiteralStringSymbol */ /* LiteralNumber */ /* LiteralNumberBin */ /* LiteralNumberFloat */ /* LiteralNumberHex */ /* LiteralNumberInteger */ /* LiteralNumberIntegerLong */ /* LiteralNumberOct */ /* Operator */ /* OperatorWord */ /* Punctuation */ /* Comment */ /* CommentHashbang */ /* CommentMultiline */ /* CommentSingle */ /* CommentSpecial */ /* CommentPreproc */ /* CommentPreprocFile */ /* Generic */ /* GenericDeleted */ /* GenericEmph */ /* GenericError */ /* GenericHeading */ /* GenericInserted */ /* GenericOutput */ /* GenericPrompt */ /* GenericStrong */ /* GenericSubheading */ /* GenericTraceback */ /* GenericUnderline */ /* TextWhitespace */ } [data-bs-theme="dark"] .bg, [data-bs-theme="light"] .bg { color: #c9c9c9; background-color: #282c34; } [data-bs-theme="dark"] .chroma, [data-bs-theme="light"] .chroma { color: #c9c9c9; background-color: #282c34; } [data-bs-theme="dark"] .chroma .err, [data-bs-theme="light"] .chroma .err { color: #cf5967; } [data-bs-theme="dark"] .chroma .lnlinks, [data-bs-theme="light"] .chroma .lnlinks { outline: none; text-decoration: none; color: inherit; } [data-bs-theme="dark"] .chroma .lntd, [data-bs-theme="light"] .chroma .lntd { vertical-align: top; padding: 0; margin: 0; border: 0; } [data-bs-theme="dark"] .chroma .lntable, [data-bs-theme="light"] .chroma .lntable { border-spacing: 0; padding: 0; margin: 0; border: 0; } [data-bs-theme="dark"] .chroma .hl, [data-bs-theme="light"] .chroma .hl { background-color: #3d4148; } [data-bs-theme="dark"] .chroma .lnt, [data-bs-theme="light"] .chroma .lnt { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em; color: #7f7f7f; } [data-bs-theme="dark"] .chroma .ln, [data-bs-theme="light"] .chroma .ln { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em; color: #7f7f7f; } [data-bs-theme="dark"] .chroma .line, [data-bs-theme="light"] .chroma .line { display: flex; } [data-bs-theme="dark"] .chroma .k, [data-bs-theme="light"] .chroma .k { color: #7fbaf5; } [data-bs-theme="dark"] .chroma .kc, [data-bs-theme="light"] .chroma .kc { color: #cf5967; } [data-bs-theme="dark"] .chroma .kd, [data-bs-theme="light"] .chroma .kd { color: #7fbaf5; } [data-bs-theme="dark"] .chroma .kn, [data-bs-theme="light"] .chroma .kn { color: #bc74c4; } [data-bs-theme="dark"] .chroma .kp, [data-bs-theme="light"] .chroma .kp { color: #bc74c4; } [data-bs-theme="dark"] .chroma .kr, [data-bs-theme="light"] .chroma .kr { color: #7fbaf5; } [data-bs-theme="dark"] .chroma .kt, [data-bs-theme="light"] .chroma .kt { color: #57c7ff; font-weight: bold; } [data-bs-theme="dark"] .chroma .na, [data-bs-theme="light"] .chroma .na { color: #bc74c4; } [data-bs-theme="dark"] .chroma .nb, [data-bs-theme="light"] .chroma .nb { color: #7fbaf5; } [data-bs-theme="dark"] .chroma .bp, [data-bs-theme="light"] .chroma .bp { color: #7fbaf5; } [data-bs-theme="dark"] .chroma .nc, [data-bs-theme="light"] .chroma .nc { color: #ecbe7b; } [data-bs-theme="dark"] .chroma .no, [data-bs-theme="light"] .chroma .no { color: #ecbe7b; } [data-bs-theme="dark"] .chroma .nd, [data-bs-theme="light"] .chroma .nd { color: #ecbe7b; } [data-bs-theme="dark"] .chroma .ne, [data-bs-theme="light"] .chroma .ne { color: #cf5967; } [data-bs-theme="dark"] .chroma .nf, [data-bs-theme="light"] .chroma .nf { color: #57c7ff; } [data-bs-theme="dark"] .chroma .nl, [data-bs-theme="light"] .chroma .nl { color: #cf5967; } [data-bs-theme="dark"] .chroma .nt, [data-bs-theme="light"] .chroma .nt { color: #bc74c4; } [data-bs-theme="dark"] .chroma .nv, [data-bs-theme="light"] .chroma .nv { color: #bc74c4; font-style: italic; } [data-bs-theme="dark"] .chroma .vc, [data-bs-theme="light"] .chroma .vc { color: #57c7ff; font-weight: bold; } [data-bs-theme="dark"] .chroma .vg, [data-bs-theme="light"] .chroma .vg { color: #ecbe7b; } [data-bs-theme="dark"] .chroma .vi, [data-bs-theme="light"] .chroma .vi { color: #57c7ff; } [data-bs-theme="dark"] .chroma .ld, [data-bs-theme="light"] .chroma .ld { color: #57c7ff; } [data-bs-theme="dark"] .chroma .s, [data-bs-theme="light"] .chroma .s { color: #82cc6a; } [data-bs-theme="dark"] .chroma .sa, [data-bs-theme="light"] .chroma .sa { color: #82cc6a; } [data-bs-theme="dark"] .chroma .sb, [data-bs-theme="light"] .chroma .sb { color: #57c7ff; } [data-bs-theme="dark"] .chroma .sc, [data-bs-theme="light"] .chroma .sc { color: #57c7ff; } [data-bs-theme="dark"] .chroma .dl, [data-bs-theme="light"] .chroma .dl { color: #82cc6a; } [data-bs-theme="dark"] .chroma .sd, [data-bs-theme="light"] .chroma .sd { color: #82cc6a; } [data-bs-theme="dark"] .chroma .s2, [data-bs-theme="light"] .chroma .s2 { color: #82cc6a; } [data-bs-theme="dark"] .chroma .se, [data-bs-theme="light"] .chroma .se { color: #56b6c2; } [data-bs-theme="dark"] .chroma .sh, [data-bs-theme="light"] .chroma .sh { color: #56b6c2; } [data-bs-theme="dark"] .chroma .si, [data-bs-theme="light"] .chroma .si { color: #82cc6a; } [data-bs-theme="dark"] .chroma .sx, [data-bs-theme="light"] .chroma .sx { color: #82cc6a; } [data-bs-theme="dark"] .chroma .sr, [data-bs-theme="light"] .chroma .sr { color: #57c7ff; } [data-bs-theme="dark"] .chroma .s1, [data-bs-theme="light"] .chroma .s1 { color: #82cc6a; } [data-bs-theme="dark"] .chroma .ss, [data-bs-theme="light"] .chroma .ss { color: #82cc6a; } [data-bs-theme="dark"] .chroma .m, [data-bs-theme="light"] .chroma .m { color: #56b6c2; } [data-bs-theme="dark"] .chroma .mb, [data-bs-theme="light"] .chroma .mb { color: #57c7ff; } [data-bs-theme="dark"] .chroma .mf, [data-bs-theme="light"] .chroma .mf { color: #56b6c2; } [data-bs-theme="dark"] .chroma .mh, [data-bs-theme="light"] .chroma .mh { color: #57c7ff; } [data-bs-theme="dark"] .chroma .mi, [data-bs-theme="light"] .chroma .mi { color: #56b6c2; } [data-bs-theme="dark"] .chroma .il, [data-bs-theme="light"] .chroma .il { color: #56b6c2; } [data-bs-theme="dark"] .chroma .mo, [data-bs-theme="light"] .chroma .mo { color: #57c7ff; } [data-bs-theme="dark"] .chroma .o, [data-bs-theme="light"] .chroma .o { color: #bc74c4; } [data-bs-theme="dark"] .chroma .ow, [data-bs-theme="light"] .chroma .ow { color: #bc74c4; } [data-bs-theme="dark"] .chroma .p, [data-bs-theme="light"] .chroma .p { color: #56b6c2; } [data-bs-theme="dark"] .chroma .c, [data-bs-theme="light"] .chroma .c { color: #3e4460; } [data-bs-theme="dark"] .chroma .ch, [data-bs-theme="light"] .chroma .ch { color: #3e4460; font-style: italic; } [data-bs-theme="dark"] .chroma .cm, [data-bs-theme="light"] .chroma .cm { color: #3e4460; } [data-bs-theme="dark"] .chroma .c1, [data-bs-theme="light"] .chroma .c1 { color: #3e4460; } [data-bs-theme="dark"] .chroma .cs, [data-bs-theme="light"] .chroma .cs { color: #bc74c4; font-style: italic; } [data-bs-theme="dark"] .chroma .cp, [data-bs-theme="light"] .chroma .cp { color: #7fbaf5; } [data-bs-theme="dark"] .chroma .cpf, [data-bs-theme="light"] .chroma .cpf { color: #7fbaf5; } [data-bs-theme="dark"] .chroma .gd, [data-bs-theme="light"] .chroma .gd { color: #cf5967; } [data-bs-theme="dark"] .chroma .ge, [data-bs-theme="light"] .chroma .ge { text-decoration: underline; } [data-bs-theme="dark"] .chroma .gr, [data-bs-theme="light"] .chroma .gr { color: #cf5967; font-weight: bold; } [data-bs-theme="dark"] .chroma .gh, [data-bs-theme="light"] .chroma .gh { color: #ecbe7b; font-weight: bold; } [data-bs-theme="dark"] .chroma .gi, [data-bs-theme="light"] .chroma .gi { color: #ecbe7b; } [data-bs-theme="dark"] .chroma .go, [data-bs-theme="light"] .chroma .go { color: #43454f; } [data-bs-theme="dark"] .chroma .gs, [data-bs-theme="light"] .chroma .gs { color: #cf5967; font-weight: bold; } [data-bs-theme="dark"] .chroma .gu, [data-bs-theme="light"] .chroma .gu { color: #cf5967; font-style: italic; } [data-bs-theme="dark"] .chroma .gl, [data-bs-theme="light"] .chroma .gl { text-decoration: underline; } /*# sourceMappingURL=main.css.map */ ================================================ FILE: docs/resources/_gen/assets/scss/app.scss_901a6e181e810c5c7347a10d84f037ab.json ================================================ {"Target":"main.83702c5537fa0c04c34adb61a7648af280d09c019220547885486514a58362197cb2edbc80454e2ef087a4e5604abe50e4672cec25bf6db782f47ea6df8beff2.css","MediaType":"text/css","Data":{"Integrity":"sha512-g3AsVTf6DATDStthp2SK8oDQnAGSIFR4hUhlFKWDYhl8su28gEVOLvCHpOVgSr5Q5Gcs7CW/bbeC9H6m34vv8g=="}} ================================================ FILE: docs/resources/_gen/assets/scss/app.scss_cdf9d7c9eb97e4550ded64a8776dd9e8.content ================================================ :root[data-bs-theme="light"],[data-bs-theme="light"] ::backdrop{--sl-color-white: hsl(224, 10%, 10%);--sl-color-gray-1: hsl(224, 14%, 16%);--sl-color-gray-2: hsl(224, 10%, 23%);--sl-color-gray-3: hsl(224, 7%, 36%);--sl-color-gray-4: hsl(224, 6%, 56%);--sl-color-gray-5: hsl(224, 6%, 77%);--sl-color-gray-6: hsl(224, 20%, 94%);--sl-color-gray-7: hsl(224, 19%, 97%);--sl-color-black: hsl(0, 0%, 100%)}:root,::backdrop{--sl-color-white: hsl(0, 0%, 100%);--sl-color-gray-1: hsl(224, 20%, 94%);--sl-color-gray-2: hsl(224, 6%, 77%);--sl-color-gray-3: hsl(224, 6%, 56%);--sl-color-gray-4: hsl(224, 7%, 36%);--sl-color-gray-5: hsl(224, 10%, 23%);--sl-color-gray-6: hsl(224, 14%, 16%);--sl-color-black: hsl(224, 10%, 10%);--sl-hue-orange: 41;--sl-color-orange-low: hsl(var(--sl-hue-orange), 39%, 22%);--sl-color-orange: hsl(var(--sl-hue-orange), 82%, 63%);--sl-color-orange-high: hsl(var(--sl-hue-orange), 82%, 87%);--sl-hue-green: 101;--sl-color-green-low: hsl(var(--sl-hue-green), 39%, 22%);--sl-color-green: hsl(var(--sl-hue-green), 82%, 63%);--sl-color-green-high: hsl(var(--sl-hue-green), 82%, 80%);--sl-hue-blue: 234;--sl-color-blue-low: hsl(var(--sl-hue-blue), 54%, 20%);--sl-color-blue: hsl(var(--sl-hue-blue), 100%, 60%);--sl-color-blue-high: hsl(var(--sl-hue-blue), 100%, 87%);--sl-hue-purple: 281;--sl-color-purple-low: hsl(var(--sl-hue-purple), 39%, 22%);--sl-color-purple: hsl(var(--sl-hue-purple), 82%, 63%);--sl-color-purple-high: hsl(var(--sl-hue-purple), 82%, 89%);--sl-hue-red: 339;--sl-color-red-low: hsl(var(--sl-hue-red), 39%, 22%);--sl-color-red: hsl(var(--sl-hue-red), 82%, 63%);--sl-color-red-high: hsl(var(--sl-hue-red), 82%, 87%);--sl-color-accent-low: hsl(224, 54%, 20%);--sl-color-accent: hsl(224, 100%, 60%);--sl-color-accent-high: hsl(224, 100%, 85%);--sl-color-text: var(--sl-color-gray-2);--sl-color-text-accent: var(--sl-color-accent-high);--sl-color-text-invert: var(--sl-color-accent-low);--sl-color-bg: var(--sl-color-black);--sl-color-bg-nav: var(--sl-color-gray-6);--sl-color-bg-sidebar: var(--sl-color-gray-6);--sl-color-bg-inline-code: var(--sl-color-gray-5);--sl-color-hairline-light: var(--sl-color-gray-5);--sl-color-hairline: var(--sl-color-gray-6);--sl-color-hairline-shade: var(--sl-color-black);--sl-color-backdrop-overlay: hsla(223, 13%, 10%, 0.66);--sl-shadow-sm: 0px 1px 1px hsla(0, 0%, 0%, 0.12), 0px 2px 1px hsla(0, 0%, 0%, 0.24);--sl-shadow-md: 0px 8px 4px hsla(0, 0%, 0%, 0.08), 0px 5px 2px hsla(0, 0%, 0%, 0.08), 0px 3px 2px hsla(0, 0%, 0%, 0.12), 0px 1px 1px hsla(0, 0%, 0%, 0.15);--sl-shadow-lg: 0px 25px 7px hsla(0, 0%, 0%, 0.03), 0px 16px 6px hsla(0, 0%, 0%, 0.1), 0px 9px 5px hsla(223, 13%, 10%, 0.33), 0px 4px 4px hsla(0, 0%, 0%, 0.75), 0px 4px 2px hsla(0, 0%, 0%, 0.25);--sl-text-xs: 0.8125rem;--sl-text-sm: 0.875rem;--sl-text-base: 1rem;--sl-text-lg: 1.125rem;--sl-text-xl: 1.25rem;--sl-text-2xl: 1.5rem;--sl-text-3xl: 1.8125rem;--sl-text-4xl: 2.1875rem;--sl-text-5xl: 2.625rem;--sl-text-6xl: 4rem;--sl-text-body: var(--sl-text-base);--sl-text-body-sm: var(--sl-text-xs);--sl-text-code: var(--sl-text-sm);--sl-text-code-sm: var(--sl-text-xs);--sl-text-h1: var(--sl-text-4xl);--sl-text-h2: var(--sl-text-3xl);--sl-text-h3: var(--sl-text-2xl);--sl-text-h4: var(--sl-text-xl);--sl-text-h5: var(--sl-text-lg);--sl-line-height: 1.8;--sl-line-height-headings: 1.2;--sl-font-system: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--sl-font-system-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--__sl-font: var(--sl-font, ""), var(--sl-font-system);--__sl-font-mono: var(--sl-font-mono, ""), var(--sl-font-system-mono);--sl-nav-height: 3.5rem;--sl-nav-pad-x: 1rem;--sl-nav-pad-y: 0.75rem;--sl-mobile-toc-height: 3rem;--sl-sidebar-width: 18.75rem;--sl-sidebar-pad-x: 1rem;--sl-content-width: 45rem;--sl-content-pad-x: 1rem;--sl-menu-button-size: 2rem;--sl-nav-gap: var(--sl-content-pad-x);--sl-outline-offset-inside: -0.1875rem;--sl-z-index-toc: 4;--sl-z-index-menu: 5;--sl-z-index-navbar: 10;--sl-z-index-skiplink: 20}:root{--purple-hsl: 255, 60%, 60%;--overlay-blurple: hsla(var(--purple-hsl), 0.2)}:root{--ec-brdRad: 0px;--ec-brdWd: 1px;--ec-brdCol: color-mix(in srgb, var(--sl-color-gray-5), transparent 25%);--ec-codeFontFml: var(--__sl-font-mono);--ec-codeFontSize: var(--sl-text-code);--ec-codeFontWg: 400;--ec-codeLineHt: var(--sl-line-height);--ec-codePadBlk: 0.75rem;--ec-codePadInl: 1rem;--ec-codeBg: #011627;--ec-codeFg: #d6deeb;--ec-codeSelBg: #1d3b53;--ec-uiFontFml: var(--__sl-font);--ec-uiFontSize: 0.9rem;--ec-uiFontWg: 400;--ec-uiLineHt: 1.65;--ec-uiPadBlk: 0.25rem;--ec-uiPadInl: 1rem;--ec-uiSelBg: #234d708c;--ec-uiSelFg: #ffffff;--ec-focusBrd: #122d42;--ec-sbThumbCol: #ffffff17;--ec-sbThumbHoverCol: #ffffff49;--ec-tm-lineMarkerAccentMarg: 0rem;--ec-tm-lineMarkerAccentWd: 0.15rem;--ec-tm-lineDiffIndMargLeft: 0.25rem;--ec-tm-inlMarkerBrdWd: 1.5px;--ec-tm-inlMarkerBrdRad: 0.2rem;--ec-tm-inlMarkerPad: 0.15rem;--ec-tm-insDiffIndContent: "+";--ec-tm-delDiffIndContent: "-";--ec-tm-markBg: #ffffff17;--ec-tm-markBrdCol: #ffffff40;--ec-tm-insBg: #1e571599;--ec-tm-insBrdCol: #487f3bd0;--ec-tm-insDiffIndCol: #79b169d0;--ec-tm-delBg: #862d2799;--ec-tm-delBrdCol: #b4554bd0;--ec-tm-delDiffIndCol: #ed8779d0;--ec-frm-shdCol: #011627;--ec-frm-frameBoxShdCssVal: none;--ec-frm-edActTabBg: var(--sl-color-gray-6);--ec-frm-edActTabFg: var(--sl-color-text);--ec-frm-edActTabBrdCol: transparent;--ec-frm-edActTabIndHt: 1px;--ec-frm-edActTabIndTopCol: var(--sl-color-accent-high);--ec-frm-edActTabIndBtmCol: transparent;--ec-frm-edTabsMargInlStart: 0;--ec-frm-edTabsMargBlkStart: 0;--ec-frm-edTabBrdRad: 0px;--ec-frm-edTabBarBg: var(--sl-color-black);--ec-frm-edTabBarBrdCol: color-mix(in srgb, var(--sl-color-gray-5), transparent 25%);--ec-frm-edTabBarBrdBtmCol: color-mix(in srgb, var(--sl-color-gray-5), transparent 25%);--ec-frm-edBg: var(--sl-color-gray-6);--ec-frm-trmTtbDotsFg: color-mix(in srgb, var(--sl-color-gray-5), transparent 25%);--ec-frm-trmTtbDotsOpa: 0.75;--ec-frm-trmTtbBg: var(--sl-color-black);--ec-frm-trmTtbFg: var(--sl-color-text);--ec-frm-trmTtbBrdBtmCol: color-mix(in srgb, var(--sl-color-gray-5), transparent 25%);--ec-frm-trmBg: var(--sl-color-gray-6);--ec-frm-inlBtnFg: var(--sl-color-text);--ec-frm-inlBtnBg: var(--sl-color-text);--ec-frm-inlBtnBgIdleOpa: 0;--ec-frm-inlBtnBgHoverOrFocusOpa: 0.2;--ec-frm-inlBtnBgActOpa: 0.3;--ec-frm-inlBtnBrd: var(--sl-color-text);--ec-frm-inlBtnBrdOpa: 0.4;--ec-frm-tooltipSuccessBg: #158744;--ec-frm-tooltipSuccessFg: white}:root,[data-bs-theme="light"]{--bs-blue: #3347ff;--bs-indigo: #6610f2;--bs-purple: #bd53ee;--bs-pink: #d63384;--bs-red: #ee5389;--bs-orange: #fd7e14;--bs-yellow: #eebd53;--bs-green: #84ee53;--bs-teal: #20c997;--bs-cyan: #0dcaf0;--bs-black: #000;--bs-white: #fff;--bs-gray: #6c757d;--bs-gray-dark: #343a40;--bs-gray-100: #f8f9fa;--bs-gray-200: #e9ecef;--bs-gray-300: #dee2e6;--bs-gray-400: #ced4da;--bs-gray-500: #adb5bd;--bs-gray-600: #6c757d;--bs-gray-700: #495057;--bs-gray-800: #343a40;--bs-gray-900: #212529;--bs-primary: #4f46e5;--bs-secondary: #6c757d;--bs-success: #84ee53;--bs-info: #3347ff;--bs-warning: #eebd53;--bs-danger: #ee5389;--bs-light: #f8f9fa;--bs-dark: #212529;--bs-primary-rgb: 79,70,229;--bs-secondary-rgb: 108,117,125;--bs-success-rgb: 132.2821,238.017,83.283;--bs-info-rgb: 51,71.4,255;--bs-warning-rgb: 238.017,189.0179,83.283;--bs-danger-rgb: 238.017,83.283,137.4399;--bs-light-rgb: 248,249,250;--bs-dark-rgb: 33,37,41;--bs-primary-text-emphasis: #201c5c;--bs-secondary-text-emphasis: #2b2f32;--bs-success-text-emphasis: #355f21;--bs-info-text-emphasis: #141d66;--bs-warning-text-emphasis: #5f4c21;--bs-danger-text-emphasis: #5f2137;--bs-light-text-emphasis: #495057;--bs-dark-text-emphasis: #495057;--bs-primary-bg-subtle: #dcdafa;--bs-secondary-bg-subtle: #e2e3e5;--bs-success-bg-subtle: #e6fcdd;--bs-info-bg-subtle: #d6daff;--bs-warning-bg-subtle: #fcf2dd;--bs-danger-bg-subtle: #fcdde7;--bs-light-bg-subtle: #fcfcfd;--bs-dark-bg-subtle: #ced4da;--bs-primary-border-subtle: #b9b5f5;--bs-secondary-border-subtle: #c4c8cb;--bs-success-border-subtle: #cef8ba;--bs-info-border-subtle: #adb6ff;--bs-warning-border-subtle: #f8e5ba;--bs-danger-border-subtle: #f8bad0;--bs-light-border-subtle: #e9ecef;--bs-dark-border-subtle: #adb5bd;--bs-white-rgb: 255,255,255;--bs-black-rgb: 0,0,0;--bs-font-sans-serif: "Heebo", "sans-serif", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--bs-gradient: linear-gradient(180deg, rgba(255,255,255,0.15), rgba(255,255,255,0));--bs-body-font-family: var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight: 400;--bs-body-line-height: 1.5;--bs-body-color: #1d2d35;--bs-body-color-rgb: 29,45,53;--bs-body-bg: #fff;--bs-body-bg-rgb: 255,255,255;--bs-emphasis-color: #000;--bs-emphasis-color-rgb: 0,0,0;--bs-secondary-color: rgba(29,45,53,0.75);--bs-secondary-color-rgb: 29,45,53;--bs-secondary-bg: #e9ecef;--bs-secondary-bg-rgb: 233,236,239;--bs-tertiary-color: rgba(29,45,53,0.5);--bs-tertiary-color-rgb: 29,45,53;--bs-tertiary-bg: #f8f9fa;--bs-tertiary-bg-rgb: 248,249,250;--bs-heading-color: inherit;--bs-link-color: #4f46e5;--bs-link-color-rgb: 79,70,229;--bs-link-decoration: none;--bs-link-hover-color: #3f38b7;--bs-link-hover-color-rgb: 63,56,183;--bs-link-hover-decoration: underline;--bs-code-color: #d63384;--bs-highlight-color: #1d2d35;--bs-highlight-bg: #fcf2dd;--bs-border-width: 1px;--bs-border-style: solid;--bs-border-color: #dee2e6;--bs-border-color-translucent: rgba(0,0,0,0.175);--bs-border-radius: .375rem;--bs-border-radius-sm: .25rem;--bs-border-radius-lg: .5rem;--bs-border-radius-xl: 1rem;--bs-border-radius-xxl: 2rem;--bs-border-radius-2xl: var(--bs-border-radius-xxl);--bs-border-radius-pill: 50rem;--bs-box-shadow: 0 0.5rem 1rem rgba(0,0,0,0.15);--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0,0,0,0.075);--bs-box-shadow-lg: 0 1rem 3rem rgba(0,0,0,0.175);--bs-box-shadow-inset: inset 0 1px 2px rgba(0,0,0,0.075);--bs-focus-ring-width: .25rem;--bs-focus-ring-opacity: .25;--bs-focus-ring-color: rgba(79,70,229,0.25);--bs-form-valid-color: #84ee53;--bs-form-valid-border-color: #84ee53;--bs-form-invalid-color: #ee5389;--bs-form-invalid-border-color: #ee5389}[data-bs-theme="dark"]{color-scheme:dark;--bs-body-color: #c1c3c8;--bs-body-color-rgb: 192.831,194.7078,199.869;--bs-body-bg: #17181c;--bs-body-bg-rgb: 22.95,24.31,28.05;--bs-emphasis-color: #fff;--bs-emphasis-color-rgb: 255,255,255;--bs-secondary-color: rgba(193,195,200,0.75);--bs-secondary-color-rgb: 192.831,194.7078,199.869;--bs-secondary-bg: #343a40;--bs-secondary-bg-rgb: 52,58,64;--bs-tertiary-color: rgba(193,195,200,0.5);--bs-tertiary-color-rgb: 192.831,194.7078,199.869;--bs-tertiary-bg: #2b3035;--bs-tertiary-bg-rgb: 43,48,53;--bs-primary-text-emphasis: #9590ef;--bs-secondary-text-emphasis: #a7acb1;--bs-success-text-emphasis: #b5f598;--bs-info-text-emphasis: #8591ff;--bs-warning-text-emphasis: #f5d798;--bs-danger-text-emphasis: #f598b8;--bs-light-text-emphasis: #f8f9fa;--bs-dark-text-emphasis: #dee2e6;--bs-primary-bg-subtle: #100e2e;--bs-secondary-bg-subtle: #161719;--bs-success-bg-subtle: #1a3011;--bs-info-bg-subtle: #0a0e33;--bs-warning-bg-subtle: #302611;--bs-danger-bg-subtle: #30111b;--bs-light-bg-subtle: #23262f;--bs-dark-bg-subtle: #1a1d20;--bs-primary-border-subtle: #2f2a89;--bs-secondary-border-subtle: #41464b;--bs-success-border-subtle: #4f8f32;--bs-info-border-subtle: #1f2b99;--bs-warning-border-subtle: #8f7132;--bs-danger-border-subtle: #8f3252;--bs-light-border-subtle: #353841;--bs-dark-border-subtle: #343a40;--bs-heading-color: #fff;--bs-link-color: #b3c7ff;--bs-link-hover-color: #c2d2ff;--bs-link-color-rgb: 178.5,198.9,255;--bs-link-hover-color-rgb: 194,210,255;--bs-code-color: #e685b5;--bs-highlight-color: #c1c3c8;--bs-highlight-bg: #5f4c21;--bs-border-color: #495057;--bs-border-color-translucent: rgba(255,255,255,0.15);--bs-form-valid-color: #b5f598;--bs-form-valid-border-color: #b5f598;--bs-form-invalid-color: #f598b8;--bs-form-invalid-border-color: #f598b8}*,*::before,*::after{box-sizing:border-box}@media (prefers-reduced-motion: no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:rgba(0,0,0,0)}h5,.h5,h4,.h4,h3,.h3,h2,.h2,h1,.h1{margin-top:0;margin-bottom:.5rem;font-weight:700;line-height:1.2;color:var(--bs-heading-color)}h1,.h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width: 1200px){h1,.h1{font-size:2.5rem}}h2,.h2{font-size:calc(1.325rem + .9vw)}@media (min-width: 1200px){h2,.h2{font-size:2rem}}h3,.h3{font-size:calc(1.3rem + .6vw)}@media (min-width: 1200px){h3,.h3{font-size:1.75rem}}h4,.h4{font-size:calc(1.275rem + .3vw)}@media (min-width: 1200px){h4,.h4{font-size:1.5rem}}h5,.h5{font-size:1.25rem}p{margin-top:0;margin-bottom:1rem}ol,ul{padding-left:2rem}ol,ul,dl{margin-top:0;margin-bottom:1rem}ol ol,ul ul,ol ul,ul ol{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}strong{font-weight:bolder}small,.small{font-size:.875em}mark,.mark{padding:.1875em;color:var(--bs-highlight-color);background-color:var(--bs-highlight-bg)}a{color:rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));text-decoration:none}a:hover{--bs-link-color-rgb: var(--bs-link-hover-color-rgb);text-decoration:underline}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}pre,code,kbd,samp{font-family:var(--bs-font-monospace);font-size:1em}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:var(--bs-code-color);word-wrap:break-word}a>code{color:inherit}kbd{padding:.1875rem .375rem;font-size:.875em;color:var(--bs-body-bg);background-color:var(--bs-body-color);border-radius:.25rem}kbd kbd{padding:0;font-size:1em}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}th{text-align:inherit;text-align:-webkit-match-parent}thead,tbody,tr,td,th{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}input,button{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button{text-transform:none}[list]:not([type="date"]):not([type="datetime-local"]):not([type="month"]):not([type="week"]):not([type="time"])::-webkit-calendar-picker-indicator{display:none !important}button,[type="button"],[type="reset"],[type="submit"]{-webkit-appearance:button}button:not(:disabled),[type="button"]:not(:disabled),[type="reset"]:not(:disabled),[type="submit"]:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-text,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type="search"]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::file-selector-button{font:inherit;-webkit-appearance:button}iframe{border:0}summary{display:list-item;cursor:pointer}[hidden]{display:none !important}.lead{font-size:1.25rem;font-weight:400}.display-4{font-size:calc(1.475rem + 2.7vw);font-weight:300;line-height:1.2}@media (min-width: 1200px){.display-4{font-size:3.5rem}}.display-5{font-size:calc(1.425rem + 2.1vw);font-weight:300;line-height:1.2}@media (min-width: 1200px){.display-5{font-size:3rem}}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.blockquote{margin-bottom:1rem;font-size:1.25rem}.blockquote>:last-child{margin-bottom:0}.img-fluid{max-width:100%;height:auto}.figure{display:inline-block}.container,.container-fluid,.container-lg{--bs-gutter-x: 3rem;--bs-gutter-y: 0;width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-right:auto;margin-left:auto}@media (min-width: 576px){.container{max-width:540px}}@media (min-width: 768px){.container{max-width:720px}}@media (min-width: 992px){.container-lg,.container{max-width:960px}}@media (min-width: 1200px){.container-lg,.container{max-width:1240px}}@media (min-width: 1400px){.container-lg,.container{max-width:1820px}}:root{--bs-breakpoint-xs: 0;--bs-breakpoint-sm: 576px;--bs-breakpoint-md: 768px;--bs-breakpoint-lg: 992px;--bs-breakpoint-xl: 1200px;--bs-breakpoint-xxl: 1400px}.row{--bs-gutter-x: 3rem;--bs-gutter-y: 0;display:flex;flex-wrap:wrap;margin-top:calc(-1 * var(--bs-gutter-y));margin-right:calc(-.5 * var(--bs-gutter-x));margin-left:calc(-.5 * var(--bs-gutter-x))}.row>*{flex-shrink:0;width:100%;max-width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-top:var(--bs-gutter-y)}@media (min-width: 768px){.col-md-12{flex:0 0 auto;width:75%}}@media (min-width: 992px){.col-lg-5{flex:0 0 auto;width:31.25%}.col-lg-8{flex:0 0 auto;width:50%}.col-lg-9{flex:0 0 auto;width:56.25%}.col-lg-10{flex:0 0 auto;width:62.5%}.col-lg-11{flex:0 0 auto;width:68.75%}.col-lg-12{flex:0 0 auto;width:75%}}@media (min-width: 1200px){.col-xl-3{flex:0 0 auto;width:18.75%}.col-xl-4{flex:0 0 auto;width:25%}.col-xl-8{flex:0 0 auto;width:50%}.col-xl-9{flex:0 0 auto;width:56.25%}.col-xl-10{flex:0 0 auto;width:62.5%}}.sticky-top{position:sticky;top:0;z-index:1020}.visually-hidden{width:1px !important;height:1px !important;padding:0 !important;margin:-1px !important;overflow:hidden !important;clip:rect(0, 0, 0, 0) !important;white-space:nowrap !important;border:0 !important}.visually-hidden:not(caption){position:absolute !important}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;content:""}.table,table{--bs-table-color-type: initial;--bs-table-bg-type: initial;--bs-table-color-state: initial;--bs-table-bg-state: initial;--bs-table-color: var(--bs-emphasis-color);--bs-table-bg: var(--bs-body-bg);--bs-table-border-color: var(--bs-border-color);--bs-table-accent-bg: rgba(0,0,0,0);--bs-table-striped-color: var(--bs-emphasis-color);--bs-table-striped-bg: rgba(var(--bs-emphasis-color-rgb), 0.05);--bs-table-active-color: var(--bs-emphasis-color);--bs-table-active-bg: rgba(var(--bs-emphasis-color-rgb), 0.1);--bs-table-hover-color: var(--bs-emphasis-color);--bs-table-hover-bg: rgba(var(--bs-emphasis-color-rgb), 0.075);width:100%;margin-bottom:1rem;vertical-align:top;border-color:var(--bs-table-border-color)}.table>:not(caption)>*>*,table>:not(caption)>*>*{padding:.5rem .5rem;color:var(--bs-table-color-state, var(--bs-table-color-type, var(--bs-table-color)));background-color:var(--bs-table-bg);border-bottom-width:var(--bs-border-width);box-shadow:inset 0 0 0 9999px var(--bs-table-bg-state, var(--bs-table-bg-type, var(--bs-table-accent-bg)))}.table>tbody,table>tbody{vertical-align:inherit}.table>thead,table>thead{vertical-align:bottom}[data-bs-theme="dark"] table{--bs-table-color: #fff;--bs-table-bg: #212529;--bs-table-border-color: #4d5154;--bs-table-striped-bg: #2c3034;--bs-table-striped-color: #fff;--bs-table-active-bg: #373b3e;--bs-table-active-color: #fff;--bs-table-hover-bg: #323539;--bs-table-hover-color: #fff;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.form-control{display:block;width:100%;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--bs-body-bg);background-clip:padding-box;border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);transition:border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out}@media (prefers-reduced-motion: reduce){.form-control{transition:none}}.form-control[type="file"]{overflow:hidden}.form-control[type="file"]:not(:disabled):not([readonly]){cursor:pointer}.form-control:focus{color:var(--bs-body-color);background-color:var(--bs-body-bg);border-color:#a7a3f2;outline:0;box-shadow:none}.form-control::-webkit-date-and-time-value{min-width:85px;height:1.5em;margin:0}.form-control::-webkit-datetime-edit{display:block;padding:0}.form-control::-moz-placeholder{color:var(--bs-secondary-color);opacity:1}.form-control::placeholder{color:var(--bs-secondary-color);opacity:1}.form-control:disabled{background-color:var(--bs-secondary-bg);opacity:1}.form-control::file-selector-button{padding:.375rem .75rem;margin:-.375rem -.75rem;margin-inline-end:.75rem;color:var(--bs-body-color);background-color:var(--bs-tertiary-bg);pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:var(--bs-border-width);border-radius:0;transition:color 0.15s ease-in-out,background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out}@media (prefers-reduced-motion: reduce){.form-control::file-selector-button{transition:none}}.form-control:hover:not(:disabled):not([readonly])::file-selector-button{background-color:var(--bs-secondary-bg)}.form-control-lg{min-height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2));padding:.5rem 1rem;font-size:1.25rem;border-radius:var(--bs-border-radius-lg)}.form-control-lg::file-selector-button{padding:.5rem 1rem;margin:-.5rem -1rem;margin-inline-end:1rem}li input[type="checkbox"]{--bs-form-check-bg: var(--bs-body-bg);flex-shrink:0;width:1em;height:1em;margin-top:.25em;vertical-align:top;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--bs-form-check-bg);background-image:var(--bs-form-check-bg-image);background-repeat:no-repeat;background-position:center;background-size:contain;border:var(--bs-border-width) solid var(--bs-border-color);-webkit-print-color-adjust:exact;print-color-adjust:exact}li input[type="checkbox"]{border-radius:.25em}li input[type="radio"][type="checkbox"]{border-radius:50%}li input[type="checkbox"]:active{filter:brightness(90%)}li input[type="checkbox"]:focus{border-color:#a7a3f2;outline:0;box-shadow:0 0 0 .25rem rgba(79,70,229,0.25)}li input[type="checkbox"]:checked{background-color:#4f46e5;border-color:#4f46e5}li input:checked[type="checkbox"]{--bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/%3e%3c/svg%3e")}li input[type="checkbox"]:checked[type="radio"]{--bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e")}li input[type="checkbox"]:indeterminate{background-color:#4f46e5;border-color:#4f46e5;--bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e")}li input[type="checkbox"]:disabled{pointer-events:none;filter:none;opacity:.5}.btn{--bs-btn-padding-x: .75rem;--bs-btn-padding-y: .375rem;--bs-btn-font-family: ;--bs-btn-font-size:1rem;--bs-btn-font-weight: 400;--bs-btn-line-height: 1.5;--bs-btn-color: var(--bs-body-color);--bs-btn-bg: transparent;--bs-btn-border-width: var(--bs-border-width);--bs-btn-border-color: transparent;--bs-btn-border-radius: var(--bs-border-radius);--bs-btn-hover-border-color: transparent;--bs-btn-box-shadow: inset 0 1px 0 rgba(255,255,255,0.15),0 1px 1px rgba(0,0,0,0.075);--bs-btn-disabled-opacity: .65;--bs-btn-focus-box-shadow: 0 0 0 0 rgba(var(--bs-btn-focus-shadow-rgb), .5);display:inline-block;padding:var(--bs-btn-padding-y) var(--bs-btn-padding-x);font-family:var(--bs-btn-font-family);font-size:var(--bs-btn-font-size);font-weight:var(--bs-btn-font-weight);line-height:var(--bs-btn-line-height);color:var(--bs-btn-color);text-align:center;vertical-align:middle;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;border:var(--bs-btn-border-width) solid var(--bs-btn-border-color);border-radius:var(--bs-btn-border-radius);background-color:var(--bs-btn-bg);transition:color 0.15s ease-in-out,background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out}@media (prefers-reduced-motion: reduce){.btn{transition:none}}.btn:hover{color:var(--bs-btn-hover-color);text-decoration:none;background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color)}.btn:focus-visible{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}:not(.btn-check)+.btn:active,.btn:first-child:active,.btn.active,.btn.show{color:var(--bs-btn-active-color);background-color:var(--bs-btn-active-bg);border-color:var(--bs-btn-active-border-color)}:not(.btn-check)+.btn:active:focus-visible,.btn:first-child:active:focus-visible,.btn.active:focus-visible,.btn.show:focus-visible{box-shadow:var(--bs-btn-focus-box-shadow)}.btn:disabled,.btn.disabled{color:var(--bs-btn-disabled-color);pointer-events:none;background-color:var(--bs-btn-disabled-bg);border-color:var(--bs-btn-disabled-border-color);opacity:var(--bs-btn-disabled-opacity)}.btn-primary{--bs-btn-color: #fff;--bs-btn-bg: #4f46e5;--bs-btn-border-color: #4f46e5;--bs-btn-hover-color: #fff;--bs-btn-hover-bg: #433cc3;--bs-btn-hover-border-color: #3f38b7;--bs-btn-focus-shadow-rgb: 105,98,233;--bs-btn-active-color: #fff;--bs-btn-active-bg: #3f38b7;--bs-btn-active-border-color: #3b35ac;--bs-btn-active-shadow: inset 0 3px 5px rgba(0,0,0,0.125);--bs-btn-disabled-color: #fff;--bs-btn-disabled-bg: #4f46e5;--bs-btn-disabled-border-color: #4f46e5}.btn-outline-primary{--bs-btn-color: #4f46e5;--bs-btn-border-color: #4f46e5;--bs-btn-hover-color: #fff;--bs-btn-hover-bg: #4f46e5;--bs-btn-hover-border-color: #4f46e5;--bs-btn-focus-shadow-rgb: 79,70,229;--bs-btn-active-color: #fff;--bs-btn-active-bg: #4f46e5;--bs-btn-active-border-color: #4f46e5;--bs-btn-active-shadow: inset 0 3px 5px rgba(0,0,0,0.125);--bs-btn-disabled-color: #4f46e5;--bs-btn-disabled-bg: transparent;--bs-btn-disabled-border-color: #4f46e5;--bs-gradient: none}.btn-link{--bs-btn-font-weight: 400;--bs-btn-color: var(--bs-link-color);--bs-btn-bg: transparent;--bs-btn-border-color: transparent;--bs-btn-hover-color: var(--bs-link-hover-color);--bs-btn-hover-border-color: transparent;--bs-btn-active-color: var(--bs-link-hover-color);--bs-btn-active-border-color: transparent;--bs-btn-disabled-color: #6c757d;--bs-btn-disabled-border-color: transparent;--bs-btn-box-shadow: 0 0 0 #000;--bs-btn-focus-shadow-rgb: 105,98,233;text-decoration:none}.btn-link:hover,.btn-link:focus-visible{text-decoration:underline}.btn-link:focus-visible{color:var(--bs-btn-color)}.btn-link:hover{color:var(--bs-btn-hover-color)}.btn-lg{--bs-btn-padding-y: .5rem;--bs-btn-padding-x: 1rem;--bs-btn-font-size:1.25rem;--bs-btn-border-radius: var(--bs-border-radius-lg)}.fade{transition:opacity 0.15s linear}@media (prefers-reduced-motion: reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.nav{--bs-nav-link-padding-x: 1rem;--bs-nav-link-padding-y: .5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color: var(--bs-link-color);--bs-nav-link-hover-color: var(--bs-link-hover-color);--bs-nav-link-disabled-color: var(--bs-secondary-color);display:flex;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:var(--bs-nav-link-padding-y) var(--bs-nav-link-padding-x);font-size:var(--bs-nav-link-font-size);font-weight:var(--bs-nav-link-font-weight);color:var(--bs-nav-link-color);background:none;border:0;transition:color 0.15s ease-in-out,background-color 0.15s ease-in-out,border-color 0.15s ease-in-out}@media (prefers-reduced-motion: reduce){.nav-link{transition:none}}.nav-link:hover,.nav-link:focus{color:var(--bs-nav-link-hover-color);text-decoration:none}.nav-link:focus-visible{outline:0;box-shadow:0 0 0 .25rem rgba(79,70,229,0.25)}.nav-link.disabled,.nav-link:disabled{color:var(--bs-nav-link-disabled-color);pointer-events:none;cursor:default}.nav-tabs{--bs-nav-tabs-border-width: var(--bs-border-width);--bs-nav-tabs-border-color: var(--bs-border-color);--bs-nav-tabs-border-radius: var(--bs-border-radius);--bs-nav-tabs-link-hover-border-color: var(--bs-secondary-bg) var(--bs-secondary-bg) var(--bs-border-color);--bs-nav-tabs-link-active-color: var(--bs-emphasis-color);--bs-nav-tabs-link-active-bg: var(--bs-body-bg);--bs-nav-tabs-link-active-border-color: var(--bs-border-color) var(--bs-border-color) var(--bs-body-bg);border-bottom:var(--bs-nav-tabs-border-width) solid var(--bs-nav-tabs-border-color)}.nav-tabs .nav-link{margin-bottom:calc(-1 * var(--bs-nav-tabs-border-width));border:var(--bs-nav-tabs-border-width) solid transparent;border-top-left-radius:var(--bs-nav-tabs-border-radius);border-top-right-radius:var(--bs-nav-tabs-border-radius)}.nav-tabs .nav-link:hover,.nav-tabs .nav-link:focus{isolation:isolate;border-color:var(--bs-nav-tabs-link-hover-border-color)}.nav-tabs .nav-link.active,.nav-tabs .nav-item.show .nav-link{color:var(--bs-nav-tabs-link-active-color);background-color:var(--bs-nav-tabs-link-active-bg);border-color:var(--bs-nav-tabs-link-active-border-color)}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{--bs-navbar-padding-x: 0;--bs-navbar-padding-y: .5rem;--bs-navbar-color: rgba(var(--bs-emphasis-color-rgb), 0.65);--bs-navbar-hover-color: rgba(var(--bs-emphasis-color-rgb), 0.8);--bs-navbar-disabled-color: rgba(var(--bs-emphasis-color-rgb), 0.3);--bs-navbar-active-color: rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-brand-padding-y: .3125rem;--bs-navbar-brand-margin-end: 1rem;--bs-navbar-brand-font-size: 1.25rem;--bs-navbar-brand-color: rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-brand-hover-color: rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-nav-link-padding-x: .5rem;--bs-navbar-toggler-padding-y: .25rem;--bs-navbar-toggler-padding-x: .75rem;--bs-navbar-toggler-font-size: 1.25rem;--bs-navbar-toggler-icon-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%2829,45,53,0.75%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e");--bs-navbar-toggler-border-color: rgba(var(--bs-emphasis-color-rgb), 0.15);--bs-navbar-toggler-border-radius: var(--bs-border-radius);--bs-navbar-toggler-focus-width: 0;--bs-navbar-toggler-transition: box-shadow 0.15s ease-in-out;position:relative;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;padding:var(--bs-navbar-padding-y) var(--bs-navbar-padding-x)}.navbar>.container,.navbar>.container-fluid,.navbar>.container-lg{display:flex;flex-wrap:inherit;align-items:center;justify-content:space-between}.navbar-brand{padding-top:var(--bs-navbar-brand-padding-y);padding-bottom:var(--bs-navbar-brand-padding-y);margin-right:var(--bs-navbar-brand-margin-end);font-size:var(--bs-navbar-brand-font-size);color:var(--bs-navbar-brand-color);white-space:nowrap}.navbar-brand:hover,.navbar-brand:focus{color:var(--bs-navbar-brand-hover-color);text-decoration:none}.navbar-nav{--bs-nav-link-padding-x: 0;--bs-nav-link-padding-y: .5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color: var(--bs-navbar-color);--bs-nav-link-hover-color: var(--bs-navbar-hover-color);--bs-nav-link-disabled-color: var(--bs-navbar-disabled-color);display:flex;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link.active,.navbar-nav .nav-link.show{color:var(--bs-navbar-active-color)}@media (min-width: 992px){.navbar-expand-lg{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-lg .navbar-nav{flex-direction:row}.navbar-expand-lg .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-lg .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto !important;height:auto !important;visibility:visible !important;background-color:transparent !important;border:0 !important;transform:none !important;transition:none}.navbar-expand-lg .offcanvas .offcanvas-header{display:none}.navbar-expand-lg .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}.navbar[data-bs-theme="dark"]{--bs-navbar-color: #c1c3c8;--bs-navbar-hover-color: #b3c7ff;--bs-navbar-disabled-color: rgba(255,255,255,0.25);--bs-navbar-active-color: #b3c7ff;--bs-navbar-brand-color: #b3c7ff;--bs-navbar-brand-hover-color: #b3c7ff;--bs-navbar-toggler-border-color: rgba(255,255,255,0.1);--bs-navbar-toggler-icon-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='%23c1c3c8' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.card{--bs-card-spacer-y: 1rem;--bs-card-spacer-x: 1rem;--bs-card-title-spacer-y: .5rem;--bs-card-title-color: ;--bs-card-subtitle-color: ;--bs-card-border-width: var(--bs-border-width);--bs-card-border-color: #e9ecef;--bs-card-border-radius: var(--bs-border-radius);--bs-card-box-shadow: ;--bs-card-inner-border-radius: calc(var(--bs-border-radius) - (var(--bs-border-width)));--bs-card-cap-padding-y: .5rem;--bs-card-cap-padding-x: 1rem;--bs-card-cap-bg: rgba(var(--bs-body-color-rgb), 0.03);--bs-card-cap-color: ;--bs-card-height: ;--bs-card-color: ;--bs-card-bg: var(--bs-body-bg);--bs-card-img-overlay-padding: 1rem;--bs-card-group-margin: 1.5rem;position:relative;display:flex;flex-direction:column;min-width:0;height:var(--bs-card-height);color:var(--bs-body-color);word-wrap:break-word;background-color:var(--bs-card-bg);background-clip:border-box;border:var(--bs-card-border-width) solid var(--bs-card-border-color);border-radius:var(--bs-card-border-radius)}.card-body{flex:1 1 auto;padding:var(--bs-card-spacer-y) var(--bs-card-spacer-x);color:var(--bs-card-color)}.page-link{position:relative;display:block;padding:var(--bs-pagination-padding-y) var(--bs-pagination-padding-x);font-size:var(--bs-pagination-font-size);color:var(--bs-pagination-color);background-color:var(--bs-pagination-bg);border:var(--bs-pagination-border-width) solid var(--bs-pagination-border-color);transition:color 0.15s ease-in-out,background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out}@media (prefers-reduced-motion: reduce){.page-link{transition:none}}.page-link:hover{z-index:2;color:var(--bs-pagination-hover-color);text-decoration:none;background-color:var(--bs-pagination-hover-bg);border-color:var(--bs-pagination-hover-border-color)}.page-link:focus{z-index:3;color:var(--bs-pagination-focus-color);background-color:var(--bs-pagination-focus-bg);outline:0;box-shadow:var(--bs-pagination-focus-box-shadow)}.page-link.active,.active>.page-link{z-index:3;color:var(--bs-pagination-active-color);background-color:var(--bs-pagination-active-bg);border-color:var(--bs-pagination-active-border-color)}.page-link.disabled,.disabled>.page-link{color:var(--bs-pagination-disabled-color);pointer-events:none;background-color:var(--bs-pagination-disabled-bg);border-color:var(--bs-pagination-disabled-border-color)}.page-item:not(:first-child) .page-link{margin-left:calc(var(--bs-border-width) * -1)}.page-item:first-child .page-link{border-top-left-radius:var(--bs-pagination-border-radius);border-bottom-left-radius:var(--bs-pagination-border-radius)}.page-item:last-child .page-link{border-top-right-radius:var(--bs-pagination-border-radius);border-bottom-right-radius:var(--bs-pagination-border-radius)}.alert-link{font-weight:700;color:var(--bs-alert-link-color)}@keyframes progress-bar-stripes{0%{background-position-x:1rem}}.btn-close{--bs-btn-close-color: #000;--bs-btn-close-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414z'/%3e%3c/svg%3e");--bs-btn-close-opacity: .5;--bs-btn-close-hover-opacity: .75;--bs-btn-close-focus-shadow: 0 0 0 .25rem rgba(79,70,229,0.25);--bs-btn-close-focus-opacity: 1;--bs-btn-close-disabled-opacity: .25;--bs-btn-close-white-filter: invert(1) grayscale(100%) brightness(200%);box-sizing:content-box;width:1em;height:1em;padding:.25em .25em;color:var(--bs-btn-close-color);background:transparent var(--bs-btn-close-bg) center/1em auto no-repeat;border:0;border-radius:.375rem;opacity:var(--bs-btn-close-opacity)}.btn-close:hover{color:var(--bs-btn-close-color);text-decoration:none;opacity:var(--bs-btn-close-hover-opacity)}.btn-close:focus{outline:0;box-shadow:var(--bs-btn-close-focus-shadow);opacity:var(--bs-btn-close-focus-opacity)}.btn-close:disabled,.btn-close.disabled{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;opacity:var(--bs-btn-close-disabled-opacity)}[data-bs-theme="dark"] .btn-close{filter:var(--bs-btn-close-white-filter)}.modal{--bs-modal-zindex: 1055;--bs-modal-width: 500px;--bs-modal-padding: 1rem;--bs-modal-margin: .5rem;--bs-modal-color: ;--bs-modal-bg: var(--bs-body-bg);--bs-modal-border-color: var(--bs-border-color-translucent);--bs-modal-border-width: var(--bs-border-width);--bs-modal-border-radius: var(--bs-border-radius-lg);--bs-modal-box-shadow: var(--bs-box-shadow-sm);--bs-modal-inner-border-radius: calc(var(--bs-border-radius-lg) - (var(--bs-border-width)));--bs-modal-header-padding-x: 1rem;--bs-modal-header-padding-y: 1rem;--bs-modal-header-padding: 1rem 1rem;--bs-modal-header-border-color: var(--bs-border-color);--bs-modal-header-border-width: var(--bs-border-width);--bs-modal-title-line-height: 1.5;--bs-modal-footer-gap: .5rem;--bs-modal-footer-bg: ;--bs-modal-footer-border-color: var(--bs-border-color);--bs-modal-footer-border-width: var(--bs-border-width);position:fixed;top:0;left:0;z-index:var(--bs-modal-zindex);display:none;width:100%;height:100%;overflow-x:hidden;overflow-y:auto;outline:0}.modal-dialog{position:relative;width:auto;margin:var(--bs-modal-margin);pointer-events:none}.modal.fade .modal-dialog{transition:transform 0.3s ease-out;transform:translate(0, -50px)}@media (prefers-reduced-motion: reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{transform:none}.modal-dialog-scrollable{height:calc(100% - var(--bs-modal-margin) * 2)}.modal-dialog-scrollable .modal-content{max-height:100%;overflow:hidden}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-content{position:relative;display:flex;flex-direction:column;width:100%;color:var(--bs-modal-color);pointer-events:auto;background-color:var(--bs-modal-bg);background-clip:padding-box;border:var(--bs-modal-border-width) solid var(--bs-modal-border-color);border-radius:var(--bs-modal-border-radius);outline:0}.modal-backdrop{--bs-backdrop-zindex: 1050;--bs-backdrop-bg: #000;--bs-backdrop-opacity: .5;position:fixed;top:0;left:0;z-index:var(--bs-backdrop-zindex);width:100vw;height:100vh;background-color:var(--bs-backdrop-bg)}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:var(--bs-backdrop-opacity)}.modal-header{display:flex;flex-shrink:0;align-items:center;padding:var(--bs-modal-header-padding);border-bottom:var(--bs-modal-header-border-width) solid var(--bs-modal-header-border-color);border-top-left-radius:var(--bs-modal-inner-border-radius);border-top-right-radius:var(--bs-modal-inner-border-radius)}.modal-header .btn-close{padding:calc(var(--bs-modal-header-padding-y) * .5) calc(var(--bs-modal-header-padding-x) * .5);margin:calc(-.5 * var(--bs-modal-header-padding-y)) calc(-.5 * var(--bs-modal-header-padding-x)) calc(-.5 * var(--bs-modal-header-padding-y)) auto}.modal-title{margin-bottom:0;line-height:var(--bs-modal-title-line-height)}.modal-body{position:relative;flex:1 1 auto;padding:var(--bs-modal-padding)}.modal-footer{display:flex;flex-shrink:0;flex-wrap:wrap;align-items:center;justify-content:flex-end;padding:calc(var(--bs-modal-padding) - var(--bs-modal-footer-gap) * .5);background-color:var(--bs-modal-footer-bg);border-top:var(--bs-modal-footer-border-width) solid var(--bs-modal-footer-border-color);border-bottom-right-radius:var(--bs-modal-inner-border-radius);border-bottom-left-radius:var(--bs-modal-inner-border-radius)}.modal-footer>*{margin:calc(var(--bs-modal-footer-gap) * .5)}@media (min-width: 576px){.modal{--bs-modal-margin: 1.75rem;--bs-modal-box-shadow: var(--bs-box-shadow)}.modal-dialog{max-width:var(--bs-modal-width);margin-right:auto;margin-left:auto}}@media (max-width: 767.98px){.modal-fullscreen-md-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-md-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-md-down .modal-header,.modal-fullscreen-md-down .modal-footer{border-radius:0}.modal-fullscreen-md-down .modal-body{overflow-y:auto}}@keyframes spinner-border{to{transform:rotate(360deg) /* rtl:ignore */}}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}.offcanvas{--bs-offcanvas-zindex: 1045;--bs-offcanvas-width: 332px;--bs-offcanvas-height: 30vh;--bs-offcanvas-padding-x: 1rem;--bs-offcanvas-padding-y: 1rem;--bs-offcanvas-color: var(--bs-body-color);--bs-offcanvas-bg: var(--bs-body-bg);--bs-offcanvas-border-width: var(--bs-border-width);--bs-offcanvas-border-color: var(--bs-border-color-translucent);--bs-offcanvas-box-shadow: var(--bs-box-shadow-sm);--bs-offcanvas-transition: transform .3s ease-in-out;--bs-offcanvas-title-line-height: 1.5}.offcanvas{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}@media (prefers-reduced-motion: reduce){.offcanvas{transition:none}}.offcanvas.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas.showing,.offcanvas.show:not(.hiding){transform:none}.offcanvas.showing,.offcanvas.hiding,.offcanvas.show{visibility:visible}.offcanvas-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:#000}.offcanvas-backdrop.fade{opacity:0}.offcanvas-backdrop.show{opacity:.5}.offcanvas-header{display:flex;align-items:center;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x)}.offcanvas-header .btn-close{padding:calc(var(--bs-offcanvas-padding-y) * .5) calc(var(--bs-offcanvas-padding-x) * .5);margin:calc(-.5 * var(--bs-offcanvas-padding-y)) calc(-.5 * var(--bs-offcanvas-padding-x)) calc(-.5 * var(--bs-offcanvas-padding-y)) auto}.offcanvas-title{margin-bottom:0;line-height:var(--bs-offcanvas-title-line-height)}.offcanvas-body{flex-grow:1;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x);overflow-y:auto}@keyframes placeholder-glow{50%{opacity:.2}}@keyframes placeholder-wave{100%{-webkit-mask-position:-200% 0%;mask-position:-200% 0%}}.d-flex{display:flex !important}.d-none{display:none !important}.position-relative{position:relative !important}.w-100{width:100% !important}.h-auto{height:auto !important}.flex-row{flex-direction:row !important}.flex-column{flex-direction:column !important}.flex-grow-1{flex-grow:1 !important}.justify-content-end{justify-content:flex-end !important}.justify-content-center{justify-content:center !important}.justify-content-between{justify-content:space-between !important}.order-3{order:3 !important}.m-2{margin:.5rem !important}.m-3{margin:1rem !important}.mx-2{margin-right:.5rem !important;margin-left:.5rem !important}.mx-auto{margin-right:auto !important;margin-left:auto !important}.my-3{margin-top:1rem !important;margin-bottom:1rem !important}.mt-3{margin-top:1rem !important}.mt-4{margin-top:1.5rem !important}.mt-5{margin-top:3rem !important}.me-2{margin-right:.5rem !important}.me-auto{margin-right:auto !important}.mb-0{margin-bottom:0 !important}.mb-2{margin-bottom:.5rem !important}.mb-3{margin-bottom:1rem !important}.mb-4{margin-bottom:1.5rem !important}.mb-5{margin-bottom:3rem !important}.ms-2{margin-left:.5rem !important}.ms-3{margin-left:1rem !important}.ms-auto{margin-left:auto !important}.mt-n3{margin-top:-1rem !important}.p-0{padding:0 !important}.p-2{padding:.5rem !important}.px-0{padding-right:0 !important;padding-left:0 !important}.py-5{padding-top:3rem !important;padding-bottom:3rem !important}.pt-4{padding-top:1.5rem !important}.pt-5{padding-top:3rem !important}.pe-4{padding-right:1.5rem !important}.pb-2{padding-bottom:.5rem !important}.pb-3{padding-bottom:1rem !important}.pb-5{padding-bottom:3rem !important}.ps-3{padding-left:1rem !important}.fs-5{font-size:1.25rem !important}.fw-bold{font-weight:700 !important}.text-end{text-align:right !important}.text-center{text-align:center !important}.text-decoration-none{text-decoration:none !important}.text-muted{--bs-text-opacity: 1;color:var(--bs-secondary-color) !important}.text-body-secondary{--bs-text-opacity: 1;color:var(--bs-secondary-color) !important}.text-reset{--bs-text-opacity: 1;color:inherit !important}.rounded-pill{border-radius:var(--bs-border-radius-pill) !important}@media (min-width: 576px){.flex-sm-row{flex-direction:row !important}}@media (min-width: 768px){.d-md-block{display:block !important}.d-md-none{display:none !important}.flex-md-row{flex-direction:row !important}}@media (min-width: 992px){.d-lg-block{display:block !important}.d-lg-none{display:none !important}.flex-lg-row{flex-direction:row !important}.order-lg-4{order:4 !important}.me-lg-1{margin-right:.25rem !important}.me-lg-3{margin-right:1rem !important}.ms-lg-2{margin-left:.5rem !important}.text-lg-start{text-align:left !important}.text-lg-end{text-align:right !important}}@media (min-width: 1200px){.d-xl-block{display:block !important}.d-xl-none{display:none !important}.flex-xl-nowrap{flex-wrap:nowrap !important}.mx-xl-auto{margin-right:auto !important;margin-left:auto !important}}@font-face{font-family:Jost;font-style:normal;font-weight:400;font-display:swap;src:local("Jost Regular Regular"),local("Jost-Regular"),local("Jost* Book"),local("Jost-Book"),url("fonts/vendor/jost/jost-v4-latin-regular.woff2") format("woff2"),url("fonts/vendor/jost/jost-v4-latin-regular.woff") format("woff")}@font-face{font-family:Jost;font-style:normal;font-weight:500;font-display:swap;src:local("Jost Regular Medium"),local("JostRoman-Medium"),local("Jost* Medium"),local("Jost-Medium"),url("fonts/vendor/jost/jost-v4-latin-500.woff2") format("woff2"),url("fonts/vendor/jost/jost-v4-latin-500.woff") format("woff")}@font-face{font-family:Jost;font-style:normal;font-weight:700;font-display:swap;src:local("Jost Regular Bold"),local("JostRoman-Bold"),local("Jost* Bold"),local("Jost-Bold"),url("fonts/vendor/jost/jost-v4-latin-700.woff2") format("woff2"),url("fonts/vendor/jost/jost-v4-latin-700.woff") format("woff")}@font-face{font-family:Jost;font-style:italic;font-weight:400;font-display:swap;src:local("Jost Italic Italic"),local("Jost-Italic"),local("Jost* BookItalic"),local("Jost-BookItalic"),url("fonts/vendor/jost/jost-v4-latin-italic.woff2") format("woff2"),url("fonts/vendor/jost/jost-v4-latin-italic.woff") format("woff")}@font-face{font-family:Jost;font-style:italic;font-weight:500;font-display:swap;src:local("Jost Italic Medium Italic"),local("JostItalic-Medium"),local("Jost* Medium Italic"),local("Jost-MediumItalic"),url("fonts/vendor/jost/jost-v4-latin-500italic.woff2") format("woff2"),url("fonts/vendor/jost/jost-v4-latin-500italic.woff") format("woff")}@font-face{font-family:Jost;font-style:italic;font-weight:700;font-display:swap;src:local("Jost Italic Bold Italic"),local("JostItalic-Bold"),local("Jost* Bold Italic"),local("Jost-BoldItalic"),url("fonts/vendor/jost/jost-v4-latin-700italic.woff2") format("woff2"),url("fonts/vendor/jost/jost-v4-latin-700italic.woff") format("woff")}html[data-bs-theme="dark"] .icon-tabler-sun{display:block}html[data-bs-theme="dark"] .icon-tabler-moon{display:none}html[data-bs-theme="light"] .icon-tabler-sun{display:none}html[data-bs-theme="light"] .icon-tabler-moon{display:block}.contributors .content,.error404 .content,.docs.list .content,.categories.list .content,.tags.list .content,.list.section .content{padding-top:1rem;padding-bottom:3rem}.content img{max-width:100%}h5,.h5,h4,.h4,h3,.h3,h2,.h2,h1,.h1{margin-top:2rem;margin-bottom:1rem}@media (min-width: 768px){body{font-size:1.125rem}h1,h2,h3,h4,h5,.h1,.h2,.h3,.h4,.h5{margin-bottom:1.125rem}}.home h1,.home .h1{font-size:calc(1.875rem + 1.5vw);margin-top:-1rem}a:hover,a:focus{text-decoration:underline}a.btn:hover,a.btn:focus{text-decoration:none}.section{padding-top:5rem;padding-bottom:5rem}body.section{padding-top:0;padding-bottom:0}.docs-sidebar{order:2}@media (min-width: 992px){.docs-sidebar{order:0;border-right:1px solid #e9ecef}@supports (position: sticky){.docs-sidebar{position:sticky;top:4.25rem;z-index:1000;height:calc(100vh - 4.25rem)}}}@media (min-width: 1200px){.docs-sidebar{flex:0 1 320px}}.docs-links{padding-bottom:5rem}@media (min-width: 992px){@supports (position: sticky){.docs-links{max-height:calc(100vh - 4rem);overflow-y:scroll}}}@media (min-width: 992px){.docs-links{display:block;width:auto;margin-right:-1.5rem;padding-bottom:4rem}}.docs-toc{order:2}@supports (position: sticky){.docs-toc{position:sticky;top:4.25rem;height:calc(100vh - 4.25rem);overflow-y:auto}}.docs-content{padding-bottom:3rem;order:1}.navbar a:hover,.navbar a:focus{text-decoration:none}#TableOfContents ul,#toc ul{padding-left:0;list-style:none}#toc a.active{color:#4f46e5;font-weight:500}.modal-backdrop{background-color:#fff}.modal-backdrop.show{opacity:0.7}@media (min-width: 768px){.modal-backdrop.show{opacity:0}}li input[type="checkbox"]{margin:0.25rem;border:1px solid #ced4da}li input[type="checkbox"]:disabled{pointer-events:none;filter:none;opacity:1}li input[type="checkbox"]:checked{background-color:#5d2f86;border-color:#5d2f86}[data-bs-theme="dark"] li input[type="checkbox"]{border:1px solid #6c757d}[data-bs-theme="dark"] li input[type="checkbox"]:checked{background-color:#b3c7ff;border-color:#b3c7ff;--bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%231d2d35' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/%3e%3c/svg%3e")}.content .svg-inline{margin-bottom:1.5rem}.content .svg-inline:not(.svg-inline-custom){height:1.875rem;width:1.875rem;stroke-width:1.5}.highlight>.chroma{border:1px solid color-mix(in srgb, var(--sl-color-gray-5), transparent 25%)}.bg{background-color:var(--sl-color-gray-7)}.chroma{background-color:var(--sl-color-gray-7)}.chroma .err{color:inherit}.chroma .lnlinks{outline:none;text-decoration:none;color:inherit}.chroma .lntd{vertical-align:top;padding:0;margin:0;border:0}.chroma .lntable{border-spacing:0;padding:0;margin:0;border:0}.chroma .hl{background-color:#0000001a}.chroma .hl{border-inline-start:0.15rem solid #00000055;margin-left:-1rem;margin-right:-1rem;padding-left:1rem;padding-right:1rem}.chroma .hl .ln{margin-left:-0.15rem}.chroma .lnt{white-space:pre;-webkit-user-select:none;-moz-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f}.chroma .ln{white-space:pre;-webkit-user-select:none;-moz-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f}.chroma .line{display:flex}.chroma .k{color:#000000;font-weight:bold}.chroma .kc{color:#000000;font-weight:bold}.chroma .kd{color:#000000;font-weight:bold}.chroma .kn{color:#000000;font-weight:bold}.chroma .kp{color:#000000;font-weight:bold}.chroma .kr{color:#000000;font-weight:bold}.chroma .kt{color:#445588;font-weight:bold}.chroma .na{color:#008080}.chroma .nb{color:#0086b3}.chroma .bp{color:#999999}.chroma .nc{color:#445588;font-weight:bold}.chroma .no{color:#008080}.chroma .nd{color:#3c5d5d;font-weight:bold}.chroma .ni{color:#800080}.chroma .ne{color:#990000;font-weight:bold}.chroma .nf{color:#990000;font-weight:bold}.chroma .nl{color:#990000;font-weight:bold}.chroma .nn{color:#555555}.chroma .nt{color:#000080}.chroma .nv{color:#008080}.chroma .vc{color:#008080}.chroma .vg{color:#008080}.chroma .vi{color:#008080}.chroma .s{color:#dd1144}.chroma .sa{color:#dd1144}.chroma .sb{color:#dd1144}.chroma .sc{color:#dd1144}.chroma .dl{color:#dd1144}.chroma .sd{color:#dd1144}.chroma .s2{color:#dd1144}.chroma .se{color:#dd1144}.chroma .sh{color:#dd1144}.chroma .si{color:#dd1144}.chroma .sx{color:#dd1144}.chroma .sr{color:#009926}.chroma .s1{color:#dd1144}.chroma .ss{color:#990073}.chroma .m{color:#009999}.chroma .mb{color:#009999}.chroma .mf{color:#009999}.chroma .mh{color:#009999}.chroma .mi{color:#009999}.chroma .il{color:#009999}.chroma .mo{color:#009999}.chroma .o{color:#000000;font-weight:bold}.chroma .ow{color:#000000;font-weight:bold}.chroma .c{color:#999988;font-style:italic}.chroma .ch{color:#999988;font-style:italic}.chroma .cm{color:#999988;font-style:italic}.chroma .c1{color:#999988;font-style:italic}.chroma .cs{color:#999999;font-weight:bold;font-style:italic}.chroma .cp{color:#999999;font-weight:bold;font-style:italic}.chroma .cpf{color:#999999;font-weight:bold;font-style:italic}.chroma .gd{color:#000000;background-color:#ffdddd}.chroma .ge{color:inherit;font-style:italic}.chroma .gr{color:#aa0000}.chroma .gh{color:#999999}.chroma .gi{color:#000000;background-color:#ddffdd}.chroma .go{color:#888888}.chroma .gp{color:#555555}.chroma .gs{font-weight:bold}.chroma .gu{color:#aaaaaa}.chroma .gt{color:#aa0000}.chroma .gl{text-decoration:underline}.chroma .w{color:#bbbbbb}[data-bs-theme="dark"] .highlight>.chroma{border:1px solid color-mix(in srgb, var(--sl-color-gray-5), transparent 25%)}[data-bs-theme="dark"] .bg{color:#c9d1d9;background-color:var(--sl-color-gray-6)}[data-bs-theme="dark"] .chroma{color:#c9d1d9;background-color:var(--sl-color-gray-6)}[data-bs-theme="dark"] .chroma .err{color:inherit}[data-bs-theme="dark"] .chroma .lnlinks{outline:none;text-decoration:none;color:inherit}[data-bs-theme="dark"] .chroma .lntd{vertical-align:top;padding:0;margin:0;border:0}[data-bs-theme="dark"] .chroma .lntable{border-spacing:0;padding:0;margin:0;border:0}[data-bs-theme="dark"] .chroma .hl{background-color:#ffffff17}[data-bs-theme="dark"] .chroma .hl{border-inline-start:0.15rem solid #ffffff40;margin-left:-1rem;margin-right:-1rem;padding-left:1rem;padding-right:1rem}[data-bs-theme="dark"] .chroma .hl .ln{margin-left:-0.15rem}[data-bs-theme="dark"] .chroma .lnt{white-space:pre;-webkit-user-select:none;-moz-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#64686c}[data-bs-theme="dark"] .chroma .ln{white-space:pre;-webkit-user-select:none;-moz-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#6e7681}[data-bs-theme="dark"] .chroma .line{display:flex}[data-bs-theme="dark"] .chroma .k{color:#ff7b72}[data-bs-theme="dark"] .chroma .kc{color:#79c0ff}[data-bs-theme="dark"] .chroma .kd{color:#ff7b72}[data-bs-theme="dark"] .chroma .kn{color:#ff7b72}[data-bs-theme="dark"] .chroma .kp{color:#79c0ff}[data-bs-theme="dark"] .chroma .kr{color:#ff7b72}[data-bs-theme="dark"] .chroma .kt{color:#ff7b72}[data-bs-theme="dark"] .chroma .na{color:#d2a8ff}[data-bs-theme="dark"] .chroma .nc{color:#f0883e;font-weight:bold}[data-bs-theme="dark"] .chroma .no{color:#79c0ff;font-weight:bold}[data-bs-theme="dark"] .chroma .nd{color:#d2a8ff;font-weight:bold}[data-bs-theme="dark"] .chroma .ni{color:#ffa657}[data-bs-theme="dark"] .chroma .ne{color:#f0883e;font-weight:bold}[data-bs-theme="dark"] .chroma .nf{color:#d2a8ff;font-weight:bold}[data-bs-theme="dark"] .chroma .nl{color:#79c0ff;font-weight:bold}[data-bs-theme="dark"] .chroma .nn{color:#ff7b72}[data-bs-theme="dark"] .chroma .py{color:#79c0ff}[data-bs-theme="dark"] .chroma .nt{color:#7ee787}[data-bs-theme="dark"] .chroma .nv{color:#79c0ff}[data-bs-theme="dark"] .chroma .l{color:#a5d6ff}[data-bs-theme="dark"] .chroma .ld{color:#79c0ff}[data-bs-theme="dark"] .chroma .s{color:#a5d6ff}[data-bs-theme="dark"] .chroma .sa{color:#79c0ff}[data-bs-theme="dark"] .chroma .sb{color:#a5d6ff}[data-bs-theme="dark"] .chroma .sc{color:#a5d6ff}[data-bs-theme="dark"] .chroma .dl{color:#79c0ff}[data-bs-theme="dark"] .chroma .sd{color:#a5d6ff}[data-bs-theme="dark"] .chroma .s2{color:#a5d6ff}[data-bs-theme="dark"] .chroma .se{color:#79c0ff}[data-bs-theme="dark"] .chroma .sh{color:#79c0ff}[data-bs-theme="dark"] .chroma .si{color:#a5d6ff}[data-bs-theme="dark"] .chroma .sx{color:#a5d6ff}[data-bs-theme="dark"] .chroma .sr{color:#79c0ff}[data-bs-theme="dark"] .chroma .s1{color:#a5d6ff}[data-bs-theme="dark"] .chroma .ss{color:#a5d6ff}[data-bs-theme="dark"] .chroma .m{color:#a5d6ff}[data-bs-theme="dark"] .chroma .mb{color:#a5d6ff}[data-bs-theme="dark"] .chroma .mf{color:#a5d6ff}[data-bs-theme="dark"] .chroma .mh{color:#a5d6ff}[data-bs-theme="dark"] .chroma .mi{color:#a5d6ff}[data-bs-theme="dark"] .chroma .il{color:#a5d6ff}[data-bs-theme="dark"] .chroma .mo{color:#a5d6ff}[data-bs-theme="dark"] .chroma .o{color:inherit;font-weight:bold}[data-bs-theme="dark"] .chroma .ow{color:#ff7b72;font-weight:bold}[data-bs-theme="dark"] .chroma .c{color:#8b949e;font-style:italic}[data-bs-theme="dark"] .chroma .ch{color:#8b949e;font-style:italic}[data-bs-theme="dark"] .chroma .cm{color:#8b949e;font-style:italic}[data-bs-theme="dark"] .chroma .c1{color:#8b949e;font-style:italic}[data-bs-theme="dark"] .chroma .cs{color:#8b949e;font-weight:bold;font-style:italic}[data-bs-theme="dark"] .chroma .cp{color:#8b949e;font-weight:bold;font-style:italic}[data-bs-theme="dark"] .chroma .cpf{color:#8b949e;font-weight:bold;font-style:italic}[data-bs-theme="dark"] .chroma .gd{color:#ffa198;background-color:#490202}[data-bs-theme="dark"] .chroma .ge{font-style:italic}[data-bs-theme="dark"] .chroma .gr{color:#ffa198}[data-bs-theme="dark"] .chroma .gh{color:#79c0ff;font-weight:bold}[data-bs-theme="dark"] .chroma .gi{color:#56d364;background-color:#0f5323}[data-bs-theme="dark"] .chroma .go{color:#8b949e}[data-bs-theme="dark"] .chroma .gp{color:#8b949e}[data-bs-theme="dark"] .chroma .gs{font-weight:bold}[data-bs-theme="dark"] .chroma .gu{color:#79c0ff}[data-bs-theme="dark"] .chroma .gt{color:#ff7b72}[data-bs-theme="dark"] .chroma .gl{text-decoration:underline}[data-bs-theme="dark"] .chroma .w{color:#6e7681}[data-bs-theme="dark"] h1,[data-bs-theme="dark"] .h1,[data-bs-theme="dark"] h2,[data-bs-theme="dark"] .h2,[data-bs-theme="dark"] h3,[data-bs-theme="dark"] .h3,[data-bs-theme="dark"] h4,[data-bs-theme="dark"] .h4{color:#fff}[data-bs-theme="dark"] body{background:#17181c;color:#c1c3c8}[data-bs-theme="dark"] a{color:#b3c7ff}[data-bs-theme="dark"] .callout a{color:inherit}[data-bs-theme="dark"] .btn-primary{--bs-btn-color: #000;--bs-btn-bg: #b3c7ff;--bs-btn-border-color: #b3c7ff;--bs-btn-hover-color: #000;--bs-btn-hover-bg: #becfff;--bs-btn-hover-border-color: #bacdff;--bs-btn-focus-shadow-rgb: 152,169,217;--bs-btn-active-color: #000;--bs-btn-active-bg: #c2d2ff;--bs-btn-active-border-color: #bacdff;--bs-btn-active-shadow: inset 0 3px 5px rgba(0,0,0,0.125);--bs-btn-disabled-color: #000;--bs-btn-disabled-bg: #b3c7ff;--bs-btn-disabled-border-color: #b3c7ff;color:#17181c}[data-bs-theme="dark"] .btn-outline-primary{--bs-btn-color: #b3c7ff;--bs-btn-border-color: #b3c7ff;--bs-btn-hover-color: #b3c7ff;--bs-btn-hover-bg: #b3c7ff;--bs-btn-hover-border-color: #b3c7ff;--bs-btn-focus-shadow-rgb: 178.5,198.9,255;--bs-btn-active-color: #000;--bs-btn-active-bg: #b3c7ff;--bs-btn-active-border-color: #b3c7ff;--bs-btn-active-shadow: inset 0 3px 5px rgba(0,0,0,0.125);--bs-btn-disabled-color: #b3c7ff;--bs-btn-disabled-bg: transparent;--bs-btn-disabled-border-color: #b3c7ff;--bs-gradient: none;color:#b3c7ff}[data-bs-theme="dark"] .btn-outline-primary:hover{color:#17181c}[data-bs-theme="dark"] .navbar{background-color:rgba(23,24,28,0.95);border-bottom:1px solid #23262f}[data-bs-theme="dark"] body.home .navbar{border-bottom:0}[data-bs-theme="dark"] .offcanvas-header{border-bottom:1px solid #343a40}[data-bs-theme="dark"] .offcanvas .nav-link{color:#c1c3c8}[data-bs-theme="dark"] .offcanvas .nav-link:hover,[data-bs-theme="dark"] .offcanvas .nav-link:focus{color:#b3c7ff}[data-bs-theme="dark"] .offcanvas .nav-link.active{color:#b3c7ff}[data-bs-theme="dark"] .page-links a{color:#c1c3c8}[data-bs-theme="dark"] .page-links a:hover{text-decoration:none;color:#b3c7ff}[data-bs-theme="dark"] .navbar .btn-link{color:#c1c3c8}[data-bs-theme="dark"] .content .btn-link{color:#b3c7ff}[data-bs-theme="dark"] .content .btn-link:hover{color:#b3c7ff}[data-bs-theme="dark"] .navbar .btn-link:hover{color:#b3c7ff}[data-bs-theme="dark"] .navbar .btn-link:active{color:#b3c7ff}[data-bs-theme="dark"] .form-control{color:#dee2e6}[data-bs-theme="dark"] .form-control::-moz-placeholder{color:#ced4da;opacity:1}[data-bs-theme="dark"] .form-control::placeholder{color:#ced4da;opacity:1}@media (min-width: 992px){[data-bs-theme="dark"] .docs-sidebar{order:0;border-right:1px solid #23262f}}[data-bs-theme="dark"] blockquote{border-left:3px solid #23262f}[data-bs-theme="dark"] .footer{border-top:1px solid #23262f}[data-bs-theme="dark"] .docs-links,[data-bs-theme="dark"] .docs-toc{scrollbar-width:thin;scrollbar-color:#17181c #17181c}[data-bs-theme="dark"] .docs-links::-webkit-scrollbar,[data-bs-theme="dark"] .docs-toc::-webkit-scrollbar{width:5px}[data-bs-theme="dark"] .docs-links::-webkit-scrollbar-track,[data-bs-theme="dark"] .docs-toc::-webkit-scrollbar-track{background:#17181c}[data-bs-theme="dark"] .docs-links::-webkit-scrollbar-thumb,[data-bs-theme="dark"] .docs-toc::-webkit-scrollbar-thumb{background:#17181c}[data-bs-theme="dark"] .docs-links:hover,[data-bs-theme="dark"] .docs-toc:hover{scrollbar-width:thin;scrollbar-color:#23262f #17181c}[data-bs-theme="dark"] .docs-links:hover::-webkit-scrollbar-thumb,[data-bs-theme="dark"] .docs-toc:hover::-webkit-scrollbar-thumb{background:#23262f}[data-bs-theme="dark"] .docs-links::-webkit-scrollbar-thumb:hover,[data-bs-theme="dark"] .docs-toc::-webkit-scrollbar-thumb:hover{background:#23262f}[data-bs-theme="dark"] .docs-links h3:not(:first-child),[data-bs-theme="dark"] .docs-links .h3:not(:first-child){border-top:1px solid #23262f}[data-bs-theme="dark"] .page-links li:not(:first-child){border-top:1px dashed #23262f}[data-bs-theme="dark"] .card{background:#17181c;border:1px solid #23262f}[data-bs-theme="dark"] .text-muted{color:#adafb6 !important}[data-bs-theme="dark"] .offcanvas{background-color:#17181c}[data-bs-theme="dark"] .page-link{color:#b3c7ff;background-color:transparent;border:var(--bs-border-width) solid #23262f}[data-bs-theme="dark"] .page-link:hover{color:#17181c;background-color:#c1c3c8;border-color:#c1c3c8}[data-bs-theme="dark"] .page-link:focus{color:#17181c;background-color:#c1c3c8}[data-bs-theme="dark"] .page-item.active .page-link{color:#17181c;background-color:#b3c7ff;border-color:#b3c7ff}[data-bs-theme="dark"] .page-item.disabled .page-link{color:var(--bs-secondary-color);background-color:#23262f;border-color:#23262f}[data-bs-theme="dark"] details{border:1px solid #23262f}[data-bs-theme="dark"] summary:hover{background:#23262f}[data-bs-theme="dark"] details[open]>summary{border-bottom:1px solid #23262f}[data-bs-theme="dark"] details summary::after{content:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='rgba%28222, 226, 230, 0.75%29' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M5 14l6-6-6-6'/%3e%3c/svg%3e")}[data-bs-theme="dark"] #toc a.active{color:#b3c7ff}[data-bs-theme="dark"] table th{color:#fff}[data-bs-theme="dark"] table,[data-bs-theme="dark"] [data-bs-theme="dark"] table{--bs-table-color: inherit;--bs-table-bg: $body-bg-dark;background:#17181c;border-color:#23262f}.btn-close:focus,.btn-close:active{outline:none;box-shadow:none}.navbar .btn-link{color:rgba(var(--bs-emphasis-color-rgb), 0.65);padding:0.4375rem 0}.btn-link:focus{outline:0;box-shadow:none}@media (min-width: 992px){.navbar .btn-link{padding:0.5625em 0.25rem 0.5rem 0.125rem}}.navbar .btn-link:hover{color:rgba(var(--bs-emphasis-color-rgb), 0.8)}.navbar .btn-link:active{color:rgba(var(--bs-emphasis-color-rgb), 1)}.btn-close{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-x'%3E%3Cline x1='18' y1='6' x2='6' y2='18'%3E%3C/line%3E%3Cline x1='6' y1='6' x2='18' y2='18'%3E%3C/line%3E%3C/svg%3E");background-size:1.5rem}.offcanvas-header .btn-close{margin-right:0 !important}.clipboard{position:relative;float:right}.btn-clipboard{transition:opacity 0.25s ease-in-out;opacity:0;position:absolute;right:0.5rem;top:0.5rem;line-height:1;padding:0.3125rem 0.3125rem 0.1875rem;background-color:transparent;border-color:transparent}@media (max-width: 767.98px){.btn-clipboard{position:absolute;right:-0.5rem;top:0.5rem}}.btn-clipboard::after{width:22px;height:22px;display:inline-block;content:"";-webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='icon icon-tabler icon-tabler-copy' width='22' height='22' viewBox='0 0 24 24' stroke-width='1' stroke='currentColor' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'%3E%3C/path%3E%3Cpath d='M8 8m0 2a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z'%3E%3C/path%3E%3Cpath d='M16 8v-2a2 2 0 0 0 -2 -2h-8a2 2 0 0 0 -2 2v8a2 2 0 0 0 2 2h2'%3E%3C/path%3E%3C/svg%3E") no-repeat 50% 50%;mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='icon icon-tabler icon-tabler-copy' width='22' height='22' viewBox='0 0 24 24' stroke-width='1' stroke='currentColor' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'%3E%3C/path%3E%3Cpath d='M8 8m0 2a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z'%3E%3C/path%3E%3Cpath d='M16 8v-2a2 2 0 0 0 -2 -2h-8a2 2 0 0 0 -2 2v8a2 2 0 0 0 2 2h2'%3E%3C/path%3E%3C/svg%3E") no-repeat 50% 50%;-webkit-mask-size:cover;mask-size:cover;background-color:#495057}.btn-clipboard:hover{border-color:transparent}.btn-clipboard:hover::after{width:22px;height:22px;display:inline-block;content:"";-webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='icon icon-tabler icon-tabler-copy' width='22' height='22' viewBox='0 0 24 24' stroke-width='1' stroke='currentColor' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'%3E%3C/path%3E%3Cpath d='M8 8m0 2a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z'%3E%3C/path%3E%3Cpath d='M16 8v-2a2 2 0 0 0 -2 -2h-8a2 2 0 0 0 -2 2v8a2 2 0 0 0 2 2h2'%3E%3C/path%3E%3C/svg%3E") no-repeat 50% 50%;mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='icon icon-tabler icon-tabler-copy' width='22' height='22' viewBox='0 0 24 24' stroke-width='1' stroke='currentColor' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'%3E%3C/path%3E%3Cpath d='M8 8m0 2a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z'%3E%3C/path%3E%3Cpath d='M16 8v-2a2 2 0 0 0 -2 -2h-8a2 2 0 0 0 -2 2v8a2 2 0 0 0 2 2h2'%3E%3C/path%3E%3C/svg%3E") no-repeat 50% 50%;-webkit-mask-size:cover;mask-size:cover;background-color:#212529}.btn-clipboard:focus,.btn-clipboard:active{border-color:transparent !important}.btn-clipboard:focus::after,.btn-clipboard:active::after{width:22px;height:22px;display:inline-block;content:"";-webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22' viewBox='0 0 24 24' stroke-width='1.25' stroke='currentColor' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'%3E%3C/path%3E%3Cpath d='M5 12l5 5l10 -10'%3E%3C/path%3E%3C/svg%3E") no-repeat 50% 50%;mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22' viewBox='0 0 24 24' stroke-width='1.25' stroke='currentColor' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'%3E%3C/path%3E%3Cpath d='M5 12l5 5l10 -10'%3E%3C/path%3E%3C/svg%3E") no-repeat 50% 50%;-webkit-mask-size:cover;mask-size:cover;background-color:#212529}[data-bs-theme="dark"] .btn-clipboard{background-color:transparent;border-color:transparent}[data-bs-theme="dark"] .btn-clipboard::after{background-color:#ced4da}[data-bs-theme="dark"] .btn-clipboard:hover{border-color:transparent}[data-bs-theme="dark"] .btn-clipboard:hover::after{background-color:#e9ecef}[data-bs-theme="dark"] .btn-clipboard:focus,[data-bs-theme="dark"] .btn-clipboard:active{border-color:transparent}[data-bs-theme="dark"] .btn-clipboard:focus::after,[data-bs-theme="dark"] .btn-clipboard:active::after{background-color:#e9ecef}.highlight{position:relative}@media (min-width: 768px){.highlight:hover .btn-clipboard{opacity:1}}.btn-cta{padding-left:2rem;padding-right:2rem}.callout{--bs-link-color-rgb: var(--callout-link);--bs-code-color: var(--callout-code-color);color:var(--callout-color, inherit);background-color:var(--callout-bg, var(--bs-gray-100));border-left:0.25rem solid var(--callout-border, var(--bs-gray-300));border-radius:0}.callout a{text-decoration:underline}.callout .highlight{background-color:rgba(0,0,0,0.05)}.callout .callout-icon.svg-inline{flex-shrink:0;height:calc(1.5 * 1.125rem)}.callout .callout-title{font-weight:700}.callout-content{min-width:0}.callout.callout-note{border-color:var(--sl-color-blue);background-color:var(--sl-color-blue-high)}.callout.callout-note .callout-icon,.callout.callout-note .callout-title,.callout.callout-note .callout-body a{color:var(--sl-color-blue-low)}.callout.callout-note .callout-body,.callout.callout-note .callout-body a:hover,.callout.callout-note .callout-body a:active{color:var(--sl-color-white)}.callout.callout-danger{border-color:var(--sl-color-red);background-color:var(--sl-color-red-high)}.callout.callout-danger .callout-icon,.callout.callout-danger .callout-title,.callout.callout-danger .callout-body a{color:var(--sl-color-red-low)}.callout.callout-danger .callout-body,.callout.callout-danger .callout-body a:hover,.callout.callout-danger .callout-body a:active{color:var(--sl-color-white)}[data-bs-theme="dark"] .callout{color:var(--sl-color-gray-1)}[data-bs-theme="dark"] .callout.callout-note{border-color:var(--sl-color-blue);background-color:var(--sl-color-blue-low)}[data-bs-theme="dark"] .callout.callout-note .callout-icon,[data-bs-theme="dark"] .callout.callout-note .callout-title,[data-bs-theme="dark"] .callout.callout-note .callout-body a{color:var(--sl-color-blue-high)}[data-bs-theme="dark"] .callout.callout-note .callout-body,[data-bs-theme="dark"] .callout.callout-note .callout-body a:hover,[data-bs-theme="dark"] .callout.callout-note .callout-body a:active{color:var(--sl-color-white)}[data-bs-theme="dark"] .callout.callout-note code:not(:where(.not-content *)){color:var(--ec-codeFg)}[data-bs-theme="dark"] .callout.callout-danger{border-color:var(--sl-color-red);background-color:var(--sl-color-red-low)}[data-bs-theme="dark"] .callout.callout-danger .callout-icon,[data-bs-theme="dark"] .callout.callout-danger .callout-title,[data-bs-theme="dark"] .callout.callout-danger .callout-body a{color:var(--sl-color-red-high)}[data-bs-theme="dark"] .callout.callout-danger .callout-body,[data-bs-theme="dark"] .callout.callout-danger .callout-body a:hover,[data-bs-theme="dark"] .callout.callout-danger .callout-body a:active{color:var(--sl-color-white)}[data-bs-theme="dark"] .callout.callout-danger code:not(:where(.not-content *)){color:var(--ec-codeFg)}.expressive-code{font-family:var(--ec-uiFontFml);font-size:var(--ec-uiFontSize);line-height:var(--ec-uiLineHt);-moz-text-size-adjust:none;text-size-adjust:none;-webkit-text-size-adjust:none;margin:1.5rem 0}.expressive-code *:not(path){all:revert;box-sizing:border-box}.expressive-code pre{display:flex;margin:0;padding:0;border:var(--ec-brdWd) solid var(--ec-brdCol);border-radius:calc(var(--ec-brdRad) + var(--ec-brdWd));background:var(--ec-codeBg)}.expressive-code pre:focus-visible{outline:3px solid var(--ec-focusBrd);outline-offset:-3px}.expressive-code pre>code{all:unset;display:block;flex:1 0 100%;padding:var(--ec-codePadBlk) 0;color:var(--ec-codeFg);font-family:var(--ec-codeFontFml);font-size:var(--ec-codeFontSize);line-height:var(--ec-codeLineHt)}.expressive-code pre{overflow-x:auto}.expressive-code pre::-webkit-scrollbar,.expressive-code pre::-webkit-scrollbar-track{background-color:inherit;border-radius:calc(var(--ec-brdRad) + var(--ec-brdWd));border-top-left-radius:0;border-top-right-radius:0}.expressive-code pre::-webkit-scrollbar-thumb{background-color:var(--ec-sbThumbCol);border:4px solid transparent;background-clip:content-box;border-radius:10px}.expressive-code pre::-webkit-scrollbar-thumb:hover{background-color:var(--ec-sbThumbHoverCol)}.expressive-code .ec-line{padding-inline:var(--ec-codePadInl);padding-inline-end:calc(2rem + var(--ec-codePadInl));direction:ltr;unicode-bidi:isolate}.expressive-code .sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0, 0, 0, 0);white-space:nowrap;border-width:0}.expressive-code .ec-line.mark{--tmLineBgCol: var(--ec-tm-markBg);--tmLineBrdCol: var(--ec-tm-markBrdCol)}.expressive-code .ec-line.ins{--tmLineBgCol: var(--ec-tm-insBg);--tmLineBrdCol: var(--ec-tm-insBrdCol)}.expressive-code .ec-line.ins::before{content:var(--ec-tm-insDiffIndContent);color:var(--ec-tm-insDiffIndCol)}.expressive-code .ec-line.del{--tmLineBgCol: var(--ec-tm-delBg);--tmLineBrdCol: var(--ec-tm-delBrdCol)}.expressive-code .ec-line.del::before{content:var(--ec-tm-delDiffIndContent);color:var(--ec-tm-delDiffIndCol)}.expressive-code .ec-line.mark,.expressive-code .ec-line.ins,.expressive-code .ec-line.del{position:relative;background:var(--tmLineBgCol);min-width:calc(100% - var(--ec-tm-lineMarkerAccentMarg));margin-inline-start:var(--ec-tm-lineMarkerAccentMarg);border-inline-start:var(--ec-tm-lineMarkerAccentWd) solid var(--tmLineBrdCol);padding-inline-start:calc(var(--ec-codePadInl) - var(--ec-tm-lineMarkerAccentMarg) - var(--ec-tm-lineMarkerAccentWd)) !important}.expressive-code .ec-line.mark::before,.expressive-code .ec-line.ins::before,.expressive-code .ec-line.del::before{position:absolute;left:var(--ec-tm-lineDiffIndMargLeft)}.expressive-code .ec-line mark,.expressive-code .ec-line .mark{--tmInlineBgCol: var(--ec-tm-markBg);--tmInlineBrdCol: var(--ec-tm-markBrdCol)}.expressive-code .ec-line ins{--tmInlineBgCol: var(--ec-tm-insBg);--tmInlineBrdCol: var(--ec-tm-insBrdCol)}.expressive-code .ec-line del{--tmInlineBgCol: var(--ec-tm-delBg);--tmInlineBrdCol: var(--ec-tm-delBrdCol)}.expressive-code .ec-line mark,.expressive-code .ec-line .mark,.expressive-code .ec-line ins,.expressive-code .ec-line del{all:unset;display:inline-block;position:relative;--tmBrdL: var(--ec-tm-inlMarkerBrdWd);--tmBrdR: var(--ec-tm-inlMarkerBrdWd);--tmRadL: var(--ec-tm-inlMarkerBrdRad);--tmRadR: var(--ec-tm-inlMarkerBrdRad);margin-inline:0.025rem;padding-inline:var(--ec-tm-inlMarkerPad);border-radius:var(--tmRadL) var(--tmRadR) var(--tmRadR) var(--tmRadL);background:var(--tmInlineBgCol);background-clip:padding-box}.expressive-code .ec-line mark.open-start,.expressive-code .ec-line .open-start.mark,.expressive-code .ec-line ins.open-start,.expressive-code .ec-line del.open-start{margin-inline-start:0;padding-inline-start:0;--tmBrdL: 0px;--tmRadL: 0}.expressive-code .ec-line mark.open-end,.expressive-code .ec-line .open-end.mark,.expressive-code .ec-line ins.open-end,.expressive-code .ec-line del.open-end{margin-inline-end:0;padding-inline-end:0;--tmBrdR: 0px;--tmRadR: 0}.expressive-code .ec-line mark::before,.expressive-code .ec-line .mark::before,.expressive-code .ec-line ins::before,.expressive-code .ec-line del::before{content:"";position:absolute;pointer-events:none;display:inline-block;inset:0;border-radius:var(--tmRadL) var(--tmRadR) var(--tmRadR) var(--tmRadL);border:var(--ec-tm-inlMarkerBrdWd) solid var(--tmInlineBrdCol);border-inline-width:var(--tmBrdL) var(--tmBrdR)}.expressive-code .frame{all:unset;position:relative;display:block;--header-border-radius: calc(var(--ec-brdRad) + var(--ec-brdWd));--tab-border-radius: calc(var(--ec-frm-edTabBrdRad) + var(--ec-brdWd));--button-spacing: 0.4rem;--code-background: var(--ec-frm-edBg);border-radius:var(--header-border-radius);box-shadow:var(--ec-frm-frameBoxShdCssVal)}.expressive-code .frame .header{display:none;z-index:1;position:relative;border-radius:var(--header-border-radius) var(--header-border-radius) 0 0}.expressive-code .frame.has-title pre,.expressive-code .frame.has-title code,.expressive-code .frame.is-terminal pre,.expressive-code .frame.is-terminal code{border-top:none;border-top-left-radius:0;border-top-right-radius:0}.expressive-code .frame .title:empty:before{content:"\a0"}.expressive-code .frame.has-title:not(.is-terminal){--button-spacing: calc(1.9rem + 2 * (var(--ec-uiPadBlk) + var(--ec-frm-edActTabIndHt)))}.expressive-code .frame.has-title:not(.is-terminal) .title{position:relative;color:var(--ec-frm-edActTabFg);background:var(--ec-frm-edActTabBg);background-clip:padding-box;margin-block-start:var(--ec-frm-edTabsMargBlkStart);padding:calc(var(--ec-uiPadBlk) + var(--ec-frm-edActTabIndHt)) var(--ec-uiPadInl);border:var(--ec-brdWd) solid var(--ec-frm-edActTabBrdCol);border-radius:var(--tab-border-radius) var(--tab-border-radius) 0 0;border-bottom:none;overflow:hidden}.expressive-code .frame.has-title:not(.is-terminal) .title::after{content:"";position:absolute;pointer-events:none;inset:0;border-top:var(--ec-frm-edActTabIndHt) solid var(--ec-frm-edActTabIndTopCol);border-bottom:var(--ec-frm-edActTabIndHt) solid var(--ec-frm-edActTabIndBtmCol)}.expressive-code .frame.has-title:not(.is-terminal) .header{display:flex;background:linear-gradient(to top, var(--ec-frm-edTabBarBrdBtmCol) var(--ec-brdWd), transparent var(--ec-brdWd)),linear-gradient(var(--ec-frm-edTabBarBg), var(--ec-frm-edTabBarBg));background-repeat:no-repeat;padding-inline-start:var(--ec-frm-edTabsMargInlStart)}.expressive-code .frame.has-title:not(.is-terminal) .header::before{content:"";position:absolute;pointer-events:none;inset:0;border:var(--ec-brdWd) solid var(--ec-frm-edTabBarBrdCol);border-radius:inherit;border-bottom:none}.expressive-code .frame.is-terminal{--button-spacing: calc(1.9rem + var(--ec-brdWd) + 2 * var(--ec-uiPadBlk));--code-background: var(--ec-frm-trmBg)}.expressive-code .frame.is-terminal .header{display:flex;align-items:center;justify-content:center;padding-block:var(--ec-uiPadBlk);padding-block-end:calc(var(--ec-uiPadBlk) + var(--ec-brdWd));position:relative;font-weight:500;letter-spacing:0.025ch;color:var(--ec-frm-trmTtbFg);background:var(--ec-frm-trmTtbBg);border:var(--ec-brdWd) solid var(--ec-brdCol);border-bottom:none}.expressive-code .frame.is-terminal .header::before{content:"";position:absolute;pointer-events:none;left:var(--ec-uiPadInl);width:2.1rem;height:0.56rem;line-height:0;background-color:var(--ec-frm-trmTtbDotsFg);opacity:var(--ec-frm-trmTtbDotsOpa);-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 60 16' preserveAspectRatio='xMidYMid meet'%3E%3Ccircle cx='8' cy='8' r='8'/%3E%3Ccircle cx='30' cy='8' r='8'/%3E%3Ccircle cx='52' cy='8' r='8'/%3E%3C/svg%3E");-webkit-mask-repeat:no-repeat;mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 60 16' preserveAspectRatio='xMidYMid meet'%3E%3Ccircle cx='8' cy='8' r='8'/%3E%3Ccircle cx='30' cy='8' r='8'/%3E%3Ccircle cx='52' cy='8' r='8'/%3E%3C/svg%3E");mask-repeat:no-repeat}.expressive-code .frame.is-terminal .header::after{content:"";position:absolute;pointer-events:none;inset:0;border-bottom:var(--ec-brdWd) solid var(--ec-frm-trmTtbBrdBtmCol)}.expressive-code .frame pre{background:var(--code-background)}.expressive-code .copy{display:flex;gap:0.25rem;flex-direction:row;position:absolute;inset-block-start:calc(var(--ec-brdWd) + var(--button-spacing));inset-inline-end:calc(var(--ec-brdWd) + var(--ec-uiPadInl) / 2);direction:ltr;unicode-bidi:isolate}.expressive-code .copy button{position:relative;align-self:flex-end;margin:0;padding:0;border:none;border-radius:0.2rem;z-index:1;cursor:pointer;transition-property:opacity, background, border-color;transition-duration:0.2s;transition-timing-function:cubic-bezier(0.25, 0.46, 0.45, 0.94);width:2.5rem;height:2.5rem;background:var(--code-background);opacity:0.75}.expressive-code .copy button div{position:absolute;inset:0;border-radius:inherit;background:var(--ec-frm-inlBtnBg);opacity:var(--ec-frm-inlBtnBgIdleOpa);transition-property:inherit;transition-duration:inherit;transition-timing-function:inherit}.expressive-code .copy button::before{content:"";position:absolute;pointer-events:none;inset:0;border-radius:inherit;border:var(--ec-brdWd) solid var(--ec-frm-inlBtnBrd);opacity:var(--ec-frm-inlBtnBrdOpa)}.expressive-code .copy button::after{content:"";position:absolute;pointer-events:none;inset:0;background-color:var(--ec-frm-inlBtnFg);-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.75'%3E%3Cpath d='M3 19a2 2 0 0 1-1-2V2a2 2 0 0 1 1-1h13a2 2 0 0 1 2 1'/%3E%3Crect x='6' y='5' width='16' height='18' rx='1.5' ry='1.5'/%3E%3C/svg%3E");-webkit-mask-repeat:no-repeat;mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.75'%3E%3Cpath d='M3 19a2 2 0 0 1-1-2V2a2 2 0 0 1 1-1h13a2 2 0 0 1 2 1'/%3E%3Crect x='6' y='5' width='16' height='18' rx='1.5' ry='1.5'/%3E%3C/svg%3E");mask-repeat:no-repeat;margin:0.475rem;line-height:0}.expressive-code .copy button:focus::after,.expressive-code .copy button:active::after{display:inline-block;content:"";-webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22' viewBox='0 0 24 24' stroke-width='1.25' stroke='black' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'%3E%3C/path%3E%3Cpath d='M5 12l5 5l10 -10'%3E%3C/path%3E%3C/svg%3E") no-repeat 50% 50%;mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22' viewBox='0 0 24 24' stroke-width='1.25' stroke='black' fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'%3E%3C/path%3E%3Cpath d='M5 12l5 5l10 -10'%3E%3C/path%3E%3C/svg%3E") no-repeat 50% 50%;-webkit-mask-size:cover;mask-size:cover;margin:0.2375rem}.expressive-code .copy button:hover,.expressive-code .copy button:focus:focus-visible{opacity:1}.expressive-code .copy button:hover div,.expressive-code .copy button:focus:focus-visible div{opacity:var(--ec-frm-inlBtnBgHoverOrFocusOpa)}.expressive-code .copy button:active{opacity:1}.expressive-code .copy button:active div{opacity:var(--ec-frm-inlBtnBgActOpa)}.expressive-code .copy .feedback{--tooltip-arrow-size: 0.35rem;--tooltip-bg: var(--ec-frm-tooltipSuccessBg);color:var(--ec-frm-tooltipSuccessFg);pointer-events:none;-moz-user-select:none;user-select:none;-webkit-user-select:none;position:relative;align-self:center;background-color:var(--tooltip-bg);z-index:99;padding:0.125rem 0.75rem;border-radius:0.2rem;margin-inline-end:var(--tooltip-arrow-size);opacity:0;transition-property:opacity, transform;transition-duration:0.2s;transition-timing-function:ease-in-out;transform:translate3d(0, 0.25rem, 0)}.expressive-code .copy .feedback::after{content:"";position:absolute;pointer-events:none;top:calc(50% - var(--tooltip-arrow-size));inset-inline-end:calc(-2 * (var(--tooltip-arrow-size) - 0.5px));border:var(--tooltip-arrow-size) solid transparent;border-inline-start-color:var(--tooltip-bg)}.expressive-code .copy .feedback.show{opacity:1;transform:translate3d(0, 0, 0)}@media (hover: hover){.expressive-code .copy button{opacity:0;width:2rem;height:2rem}.expressive-code .frame:hover .copy button:not(:hover),.expressive-code .frame:focus-within :focus-visible~.copy button:not(:hover),.expressive-code .frame .copy .feedback.show~button:not(:hover){opacity:0.75}}:root{--ec-brdRad: 0px;--ec-brdWd: 1px;--ec-brdCol: color-mix(in srgb, var(--sl-color-gray-5), transparent 25%);--ec-codeFontFml: var(--__sl-font-mono);--ec-codeFontSize: var(--sl-text-code);--ec-codeFontWg: 400;--ec-codeLineHt: var(--sl-line-height);--ec-codePadBlk: 0;--ec-codePadInl: 1rem;--ec-codeBg: #011627;--ec-codeFg: #d6deeb;--ec-codeSelBg: #1d3b53;--ec-uiFontFml: var(--__sl-font);--ec-uiFontSize: 0.9rem;--ec-uiFontWg: 400;--ec-uiLineHt: 1.65;--ec-uiPadBlk: 0.25rem;--ec-uiPadInl: 1rem;--ec-uiSelBg: #234d708c;--ec-uiSelFg: #ffffff;--ec-focusBrd: #122d42;--ec-sbThumbCol: #ffffff17;--ec-sbThumbHoverCol: #ffffff49;--ec-tm-lineMarkerAccentMarg: 0rem;--ec-tm-lineMarkerAccentWd: 0.15rem;--ec-tm-lineDiffIndMargLeft: 0.25rem;--ec-tm-inlMarkerBrdWd: 1.5px;--ec-tm-inlMarkerBrdRad: 0.2rem;--ec-tm-inlMarkerPad: 0.15rem;--ec-tm-insDiffIndContent: "+";--ec-tm-delDiffIndContent: "-";--ec-tm-markBg: #ffffff17;--ec-tm-markBrdCol: #ffffff40;--ec-tm-insBg: #1e571599;--ec-tm-insBrdCol: #487f3bd0;--ec-tm-insDiffIndCol: #79b169d0;--ec-tm-delBg: #862d2799;--ec-tm-delBrdCol: #b4554bd0;--ec-tm-delDiffIndCol: #ed8779d0;--ec-frm-shdCol: #011627;--ec-frm-frameBoxShdCssVal: none;--ec-frm-edActTabBg: var(--sl-color-gray-6);--ec-frm-edActTabFg: var(--sl-color-text);--ec-frm-edActTabBrdCol: transparent;--ec-frm-edActTabIndHt: 1px;--ec-frm-edActTabIndTopCol: var(--sl-color-accent-high);--ec-frm-edActTabIndBtmCol: transparent;--ec-frm-edTabsMargInlStart: 0;--ec-frm-edTabsMargBlkStart: 0;--ec-frm-edTabBrdRad: 0px;--ec-frm-edTabBarBg: var(--sl-color-black);--ec-frm-edTabBarBrdCol: color-mix(in srgb, var(--sl-color-gray-5), transparent 25%);--ec-frm-edTabBarBrdBtmCol: color-mix(in srgb, var(--sl-color-gray-5), transparent 25%);--ec-frm-edBg: var(--sl-color-gray-6);--ec-frm-trmTtbDotsFg: color-mix(in srgb, var(--sl-color-gray-5), transparent 25%);--ec-frm-trmTtbDotsOpa: 0.75;--ec-frm-trmTtbBg: var(--sl-color-black);--ec-frm-trmTtbFg: var(--sl-color-text);--ec-frm-trmTtbBrdBtmCol: color-mix(in srgb, var(--sl-color-gray-5), transparent 25%);--ec-frm-trmBg: var(--sl-color-gray-6);--ec-frm-inlBtnFg: var(--sl-color-text);--ec-frm-inlBtnBg: var(--sl-color-text);--ec-frm-inlBtnBgIdleOpa: 0;--ec-frm-inlBtnBgHoverOrFocusOpa: 0.2;--ec-frm-inlBtnBgActOpa: 0.3;--ec-frm-inlBtnBrd: var(--sl-color-text);--ec-frm-inlBtnBrdOpa: 0.4;--ec-frm-tooltipSuccessBg: #158744;--ec-frm-tooltipSuccessFg: white}.expressive-code .ec-line span[style^="--"]:not([class]){color:var(0, inherit);font-style:var(0fs, inherit);font-weight:var(0fw, inherit);-webkit-text-decoration:var(0td, inherit);text-decoration:var(0td, inherit)}@media (prefers-color-scheme: light){:root:not([data-bs-theme="dark"]){--ec-codeBg: #fbfbfb;--ec-codeFg: #403f53;--ec-codeSelBg: #e0e0e0;--ec-uiSelBg: #d3e8f8;--ec-uiSelFg: #403f53;--ec-focusBrd: #93a1a1;--ec-sbThumbCol: #0000001a;--ec-sbThumbHoverCol: #0000005c;--ec-tm-markBg: #0000001a;--ec-tm-markBrdCol: #00000055;--ec-tm-insBg: #8ec77d99;--ec-tm-insDiffIndCol: #336a28d0;--ec-tm-delBg: #ff9c8e99;--ec-tm-delDiffIndCol: #9d4138d0;--ec-frm-shdCol: #d9d9d9;--ec-frm-edActTabBg: var(--sl-color-gray-7);--ec-frm-edActTabIndTopCol: #5d2f86;--ec-frm-edTabBarBg: var(--sl-color-gray-6);--ec-frm-edBg: var(--sl-color-gray-7);--ec-frm-trmTtbBg: var(--sl-color-gray-6);--ec-frm-trmBg: var(--sl-color-gray-7);--ec-frm-tooltipSuccessBg: #078662}:root:not([data-bs-theme="dark"]) .expressive-code .ec-line span[style^="--"]:not([class]){color:var(1, inherit);font-style:var(1fs, inherit);font-weight:var(1fw, inherit);-webkit-text-decoration:var(1td, inherit);text-decoration:var(1td, inherit)}}:root[data-bs-theme="light"] .expressive-code,.expressive-code[data-bs-theme="light"]{--ec-codeBg: #fbfbfb;--ec-codeFg: #403f53;--ec-codeSelBg: #e0e0e0;--ec-uiSelBg: #d3e8f8;--ec-uiSelFg: #403f53;--ec-focusBrd: #93a1a1;--ec-sbThumbCol: #0000001a;--ec-sbThumbHoverCol: #0000005c;--ec-tm-markBg: #0000001a;--ec-tm-markBrdCol: #00000055;--ec-tm-insBg: #8ec77d99;--ec-tm-insDiffIndCol: #336a28d0;--ec-tm-delBg: #ff9c8e99;--ec-tm-delDiffIndCol: #9d4138d0;--ec-frm-shdCol: #d9d9d9;--ec-frm-edActTabBg: var(--sl-color-gray-7);--ec-frm-edActTabIndTopCol: #5d2f86;--ec-frm-edTabBarBg: var(--sl-color-gray-6);--ec-frm-edBg: var(--sl-color-gray-7);--ec-frm-trmTtbBg: var(--sl-color-gray-6);--ec-frm-trmBg: var(--sl-color-gray-7);--ec-frm-tooltipSuccessBg: #078662}:root[data-bs-theme="light"] .expressive-code .ec-line span[style^="--"]:not([class]),.expressive-code[data-bs-theme="light"] .ec-line span[style^="--"]:not([class]){color:var(1, inherit);font-style:var(1fs, inherit);font-weight:var(1fw, inherit);-webkit-text-decoration:var(1td, inherit);text-decoration:var(1td, inherit)}pre,code,kbd,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:.875rem}code:not(:where(.not-content *)){background-color:var(--sl-color-gray-6);margin-block:-0.125rem;padding:0.125rem 0.375rem;color:inherit}[data-bs-theme="dark"] code:not(:where(.not-content *)){background-color:var(--sl-color-gray-5)}.math-block{display:block;margin:2rem 0;overflow-x:auto}.math-inline{display:inline}[data-bs-theme="dark"] .math-inline img,[data-bs-theme="dark"] .math-block img{filter:invert(1)}img.diagram{height:auto;width:100%;margin:1rem 0 2rem}img.diagram-kroki-mermaid{background:#fff}.highlight>pre{padding:0.875rem 1rem}.highlight div{padding:0}.highlight>.chroma{overflow-x:auto;border:1px solid color-mix(in srgb, var(--sl-color-gray-5), transparent 25%)}.chroma .ln{padding:0 0.5rem 0 0}.chroma .hl{border-inline-start:0.15rem solid #0005;margin-left:-1rem;margin-right:-1rem;padding-left:1rem;padding-right:1rem}.chroma .hl .ln{margin-left:-0.15rem}.highlight .chroma .lntable .lnt,.highlight .chroma .lntable .hl{display:flex}.chroma .lntd:first-child{padding:0}.chroma .lntd:first-child .lnt{padding-left:1rem}.chroma .lntd:nth-child(2){padding:0}.highlight .chroma .lntable .lntd+.lntd{width:100%}[data-bs-theme="dark"] .chroma .ln{padding:0 0.5em 0 0}.chroma .lntd pre{padding:1rem 0;margin-bottom:0}.highlight>.chroma::-webkit-scrollbar,.highlight>.chroma::-webkit-scrollbar-track{background-color:inherit;border-radius:1px;border-top-left-radius:0;border-top-right-radius:0}.highlight>.chroma::-webkit-scrollbar-thumb{background-color:#dddee0;border:4px solid transparent;background-clip:content-box;border-radius:10px}.highlight>.chroma::-webkit-scrollbar-thumb:hover{background-color:#9d9e9f}[data-bs-theme="dark"] .highlight>.chroma::-webkit-scrollbar-thumb{background-color:#ffffff17}[data-bs-theme="dark"] .highlight>.chroma::-webkit-scrollbar-thumb:hover{background-color:#ffffff49}[data-bs-theme="dark"] .highlight>.chroma{border:1px solid color-mix(in srgb, var(--sl-color-gray-5), transparent 25%)}[data-bs-theme="dark"] .chroma .hl{border-inline-start:0.15rem solid #ffffff40;margin-left:-1rem;margin-right:-1rem;padding-left:1rem;padding-right:1rem}[data-bs-theme="dark"] .chroma .hl .ln{margin-left:-0.15rem}blockquote{margin-bottom:1rem;font-size:1.25rem;border-left:3px solid #dee2e6;padding-left:1rem}details{display:block;position:relative;border:1px solid #e9ecef;border-radius:0.25rem;padding:0.5rem 1rem 0;margin:0.5rem 0}summary{list-style:none;display:inline-block;width:calc(100% + 2rem);margin:-0.5rem -1rem 0;padding:0.5rem 1rem}summary::-webkit-details-marker{display:none}summary:hover{background:#f8f9fa}details summary::after{display:inline-block;content:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='rgba%2829, 45, 53, 0.75%29' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M5 14l6-6-6-6'/%3e%3c/svg%3e");transition:transform 0.35s ease;transform-origin:center center;position:absolute;right:1rem}details[open]>summary::after{transform:rotate(90deg)}details[open]{padding:0.5rem 1rem}details[open]>summary{border-bottom:1px solid #dee2e6;margin-bottom:0.5rem}details h2,details .h2,details h3,details .h3,details h4,details .h4{margin:1rem 0 0.5rem}details p:last-child{margin-bottom:0}details ul,details ol{margin-bottom:0}details pre{margin:0 0 1rem}.search-form label{font-weight:normal}img{max-width:100%;height:auto}img[data-sizes="auto"]{display:block}img{font-size:0}figcaption{font-size:1rem;margin-top:0.5rem;font-style:italic}.blur-up{filter:blur(5px);transition:filter 400ms}.blur-up.lazyloaded{filter:unset}.search-form .form-control:focus{border:2px solid #4f46e5}[data-bs-theme="dark"] .search-form .form-control:focus{border:2px solid #b3c7ff}[data-bs-theme="dark"] .search-form .btn-link{color:#b3c7ff}.search-form .btn-link,.modal-body p.message,.modal-footer{font-size:.875rem}.modal-body::-webkit-scrollbar{width:0.25rem}.modal-body::-webkit-scrollbar-track{background-color:#f1f1f1}.modal-body::-webkit-scrollbar-thumb{background-color:#c1c1c1}[data-bs-theme="dark"] .modal-body::-webkit-scrollbar-track{background-color:#424242}[data-bs-theme="dark"] .modal-body::-webkit-scrollbar-thumb{background-color:#686868}@media (min-width: 768px){#searchModal .modal-dialog{max-height:40rem}}.search-result h2,.search-result .h2{margin-top:0}.search-result a:focus{outline:0 none}.search-result .content{margin-top:0.5rem;padding-top:0 !important;padding-bottom:0 !important}.search-result .card .content p{margin-bottom:0}.search-result .card .content a{position:relative;z-index:1}.search-result:hover .card,.search-result.selected .card{background-color:#4f46e5;color:#fff}.search-result:hover .card .content a,.search-result.selected .card .content a{color:#fff;text-decoration:underline}[data-bs-theme="dark"] .search-result:hover .card,[data-bs-theme="dark"] .search-result.selected .card{background-color:#b3c7ff;color:#23262f}[data-bs-theme="dark"] .search-result:hover .card .content a,[data-bs-theme="dark"] .search-result.selected .card .content a{color:#23262f;text-decoration:underline}[data-bs-theme="dark"] .search-result:hover .card h2,[data-bs-theme="dark"] .search-result:hover .card .h2,[data-bs-theme="dark"] .search-result.selected .card h2,[data-bs-theme="dark"] .search-result.selected .card .h2{color:#17181c}.search-result .submitted{font-size:.875rem;margin-top:0.5rem}.section-nav{padding-top:2rem}.section-nav details{border:0;padding:0;margin:0.5rem 0}.section-nav details[open]{padding:0}.section-nav summary{width:100%;padding:0;margin:0;font-weight:700}.section-nav summary:hover{background:none}.section-nav details[open]>summary{border-bottom:0;margin-bottom:0}.section-nav ul.list-nested details{padding-left:1rem;margin-top:0.5rem}.section-nav ul.list-nested li{margin:0}.section-nav a{display:block;margin:0.5rem 0;color:#1d2d35;font-size:1rem;text-decoration:none}.section-nav a:hover,.section-nav a:active{color:#4f46e5}.section-nav li.active a{color:#4f46e5;font-weight:500}.section-nav ul.list-nested li a{padding-left:1rem}.section-nav ul.list-nested{border-left:1px solid #e9ecef}[data-bs-theme="dark"] .section-nav ul.list-nested{border-left:1px solid #23262f}[data-bs-theme="dark"] .section-nav a{color:#c1c3c8}[data-bs-theme="dark"] .section-nav a:hover,[data-bs-theme="dark"] .section-nav a:active{color:var(--sl-color-text-accent)}[data-bs-theme="dark"] .section-nav li.active a{color:var(--sl-color-text-accent);font-weight:500}[data-bs-theme="dark"] .section-nav summary{color:#fff}table{margin:3rem 0}.nav-tabs{border-bottom:0.0625rem solid #d8dee4;margin-bottom:1rem}.nav-tabs .nav-link{margin-bottom:-0.0625rem !important;background:none;border:0;border-top-left-radius:0;border-top-right-radius:0;color:inherit}.nav-tabs .nav-link:hover,.nav-tabs .nav-link:focus{isolation:isolate;border-color:transparent;color:var(--bs-emphasis-color)}.nav-tabs .nav-link.active,.nav-tabs .nav-item.show .nav-link{background-color:transparent;border-color:transparent;border-bottom:0.125rem solid #4f46e5}[data-bs-theme="dark"] .nav-tabs{border-bottom:0.0625rem solid #343a40}[data-bs-theme="dark"] .nav-tabs .nav-link.active,[data-bs-theme="dark"] .nav-tabs .nav-item.show .nav-link{border-bottom:0.125rem solid #b3c7ff}.footer{border-top:1px solid #e9ecef;padding-top:1.125rem;padding-bottom:1.125rem}.footer ul{margin-bottom:0}.footer li{font-size:.875rem;margin-bottom:0}.footer .list-inline-item:not(:last-child){margin-right:1rem}@media (max-width: 991.98px){.footer .col-lg-8{margin-top:0.25rem;margin-bottom:0.25rem}}@media (min-width: 768px){.footer li{font-size:1rem}}.navbar-brand{font-weight:700}.navbar-brand svg{margin-right:0.25rem}[data-bs-theme="dark"] .navbar-brand{color:inherit}.navbar{z-index:1000;background-color:rgba(255,255,255,0.95);border-bottom:1px solid #e9ecef}@media (min-width: 992px){.navbar{z-index:1025}}@media (min-width: 768px){.navbar-brand{font-size:1.375rem}}.nav-item{margin-left:0}@media (max-width: 991.98px){.navbar-nav .nav-link{font-weight:400}}@media (min-width: 768px){.nav-item{margin-left:0.5rem}}@media (max-width: 575.98px){.navbar .offcanvas.offcanvas-start,.navbar .offcanvas.offcanvas-end{width:80vw}}.offcanvas-header{border-bottom:1px solid #dee2e6;padding-top:1.0625rem;padding-bottom:0.8125rem}h5.offcanvas-title,.offcanvas-title.h5{margin:0;color:inherit}.offcanvas .nav-link{color:#1d2d35}.offcanvas .nav-link:hover,.offcanvas .nav-link:focus{color:#4f46e5}.offcanvas .nav-link.active{color:#4f46e5}.home .navbar{border-bottom:0}@media (min-width: 992px){.navbar-brand{margin-right:0.75rem !important}}.social-link{padding-right:0.375rem;padding-left:0.375rem}@media (max-width: 991.98px){#buttonColorMode{margin:0.5rem 0}#socialMenu{margin:0.5rem 0 0.5rem -0.25rem}.navbar-nav{margin-top:1rem}.nav-item .nav-link{font-weight:400;font-size:1.125rem}}.modal-backdrop,.offcanvas-backdrop{visibility:hidden;background:rgba(23,24,28,0.5);opacity:0}[data-bs-theme="dark"] .modal-backdrop,[data-bs-theme="dark"] .offcanvas-backdrop{visibility:hidden;background:rgba(23,24,28,0.5);opacity:0}.modal-backdrop.show,.offcanvas-backdrop.show{visibility:visible;opacity:1;-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px)}.showing,.hiding{transition:none;display:none}.navbar>.container,.navbar>.container-fluid,.navbar>.container-lg{padding-right:0.75rem}.docs-content>h2[id]::before,.docs-content>[id].h2::before,.docs-content>h3[id]::before,.docs-content>[id].h3::before,.docs-content>h4[id]::before,.docs-content>[id].h4::before{display:block;height:6rem;margin-top:-6rem;content:""}.docs-content ul,.docs-content ol{margin-bottom:1rem}.anchor{visibility:hidden;margin-left:0.375rem}.edit-page a{color:var(--sl-color-gray-3)}h1:hover a,.h1:hover a,h2:hover a,.h2:hover a,h3:hover a,.h3:hover a,h4:hover a,.h4:hover a{visibility:visible;text-decoration:none}.card-list{margin-top:2.25rem}.page-footer-meta{margin-top:2rem;margin-bottom:2rem}.edit-page{font-size:.875rem;margin-top:0.25rem;margin-bottom:0.25rem}@media (min-width: 768px){.edit-page{font-size:1rem;margin-top:0.75rem;margin-bottom:0.25rem}}.edit-page a:hover{color:var(--sl-color-gray-4);text-decoration:none}[data-bs-theme="dark"] .edit-page a:hover{color:var(--sl-color-gray-2)}.edit-page svg{margin-right:0.25rem;margin-bottom:0.25rem}p.meta{margin-top:0.5rem;font-size:1rem}.toc-mobile{margin-top:2rem;margin-bottom:2rem}.page-link:hover{text-decoration:none}ul li{margin:0.25rem 0}.page-nav .card .icon-tabler-arrow-left{margin-right:0.75rem}.page-nav .card .icon-tabler-arrow-right{margin-left:0.75rem}.page-nav .card:hover{border:1px solid #d9d9d9}[data-bs-theme="dark"] .page-nav .card{border:1px solid #353841}[data-bs-theme="dark"] .page-nav .card:hover{border:1px solid #888c96}.home .card,.contributors.list .card,.categories.list .card,.tags.list .card{margin-top:2rem;margin-bottom:2rem;transition:transform 0.3s}.home .content .card:hover,.contributors.list .content .card:hover,.categories.list .content .card:hover,.tags.list .content .card:hover{transform:scale(1.025)}.home .content .card-body,.contributors.list .content .card-body,.categories.list .content .card-body,.tags.list .content .card-body{padding:0 2rem 1rem}.page-item:first-child,.page-item:last-child,.page-item.disabled{display:none}.page-item a{margin-left:0.5rem;margin-right:0.5rem;padding-left:0.875rem;padding-right:0.875rem}.docs-links,.docs-toc{scrollbar-width:thin;scrollbar-color:#fff #fff}.docs-links::-webkit-scrollbar,.docs-toc::-webkit-scrollbar{width:5px}.docs-links::-webkit-scrollbar-track,.docs-toc::-webkit-scrollbar-track{background:#fff}.docs-links::-webkit-scrollbar-thumb,.docs-toc::-webkit-scrollbar-thumb{background:#fff}.docs-links:hover,.docs-toc:hover{scrollbar-width:thin;scrollbar-color:#e9ecef #fff}.docs-links:hover::-webkit-scrollbar-thumb,.docs-toc:hover::-webkit-scrollbar-thumb{background:#e9ecef}.docs-links::-webkit-scrollbar-thumb:hover,.docs-toc::-webkit-scrollbar-thumb:hover{background:#e9ecef}.docs-links h3,.docs-links .h3,.page-links h3,.page-links .h3{font-size:1.125rem;margin:1.25rem 0 0.5rem;padding:1.5rem 0 0}@media (min-width: 992px){.docs-links h3,.docs-links .h3,.page-links h3,.page-links .h3{margin:1.125rem 1.5rem 0.75rem 0;padding:1.375rem 0 0}}.docs-links h3:not(:first-child),.docs-links .h3:not(:first-child){border-top:1px solid #e9ecef}.page-links li{margin-top:0.375rem;padding-top:0.375rem}.page-links li ul li{border-top:none;padding-left:1rem;margin-top:0.125rem;padding-top:0.125rem}.page-links li:not(:first-child){border-top:1px dashed #e9ecef}.page-links a{color:#1d2d35;display:block;padding:0.125rem 0;font-size:.9375rem;text-decoration:none}.page-links a:hover,.page-links a.active{text-decoration:none;color:#4f46e5}.nav-link.active{font-weight:500}*{-webkit-font-smoothing:antialiased}h1,.h1,h2,.h2,h3,.h3,h4,.h4,h5,.h5,.navbar-brand{font-family:Quicksand, sans-serif;font-weight:700}[data-bs-theme="dark"] .only-light{display:none}[data-bs-theme="light"] .only-dark{display:none}.homepage-features .row{max-width:1400px;margin:0 auto}@media (min-width: 992px){.text-center.text-lg-start{padding-right:2rem}}.learn-container{max-width:1200px;margin:0 auto;padding:3rem 1rem}.learn-header{text-align:center;margin-bottom:4rem}.learn-main-title{font-size:3rem;font-weight:700;margin-bottom:1rem;color:var(--bs-body-color)}.learn-subtitle{font-size:1.25rem;color:var(--bs-body-color-secondary);max-width:600px;margin:0 auto;line-height:1.6}.learn-section{margin-bottom:4rem}.learn-section:last-child{margin-bottom:0}.learn-section-title{text-align:center;font-size:2rem;font-weight:600;margin-bottom:3rem;color:var(--bs-body-color)}.learn-row{display:grid;grid-template-columns:repeat(3, 1fr);gap:2rem;justify-items:center}.learn-card{display:flex;flex-direction:column;padding:2rem;border-radius:0.75rem;transition:all 0.3s ease;text-decoration:none;background:var(--bs-body-bg);border:1px solid var(--bs-border-color);box-shadow:0 2px 4px rgba(0,0,0,0.05);min-height:280px;width:100%;max-width:350px;text-align:center;color:inherit;cursor:pointer;position:relative;overflow:hidden}.learn-card:hover{transform:translateY(-2px);box-shadow:0 8px 16px rgba(0,0,0,0.1);text-decoration:none;color:inherit;border-color:var(--bs-primary)}.learn-card:focus{outline:2px solid var(--bs-primary);outline-offset:2px}.learn-card-icon{margin:0 auto 0;color:var(--bs-primary);display:flex;justify-content:center;align-items:center;width:100px;height:100px}.learn-card h3,.learn-card .h3{font-size:1.25rem;font-weight:600;margin-bottom:0.5rem;color:var(--bs-body-color);margin-top:0}.learn-card-secondary{display:block;font-size:0.9rem;font-weight:600;color:var(--bs-gray-600);margin-bottom:1rem}.learn-card p{font-size:0.95rem;line-height:1.6;margin-bottom:2rem;flex-grow:1;color:var(--bs-body-color-secondary)}[data-bs-theme="dark"] .learn-card{background:var(--bs-body-bg);border-color:var(--bs-border-color)}[data-bs-theme="dark"] .learn-card:hover{border-color:var(--bs-primary)}@media (max-width: 767.98px){.learn-container{padding:2rem 1rem}.learn-header{margin-bottom:3rem}.learn-main-title{font-size:2.5rem}.learn-subtitle{font-size:1.1rem}.learn-section-title{font-size:1.5rem;margin-bottom:2rem}.learn-row{grid-template-columns:1fr;gap:1.5rem}.learn-card{padding:1.5rem;min-height:240px;max-width:none}}@media (min-width: 768px) and (max-width: 1199.98px){.learn-row{grid-template-columns:repeat(2, 1fr)}.learn-section:first-child .learn-row{grid-template-columns:repeat(2, 1fr)}.learn-section:first-child .learn-row .learn-card:nth-child(3){grid-column:1 / -1;justify-self:center}}.quickstart-container{max-width:800px;margin:0 auto;padding:3rem 1rem}.quickstart-header{text-align:center;margin-bottom:3rem}.quickstart-main-title{font-size:3rem;font-weight:700;margin-bottom:1rem;color:var(--bs-body-color)}.quickstart-subtitle{font-size:1.25rem;color:var(--bs-body-color-secondary);max-width:600px;margin:0 auto;line-height:1.6}.quickstart-content{font-size:1.1rem;line-height:1.8}body.quickstart .event-driven-banner{display:none}@media (max-width: 767.98px){.quickstart-container{padding:2rem 1rem}.quickstart-header{margin-bottom:2rem}.quickstart-main-title{font-size:2.5rem}.quickstart-subtitle{font-size:1.1rem}}@media (max-width: 991.98px){.display-5{font-size:2rem}.text-center.text-lg-start{text-align:center !important;padding-right:0}}.pubsub-logos-container{padding:2rem 0}.pubsub-logos-grid{display:flex;flex-wrap:wrap;gap:2rem;max-width:900px;margin:0 auto;align-items:center;justify-content:center}.pubsub-logo-item{display:flex;flex-direction:column;align-items:center;text-align:center;transition:all 0.3s ease;padding:1rem;border-radius:0.75rem;text-decoration:none;color:inherit;flex:0 0 120px}.pubsub-logo-item img{width:60px;height:60px;margin-bottom:0.75rem;transition:all 0.3s ease}.pubsub-logo-item span{font-size:0.9rem;font-weight:600;color:var(--bs-body-color-secondary);transition:all 0.3s ease}.pubsub-logo-item:hover{background:rgba(var(--bs-primary-rgb), 0.05);text-decoration:none;color:inherit}.pubsub-logo-item:hover span{color:var(--bs-primary)}.pubsub-logo-item:focus{outline:2px solid var(--bs-primary);outline-offset:2px;text-decoration:none;color:inherit}[data-bs-theme="dark"] .pubsub-logo-item:hover{background:rgba(var(--bs-primary-rgb), 0.1)}@media (max-width: 767.98px){.pubsub-logos-grid{grid-template-columns:repeat(3, 1fr);gap:1.5rem;max-width:100%}.pubsub-logo-item{padding:0.75rem}.pubsub-logo-item img{width:50px;height:50px;margin-bottom:0.5rem}.pubsub-logo-item span{font-size:0.8rem}}@media (max-width: 575.98px){.pubsub-logos-grid{grid-template-columns:repeat(2, 1fr);gap:1rem}.pubsub-logo-item img{width:45px;height:45px}.pubsub-logo-item span{font-size:0.75rem}}.homepage-code-block .frame{border-radius:12px !important;box-shadow:0 4px 12px rgba(0,0,0,0.1),0 2px 4px rgba(0,0,0,0.06) !important;border:1px solid var(--bs-border-color) !important;overflow:hidden;overflow-x:auto}.homepage-code-block .frame *{border-radius:inherit !important}.homepage-code-block .frame pre,.homepage-code-block .frame code,.homepage-code-block .frame .expressive-code-block{border-radius:12px !important;border:none !important}.homepage-code-block .frame .highlight{border-radius:12px !important;border:none !important}[data-bs-theme="light"] .homepage-code-block .frame pre,[data-bs-theme="light"] .homepage-code-block .frame code,[data-bs-theme="light"] .homepage-code-block .frame .expressive-code-block{background:white !important}@media (max-width: 767.98px){.homepage-code-block{margin-left:-45px;margin-right:-45px}}[data-bs-theme="dark"] .homepage-code-block .frame{box-shadow:0 4px 12px rgba(0,0,0,0.3),0 2px 4px rgba(0,0,0,0.2) !important;border:1px solid var(--bs-border-color) !important}.gradient-text-1{background:linear-gradient(to right, #6366f1, #8b5cf6, #3b82f6);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;color:transparent}.homepage-section-gradient{position:relative}.homepage-section-gradient::before{content:'';position:absolute;top:0;left:50%;transform:translateX(-50%);width:100vw;height:100%;border-top:1px solid rgba(0,0,0,0.08);pointer-events:none;z-index:-1}.homepage-section-gradient::before{background:linear-gradient(180deg, rgba(79,70,229,0.03) 0%, transparent 100%)}[data-bs-theme="dark"] .homepage-section-gradient::before{background:linear-gradient(180deg, rgba(79,70,229,0.06) 0%, transparent 100%);border-top:1px solid rgba(255,255,255,0.08)}[data-bs-theme="dark"] .bg,[data-bs-theme="light"] .bg{color:#c9c9c9;background-color:#282c34}[data-bs-theme="dark"] .chroma,[data-bs-theme="light"] .chroma{color:#c9c9c9;background-color:#282c34}[data-bs-theme="dark"] .chroma .err,[data-bs-theme="light"] .chroma .err{color:#cf5967}[data-bs-theme="dark"] .chroma .lnlinks,[data-bs-theme="light"] .chroma .lnlinks{outline:none;text-decoration:none;color:inherit}[data-bs-theme="dark"] .chroma .lntd,[data-bs-theme="light"] .chroma .lntd{vertical-align:top;padding:0;margin:0;border:0}[data-bs-theme="dark"] .chroma .lntable,[data-bs-theme="light"] .chroma .lntable{border-spacing:0;padding:0;margin:0;border:0}[data-bs-theme="dark"] .chroma .hl,[data-bs-theme="light"] .chroma .hl{background-color:#3d4148}[data-bs-theme="dark"] .chroma .lnt,[data-bs-theme="light"] .chroma .lnt{white-space:pre;-webkit-user-select:none;-moz-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f}[data-bs-theme="dark"] .chroma .ln,[data-bs-theme="light"] .chroma .ln{white-space:pre;-webkit-user-select:none;-moz-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f}[data-bs-theme="dark"] .chroma .line,[data-bs-theme="light"] .chroma .line{display:flex}[data-bs-theme="dark"] .chroma .k,[data-bs-theme="light"] .chroma .k{color:#7fbaf5}[data-bs-theme="dark"] .chroma .kc,[data-bs-theme="light"] .chroma .kc{color:#cf5967}[data-bs-theme="dark"] .chroma .kd,[data-bs-theme="light"] .chroma .kd{color:#7fbaf5}[data-bs-theme="dark"] .chroma .kn,[data-bs-theme="light"] .chroma .kn{color:#bc74c4}[data-bs-theme="dark"] .chroma .kp,[data-bs-theme="light"] .chroma .kp{color:#bc74c4}[data-bs-theme="dark"] .chroma .kr,[data-bs-theme="light"] .chroma .kr{color:#7fbaf5}[data-bs-theme="dark"] .chroma .kt,[data-bs-theme="light"] .chroma .kt{color:#57c7ff;font-weight:bold}[data-bs-theme="dark"] .chroma .na,[data-bs-theme="light"] .chroma .na{color:#bc74c4}[data-bs-theme="dark"] .chroma .nb,[data-bs-theme="light"] .chroma .nb{color:#7fbaf5}[data-bs-theme="dark"] .chroma .bp,[data-bs-theme="light"] .chroma .bp{color:#7fbaf5}[data-bs-theme="dark"] .chroma .nc,[data-bs-theme="light"] .chroma .nc{color:#ecbe7b}[data-bs-theme="dark"] .chroma .no,[data-bs-theme="light"] .chroma .no{color:#ecbe7b}[data-bs-theme="dark"] .chroma .nd,[data-bs-theme="light"] .chroma .nd{color:#ecbe7b}[data-bs-theme="dark"] .chroma .ne,[data-bs-theme="light"] .chroma .ne{color:#cf5967}[data-bs-theme="dark"] .chroma .nf,[data-bs-theme="light"] .chroma .nf{color:#57c7ff}[data-bs-theme="dark"] .chroma .nl,[data-bs-theme="light"] .chroma .nl{color:#cf5967}[data-bs-theme="dark"] .chroma .nt,[data-bs-theme="light"] .chroma .nt{color:#bc74c4}[data-bs-theme="dark"] .chroma .nv,[data-bs-theme="light"] .chroma .nv{color:#bc74c4;font-style:italic}[data-bs-theme="dark"] .chroma .vc,[data-bs-theme="light"] .chroma .vc{color:#57c7ff;font-weight:bold}[data-bs-theme="dark"] .chroma .vg,[data-bs-theme="light"] .chroma .vg{color:#ecbe7b}[data-bs-theme="dark"] .chroma .vi,[data-bs-theme="light"] .chroma .vi{color:#57c7ff}[data-bs-theme="dark"] .chroma .ld,[data-bs-theme="light"] .chroma .ld{color:#57c7ff}[data-bs-theme="dark"] .chroma .s,[data-bs-theme="light"] .chroma .s{color:#82cc6a}[data-bs-theme="dark"] .chroma .sa,[data-bs-theme="light"] .chroma .sa{color:#82cc6a}[data-bs-theme="dark"] .chroma .sb,[data-bs-theme="light"] .chroma .sb{color:#57c7ff}[data-bs-theme="dark"] .chroma .sc,[data-bs-theme="light"] .chroma .sc{color:#57c7ff}[data-bs-theme="dark"] .chroma .dl,[data-bs-theme="light"] .chroma .dl{color:#82cc6a}[data-bs-theme="dark"] .chroma .sd,[data-bs-theme="light"] .chroma .sd{color:#82cc6a}[data-bs-theme="dark"] .chroma .s2,[data-bs-theme="light"] .chroma .s2{color:#82cc6a}[data-bs-theme="dark"] .chroma .se,[data-bs-theme="light"] .chroma .se{color:#56b6c2}[data-bs-theme="dark"] .chroma .sh,[data-bs-theme="light"] .chroma .sh{color:#56b6c2}[data-bs-theme="dark"] .chroma .si,[data-bs-theme="light"] .chroma .si{color:#82cc6a}[data-bs-theme="dark"] .chroma .sx,[data-bs-theme="light"] .chroma .sx{color:#82cc6a}[data-bs-theme="dark"] .chroma .sr,[data-bs-theme="light"] .chroma .sr{color:#57c7ff}[data-bs-theme="dark"] .chroma .s1,[data-bs-theme="light"] .chroma .s1{color:#82cc6a}[data-bs-theme="dark"] .chroma .ss,[data-bs-theme="light"] .chroma .ss{color:#82cc6a}[data-bs-theme="dark"] .chroma .m,[data-bs-theme="light"] .chroma .m{color:#56b6c2}[data-bs-theme="dark"] .chroma .mb,[data-bs-theme="light"] .chroma .mb{color:#57c7ff}[data-bs-theme="dark"] .chroma .mf,[data-bs-theme="light"] .chroma .mf{color:#56b6c2}[data-bs-theme="dark"] .chroma .mh,[data-bs-theme="light"] .chroma .mh{color:#57c7ff}[data-bs-theme="dark"] .chroma .mi,[data-bs-theme="light"] .chroma .mi{color:#56b6c2}[data-bs-theme="dark"] .chroma .il,[data-bs-theme="light"] .chroma .il{color:#56b6c2}[data-bs-theme="dark"] .chroma .mo,[data-bs-theme="light"] .chroma .mo{color:#57c7ff}[data-bs-theme="dark"] .chroma .o,[data-bs-theme="light"] .chroma .o{color:#bc74c4}[data-bs-theme="dark"] .chroma .ow,[data-bs-theme="light"] .chroma .ow{color:#bc74c4}[data-bs-theme="dark"] .chroma .p,[data-bs-theme="light"] .chroma .p{color:#56b6c2}[data-bs-theme="dark"] .chroma .c,[data-bs-theme="light"] .chroma .c{color:#3e4460}[data-bs-theme="dark"] .chroma .ch,[data-bs-theme="light"] .chroma .ch{color:#3e4460;font-style:italic}[data-bs-theme="dark"] .chroma .cm,[data-bs-theme="light"] .chroma .cm{color:#3e4460}[data-bs-theme="dark"] .chroma .c1,[data-bs-theme="light"] .chroma .c1{color:#3e4460}[data-bs-theme="dark"] .chroma .cs,[data-bs-theme="light"] .chroma .cs{color:#bc74c4;font-style:italic}[data-bs-theme="dark"] .chroma .cp,[data-bs-theme="light"] .chroma .cp{color:#7fbaf5}[data-bs-theme="dark"] .chroma .cpf,[data-bs-theme="light"] .chroma .cpf{color:#7fbaf5}[data-bs-theme="dark"] .chroma .gd,[data-bs-theme="light"] .chroma .gd{color:#cf5967}[data-bs-theme="dark"] .chroma .ge,[data-bs-theme="light"] .chroma .ge{text-decoration:underline}[data-bs-theme="dark"] .chroma .gr,[data-bs-theme="light"] .chroma .gr{color:#cf5967;font-weight:bold}[data-bs-theme="dark"] .chroma .gh,[data-bs-theme="light"] .chroma .gh{color:#ecbe7b;font-weight:bold}[data-bs-theme="dark"] .chroma .gi,[data-bs-theme="light"] .chroma .gi{color:#ecbe7b}[data-bs-theme="dark"] .chroma .go,[data-bs-theme="light"] .chroma .go{color:#43454f}[data-bs-theme="dark"] .chroma .gs,[data-bs-theme="light"] .chroma .gs{color:#cf5967;font-weight:bold}[data-bs-theme="dark"] .chroma .gu,[data-bs-theme="light"] .chroma .gu{color:#cf5967;font-style:italic}[data-bs-theme="dark"] .chroma .gl,[data-bs-theme="light"] .chroma .gl{text-decoration:underline} ================================================ FILE: docs/resources/_gen/assets/scss/app.scss_cdf9d7c9eb97e4550ded64a8776dd9e8.json ================================================ {"Target":"main.c9aa351ca37dda2041352b32354756af1febb20a2d4a399b26dde00035e7b7022e9af6d4482f9f9f9d29aacdc8f99a6a6a01884a63bb53d6e3974dd6629034c5.css","MediaType":"text/css","Data":{"Integrity":"sha512-yao1HKN92iBBNSsyNUdWrx/rsgotSjmbJt3gADXntwIumvbUSC+fn50pqs3I+ZpqagGISmO7U9bjl03WYpA0xQ=="}} ================================================ FILE: docs/static/.gitkeep ================================================ ================================================ FILE: go.mod ================================================ module github.com/ThreeDotsLabs/watermill go 1.23.0 toolchain go1.24.3 require ( github.com/cenkalti/backoff/v5 v5.0.3 github.com/go-chi/chi/v5 v5.2.2 github.com/gogo/protobuf v1.3.2 github.com/golang/protobuf v1.5.4 github.com/google/uuid v1.6.0 github.com/lithammer/shortuuid/v3 v3.0.7 github.com/oklog/ulid v1.3.1 github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.23.0 github.com/sony/gobreaker v1.0.0 github.com/stretchr/testify v1.11.0 google.golang.org/protobuf v1.36.8 ) require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/kr/text v0.2.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.65.0 // indirect github.com/prometheus/procfs v0.17.0 // indirect golang.org/x/sys v0.35.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) ================================================ FILE: go.sum ================================================ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8= github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.20.2 h1:5ctymQzZlyOON1666svgwn3s6IKWgfbjsejTMiXIyjg= github.com/prometheus/client_golang v1.20.2/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc= github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE= github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ= github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: internal/channel.go ================================================ package internal // IsChannelClosed returns true if provided `chan struct{}` is closed. // IsChannelClosed panics if message is sent to this channel. func IsChannelClosed(channel chan struct{}) bool { select { case _, ok := <-channel: if ok { panic("received unexpected message") } return true default: return false } } ================================================ FILE: internal/channel_test.go ================================================ package internal_test import ( "testing" "github.com/ThreeDotsLabs/watermill/internal" "github.com/stretchr/testify/assert" ) func TestIsChannelClosed(t *testing.T) { closed := make(chan struct{}) close(closed) withSentValue := make(chan struct{}, 1) withSentValue <- struct{}{} testCases := []struct { Name string Channel chan struct{} ExpectedPanic bool ExpectedClosed bool }{ { Name: "not_closed", Channel: make(chan struct{}), ExpectedPanic: false, ExpectedClosed: false, }, { Name: "closed", Channel: closed, ExpectedPanic: false, ExpectedClosed: true, }, { Name: "with_sent_value", Channel: withSentValue, ExpectedPanic: true, ExpectedClosed: false, }, } for _, c := range testCases { t.Run(c.Name, func(t *testing.T) { testFunc := func() { closed := internal.IsChannelClosed(c.Channel) assert.EqualValues(t, c.ExpectedClosed, closed) } if c.ExpectedPanic { assert.Panics(t, testFunc) } else { assert.NotPanics(t, testFunc) } }) } } ================================================ FILE: internal/name.go ================================================ package internal import ( "fmt" "strings" ) // StructName returns a normalized name of the passed structure. func StructName(v interface{}) string { if s, ok := v.(fmt.Stringer); ok { return s.String() } s := fmt.Sprintf("%T", v) // trim the pointer marker, if any return strings.TrimLeft(s, "*") } ================================================ FILE: internal/name_test.go ================================================ package internal_test import ( "testing" "github.com/ThreeDotsLabs/watermill/internal" "github.com/stretchr/testify/assert" ) type testStruct struct{} type stringerStruct struct{} func (stringerStruct) String() string { return "stringer" } func TestStructName(t *testing.T) { testCases := []struct { Name string Struct interface{} ExpectedName string }{ { Name: "simple_struct", Struct: testStruct{}, ExpectedName: "internal_test.testStruct", }, { Name: "pointer_struct", Struct: &testStruct{}, ExpectedName: "internal_test.testStruct", }, { Name: "stringer", Struct: stringerStruct{}, ExpectedName: "stringer", }, } for _, c := range testCases { t.Run(c.Name, func(t *testing.T) { s := internal.StructName(c.Struct) assert.Equal(t, c.ExpectedName, s) }) } } ================================================ FILE: internal/norace.go ================================================ //go:build !race // +build !race package internal const RaceEnabled = false ================================================ FILE: internal/publisher/errors.go ================================================ package publisher import ( "strings" "github.com/ThreeDotsLabs/watermill/message" ) type ErrCouldNotPublish struct { reasons map[string]error } func (e *ErrCouldNotPublish) addMsg(msg *message.Message, reason error) { e.reasons[msg.UUID] = reason } func NewErrCouldNotPublish() *ErrCouldNotPublish { return &ErrCouldNotPublish{make(map[string]error)} } func (e ErrCouldNotPublish) Len() int { return len(e.reasons) } func (e ErrCouldNotPublish) Error() string { if len(e.reasons) == 0 { return "" } b := strings.Builder{} b.WriteString("Could not publish the messages:\n") for uuid, reason := range e.reasons { b.WriteString(uuid + " : " + reason.Error() + "\n") } return b.String() } func (e ErrCouldNotPublish) Reasons() map[string]error { return e.reasons } ================================================ FILE: internal/publisher/retry.go ================================================ package publisher import ( "time" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill/message" "github.com/pkg/errors" ) var ( ErrNonPositiveNumberOfRetries = errors.New("number of retries should be positive") ErrNonPositiveTimeToFirstRetry = errors.New("time to first retry should be positive") ) type RetryPublisherConfig struct { MaxRetries int // each subsequent retry doubles the time to next retry. TimeToFirstRetry time.Duration Logger watermill.LoggerAdapter } func (c *RetryPublisherConfig) setDefaults() { if c.MaxRetries == 0 { c.MaxRetries = 5 } if c.TimeToFirstRetry == 0 { c.TimeToFirstRetry = time.Second } if c.Logger == nil { c.Logger = watermill.NopLogger{} } } func (c RetryPublisherConfig) validate() error { if c.MaxRetries <= 0 { return ErrNonPositiveNumberOfRetries } if c.TimeToFirstRetry <= 0 { return ErrNonPositiveTimeToFirstRetry } return nil } // RetryPublisher is a decorator for a publisher that retries message publishing after a failure. type RetryPublisher struct { pub message.Publisher config RetryPublisherConfig } func NewRetryPublisher(pub message.Publisher, config RetryPublisherConfig) (*RetryPublisher, error) { config.setDefaults() if err := config.validate(); err != nil { return nil, errors.Wrap(err, "invalid RetryPublisher config") } return &RetryPublisher{ pub, config, }, nil } func (p RetryPublisher) Publish(topic string, messages ...*message.Message) error { failedMessages := NewErrCouldNotPublish() // todo: do some parallel processing maybe? this is a very basic implementation for _, msg := range messages { err := p.send(topic, msg) if err != nil { failedMessages.addMsg(msg, err) } } if failedMessages.Len() > 0 { return failedMessages } return nil } func (p RetryPublisher) Close() error { return p.pub.Close() } // send sends one message at a time to prevent sending a successful message more than once. func (p RetryPublisher) send(topic string, msg *message.Message) error { var err error timeToNextRetry := p.config.TimeToFirstRetry for i := 0; i < p.config.MaxRetries; i++ { err = p.pub.Publish(topic, msg) if err == nil { return nil } p.config.Logger.Info("Publish failed, retrying in "+timeToNextRetry.String(), watermill.LogFields{ "error": err, }) time.Sleep(timeToNextRetry) timeToNextRetry *= 2 } return err } ================================================ FILE: internal/publisher/retry_test.go ================================================ package publisher_test import ( "testing" "time" "github.com/stretchr/testify/assert" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill/internal/publisher" "github.com/stretchr/testify/require" "github.com/ThreeDotsLabs/watermill/message" "github.com/pkg/errors" ) var errCouldNotPublish = errors.New("could not publish, try again") // FailingPublisher mocks a publisher that fails a specific number of time for a message, then succeeds. type FailingPublisher struct { howManyFails map[string]int howManyPublished map[string]int } func (p *FailingPublisher) Publish(topic string, messages ...*message.Message) error { for _, msg := range messages { howManyFails, ok := p.howManyFails[msg.UUID] if !ok || howManyFails <= 0 { p.publish(msg) continue } p.howManyFails[msg.UUID]-- return errCouldNotPublish } return nil } func (p *FailingPublisher) publish(msg *message.Message) { if _, ok := p.howManyPublished[msg.UUID]; !ok { p.howManyPublished[msg.UUID] = 1 return } p.howManyPublished[msg.UUID]++ } func (p *FailingPublisher) Close() error { return nil } func TestRetryPublisher_Publish_after_retries(t *testing.T) { msg := message.NewMessage("uuid", []byte{}) pub := FailingPublisher{ howManyFails: map[string]int{ msg.UUID: 4, }, howManyPublished: map[string]int{}, } conf := publisher.RetryPublisherConfig{ MaxRetries: 5, TimeToFirstRetry: time.Millisecond, Logger: watermill.NopLogger{}, } retryPub, err := publisher.NewRetryPublisher(&pub, conf) require.NoError(t, err) // given require.True(t, pub.howManyFails[msg.UUID] < conf.MaxRetries, "Publisher must fail less than MaxRetries times") // when err = retryPub.Publish("topic", msg) // then require.NoError(t, err) assert.Contains(t, pub.howManyPublished, msg.UUID) assert.Equal(t, pub.howManyPublished[msg.UUID], 1, "Expected msg to be published exactly once") } func TestRetryPublisher_Publish_too_many_retries(t *testing.T) { msg := message.NewMessage("uuid", []byte{}) pub := FailingPublisher{ howManyFails: map[string]int{ msg.UUID: 5, }, howManyPublished: map[string]int{}, } conf := publisher.RetryPublisherConfig{ MaxRetries: 5, TimeToFirstRetry: time.Millisecond, Logger: watermill.NopLogger{}, } retryPub, err := publisher.NewRetryPublisher(&pub, conf) require.NoError(t, err) // given require.True(t, pub.howManyFails[msg.UUID] >= conf.MaxRetries, "Publisher must fail at least MaxRetries times") // when err = retryPub.Publish("topic", msg) // then require.Error(t, err) cnpErr, ok := err.(*publisher.ErrCouldNotPublish) require.True(t, ok, "expected the ErrCouldNotPublish composite error type") assert.Equal(t, 1, cnpErr.Len(), "attempted to publish one message, expecting one error") assert.Equal(t, errCouldNotPublish, errors.Cause(cnpErr.Reasons()[msg.UUID])) assert.NotContains(t, pub.howManyPublished, msg.UUID, "expected msg to not be published") } func TestPublishEachMessageOnlyOnce(t *testing.T) { msg1 := message.NewMessage("uuid1", []byte{}) msg2 := message.NewMessage("uuid2", []byte{}) pub := FailingPublisher{ howManyFails: map[string]int{ msg1.UUID: 2, msg2.UUID: 4, }, howManyPublished: map[string]int{}, } conf := publisher.RetryPublisherConfig{ MaxRetries: 5, TimeToFirstRetry: time.Millisecond, Logger: watermill.NopLogger{}, } retryPub, err := publisher.NewRetryPublisher(&pub, conf) require.NoError(t, err) // given require.True(t, pub.howManyFails[msg1.UUID] < conf.MaxRetries, "Publisher must fail less than MaxRetries times for msg1") require.True(t, pub.howManyFails[msg2.UUID] < conf.MaxRetries, "Publisher must fail less than MaxRetries times for msg2") require.True(t, pub.howManyFails[msg1.UUID] < pub.howManyFails[msg2.UUID], "Publisher must fail less times for msg1 than msg2") // when err = retryPub.Publish("topic", msg1, msg2) // then require.NoError(t, err) assert.Equal(t, pub.howManyPublished[msg1.UUID], 1, "expected msg1 to be published only once") assert.Equal(t, pub.howManyPublished[msg2.UUID], 1, "expected msg2 to be published only once") } var ErrCouldNotClose = errors.New("this publisher fails on Close()") // ClosingPublisher mocks a publisher that may fail on closing so we may check if the Close() call propagated correctly. type ClosingPublisher struct { closed bool failOnClose bool } func (ClosingPublisher) Publish(topic string, messages ...*message.Message) error { return nil } func (p *ClosingPublisher) Close() error { if p.failOnClose { return ErrCouldNotClose } p.closed = true return nil } func TestRetryPublisher_Close(t *testing.T) { pub := ClosingPublisher{} retryPub, err := publisher.NewRetryPublisher(&pub, publisher.RetryPublisherConfig{}) require.NoError(t, err) // given require.False(t, pub.closed) // when err = retryPub.Close() // then require.NoError(t, err) assert.True(t, pub.closed) } func TestRetryPublisher_Close_failed(t *testing.T) { pub := ClosingPublisher{failOnClose: true} retryPub, err := publisher.NewRetryPublisher(&pub, publisher.RetryPublisherConfig{}) require.NoError(t, err) // given require.False(t, pub.closed) // when err = retryPub.Close() // then require.Error(t, err) assert.Equal(t, ErrCouldNotClose, errors.Cause(err)) } ================================================ FILE: internal/race.go ================================================ //go:build race // +build race package internal const RaceEnabled = true ================================================ FILE: internal/subscriber/multiplier.go ================================================ package subscriber import ( "context" stdErrors "errors" "sync" "github.com/ThreeDotsLabs/watermill/message" "github.com/pkg/errors" ) // Constructor is a function that creates a subscriber. type Constructor func() (message.Subscriber, error) type multiplier struct { subscriberConstructor func() (message.Subscriber, error) subscribersCount int subscribers []message.Subscriber } // NewMultiplier returns multiplier subscriber decorator, // which under the hood is calling subscribe multiple times to increase throughput. func NewMultiplier(constructor Constructor, subscribersCount int) message.Subscriber { return &multiplier{ subscriberConstructor: constructor, subscribersCount: subscribersCount, } } func (s *multiplier) Subscribe(ctx context.Context, topic string) (msgs <-chan *message.Message, err error) { defer func() { if err != nil { if closeErr := s.Close(); closeErr != nil { err = stdErrors.Join(err, closeErr) } } }() out := make(chan *message.Message) subWg := sync.WaitGroup{} subWg.Add(s.subscribersCount) for i := 0; i < s.subscribersCount; i++ { sub, err := s.subscriberConstructor() if err != nil { return nil, errors.Wrap(err, "cannot create subscriber") } s.subscribers = append(s.subscribers, sub) msgs, err := sub.Subscribe(ctx, topic) if err != nil { return nil, errors.Wrap(err, "cannot subscribe") } go func() { for msg := range msgs { out <- msg } subWg.Done() }() } go func() { subWg.Wait() close(out) }() return out, nil } func (s *multiplier) Close() error { var err error for _, sub := range s.subscribers { if closeErr := sub.Close(); closeErr != nil { err = stdErrors.Join(err, closeErr) } } return err } ================================================ FILE: log.go ================================================ package watermill import ( "errors" "fmt" "io" "log" "maps" "os" "reflect" "slices" "sort" "strings" "sync" "time" ) // LogFields is the logger's key-value list of fields. type LogFields map[string]interface{} // Add adds new fields to the list of LogFields. func (l LogFields) Add(newFields LogFields) LogFields { resultFields := make(LogFields, len(l)+len(newFields)) maps.Copy(resultFields, l) maps.Copy(resultFields, newFields) return resultFields } // Copy copies the LogFields. func (l LogFields) Copy() LogFields { cpy := make(LogFields, len(l)) maps.Copy(cpy, l) return cpy } // LoggerAdapter is an interface, that you need to implement to support Watermill logging. // You can use StdLoggerAdapter as a reference implementation. type LoggerAdapter interface { Error(msg string, err error, fields LogFields) Info(msg string, fields LogFields) Debug(msg string, fields LogFields) Trace(msg string, fields LogFields) With(fields LogFields) LoggerAdapter } // NopLogger is a logger which discards all logs. type NopLogger struct{} func (NopLogger) Error(msg string, err error, fields LogFields) {} func (NopLogger) Info(msg string, fields LogFields) {} func (NopLogger) Debug(msg string, fields LogFields) {} func (NopLogger) Trace(msg string, fields LogFields) {} func (l NopLogger) With(fields LogFields) LoggerAdapter { return l } // StdLoggerAdapter is a logger implementation, which sends all logs to provided standard output. type StdLoggerAdapter struct { ErrorLogger *log.Logger InfoLogger *log.Logger DebugLogger *log.Logger TraceLogger *log.Logger fields LogFields } // NewStdLogger creates StdLoggerAdapter which sends all logs to stderr. func NewStdLogger(debug, trace bool) LoggerAdapter { return NewStdLoggerWithOut(os.Stderr, debug, trace) } // NewStdLoggerWithOut creates StdLoggerAdapter which sends all logs to provided io.Writer. func NewStdLoggerWithOut(out io.Writer, debug bool, trace bool) LoggerAdapter { l := log.New(out, "[watermill] ", log.LstdFlags|log.Lmicroseconds|log.Lshortfile) a := &StdLoggerAdapter{InfoLogger: l, ErrorLogger: l} if debug { a.DebugLogger = l } if trace { a.TraceLogger = l } return a } func (l *StdLoggerAdapter) Error(msg string, err error, fields LogFields) { l.log(l.ErrorLogger, "ERROR", msg, fields.Add(LogFields{"err": err})) } func (l *StdLoggerAdapter) Info(msg string, fields LogFields) { l.log(l.InfoLogger, "INFO ", msg, fields) } func (l *StdLoggerAdapter) Debug(msg string, fields LogFields) { l.log(l.DebugLogger, "DEBUG", msg, fields) } func (l *StdLoggerAdapter) Trace(msg string, fields LogFields) { l.log(l.TraceLogger, "TRACE", msg, fields) } func (l *StdLoggerAdapter) With(fields LogFields) LoggerAdapter { return &StdLoggerAdapter{ ErrorLogger: l.ErrorLogger, InfoLogger: l.InfoLogger, DebugLogger: l.DebugLogger, TraceLogger: l.TraceLogger, fields: l.fields.Add(fields), } } func (l *StdLoggerAdapter) log(logger *log.Logger, level string, msg string, fields LogFields) { if logger == nil { return } fieldsStr := "" allFields := l.fields.Add(fields) keys := make([]string, len(allFields)) i := 0 for field := range allFields { keys[i] = field i++ } sort.Strings(keys) for _, key := range keys { var valueStr string value := allFields[key] if stringer, ok := value.(fmt.Stringer); ok { valueStr = stringer.String() } else { valueStr = fmt.Sprintf("%v", value) } if strings.Contains(valueStr, " ") { valueStr = `"` + valueStr + `"` } fieldsStr += key + "=" + valueStr + " " } _ = logger.Output(3, fmt.Sprintf("\t"+`level=%s msg="%s" %s`, level, msg, fieldsStr)) } type LogLevel uint const ( TraceLogLevel LogLevel = iota + 1 DebugLogLevel InfoLogLevel ErrorLogLevel ) type CapturedMessage struct { Level LogLevel Time time.Time Fields LogFields Msg string Err error } func (c CapturedMessage) ContentEquals(other CapturedMessage) bool { return c.Level == other.Level && reflect.DeepEqual(c.Fields, other.Fields) && c.Msg == other.Msg && errors.Is(c.Err, other.Err) } // CaptureLoggerAdapter is a logger which captures all logs. // This logger is mostly useful for testing logging. type CaptureLoggerAdapter struct { captured map[LogLevel][]CapturedMessage fields LogFields lock *sync.Mutex } func NewCaptureLogger() *CaptureLoggerAdapter { return &CaptureLoggerAdapter{ captured: map[LogLevel][]CapturedMessage{}, lock: &sync.Mutex{}, } } func (c *CaptureLoggerAdapter) With(fields LogFields) LoggerAdapter { c.lock.Lock() defer c.lock.Unlock() return &CaptureLoggerAdapter{ captured: c.captured, // we are passing the same map, so we'll capture logs from this instance as well fields: c.fields.Copy().Add(fields), lock: c.lock, } } func (c *CaptureLoggerAdapter) capture(level LogLevel, msg string, err error, fields LogFields) { c.lock.Lock() defer c.lock.Unlock() logMsg := CapturedMessage{ Level: level, Time: time.Now(), Fields: c.fields.Add(fields), Msg: msg, Err: err, } c.captured[level] = append(c.captured[level], logMsg) } func (c *CaptureLoggerAdapter) Captured() map[LogLevel][]CapturedMessage { c.lock.Lock() defer c.lock.Unlock() return c.captured } type Logfer interface { Logf(format string, a ...interface{}) } func (c *CaptureLoggerAdapter) PrintCaptured(t Logfer) { c.lock.Lock() defer c.lock.Unlock() for level, messages := range c.captured { for _, msg := range messages { t.Logf("%s %d %s %v", msg.Time.Format("15:04:05.999999999"), level, msg.Msg, msg.Fields) } } } func (c *CaptureLoggerAdapter) Has(msg CapturedMessage) bool { c.lock.Lock() defer c.lock.Unlock() return slices.ContainsFunc(c.captured[msg.Level], msg.ContentEquals) } func (c *CaptureLoggerAdapter) HasError(err error) bool { c.lock.Lock() defer c.lock.Unlock() for _, capturedMsg := range c.captured[ErrorLogLevel] { if errors.Is(err, capturedMsg.Err) { return true } } return false } func (c *CaptureLoggerAdapter) Error(msg string, err error, fields LogFields) { c.capture(ErrorLogLevel, msg, err, fields) } func (c *CaptureLoggerAdapter) Info(msg string, fields LogFields) { c.capture(InfoLogLevel, msg, nil, fields) } func (c *CaptureLoggerAdapter) Debug(msg string, fields LogFields) { c.capture(DebugLogLevel, msg, nil, fields) } func (c *CaptureLoggerAdapter) Trace(msg string, fields LogFields) { c.capture(TraceLogLevel, msg, nil, fields) } ================================================ FILE: log_test.go ================================================ package watermill_test import ( "bytes" "errors" "testing" "github.com/stretchr/testify/assert" "github.com/ThreeDotsLabs/watermill" ) func TestLogFields_Copy(t *testing.T) { fields1 := watermill.LogFields{"foo": "bar"} fields2 := fields1.Copy() fields2["foo"] = "baz" assert.Equal(t, fields1["foo"], "bar") assert.Equal(t, fields2["foo"], "baz") } func TestStdLogger_with(t *testing.T) { buf := bytes.NewBuffer([]byte{}) cleanLogger := watermill.NewStdLoggerWithOut(buf, true, true) withLogFieldsLogger := cleanLogger.With(watermill.LogFields{"foo": "1"}) for name, logger := range map[string]watermill.LoggerAdapter{"clean": cleanLogger, "with": withLogFieldsLogger} { logger.Error(name, nil, watermill.LogFields{"bar": "2"}) logger.Info(name, watermill.LogFields{"bar": "2"}) logger.Debug(name, watermill.LogFields{"bar": "2"}) logger.Trace(name, watermill.LogFields{"bar": "2"}) } cleanLoggerOut := buf.String() assert.Contains(t, cleanLoggerOut, `level=ERROR msg="clean" bar=2 err=`) assert.Contains(t, cleanLoggerOut, `level=INFO msg="clean" bar=2`) assert.Contains(t, cleanLoggerOut, `level=TRACE msg="clean" bar=2`) assert.Contains(t, cleanLoggerOut, `level=ERROR msg="with" bar=2 err= foo=1`) assert.Contains(t, cleanLoggerOut, `level=INFO msg="with" bar=2 foo=1`) assert.Contains(t, cleanLoggerOut, `level=TRACE msg="with" bar=2 foo=1`) } type stringer struct{} func (s stringer) String() string { return "stringer" } func TestStdLoggerAdapter_stringer_field(t *testing.T) { buf := bytes.NewBuffer([]byte{}) logger := watermill.NewStdLoggerWithOut(buf, true, true) logger.Info("foo", watermill.LogFields{"foo": stringer{}}) out := buf.String() assert.Contains(t, out, `foo=stringer`) } func TestStdLoggerAdapter_field_with_space(t *testing.T) { buf := bytes.NewBuffer([]byte{}) logger := watermill.NewStdLoggerWithOut(buf, true, true) logger.Info("foo", watermill.LogFields{"foo": `bar baz`}) out := buf.String() assert.Contains(t, out, `foo="bar baz"`) } func TestCaptureLoggerAdapter(t *testing.T) { var logger watermill.LoggerAdapter = watermill.NewCaptureLogger() err := errors.New("error") logger = logger.With(watermill.LogFields{"default": "field"}) logger.Error("error", err, watermill.LogFields{"bar": "2"}) logger.Info("info", watermill.LogFields{"bar": "2"}) logger.Debug("debug", watermill.LogFields{"bar": "2"}) logger.Trace("trace", watermill.LogFields{"bar": "2"}) expectedLogs := map[watermill.LogLevel][]watermill.CapturedMessage{ watermill.TraceLogLevel: { watermill.CapturedMessage{ Level: watermill.TraceLogLevel, Fields: watermill.LogFields{"bar": "2", "default": "field"}, Msg: "trace", Err: error(nil), }, }, watermill.DebugLogLevel: { watermill.CapturedMessage{ Level: watermill.DebugLogLevel, Fields: watermill.LogFields{"default": "field", "bar": "2"}, Msg: "debug", Err: error(nil), }, }, watermill.InfoLogLevel: { watermill.CapturedMessage{ Level: watermill.InfoLogLevel, Fields: watermill.LogFields{"default": "field", "bar": "2"}, Msg: "info", Err: error(nil), }, }, watermill.ErrorLogLevel: { watermill.CapturedMessage{ Level: watermill.ErrorLogLevel, Fields: watermill.LogFields{"default": "field", "bar": "2"}, Msg: "error", Err: err, }, }, } capturedLogger := logger.(*watermill.CaptureLoggerAdapter) assert.Equal(t, len(expectedLogs), len(capturedLogger.Captured())) for _, logs := range expectedLogs { for _, log := range logs { assert.True(t, capturedLogger.Has(log)) } } assert.False(t, capturedLogger.Has(watermill.CapturedMessage{ Level: 0, Fields: nil, Msg: "", Err: nil, })) assert.True(t, capturedLogger.HasError(err)) assert.False(t, capturedLogger.HasError(errors.New("foo"))) } ================================================ FILE: message/decorator.go ================================================ package message import ( "context" "sync" ) // MessageTransformSubscriberDecorator creates a subscriber decorator that calls transform // on each message that passes through the subscriber. func MessageTransformSubscriberDecorator(transform func(*Message)) SubscriberDecorator { if transform == nil { panic("transform function is nil") } return func(sub Subscriber) (Subscriber, error) { return &messageTransformSubscriberDecorator{ sub: sub, transform: transform, }, nil } } // MessageTransformPublisherDecorator creates a publisher decorator that calls transform // on each message that passes through the publisher. func MessageTransformPublisherDecorator(transform func(*Message)) PublisherDecorator { if transform == nil { panic("transform function is nil") } return func(pub Publisher) (Publisher, error) { return &messageTransformPublisherDecorator{ Publisher: pub, transform: transform, }, nil } } type messageTransformSubscriberDecorator struct { sub Subscriber transform func(*Message) subscribeWg sync.WaitGroup } func (t *messageTransformSubscriberDecorator) Subscribe(ctx context.Context, topic string) (<-chan *Message, error) { in, err := t.sub.Subscribe(ctx, topic) if err != nil { return nil, err } out := make(chan *Message) t.subscribeWg.Add(1) go func() { for msg := range in { t.transform(msg) out <- msg } close(out) t.subscribeWg.Done() }() return out, nil } func (t *messageTransformSubscriberDecorator) Close() error { err := t.sub.Close() t.subscribeWg.Wait() return err } type messageTransformPublisherDecorator struct { Publisher transform func(*Message) } // Publish applies the transform to each message and returns the underlying Publisher's result. func (d messageTransformPublisherDecorator) Publish(topic string, messages ...*Message) error { for i := range messages { d.transform(messages[i]) } return d.Publisher.Publish(topic, messages...) } ================================================ FILE: message/decorator_bench_test.go ================================================ package message_test import ( "context" "sync" "testing" "github.com/stretchr/testify/require" "github.com/ThreeDotsLabs/watermill/message" ) type benchSubscriber struct { msgCh chan *message.Message closeCh chan struct{} running sync.Mutex } func (b *benchSubscriber) Subscribe(ctx context.Context, topic string) (<-chan *message.Message, error) { return b.msgCh, nil } func (b *benchSubscriber) Close() error { close(b.closeCh) b.running.Lock() close(b.msgCh) b.running.Unlock() return nil } func newBenchSubscriber() *benchSubscriber { sub := &benchSubscriber{ msgCh: make(chan *message.Message, 1), closeCh: make(chan struct{}), } // continually produce messages until Close() go func() { sub.running.Lock() msg := message.NewMessage("", []byte{}) for { select { case <-sub.closeCh: sub.running.Unlock() return case sub.msgCh <- msg: // the buffer limit is 1, so this will block until someone consumes } } }() return sub } func BenchmarkMessageTransformSubscriberDecorator(b *testing.B) { b.ReportAllocs() b.Run("no_decorator", benchmarkNoDecorator) b.Run("message_transform_decorator", benchmarkMessageTransformSubscriberDecorator) } func benchmarkNoDecorator(b *testing.B) { sub := newBenchSubscriber() in, err := sub.Subscribe(context.Background(), "") require.NoError(b, err) b.ResetTimer() for i := 0; i < b.N; i++ { // consume one message <-in } } func benchmarkMessageTransformSubscriberDecorator(b *testing.B) { sub := newBenchSubscriber() noopDecorator := message.MessageTransformSubscriberDecorator(func(*message.Message) {}) decoratedSub, err := noopDecorator(sub) require.NoError(b, err) in, err := decoratedSub.Subscribe(context.Background(), "") require.NoError(b, err) b.ResetTimer() for i := 0; i < b.N; i++ { // consume one message <-in } } ================================================ FILE: message/decorator_test.go ================================================ package message_test import ( "context" "strconv" "testing" "time" "github.com/ThreeDotsLabs/watermill/pubsub/tests" "github.com/pkg/errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill/message" "github.com/ThreeDotsLabs/watermill/message/subscriber" "github.com/ThreeDotsLabs/watermill/pubsub/gochannel" ) var noop = func(*message.Message) {} var closingErr = errors.New("mock error on close") type mockSubscriber struct { ch chan *message.Message } func (m mockSubscriber) Subscribe(context.Context, string) (<-chan *message.Message, error) { return m.ch, nil } func (m mockSubscriber) Close() error { close(m.ch) return nil } func TestMessageTransformSubscriberDecorator_transparent(t *testing.T) { sub := mockSubscriber{make(chan *message.Message)} decorated, err := message.MessageTransformSubscriberDecorator(noop)(sub) require.NoError(t, err) messages, err := decorated.Subscribe(context.Background(), "topic") require.NoError(t, err) richMessage := message.NewMessage("uuid", []byte("serious payloads")) richMessage.Metadata.Set("k1", "v1") richMessage.Metadata.Set("k2", "v2") go func() { sub.ch <- richMessage }() received, all := subscriber.BulkRead(messages, 1, time.Second) require.True(t, all) assert.True(t, received[0].Equals(richMessage), "expected the message to pass unchanged through decorator") } type closingSubscriber struct { closed bool } func (closingSubscriber) Subscribe(context.Context, string) (<-chan *message.Message, error) { return nil, nil } func (c *closingSubscriber) Close() error { c.closed = true return closingErr } func TestMessageTransformSubscriberDecorator_Close(t *testing.T) { cs := &closingSubscriber{} decoratedSub, err := message.MessageTransformSubscriberDecorator(noop)(cs) require.NoError(t, err) // given require.False(t, cs.closed) // when decoratedCloseErr := decoratedSub.Close() // then assert.True( t, cs.closed, "expected the Close() call to propagate to decorated subscriber", ) assert.Equal( t, closingErr, decoratedCloseErr, "expected the decorator to propagate the closing error from underlying subscriber", ) } func TestMessageTransformSubscriberDecorator_Subscribe(t *testing.T) { numMessages := 1000 pubSub := gochannel.NewGoChannel(gochannel.Config{}, watermill.NewStdLogger(true, true)) onMessage := func(msg *message.Message) { msg.Metadata.Set("key", "value") } decorator := message.MessageTransformSubscriberDecorator(onMessage) decoratedSub, err := decorator(pubSub) require.NoError(t, err) messages, err := decoratedSub.Subscribe(context.Background(), "topic") require.NoError(t, err) sent := message.Messages{} go func() { for i := 0; i < numMessages; i++ { msg := message.NewMessage(strconv.Itoa(i), []byte{}) sent = append(sent, msg) err = pubSub.Publish("topic", msg) require.NoError(t, err) } }() received, all := subscriber.BulkRead(messages, numMessages, time.Second) require.True(t, all) tests.AssertAllMessagesReceived(t, sent, received) for _, msg := range received { assert.Equal( t, "value", msg.Metadata.Get("key"), "expected onMessage callback to have set metadata", ) } } type mockPublisher struct { published message.Messages } func (m *mockPublisher) Publish(topic string, messages ...*message.Message) error { m.published = append(m.published, messages...) return nil } func (m mockPublisher) Close() error { return nil } func TestMessageTransformPublisherDecorator_transparent(t *testing.T) { pub := &mockPublisher{message.Messages{}} decorated, err := message.MessageTransformPublisherDecorator(noop)(pub) require.NoError(t, err) richMessage := message.NewMessage("uuid", []byte("serious payloads")) richMessage.Metadata.Set("k1", "v1") richMessage.Metadata.Set("k2", "v2") require.NoError(t, decorated.Publish("topic", richMessage)) require.Len(t, pub.published, 1) assert.True(t, pub.published[0].Equals(richMessage), "expected the message to pass unchanged through decorator") } type closingPublisher struct { closed bool } func (c *closingPublisher) Publish(topic string, messages ...*message.Message) error { return nil } func (c *closingPublisher) Close() error { c.closed = true return closingErr } func TestMessageTransformPublisherDecorator_Close(t *testing.T) { cp := &closingPublisher{} decoratedPub, err := message.MessageTransformPublisherDecorator(noop)(cp) require.NoError(t, err) // given require.False(t, cp.closed) // when decoratedCloseErr := decoratedPub.Close() // then assert.True( t, cp.closed, "expected the Close() call to propagate to decorated publisher", ) assert.Equal( t, closingErr, decoratedCloseErr, "expected the decorator to propagate the closing error from underlying publisher", ) } func TestMessageTransformPublisherDecorator_Subscribe(t *testing.T) { numMessages := 1000 pub := &mockPublisher{} onMessage := func(msg *message.Message) { msg.Metadata.Set("key", "value") } decorator := message.MessageTransformPublisherDecorator(onMessage) decoratedPub, err := decorator(pub) require.NoError(t, err) for i := 0; i < numMessages; i++ { msg := message.NewMessage(strconv.Itoa(i), []byte{}) require.NoError(t, decoratedPub.Publish("topic", msg)) } for i, msg := range pub.published { assert.Equal(t, strconv.Itoa(i), msg.UUID, "expected messages to arrive in unchanged order") assert.Equal( t, "value", msg.Metadata.Get("key"), "expected onMessage callback to have set metadata", ) } } func TestMessageTransformer_nil_panics(t *testing.T) { require.Panics( t, func() { _ = message.MessageTransformSubscriberDecorator(nil) }, "expected to panic if transform is nil", ) require.Panics( t, func() { _ = message.MessageTransformPublisherDecorator(nil) }, "expected to panic if transform is nil", ) } ================================================ FILE: message/message.go ================================================ package message import ( "bytes" "context" "sync" ) var closedchan = make(chan struct{}) func init() { close(closedchan) } // Payload is the Message's payload. type Payload []byte // Message is the basic transfer unit. // Messages are emitted by Publishers and received by Subscribers. // // A publisher can modify the message during publishing, e.g. can alter the metadata. // Avoid modifying the message in parallel with publishing, as it can lead to data races. // In general, a message should be passed to a single Publish and then considered immutable. // If needed, use the Copy method to create a new message. type Message struct { // UUID is a unique identifier of the message. // // It is only used by Watermill for debugging. // UUID can be empty. UUID string // Metadata contains the message metadata. // // Can be used to store data which doesn't require unmarshalling the entire payload. // It is something similar to HTTP request's headers. // // Metadata is marshaled and will be saved to the PubSub. Metadata Metadata // Payload is the message's payload. Payload Payload // ack is closed when acknowledge is received. ack chan struct{} // noAck is closed when negative acknowledge is received. noAck chan struct{} ackMutex sync.Mutex ackSentType ackType ctx context.Context } // NewMessage creates a new Message with given uuid and payload. func NewMessage(uuid string, payload Payload) *Message { return &Message{ UUID: uuid, Metadata: make(map[string]string), Payload: payload, ack: make(chan struct{}), noAck: make(chan struct{}), } } // NewMessageWithContext creates a new Message with given uuid, payload, and context. func NewMessageWithContext(ctx context.Context, uuid string, payload Payload) *Message { msg := NewMessage(uuid, payload) msg.SetContext(ctx) return msg } type ackType int const ( noAckSent ackType = iota ack nack ) // Equals compare, that two messages are equal. Acks/Nacks are not compared. func (m *Message) Equals(toCompare *Message) bool { if m.UUID != toCompare.UUID { return false } if len(m.Metadata) != len(toCompare.Metadata) { return false } for key, value := range m.Metadata { if value != toCompare.Metadata[key] { return false } } return bytes.Equal(m.Payload, toCompare.Payload) } // Ack sends message's acknowledgement. // // Ack is not blocking. // Ack is idempotent. // False is returned, if Nack is already sent. func (m *Message) Ack() bool { m.ackMutex.Lock() defer m.ackMutex.Unlock() if m.ackSentType == nack { return false } if m.ackSentType != noAckSent { return true } m.ackSentType = ack if m.ack == nil { m.ack = closedchan } else { close(m.ack) } return true } // Nack sends message's negative acknowledgement. // // Nack is not blocking. // Nack is idempotent. // False is returned, if Ack is already sent. func (m *Message) Nack() bool { m.ackMutex.Lock() defer m.ackMutex.Unlock() if m.ackSentType == ack { return false } if m.ackSentType != noAckSent { return true } m.ackSentType = nack if m.noAck == nil { m.noAck = closedchan } else { close(m.noAck) } return true } // Acked returns channel which is closed when acknowledgement is sent. // // Usage: // // select { // case <-message.Acked(): // // ack received // case <-message.Nacked(): // // nack received // } func (m *Message) Acked() <-chan struct{} { return m.ack } // Nacked returns channel which is closed when negative acknowledgement is sent. // // Usage: // // select { // case <-message.Acked(): // // ack received // case <-message.Nacked(): // // nack received // } func (m *Message) Nacked() <-chan struct{} { return m.noAck } // Context returns the message's context. To change the context, use // SetContext. // // The returned context is always non-nil; it defaults to the // background context. func (m *Message) Context() context.Context { if m.ctx != nil { return m.ctx } return context.Background() } // SetContext sets provided context to the message. func (m *Message) SetContext(ctx context.Context) { m.ctx = ctx } // Copy copies all message without Acks/Nacks. // The context is not propagated to the copy. func (m *Message) Copy() *Message { msg := NewMessage(m.UUID, m.Payload) for k, v := range m.Metadata { msg.Metadata.Set(k, v) } return msg } // CopyWithContext copies all message without Acks/Nacks. // The context is also propagated to the copy. func (m *Message) CopyWithContext() *Message { msg := m.Copy() msg.ctx = m.ctx return msg } ================================================ FILE: message/message_test.go ================================================ package message_test import ( "context" "testing" "github.com/ThreeDotsLabs/watermill/message" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestMessage_Equals(t *testing.T) { withMetadata := func(msg *message.Message, metadata message.Metadata) *message.Message { msg.Metadata = metadata return msg } testCases := []struct { Name string Msg1 *message.Message Msg2 *message.Message Equals bool }{ { Name: "equal", Msg1: message.NewMessage("1", []byte("foo")), Msg2: message.NewMessage("1", []byte("foo")), Equals: true, }, { Name: "different_uuid", Msg1: message.NewMessage("1", []byte("foo")), Msg2: message.NewMessage("2", []byte("foo")), Equals: false, }, { Name: "different_payload", Msg1: message.NewMessage("1", []byte("foo")), Msg2: message.NewMessage("1", []byte("bar")), Equals: false, }, { Name: "different_metadata", Msg1: withMetadata(message.NewMessage("1", []byte("foo")), map[string]string{"foo": "1"}), Msg2: withMetadata(message.NewMessage("1", []byte("foo")), map[string]string{"foo": "2"}), Equals: false, }, } for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { assert.Equal(t, tc.Equals, tc.Msg1.Equals(tc.Msg2)) assert.Equal(t, tc.Equals, tc.Msg2.Equals(tc.Msg1)) }) } } func TestMessage_Ack(t *testing.T) { msg := &message.Message{} require.True(t, msg.Ack()) assertAcked(t, msg) assertNoNack(t, msg) } func TestMessage_Ack_idempotent(t *testing.T) { msg := &message.Message{} require.True(t, msg.Ack()) require.True(t, msg.Ack()) assertAcked(t, msg) } func TestMessage_Ack_already_Nack(t *testing.T) { msg := &message.Message{} require.True(t, msg.Nack()) assert.False(t, msg.Ack()) } func TestMessage_Nack(t *testing.T) { msg := &message.Message{} require.True(t, msg.Nack()) assertNoAck(t, msg) assertNacked(t, msg) } func TestMessage_Nack_idempotent(t *testing.T) { msg := &message.Message{} require.True(t, msg.Nack()) require.True(t, msg.Nack()) assertNacked(t, msg) } func TestMessage_Nack_already_Ack(t *testing.T) { msg := &message.Message{} require.True(t, msg.Ack()) assert.False(t, msg.Nack()) } func TestMessage_Copy(t *testing.T) { msg := message.NewMessage("1", []byte("foo")) msgCopy := msg.Copy() require.True(t, msg.Ack()) assertAcked(t, msg) assertNoAck(t, msgCopy) assert.True(t, msg.Equals(msgCopy)) } type ctxKey string func TestMessage_CopyWithContext(t *testing.T) { msg := message.NewMessage("1", []byte("foo")) testCtx := context.Background() testCtx = context.WithValue(testCtx, ctxKey("foo"), "bar") msg.SetContext(testCtx) msgCopy := msg.CopyWithContext() copyMsgCtx := msgCopy.Context() assert.True(t, copyMsgCtx.Value(ctxKey("foo")) == "bar", "expected context not being copied") assert.False(t, copyMsgCtx.Value(ctxKey("abc")) == "def", "non-expected context being copied") assert.True(t, msg.Equals(msgCopy)) } func TestMessage_CopyWithContextAndMetadata(t *testing.T) { msg := message.NewMessage("1", []byte("foo")) testCtx := context.Background() testCtx = context.WithValue(testCtx, ctxKey("foo"), "bar") msg.SetContext(testCtx) msg.Metadata.Set("foo", "bar") msgCopy := msg.CopyWithContext() msg.Metadata.Set("foo", "baz") copyMsgCtx := msgCopy.Context() assert.True(t, copyMsgCtx.Value(ctxKey("foo")) == "bar", "expected context not being copied") assert.Equal(t, msgCopy.Metadata.Get("foo"), "bar", "did not expect changing source message's metadata to alter copy's metadata") } func TestMessage_CopyMetadata(t *testing.T) { msg := message.NewMessage("1", []byte("foo")) msg.Metadata.Set("foo", "bar") msgCopy := msg.Copy() msg.Metadata.Set("foo", "baz") assert.Equal(t, msgCopy.Metadata.Get("foo"), "bar", "did not expect changing source message's metadata to alter copy's metadata") } func assertAcked(t *testing.T, msg *message.Message) { select { case <-msg.Acked(): // ok default: t.Fatal("no ack received") } } func assertNacked(t *testing.T, msg *message.Message) { select { case <-msg.Nacked(): // ok default: t.Fatal("no ack received") } } func assertNoAck(t *testing.T, msg *message.Message) { select { case <-msg.Acked(): t.Fatal("nack should be not sent") default: // ok } } func assertNoNack(t *testing.T, msg *message.Message) { select { case <-msg.Nacked(): t.Fatal("nack should be not sent") default: // ok } } ================================================ FILE: message/messages.go ================================================ package message // Messages is a slice of messages. type Messages []*Message // IDs returns a slice of Messages' IDs. func (m Messages) IDs() []string { ids := make([]string, len(m)) for i, msg := range m { ids[i] = msg.UUID } return ids } ================================================ FILE: message/messages_test.go ================================================ package message_test import ( "testing" "github.com/ThreeDotsLabs/watermill/message" "github.com/stretchr/testify/assert" ) func TestMessages_IDs(t *testing.T) { msgs := message.Messages{ message.NewMessage("1", nil), message.NewMessage("2", nil), message.NewMessage("3", nil), } assert.Equal(t, []string{"1", "2", "3"}, msgs.IDs()) } ================================================ FILE: message/metadata.go ================================================ package message // Metadata is sent with every message to provide extra context without unmarshaling the message payload. type Metadata map[string]string // Get returns the metadata value for the given key. If the key is not found, an empty string is returned. func (m Metadata) Get(key string) string { if v, ok := m[key]; ok { return v } return "" } // Set sets the metadata key to value. func (m Metadata) Set(key, value string) { m[key] = value } ================================================ FILE: message/pubsub.go ================================================ package message import ( "context" ) // Publisher is the emitting part of a Pub/Sub. type Publisher interface { // Publish publishes provided messages to the given topic. // // Publish can be synchronous or asynchronous - it depends on the implementation. // // Most publisher implementations don't support atomic publishing of messages. // This means that if publishing one of the messages fails, the next messages will not be published. // // Publish does not work with a single Context. // Use the Context() method of each message instead. // // Publish must be thread safe. Publish(topic string, messages ...*Message) error // Close should flush unsent messages if publisher is async. Close() error } // Subscriber is the consuming part of the Pub/Sub. type Subscriber interface { // Subscribe returns an output channel with messages from the provided topic. // The channel is closed after Close() is called on the subscriber. // // To receive the next message, `Ack()` must be called on the received message. // If message processing fails and the message should be redelivered `Nack()` should be called instead. // // When the provided ctx is canceled, the subscriber closes the subscription and the output channel. // The provided ctx is passed to all produced messages. // When Nack or Ack is called on the message, the context of the message is canceled. Subscribe(ctx context.Context, topic string) (<-chan *Message, error) // Close closes all subscriptions with their output channels and flushes offsets etc. when needed. Close() error } // SubscribeInitializer is used to initialize subscribers. type SubscribeInitializer interface { // SubscribeInitialize can be called to initialize subscribe before consume. // When calling Subscribe before Publish, SubscribeInitialize should be not required. // // Not every Pub/Sub requires this initialization, and it may be optional for performance improvements etc. // For detailed SubscribeInitialize functionality, please check Pub/Subs godoc. // // Implementing SubscribeInitialize is not obligatory. SubscribeInitialize(topic string) error } ================================================ FILE: message/router/middleware/circuit_breaker.go ================================================ package middleware import ( "github.com/ThreeDotsLabs/watermill/message" "github.com/sony/gobreaker" ) // CircuitBreaker is a middleware that wraps the handler in a circuit breaker. // Based on the configuration, the circuit breaker will fail fast if the handler keeps returning errors. // This is useful for preventing cascading failures. type CircuitBreaker struct { cb *gobreaker.CircuitBreaker } // NewCircuitBreaker returns a new CircuitBreaker middleware. // Refer to the gobreaker documentation for the available settings. func NewCircuitBreaker(settings gobreaker.Settings) CircuitBreaker { return CircuitBreaker{ cb: gobreaker.NewCircuitBreaker(settings), } } // Middleware returns the CircuitBreaker middleware. func (c CircuitBreaker) Middleware(h message.HandlerFunc) message.HandlerFunc { return func(msg *message.Message) ([]*message.Message, error) { out, err := c.cb.Execute(func() (interface{}, error) { return h(msg) }) var result []*message.Message if out != nil { result = out.([]*message.Message) } return result, err } } ================================================ FILE: message/router/middleware/circuit_breaker_test.go ================================================ package middleware_test import ( "errors" "testing" "time" "github.com/ThreeDotsLabs/watermill/message" "github.com/ThreeDotsLabs/watermill/message/router/middleware" "github.com/sony/gobreaker" "github.com/stretchr/testify/assert" ) func TestCircuitBreaker(t *testing.T) { t.Parallel() count := 0 failing := true h := middleware.NewCircuitBreaker( gobreaker.Settings{ Name: "test", Timeout: time.Millisecond * 50, }, ).Middleware(func(msg *message.Message) (messages []*message.Message, e error) { count++ if failing { return nil, errors.New("test error") } return nil, nil }) msg := message.NewMessage("1", nil) // The first 6 calls should fail and increment the count for i := 0; i < 6; i++ { _, err := h(msg) assert.Error(t, err) } assert.Equal(t, 6, count) // The next calls should fail and not increment the count (the circuit breaker is open) for i := 0; i < 4; i++ { _, err := h(msg) assert.Error(t, err) } assert.Equal(t, 6, count) time.Sleep(time.Millisecond * 100) failing = false // After a timeout, the Circuit Breaker is closed again for i := 0; i < 4; i++ { _, err := h(msg) assert.NoError(t, err) } assert.Equal(t, 10, count) } ================================================ FILE: message/router/middleware/correlation.go ================================================ package middleware import ( "github.com/ThreeDotsLabs/watermill/message" ) // CorrelationIDMetadataKey is used to store the correlation ID in metadata. const CorrelationIDMetadataKey = "correlation_id" // SetCorrelationID sets a correlation ID for the message. // // SetCorrelationID should be called when the message enters the system. // When message is produced in a request (for example HTTP), // message correlation ID should be the same as the request's correlation ID. func SetCorrelationID(id string, msg *message.Message) { if MessageCorrelationID(msg) != "" { return } msg.Metadata.Set(CorrelationIDMetadataKey, id) } // MessageCorrelationID returns correlation ID from the message. func MessageCorrelationID(message *message.Message) string { return message.Metadata.Get(CorrelationIDMetadataKey) } // CorrelationID adds correlation ID to all messages produced by the handler. // ID is based on ID from message received by handler. // // To make CorrelationID working correctly, SetCorrelationID must be called to first message entering the system. func CorrelationID(h message.HandlerFunc) message.HandlerFunc { return func(message *message.Message) ([]*message.Message, error) { producedMessages, err := h(message) correlationID := MessageCorrelationID(message) for _, msg := range producedMessages { SetCorrelationID(correlationID, msg) } return producedMessages, err } } ================================================ FILE: message/router/middleware/correlation_test.go ================================================ package middleware_test import ( "testing" "github.com/pkg/errors" "github.com/stretchr/testify/assert" "github.com/ThreeDotsLabs/watermill/message/router/middleware" "github.com/ThreeDotsLabs/watermill/message" ) func TestCorrelationID(t *testing.T) { handlerErr := errors.New("foo") handler := middleware.CorrelationID(func(msg *message.Message) ([]*message.Message, error) { return message.Messages{message.NewMessage("2", nil)}, handlerErr }) msg := message.NewMessage("1", nil) middleware.SetCorrelationID("correlation_id", msg) producedMsgs, err := handler(msg) assert.Equal(t, "2", producedMsgs[0].UUID) assert.Equal(t, middleware.MessageCorrelationID(producedMsgs[0]), "correlation_id") assert.Equal(t, handlerErr, err) } func TestSetCorrelationID_already_set(t *testing.T) { msg := message.NewMessage("", nil) middleware.SetCorrelationID("foo", msg) middleware.SetCorrelationID("bar", msg) assert.Equal(t, "foo", middleware.MessageCorrelationID(msg)) } ================================================ FILE: message/router/middleware/deduplicator.go ================================================ package middleware import ( "bytes" "context" "crypto/sha256" "fmt" "hash/adler32" "io" "math" "sync" "time" "github.com/ThreeDotsLabs/watermill/message" "github.com/pkg/errors" ) // MessageHasherReadLimitMinimum specifies the least number // of bytes of a [message.Message] are used for calculating // their hash values using a [MessageHasher]. const MessageHasherReadLimitMinimum = 64 // ExpiringKeyRepository is a state container for checking the // existence of a key in a certain time window. // All operations must be safe for concurrent use. type ExpiringKeyRepository interface { // IsDuplicate returns `true` if the key // was not checked in recent past. // The key must expire in a certain time window. IsDuplicate(ctx context.Context, key string) (ok bool, err error) } // MessageHasher returns a short tag that describes // a message. The tag should be unique per message, // but avoiding hash collisions entirely is not practical // for performance reasons. Used for powering [Deduplicator]s. type MessageHasher func(*message.Message) (string, error) // Deduplicator drops similar messages if they are present // in a [ExpiringKeyRepository]. The similarity is determined // by a [MessageHasher]. Time out is applied to repository // operations using [context.WithTimeout]. // // Call [Deduplicator.Middleware] for a new middleware // or [Deduplicator.Decorator] for a [message.PublisherDecorator]. // // KeyFactory defaults to [NewMessageHasherAdler32] with read // limit set to [math.MaxInt64] for fast tagging. // Use [NewMessageHasherSHA256] for minimal collisions. // // Repository defaults to [NewMapExpiringKeyRepository] with one // minute retention window. This default setting is performant // but **does not support distributed operations**. If you // implement a [ExpiringKeyRepository] backed by Redis, // please submit a pull request. // // Timeout defaults to one minute. If lower than // five milliseconds, it is set to five milliseconds. // // [ExpiringKeyRepository] must expire values // in a certain time window. If there is no expiration, only one // unique message will be ever delivered as long as the repository // keeps its state. type Deduplicator struct { KeyFactory MessageHasher Repository ExpiringKeyRepository Timeout time.Duration } // IsDuplicate returns true if the message hash tag calculated // using a [MessageHasher] was seen in deduplication time window. func (d *Deduplicator) IsDuplicate(m *message.Message) (bool, error) { key, err := d.KeyFactory(m) if err != nil { return false, err } ctx, cancel := context.WithTimeout(m.Context(), d.Timeout) defer cancel() return d.Repository.IsDuplicate(ctx, key) } func applyDefaultsToDeduplicator(d *Deduplicator) *Deduplicator { if d == nil { kr, err := NewMapExpiringKeyRepository(time.Minute) if err != nil { panic(err) } return &Deduplicator{ KeyFactory: NewMessageHasherAdler32(math.MaxInt64), Repository: kr, Timeout: time.Minute, } } if d.KeyFactory == nil { d.KeyFactory = NewMessageHasherAdler32(math.MaxInt64) } if d.Repository == nil { kr, err := NewMapExpiringKeyRepository(time.Minute) if err != nil { panic(err) } d.Repository = kr } if d.Timeout < time.Millisecond*5 { d.Timeout = time.Millisecond * 5 } return d } // Middleware returns the [message.HandlerMiddleware] // that drops similar messages in a given time window. func (d *Deduplicator) Middleware(h message.HandlerFunc) message.HandlerFunc { d = applyDefaultsToDeduplicator(d) return func(msg *message.Message) ([]*message.Message, error) { isDuplicate, err := d.IsDuplicate(msg) if err != nil { return nil, err } if isDuplicate { return nil, nil } return h(msg) } } type mapExpiringKeyRepository struct { window time.Duration mu *sync.Mutex tags map[string]time.Time } // NewMapExpiringKeyRepository returns a memory store // backed by a regular hash map protected by // a [sync.Mutex]. The state **cannot be shared or synchronized // between instances** by design for performance. // // If you need to drop duplicate messages by orchestration, // implement [ExpiringKeyRepository] interface backed by Redis // or similar. // // Window specifies the minimum duration of how long the // duplicate tags are remembered for. Real duration can // extend up to 50% longer because it depends on the // clean up cycle. func NewMapExpiringKeyRepository(window time.Duration) (ExpiringKeyRepository, error) { if window < time.Millisecond { return nil, errors.New("deduplication window of less than a millisecond is impractical") } kr := &mapExpiringKeyRepository{ window: window, mu: &sync.Mutex{}, tags: make(map[string]time.Time), } ticker := time.NewTicker(window / 2) go kr.cleanOutLoop(context.Background(), ticker) return kr, nil } func (kr *mapExpiringKeyRepository) IsDuplicate( ctx context.Context, key string, ) (bool, error) { kr.mu.Lock() _, alreadySeen := kr.tags[key] if alreadySeen { // NOTE: could also check if key expires.After(t) // and remove it for exact expiration // instead of fuzzy until-next clean up expiration // but this should not be needed for most use cases. kr.mu.Unlock() return true, nil } kr.tags[key] = time.Now().Add(kr.window) kr.mu.Unlock() return false, nil } func (kr *mapExpiringKeyRepository) cleanOutLoop(ctx context.Context, ticker *time.Ticker) { for { select { case <-ctx.Done(): return // execution ended, part the go routine case tagsBefore := <-ticker.C: kr.cleanOut(tagsBefore) } } } func (kr *mapExpiringKeyRepository) cleanOut(tagsBefore time.Time) { kr.mu.Lock() defer kr.mu.Unlock() for hash, expires := range kr.tags { if expires.Before(tagsBefore) { delete(kr.tags, hash) } } } // Len returns the number of known tags that have not been // cleaned out yet. func (kr *mapExpiringKeyRepository) Len() (count int) { kr.mu.Lock() count = len(kr.tags) kr.mu.Unlock() return } // NewMessageHasherAdler32 generates message hashes using a fast // Adler-32 checksum of the [message.Message] body. Read // limit specifies how many bytes of the message are // used for calculating the hash. // // Lower limit improves performance but results in more false // positives. Read limit must be greater than // [MessageHasherReadLimitMinimum]. func NewMessageHasherAdler32(readLimit int64) MessageHasher { if readLimit < MessageHasherReadLimitMinimum { readLimit = MessageHasherReadLimitMinimum } return func(m *message.Message) (string, error) { h := adler32.New() _, err := io.CopyN(h, bytes.NewReader(m.Payload), readLimit) if err != nil && err != io.EOF { return "", err } return string(h.Sum(nil)), nil } } // NewMessageHasherSHA256 generates message hashes using a slower // but more resilient hashing of the [message.Message] body. Read // limit specifies how many bytes of the message are // used for calculating the hash. // // Lower limit improves performance but results in more false // positives. Read limit must be greater than // [MessageHasherReadLimitMinimum]. func NewMessageHasherSHA256(readLimit int64) MessageHasher { if readLimit < MessageHasherReadLimitMinimum { readLimit = MessageHasherReadLimitMinimum } return func(m *message.Message) (string, error) { h := sha256.New() _, err := io.CopyN(h, bytes.NewReader(m.Payload), readLimit) if err != nil && err != io.EOF { return "", err } return string(h.Sum(nil)), nil } } // NewMessageHasherFromMetadataField looks for a hash value // inside message metadata instead of calculating a new one. // Useful if a [MessageHasher] was applied in a previous // [message.HandlerFunc]. func NewMessageHasherFromMetadataField(field string) MessageHasher { return func(m *message.Message) (string, error) { fromMetadata, ok := m.Metadata[field] if ok { return fromMetadata, nil } return "", fmt.Errorf("cannot recover hash value from metadata of message #%s: field %q is absent", m.UUID, field) } } type deduplicatingPublisherDecorator struct { message.Publisher deduplicator *Deduplicator } func (d *deduplicatingPublisherDecorator) Publish( topic string, messages ...*message.Message, ) (err error) { notRecent := make([]*message.Message, 0, len(messages)) isDuplicate := false for _, m := range messages { isDuplicate, err = d.deduplicator.IsDuplicate(m) if err != nil { return err } if isDuplicate { m.Ack() // acknowledge and ignore continue } notRecent = append(notRecent, m) } return d.Publisher.Publish(topic, notRecent...) } // PublisherDecorator returns a decorator that // acknowledges and drops every [message.Message] that // was recognized by a [Deduplicator]. // // The returned decorator provides the same functionality // to a [message.Publisher] as [Deduplicator.Middleware] // to a [message.Router]. func (d *Deduplicator) PublisherDecorator() message.PublisherDecorator { return func(pub message.Publisher) (message.Publisher, error) { if pub == nil { return nil, errors.New("cannot decorate a publisher") } return &deduplicatingPublisherDecorator{ Publisher: pub, deduplicator: applyDefaultsToDeduplicator(d), }, nil } } ================================================ FILE: message/router/middleware/deduplicator_test.go ================================================ package middleware_test import ( "context" "fmt" "testing" "time" "github.com/ThreeDotsLabs/watermill/message" "github.com/ThreeDotsLabs/watermill/message/router/middleware" "github.com/ThreeDotsLabs/watermill/pubsub/gochannel" "github.com/stretchr/testify/assert" ) func TestDeduplicatorMiddleware(t *testing.T) { t.Parallel() count := 0 d := &middleware.Deduplicator{ KeyFactory: middleware.NewMessageHasherAdler32(1024), // KeyFactory: middleware.NewMessageHasherSHA256(1024), Timeout: time.Second, } h := d.Middleware(func(msg *message.Message) (messages []*message.Message, e error) { count++ return nil, nil }) for i := 0; i < 6; i++ { // only one should go through msg := message.NewMessage( fmt.Sprintf("first%d", i), []byte("1"), ) _, err := h(msg) assert.NoError(t, err) } for i := 0; i < 2; i++ { // only one should go through msg := message.NewMessage( fmt.Sprintf("second%d", i), []byte("2"), ) _, err := h(msg) assert.NoError(t, err) } assert.Equal(t, 2, count) } func TestDeduplicatorPublisherDecorator(t *testing.T) { t.Parallel() pubSub := gochannel.NewGoChannel(gochannel.Config{ OutputChannelBuffer: 100, Persistent: true, }, nil) defer pubSub.Close() const testDedupeTopic = "testTopic" ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*50) defer cancel() d := &middleware.Deduplicator{ KeyFactory: middleware.NewMessageHasherAdler32(1024), // KeyFactory: middleware.NewMessageHasherSHA256(1024), Timeout: time.Second, } decorated, err := d.PublisherDecorator()(pubSub) assert.NoError(t, err) for i := 0; i < 6; i++ { // only one should go through msg := message.NewMessage( fmt.Sprintf("first%d", i), []byte("1"), ) err := decorated.Publish(testDedupeTopic, msg) assert.NoError(t, err) } for i := 0; i < 2; i++ { // only one should go through msg := message.NewMessage( fmt.Sprintf("second%d", i), []byte("2"), ) err := decorated.Publish(testDedupeTopic, msg) assert.NoError(t, err) } got, err := pubSub.Subscribe(ctx, testDedupeTopic) assert.NoError(t, err) count := 0 for m := range got { count++ m.Ack() t.Log("got message:", m.UUID) } assert.Equal(t, 2, count) } func TestMessageHasherAdler32(t *testing.T) { t.Parallel() short := middleware.NewMessageHasherAdler32(0) full := middleware.NewMessageHasherAdler32(middleware.MessageHasherReadLimitMinimum) msg := message.NewMessage("adlerTest", []byte("some random data")) h1, err := short(msg) assert.NoError(t, err) h2, err := full(msg) assert.NoError(t, err) if h1 != h2 { t.Fatal("MessageHasherReadLimitMinimum did not apply to Adler32 message hasher") } } func TestMessageHasherSHA256(t *testing.T) { t.Parallel() short := middleware.NewMessageHasherSHA256(0) full := middleware.NewMessageHasherSHA256(middleware.MessageHasherReadLimitMinimum) msg := message.NewMessage("adlerTest", []byte("some random data")) h1, err := short(msg) assert.NoError(t, err) h2, err := full(msg) assert.NoError(t, err) if h1 != h2 { t.Fatal("MessageHasherReadLimitMinimum did not apply to SHA256 message hasher") } } func TestMessageHasherFromMetadataField(t *testing.T) { t.Parallel() field := "hash" value := "someHash" msg := message.NewMessage("one", []byte("1")) msg.Metadata[field] = value metadataPull := middleware.NewMessageHasherFromMetadataField(field) h, err := metadataPull(msg) assert.NoError(t, err) assert.Equal(t, h, value) delete(msg.Metadata, field) // empty out _, err = metadataPull(msg) assert.Error(t, err) } func TestMapExpiringKeyRepositoryCleanup(t *testing.T) { t.Parallel() wait := time.Millisecond * 5 kr, err := middleware.NewMapExpiringKeyRepository(wait) if err != nil { t.Fatal(err) } count := 0 d := &middleware.Deduplicator{ Repository: kr, KeyFactory: middleware.NewMessageHasherAdler32(1024), Timeout: time.Second, } h := d.Middleware(func(msg *message.Message) (messages []*message.Message, e error) { count++ return nil, nil }) for i := 0; i < 6; i++ { // only one should go through msg := message.NewMessage( fmt.Sprintf("expiring%d", i), fmt.Appendf(nil, "expiring%d", i), ) _, err := h(msg) assert.NoError(t, err) } type supportsLen interface { Len() int } measurable, ok := kr.(supportsLen) if !ok { t.Fatal("repository does not allow measuring its length") } if l := measurable.Len(); l != 6 { t.Errorf("expected 6 tags, but %d remain", l) } assert.Eventually( t, func() bool { return count == 6 }, wait*3, time.Millisecond, "sent six messages, but only received %d", count, ) assert.Eventually( t, func() bool { return measurable.Len() == 0 }, wait*3, time.Millisecond, "tags should have been cleaned out, but %d remain", measurable.Len(), ) } ================================================ FILE: message/router/middleware/delay_on_error.go ================================================ package middleware import ( "time" "github.com/ThreeDotsLabs/watermill/components/delay" "github.com/ThreeDotsLabs/watermill/message" ) // DelayOnError is a middleware that adds the delay metadata to the message if an error occurs. // // IMPORTANT: The delay metadata doesn't cause delays with all Pub/Subs! Using it won't have any effect on Pub/Subs that don't support it. // See the list of supported Pub/Subs in the documentation: https://watermill.io/advanced/delayed-messages/ type DelayOnError struct { // InitialInterval is the first interval between retries. Subsequent intervals will be scaled by Multiplier. InitialInterval time.Duration // MaxInterval sets the limit for the exponential backoff of retries. The interval will not be increased beyond MaxInterval. MaxInterval time.Duration // Multiplier is the factor by which the waiting interval will be multiplied between retries. Multiplier float64 } func (d *DelayOnError) Middleware(h message.HandlerFunc) message.HandlerFunc { return func(msg *message.Message) ([]*message.Message, error) { msgs, err := h(msg) if err != nil { d.applyDelay(msg) } return msgs, err } } func (d *DelayOnError) applyDelay(msg *message.Message) { delayedForStr := msg.Metadata.Get(delay.DelayedForKey) delayedFor, err := time.ParseDuration(delayedForStr) if delayedForStr != "" && err == nil { delayedFor *= time.Duration(d.Multiplier) if delayedFor > d.MaxInterval { delayedFor = d.MaxInterval } delay.Message(msg, delay.For(delayedFor)) } else { delay.Message(msg, delay.For(d.InitialInterval)) } } ================================================ FILE: message/router/middleware/delay_on_error_test.go ================================================ package middleware_test import ( "errors" "testing" "time" "github.com/stretchr/testify/assert" "github.com/ThreeDotsLabs/watermill/components/delay" "github.com/ThreeDotsLabs/watermill/message" "github.com/ThreeDotsLabs/watermill/message/router/middleware" ) func TestDelayOnError(t *testing.T) { m := middleware.DelayOnError{ InitialInterval: time.Second, MaxInterval: time.Second * 10, Multiplier: 2, } msg := message.NewMessage("1", []byte("test")) getDelayFor := func(msg *message.Message) string { return msg.Metadata.Get(delay.DelayedForKey) } okHandler := func(msg *message.Message) ([]*message.Message, error) { return nil, nil } errHandler := func(msg *message.Message) ([]*message.Message, error) { return nil, errors.New("error") } assert.Equal(t, "", getDelayFor(msg)) _, _ = m.Middleware(okHandler)(msg) assert.Equal(t, "", getDelayFor(msg)) _, _ = m.Middleware(errHandler)(msg) assert.Equal(t, "1s", getDelayFor(msg)) _, _ = m.Middleware(errHandler)(msg) assert.Equal(t, "2s", getDelayFor(msg)) _, _ = m.Middleware(errHandler)(msg) assert.Equal(t, "4s", getDelayFor(msg)) _, _ = m.Middleware(errHandler)(msg) assert.Equal(t, "8s", getDelayFor(msg)) _, _ = m.Middleware(errHandler)(msg) assert.Equal(t, "10s", getDelayFor(msg)) _, _ = m.Middleware(errHandler)(msg) assert.Equal(t, "10s", getDelayFor(msg)) } ================================================ FILE: message/router/middleware/duplicator.go ================================================ package middleware import ( "github.com/ThreeDotsLabs/watermill/message" ) // Duplicator is processing messages twice, to ensure that the endpoint is idempotent. func Duplicator(h message.HandlerFunc) message.HandlerFunc { return func(msg *message.Message) ([]*message.Message, error) { firstProducedMessages, firstErr := h(msg) if firstErr != nil { return nil, firstErr } secondProducedMessages, secondErr := h(msg) if secondErr != nil { return nil, secondErr } return append(firstProducedMessages, secondProducedMessages...), nil } } ================================================ FILE: message/router/middleware/duplicator_test.go ================================================ package middleware_test import ( "errors" "testing" "github.com/stretchr/testify/assert" "github.com/ThreeDotsLabs/watermill/message" "github.com/ThreeDotsLabs/watermill/message/router/middleware" ) var ( someMsg = message.NewMessage("1", nil) ) func TestDuplicator(t *testing.T) { var executionsCount int producedMessages, err := middleware.Duplicator(func(msg *message.Message) ([]*message.Message, error) { executionsCount++ return []*message.Message{msg}, nil })(someMsg) assert.NoError(t, err) assert.Len(t, producedMessages, 2) assert.Equal(t, "1", producedMessages[0].UUID) assert.Equal(t, "1", producedMessages[1].UUID) assert.Equal(t, 2, executionsCount) } func TestDuplicator_errors(t *testing.T) { _, err := middleware.Duplicator(func(msg *message.Message) ([]*message.Message, error) { return nil, errors.New("some error") })(someMsg) assert.Error(t, err, "some error") var wasExecuted bool _, err = middleware.Duplicator(func(msg *message.Message) ([]*message.Message, error) { if wasExecuted { return nil, errors.New("some other error") } wasExecuted = true return []*message.Message{msg}, nil })(someMsg) assert.Error(t, err, "some other error") } ================================================ FILE: message/router/middleware/ignore_errors.go ================================================ package middleware import ( "github.com/ThreeDotsLabs/watermill/message" "github.com/pkg/errors" ) // IgnoreErrors provides a middleware that makes the handler ignore some explicitly whitelisted errors. type IgnoreErrors struct { ignoredErrors map[string]struct{} } // NewIgnoreErrors creates a new IgnoreErrors middleware. func NewIgnoreErrors(errs []error) IgnoreErrors { errsMap := make(map[string]struct{}, len(errs)) for _, err := range errs { errsMap[err.Error()] = struct{}{} } return IgnoreErrors{errsMap} } // Middleware returns the IgnoreErrors middleware. func (i IgnoreErrors) Middleware(h message.HandlerFunc) message.HandlerFunc { return func(msg *message.Message) ([]*message.Message, error) { events, err := h(msg) if err != nil { if _, ok := i.ignoredErrors[errors.Cause(err).Error()]; ok { return events, nil } return events, err } return events, nil } } ================================================ FILE: message/router/middleware/ignore_errors_test.go ================================================ package middleware_test import ( "testing" "github.com/ThreeDotsLabs/watermill/message" "github.com/ThreeDotsLabs/watermill/message/router/middleware" "github.com/pkg/errors" "github.com/stretchr/testify/assert" ) func TestIgnoreErrors_Middleware(t *testing.T) { testCases := []struct { Name string IgnoredErrors []error TestError error ShouldBeIgnored bool }{ { Name: "ignored_error", IgnoredErrors: []error{errors.New("test")}, TestError: errors.New("test"), ShouldBeIgnored: true, }, { Name: "not_ignored_error", IgnoredErrors: []error{errors.New("test")}, TestError: errors.New("not_ignored"), ShouldBeIgnored: false, }, { Name: "wrapped_error_should_ignore", IgnoredErrors: []error{errors.New("test")}, TestError: errors.Wrap(errors.New("test"), "wrapped"), ShouldBeIgnored: true, }, } for _, c := range testCases { t.Run(c.Name, func(t *testing.T) { m := middleware.NewIgnoreErrors(c.IgnoredErrors) messagesToProduce := []*message.Message{message.NewMessage("1", nil)} producedMessages, err := m.Middleware(func(msg *message.Message) ([]*message.Message, error) { return messagesToProduce, c.TestError })(nil) if c.ShouldBeIgnored { assert.NoError(t, err) } else { assert.Equal(t, c.TestError, err) } assert.Equal(t, messagesToProduce, producedMessages) }) } } ================================================ FILE: message/router/middleware/instant_ack.go ================================================ package middleware import "github.com/ThreeDotsLabs/watermill/message" // InstantAck makes the handler instantly acknowledge the incoming message, regardless of any errors. // It may be used to gain throughput, but at a cost: // If you had exactly-once delivery, you may expect at-least-once instead. // If you had ordered messages, the ordering might be broken. func InstantAck(h message.HandlerFunc) message.HandlerFunc { return func(message *message.Message) ([]*message.Message, error) { message.Ack() return h(message) } } ================================================ FILE: message/router/middleware/instant_ack_test.go ================================================ package middleware import ( "testing" "github.com/ThreeDotsLabs/watermill/message" "github.com/pkg/errors" "github.com/stretchr/testify/assert" ) func TestInstantAck(t *testing.T) { producedMessages := message.Messages{message.NewMessage("2", nil)} producedErr := errors.New("foo") h := InstantAck(func(msg *message.Message) (messages []*message.Message, e error) { return producedMessages, producedErr }) msg := message.NewMessage("1", nil) handlerMessages, handlerErr := h(msg) assert.EqualValues(t, producedMessages, handlerMessages) assert.Equal(t, producedErr, handlerErr) select { case <-msg.Acked(): // ok case <-msg.Nacked(): t.Fatal("expected ack, not nack") default: t.Fatal("no ack received") } } ================================================ FILE: message/router/middleware/message_test.go ================================================ package middleware_test import ( "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill/message" "github.com/pkg/errors" ) type mockPublisherBehaviour int const ( BehaviourAlwaysOK mockPublisherBehaviour = iota + 1 BehaviourAlwaysFail BehaviourAlwaysPanic ) var ( errClosed = errors.New("closed") errFailed = errors.New("failed") errPanicked = errors.New("panicked") ) type mockPublisher struct { behaviour mockPublisherBehaviour closed bool produced []*message.Message } func (mp *mockPublisher) Publish(topic string, messages ...*message.Message) error { if mp.closed { return errClosed } switch mp.behaviour { case BehaviourAlwaysOK: case BehaviourAlwaysFail: return errFailed case BehaviourAlwaysPanic: panic(errPanicked) } mp.produced = append(mp.produced, messages...) return nil } func (mp *mockPublisher) Close() error { mp.closed = true return nil } func (mp *mockPublisher) PopMessages() []*message.Message { defer func() { mp.produced = []*message.Message{} }() return mp.produced } var handlerFuncAlwaysOKMessages = []*message.Message{ message.NewMessage(watermill.NewUUID(), nil), message.NewMessage(watermill.NewUUID(), nil), } func handlerFuncAlwaysOK(*message.Message) ([]*message.Message, error) { return handlerFuncAlwaysOKMessages, nil } func handlerFuncAlwaysFailing(*message.Message) ([]*message.Message, error) { return nil, errFailed } ================================================ FILE: message/router/middleware/poison.go ================================================ package middleware import ( stdErrors "errors" "github.com/ThreeDotsLabs/watermill/message" "github.com/pkg/errors" ) // ErrInvalidPoisonQueueTopic occurs when the topic supplied to the PoisonQueue constructor is invalid. var ErrInvalidPoisonQueueTopic = errors.New("invalid poison queue topic") // Metadata keys which marks the reason and context why the message was deemed poisoned. const ( ReasonForPoisonedKey = "reason_poisoned" PoisonedTopicKey = "topic_poisoned" PoisonedHandlerKey = "handler_poisoned" PoisonedSubscriberKey = "subscriber_poisoned" ) type poisonQueue struct { topic string pub message.Publisher shouldGoToPoisonQueue func(err error) bool } // PoisonQueue provides a middleware that salvages unprocessable messages and published them on a separate topic. // The main middleware chain then continues on, business as usual. func PoisonQueue(pub message.Publisher, topic string) (message.HandlerMiddleware, error) { if topic == "" { return nil, ErrInvalidPoisonQueueTopic } pq := poisonQueue{ topic: topic, pub: pub, shouldGoToPoisonQueue: func(err error) bool { return true }, } return pq.Middleware, nil } // PoisonQueueWithFilter is just like PoisonQueue, but accepts a function that decides which errors qualify for the poison queue. func PoisonQueueWithFilter(pub message.Publisher, topic string, shouldGoToPoisonQueue func(err error) bool) (message.HandlerMiddleware, error) { if topic == "" { return nil, ErrInvalidPoisonQueueTopic } pq := poisonQueue{ topic: topic, pub: pub, shouldGoToPoisonQueue: shouldGoToPoisonQueue, } return pq.Middleware, nil } func (pq poisonQueue) publishPoisonMessage(msg *message.Message, err error) error { // no problems encountered, carry on if err == nil { return nil } // add context why it was poisoned msg.Metadata.Set(ReasonForPoisonedKey, err.Error()) msg.Metadata.Set(PoisonedTopicKey, message.SubscribeTopicFromCtx(msg.Context())) msg.Metadata.Set(PoisonedHandlerKey, message.HandlerNameFromCtx(msg.Context())) msg.Metadata.Set(PoisonedSubscriberKey, message.SubscriberNameFromCtx(msg.Context())) // don't intercept error from publish. Can't help you if the publisher is down as well. return pq.pub.Publish(pq.topic, msg) } func (pq poisonQueue) Middleware(h message.HandlerFunc) message.HandlerFunc { return func(msg *message.Message) (events []*message.Message, err error) { defer func() { if err != nil { if !pq.shouldGoToPoisonQueue(err) { return } // handler didn't cope with the message; publish it on the poison topic and carry on as usual publishErr := pq.publishPoisonMessage(msg, err) if publishErr != nil { publishErr = errors.Wrap(publishErr, "cannot publish message to poison queue") err = stdErrors.Join(err, publishErr) return } err = nil return } }() // if h fails, the deferred function will salvage all that it can return h(msg) } } ================================================ FILE: message/router/middleware/poison_test.go ================================================ package middleware_test import ( "context" stdErrors "errors" "testing" "time" "github.com/pkg/errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill/message" "github.com/ThreeDotsLabs/watermill/message/router/middleware" "github.com/ThreeDotsLabs/watermill/message/subscriber" "github.com/ThreeDotsLabs/watermill/pubsub/gochannel" ) const topic = "testing_poison_queue_topic" // TestPoisonQueue_handler_ok simulates the situation when the message is processed correctly // We expect that all messages pass through the middleware unaffected and the poison queue catches no messages. func TestPoisonQueue_handler_ok(t *testing.T) { poisonPublisher := mockPublisher{behaviour: BehaviourAlwaysOK} poisonQueue, err := middleware.PoisonQueue(&poisonPublisher, topic) require.NoError(t, err) poisonQueueWithFilter, err := middleware.PoisonQueueWithFilter(&poisonPublisher, topic, func(err error) bool { return true }) require.NoError(t, err) testCases := []struct { Name string Middleware message.HandlerMiddleware }{ { Name: "PoisonQueue", Middleware: poisonQueue, }, { Name: "PoisonQueueWithFilter", Middleware: poisonQueueWithFilter, }, } for _, c := range testCases { t.Run(c.Name, func(t *testing.T) { produced, err := c.Middleware(handlerFuncAlwaysOK)( message.NewMessage("uuid", nil), ) assert.NoError(t, err) assert.Equal(t, handlerFuncAlwaysOKMessages, produced) assert.Empty(t, poisonPublisher.PopMessages()) }) } } func TestPoisonQueue_handler_failing(t *testing.T) { poisonPublisher := mockPublisher{behaviour: BehaviourAlwaysOK} poisonQueue, err := middleware.PoisonQueue(&poisonPublisher, topic) require.NoError(t, err) poisonQueueWithFilter, err := middleware.PoisonQueueWithFilter(&poisonPublisher, topic, func(err error) bool { return true }) require.NoError(t, err) testCases := []struct { Name string Middleware message.HandlerMiddleware }{ { Name: "PoisonQueue", Middleware: poisonQueue, }, { Name: "PoisonQueueWithFilter", Middleware: poisonQueueWithFilter, }, } for _, c := range testCases { t.Run(c.Name, func(t *testing.T) { msg := message.NewMessage("uuid", []byte("payload")) produced, err := c.Middleware(handlerFuncAlwaysFailing)( msg, ) // the middleware itself should not fail; the publisher is working OK, so no error is passed down the chain assert.NoError(t, err) // but no messages should be passed assert.Empty(t, produced) // the original message should end up in the poison queue poisonMsgs := poisonPublisher.PopMessages() require.Len(t, poisonMsgs, 1) assert.Equal(t, msg.Payload, poisonMsgs[0].Payload) // there should be additional metadata telling why the message was poisoned // it should be the error that the handler failed with assert.Equal(t, errFailed.Error(), poisonMsgs[0].Metadata.Get(middleware.ReasonForPoisonedKey)) }) } } func TestPoisonQueue_context_values(t *testing.T) { pubSub := gochannel.NewGoChannel( gochannel.Config{Persistent: true}, watermill.NewStdLogger(true, true), ) logger := watermill.NewStdLogger(true, true) router, err := message.NewRouter(message.RouterConfig{}, logger) require.NoError(t, err) pq, err := middleware.PoisonQueue(pubSub, "poison_queue") require.NoError(t, err) router.AddMiddleware(pq) router.AddConsumerHandler("handler_name", "test", pubSub, func(msg *message.Message) error { return errors.New("error") }) go func() { require.NoError(t, router.Run(context.Background())) }() require.NoError(t, err) defer router.Close() select { case <-router.Running(): // ok case <-time.After(time.Second): t.Fatal("waiting for router timeout") } err = pubSub.Publish("test", message.NewMessage("1", nil)) require.NoError(t, err) msgs, err := pubSub.Subscribe(context.Background(), "poison_queue") require.NoError(t, err) messages, all := subscriber.BulkRead(msgs, 1, time.Second) require.True(t, all, "no messages received") assert.Equal(t, "handler_name", messages[0].Metadata[middleware.PoisonedHandlerKey]) assert.Equal(t, "gochannel.GoChannel", messages[0].Metadata[middleware.PoisonedSubscriberKey]) assert.Equal(t, "test", messages[0].Metadata[middleware.PoisonedTopicKey]) assert.Equal(t, "error", messages[0].Metadata[middleware.ReasonForPoisonedKey]) } func TestPoisonQueue_handler_failing_publisher_failing(t *testing.T) { poisonPublisher := mockPublisher{behaviour: BehaviourAlwaysFail} poisonQueue, err := middleware.PoisonQueue(&poisonPublisher, topic) require.NoError(t, err) poisonQueueWithFilter, err := middleware.PoisonQueueWithFilter(&poisonPublisher, topic, func(err error) bool { return true }) require.NoError(t, err) testCases := []struct { Name string Middleware message.HandlerMiddleware }{ { Name: "PoisonQueue", Middleware: poisonQueue, }, { Name: "PoisonQueueWithFilter", Middleware: poisonQueueWithFilter, }, } for _, c := range testCases { t.Run(c.Name, func(t *testing.T) { msg := message.NewMessage("uuid", nil) produced, err := poisonQueue(handlerFuncAlwaysFailing)( msg, ) // publisher failed, can't hide the error anymore // Instead of checking the specific error, we check if the error.Is() is the same as the one we expect assert.ErrorIs(t, err, errFailed) // can't really expect any produced messages assert.Empty(t, produced) // nor poison messages assert.Empty(t, poisonPublisher.PopMessages()) }) } } func TestPoisonQueueWithFilter_poison_queue(t *testing.T) { poisonPublisher := mockPublisher{behaviour: BehaviourAlwaysOK} poisonQueueErr := errors.New("poison queue err") msg := message.NewMessage("uuid", []byte("payload")) poisonQueue, err := middleware.PoisonQueueWithFilter(&poisonPublisher, topic, func(err error) bool { return stdErrors.Is(err, poisonQueueErr) }) require.NoError(t, err) _, err = poisonQueue(func(msg *message.Message) (messages []*message.Message, e error) { return nil, poisonQueueErr })(msg) assert.NoError(t, err) require.Len(t, poisonPublisher.PopMessages(), 1) } func TestPoisonQueueWithFilter_non_poison_queue(t *testing.T) { poisonPublisher := mockPublisher{behaviour: BehaviourAlwaysOK} nonPoisonQueueErr := errors.New("non poison queue err") msg := message.NewMessage("uuid", []byte("payload")) poisonQueue, err := middleware.PoisonQueueWithFilter(&poisonPublisher, topic, func(err error) bool { return !stdErrors.Is(err, nonPoisonQueueErr) }) require.NoError(t, err) _, err = poisonQueue(func(msg *message.Message) (messages []*message.Message, e error) { return nil, nonPoisonQueueErr })(msg) assert.Error(t, err) require.Len(t, poisonPublisher.PopMessages(), 0) } ================================================ FILE: message/router/middleware/randomfail.go ================================================ package middleware import ( "math/rand" "github.com/ThreeDotsLabs/watermill/message" "github.com/pkg/errors" ) func shouldFail(probability float32) bool { r := rand.Float32() return r <= probability } // RandomFail makes the handler fail with an error based on random chance. Error probability should be in the range (0,1). func RandomFail(errorProbability float32) message.HandlerMiddleware { return func(h message.HandlerFunc) message.HandlerFunc { return func(message *message.Message) ([]*message.Message, error) { if shouldFail(errorProbability) { return nil, errors.New("random fail occurred") } return h(message) } } } // RandomPanic makes the handler panic based on random chance. Panic probability should be in the range (0,1). func RandomPanic(panicProbability float32) message.HandlerMiddleware { return func(h message.HandlerFunc) message.HandlerFunc { return func(message *message.Message) ([]*message.Message, error) { if shouldFail(panicProbability) { panic("random panic occurred") } return h(message) } } } ================================================ FILE: message/router/middleware/randomfail_test.go ================================================ package middleware_test import ( "testing" "github.com/ThreeDotsLabs/watermill/message" "github.com/ThreeDotsLabs/watermill/message/router/middleware" "github.com/stretchr/testify/assert" ) func TestRandomFail(t *testing.T) { h := middleware.RandomFail(1)(func(msg *message.Message) (messages []*message.Message, e error) { return nil, nil }) _, err := h(message.NewMessage("1", nil)) assert.Error(t, err) } func TestRandomPanic(t *testing.T) { h := middleware.RandomPanic(1)(func(msg *message.Message) (messages []*message.Message, e error) { return nil, nil }) assert.Panics(t, func() { _, _ = h(message.NewMessage("1", nil)) }) } ================================================ FILE: message/router/middleware/recoverer.go ================================================ package middleware import ( "fmt" "runtime/debug" "github.com/ThreeDotsLabs/watermill/message" "github.com/pkg/errors" ) // RecoveredPanicError holds the recovered panic's error along with the stacktrace. type RecoveredPanicError struct { V interface{} Stacktrace string } func (p RecoveredPanicError) Error() string { return fmt.Sprintf("panic occurred: %#v, stacktrace: \n%s", p.V, p.Stacktrace) } // Recoverer recovers from any panic in the handler and appends RecoveredPanicError with the stacktrace // to any error returned from the handler. func Recoverer(h message.HandlerFunc) message.HandlerFunc { return func(event *message.Message) (events []*message.Message, err error) { panicked := true defer func() { if r := recover(); r != nil || panicked { err = errors.WithStack(RecoveredPanicError{V: r, Stacktrace: string(debug.Stack())}) } }() events, err = h(event) panicked = false return events, err } } ================================================ FILE: message/router/middleware/recoverer_test.go ================================================ package middleware_test import ( "testing" "github.com/stretchr/testify/assert" "github.com/ThreeDotsLabs/watermill/message" "github.com/ThreeDotsLabs/watermill/message/router/middleware" "github.com/stretchr/testify/require" ) func TestRecoverer_Panic(t *testing.T) { h := middleware.Recoverer(func(msg *message.Message) (messages []*message.Message, e error) { panic("foo") }) _, err := h(message.NewMessage("1", nil)) require.Error(t, err) assert.Contains(t, err.Error(), "message/router/middleware/recoverer.go") // stacktrace part } func TestRecoverer_PanicNil(t *testing.T) { h := middleware.Recoverer(func(msg *message.Message) (messages []*message.Message, e error) { panic(nil) }) _, err := h(message.NewMessage("1", nil)) require.Error(t, err) } func TestRecoverer_NoPanic(t *testing.T) { h := middleware.Recoverer(func(msg *message.Message) (messages []*message.Message, e error) { return nil, nil }) _, err := h(message.NewMessage("1", nil)) require.NoError(t, err) } ================================================ FILE: message/router/middleware/retry.go ================================================ package middleware import ( "time" "github.com/cenkalti/backoff/v5" "github.com/pkg/errors" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill/message" ) // RetryParams holds the parameters for a retry attempt type RetryParams struct { // Err is the error that caused the retry attempt. Err error // RetryNum is the number of the retry attempt, starting from 1. RetryNum int // Delay is the delay for the next retry attempt. Delay time.Duration } // Retry provides a middleware that retries the handler if errors are returned. // The retry behaviour is configurable, with exponential backoff and maximum elapsed time. type Retry struct { // MaxRetries is maximum number of times a retry will be attempted. MaxRetries int // InitialInterval is the first interval between retries. Subsequent intervals will be scaled by Multiplier. InitialInterval time.Duration // MaxInterval sets the limit for the exponential backoff of retries. The interval will not be increased beyond MaxInterval. MaxInterval time.Duration // Multiplier is the factor by which the waiting interval will be multiplied between retries. Multiplier float64 // MaxElapsedTime sets the time limit of how long retries will be attempted. Disabled if 0. MaxElapsedTime time.Duration // RandomizationFactor randomizes the spread of the backoff times within the interval of: // [currentInterval * (1 - randomization_factor), currentInterval * (1 + randomization_factor)]. RandomizationFactor float64 // OnRetryHook is an optional function that will be executed on each retry attempt. // The number of the current retry is passed as retryNum, OnRetryHook func(retryNum int, delay time.Duration) // ShouldRetry is an optional function that will be executed before each retry attempt. // If ShouldRetry returns false, the retry will not be attempted. ShouldRetry func(params RetryParams) bool // ResetContextOnRetry indicates whether the message context should be reset on each retry attempt. // See more: https://github.com/ThreeDotsLabs/watermill/issues/467 // // This is not enabled by default to keep backward compatibility // (in theory, someone may want to preserve context values between retries). ResetContextOnRetry bool Logger watermill.LoggerAdapter } // Middleware returns the Retry middleware. func (r Retry) Middleware(h message.HandlerFunc) message.HandlerFunc { return func(msg *message.Message) ([]*message.Message, error) { originalCtx := msg.Context() retryNum := 0 expBackoff := backoff.NewExponentialBackOff() expBackoff.InitialInterval = r.InitialInterval expBackoff.MaxInterval = r.MaxInterval expBackoff.Multiplier = r.Multiplier expBackoff.RandomizationFactor = r.RandomizationFactor // MaxRetries + 1 because the first attempt is not a retry retryBackoff := backoff.WithMaxTries(uint(r.MaxRetries + 1)) maxElapsedBackoff := backoff.WithMaxElapsedTime(r.MaxElapsedTime) // notification: called on a failed retry attempt. notification := func(err error, delay time.Duration) { if r.Logger != nil { r.Logger.Error("Error occurred, retrying", err, watermill.LogFields{ "retry_no": retryNum, "max_retries": r.MaxRetries, "wait_time": delay, }) } } // operation: the function that will be retried. operation := func() ([]*message.Message, error) { select { case <-originalCtx.Done(): return nil, originalCtx.Err() default: if r.ResetContextOnRetry { // message is passed as a pointer, so it's context can be canceled // by the previous attempts -> it will break retries, because any // underlying logic that relies on the context will fail. // see more: https://github.com/ThreeDotsLabs/watermill/issues/467 // // to avoid this, we need to reset the original context on each attempt // we may lose context value that was set by the previous attempt msg.SetContext(originalCtx) } producedMessages, err := h(msg) if err == nil { return producedMessages, nil } if r.ShouldRetry != nil && !r.ShouldRetry(RetryParams{ RetryNum: retryNum, Err: err, Delay: expBackoff.NextBackOff(), }) { // backoff.Permanent will stop the retry attempts return producedMessages, backoff.Permanent(err) } if r.OnRetryHook != nil && retryNum > 0 { // call RetryHook function on each retry attempt. r.OnRetryHook(retryNum, expBackoff.NextBackOff()) } retryNum++ return producedMessages, err } } producedMessages, retryErr := backoff.Retry( originalCtx, operation, backoff.WithBackOff(expBackoff), retryBackoff, maxElapsedBackoff, backoff.WithNotify(notification), ) var backoffPermanentError *backoff.PermanentError if errors.As(retryErr, &backoffPermanentError) { // just in case, we don't want to expose backoff.PermanentError to the outside world return producedMessages, backoffPermanentError.Unwrap() } if retryErr != nil { return producedMessages, retryErr } return producedMessages, nil } } ================================================ FILE: message/router/middleware/retry_test.go ================================================ package middleware_test import ( "context" "testing" "time" "github.com/cenkalti/backoff/v5" "github.com/pkg/errors" "github.com/stretchr/testify/assert" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill/message" "github.com/ThreeDotsLabs/watermill/message/router/middleware" ) func TestRetry_retry(t *testing.T) { retry := middleware.Retry{ MaxRetries: 1, } runCount := 0 producedMessages := message.Messages{message.NewMessage("2", nil)} h := retry.Middleware(func(msg *message.Message) (messages []*message.Message, e error) { runCount++ if runCount == 0 { return nil, errors.New("foo") } return producedMessages, nil }) handlerMessages, handlerErr := h(message.NewMessage("1", nil)) assert.Equal(t, 1, runCount) assert.EqualValues(t, producedMessages, handlerMessages) assert.NoError(t, handlerErr) } func TestRetry_max_retries(t *testing.T) { retry := middleware.Retry{ MaxRetries: 1, Logger: watermill.NewStdLogger(true, true), } runCount := 0 h := retry.Middleware(func(msg *message.Message) (messages []*message.Message, e error) { runCount++ return nil, errors.New("foo") }) _, err := h(message.NewMessage("1", nil)) assert.Equal(t, 2, runCount) assert.EqualError(t, err, "foo") } func TestRetry_retry_hook(t *testing.T) { var retriesFromHook []int retry := middleware.Retry{ MaxRetries: 2, OnRetryHook: func(retryNum int, delay time.Duration) { retriesFromHook = append(retriesFromHook, retryNum) }, } h := retry.Middleware(func(msg *message.Message) (messages []*message.Message, e error) { return nil, errors.New("foo") }) _, _ = h(message.NewMessage("1", nil)) assert.EqualValues(t, []int{1, 2}, retriesFromHook) } func TestRetry_logger(t *testing.T) { logger := watermill.NewCaptureLogger() retry := middleware.Retry{ MaxRetries: 2, Logger: logger, } handlerErr := errors.New("foo") h := retry.Middleware(func(msg *message.Message) (messages []*message.Message, e error) { return nil, handlerErr }) _, _ = h(message.NewMessage("1", nil)) assert.True(t, logger.HasError(handlerErr)) } func TestRetry_ctx_cancel(t *testing.T) { retry := middleware.Retry{ InitialInterval: time.Minute, } producedMessages := message.Messages{message.NewMessage("2", nil)} h := retry.Middleware(func(msg *message.Message) (messages []*message.Message, e error) { return producedMessages, errors.New("err") }) msg := message.NewMessage("1", nil) ctx, cancel := context.WithCancel(context.Background()) defer cancel() msg.SetContext(ctx) done := make(chan struct{}) type handlerResult struct { Messages message.Messages Err error } handlerResultCh := make(chan handlerResult, 1) go func() { messages, err := h(msg) handlerResultCh <- handlerResult{messages, err} close(done) }() select { case <-done: t.Fatal("handler should be still during retrying") default: // ok } cancel() select { case <-done: // ok case <-time.After(time.Second): t.Fatal("ctx cancelled, retrying should be done") } handlerResultReceived := <-handlerResultCh assert.Error(t, handlerResultReceived.Err) // produced messages are nil since ctx is canceling the operation in the middle assert.Nil(t, handlerResultReceived.Messages) } func TestRetry_max_elapsed(t *testing.T) { maxRetries := 10 sleepInHandler := time.Millisecond * 20 retry := middleware.Retry{ MaxElapsedTime: time.Millisecond * 10, MaxRetries: maxRetries, } runTimeWithoutMaxElapsedTime := sleepInHandler * time.Duration(maxRetries) h := retry.Middleware(func(msg *message.Message) (messages []*message.Message, e error) { time.Sleep(sleepInHandler) return nil, errors.New("foo") }) startTime := time.Now() _, _ = h(message.NewMessage("1", nil)) timeElapsed := time.Since(startTime) assert.True( t, timeElapsed < runTimeWithoutMaxElapsedTime, "handler should run less than %s, time elapsed: %s", runTimeWithoutMaxElapsedTime, timeElapsed, ) } func TestRetry_max_interval(t *testing.T) { t.Parallel() maxRetries := 10 backoffTimes := make([]time.Duration, maxRetries) maxInterval := time.Millisecond * 30 retry := middleware.Retry{ MaxRetries: maxRetries, InitialInterval: time.Millisecond * 10, MaxInterval: maxInterval, Multiplier: 2.0, RandomizationFactor: 0, OnRetryHook: func(retryNum int, delay time.Duration) { backoffTimes[retryNum-1] = delay }, } h := retry.Middleware(func(msg *message.Message) (messages []*message.Message, e error) { return nil, errors.New("bar") }) _, _ = h(message.NewMessage("2", nil)) for i, delay := range backoffTimes { assert.True(t, delay <= maxInterval, "wait interval %d (%s) exceeds maxInterval (%s)", i, delay, maxInterval) } } func TestRetry_first_run_no_delay(t *testing.T) { t.Parallel() initialInterval := time.Millisecond * 100 retry := middleware.Retry{ MaxElapsedTime: initialInterval * 2, MaxRetries: 10, InitialInterval: initialInterval, } h := retry.Middleware(func(msg *message.Message) (messages []*message.Message, e error) { return nil, nil }) start := time.Now() _, _ = h(message.NewMessage("1", nil)) elapsed := time.Since(start) assert.True(t, elapsed < initialInterval, "first retry should not wait, elapsed: %s", elapsed) } func TestRetry_should_retry(t *testing.T) { errToSkip := errors.New("this should be skipped") retry := middleware.Retry{ MaxRetries: 5, ShouldRetry: func(params middleware.RetryParams) bool { return !errors.Is(params.Err, errToSkip) }, } runCount := 0 h := retry.Middleware(func(msg *message.Message) (messages []*message.Message, e error) { runCount++ return nil, errToSkip }) handlerMessages, handlerErr := h(message.NewMessage("1", nil)) assert.Equal(t, 1, runCount) assert.Nil(t, handlerMessages) assert.ErrorIs(t, handlerErr, errToSkip) // to not create any dependency on backoff package var backoffPermanentError *backoff.PermanentError assert.False(t, errors.As(handlerErr, &backoffPermanentError)) } // Test that the ShouldRetry function is called on each retry attempt. // Under the hood, the second attempt goes over a bit different code path, // so we want to make sure that it works consistently. func TestRetry_should_retry_second_attempt(t *testing.T) { errToSkip := errors.New("this should be skipped") retry := middleware.Retry{ MaxRetries: 5, ShouldRetry: func(params middleware.RetryParams) bool { return !errors.Is(params.Err, errToSkip) }, } runCount := 0 h := retry.Middleware(func(msg *message.Message) (messages []*message.Message, e error) { runCount++ if runCount == 1 { return nil, errors.New("some other error") } else { return nil, errToSkip } }) handlerMessages, handlerErr := h(message.NewMessage("1", nil)) assert.Equal(t, 2, runCount) assert.Nil(t, handlerMessages) assert.ErrorIs(t, handlerErr, errToSkip) // to not create any dependency on backoff package var backoffPermanentError *backoff.PermanentError assert.False(t, errors.As(handlerErr, &backoffPermanentError)) } // Test that if backoff.Permanent error stops the retries. // For sake of potential future regressions (see Hyrum's Law). func TestRetry_backoff_backoff_permanent(t *testing.T) { errToSkip := errors.New("this should be skipped") retry := middleware.Retry{ MaxRetries: 5, } runCount := 0 h := retry.Middleware(func(msg *message.Message) (messages []*message.Message, e error) { runCount++ return nil, backoff.Permanent(errToSkip) }) handlerMessages, handlerErr := h(message.NewMessage("1", nil)) assert.Equal(t, 1, runCount) assert.Nil(t, handlerMessages) assert.ErrorIs(t, handlerErr, errToSkip) // to not create any dependency on backoff package var backoffPermanentError *backoff.PermanentError assert.False(t, errors.As(handlerErr, &backoffPermanentError)) } // TestRetry_uncancel_context checks scenario when context is canceled, // and we want to retry. More context: https://github.com/ThreeDotsLabs/watermill/issues/467 // // Message is passed as a pointer, so underlying middlewares or handlers can cancel its context. // In this scenario, retrying is pointless because context is already canceled, so any operation // that relies on context will fail immediately. func TestRetry_uncancel_context(t *testing.T) { retry := middleware.Retry{ MaxRetries: 5, ResetContextOnRetry: true, } num := 0 var ctxCancelMiddleware = func(h message.HandlerFunc) message.HandlerFunc { return func(msg *message.Message) ([]*message.Message, error) { num++ ctx, cancel := context.WithCancel(msg.Context()) defer func() { cancel() }() if num == 1 { t.Log("Run 1: canceling context") cancel() } else { t.Logf("Run %d: context is not canceled", num) } msg.SetContext(ctx) return h(msg) } } h := func(msg *message.Message) (messages []*message.Message, e error) { return nil, msg.Context().Err() } h = ctxCancelMiddleware(h) h = retry.Middleware(h) _, handlerErr := h(message.NewMessage("1", nil)) assert.NoError(t, handlerErr) } ================================================ FILE: message/router/middleware/throttle.go ================================================ package middleware import ( "time" "github.com/ThreeDotsLabs/watermill/message" ) // Throttle provides a middleware that limits the amount of messages processed per unit of time. // This may be done e.g. to prevent excessive load caused by running a handler on a long queue of unprocessed messages. type Throttle struct { ticker *time.Ticker } // NewThrottle creates a new Throttle middleware. // Example duration and count: NewThrottle(10, time.Second) for 10 messages per second func NewThrottle(count int64, duration time.Duration) *Throttle { return &Throttle{ ticker: time.NewTicker(duration / time.Duration(count)), } } // Middleware returns the Throttle middleware. func (t Throttle) Middleware(h message.HandlerFunc) message.HandlerFunc { return func(message *message.Message) ([]*message.Message, error) { // throttle is shared by multiple handlers, which will wait for their "tick" <-t.ticker.C return h(message) } } ================================================ FILE: message/router/middleware/throttle_test.go ================================================ package middleware_test import ( "context" "testing" "time" "github.com/pkg/errors" "github.com/ThreeDotsLabs/watermill/message" "github.com/ThreeDotsLabs/watermill/message/router/middleware" "github.com/stretchr/testify/assert" ) const ( perSecond = 10 testTimeout = time.Second concurrentHandlers = 10 ) func TestThrottle_Middleware(t *testing.T) { throttle := middleware.NewThrottle(perSecond, testTimeout) ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() producedMessagesChannel := make(chan struct{}) producedMessagesCounter := 0 for i := 0; i < concurrentHandlers; i++ { go func() { for { producedMessages := []*message.Message{message.NewMessage("produced", nil)} producedErr := errors.New("produced err") produced, err := throttle.Middleware(func(msg *message.Message) ([]*message.Message, error) { return producedMessages, producedErr })( message.NewMessage("uuid", nil), ) assert.Equal(t, producedMessages, produced) assert.Equal(t, producedErr, err) go func() { // non blocking counting producedMessagesChannel <- struct{}{} }() select { case <-ctx.Done(): break default: } } }() } CounterLoop: for { select { case <-ctx.Done(): break CounterLoop case <-producedMessagesChannel: producedMessagesCounter++ } } t.Logf("produced %d messages in %d seconds, at rate of total %d messages per second", producedMessagesCounter, int(testTimeout.Seconds()), perSecond, ) assert.True(t, producedMessagesCounter <= int(perSecond*testTimeout.Seconds())) assert.True(t, producedMessagesCounter > 0) } ================================================ FILE: message/router/middleware/timeout.go ================================================ package middleware import ( "context" "time" "github.com/ThreeDotsLabs/watermill/message" ) // Timeout makes the handler cancel the incoming message's context after a specified time. // Any timeout-sensitive functionality of the handler should listen on msg.Context().Done() to know when to fail. func Timeout(timeout time.Duration) func(message.HandlerFunc) message.HandlerFunc { return func(h message.HandlerFunc) message.HandlerFunc { return func(msg *message.Message) ([]*message.Message, error) { ctx, cancel := context.WithTimeout(msg.Context(), timeout) defer func() { cancel() }() msg.SetContext(ctx) return h(msg) } } } ================================================ FILE: message/router/middleware/timeout_test.go ================================================ package middleware_test import ( "errors" "testing" "time" "github.com/stretchr/testify/require" "github.com/ThreeDotsLabs/watermill/message" "github.com/ThreeDotsLabs/watermill/message/router/middleware" ) func TestTimeout(t *testing.T) { timeout := middleware.Timeout(time.Millisecond * 10) h := timeout(func(msg *message.Message) ([]*message.Message, error) { delay := time.After(time.Millisecond * 100) select { case <-msg.Context().Done(): return nil, nil case <-delay: return nil, errors.New("timeout did not occur") } }) _, err := h(message.NewMessage("any-uuid", nil)) require.NoError(t, err) } ================================================ FILE: message/router/plugin/signals.go ================================================ package plugin import ( "fmt" "os" "os/signal" "syscall" "github.com/ThreeDotsLabs/watermill/message" ) // SignalsHandler is a plugin that kills the router after SIGINT or SIGTERM is sent to the process. func SignalsHandler(r *message.Router) error { sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) go func() { sig := <-sigs r.Logger().Info(fmt.Sprintf("Received %s signal, closing\n", sig), nil) err := r.Close() if err != nil { r.Logger().Error("Router close failed", err, nil) } }() return nil } ================================================ FILE: message/router.go ================================================ package message import ( "context" "fmt" "runtime/debug" "sync" "time" "github.com/pkg/errors" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill/internal" sync_internal "github.com/ThreeDotsLabs/watermill/pubsub/sync" ) var ( // ErrOutputInNoPublisherHandler happens when a handler func returned some messages in a no-publisher handler. // todo: maybe change the handler func signature in no-publisher handler so that there's no possibility for this ErrOutputInNoPublisherHandler = errors.New("returned output messages in a handler without publisher") ) // HandlerFunc is function called when message is received. // // msg.Ack() is called automatically when HandlerFunc doesn't return error. // When HandlerFunc returns error, msg.Nack() is called. // When msg.Ack() was called in handler and HandlerFunc returns error, // msg.Nack() will be not sent because Ack was already sent. // // HandlerFunc's are executed parallel when multiple messages was received // (because msg.Ack() was sent in HandlerFunc or Subscriber supports multiple consumers). type HandlerFunc func(msg *Message) ([]*Message, error) // NoPublishHandlerFunc is HandlerFunc alternative, which doesn't produce any messages. type NoPublishHandlerFunc func(msg *Message) error // PassthroughHandler is a handler that passes the message unchanged from the subscriber to the publisher. var PassthroughHandler HandlerFunc = func(msg *Message) ([]*Message, error) { return []*Message{msg}, nil } // HandlerMiddleware allows us to write something like decorators to HandlerFunc. // It can execute something before handler (for example: modify consumed message) // or after (modify produced messages, ack/nack on consumed message, handle errors, logging, etc.). // // It can be attached to the router by using `AddMiddleware` method. // // Example: // // func ExampleMiddleware(h message.HandlerFunc) message.HandlerFunc { // return func(message *message.Message) ([]*message.Message, error) { // fmt.Println("executed before handler") // producedMessages, err := h(message) // fmt.Println("executed after handler") // // return producedMessages, err // } // } type HandlerMiddleware func(h HandlerFunc) HandlerFunc // RouterPlugin is function which is executed on Router start. type RouterPlugin func(*Router) error // PublisherDecorator wraps the underlying Publisher, adding some functionality. type PublisherDecorator func(pub Publisher) (Publisher, error) // SubscriberDecorator wraps the underlying Subscriber, adding some functionality. type SubscriberDecorator func(sub Subscriber) (Subscriber, error) // RouterConfig holds the Router's configuration options. type RouterConfig struct { // CloseTimeout determines how long router should work for handlers when closing. CloseTimeout time.Duration } func (c *RouterConfig) setDefaults() { if c.CloseTimeout == 0 { c.CloseTimeout = time.Second * 30 } } // Validate returns Router configuration error, if any. func (c RouterConfig) Validate() error { return nil } // NewRouter creates a new Router with given configuration. func NewRouter(config RouterConfig, logger watermill.LoggerAdapter) (*Router, error) { config.setDefaults() if err := config.Validate(); err != nil { return nil, errors.Wrap(err, "invalid config") } return newRouter(config, logger), nil } // NewDefaultRouter creates a new Router with default configuration. func NewDefaultRouter(logger watermill.LoggerAdapter) *Router { config := RouterConfig{} config.setDefaults() return newRouter(config, logger) } func newRouter(config RouterConfig, logger watermill.LoggerAdapter) *Router { if logger == nil { logger = watermill.NopLogger{} } return &Router{ config: config, handlers: map[string]*handler{}, handlersWg: &sync.WaitGroup{}, runningHandlersWg: &sync.WaitGroup{}, runningHandlersWgLock: &sync.Mutex{}, handlerAdded: make(chan struct{}), middlewaresLock: &sync.RWMutex{}, handlersLock: &sync.RWMutex{}, closingInProgressCh: make(chan struct{}), closedCh: make(chan struct{}), logger: logger, running: make(chan struct{}), } } type middleware struct { Handler HandlerMiddleware HandlerName string IsRouterLevel bool } // Router is responsible for handling messages from subscribers using provided handler functions. // // If the handler function returns a message, the message is published with the publisher. // You can use middlewares to wrap handlers with common logic like logging, instrumentation, etc. type Router struct { config RouterConfig middlewares []middleware middlewaresLock *sync.RWMutex plugins []RouterPlugin handlers map[string]*handler handlersLock *sync.RWMutex handlersWg *sync.WaitGroup runningHandlersWg *sync.WaitGroup runningHandlersWgLock *sync.Mutex handlerAdded chan struct{} closingInProgressCh chan struct{} closedCh chan struct{} closed bool closedLock sync.Mutex logger watermill.LoggerAdapter publisherDecorators []PublisherDecorator subscriberDecorators []SubscriberDecorator isRunning bool running chan struct{} } // Logger returns the Router's logger. func (r *Router) Logger() watermill.LoggerAdapter { return r.logger } // AddMiddleware adds a new middleware to the router. // // The order of middleware matters. Middleware added at the beginning is executed first. func (r *Router) AddMiddleware(m ...HandlerMiddleware) { r.logger.Debug("Adding middleware", watermill.LogFields{"count": fmt.Sprintf("%d", len(m))}) r.addRouterLevelMiddleware(m...) } func (r *Router) addRouterLevelMiddleware(m ...HandlerMiddleware) { for _, handlerMiddleware := range m { middleware := middleware{ Handler: handlerMiddleware, HandlerName: "", IsRouterLevel: true, } r.middlewares = append(r.middlewares, middleware) } } func (r *Router) addHandlerLevelMiddleware(handlerName string, m ...HandlerMiddleware) { r.middlewaresLock.Lock() defer r.middlewaresLock.Unlock() for _, handlerMiddleware := range m { middleware := middleware{ Handler: handlerMiddleware, HandlerName: handlerName, IsRouterLevel: false, } r.middlewares = append(r.middlewares, middleware) } } // AddPlugin adds a new plugin to the router. // Plugins are executed during startup of the router. // // A plugin can, for example, close the router after SIGINT or SIGTERM is sent to the process (SignalsHandler plugin). func (r *Router) AddPlugin(p ...RouterPlugin) { r.logger.Debug("Adding plugins", watermill.LogFields{"count": fmt.Sprintf("%d", len(p))}) r.plugins = append(r.plugins, p...) } // AddPublisherDecorators wraps the router's Publisher. // The first decorator is the innermost, i.e. calls the original publisher. func (r *Router) AddPublisherDecorators(dec ...PublisherDecorator) { r.logger.Debug("Adding publisher decorators", watermill.LogFields{"count": fmt.Sprintf("%d", len(dec))}) r.publisherDecorators = append(r.publisherDecorators, dec...) } // AddSubscriberDecorators wraps the router's Subscriber. // The first decorator is the innermost, i.e. calls the original subscriber. func (r *Router) AddSubscriberDecorators(dec ...SubscriberDecorator) { r.logger.Debug("Adding subscriber decorators", watermill.LogFields{"count": fmt.Sprintf("%d", len(dec))}) r.subscriberDecorators = append(r.subscriberDecorators, dec...) } // Handlers returns all registered handlers. func (r *Router) Handlers() map[string]HandlerFunc { handlers := map[string]HandlerFunc{} for handlerName, handler := range r.handlers { handlers[handlerName] = handler.handlerFunc } return handlers } // DuplicateHandlerNameError is sent in a panic when you try to add a second handler with the same name. type DuplicateHandlerNameError struct { HandlerName string } func (d DuplicateHandlerNameError) Error() string { return fmt.Sprintf("handler with name %s already exists", d.HandlerName) } // AddHandler adds a new handler. // // handlerName must be unique. For now, it is used only for debugging. // // subscribeTopic is a topic from which handler will receive messages. // // publishTopic is a topic to which router will produce messages returned by handlerFunc. // When handler needs to publish to multiple topics, // it is recommended to use AddConsumerHandler and inject a Publisher or implement middleware // which will catch messages and publish to topic based on metadata for example. // // If handler is added while router is already running, you need to explicitly call RunHandlers(). func (r *Router) AddHandler( handlerName string, subscribeTopic string, subscriber Subscriber, publishTopic string, publisher Publisher, handlerFunc HandlerFunc, ) *Handler { r.logger.Info("Adding handler", watermill.LogFields{ "handler_name": handlerName, "topic": subscribeTopic, }) r.handlersLock.Lock() defer r.handlersLock.Unlock() if _, ok := r.handlers[handlerName]; ok { panic(DuplicateHandlerNameError{handlerName}) } publisherName, subscriberName := internal.StructName(publisher), internal.StructName(subscriber) newHandler := &handler{ name: handlerName, logger: r.logger, subscriber: subscriber, subscribeTopic: subscribeTopic, subscriberName: subscriberName, publisher: publisher, publishTopic: publishTopic, publisherName: publisherName, handlerFunc: handlerFunc, runningHandlersWg: r.runningHandlersWg, runningHandlersWgLock: r.runningHandlersWgLock, messagesCh: nil, routersCloseCh: r.closingInProgressCh, startedCh: make(chan struct{}), } r.handlersWg.Add(1) r.handlers[handlerName] = newHandler select { case r.handlerAdded <- struct{}{}: default: // watchAllHandlersStopped is not always waiting for handlerAdded } return &Handler{ router: r, handler: newHandler, } } // AddConsumerHandler adds a new handler that does not return any messages. // It can publish messages by directly using a Publisher. // // handlerName must be unique. For now, it is used only for debugging. // // subscribeTopic is a topic from which handler will receive messages. // // subscriber is Subscriber from which messages will be consumed. // // If handler is added while router is already running, you need to explicitly call RunHandlers(). func (r *Router) AddConsumerHandler( handlerName string, subscribeTopic string, subscriber Subscriber, handlerFunc NoPublishHandlerFunc, ) *Handler { handlerFuncAdapter := func(msg *Message) ([]*Message, error) { return nil, handlerFunc(msg) } return r.AddHandler(handlerName, subscribeTopic, subscriber, "", disabledPublisher{}, handlerFuncAdapter) } // AddNoPublisherHandler adds a new handler. // This handler cannot return messages. // // handlerName must be unique. For now, it is used only for debugging. // // subscribeTopic is a topic from which handler will receive messages. // // subscriber is Subscriber from which messages will be consumed. // // If handler is added while router is already running, you need to explicitly call RunHandlers(). // // Deprecated: use AddConsumerHandler instead. func (r *Router) AddNoPublisherHandler( handlerName string, subscribeTopic string, subscriber Subscriber, handlerFunc NoPublishHandlerFunc, ) *Handler { return r.AddConsumerHandler(handlerName, subscribeTopic, subscriber, handlerFunc) } // Run runs all plugins and handlers and starts subscribing to provided topics. // This call is blocking while the router is running. // // When all handlers have stopped (for example, because subscriptions were closed), the router will also stop. // // To stop Run() you should call Close() on the router. // // ctx will be propagated to all subscribers. // // When all handlers are stopped (for example: because of closed connection), Run() will be also stopped. func (r *Router) Run(ctx context.Context) (err error) { if r.isRunning { return errors.New("router is already running") } r.isRunning = true ctx, cancel := context.WithCancel(ctx) defer cancel() r.logger.Debug("Loading plugins", nil) for _, plugin := range r.plugins { if err := plugin(r); err != nil { return errors.Wrapf(err, "cannot initialize plugin %v", plugin) } } r.watchAllHandlersStopped(ctx) if err := r.RunHandlers(ctx); err != nil { return err } close(r.running) <-r.closingInProgressCh cancel() r.logger.Info("Waiting for messages", watermill.LogFields{ "timeout": r.config.CloseTimeout, }) <-r.closedCh r.logger.Info("All messages processed", nil) return nil } // RunHandlers runs all handlers that were added after Run(). // RunHandlers is idempotent, so can be called multiple times safely. func (r *Router) RunHandlers(ctx context.Context) error { if !r.isRunning { return errors.New("you can't call RunHandlers on non-running router") } r.handlersLock.Lock() defer r.handlersLock.Unlock() r.logger.Info("Running router handlers", watermill.LogFields{"count": len(r.handlers)}) for name, h := range r.handlers { if h.started { continue } if err := r.decorateHandlerPublisher(h); err != nil { return errors.Wrapf(err, "could not decorate publisher of handler %s", name) } if err := r.decorateHandlerSubscriber(h); err != nil { return errors.Wrapf(err, "could not decorate subscriber of handler %s", name) } logger := r.logger.With(watermill.LogFields{ "subscriber_name": h.name, "topic": h.subscribeTopic, }) logger.Debug("Subscribing to topic", nil) ctx, cancel := context.WithCancel(ctx) messages, err := h.subscriber.Subscribe(ctx, h.subscribeTopic) if err != nil { cancel() return errors.Wrapf(err, "cannot subscribe topic %s", h.subscribeTopic) } h.messagesCh = messages h.started = true close(h.startedCh) h.stopFn = cancel h.stopped = make(chan struct{}) go func() { defer cancel() r.middlewaresLock.Lock() middlewares := append([]middleware{}, r.middlewares...) r.middlewaresLock.Unlock() h.run(ctx, middlewares) r.handlersWg.Done() logger.Info("Subscriber stopped", nil) r.handlersLock.Lock() delete(r.handlers, name) r.handlersLock.Unlock() logger.Trace("Removed subscriber from r.handlers", nil) close(h.stopped) }() } return nil } // watchAllHandlersStopped closes router when all handlers have stopped, // (for example, because for example all subscriptions are closed) func (r *Router) watchAllHandlersStopped(ctx context.Context) { r.handlersLock.RLock() hasNoHandlersYet := len(r.handlers) == 0 r.handlersLock.RUnlock() go func() { if hasNoHandlersYet { // we can start router without any handlers, // in that situation router would be closed immediately (even if they are no routers) // let's wait for select { case <-r.handlerAdded: // it should be some handler to track case <-r.closedCh: // let's avoid goroutine leak return } } r.handlersWg.Wait() if r.IsClosed() { r.logger.Trace("watchAllHandlersStopped: already closed", nil) // already closed return } // Only log an error if the context was not canceled, but handlers were stopped. select { case <-ctx.Done(): default: r.logger.Error("All handlers stopped, closing router", errors.New("all router handlers stopped"), nil) } if err := r.Close(); err != nil { r.logger.Error("Cannot close router", err, nil) } }() } // Running is closed when router is running. // In other words: you can wait till router is running using // // fmt.Println("Starting router") // go r.Run(ctx) // <- r.Running() // fmt.Println("Router is running") // // Warning: for historical reasons, this channel is not aware of router closing - the channel will be closed if the router has been running and closed. func (r *Router) Running() chan struct{} { return r.running } // IsRunning returns true when router is running. // // Warning: for historical reasons, this method is not aware of router closing. // If you want to know if the router was closed, use IsClosed. func (r *Router) IsRunning() bool { select { case <-r.running: return true default: return false } } // Close gracefully closes the router with a timeout provided in the configuration. func (r *Router) Close() error { r.closedLock.Lock() defer r.closedLock.Unlock() r.handlersLock.Lock() defer r.handlersLock.Unlock() if r.closed { r.logger.Debug("Already closed", nil) return nil } r.logger.Debug("Running Close()", nil) r.closed = true r.logger.Info("Closing router", nil) defer r.logger.Info("Router closed", nil) close(r.closingInProgressCh) defer close(r.closedCh) timedout := r.waitForHandlers() if timedout { return errors.New("router close timeout") } return nil } func (r *Router) waitForHandlers() bool { var waitGroup sync.WaitGroup waitGroup.Add(1) go func() { defer waitGroup.Done() r.handlersWg.Wait() }() waitGroup.Add(1) go func() { defer waitGroup.Done() r.runningHandlersWgLock.Lock() defer r.runningHandlersWgLock.Unlock() r.runningHandlersWg.Wait() }() return sync_internal.WaitGroupTimeout(&waitGroup, r.config.CloseTimeout) } func (r *Router) IsClosed() bool { r.closedLock.Lock() defer r.closedLock.Unlock() return r.closed } type handler struct { name string logger watermill.LoggerAdapter subscriber Subscriber subscribeTopic string subscriberName string publisher Publisher publishTopic string publisherName string handlerFunc HandlerFunc runningHandlersWg *sync.WaitGroup runningHandlersWgLock *sync.Mutex messagesCh <-chan *Message started bool startedCh chan struct{} stopFn context.CancelFunc stopped chan struct{} routersCloseCh chan struct{} } func (h *handler) run(ctx context.Context, middlewares []middleware) { h.logger.Info("Starting handler", watermill.LogFields{ "subscriber_name": h.name, "topic": h.subscribeTopic, }) middlewareHandler := h.handlerFunc // first added middlewares should be executed first (so should be at the top of call stack) for i := len(middlewares) - 1; i >= 0; i-- { currentMiddleware := middlewares[i] isValidHandlerLevelMiddleware := currentMiddleware.HandlerName == h.name if currentMiddleware.IsRouterLevel || isValidHandlerLevelMiddleware { middlewareHandler = currentMiddleware.Handler(middlewareHandler) } } go h.handleClose(ctx) for msg := range h.messagesCh { h.runningHandlersWgLock.Lock() h.runningHandlersWg.Add(1) h.runningHandlersWgLock.Unlock() go h.handleMessage(msg, middlewareHandler) } if h.publisher != nil { h.logger.Debug("Waiting for publisher to close", nil) if err := h.publisher.Close(); err != nil { h.logger.Error("Failed to close publisher", err, nil) } h.logger.Debug("Publisher closed", nil) } h.logger.Debug("Router handler stopped", nil) } // Handler handles Messages. type Handler struct { router *Router handler *handler } // AddMiddleware adds new middleware to the specified handler in the router. // // The order of middleware matters. Middleware added at the beginning is executed first. func (h *Handler) AddMiddleware(m ...HandlerMiddleware) { handler := h.handler handler.logger.Debug("Adding middleware to handler", watermill.LogFields{ "count": fmt.Sprintf("%d", len(m)), "handlerName": handler.name, }) h.router.addHandlerLevelMiddleware(handler.name, m...) } // Started returns channel which is stopped when handler is running. func (h *Handler) Started() chan struct{} { return h.handler.startedCh } // Stop stops the handler. // Stop is asynchronous. // You can check if handler was stopped with Stopped() function. func (h *Handler) Stop() { if !h.handler.started { panic("handler is not started") } h.handler.stopFn() } // Stopped returns channel which is stopped when handler did stop. func (h *Handler) Stopped() chan struct{} { return h.handler.stopped } // decorateHandlerPublisher applies the decorator chain to handler's publisher. // They are applied in reverse order, so that the later decorators use the result of former ones. func (r *Router) decorateHandlerPublisher(h *handler) error { var err error pub := h.publisher for i := len(r.publisherDecorators) - 1; i >= 0; i-- { decorator := r.publisherDecorators[i] pub, err = decorator(pub) if err != nil { return errors.Wrap(err, "could not apply publisher decorator") } } r.handlers[h.name].publisher = pub return nil } // decorateHandlerSubscriber applies the decorator chain to handler's subscriber. // They are applied in regular order, so that the later decorators use the result of former ones. func (r *Router) decorateHandlerSubscriber(h *handler) error { var err error sub := h.subscriber // add values to message context to subscriber // it goes before other decorators, so that they may take advantage of these values messageTransform := func(msg *Message) { if msg != nil { h.addHandlerContext(msg) } } sub, err = MessageTransformSubscriberDecorator(messageTransform)(sub) if err != nil { return errors.Wrapf(err, "cannot wrap subscriber with context decorator") } for _, decorator := range r.subscriberDecorators { sub, err = decorator(sub) if err != nil { return errors.Wrap(err, "could not apply subscriber decorator") } } r.handlers[h.name].subscriber = sub return nil } // addHandlerContext enriches the context with values that are relevant within this handler's context. func (h *handler) addHandlerContext(messages ...*Message) { for i, msg := range messages { ctx := msg.Context() if h.name != "" { ctx = context.WithValue(ctx, handlerNameKey, h.name) } if h.publisherName != "" { ctx = context.WithValue(ctx, publisherNameKey, h.publisherName) } if h.subscriberName != "" { ctx = context.WithValue(ctx, subscriberNameKey, h.subscriberName) } if h.subscribeTopic != "" { ctx = context.WithValue(ctx, subscribeTopicKey, h.subscribeTopic) } if h.publishTopic != "" { ctx = context.WithValue(ctx, publishTopicKey, h.publishTopic) } messages[i].SetContext(ctx) } } func (h *handler) handleClose(ctx context.Context) { select { case <-h.routersCloseCh: // for backward compatibility we are closing subscriber h.logger.Debug("Waiting for subscriber to close", nil) if err := h.subscriber.Close(); err != nil { h.logger.Error("Failed to close subscriber", err, nil) } h.logger.Debug("Subscriber closed", nil) case <-ctx.Done(): // we are closing subscriber just when entire router is closed } h.stopFn() } func (h *handler) handleMessage(msg *Message, handler HandlerFunc) { defer h.runningHandlersWg.Done() msgFields := watermill.LogFields{"message_uuid": msg.UUID, "handler_name": h.name} defer func() { if recovered := recover(); recovered != nil { h.logger.Error( "Panic recovered in handler. Stack: "+string(debug.Stack()), errors.Errorf("%s", recovered), msgFields, ) msg.Nack() } }() h.logger.Trace("Received message", msgFields) producedMessages, err := handler(msg) if err != nil { if !errors.Is(err, context.Canceled) { h.logger.Error("Handler returned error", err, msgFields) } msg.Nack() return } h.addHandlerContext(producedMessages...) if err := h.publishProducedMessages(producedMessages, msgFields); err != nil { h.logger.Error("Publishing produced messages failed", err, nil) msg.Nack() return } msg.Ack() h.logger.Trace("Message acked", msgFields) } func (h *handler) publishProducedMessages(producedMessages Messages, msgFields watermill.LogFields) error { if len(producedMessages) == 0 { return nil } if h.publisher == nil { return ErrOutputInNoPublisherHandler } h.logger.Trace("Sending produced messages", msgFields.Add(watermill.LogFields{ "produced_messages_count": len(producedMessages), "publish_topic": h.publishTopic, })) if err := h.publisher.Publish(h.publishTopic, producedMessages...); err != nil { h.logger.Error("Cannot publish messages", err, msgFields.Add(watermill.LogFields{ "not_sent_message": fmt.Sprintf("%#v", producedMessages), })) return err } return nil } type disabledPublisher struct{} func (disabledPublisher) Publish(topic string, messages ...*Message) error { return ErrOutputInNoPublisherHandler } func (disabledPublisher) Close() error { return nil } ================================================ FILE: message/router_context.go ================================================ package message import ( "context" ) type ctxKey string const ( handlerNameKey ctxKey = "handler_name" publisherNameKey ctxKey = "publisher_name" subscriberNameKey ctxKey = "subscriber_name" subscribeTopicKey ctxKey = "subscribe_topic" publishTopicKey ctxKey = "publish_topic" ) func valFromCtx(ctx context.Context, key ctxKey) string { val, ok := ctx.Value(key).(string) if !ok { return "" } return val } // HandlerNameFromCtx returns the name of the message handler in the router that consumed the message. func HandlerNameFromCtx(ctx context.Context) string { return valFromCtx(ctx, handlerNameKey) } // PublisherNameFromCtx returns the name of the message publisher type that published the message in the router. // For example, for Kafka it will be `kafka.Publisher`. func PublisherNameFromCtx(ctx context.Context) string { return valFromCtx(ctx, publisherNameKey) } // SubscriberNameFromCtx returns the name of the message subscriber type that subscribed to the message in the router. // For example, for Kafka it will be `kafka.Subscriber`. func SubscriberNameFromCtx(ctx context.Context) string { return valFromCtx(ctx, subscriberNameKey) } // SubscribeTopicFromCtx returns the topic from which message was received in the router. func SubscribeTopicFromCtx(ctx context.Context) string { return valFromCtx(ctx, subscribeTopicKey) } // PublishTopicFromCtx returns the topic to which message will be published by the router. func PublishTopicFromCtx(ctx context.Context) string { return valFromCtx(ctx, publishTopicKey) } ================================================ FILE: message/router_context_test.go ================================================ package message_test import ( "context" "testing" "github.com/stretchr/testify/require" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill/message" ) type namedMockPublisher struct{} func (namedMockPublisher) Publish(topic string, messages ...*message.Message) error { return nil } func (namedMockPublisher) Close() error { return nil } func (namedMockPublisher) String() string { return "this publisher implements Stringer" } type namedMockSubscriber struct{ ch chan *message.Message } func (s namedMockSubscriber) Subscribe(context.Context, string) (<-chan *message.Message, error) { return s.ch, nil } func (s *namedMockSubscriber) Close() error { close(s.ch); return nil } func (namedMockSubscriber) String() string { return "this subscriber implements Stringer" } func TestRouter_Context_Stringer(t *testing.T) { // If a publisher or subscriber implements Stringer, it's name is the result of String(). // The messages processed by a router handler should have publisher and subscriber name in their context. // given capturedMessages := make(chan *message.Message) router, handlerFunc := setupPubsubNameTests(t, capturedMessages) sub := &namedMockSubscriber{make(chan *message.Message)} pub := namedMockPublisher{} handlerName := "handler_name_stringer_test" router.AddHandler( handlerName, "sub-topic", sub, "pub-topic", pub, handlerFunc, ) go func() { if err := router.Run(context.Background()); err != nil { panic(err) } }() defer func() { if err := router.Close(); err != nil { panic(err) } }() <-router.Running() // when sub.ch <- message.NewMessage("", []byte{}) capturedMsg := <-capturedMessages ctx := capturedMsg.Context() // then require.Equal(t, handlerName, message.HandlerNameFromCtx(ctx)) require.Equal(t, sub.String(), message.SubscriberNameFromCtx(ctx)) require.Equal(t, pub.String(), message.PublisherNameFromCtx(ctx)) require.Equal(t, "sub-topic", message.SubscribeTopicFromCtx(ctx)) require.Equal(t, "pub-topic", message.PublishTopicFromCtx(ctx)) } type unnamedMockPublisher struct{} func (unnamedMockPublisher) Publish(topic string, messages ...*message.Message) error { return nil } func (unnamedMockPublisher) Close() error { return nil } type unnamedMockSubscriber struct{ ch chan *message.Message } func (s unnamedMockSubscriber) Subscribe(context.Context, string) (<-chan *message.Message, error) { return s.ch, nil } func (s *unnamedMockSubscriber) Close() error { close(s.ch); return nil } func TestRouter_Context_TypeName(t *testing.T) { // If a publisher or subscriber does not implement Stringer, it's name is the type name. // The messages processed by a router handler should have publisher and subscriber name in their context. // given capturedMessages := make(chan *message.Message) router, handlerFunc := setupPubsubNameTests(t, capturedMessages) sub := &unnamedMockSubscriber{make(chan *message.Message)} pub := unnamedMockPublisher{} handlerName := "handler_name_typename_test" router.AddHandler( handlerName, "", sub, "", pub, handlerFunc, ) go func() { if err := router.Run(context.Background()); err != nil { panic(err) } }() defer func() { if err := router.Close(); err != nil { panic(err) } }() <-router.Running() // when sub.ch <- message.NewMessage("", []byte{}) capturedMsg := <-capturedMessages ctx := capturedMsg.Context() // then require.Equal(t, handlerName, message.HandlerNameFromCtx(ctx)) require.Equal(t, "message_test.unnamedMockSubscriber", message.SubscriberNameFromCtx(ctx)) require.Equal(t, "message_test.unnamedMockPublisher", message.PublisherNameFromCtx(ctx)) } func setupPubsubNameTests(t *testing.T, capturedMessages chan (*message.Message)) (*message.Router, message.HandlerFunc) { logger := watermill.NewStdLogger(true, true) router, err := message.NewRouter(message.RouterConfig{}, logger) require.NoError(t, err) handlerFunc := func(msg *message.Message) ([]*message.Message, error) { capturedMessages <- msg require.True(t, msg.Ack()) return message.Messages{message.NewMessage(msg.UUID+"_copy", msg.Payload)}, nil } return router, handlerFunc } ================================================ FILE: message/router_test.go ================================================ package message_test import ( "context" "fmt" "sync" "testing" "time" "github.com/pkg/errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill/internal" "github.com/ThreeDotsLabs/watermill/message" "github.com/ThreeDotsLabs/watermill/message/subscriber" "github.com/ThreeDotsLabs/watermill/pubsub/gochannel" "github.com/ThreeDotsLabs/watermill/pubsub/tests" ) func TestRouter_functional(t *testing.T) { testID := watermill.NewUUID() subscribeTopic := "test_topic_" + testID pub, sub := createPubSub() defer func() { assert.NoError(t, pub.Close()) assert.NoError(t, sub.Close()) }() messagesCount := 50 var expectedReceivedMessages message.Messages allMessagesSent := make(chan struct{}) go func() { expectedReceivedMessages = publishMessagesForHandler(t, messagesCount, pub, sub, subscribeTopic) allMessagesSent <- struct{}{} }() receivedMessagesCh1 := make(chan *message.Message, messagesCount) receivedMessagesCh2 := make(chan *message.Message, messagesCount) sentByHandlerCh := make(chan *message.Message, messagesCount) publishedEventsTopic := "published_events_" + testID publishedByHandlerCh, err := sub.Subscribe(context.Background(), publishedEventsTopic) var publishedByHandler message.Messages allPublishedByHandler := make(chan struct{}) go func() { var all bool publishedByHandler, all = subscriber.BulkRead(publishedByHandlerCh, messagesCount, time.Second*10) assert.True(t, all) allPublishedByHandler <- struct{}{} }() require.NoError(t, err) r, err := message.NewRouter( message.RouterConfig{}, watermill.NewStdLogger(true, true), ) require.NoError(t, err) r.AddHandler( "test_subscriber_1", subscribeTopic, sub, publishedEventsTopic, pub, func(msg *message.Message) (producedMessages []*message.Message, err error) { receivedMessagesCh1 <- msg toPublish := message.NewMessage(watermill.NewUUID(), nil) sentByHandlerCh <- toPublish return []*message.Message{toPublish}, nil }, ) r.AddConsumerHandler( "test_subscriber_2", subscribeTopic, sub, func(msg *message.Message) error { receivedMessagesCh2 <- msg return nil }, ) go func() { assert.False(t, r.IsRunning()) assert.NoError(t, r.Run(context.Background())) }() <-r.Running() defer func() { assert.True(t, r.IsRunning()) assert.NoError(t, r.Close()) assert.True(t, r.IsClosed()) }() <-allMessagesSent expectedSentByHandler, all := readMessages(sentByHandlerCh, len(expectedReceivedMessages), time.Second*10) assert.True(t, all) receivedMessages1, all := subscriber.BulkRead(receivedMessagesCh1, len(expectedReceivedMessages), time.Second*10) assert.True(t, all) tests.AssertAllMessagesReceived(t, expectedReceivedMessages, receivedMessages1) receivedMessages2, all := subscriber.BulkRead(receivedMessagesCh2, len(expectedReceivedMessages), time.Second*10) assert.True(t, all) tests.AssertAllMessagesReceived(t, expectedReceivedMessages, receivedMessages2) <-allPublishedByHandler tests.AssertAllMessagesReceived(t, expectedSentByHandler, publishedByHandler) } func TestRouter_functional_nack(t *testing.T) { pub, sub := createPubSub() defer func() { assert.NoError(t, pub.Close()) assert.NoError(t, sub.Close()) }() r, err := message.NewRouter( message.RouterConfig{}, watermill.NewStdLogger(true, true), ) require.NoError(t, err) nackSend := make(chan struct{}) messageReceived := make(chan *message.Message, 2) r.AddConsumerHandler( "test_subscriber_1", "subscribe_topic", sub, func(msg *message.Message) error { messageReceived <- msg if !internal.IsChannelClosed(nackSend) { msg.Nack() close(nackSend) } return nil }, ) go func() { require.NoError(t, r.Run(context.Background())) }() defer func() { assert.NoError(t, r.Close()) }() <-r.Running() publishedMsg := message.NewMessage("1", nil) require.NoError(t, pub.Publish("subscribe_topic", publishedMsg)) messages, all := subscriber.BulkRead(messageReceived, 2, time.Second) assert.True(t, all, "not all messages received, probably not ack received, received %d", len(messages)) tests.AssertAllMessagesReceived(t, []*message.Message{publishedMsg, publishedMsg}, messages) } func TestRouter_ack_on_publishing_success(t *testing.T) { publisher := &failingPublisherMock{ shouldPanic: false, shouldError: false, } subscriber := &subscriberMock{ messages: make(chan *message.Message), } router, err := message.NewRouter(message.RouterConfig{ CloseTimeout: time.Second, }, watermill.NewStdLogger(true, true)) require.NoError(t, err) handlerFunc := func(msg *message.Message) ([]*message.Message, error) { return message.Messages{msg}, nil } topic := "ack_on_publishing_success" router.AddHandler( "ack_on_publishing_success_handler", topic, subscriber, topic, publisher, handlerFunc, ) go func() { err := router.Run(context.Background()) require.NoError(t, err) }() <-router.Running() msg := message.NewMessage("uuid", []byte{}) subscriber.messages <- msg select { case <-msg.Acked(): // ok case <-msg.Nacked(): t.Fatal("did not expect the message to be nacked") case <-time.After(5 * time.Second): t.Fatal("expected the message to be acked") } err = router.Close() require.NoError(t, err) } func TestRouter_nack_on_publishing_failure(t *testing.T) { publisher := &failingPublisherMock{ shouldPanic: false, shouldError: true, } subscriber := &subscriberMock{ messages: make(chan *message.Message), } router, err := message.NewRouter(message.RouterConfig{ CloseTimeout: time.Second, }, watermill.NewStdLogger(true, true)) require.NoError(t, err) handlerFunc := func(msg *message.Message) ([]*message.Message, error) { return message.Messages{msg}, nil } topic := "nack_on_publishing_failure" router.AddHandler( "nack_on_publishing_failure_handler", topic, subscriber, topic, publisher, handlerFunc, ) go func() { err := router.Run(context.Background()) require.NoError(t, err) }() <-router.Running() msg := message.NewMessage("uuid", []byte{}) subscriber.messages <- msg select { case <-msg.Acked(): t.Fatal("did not expect the message to be acked") case <-msg.Nacked(): // ok case <-time.After(5 * time.Second): t.Fatal("expected the message to be nacked") } err = router.Close() require.NoError(t, err) } func TestRouter_nack_on_panic(t *testing.T) { publisher := &failingPublisherMock{ shouldPanic: true, shouldError: false, } subscriber := &subscriberMock{ messages: make(chan *message.Message), } router, err := message.NewRouter(message.RouterConfig{ CloseTimeout: time.Second, }, watermill.NewStdLogger(true, true)) require.NoError(t, err) handlerFunc := func(msg *message.Message) ([]*message.Message, error) { return message.Messages{msg}, nil } topic := "nack_on_panic" router.AddHandler( "nack_on_panic_handler", topic, subscriber, topic, publisher, handlerFunc, ) go func() { err := router.Run(context.Background()) require.NoError(t, err) }() <-router.Running() msg := message.NewMessage("uuid", []byte{}) subscriber.messages <- msg select { case <-msg.Acked(): t.Fatal("did not expect the message to be acked") case <-msg.Nacked(): // ok case <-time.After(5 * time.Second): t.Fatal("expected the message to be nacked") } err = router.Close() require.NoError(t, err) } func TestRouter_nack_on_handler_failure(t *testing.T) { publisher := &failingPublisherMock{ shouldPanic: false, shouldError: false, } subscriber := &subscriberMock{ messages: make(chan *message.Message), } router, err := message.NewRouter(message.RouterConfig{ CloseTimeout: time.Second, }, watermill.NewStdLogger(true, true)) require.NoError(t, err) handlerFunc := func(msg *message.Message) ([]*message.Message, error) { return nil, errors.New("handler error") } topic := "nack_on_handler_failure" router.AddHandler( "nack_on_handler_failure_handler", topic, subscriber, topic, publisher, handlerFunc, ) go func() { err := router.Run(context.Background()) require.NoError(t, err) }() <-router.Running() msg := message.NewMessage("uuid", []byte{}) subscriber.messages <- msg select { case <-msg.Acked(): t.Fatal("did not expect the message to be acked") case <-msg.Nacked(): // ok case <-time.After(5 * time.Second): t.Fatal("expected the message to be nacked") } } func TestRouter_AddMiddleware_to_router(t *testing.T) { pub, sub := createPubSub() defer func() { assert.NoError(t, pub.Close()) assert.NoError(t, sub.Close()) }() router, err := message.NewRouter(message.RouterConfig{ CloseTimeout: time.Second, }, watermill.NewStdLogger(true, true)) require.NoError(t, err) middlewareCount := 3 middlewareCh := make(chan string, middlewareCount) allMiddlewareExecuted := make(chan struct{}, 1) var executedMiddleware []string handlerFunc := func(msg *message.Message) ([]*message.Message, error) { return message.Messages{msg}, nil } firstMiddleware := func(h message.HandlerFunc) message.HandlerFunc { middlewareCh <- "firstMiddleware" return h } secondMiddleware := func(h message.HandlerFunc) message.HandlerFunc { middlewareCh <- "secondMiddleware" return h } thirdMiddleware := func(h message.HandlerFunc) message.HandlerFunc { middlewareCh <- "thirdMiddleware" return h } topic := "some_topic" router.AddMiddleware(firstMiddleware) router.AddHandler( "some_topic_handler", topic, sub, topic, pub, handlerFunc, ) router.AddMiddleware(secondMiddleware) router.AddMiddleware(thirdMiddleware) go func() { msg := message.NewMessage(watermill.NewUUID(), []byte("test_payload")) err := pub.Publish(topic, msg) require.NoError(t, err) }() go func() { for middlewareName := range middlewareCh { executedMiddleware = append(executedMiddleware, middlewareName) if len(executedMiddleware) == 3 { allMiddlewareExecuted <- struct{}{} break } } allMiddlewareExecuted <- struct{}{} }() go func() { err := router.Run(context.Background()) require.NoError(t, err) }() <-router.Running() <-allMiddlewareExecuted err = router.Close() require.NoError(t, err) require.Equal(t, "firstMiddleware", executedMiddleware[2]) require.Equal(t, "secondMiddleware", executedMiddleware[1]) require.Equal(t, "thirdMiddleware", executedMiddleware[0]) } func TestRouter_AddMiddleware_to_handler(t *testing.T) { pub, sub := createPubSub() defer func() { assert.NoError(t, pub.Close()) assert.NoError(t, sub.Close()) }() router, err := message.NewRouter(message.RouterConfig{ CloseTimeout: time.Second, }, watermill.NewStdLogger(true, true)) require.NoError(t, err) middlewareCount := 4 middlewareCh := make(chan string, middlewareCount) allMiddlewareExecuted := make(chan struct{}, 1) var executedMiddleware []string handlerFunc := func(msg *message.Message) ([]*message.Message, error) { return message.Messages{msg}, nil } firstMiddleware := func(h message.HandlerFunc) message.HandlerFunc { middlewareCh <- "firstMiddleware" return h } secondMiddleware := func(h message.HandlerFunc) message.HandlerFunc { middlewareCh <- "secondMiddleware" return h } thirdMiddleware := func(h message.HandlerFunc) message.HandlerFunc { middlewareCh <- "thirdMiddleware" return h } fourthMiddleware := func(h message.HandlerFunc) message.HandlerFunc { middlewareCh <- "fourthMiddleware" return h } topic := "some_topic" router.AddMiddleware(firstMiddleware) router.AddHandler( "some_topic_handler", topic, sub, topic, pub, handlerFunc, ).AddMiddleware(secondMiddleware, thirdMiddleware) router.AddMiddleware(fourthMiddleware) go func() { for middlewareName := range middlewareCh { executedMiddleware = append(executedMiddleware, middlewareName) if len(executedMiddleware) == middlewareCount { allMiddlewareExecuted <- struct{}{} break } } allMiddlewareExecuted <- struct{}{} }() go func() { err := router.Run(context.Background()) require.NoError(t, err) }() <-router.Running() <-allMiddlewareExecuted err = router.Close() require.NoError(t, err) require.Equal(t, "firstMiddleware", executedMiddleware[3]) require.Equal(t, "secondMiddleware", executedMiddleware[2]) require.Equal(t, "thirdMiddleware", executedMiddleware[1]) require.Equal(t, "fourthMiddleware", executedMiddleware[0]) } func TestRouter_AddMiddleware_to_handler_many(t *testing.T) { pub, sub := createPubSub() defer func() { assert.NoError(t, pub.Close()) assert.NoError(t, sub.Close()) }() router, err := message.NewRouter(message.RouterConfig{ CloseTimeout: time.Second, }, watermill.NewStdLogger(true, true)) require.NoError(t, err) middlewareCount := 6 middlewareCh := make(chan string, middlewareCount) allMiddlewareExecuted := make(chan struct{}, 1) var executedMiddleware []string handlerFunc := func(msg *message.Message) ([]*message.Message, error) { return message.Messages{msg}, nil } firstMiddleware := func(h message.HandlerFunc) message.HandlerFunc { middlewareCh <- "firstMiddleware" return h } secondMiddleware := func(h message.HandlerFunc) message.HandlerFunc { middlewareCh <- "secondMiddleware" return h } thirdMiddleware := func(h message.HandlerFunc) message.HandlerFunc { middlewareCh <- "thirdMiddleware" return h } fourthMiddleware := func(h message.HandlerFunc) message.HandlerFunc { middlewareCh <- "fourthMiddleware" return h } topic := "some_topic" router.AddMiddleware(firstMiddleware) router.AddHandler( "some_topic_handler", topic, sub, topic, pub, handlerFunc, ).AddMiddleware(secondMiddleware) router.AddHandler( "some_other_topic_handler", "some_other_topic", sub, "some_other_topic", pub, func(msg *message.Message) ([]*message.Message, error) { return message.Messages{msg}, nil }, ).AddMiddleware(thirdMiddleware) router.AddMiddleware(fourthMiddleware) go func() { for middlewareName := range middlewareCh { executedMiddleware = append(executedMiddleware, middlewareName) if len(executedMiddleware) == middlewareCount { allMiddlewareExecuted <- struct{}{} break } } allMiddlewareExecuted <- struct{}{} }() go func() { err := router.Run(context.Background()) require.NoError(t, err) }() <-router.Running() <-allMiddlewareExecuted err = router.Close() require.NoError(t, err) require.Equal(t, 6, len(executedMiddleware)) counts := map[string]int{} for _, m := range executedMiddleware { counts[m] += 1 } require.Equal(t, 2, counts["firstMiddleware"]) require.Equal(t, 1, counts["secondMiddleware"]) require.Equal(t, 1, counts["thirdMiddleware"]) require.Equal(t, 2, counts["fourthMiddleware"]) } func TestRouter_RunHandlers(t *testing.T) { ctx := context.Background() testID := watermill.NewUUID() subscribeTopic := "test_topic_" + testID pubsub := gochannel.NewGoChannel( gochannel.Config{Persistent: true}, watermill.NewStdLogger(true, true), ) defer func() { assert.NoError(t, pubsub.Close()) }() r, err := message.NewRouter( message.RouterConfig{}, watermill.NewStdLogger(true, true), ) require.NoError(t, err) defer func() { assert.NoError(t, r.Close()) }() go func() { require.NoError(t, r.Run(ctx)) }() <-r.Running() messagesCount := 3 var expectedReceivedMessages message.Messages receivedMessagesCh := make(chan *message.Message, messagesCount) handler := r.AddConsumerHandler( "test_subscriber_1", subscribeTopic, pubsub, func(msg *message.Message) error { receivedMessagesCh <- msg return nil }, ) require.NotNil(t, handler) require.NoError(t, err) require.NoError(t, r.RunHandlers(ctx)) require.NoError(t, r.RunHandlers(ctx)) // RunHandlers should be idempotent select { case <-handler.Started(): // ok case <-time.After(time.Second): t.Fatal("timeout waiting for handler") } expectedReceivedMessages = publishMessagesForHandler(t, messagesCount, pubsub, pubsub, subscribeTopic) receivedMessages1, all := subscriber.BulkRead(receivedMessagesCh, len(expectedReceivedMessages), time.Second*10) assert.True(t, all) tests.AssertAllMessagesReceived(t, expectedReceivedMessages, receivedMessages1) } func TestRouter_close_handler(t *testing.T) { testID := watermill.NewUUID() subscribeTopic1 := "test_topic_1_" + testID subscribeTopic2 := "test_topic_2_" + testID pub, sub := createPubSub() defer func() { assert.NoError(t, pub.Close()) assert.NoError(t, sub.Close()) }() r, err := message.NewRouter( message.RouterConfig{}, watermill.NewStdLogger(true, true), ) require.NoError(t, err) messagesCount := 3 var expectedReceivedMessages message.Messages receivedMessagesCh1 := make(chan *message.Message, messagesCount) handler := r.AddConsumerHandler( "test_subscriber_1", subscribeTopic1, sub, func(msg *message.Message) error { receivedMessagesCh1 <- msg return nil }, ) // to keep at least one running handler to prevent router from closing r.AddConsumerHandler( "noop_handler", watermill.NewUUID(), sub, func(msg *message.Message) error { return nil }, ) go func() { require.NoError(t, r.Run(context.Background())) }() <-r.Running() expectedReceivedMessages = publishMessagesForHandler(t, messagesCount, pub, sub, subscribeTopic1) receivedMessages1, all := subscriber.BulkRead(receivedMessagesCh1, len(expectedReceivedMessages), time.Second*10) assert.True(t, all) tests.AssertAllMessagesReceived(t, expectedReceivedMessages, receivedMessages1) handler.Stop() select { case <-handler.Stopped(): // ok case <-time.After(time.Second): t.Fatal("timeout waiting for handler stopped") } _ = publishMessagesForHandler(t, 1, pub, sub, subscribeTopic1) _, received := subscriber.BulkRead(receivedMessagesCh1, 1, time.Millisecond*1) assert.False(t, received) receivedMessagesCh2 := make(chan *message.Message, messagesCount) // we are adding the same handler again, with the same name r.AddConsumerHandler( "test_subscriber_1", subscribeTopic2, sub, func(msg *message.Message) error { receivedMessagesCh2 <- msg return nil }, ) err = r.RunHandlers(context.Background()) require.NoError(t, err) expectedReceivedMessages = publishMessagesForHandler(t, messagesCount, pub, sub, subscribeTopic2) receivedMessages2, all := subscriber.BulkRead(receivedMessagesCh2, len(expectedReceivedMessages), time.Second*10) assert.True(t, all) tests.AssertAllMessagesReceived(t, expectedReceivedMessages, receivedMessages2) } type subscriberMock struct { messages chan *message.Message } func (s *subscriberMock) Subscribe(ctx context.Context, topic string) (<-chan *message.Message, error) { return s.messages, nil } func (s *subscriberMock) Close() error { close(s.messages) return nil } type failingPublisherMock struct { shouldPanic bool shouldError bool } func (p *failingPublisherMock) Publish(topic string, messages ...*message.Message) error { if p.shouldPanic { panic("publisher panicked") } if p.shouldError { return errors.New("publisher failed") } return nil } func (p *failingPublisherMock) Close() error { return nil } func TestRouter_stop_when_all_handlers_stopped(t *testing.T) { pub1, sub1 := createPubSub() pub2, sub2 := createPubSub() defer func() { assert.NoError(t, pub1.Close()) assert.NoError(t, sub1.Close()) assert.NoError(t, pub2.Close()) assert.NoError(t, sub2.Close()) }() r, err := message.NewRouter( message.RouterConfig{}, watermill.NewStdLogger(true, true), ) require.NoError(t, err) r.AddConsumerHandler( "handler_1", "foo", sub1, func(msg *message.Message) error { return nil }, ) r.AddConsumerHandler( "handler_2", "foo", sub2, func(msg *message.Message) error { return nil }, ) routerStopped := make(chan struct{}) go func() { assert.NoError(t, r.Run(context.Background())) close(routerStopped) }() <-r.Running() require.NoError(t, pub1.Close()) require.NoError(t, sub1.Close()) select { case <-routerStopped: t.Fatal("only one handler has stopped") case <-time.After(time.Millisecond * 100): // ok } require.NoError(t, pub2.Close()) require.NoError(t, sub2.Close()) select { case <-routerStopped: // ok case <-time.After(time.Second): t.Fatal("router not stopped") } } type benchMockSubscriber struct { messagesToSend []*message.Message } func (m benchMockSubscriber) Subscribe(_ context.Context, topic string) (<-chan *message.Message, error) { out := make(chan *message.Message) go func() { for _, msg := range m.messagesToSend { out <- msg <-msg.Acked() } close(out) }() return out, nil } func (benchMockSubscriber) Close() error { return nil } type nopPublisher struct{} func (nopPublisher) Publish(topic string, messages ...*message.Message) error { return nil } func (nopPublisher) Close() error { return nil } func BenchmarkRouterHandler(b *testing.B) { logger := watermill.NopLogger{} allProcessedWg := sync.WaitGroup{} allProcessedWg.Add(b.N) router, err := message.NewRouter(message.RouterConfig{}, logger) if err != nil { b.Fatal(err) } sub := createBenchSubscriber(b) router.AddHandler( "handler", "benchmark_topic", sub, "publish_topic", nopPublisher{}, func(msg *message.Message) (messages []*message.Message, e error) { allProcessedWg.Done() return []*message.Message{msg}, nil }, ) go func() { allProcessedWg.Wait() router.Close() }() b.ResetTimer() if err := router.Run(context.Background()); err != nil { b.Fatal(err) } } func TestRouterNoPublisherHandler(t *testing.T) { pub, sub := createPubSub() defer func() { assert.NoError(t, pub.Close()) assert.NoError(t, sub.Close()) }() logger := watermill.NewCaptureLogger() r, err := message.NewRouter( message.RouterConfig{}, logger, ) require.NoError(t, err) wait := make(chan struct{}) r.AddConsumerHandler( "test_no_publisher_handler", "subscribe_topic", sub, func(msg *message.Message) error { close(wait) return nil }, ) go func() { err = r.Run(context.Background()) require.NoError(t, err) }() defer r.Close() <-r.Running() publishedMsg := message.NewMessage("1", nil) err = pub.Publish("subscribe_topic", publishedMsg) require.NoError(t, err) select { case <-wait: // ok case <-time.After(time.Second): t.Fatal("no message received") } require.NoError(t, r.Close()) } func BenchmarkRouterNoPublisherHandler(b *testing.B) { logger := watermill.NopLogger{} allProcessedWg := sync.WaitGroup{} allProcessedWg.Add(b.N) router, err := message.NewRouter(message.RouterConfig{}, logger) if err != nil { b.Fatal(err) } sub := createBenchSubscriber(b) router.AddConsumerHandler( "handler", "benchmark_topic", sub, func(msg *message.Message) (e error) { allProcessedWg.Done() return nil }, ) go func() { allProcessedWg.Wait() router.Close() }() b.ResetTimer() if err := router.Run(context.Background()); err != nil { b.Fatal(err) } } // TestRouterDecoratorsOrder checks that the publisher/subscriber decorators are applied in the order they are registered. func TestRouterDecoratorsOrder(t *testing.T) { logger := watermill.NewStdLogger(true, true) router, err := message.NewRouter(message.RouterConfig{}, logger) require.NoError(t, err) pub, sub := createPubSub() pubDecorator1 := message.MessageTransformPublisherDecorator(func(m *message.Message) { m.Metadata.Set("pub", m.Metadata.Get("pub")+"foo") }) pubDecorator2 := message.MessageTransformPublisherDecorator(func(m *message.Message) { m.Metadata.Set("pub", m.Metadata.Get("pub")+"bar") }) subDecorator1 := message.MessageTransformSubscriberDecorator(func(m *message.Message) { m.Metadata.Set("sub", m.Metadata.Get("sub")+"foo") }) subDecorator2 := message.MessageTransformSubscriberDecorator(func(m *message.Message) { m.Metadata.Set("sub", m.Metadata.Get("sub")+"bar") }) router.AddPublisherDecorators(pubDecorator1, pubDecorator2) router.AddSubscriberDecorators(subDecorator1, subDecorator2) router.AddHandler( "handler", "subTopic", sub, "pubTopic", pub, func(msg *message.Message) ([]*message.Message, error) { return message.Messages{msg}, nil }, ) go func() { if err := router.Run(context.Background()); err != nil { panic(err) } }() defer func() { if err := router.Close(); err != nil { panic(err) } }() <-router.Running() transformedMessages, err := sub.Subscribe(context.Background(), "pubTopic") require.NoError(t, err) var transformedMessage *message.Message messageObtained := make(chan struct{}) go func() { transformedMessage = <-transformedMessages close(messageObtained) }() require.NoError(t, pub.Publish("subTopic", message.NewMessage(watermill.NewUUID(), []byte{}))) select { case <-time.After(5 * time.Second): t.Fatal("test timed out") case <-messageObtained: } assert.Equal(t, "foobar", transformedMessage.Metadata.Get("pub")) assert.Equal(t, "foobar", transformedMessage.Metadata.Get("sub")) } func TestRouter_concurrent_close(t *testing.T) { logger := watermill.NewStdLogger(true, true) router, err := message.NewRouter(message.RouterConfig{}, logger) require.NoError(t, err) go func() { err := router.Close() require.NoError(t, err) }() err = router.Close() require.NoError(t, err) } func TestRouter_concurrent_close_on_handlers_closed(t *testing.T) { logger := watermill.NewStdLogger(true, true) router, err := message.NewRouter(message.RouterConfig{}, logger) require.NoError(t, err) _, sub := createPubSub() router.AddConsumerHandler( "handler", "subTopic", sub, func(msg *message.Message) error { return nil }, ) go func() { if err := router.Run(context.Background()); err != nil { panic(err) } }() <-router.Running() go func() { err := sub.Close() require.NoError(t, err) }() err = router.Close() require.NoError(t, err) } func createBenchSubscriber(b *testing.B) benchMockSubscriber { var messagesToSend []*message.Message for i := 0; i < b.N; i++ { messagesToSend = append( messagesToSend, message.NewMessage(watermill.NewUUID(), fmt.Appendf(nil, "%d", i)), ) } return benchMockSubscriber{messagesToSend} } func publishMessagesForHandler(t *testing.T, messagesCount int, pub message.Publisher, sub message.Subscriber, topicName string) []*message.Message { var messagesToPublish []*message.Message for i := 0; i < messagesCount; i++ { msg := message.NewMessage(watermill.NewUUID(), fmt.Appendf(nil, "%d", i)) messagesToPublish = append(messagesToPublish, msg) } for _, msg := range messagesToPublish { err := pub.Publish(topicName, msg) require.NoError(t, err) } return messagesToPublish } func createPubSub() (message.Publisher, message.Subscriber) { pubSub := gochannel.NewGoChannel( gochannel.Config{Persistent: true}, watermill.NewStdLogger(true, true), ) return pubSub, pubSub } func readMessages(messagesCh <-chan *message.Message, limit int, timeout time.Duration) (receivedMessages []*message.Message, all bool) { allMessagesReceived := make(chan struct{}, 1) go func() { for msg := range messagesCh { receivedMessages = append(receivedMessages, msg) if len(receivedMessages) == limit { allMessagesReceived <- struct{}{} break } } // messagesCh stopped allMessagesReceived <- struct{}{} }() select { case <-allMessagesReceived: case <-time.After(timeout): } return receivedMessages, len(receivedMessages) == limit } func TestRouter_Handlers(t *testing.T) { pub, sub := createPubSub() defer func() { assert.NoError(t, pub.Close()) assert.NoError(t, sub.Close()) }() logger := watermill.NewCaptureLogger() r, err := message.NewRouter( message.RouterConfig{}, logger, ) require.NoError(t, err) handlerCalled := false handlerName := "test_get_handler" r.AddConsumerHandler( handlerName, "subscribe_topic", sub, func(msg *message.Message) error { handlerCalled = true return nil }, ) actual := r.Handlers() assert.Len(t, actual, 1) actualHandler := actual[handlerName] assert.NotNil(t, actualHandler) messages, err := actualHandler(nil) assert.Empty(t, messages) assert.NoError(t, err) assert.True(t, handlerCalled, "Handler function should be the same") } func TestRouter_wait_for_handlers_before_shutdown(t *testing.T) { t.Parallel() pub, sub := createPubSub() defer func() { assert.NoError(t, pub.Close()) assert.NoError(t, sub.Close()) }() logger := watermill.NewCaptureLogger() r, err := message.NewRouter( message.RouterConfig{}, logger, ) require.NoError(t, err) handlerStarted := make(chan struct{}) routerClosed := make(chan struct{}) r.AddConsumerHandler( "foo", "subscribe_topic", sub, func(msg *message.Message) error { close(handlerStarted) select {} }, ) go func() { err := r.Run(context.Background()) assert.NoError(t, err) }() <-r.Running() err = pub.Publish("subscribe_topic", message.NewMessage(watermill.NewUUID(), nil)) require.NoError(t, err) <-handlerStarted go func() { assert.NoError(t, r.Close()) close(routerClosed) }() select { case <-routerClosed: t.Fatal("Router should wait for handlers to finish") case <-time.After(time.Millisecond * 100): // ok, router is still running } } func TestRouter_wait_for_handlers_before_shutdown_timeout(t *testing.T) { t.Parallel() pub, sub := createPubSub() defer func() { assert.NoError(t, pub.Close()) assert.NoError(t, sub.Close()) }() logger := watermill.NewCaptureLogger() r, err := message.NewRouter( message.RouterConfig{ CloseTimeout: time.Millisecond * 1, }, logger, ) require.NoError(t, err) handlerStarted := make(chan struct{}) r.AddConsumerHandler( "foo", "subscribe_topic", sub, func(msg *message.Message) error { close(handlerStarted) select {} }, ) go func() { err := r.Run(context.Background()) assert.NoError(t, err) }() <-r.Running() err = pub.Publish("subscribe_topic", message.NewMessage(watermill.NewUUID(), nil)) require.NoError(t, err) <-handlerStarted assert.EqualError(t, r.Close(), "router close timeout") } func TestRouter_context_cancel_does_not_log_error(t *testing.T) { t.Parallel() pub, sub := createPubSub() defer func() { assert.NoError(t, pub.Close()) assert.NoError(t, sub.Close()) }() logger := watermill.NewCaptureLogger() r, err := message.NewRouter(message.RouterConfig{}, logger) require.NoError(t, err) r.AddConsumerHandler( "foo", "subscribe_topic", sub, func(msg *message.Message) error { return nil }, ) ctx, cancel := context.WithCancel(context.Background()) go func() { err := r.Run(ctx) assert.NoError(t, err) }() <-r.Running() // Cancel the context cancel() require.Eventually(t, func() bool { return r.IsClosed() }, 3*time.Second, 5*time.Millisecond, "Router should be closed after all handlers are stopped") assert.Empty(t, logger.Captured()[watermill.ErrorLogLevel], "No error should be logged when context is canceled") } // TestRouter_nack_on_context_canceled checks that the message is Nacked // when the handler returns context.Canceled. func TestRouter_nack_on_context_canceled(t *testing.T) { t.Parallel() pubSub := gochannel.NewGoChannel(gochannel.Config{}, watermill.NopLogger{}) defer func() { assert.NoError(t, pubSub.Close()) }() logger := watermill.NewStdLogger(false, false) // Use StdLogger, logging check is in another test r, err := message.NewRouter(message.RouterConfig{}, logger) require.NoError(t, err) handlerProcessed := make(chan struct{}) subscribeTopic := "test_nack_on_context_canceled_" + watermill.NewUUID() r.AddConsumerHandler( "test_handler", subscribeTopic, pubSub, func(msg *message.Message) error { defer func() { handlerProcessed <- struct{}{} }() // Simulate handler returning context.Canceled return context.Canceled }, ) ctx, cancel := context.WithCancel(context.Background()) defer cancel() runErrCh := make(chan error, 1) go func() { runErrCh <- r.Run(ctx) }() select { case <-r.Running(): // proceed case err := <-runErrCh: t.Fatalf("Router failed to start: %v", err) case <-time.After(5 * time.Second): t.Fatal("Router did not start") } // Publish a message to trigger the handler msg := message.NewMessage(watermill.NewUUID(), nil) err = pubSub.Publish(subscribeTopic, msg) require.NoError(t, err) // Wait for the handler to process the message select { case <-handlerProcessed: // ok case <-time.After(5 * time.Second): t.Fatal("Handler did not process message in time") } // Message should be re-sent when nacked select { case <-handlerProcessed: // ok case <-time.After(5 * time.Second): t.Fatal("Handler did not process message in time") } } func TestRouter_stopping_all_handlers_logs_error(t *testing.T) { t.Parallel() pub, sub := createPubSub() defer func() { assert.NoError(t, pub.Close()) assert.NoError(t, sub.Close()) }() logger := watermill.NewCaptureLogger() defer logger.PrintCaptured(t) r, err := message.NewRouter(message.RouterConfig{}, logger) require.NoError(t, err) r.AddConsumerHandler( "foo", "subscribe_topic", sub, func(msg *message.Message) error { return nil }, ) ctx := context.Background() go func() { err := r.Run(ctx) assert.NoError(t, err) }() <-r.Running() // Stop the subscriber - this should close the router with an error logged err = sub.Close() require.NoError(t, err) require.Eventually( t, func() bool { return r.IsClosed() }, 1*time.Second, 1*time.Millisecond, "Router should be closed after all handlers are stopped", ) expectedLogMessage := watermill.CapturedMessage{ Level: watermill.ErrorLogLevel, Msg: "All handlers stopped, closing router", Err: errors.New("all router handlers stopped"), } // Note: using logger.Has does not work here, since the error is not exposed (and thus not deep equal-able) for _, capturedMessage := range logger.Captured()[watermill.ErrorLogLevel] { if capturedMessage.Level == expectedLogMessage.Level && capturedMessage.Msg == expectedLogMessage.Msg && capturedMessage.Err.Error() == expectedLogMessage.Err.Error() { return } } assert.Fail( t, "expected log message not found, logs: %#v", logger.Captured(), ) } ================================================ FILE: message/subscriber/read.go ================================================ package subscriber import ( "time" "github.com/ThreeDotsLabs/watermill/message" ) // BulkRead reads provided amount of messages from the provided channel, until a timeout occurs or the limit is reached. func BulkRead(messagesCh <-chan *message.Message, limit int, timeout time.Duration) (receivedMessages message.Messages, all bool) { MessagesLoop: for len(receivedMessages) < limit { select { case msg, ok := <-messagesCh: if !ok { break MessagesLoop } receivedMessages = append(receivedMessages, msg) msg.Ack() case <-time.After(timeout): break MessagesLoop } } return receivedMessages, len(receivedMessages) == limit } // BulkReadWithDeduplication reads provided number of messages from the provided channel, ignoring duplicates, // until a timeout occurs or the limit is reached. func BulkReadWithDeduplication(messagesCh <-chan *message.Message, limit int, timeout time.Duration) (receivedMessages message.Messages, all bool) { receivedIDs := map[string]struct{}{} MessagesLoop: for len(receivedMessages) < limit { select { case msg, ok := <-messagesCh: if !ok { break MessagesLoop } if _, ok := receivedIDs[msg.UUID]; !ok { receivedIDs[msg.UUID] = struct{}{} receivedMessages = append(receivedMessages, msg) } msg.Ack() case <-time.After(timeout): break MessagesLoop } } return receivedMessages, len(receivedMessages) == limit } ================================================ FILE: message/subscriber/read_test.go ================================================ package subscriber_test import ( "testing" "time" "github.com/ThreeDotsLabs/watermill/pubsub/tests" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill/message" "github.com/ThreeDotsLabs/watermill/message/subscriber" "github.com/stretchr/testify/assert" ) type bulkReadFunc func(messagesCh <-chan *message.Message, limit int, timeout time.Duration) (receivedMessages message.Messages, all bool) func TestBulkRead(t *testing.T) { testCases := []struct { Name string BulkReadFunc bulkReadFunc }{ { Name: "BulkRead", BulkReadFunc: subscriber.BulkRead, }, { Name: "BulkReadWithDeduplication", BulkReadFunc: subscriber.BulkReadWithDeduplication, }, } for _, c := range testCases { t.Run(c.Name, func(t *testing.T) { messagesCount := 100 var messages []*message.Message messagesCh := make(chan *message.Message, messagesCount) for i := 0; i < messagesCount; i++ { msg := message.NewMessage(watermill.NewUUID(), nil) messages = append(messages, msg) messagesCh <- msg } readMessages, all := subscriber.BulkRead(messagesCh, messagesCount, time.Second) assert.True(t, all) tests.AssertAllMessagesReceived(t, messages, readMessages) }) } } func TestBulkRead_timeout(t *testing.T) { testCases := []struct { Name string BulkReadFunc bulkReadFunc }{ { Name: "BulkRead", BulkReadFunc: subscriber.BulkRead, }, { Name: "BulkReadWithDeduplication", BulkReadFunc: subscriber.BulkReadWithDeduplication, }, } for _, c := range testCases { t.Run(c.Name, func(t *testing.T) { messagesCount := 100 sendLimit := 90 messagesCh := make(chan *message.Message, messagesCount) for i := 0; i < messagesCount; i++ { msg := message.NewMessage(watermill.NewUUID(), nil) if i < sendLimit { messagesCh <- msg } } bulkReadStart := time.Now() readMessages, all := subscriber.BulkRead(messagesCh, messagesCount, time.Millisecond) assert.WithinDuration(t, bulkReadStart, time.Now(), time.Millisecond*100) assert.False(t, all) assert.Equal(t, sendLimit, len(readMessages)) }) } } func TestBulkRead_with_limit(t *testing.T) { testCases := []struct { Name string BulkReadFunc bulkReadFunc }{ { Name: "BulkRead", BulkReadFunc: subscriber.BulkRead, }, { Name: "BulkReadWithDeduplication", BulkReadFunc: subscriber.BulkReadWithDeduplication, }, } for _, c := range testCases { t.Run(c.Name, func(t *testing.T) { messagesCount := 110 limit := 100 messagesCh := make(chan *message.Message, messagesCount) for i := 0; i < messagesCount; i++ { msg := message.NewMessage(watermill.NewUUID(), nil) messagesCh <- msg } readMessages, all := subscriber.BulkRead(messagesCh, limit, time.Second) assert.True(t, all) assert.Equal(t, limit, len(readMessages)) }) } } func TestBulkRead_return_on_channel_close(t *testing.T) { testCases := []struct { Name string BulkReadFunc bulkReadFunc }{ { Name: "BulkRead", BulkReadFunc: subscriber.BulkRead, }, { Name: "BulkReadWithDeduplication", BulkReadFunc: subscriber.BulkReadWithDeduplication, }, } for _, c := range testCases { t.Run(c.Name, func(t *testing.T) { messagesCount := 100 sendLimit := 90 messagesCh := make(chan *message.Message, messagesCount) messagesChClosed := false for i := 0; i < messagesCount; i++ { msg := message.NewMessage(watermill.NewUUID(), nil) if i < sendLimit { messagesCh <- msg } else if !messagesChClosed { close(messagesCh) messagesChClosed = true } } bulkReadStart := time.Now() _, all := subscriber.BulkRead(messagesCh, messagesCount, time.Second) assert.WithinDuration(t, bulkReadStart, time.Now(), time.Millisecond*100) assert.False(t, all) }) } } func TestBulkReadWithDeduplication(t *testing.T) { messagesCh := make(chan *message.Message, 3) msg1 := message.NewMessage(watermill.NewUUID(), nil) msg2 := message.NewMessage(watermill.NewUUID(), nil) messagesCh <- msg1 messagesCh <- msg1 messagesCh <- msg2 readMessages, all := subscriber.BulkReadWithDeduplication(messagesCh, 2, time.Second) assert.True(t, all) assert.Equal(t, []string{msg1.UUID, msg2.UUID}, readMessages.IDs()) } ================================================ FILE: netlify.toml ================================================ [build] command = "./build.sh --copy && npm run build" base = "docs/" publish = "docs/public/" [build.environment] NODE_VERSION = "20.11.0" NPM_VERSION = "10.2.4" HUGO_VERSION = "0.127.0" [context.deploy-preview] command = "./build.sh --copy && npm run build:branch" [context.branch-deploy] command = "./build.sh --copy && npm run build:branch" [[redirects]] from = "/api/event" to = "https://academy-api.threedots.tech/api/event" force = true status = 200 [[redirects]] from = "/docs/fanin" to = "/advanced/fanin/" status = 301 [[redirects]] from = "/docs/forwarder" to = "/advanced/forwarder/" status = 301 [[redirects]] from = "/docs/metrics" to = "/advanced/metrics/" status = 301 [[redirects]] from = "/docs/pub-sub-implementing" to = "/development/pub-sub-implementing/" status = 301 [[redirects]] from = "/pubsubs/amazonsqs/" to = "/pubsubs/aws/" status = 301 [[redirects]] from = "/docs/getting-started" to = "/learn/getting-started/" status = 301 ================================================ FILE: pubsub/doc.go ================================================ // Infrastructure directory contains Pub/Subs implementations. // // Detailed Pub/Subs docs: https://watermill.io/pubsubs/ // Getting started guide: https://watermill.io/learn/getting-started/ package pubsub ================================================ FILE: pubsub/gochannel/doc.go ================================================ // This is just the simplest Pub/Sub implementation // // All Pub/Sub implementations can be found at https://watermill.io/pubsubs/ package gochannel ================================================ FILE: pubsub/gochannel/fanout.go ================================================ package gochannel import ( "context" "errors" "fmt" "sync" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill/message" ) // FanOut is a component that receives messages from a topic and passes them // to all subscribers. In effect, messages are "multiplied". // // A typical use case for using FanOut is having one external subscription and multiple workers // inside the process. // // You need to call AddSubscription method for all topics that you want to listen to. // This needs to be done *before* starting the FanOut. // // FanOut exposes the standard Subscriber interface. type FanOut struct { internalPubSub *GoChannel internalRouter *message.Router subscriber message.Subscriber logger watermill.LoggerAdapter subscribedTopics map[string]struct{} subscribedLock sync.Mutex } // NewFanOut creates a new FanOut. func NewFanOut( subscriber message.Subscriber, logger watermill.LoggerAdapter, ) (*FanOut, error) { if subscriber == nil { return nil, errors.New("missing subscriber") } if logger == nil { logger = watermill.NopLogger{} } router, err := message.NewRouter(message.RouterConfig{}, logger) if err != nil { return nil, err } return &FanOut{ internalPubSub: NewGoChannel(Config{}, logger), internalRouter: router, subscriber: subscriber, logger: logger, subscribedTopics: map[string]struct{}{}, }, nil } // AddSubscription add an internal subscription for the given topic. // You need to call this method with all topics that you want to listen to, before the FanOut is started. // AddSubscription is idempotent. func (f *FanOut) AddSubscription(topic string) { f.subscribedLock.Lock() defer f.subscribedLock.Unlock() _, ok := f.subscribedTopics[topic] if ok { // Subscription already exists return } f.logger.Trace("Adding fan-out subscription for topic", watermill.LogFields{ "topic": topic, }) f.internalRouter.AddHandler( fmt.Sprintf("fanout-%s", topic), topic, f.subscriber, topic, f.internalPubSub, message.PassthroughHandler, ) f.subscribedTopics[topic] = struct{}{} } // Run runs the FanOut. func (f *FanOut) Run(ctx context.Context) error { return f.internalRouter.Run(ctx) } // Running is closed when FanOut is running. func (f *FanOut) Running() chan struct{} { return f.internalRouter.Running() } func (f *FanOut) IsClosed() bool { return f.internalRouter.IsClosed() } // Subscribe starts subscription to the FanOut's internal Pub/Sub. func (f *FanOut) Subscribe(ctx context.Context, topic string) (<-chan *message.Message, error) { return f.internalPubSub.Subscribe(ctx, topic) } // Close closes the FanOut's internal Pub/Sub. func (f *FanOut) Close() error { var err error if routerCloseErr := f.internalRouter.Close(); routerCloseErr != nil { err = errors.Join(err, routerCloseErr) } if internalPubSubCloseErr := f.internalPubSub.Close(); internalPubSubCloseErr != nil { err = errors.Join(err, internalPubSubCloseErr) } return err } ================================================ FILE: pubsub/gochannel/fanout_test.go ================================================ package gochannel_test import ( "context" "fmt" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill/message" "github.com/ThreeDotsLabs/watermill/pubsub/gochannel" ) func TestFanOut(t *testing.T) { const ( upstreamTopic = "upstream-topic" ) logger := watermill.NopLogger{} upstreamPubSub := gochannel.NewGoChannel(gochannel.Config{}, logger) fanout, err := gochannel.NewFanOut(upstreamPubSub, logger) require.NoError(t, err) fanout.AddSubscription(upstreamTopic) workersCount := 10 messagesCount := 100 router, err := message.NewRouter(message.RouterConfig{}, logger) require.NoError(t, err) expectedNumberOfMessages := workersCount * messagesCount receivedMessages := make(chan struct{}, expectedNumberOfMessages) for i := 0; i < workersCount; i++ { router.AddConsumerHandler( fmt.Sprintf("worker-%v", i), upstreamTopic, fanout, func(msg *message.Message) error { receivedMessages <- struct{}{} return nil }, ) } ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() go func() { err := router.Run(ctx) require.NoError(t, err) }() go func() { err := fanout.Run(ctx) require.NoError(t, err) }() <-router.Running() <-fanout.Running() go func() { for i := 0; i < messagesCount; i++ { msg := message.NewMessage(watermill.NewUUID(), nil) err := upstreamPubSub.Publish(upstreamTopic, msg) if err != nil { panic(err) } } }() <-ctx.Done() counter := 0 loop: for { select { case <-receivedMessages: counter += 1 case <-time.After(time.Second): close(receivedMessages) break loop } } require.Equal(t, expectedNumberOfMessages, counter) } func TestFanOut_RouterClosed(t *testing.T) { logger := watermill.NopLogger{} pubSub := gochannel.NewGoChannel(gochannel.Config{}, logger) fanout, err := gochannel.NewFanOut(pubSub, logger) require.NoError(t, err) fanout.AddSubscription("some-topic") go func() { err := fanout.Run(context.Background()) require.NoError(t, err) }() <-fanout.Running() err = fanout.Close() require.NoError(t, err) assert.True(t, fanout.IsClosed()) } ================================================ FILE: pubsub/gochannel/pubsub.go ================================================ package gochannel import ( "context" "sync" "github.com/lithammer/shortuuid/v3" "github.com/pkg/errors" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill/message" ) // Config holds the GoChannel Pub/Sub's configuration options. type Config struct { // Output channel buffer size. OutputChannelBuffer int64 // If persistent is set to true, when subscriber subscribes to the topic, // it will receive all previously produced messages. // // All messages are persisted to the memory (simple slice), // so be aware that with large amount of messages you can go out of the memory. Persistent bool // When true, Publish will block until subscriber Ack's the message. // If there are no subscribers, Publish will not block (also when Persistent is true). BlockPublishUntilSubscriberAck bool // PreserveContext is a flag that determines if the context should be preserved when sending messages to subscribers. // This behavior is different from other implementations of Publishers where data travels over the network, // hence context can't be preserved in those cases PreserveContext bool } // GoChannel is the simplest Pub/Sub implementation. // It is based on Golang's channels which are sent within the process. // // GoChannel has no global state, // that means that you need to use the same instance for Publishing and Subscribing! // // When GoChannel is persistent, messages order is not guaranteed. type GoChannel struct { config Config logger watermill.LoggerAdapter subscribersWg sync.WaitGroup subscribers map[string][]*subscriber subscribersLock sync.RWMutex subscribersByTopicLock sync.Map // map of *sync.Mutex closed bool closedLock sync.Mutex closing chan struct{} persistedMessages map[string][]*message.Message persistedMessagesLock sync.RWMutex } // NewGoChannel creates new GoChannel Pub/Sub. // // This GoChannel is not persistent. // That means if you send a message to a topic to which no subscriber is subscribed, that message will be discarded. func NewGoChannel(config Config, logger watermill.LoggerAdapter) *GoChannel { if logger == nil { logger = watermill.NopLogger{} } return &GoChannel{ config: config, subscribers: make(map[string][]*subscriber), subscribersByTopicLock: sync.Map{}, logger: logger.With( watermill.LogFields{ "pubsub_uuid": shortuuid.New(), }, ), closing: make(chan struct{}), persistedMessages: map[string][]*message.Message{}, } } // Publish in GoChannel is NOT blocking until all consumers consume. // Messages will be sent in background. // // Messages may be persisted or not, depending on persistent attribute. func (g *GoChannel) Publish(topic string, messages ...*message.Message) error { if g.isClosed() { return errors.New("Pub/Sub closed") } messagesToPublish := make(message.Messages, len(messages)) for i, msg := range messages { if g.config.PreserveContext { messagesToPublish[i] = msg.CopyWithContext() } else { messagesToPublish[i] = msg.Copy() } } g.subscribersLock.RLock() defer g.subscribersLock.RUnlock() subLock, loaded := g.subscribersByTopicLock.LoadOrStore(topic, &sync.Mutex{}) subLock.(*sync.Mutex).Lock() if !loaded { defer g.subscribersByTopicLock.Delete(topic) } defer subLock.(*sync.Mutex).Unlock() if g.config.Persistent { g.persistedMessagesLock.Lock() if _, ok := g.persistedMessages[topic]; !ok { g.persistedMessages[topic] = make([]*message.Message, 0) } g.persistedMessages[topic] = append(g.persistedMessages[topic], messagesToPublish...) g.persistedMessagesLock.Unlock() } for i := range messagesToPublish { msg := messagesToPublish[i] ackedBySubscribers, err := g.sendMessage(topic, msg) if err != nil { return err } if g.config.BlockPublishUntilSubscriberAck { g.waitForAckFromSubscribers(msg, ackedBySubscribers) } } return nil } func (g *GoChannel) waitForAckFromSubscribers(msg *message.Message, ackedByConsumer <-chan struct{}) { logFields := watermill.LogFields{"message_uuid": msg.UUID} g.logger.Debug("Waiting for subscribers ack", logFields) select { case <-ackedByConsumer: g.logger.Trace("Message acked by subscribers", logFields) case <-g.closing: g.logger.Trace("Closing Pub/Sub before ack from subscribers", logFields) } } func (g *GoChannel) sendMessage(topic string, message *message.Message) (<-chan struct{}, error) { subscribers := g.topicSubscribers(topic) ackedBySubscribers := make(chan struct{}) logFields := watermill.LogFields{"message_uuid": message.UUID, "topic": topic} if len(subscribers) == 0 { close(ackedBySubscribers) g.logger.Info("No subscribers to send message", logFields) return ackedBySubscribers, nil } go func(subscribers []*subscriber) { wg := &sync.WaitGroup{} for i := range subscribers { subscriber := subscribers[i] wg.Add(1) go func() { subscriber.sendMessageToSubscriber(message, logFields) wg.Done() }() } wg.Wait() close(ackedBySubscribers) }(subscribers) return ackedBySubscribers, nil } // Subscribe returns channel to which all published messages are sent. // Messages are not persisted. If there are no subscribers and message is produced it will be gone. // // There are no consumer groups support etc. Every consumer will receive every produced message. func (g *GoChannel) Subscribe(ctx context.Context, topic string) (<-chan *message.Message, error) { g.closedLock.Lock() if g.closed { g.closedLock.Unlock() return nil, errors.New("Pub/Sub closed") } g.subscribersWg.Add(1) g.closedLock.Unlock() g.subscribersLock.Lock() subLock, _ := g.subscribersByTopicLock.LoadOrStore(topic, &sync.Mutex{}) subLock.(*sync.Mutex).Lock() s := &subscriber{ ctx: ctx, uuid: watermill.NewUUID(), outputChannel: make(chan *message.Message, g.config.OutputChannelBuffer), logger: g.logger, closing: make(chan struct{}), preserveContext: g.config.PreserveContext, } go func(s *subscriber, g *GoChannel) { select { case <-ctx.Done(): // unblock case <-g.closing: // unblock } s.Close() g.subscribersLock.Lock() defer g.subscribersLock.Unlock() subLock, _ := g.subscribersByTopicLock.Load(topic) subLock.(*sync.Mutex).Lock() defer subLock.(*sync.Mutex).Unlock() g.removeSubscriber(topic, s) g.subscribersWg.Done() }(s, g) if !g.config.Persistent { defer g.subscribersLock.Unlock() defer subLock.(*sync.Mutex).Unlock() g.addSubscriber(topic, s) return s.outputChannel, nil } go func(s *subscriber) { defer g.subscribersLock.Unlock() defer subLock.(*sync.Mutex).Unlock() g.persistedMessagesLock.RLock() messages, ok := g.persistedMessages[topic] g.persistedMessagesLock.RUnlock() if ok { for i := range messages { msg := g.persistedMessages[topic][i] logFields := watermill.LogFields{"message_uuid": msg.UUID, "topic": topic} go s.sendMessageToSubscriber(msg, logFields) } } g.addSubscriber(topic, s) }(s) return s.outputChannel, nil } func (g *GoChannel) addSubscriber(topic string, s *subscriber) { if _, ok := g.subscribers[topic]; !ok { g.subscribers[topic] = make([]*subscriber, 0) } g.subscribers[topic] = append(g.subscribers[topic], s) } func (g *GoChannel) removeSubscriber(topic string, toRemove *subscriber) { removed := false for i, sub := range g.subscribers[topic] { if sub == toRemove { g.subscribers[topic] = append(g.subscribers[topic][:i], g.subscribers[topic][i+1:]...) removed = true if len(g.subscribers[topic]) == 0 && !g.config.Persistent { // Free up the memory taken by a topic which no longer has subscribers. // This operation allows publishing and subscribing to narrowly // focused topics that include random data like UUIDs in topic name. // // Without this operation, memory usage will grow indefinitely in a long-running service // as the map grows larger and larger with keys pointing to empty slices. delete(g.subscribers, topic) g.subscribersByTopicLock.Delete(topic) } break } } if !removed { panic("cannot remove subscriber, not found " + toRemove.uuid) } } func (g *GoChannel) topicSubscribers(topic string) []*subscriber { subscribers, ok := g.subscribers[topic] if !ok { return nil } // let's do a copy to avoid race conditions and deadlocks due to lock subscribersCopy := make([]*subscriber, len(subscribers)) copy(subscribersCopy, subscribers) return subscribersCopy } func (g *GoChannel) isClosed() bool { g.closedLock.Lock() defer g.closedLock.Unlock() return g.closed } // Close closes the GoChannel Pub/Sub. func (g *GoChannel) Close() error { g.closedLock.Lock() defer g.closedLock.Unlock() if g.closed { return nil } g.closed = true close(g.closing) g.logger.Debug("Closing Pub/Sub, waiting for subscribers", nil) g.subscribersWg.Wait() g.logger.Info("Pub/Sub closed", nil) g.persistedMessages = nil return nil } type subscriber struct { ctx context.Context uuid string sending sync.Mutex outputChannel chan *message.Message logger watermill.LoggerAdapter closed bool closing chan struct{} preserveContext bool } func (s *subscriber) Close() { if s.closed { return } close(s.closing) s.logger.Debug("Closing subscriber, waiting for sending lock", nil) // ensuring that we are not sending to closed channel s.sending.Lock() defer s.sending.Unlock() s.logger.Debug("GoChannel Pub/Sub Subscriber closed", nil) s.closed = true close(s.outputChannel) } func (s *subscriber) sendMessageToSubscriber(msg *message.Message, logFields watermill.LogFields) { ctx := msg.Context() // By default, the subscriber uses the context from the message and it's canceled right after it's processed. // If the message's context is preserved, the top-level client is responsible for canceling it. if !s.preserveContext { var cancelCtx context.CancelFunc ctx, cancelCtx = context.WithCancel(s.ctx) defer cancelCtx() } SendToSubscriber: for { // copy the message to prevent ack/nack propagation to other consumers // also allows to make retries on a fresh copy of the original message msgToSend := msg.Copy() msgToSend.SetContext(ctx) s.logger.Trace("Sending msg to subscriber", logFields) s.sending.Lock() if s.closed { s.logger.Info("Pub/Sub closed, discarding msg", logFields) s.sending.Unlock() return } select { case s.outputChannel <- msgToSend: s.logger.Trace("Sent message to subscriber", logFields) case <-s.closing: s.logger.Trace("Closing, message discarded", logFields) s.sending.Unlock() return } s.sending.Unlock() select { case <-msgToSend.Acked(): s.logger.Trace("Message acked", logFields) return case <-msgToSend.Nacked(): s.logger.Trace("Nack received, resending message", logFields) continue SendToSubscriber case <-s.closing: s.logger.Trace("Closing, message discarded", logFields) return } } } ================================================ FILE: pubsub/gochannel/pubsub_bench_test.go ================================================ package gochannel_test import ( "testing" "github.com/ThreeDotsLabs/watermill/pubsub/gochannel" "github.com/ThreeDotsLabs/watermill/pubsub/tests" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill/message" ) func BenchmarkSubscriber(b *testing.B) { tests.BenchSubscriber(b, func(n int) (message.Publisher, message.Subscriber) { pubSub := gochannel.NewGoChannel( gochannel.Config{OutputChannelBuffer: int64(n)}, watermill.NopLogger{}, ) return pubSub, pubSub }) } func BenchmarkSubscriberPersistent(b *testing.B) { tests.BenchSubscriber(b, func(n int) (message.Publisher, message.Subscriber) { pubSub := gochannel.NewGoChannel( gochannel.Config{ OutputChannelBuffer: int64(n), Persistent: true, }, watermill.NopLogger{}, ) return pubSub, pubSub }) } ================================================ FILE: pubsub/gochannel/pubsub_internal_test.go ================================================ package gochannel import ( "context" "strconv" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill/message" ) func TestSubscribe_clean_subscriber_data(t *testing.T) { subCount := 100 pubSub := NewGoChannel( Config{OutputChannelBuffer: int64(subCount)}, watermill.NewStdLogger(false, false), ) topicName := "test_topic" for i := 0; i < subCount; i++ { ctx, cancel := context.WithCancel(context.Background()) _, err := pubSub.Subscribe(ctx, topicName+"_index_"+strconv.Itoa(i)) require.NoError(t, err) cancel() } err := pubSub.Close() require.NoError(t, err) assert.Len(t, pubSub.subscribers, 0) lockCount := 0 pubSub.subscribersByTopicLock.Range(func(_, _ any) bool { lockCount++ return true }) assert.Equal(t, 0, lockCount) assert.NoError(t, pubSub.Close()) } func TestPublish_clean_lock_data(t *testing.T) { messageCount := 100 pubSub := NewGoChannel( Config{OutputChannelBuffer: int64(messageCount)}, watermill.NewStdLogger(false, false), ) topicName := "test_topic" _, err := pubSub.Subscribe(context.Background(), topicName+"_index_"+strconv.Itoa(0)) require.NoError(t, err) for i := 0; i < messageCount; i++ { err := pubSub.Publish(topicName+"_index_"+strconv.Itoa(i), message.NewMessage(watermill.NewShortUUID(), nil)) require.NoError(t, err) } lockCount := 0 pubSub.subscribersByTopicLock.Range(func(_, _ any) bool { lockCount++ return true }) assert.Equal(t, 1, lockCount) assert.NoError(t, pubSub.Close()) } ================================================ FILE: pubsub/gochannel/pubsub_stress_test.go ================================================ //go:build stress // +build stress package gochannel_test import ( "testing" "github.com/ThreeDotsLabs/watermill/pubsub/tests" ) func TestPublishSubscribe_stress(t *testing.T) { tests.TestPubSubStressTest( t, tests.Features{ ConsumerGroups: false, ExactlyOnceDelivery: true, GuaranteedOrder: false, Persistent: false, RequireSingleInstance: true, }, createPersistentPubSub, nil, ) } ================================================ FILE: pubsub/gochannel/pubsub_test.go ================================================ package gochannel_test import ( "context" "fmt" "log" "sync" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill/message" "github.com/ThreeDotsLabs/watermill/message/subscriber" "github.com/ThreeDotsLabs/watermill/pubsub/gochannel" "github.com/ThreeDotsLabs/watermill/pubsub/tests" ) func createPersistentPubSub(t *testing.T) (message.Publisher, message.Subscriber) { pubSub := gochannel.NewGoChannel( gochannel.Config{ OutputChannelBuffer: 10000, Persistent: true, }, watermill.NewStdLogger(true, true), ) return pubSub, pubSub } func createPersistentPubSubWithContextPreserved(t *testing.T) (message.Publisher, message.Subscriber) { pubSub := gochannel.NewGoChannel( gochannel.Config{ OutputChannelBuffer: 10000, Persistent: true, PreserveContext: true, }, watermill.NewStdLogger(true, true), ) return pubSub, pubSub } func TestPublishSubscribe_persistent(t *testing.T) { tests.TestPubSub( t, tests.Features{ ConsumerGroups: false, ExactlyOnceDelivery: true, GuaranteedOrder: false, Persistent: false, RequireSingleInstance: true, }, createPersistentPubSub, nil, ) } func TestPublishSubscribe_context_preserved(t *testing.T) { tests.TestPubSub( t, tests.Features{ ConsumerGroups: false, ExactlyOnceDelivery: true, GuaranteedOrder: false, Persistent: false, RequireSingleInstance: true, ContextPreserved: true, }, createPersistentPubSubWithContextPreserved, nil, ) } func TestPublishSubscribe_not_persistent(t *testing.T) { messagesCount := 100 pubSub := gochannel.NewGoChannel( gochannel.Config{OutputChannelBuffer: int64(messagesCount)}, watermill.NewStdLogger(true, true), ) topicName := "test_topic_" + watermill.NewUUID() msgs, err := pubSub.Subscribe(context.Background(), topicName) require.NoError(t, err) sendMessages := tests.PublishSimpleMessages(t, messagesCount, pubSub, topicName) receivedMsgs, _ := subscriber.BulkRead(msgs, messagesCount, time.Second) tests.AssertAllMessagesReceived(t, sendMessages, receivedMsgs) assert.NoError(t, pubSub.Close()) } func TestPublishSubscribe_not_persistent_with_context(t *testing.T) { messagesCount := 100 pubSub := gochannel.NewGoChannel( gochannel.Config{OutputChannelBuffer: int64(messagesCount), PreserveContext: true}, watermill.NewStdLogger(true, true), ) topicName := "test_topic_" + watermill.NewUUID() msgs, err := pubSub.Subscribe(context.Background(), topicName) require.NoError(t, err) const contextKeyString = "foo" sendMessages := tests.PublishSimpleMessagesWithContext(t, messagesCount, contextKeyString, pubSub, topicName) receivedMsgs, _ := subscriber.BulkRead(msgs, messagesCount, time.Second) expectedContexts := make(map[string]context.Context) for _, msg := range sendMessages { expectedContexts[msg.UUID] = msg.Context() } tests.AssertAllMessagesReceived(t, sendMessages, receivedMsgs) tests.AssertAllMessagesHaveSameContext(t, contextKeyString, expectedContexts, receivedMsgs) assert.NoError(t, pubSub.Close()) } func TestPublishSubscribe_block_until_ack(t *testing.T) { pubSub := gochannel.NewGoChannel( gochannel.Config{BlockPublishUntilSubscriberAck: true}, watermill.NewStdLogger(true, true), ) topicName := "test_topic_" + watermill.NewUUID() msgs, err := pubSub.Subscribe(context.Background(), topicName) require.NoError(t, err) published := make(chan struct{}) go func() { err := pubSub.Publish(topicName, message.NewMessage("1", nil)) require.NoError(t, err) close(published) }() msg1 := <-msgs select { case <-published: t.Fatal("publish should be blocked until ack") default: // ok } msg1.Nack() select { case <-published: t.Fatal("publish should be blocked after nack") default: // ok } msg2 := <-msgs msg2.Ack() select { case <-published: // ok case <-time.After(time.Second): t.Fatal("publish should be not blocked after ack") } } func TestPublishSubscribe_race_condition_on_subscribe(t *testing.T) { testsCount := 15 if testing.Short() { testsCount = 3 } for i := 0; i < testsCount; i++ { t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { t.Parallel() testPublishSubscribeSubRace(t) }) } } func TestSubscribe_race_condition_when_closing(t *testing.T) { testsCount := 15 if testing.Short() { testsCount = 3 } for i := 0; i < testsCount; i++ { t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { t.Parallel() pubSub := gochannel.NewGoChannel( gochannel.Config{}, watermill.NewStdLogger(true, false), ) go func() { err := pubSub.Close() require.NoError(t, err) }() _, err := pubSub.Subscribe(context.Background(), "topic") require.NoError(t, err) }) } } func TestPublish_race_condition_when_closing(t *testing.T) { testsCount := 15 if testing.Short() { testsCount = 3 } for i := 0; i < testsCount; i++ { t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { t.Parallel() pubSub := gochannel.NewGoChannel( gochannel.Config{}, watermill.NewStdLogger(true, false), ) go func() { _ = pubSub.Publish("topic", message.NewMessage(watermill.NewShortUUID(), nil)) }() err := pubSub.Close() require.NoError(t, err) }) } } func TestPublishSubscribe_do_not_block_other_subscribers(t *testing.T) { pubSub := gochannel.NewGoChannel( gochannel.Config{}, watermill.NewStdLogger(true, true), ) topicName := "test_topic_" + watermill.NewUUID() msgsFromSubscriber1, err := pubSub.Subscribe(context.Background(), topicName) require.NoError(t, err) _, err = pubSub.Subscribe(context.Background(), topicName) require.NoError(t, err) msgsFromSubscriber3, err := pubSub.Subscribe(context.Background(), topicName) require.NoError(t, err) err = pubSub.Publish(topicName, message.NewMessage("1", nil)) require.NoError(t, err) received := make(chan struct{}) go func() { msg := <-msgsFromSubscriber1 msg.Ack() msg = <-msgsFromSubscriber3 msg.Ack() close(received) }() select { case <-received: // ok case <-time.After(5 * time.Second): t.Fatal("subscriber which didn't ack a message blocked other subscribers from receiving it") } } func TestPublishSubscribe_flush_output_channel(t *testing.T) { messagesCount := 300 logger := watermill.NewStdLogger(true, true) ctx := context.Background() config := gochannel.Config{ OutputChannelBuffer: int64(messagesCount), Persistent: false, BlockPublishUntilSubscriberAck: false, } pubSub := gochannel.NewGoChannel( config, logger, ) totalMessage := 0 artificialWorkload := time.Millisecond * 5 //keep it small but noticeable in logs topicName := "test_topic" messageChannel, err := pubSub.Subscribe(ctx, topicName) if err != nil { t.Fatal(err) } // waitgroup for stopping the subscriber handler from processing/receiving messages until we are done filling the buffer of the pubSub var wgStartSubscriber sync.WaitGroup wgStartSubscriber.Add(1) // waitgroup for expected buffer to be flushed var wgFlushBuffer sync.WaitGroup wgFlushBuffer.Add(messagesCount) // start subscriber handler in a go routine // reads out the messages, if all is ok it should be able to ("flush") read all messages from a 'closed' pubsub go func(messageChannel <-chan *message.Message) { wgStartSubscriber.Wait() for msg := range messageChannel { // artificial workload time.Sleep(artificialWorkload) msg.Ack() logger.Trace("message acked", nil) // would normally use atomic value here but concurrency shouldn't be an issue for this test totalMessage++ wgFlushBuffer.Done() } logger.Trace("channel closed", nil) }(messageChannel) tests.PublishSimpleMessages(t, messagesCount, pubSub, topicName) // wait for buffer to fill then start reading in subscriber handler for { if len(messageChannel) != int(config.OutputChannelBuffer) { continue } else { wgStartSubscriber.Done() break } } err = pubSub.Close() if err != nil { t.Fatal(err) } // Publishing new message should still error as expected err = pubSub.Publish(topicName, message.NewMessage(watermill.NewUUID(), []byte("x"))) assert.ErrorContains(t, err, "Pub/Sub closed") // And so should subscribe _, err = pubSub.Subscribe(ctx, topicName) assert.ErrorContains(t, err, "Pub/Sub closed") wgFlushBuffer.Wait() // But subscriber handler should still be able to read the remaining messages from output channel aka flushing assert.Equal(t, messagesCount, totalMessage) } func testPublishSubscribeSubRace(t *testing.T) { t.Helper() messagesCount := 500 subscribersCount := 200 if testing.Short() { messagesCount = 200 subscribersCount = 20 } pubSub := gochannel.NewGoChannel( gochannel.Config{ OutputChannelBuffer: int64(messagesCount), Persistent: true, }, watermill.NewStdLogger(true, false), ) allSent := sync.WaitGroup{} allSent.Add(messagesCount) allReceived := sync.WaitGroup{} sentMessages := message.Messages{} go func() { for i := 0; i < messagesCount; i++ { msg := message.NewMessage(watermill.NewUUID(), nil) sentMessages = append(sentMessages, msg) go func() { require.NoError(t, pubSub.Publish("topic", msg)) allSent.Done() }() } }() subscriberReceivedCh := make(chan message.Messages, subscribersCount) for i := 0; i < subscribersCount; i++ { allReceived.Add(1) go func() { msgs, err := pubSub.Subscribe(context.Background(), "topic") require.NoError(t, err) received, _ := subscriber.BulkRead(msgs, messagesCount, time.Second*10) subscriberReceivedCh <- received allReceived.Done() }() } log.Println("waiting for all sent") allSent.Wait() log.Println("waiting for all received") allReceived.Wait() close(subscriberReceivedCh) log.Println("asserting") for subMsgs := range subscriberReceivedCh { tests.AssertAllMessagesReceived(t, sentMessages, subMsgs) } } ================================================ FILE: pubsub/sync/waitgroup.go ================================================ package sync import ( "sync" "time" ) // WaitGroupTimeout adds timeout feature for sync.WaitGroup.Wait(). // It returns true, when timed out. func WaitGroupTimeout(wg *sync.WaitGroup, timeout time.Duration) bool { wgClosed := make(chan struct{}, 1) go func() { wg.Wait() wgClosed <- struct{}{} }() select { case <-wgClosed: return false case <-time.After(timeout): return true } } ================================================ FILE: pubsub/sync/waitgroup_test.go ================================================ package sync import ( "sync" "testing" "time" "github.com/stretchr/testify/assert" ) func TestWaitGroupTimeout_no_timeout(t *testing.T) { wg := &sync.WaitGroup{} timedout := WaitGroupTimeout(wg, time.Millisecond*100) assert.False(t, timedout) } func TestWaitGroupTimeout_timeout(t *testing.T) { wg := &sync.WaitGroup{} wg.Add(1) timedout := WaitGroupTimeout(wg, time.Millisecond*100) assert.True(t, timedout) } ================================================ FILE: pubsub/tests/bench_pubsub.go ================================================ package tests import ( "context" "testing" "time" "github.com/ThreeDotsLabs/watermill/message" "github.com/ThreeDotsLabs/watermill/message/subscriber" ) // BenchmarkPubSubConstructor is a function that creates a Publisher and Subscriber to be used for benchmarks. type BenchmarkPubSubConstructor func(n int) (message.Publisher, message.Subscriber) // BenchSubscriber runs benchmark on a message Subscriber. func BenchSubscriber(b *testing.B, pubSubConstructor BenchmarkPubSubConstructor) { pub, sub := pubSubConstructor(b.N) topicName := testTopicName(TestContext{TestID: NewTestID()}) messages, err := sub.Subscribe(context.Background(), topicName) if err != nil { b.Fatal(err) } go func() { for i := 0; i < b.N; i++ { msg := message.NewMessage("1", nil) err := pub.Publish(topicName, msg) if err != nil { panic(err) } } }() b.ResetTimer() consumedMessages, all := subscriber.BulkRead(messages, b.N, time.Second*60) if !all { b.Fatalf("not all messages received, have %d, expected %d", len(consumedMessages), b.N) } } ================================================ FILE: pubsub/tests/test_asserts.go ================================================ package tests import ( "context" "sort" "testing" "github.com/ThreeDotsLabs/watermill/message" "github.com/stretchr/testify/assert" ) func difference(a, b []string) []string { mb := map[string]bool{} for _, x := range b { mb[x] = true } ab := []string{} for _, x := range a { if _, ok := mb[x]; !ok { ab = append(ab, x) } } return ab } // MissingMessages returns a list of missing messages UUIDs. func MissingMessages(expected message.Messages, received message.Messages) []string { sentIDs := expected.IDs() receivedIDs := received.IDs() sort.Strings(sentIDs) sort.Strings(receivedIDs) return difference(sentIDs, receivedIDs) } // AssertAllMessagesReceived checks if all messages were received, // ignoring the order and assuming that they are already deduplicated. func AssertAllMessagesReceived(t *testing.T, sent message.Messages, received message.Messages) bool { sentIDs := sent.IDs() receivedIDs := received.IDs() sort.Strings(sentIDs) sort.Strings(receivedIDs) if len(sentIDs) != len(receivedIDs) { t.Errorf("id's count is different: received: %d, sent: %d", len(receivedIDs), len(sentIDs)) } missing := MissingMessages(sent, received) extra := MissingMessages(received, sent) if len(missing) > 0 || len(extra) > 0 { t.Errorf("received different messages ID's, missing: %s, extra %s", missing, extra) return false } return true } // AssertMessagesPayloads check if received messages have the same payload as expected in expectedPayloads. func AssertMessagesPayloads( t *testing.T, expectedPayloads map[string][]byte, received []*message.Message, ) bool { assert.Len(t, received, len(expectedPayloads)) receivedMsgs := map[string]interface{}{} for _, msg := range received { receivedMsgs[msg.UUID] = string(msg.Payload) } ok := true for msgUUID, sentMsgPayload := range expectedPayloads { if !assert.EqualValues(t, sentMsgPayload, receivedMsgs[msgUUID]) { ok = false } } return ok } // AssertMessagesMetadata checks if metadata of all received messages is the same as in expectedValues. func AssertMessagesMetadata(t *testing.T, key string, expectedValues map[string]string, received []*message.Message) bool { assert.Len(t, received, len(expectedValues)) ok := true for _, msg := range received { if !assert.Equal(t, expectedValues[msg.UUID], msg.Metadata[key]) { ok = false } } return ok } // AssertAllMessagesHaveSameContext checks if context of all received messages is the same as in expectedValues, if PreserveContext is enabled. func AssertAllMessagesHaveSameContext(t *testing.T, contextKeyString string, expectedValues map[string]context.Context, received []*message.Message) { assert.Len(t, received, len(expectedValues)) for _, msg := range received { expectedValue := expectedValues[msg.UUID].Value(contextKey(contextKeyString)).(string) actualValue := msg.Context().Value(contextKey(contextKeyString)) assert.Equal(t, expectedValue, actualValue) } } ================================================ FILE: pubsub/tests/test_pubsub.go ================================================ package tests import ( "context" "fmt" "log" "math/rand" "os" "os/exec" "reflect" "runtime" "strconv" "strings" "sync" "testing" "time" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill/internal" internalSubscriber "github.com/ThreeDotsLabs/watermill/internal/subscriber" "github.com/ThreeDotsLabs/watermill/message" "github.com/ThreeDotsLabs/watermill/message/subscriber" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) var defaultTimeout = time.Second * 15 // TestPubSub is a universal test suite. Every Pub/Sub implementation should pass it // before it's considered production ready. // // Execution of the tests may be a bit different for every Pub/Sub. You can configure it by changing provided Features. func TestPubSub( t *testing.T, features Features, pubSubConstructor PubSubConstructor, consumerGroupPubSubConstructor ConsumerGroupPubSubConstructor, ) { testFuncs := []struct { Func func(t *testing.T, tCtx TestContext, pubSubConstructor PubSubConstructor) NotParallel bool }{ {Func: TestPublishSubscribe}, {Func: TestConcurrentSubscribe}, {Func: TestConcurrentSubscribeMultipleTopics}, {Func: TestResendOnError}, {Func: TestNoAck}, {Func: TestContinueAfterSubscribeClose}, {Func: TestConcurrentClose}, {Func: TestContinueAfterErrors}, {Func: TestPublishSubscribeInOrder}, {Func: TestPublisherClose}, {Func: TestTopic}, {Func: TestMessageCtx}, {Func: TestSubscribeCtx}, {Func: TestNewSubscriberReceivesOldMessages}, { Func: TestReconnect, NotParallel: true, }, } for i := range testFuncs { testFunc := testFuncs[i] runTest( t, getTestName(testFunc.Func), func(t *testing.T, testCtx TestContext) { testFunc.Func(t, testCtx, pubSubConstructor) }, features, !testFunc.NotParallel, ) } runTest( t, getTestName(TestConsumerGroups), func(t *testing.T, testCtx TestContext) { TestConsumerGroups( t, testCtx, consumerGroupPubSubConstructor, ) }, features, true, ) } // Features are used to configure Pub/Subs implementations behaviour. // Different features set decides also which, and how tests should be run. type Features struct { // ConsumerGroups should be true, if consumer groups are supported. ConsumerGroups bool // ExactlyOnceDelivery should be true, if exactly-once delivery is supported. ExactlyOnceDelivery bool // GuaranteedOrder should be true, if order of messages is guaranteed. GuaranteedOrder bool // Some Pub/Subs guarantee the order only when one subscriber is subscribed at a time. GuaranteedOrderWithSingleSubscriber bool // Persistent should be true, if messages are persistent between multiple instances of a Pub/Sub // (in practice, only GoChannel doesn't support that). Persistent bool // RestartServiceCommand is a command to test reconnects. It should restart the message broker. // Example: []string{"docker", "restart", "rabbitmq"} RestartServiceCommand []string // RequireSingleInstance must be true,if a PubSub requires a single instance to work properly // (for example: GoChannel implementation). RequireSingleInstance bool // NewSubscriberReceivesOldMessages should be set to true if messages are persisted even // if they are already consumed (for example, like in Kafka). NewSubscriberReceivesOldMessages bool // GenerateTopicFunc overrides standard topic name generation. GenerateTopicFunc func(tctx TestContext) string // GenerateIDFunc determines which function should be used for generating test IDs, NewTestID is used by default. GenerateIDFunc func() TestID // ForceShort forces running tests in short mode. // It's useful for Pub/Subs that are slow or have some limitations. ForceShort bool // ContextPreserved should be set to true if the Pub/Sub implementation preserves the context // of the message when it's published and consumed. ContextPreserved bool } // RunOnlyFastTests returns true if -short flag was provided -race was not provided. // Useful for excluding some slow tests. func RunOnlyFastTests() bool { return testing.Short() && !internal.RaceEnabled } // PubSubConstructor is a function that creates a Publisher and a Subscriber. type PubSubConstructor func(t *testing.T) (message.Publisher, message.Subscriber) // ConsumerGroupPubSubConstructor is a function that creates a Publisher and a Subscriber that use given consumer group. type ConsumerGroupPubSubConstructor func(t *testing.T, consumerGroup string) (message.Publisher, message.Subscriber) // SimpleMessage is deprecated: not used anywhere internally type SimpleMessage struct { Num int `json:"num"` } func getTestName(testFunc interface{}) string { fullName := runtime.FuncForPC(reflect.ValueOf(testFunc).Pointer()).Name() nameSliced := strings.Split(fullName, ".") return nameSliced[len(nameSliced)-1] } // TestID is a unique ID of a test. type TestID string func (t TestID) String() string { return string(t) } // NewTestID returns a new unique TestID. func NewTestID() TestID { return TestID(watermill.NewUUID()) } // NewTestULID returns a new unique TestID using ULID. func NewTestULID() TestID { return TestID(watermill.NewULID()) } // TestContext is a collection of values that belong to a single test. type TestContext struct { // Unique ID of the test TestID TestID // PubSub features Features Features } func runTest( t *testing.T, name string, fn func(t *testing.T, testCtx TestContext), features Features, parallel bool, ) { t.Run(name, func(t *testing.T) { if parallel { t.Parallel() } testID := NewTestID() t.Run(string(testID), func(t *testing.T) { tCtx := TestContext{ TestID: testID, Features: features, } fn(t, tCtx) }) }) } const defaultStressTestTestsCount = 10 // TestPubSubStressTest runs stress tests on a chosen Pub/Sub. func TestPubSubStressTest( t *testing.T, features Features, pubSubConstructor PubSubConstructor, consumerGroupPubSubConstructor ConsumerGroupPubSubConstructor, ) { stressTestsCount, _ := strconv.ParseInt(os.Getenv("STRESS_TEST_COUNT"), 10, 64) if stressTestsCount == 0 { stressTestsCount = defaultStressTestTestsCount } for i := 0; i < int(stressTestsCount); i++ { t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { t.Parallel() TestPubSub(t, features, pubSubConstructor, consumerGroupPubSubConstructor) }) } } // TestPublishSubscribe runs basic publish and subscribe tests on a chosen Pub/Sub. func TestPublishSubscribe( t *testing.T, tCtx TestContext, pubSubConstructor PubSubConstructor, ) { pub, sub := pubSubConstructor(t) topicName := testTopicName(tCtx) if subscribeInitializer, ok := sub.(message.SubscribeInitializer); ok { require.NoError(t, subscribeInitializer.SubscribeInitialize(topicName)) } var messagesToPublish []*message.Message messagesPayloads := map[string][]byte{} messagesTestMetadata := map[string]string{} for i := 0; i < 100; i++ { id := watermill.NewUUID() testMetadata := watermill.NewUUID() payload := fmt.Appendf(nil, "%d", i) msg := message.NewMessage(id, payload) msg.Metadata.Set("test", testMetadata) messagesTestMetadata[id] = testMetadata messagesToPublish = append(messagesToPublish, msg) messagesPayloads[id] = payload } err := publishWithRetry(pub, topicName, messagesToPublish...) require.NoError(t, err, "cannot publish message") messages, err := sub.Subscribe(context.Background(), topicName) require.NoError(t, err) receivedMessages, all := bulkRead(tCtx, messages, len(messagesToPublish), defaultTimeout*3) assert.True(t, all) AssertAllMessagesReceived(t, messagesToPublish, receivedMessages) AssertMessagesPayloads(t, messagesPayloads, receivedMessages) AssertMessagesMetadata(t, "test", messagesTestMetadata, receivedMessages) closePubSub(t, pub, sub) assertMessagesChannelClosed(t, messages) } // TestConcurrentSubscribe tests subscribing to messages by multiple concurrent subscribers. func TestConcurrentSubscribe( t *testing.T, tCtx TestContext, pubSubConstructor PubSubConstructor, ) { pub, initSub := pubSubConstructor(t) defer closePubSub(t, pub, initSub) topicName := testTopicName(tCtx) messagesCount := 5000 subscribersCount := 50 if testing.Short() || tCtx.Features.ForceShort { messagesCount = 100 subscribersCount = 10 } if subscribeInitializer, ok := initSub.(message.SubscribeInitializer); ok { require.NoError(t, subscribeInitializer.SubscribeInitialize(topicName)) } publishedMessages := AddSimpleMessagesParallel(t, messagesCount, pub, topicName, 50) var sub message.Subscriber if tCtx.Features.RequireSingleInstance { sub = initSub } else { sub = createMultipliedSubscriber(t, pubSubConstructor, subscribersCount) } defer closePubSub(t, pub, sub) messages, err := sub.Subscribe(context.Background(), topicName) require.NoError(t, err) receivedMessages, all := bulkRead(tCtx, messages, len(publishedMessages), defaultTimeout*3) assert.True(t, all) AssertAllMessagesReceived(t, publishedMessages, receivedMessages) } // TestConcurrentSubscribeMultipleTopics tests subscribing to messages by concurrent subscribers on multiple topics. func TestConcurrentSubscribeMultipleTopics( t *testing.T, tCtx TestContext, pubSubConstructor PubSubConstructor, ) { pub, sub := pubSubConstructor(t) defer closePubSub(t, pub, sub) messagesCount := 100 topicsCount := 20 if testing.Short() || tCtx.Features.ForceShort { messagesCount = 50 topicsCount = 10 } var messagesToPublish []*message.Message for i := 0; i < messagesCount; i++ { id := watermill.NewUUID() msg := message.NewMessage(id, []byte("x")) messagesToPublish = append(messagesToPublish, msg) } subsWg := sync.WaitGroup{} subsWg.Add(topicsCount) receivedMessagesCh := make(chan message.Messages, topicsCount) for i := 0; i < topicsCount; i++ { topicName := testTopicName(tCtx) + fmt.Sprintf("-%d", i) var messagesToPublishForTopic []*message.Message for _, msg := range messagesToPublish { newMsg := msg.Copy() messagesToPublishForTopic = append(messagesToPublishForTopic, newMsg) } go func() { defer subsWg.Done() if subscribeInitializer, ok := sub.(message.SubscribeInitializer); ok { err := subscribeInitializer.SubscribeInitialize(topicName) if err != nil { t.Error(err) } } err := publishWithRetry(pub, topicName, messagesToPublishForTopic...) if err != nil { t.Error(err) } messages, err := sub.Subscribe(context.Background(), topicName) if err != nil { t.Error(err) } topicMessages, _ := bulkRead(tCtx, messages, len(messagesToPublishForTopic), defaultTimeout*5) receivedMessagesCh <- topicMessages }() } subsWg.Wait() close(receivedMessagesCh) topicsReceivedMessages := 0 for msgs := range receivedMessagesCh { AssertAllMessagesReceived(t, messagesToPublish, msgs) topicsReceivedMessages++ } assert.Equal(t, topicsCount, topicsReceivedMessages) } // TestPublishSubscribeInOrder tests if published messages are received in a proper order. // This test is skipped for Pub/Subs that don't support GuaranteedOrder feature. func TestPublishSubscribeInOrder( t *testing.T, tCtx TestContext, pubSubConstructor PubSubConstructor, ) { if !tCtx.Features.GuaranteedOrder { t.Skipf("order is not guaranteed") } messagesCount := 1000 if testing.Short() || tCtx.Features.ForceShort { messagesCount = 100 } pub, initSub := pubSubConstructor(t) defer closePubSub(t, pub, initSub) topicName := testTopicName(tCtx) if subscribeInitializer, ok := initSub.(message.SubscribeInitializer); ok { require.NoError(t, subscribeInitializer.SubscribeInitialize(topicName)) } var messagesToPublish []*message.Message expectedMessages := map[string][]string{} for i := 0; i < messagesCount; i++ { id := watermill.NewUUID() msgType := fmt.Sprintf("%d", i%16) msg := message.NewMessage(id, []byte(msgType)) messagesToPublish = append(messagesToPublish, msg) if _, ok := expectedMessages[msgType]; !ok { expectedMessages[msgType] = []string{} } expectedMessages[msgType] = append(expectedMessages[msgType], msg.UUID) } err := publishWithRetry(pub, topicName, messagesToPublish...) require.NoError(t, err) var sub message.Subscriber if tCtx.Features.RequireSingleInstance { sub = initSub } else { subscribersCount := 10 if tCtx.Features.GuaranteedOrderWithSingleSubscriber { subscribersCount = 1 } sub = createMultipliedSubscriber(t, pubSubConstructor, subscribersCount) defer require.NoError(t, sub.Close()) } messages, err := sub.Subscribe(context.Background(), topicName) require.NoError(t, err) receivedMessages, all := bulkRead(tCtx, messages, len(messagesToPublish), defaultTimeout) require.True(t, all, "not all messages received (%d of %d)", len(receivedMessages), len(messagesToPublish)) receivedMessagesByType := map[string][]string{} for _, msg := range receivedMessages { if _, ok := receivedMessagesByType[string(msg.Payload)]; !ok { receivedMessagesByType[string(msg.Payload)] = []string{} } receivedMessagesByType[string(msg.Payload)] = append(receivedMessagesByType[string(msg.Payload)], msg.UUID) } require.Equal(t, len(receivedMessagesByType), len(expectedMessages)) require.Equal(t, len(receivedMessages), len(messagesToPublish)) for key, ids := range expectedMessages { assert.Equal(t, ids, receivedMessagesByType[key]) } } // TestResendOnError tests if messages are re-delivered after the subscriber fails to process them. func TestResendOnError( t *testing.T, tCtx TestContext, pubSubConstructor PubSubConstructor, ) { pub, sub := pubSubConstructor(t) defer closePubSub(t, pub, sub) topicName := testTopicName(tCtx) if subscribeInitializer, ok := sub.(message.SubscribeInitializer); ok { require.NoError(t, subscribeInitializer.SubscribeInitialize(topicName)) } messagesToSend := 100 nacksCount := 2 var publishedMessages message.Messages allMessagesSent := make(chan struct{}) publishedMessages = PublishSimpleMessages(t, messagesToSend, pub, topicName) close(allMessagesSent) messages, err := sub.Subscribe(context.Background(), topicName) require.NoError(t, err) NackLoop: for i := 0; i < nacksCount; i++ { select { case msg, closed := <-messages: if !closed { t.Fatal("messages channel closed before all received") } log.Println("sending err for ", msg.UUID) msg.Nack() case <-time.After(defaultTimeout): break NackLoop } } receivedMessages, _ := bulkRead(tCtx, messages, messagesToSend, defaultTimeout) <-allMessagesSent AssertAllMessagesReceived(t, publishedMessages, receivedMessages) } // TestNoAck tests if no new messages are received by the subscriber until the previous message is acknowledged. // This test is skipped for Pub/Subs that don't support GuaranteedOrder feature. func TestNoAck( t *testing.T, tCtx TestContext, pubSubConstructor PubSubConstructor, ) { if !tCtx.Features.GuaranteedOrder { t.Skip("guaranteed order is required for this test") } pub, sub := pubSubConstructor(t) defer closePubSub(t, pub, sub) topicName := testTopicName(tCtx) if subscribeInitializer, ok := sub.(message.SubscribeInitializer); ok { require.NoError(t, subscribeInitializer.SubscribeInitialize(topicName)) } for i := 0; i < 2; i++ { id := watermill.NewUUID() log.Printf("sending %s", id) msg := message.NewMessage(id, []byte("x")) err := publishWithRetry(pub, topicName, msg) require.NoError(t, err) } messages, err := sub.Subscribe(context.Background(), topicName) require.NoError(t, err) receivedMessage := make(chan struct{}) unlockAck := make(chan struct{}, 1) go func() { msg := <-messages receivedMessage <- struct{}{} <-unlockAck msg.Ack() }() select { case <-receivedMessage: // ok case <-time.After(defaultTimeout): t.Fatal("timed out") } select { case msg := <-messages: t.Fatalf("messages channel should be blocked since Ack() was not sent, received %s", msg.UUID) case <-time.After(time.Millisecond * 100): // ok } unlockAck <- struct{}{} select { case msg := <-messages: msg.Ack() case <-time.After(time.Second * 5): t.Fatal("messages channel should be unblocked after Ack()") } if tCtx.Features.ExactlyOnceDelivery { select { case <-messages: t.Fatal("msg should be not sent again") case <-time.After(time.Millisecond * 50): // ok } } } // TestContinueAfterSubscribeClose checks, that we don't lose messages after closing subscriber. func TestContinueAfterSubscribeClose( t *testing.T, tCtx TestContext, createPubSub PubSubConstructor, ) { if !tCtx.Features.Persistent { t.Skip("Non-Persistent is not supported yet") } if tCtx.Features.ExactlyOnceDelivery { t.Skip("ExactlyOnceDelivery test is not supported yet") } totalMessagesCount := 5000 batches := 5 if testing.Short() || tCtx.Features.ForceShort { totalMessagesCount = 50 batches = 2 } batchSize := int(totalMessagesCount / batches) readAttempts := batches * 4 pub, sub := createPubSub(t) defer closePubSub(t, pub, sub) topicName := testTopicName(tCtx) if subscribeInitializer, ok := sub.(message.SubscribeInitializer); ok { require.NoError(t, subscribeInitializer.SubscribeInitialize(topicName)) } publishedMessages := AddSimpleMessagesParallel(t, totalMessagesCount, pub, topicName, 50) receivedMessages := map[string]*message.Message{} for i := 0; i < readAttempts; i++ { pub, sub := createPubSub(t) messages, err := sub.Subscribe(context.Background(), topicName) require.NoError(t, err) messagesToRead := batchSize messagesLeft := totalMessagesCount - len(receivedMessages) if messagesToRead > messagesLeft { messagesToRead = messagesLeft } receivedMessagesBatch, _ := bulkRead(tCtx, messages, messagesToRead, defaultTimeout) closePubSub(t, pub, sub) for _, msg := range receivedMessagesBatch { receivedMessages[msg.UUID] = msg } closePubSub(t, pub, sub) if len(receivedMessages) >= totalMessagesCount { break } } // to make this test more robust - let's consume all missing messages // (we care here if we didn't lose any message, not if we received duplicated) missingMessagesCount := totalMessagesCount - len(receivedMessages) if missingMessagesCount > 0 && !tCtx.Features.ExactlyOnceDelivery { messages, err := sub.Subscribe(context.Background(), topicName) require.NoError(t, err) defer closePubSub(t, pub, sub) timeout := time.After(defaultTimeout) MessagesLoop: for len(receivedMessages) < totalMessagesCount { select { case msg, ok := <-messages: if !ok { break MessagesLoop } receivedMessages[msg.UUID] = msg msg.Ack() case <-timeout: break MessagesLoop } } } // we need to deduplicate messages, because bulkRead will deduplicate only per one batch uniqueReceivedMessages := message.Messages{} for _, msg := range receivedMessages { uniqueReceivedMessages = append(uniqueReceivedMessages, msg) } AssertAllMessagesReceived(t, publishedMessages, uniqueReceivedMessages) } // TestConcurrentClose tests if the Pub/Sub works correctly when subscribers are being closed concurrently. func TestConcurrentClose( t *testing.T, tCtx TestContext, createPubSub PubSubConstructor, ) { if tCtx.Features.ExactlyOnceDelivery { t.Skip("ExactlyOnceDelivery test is not supported yet") } topicName := testTopicName(tCtx) createPub, createSub := createPubSub(t) if subscribeInitializer, ok := createSub.(message.SubscribeInitializer); ok { require.NoError(t, subscribeInitializer.SubscribeInitialize(topicName)) } closePubSub(t, createPub, createSub) totalMessagesCount := 50 closeWg := sync.WaitGroup{} closeWg.Add(10) for i := 0; i < 10; i++ { go func() { defer closeWg.Done() pub, sub := createPubSub(t) defer closePubSub(t, pub, sub) _, err := sub.Subscribe(context.Background(), topicName) require.NoError(t, err) }() } closeWg.Wait() pub, sub := createPubSub(t) expectedMessages := PublishSimpleMessages(t, totalMessagesCount, pub, topicName) closePubSub(t, pub, sub) pub, sub = createPubSub(t) messages, err := sub.Subscribe(context.Background(), topicName) require.NoError(t, err) receivedMessages, all := bulkRead(tCtx, messages, len(expectedMessages), defaultTimeout*3) assert.True(t, all) AssertAllMessagesReceived(t, expectedMessages, receivedMessages) closePubSub(t, pub, sub) } // TestContinueAfterErrors tests if messages are processed again after an initial failure. func TestContinueAfterErrors( t *testing.T, tCtx TestContext, createPubSub PubSubConstructor, ) { pub, sub := createPubSub(t) defer closePubSub(t, pub, sub) topicName := testTopicName(tCtx) if subscribeInitializer, ok := sub.(message.SubscribeInitializer); ok { require.NoError(t, subscribeInitializer.SubscribeInitialize(topicName)) } totalMessagesCount := 50 subscribersToNack := 3 nacksPerSubscriber := 100 if testing.Short() || tCtx.Features.ForceShort { subscribersToNack = 1 nacksPerSubscriber = 5 } messagesToPublish := PublishSimpleMessages(t, totalMessagesCount, pub, topicName) for i := 0; i < subscribersToNack; i++ { var errorsPub message.Publisher var errorsSub message.Subscriber if !tCtx.Features.Persistent { errorsPub = pub errorsSub = sub } else { errorsPub, errorsSub = createPubSub(t) } messages, err := errorsSub.Subscribe(context.Background(), topicName) require.NoError(t, err) for j := 0; j < nacksPerSubscriber; j++ { select { case msg := <-messages: msg.Nack() case <-time.After(defaultTimeout): t.Fatal("no messages left, probably seek after error doesn't work") } } if tCtx.Features.Persistent { closePubSub(t, errorsPub, errorsSub) } } messages, err := sub.Subscribe(context.Background(), topicName) require.NoError(t, err) // only nacks was sent, so all messages should be consumed receivedMessages, _ := bulkRead(tCtx, messages, totalMessagesCount, defaultTimeout*6) AssertAllMessagesReceived(t, messagesToPublish, receivedMessages) } // TestConsumerGroups tests if the consumer groups feature behaves correctly. // This test is skipped for Pub/Sub that don't support ConsumerGroups feature. func TestConsumerGroups( t *testing.T, tCtx TestContext, pubSubConstructor ConsumerGroupPubSubConstructor, ) { if !tCtx.Features.ConsumerGroups { t.Skip("consumer groups are not supported") } publisherPub, publisherSub := pubSubConstructor(t, "test_"+watermill.NewUUID()) defer closePubSub(t, publisherPub, publisherSub) topicName := testTopicName(tCtx) if subscribeInitializer, ok := publisherSub.(message.SubscribeInitializer); ok { require.NoError(t, subscribeInitializer.SubscribeInitialize(topicName)) } totalMessagesCount := 50 group1 := generateConsumerGroup(t, pubSubConstructor, topicName, tCtx) group2 := generateConsumerGroup(t, pubSubConstructor, topicName, tCtx) messagesToPublish := PublishSimpleMessages(t, totalMessagesCount, publisherPub, topicName) assertConsumerGroupReceivedMessages(t, tCtx, pubSubConstructor, group1, topicName, messagesToPublish) assertConsumerGroupReceivedMessages(t, tCtx, pubSubConstructor, group2, topicName, messagesToPublish) } // TestPublisherClose sends big amount of messages and them run close to ensure that messages are not lost during adding. func TestPublisherClose( t *testing.T, tCtx TestContext, pubSubConstructor PubSubConstructor, ) { pub, sub := pubSubConstructor(t) defer closePubSub(t, pub, sub) topicName := testTopicName(tCtx) if subscribeInitializer, ok := sub.(message.SubscribeInitializer); ok { require.NoError(t, subscribeInitializer.SubscribeInitialize(topicName)) } messagesCount := 10000 if testing.Short() || tCtx.Features.ForceShort { messagesCount = 100 } producedMessages := AddSimpleMessagesParallel(t, messagesCount, pub, topicName, 20) messages, err := sub.Subscribe(context.Background(), topicName) require.NoError(t, err) receivedMessages, _ := bulkRead(tCtx, messages, messagesCount, defaultTimeout*3) AssertAllMessagesReceived(t, producedMessages, receivedMessages) } // TestTopic tests if different topics work correctly in a Pub/Sub. func TestTopic( t *testing.T, tCtx TestContext, pubSubConstructor PubSubConstructor, ) { pub, sub := pubSubConstructor(t) defer closePubSub(t, pub, sub) topic1 := testTopicName(tCtx) + "-1" topic2 := testTopicName(tCtx) + "-2" if subscribeInitializer, ok := sub.(message.SubscribeInitializer); ok { require.NoError(t, subscribeInitializer.SubscribeInitialize(topic1)) } if subscribeInitializer, ok := sub.(message.SubscribeInitializer); ok { require.NoError(t, subscribeInitializer.SubscribeInitialize(topic2)) } topic1Msg := message.NewMessage(watermill.NewUUID(), []byte("x")) topic2Msg := message.NewMessage(watermill.NewUUID(), []byte("x")) require.NoError(t, publishWithRetry(pub, topic1, topic1Msg)) require.NoError(t, publishWithRetry(pub, topic2, topic2Msg)) messagesTopic1, err := sub.Subscribe(context.Background(), topic1) require.NoError(t, err) messagesTopic2, err := sub.Subscribe(context.Background(), topic2) require.NoError(t, err) messagesConsumedTopic1, received := bulkRead(tCtx, messagesTopic1, 1, defaultTimeout) require.True(t, received, "no messages received in topic %s", topic1) messagesConsumedTopic2, received := bulkRead(tCtx, messagesTopic2, 1, defaultTimeout) require.True(t, received, "no messages received in topic %s", topic2) assert.Equal(t, messagesConsumedTopic1.IDs()[0], topic1Msg.UUID) assert.Equal(t, messagesConsumedTopic2.IDs()[0], topic2Msg.UUID) } // TestMessageCtx tests if the Message's Context works correctly. func TestMessageCtx( t *testing.T, tCtx TestContext, pubSubConstructor PubSubConstructor, ) { pub, sub := pubSubConstructor(t) defer closePubSub(t, pub, sub) topicName := testTopicName(tCtx) if subscribeInitializer, ok := sub.(message.SubscribeInitializer); ok { require.NoError(t, subscribeInitializer.SubscribeInitialize(topicName)) } messages, err := sub.Subscribe(context.Background(), topicName) require.NoError(t, err) msg1 := message.NewMessage(watermill.NewUUID(), []byte("x")) msg2 := message.NewMessage(watermill.NewUUID(), []byte("x")) // this might actually be an error in some pubsubs (http), because we close the subscriber without ACK. _ = pub.Publish(topicName, msg1) _ = pub.Publish(topicName, msg2) select { case msg := <-messages: require.True(t, msg.Ack()) if !tCtx.Features.ContextPreserved { select { case <-msg.Context().Done(): // ok case <-time.After(defaultTimeout): t.Fatal("context should be canceled after Ack") } } case <-time.After(defaultTimeout): t.Fatal("no message received") } select { case msg := <-messages: go closePubSub(t, pub, sub) if !tCtx.Features.ContextPreserved { select { case <-msg.Context().Done(): // ok case <-time.After(defaultTimeout): t.Fatal("context should be canceled after pubSub.Close()") } } case <-time.After(defaultTimeout): t.Fatal("no message received") } } type contextKey string // TestSubscribeCtx tests if the Subscriber's Context works correctly. func TestSubscribeCtx( t *testing.T, tCtx TestContext, pubSubConstructor PubSubConstructor, ) { pub, sub := pubSubConstructor(t) defer closePubSub(t, pub, sub) const messagesCount = 20 ctxWithCancel, cancel := context.WithCancel(context.Background()) ctxWithCancel = context.WithValue(ctxWithCancel, contextKey("foo"), "bar") topicName := testTopicName(tCtx) if subscribeInitializer, ok := sub.(message.SubscribeInitializer); ok { require.NoError(t, subscribeInitializer.SubscribeInitialize(topicName)) } var publishedMessages message.Messages var contextKeyString = "abc" if tCtx.Features.ContextPreserved { publishedMessages = PublishSimpleMessagesWithContext(t, messagesCount, contextKeyString, pub, topicName) } else { publishedMessages = PublishSimpleMessages(t, messagesCount, pub, topicName) } msgsToCancel, err := sub.Subscribe(ctxWithCancel, topicName) require.NoError(t, err) cancel() timeout := time.After(defaultTimeout) ClosedLoop: for { select { case msg, open := <-msgsToCancel: if !open { break ClosedLoop } msg.Nack() case <-timeout: t.Fatal("messages channel is not closed after ", defaultTimeout) } time.Sleep(time.Millisecond * 100) } ctx := context.WithValue(context.Background(), contextKey("foo"), "bar") // For mocking the output of pub-subs where context is preserved vs not preserved expectedContexts := make(map[string]context.Context) for _, msg := range publishedMessages { if tCtx.Features.ContextPreserved { expectedContexts[msg.UUID] = msg.Context() } else { expectedContexts[msg.UUID] = ctx } } msgs, err := sub.Subscribe(ctx, topicName) require.NoError(t, err) receivedMessages, _ := bulkRead(tCtx, msgs, messagesCount, defaultTimeout) AssertAllMessagesReceived(t, publishedMessages, receivedMessages) if tCtx.Features.ContextPreserved { AssertAllMessagesHaveSameContext(t, contextKeyString, expectedContexts, receivedMessages) } } // TestReconnect tests if reconnecting to a Pub/Sub works correctly. func TestReconnect( t *testing.T, tCtx TestContext, pubSubConstructor PubSubConstructor, ) { if len(tCtx.Features.RestartServiceCommand) == 0 { t.Skip("no RestartServiceCommand provided, cannot test reconnect") } pub, sub := pubSubConstructor(t) topicName := testTopicName(tCtx) if subscribeInitializer, ok := sub.(message.SubscribeInitializer); ok { require.NoError(t, subscribeInitializer.SubscribeInitialize(topicName)) } const messagesCount = 10000 const publishersCount = 100 restartAfterMessages := map[int]struct{}{ messagesCount / 3: {}, // restart at 1/3 of messages messagesCount / 2: {}, // restart at 1/2 of messages } messages, err := sub.Subscribe(context.Background(), topicName) require.NoError(t, err) var publishedMessages message.Messages messagePublished := make(chan *message.Message, messagesCount) publishMessage := make(chan struct{}) go func() { for i := 0; i < messagesCount; i++ { publishMessage <- struct{}{} if _, shouldRestart := restartAfterMessages[i]; shouldRestart { go restartServer(t, tCtx.Features) } } close(publishMessage) }() go func() { for msg := range messagePublished { publishedMessages = append(publishedMessages, msg) } }() for i := 0; i < publishersCount; i++ { go func() { for range publishMessage { id := watermill.NewUUID() msg := message.NewMessage(id, []byte("x")) for { fmt.Println("publishing message") // some randomization in sending if rand.Int31n(10) == 0 { time.Sleep(time.Millisecond * 500) } if err := publishWithRetry(pub, topicName, msg); err == nil { break } fmt.Printf("cannot publish message %s, trying again, err: %s\n", msg.UUID, err) time.Sleep(time.Millisecond * 500) } messagePublished <- msg } }() } receivedMessages, allMessages := bulkRead(tCtx, messages, messagesCount, defaultTimeout*4) assert.True(t, allMessages, "not all messages received (has %d of %d)", len(receivedMessages), messagesCount) AssertAllMessagesReceived(t, publishedMessages, receivedMessages) closePubSub(t, pub, sub) } // TestNewSubscriberReceivesOldMessages tests if a new subscriber receives previously published messages. func TestNewSubscriberReceivesOldMessages( t *testing.T, tCtx TestContext, pubSubConstructor PubSubConstructor, ) { if !tCtx.Features.NewSubscriberReceivesOldMessages { t.Skip("only subscribers with TestNewSubscriberReceivesOldMessages are supported") } publishedMessages := message.Messages{} pub, sub := pubSubConstructor(t) topicName := testTopicName(tCtx) if subscribeInitializer, ok := sub.(message.SubscribeInitializer); ok { require.NoError(t, subscribeInitializer.SubscribeInitialize(topicName)) } require.NoError(t, sub.Close()) var publishMessage = func() { publishedMessages = append(publishedMessages, PublishSimpleMessages(t, 1, pub, topicName)...) } publishMessage() type Subscriber struct { Msgs <-chan *message.Message Subscriber message.Subscriber ConsumedMessages int } var subscribers []*Subscriber defer func() { for _, sub := range subscribers { require.NoError(t, sub.Subscriber.Close()) } }() var addSubscriber = func() { pub, sub := pubSubConstructor(t) require.NoError(t, pub.Close()) msgs, err := sub.Subscribe(context.Background(), topicName) require.NoError(t, err) subscribers = append(subscribers, &Subscriber{ Msgs: msgs, Subscriber: sub, ConsumedMessages: 0, }) } var consumeMessages = func() { for i, sub := range subscribers { toConsume := len(publishedMessages) - sub.ConsumedMessages receivedMessages, all := bulkRead(tCtx, sub.Msgs, toConsume, defaultTimeout) require.True(t, all, "subscriber %d not received all messages (%d/%d)", i, len(receivedMessages), toConsume) fmt.Printf("subscriber no %d consumed %d messages\n", i, toConsume) sub.ConsumedMessages += toConsume } } publishMessage() addSubscriber() consumeMessages() publishMessage() addSubscriber() consumeMessages() publishMessage() addSubscriber() consumeMessages() } func restartServer(t *testing.T, features Features) { fmt.Println("restarting server with:", features.RestartServiceCommand) cmd := exec.Command(features.RestartServiceCommand[0], features.RestartServiceCommand[1:]...) cmd.Stderr = os.Stderr cmd.Stdout = os.Stdout if err := cmd.Run(); err != nil { t.Error(err) } fmt.Println("server restarted") } func assertConsumerGroupReceivedMessages( t *testing.T, tCtx TestContext, pubSubConstructor ConsumerGroupPubSubConstructor, consumerGroup string, topicName string, expectedMessages []*message.Message, ) { pub, sub := pubSubConstructor(t, consumerGroup) defer closePubSub(t, pub, sub) messages, err := sub.Subscribe(context.Background(), topicName) require.NoError(t, err) receivedMessages, all := bulkRead(tCtx, messages, len(expectedMessages), defaultTimeout) assert.True(t, all) AssertAllMessagesReceived(t, expectedMessages, receivedMessages) } func testTopicName(tCtx TestContext) string { if tCtx.Features.GenerateTopicFunc != nil { return tCtx.Features.GenerateTopicFunc(tCtx) } return "topic-" + string(tCtx.TestID) } func newTestID(tCtx TestContext) TestID { if tCtx.Features.GenerateIDFunc != nil { return tCtx.Features.GenerateIDFunc() } return NewTestID() } func closePubSub(t *testing.T, pub message.Publisher, sub message.Subscriber) { err := pub.Close() require.NoError(t, err) err = sub.Close() require.NoError(t, err) } func generateConsumerGroup(t *testing.T, pubSubConstructor ConsumerGroupPubSubConstructor, topicName string, tCtx TestContext) string { groupName := "cg_" + newTestID(tCtx).String() // create a pubsub to ensure that the consumer group exists // for those providers that require subscription before publishing messages (e.g. Google Cloud PubSub) pub, sub := pubSubConstructor(t, groupName) if subInitializer, ok := sub.(message.SubscribeInitializer); ok { require.NoError(t, subInitializer.SubscribeInitialize(topicName)) } _, err := sub.Subscribe(context.Background(), topicName) require.NoError(t, err) closePubSub(t, pub, sub) return groupName } // PublishSimpleMessages publishes provided number of simple messages without a payload. func PublishSimpleMessages(t *testing.T, messagesCount int, publisher message.Publisher, topicName string) message.Messages { var messagesToPublish []*message.Message for i := 0; i < messagesCount; i++ { id := watermill.NewUUID() msg := message.NewMessage(id, []byte("x")) messagesToPublish = append(messagesToPublish, msg) err := publishWithRetry(publisher, topicName, msg) require.NoError(t, err, "cannot publish messages") } return messagesToPublish } // PublishSimpleMessagesWithContext publishes provided number of simple messages without a payload, but custom context func PublishSimpleMessagesWithContext(t *testing.T, messagesCount int, contextKeyString string, publisher message.Publisher, topicName string) message.Messages { var messagesToPublish []*message.Message for i := 0; i < messagesCount; i++ { id := watermill.NewUUID() msg := message.NewMessage(id, nil) msg.SetContext(context.WithValue(context.Background(), contextKey(contextKeyString), "bar"+strconv.Itoa(i))) messagesToPublish = append(messagesToPublish, msg) err := publishWithRetry(publisher, topicName, msg) require.NoError(t, err, "cannot publish messages") } return messagesToPublish } // AddSimpleMessagesParallel publishes provided number of simple messages without a payload // using the provided number of publishers (goroutines). func AddSimpleMessagesParallel(t *testing.T, messagesCount int, publisher message.Publisher, topicName string, publishers int) message.Messages { var messagesToPublish []*message.Message publishMsg := make(chan *message.Message) wg := sync.WaitGroup{} wg.Add(messagesCount) for i := 0; i < publishers; i++ { go func() { for msg := range publishMsg { err := publishWithRetry(publisher, topicName, msg.Copy()) require.NoError(t, err, "cannot publish messages") wg.Done() } }() } for i := 0; i < messagesCount; i++ { id := watermill.NewUUID() msg := message.NewMessage(id, []byte("x")) messagesToPublish = append(messagesToPublish, msg) publishMsg <- msg } close(publishMsg) wg.Wait() return messagesToPublish } func assertMessagesChannelClosed(t *testing.T, messages <-chan *message.Message) bool { select { case _, open := <-messages: return assert.False(t, open) default: t.Error("messages channel is not closed (blocked)") return false } } func publishWithRetry(publisher message.Publisher, topic string, messages ...*message.Message) error { retries := 5 for { err := publisher.Publish(topic, messages...) if err == nil { return nil } retries-- fmt.Printf("error on publish: %s, %d retries left\n", err, retries) if retries == 0 { return err } } } func bulkRead(testCtx TestContext, messagesCh <-chan *message.Message, limit int, timeout time.Duration) (receivedMessages message.Messages, all bool) { start := time.Now() defer func() { duration := time.Since(start) logMsg := "all messages (%d/%d) received in bulk read after %s of %s (test ID: %s)\n" if !all { logMsg = "not " + logMsg } log.Printf(logMsg, len(receivedMessages), limit, duration, timeout, testCtx.TestID) }() if !testCtx.Features.ExactlyOnceDelivery { return subscriber.BulkReadWithDeduplication(messagesCh, limit, timeout) } return subscriber.BulkRead(messagesCh, limit, timeout) } func createMultipliedSubscriber(t *testing.T, pubSubConstructor PubSubConstructor, subscribersCount int) message.Subscriber { return internalSubscriber.NewMultiplier( func() (message.Subscriber, error) { pub, sub := pubSubConstructor(t) require.NoError(t, pub.Close()) // pub is not needed return sub, nil }, subscribersCount, ) } ================================================ FILE: pubsub/tests/test_pubsub_stress.go ================================================ //go:build stress // +build stress package tests import ( "runtime" ) func init() { // stress tests may work a bit slower defaultTimeout *= 5 // Set GOMAXPROCS to double the number of CPUs runtime.GOMAXPROCS(runtime.GOMAXPROCS(0) * 2) } ================================================ FILE: slog.go ================================================ package watermill import ( "context" "log/slog" ) // LevelTrace must be added, because [slog] package does not have one by default. Generate it by subtracting 4 levels from [slog.Debug] following the example of [slog.LevelWarn] and [slog.LevelError] which are set to 4 and 8. const LevelTrace = slog.LevelDebug - 4 func slogAttrsFromFields(fields LogFields) []any { result := make([]any, 0, len(fields)*2) for key, value := range fields { result = append(result, key, value) } return result } // SlogLoggerAdapter wraps [slog.Logger]. type SlogLoggerAdapter struct { slog *slog.Logger watermillLevelToSlog map[slog.Level]slog.Level } // Error logs a message to [slog.LevelError]. func (s *SlogLoggerAdapter) Error(msg string, err error, fields LogFields) { s.log(slog.LevelError, msg, append(slogAttrsFromFields(fields), "error", err)...) } // Info logs a message to [slog.LevelInfo]. func (s *SlogLoggerAdapter) Info(msg string, fields LogFields) { s.log(slog.LevelInfo, msg, slogAttrsFromFields(fields)...) } // Debug logs a message to [slog.LevelDebug]. func (s *SlogLoggerAdapter) Debug(msg string, fields LogFields) { s.log(slog.LevelDebug, msg, slogAttrsFromFields(fields)...) } // Trace logs a message to [LevelTrace]. func (s *SlogLoggerAdapter) Trace(msg string, fields LogFields) { s.log( LevelTrace, msg, slogAttrsFromFields(fields)..., ) } func (s *SlogLoggerAdapter) log(level slog.Level, msg string, args ...any) { mappedLevel, ok := s.watermillLevelToSlog[level] if ok { level = mappedLevel } s.slog.Log( // Void context, following the slog example // as it treats context slightly differently from // normal usage, minding contextual // values, but ignoring contextual deadline. // See the [slog] package documentation // for more details. context.Background(), level, msg, args..., ) } // With return a [SlogLoggerAdapter] with a set of fields injected into all consequent logging messages. func (s *SlogLoggerAdapter) With(fields LogFields) LoggerAdapter { return &SlogLoggerAdapter{slog: s.slog.With(slogAttrsFromFields(fields)...), watermillLevelToSlog: s.watermillLevelToSlog} } // NewSlogLogger creates an adapter to the standard library's structured logging package. A `nil` logger is substituted for the result of [slog.Default]. func NewSlogLogger(logger *slog.Logger) LoggerAdapter { if logger == nil { logger = slog.Default() } return &SlogLoggerAdapter{ slog: logger, } } // NewSlogLoggerWithLevelMapping creates an adapter to the standard library's structured logging package. A `nil` logger is substituted for the result of [slog.Default]. // The `watermillLevelToSlog` parameter is a map that maps Watermill's log levels to the levels of the structured logger. // It's helpful, when want to for example log Watermill's info logs as debug in slog. func NewSlogLoggerWithLevelMapping(logger *slog.Logger, watermillLevelToSlog map[slog.Level]slog.Level) LoggerAdapter { if logger == nil { logger = slog.Default() } return &SlogLoggerAdapter{ slog: logger, watermillLevelToSlog: watermillLevelToSlog, } } ================================================ FILE: slog_test.go ================================================ package watermill import ( "bytes" "errors" "strings" "testing" "log/slog" "github.com/stretchr/testify/assert" ) func TestSlogLoggerAdapter(t *testing.T) { b := &bytes.Buffer{} logger := NewSlogLogger(slog.New(slog.NewTextHandler( b, // output &slog.HandlerOptions{ Level: LevelTrace, ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { if a.Key == "time" && len(groups) == 0 { // omit time stamp to make the test idempotent a.Value = slog.StringValue("[omit]") } return a }, }, ))) logger = logger.With(LogFields{ "common1": "commonvalue", }) logger.Trace("test trace", LogFields{ "field1": "value1", }) logger.Error("test error", errors.New("error message"), LogFields{ "field2": "value2", }) logger.Info("test info", LogFields{ "field3": "value3", }) assert.Equal(t, strings.TrimSpace(` time=[omit] level=DEBUG-4 msg="test trace" common1=commonvalue field1=value1 time=[omit] level=ERROR msg="test error" common1=commonvalue field2=value2 error="error message" time=[omit] level=INFO msg="test info" common1=commonvalue field3=value3 `), strings.TrimSpace(b.String()), "Logging output does not match saved template.", ) } func TestSlogLoggerAdapter_level_mapping(t *testing.T) { b := &bytes.Buffer{} logger := NewSlogLoggerWithLevelMapping( slog.New(slog.NewTextHandler( b, // output &slog.HandlerOptions{ Level: LevelTrace, ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { if a.Key == "time" && len(groups) == 0 { // omit time stamp to make the test idempotent a.Value = slog.StringValue("[omit]") } return a }, }, )), map[slog.Level]slog.Level{ slog.LevelInfo: slog.LevelDebug, }, ) logger = logger.With(LogFields{ "common1": "commonvalue", }) logger.Trace("test trace", LogFields{ "field1": "value1", }) logger.Error("test error", errors.New("error message"), LogFields{ "field2": "value2", }) logger.Info("test info mapped to debug", LogFields{ "field3": "value3", }) assert.Equal(t, strings.TrimSpace(` time=[omit] level=DEBUG-4 msg="test trace" common1=commonvalue field1=value1 time=[omit] level=ERROR msg="test error" common1=commonvalue field2=value2 error="error message" time=[omit] level=DEBUG msg="test info mapped to debug" common1=commonvalue field3=value3 `), strings.TrimSpace(b.String()), "Logging output does not match saved template.", ) } ================================================ FILE: tools/mill/.default-config.yml ================================================ # These are the current default settings for the Watermill CLI tool. # Use them as a template for your own config log: false trace: false debug: false amqp: uri: "" durable: true consume: exchange: "" queue: "" produce: exchange: "" exchangetype: fanout routingkey: "" googlecloud: projectid: "" topic: "" consume: subscription: "" produce: subscription: add: ackdeadline: 10s labels: "" retainacked: false retentionduration: 168h0m0s rm: # no flags for rm yet kafka: brokers: [] topic: "" consume: consumergroup: "" frombeginning: false produce: # no flags for produce yet ================================================ FILE: tools/mill/Makefile ================================================ mill: go build -o mill main.go ================================================ FILE: tools/mill/README.md ================================================ # mill - a simple CLI tool for Watermill `mill` is a CLI tool for the [Watermill](https://watermill.io) library. It has two basic functionalities, namely producing and consuming messages on the following Pub/Subs: 1. Kafka 2. Google Cloud Pub/Sub 3. RabbitMQ See Watermill's [Supported Pub/Subs](https://watermill.io/pubsubs) for more details on how this works. ## Installation To install this tool, just execute: ```bash go install github.com/ThreeDotsLabs/watermill/tools/mill@latest ``` This will install a `mill` binary in your system. ## Consume mode In consume mode, the tool subscribes to a topic/queue/subscription (nomenclature depending on the particular Pub/Sub provider) and prints the messages' payload to the standard output. Other outputs, for example ones that add a timestamp or preserve UUIDs or metadata, are easily attainable by modification of the marshaling function of the `io.Publisher` of the `consumeCmd`. ## Produce mode In produce mode, subsequent lines of data from the standard input are transformed into messages outgoing to the requested provider's topic/exchange. The message's payload is set to the line from stdin, the UUID is auto-generated and the metadata is empty. Similarly, the contents of the message could be parsed differently from stdin, by modification of the unmarshaling function of the `io.Subscriber` of the `produceCmd`. ## Usage The basic syntax of the tool is: ```bash mill ``` with the appropriate flags regulating the specific behaviour of each command. `command` is usually one of `produce` or `consume`, but some providers may handle additional commands that are specific for them. The flags are context-specific, so the best way to find out about them is to use the `-h` flag and study which flags are available/required for the specific context and act accordingly. ### Advanced usage A neat feature of producers and consumers is that you can use the power of stdin/stdout piping for stuff like: ```bash myservice | tee myservice.log | mill kafka produce -t myservice-logs --brokers kafka-host:8082 ``` And on another host: ```bash mill kafka consume -t myservice-logs --brokers kafka-host:8082 >> myservice.log ``` In the above example, the host on which `myservice` runs has its own copy of `myservice.log` and any host that consumes from the kafka topic will replicate the log entries in their local copy. ## Additional functionalities ### Google Cloud Pub/Sub #### Adding/Removing subscriptions You can use `mill` to create/remove subscriptions for Google Cloud Pub/Sub: ```bash mill googlecloud subscription add -t mill googlecloud subscription rm ``` Additional flags are available for `subscription add` to regulate the newly created subscription's settings. #### Listing subscriptions You can use `mill` to list existing subscriptions: ```bash mill googlecloud subscription ls [-t topic] ``` The topic is optional. If omitted, all topics will be listed with their subscriptions. ================================================ FILE: tools/mill/cmd/amqp.go ================================================ package cmd import ( "github.com/spf13/cobra" "github.com/spf13/viper" "github.com/ThreeDotsLabs/watermill-amqp/v3/pkg/amqp" ) var amqpCmd = &cobra.Command{ Use: "amqp", Short: "Commands for the AMQP Pub/Sub provider", Long: `Consume or produce messages from the AMQP Pub/Sub provider. For the configuration of consuming/producing of the messages, check the help of the relevant command.`, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { err := rootCmd.PersistentPreRunE(cmd, args) if err != nil { return err } logger.Debug("Using AMQP Pub/Sub", nil) if cmd.Use == "consume" { consumer, err = amqp.NewSubscriber(amqpConsumerConfig(), logger) if err != nil { return err } } if cmd.Use == "produce" { producer, err = amqp.NewPublisher(amqpProducerConfig(), logger) if err != nil { return err } } return nil }, } func amqpConsumerConfig() amqp.Config { uri := viper.GetString("amqp.uri") queue := viper.GetString("amqp.consume.queue") exchangeName := viper.GetString("amqp.consume.exchange") exchangeType := viper.GetString("amqp.produce.exchangeType") durable := viper.GetBool("amqp.durable") return amqp.Config{ Connection: amqp.ConnectionConfig{ AmqpURI: uri, }, Marshaler: amqp.DefaultMarshaler{}, Queue: amqp.QueueConfig{ GenerateName: func(topic string) string { return queue }, Durable: durable, }, Consume: amqp.ConsumeConfig{ Qos: amqp.QosConfig{ PrefetchCount: 1, }, }, Exchange: amqp.ExchangeConfig{ GenerateName: func(topic string) string { return exchangeName }, Type: exchangeType, Durable: durable, }, } } func amqpProducerConfig() amqp.Config { uri := viper.GetString("amqp.uri") exchangeName := viper.GetString("amqp.produce.exchange") exchangeType := viper.GetString("amqp.produce.exchangeType") routingKey := viper.GetString("amqp.produce.routingKey") durable := viper.GetBool("amqp.durable") return amqp.Config{ Connection: amqp.ConnectionConfig{ AmqpURI: uri, }, Marshaler: amqp.DefaultMarshaler{}, Exchange: amqp.ExchangeConfig{ GenerateName: func(topic string) string { return exchangeName }, Type: exchangeType, Durable: durable, }, Publish: amqp.PublishConfig{ GenerateRoutingKey: func(topic string) string { return routingKey }, }, } } func init() { rootCmd.AddCommand(amqpCmd) configureAmqpCmd() consumeCmd := addConsumeCmd(amqpCmd, "amqp.consume.queue") configureConsumeCmd(consumeCmd) produceCmd := addProduceCmd(amqpCmd, "amqp.produce.exchange") configureProduceCmd(produceCmd) } func configureAmqpCmd() { amqpCmd.PersistentFlags().StringP( "uri", "u", "", "The URI to the AMQP instance (required)", ) ensure(amqpCmd.MarkPersistentFlagRequired("uri")) ensure(viper.BindPFlag("amqp.uri", amqpCmd.PersistentFlags().Lookup("uri"))) amqpCmd.PersistentFlags().Bool( "durable", true, "If true, the queues and exchanges created automatically (if any) will be durable", ) ensure(viper.BindPFlag("amqp.durable", amqpCmd.PersistentFlags().Lookup("durable"))) amqpCmd.PersistentFlags().String( "exchange-type", "fanout", "If exchange needs to be created, it will be created with this type. The common types are 'direct', 'fanout', 'topic' and 'headers'.", ) ensure(viper.BindPFlag("amqp.produce.exchangeType", amqpCmd.PersistentFlags().Lookup("exchange-type"))) } func configureConsumeCmd(consumeCmd *cobra.Command) { consumeCmd.PersistentFlags().StringP( "queue", "q", "", "The name of the AMQP queue to consume messages from (required)", ) ensure(consumeCmd.MarkPersistentFlagRequired("queue")) ensure(viper.BindPFlag("amqp.consume.queue", consumeCmd.PersistentFlags().Lookup("queue"))) consumeCmd.PersistentFlags().StringP( "exchange", "x", "", "If non-empty, an exchange with this name is created if it didn't exist. Then, the queue is bound to this exchange.", ) ensure(viper.BindPFlag("amqp.consume.exchange", consumeCmd.PersistentFlags().Lookup("exchange"))) } func configureProduceCmd(produceCmd *cobra.Command) { produceCmd.PersistentFlags().StringP( "exchange", "x", "", "The name of the AMQP exchange to produce messages to (required)", ) ensure(produceCmd.MarkPersistentFlagRequired("exchange")) ensure(viper.BindPFlag("amqp.produce.exchange", produceCmd.PersistentFlags().Lookup("exchange"))) produceCmd.PersistentFlags().StringP( "routing-key", "r", "", "The routing key to use when publishing the message.", ) ensure(produceCmd.MarkPersistentFlagRequired("routing-key")) ensure(viper.BindPFlag("amqp.produce.routingKey", produceCmd.PersistentFlags().Lookup("routing-key"))) } ================================================ FILE: tools/mill/cmd/consume.go ================================================ package cmd import ( "context" "os" "time" "github.com/pkg/errors" "github.com/spf13/cobra" "github.com/spf13/viper" "github.com/ThreeDotsLabs/watermill-io/pkg/io" "github.com/ThreeDotsLabs/watermill/message" "github.com/ThreeDotsLabs/watermill/message/router/plugin" ) // consumer is initialized by the parent command to the pub/sub provider of choice. var consumer message.Subscriber func addConsumeCmd(parent *cobra.Command, topicKey string) *cobra.Command { cmd := &cobra.Command{ Use: "consume", Short: "Consume messages from a pub/sub and print them to stdout", Long: `Consume messages from the pub/sub of your choice and print them on the standard output. For the configuration of particular pub/sub providers, see the help for the provider commands.`, RunE: func(cmd *cobra.Command, args []string) error { topic := viper.GetString(topicKey) router, err := message.NewRouter( message.RouterConfig{ CloseTimeout: 5 * time.Second, }, logger, ) if err != nil { return errors.Wrap(err, "could not create router") } router.AddPlugin(plugin.SignalsHandler) out, err := io.NewPublisher( os.Stdout, io.PublisherConfig{ MarshalFunc: io.PayloadMarshalFunc, }, logger, ) if err != nil { return errors.Wrap(err, "could not create console producer") } router.AddHandler( "dump_to_stdout", topic, consumer, "", out, func(msg *message.Message) ([]*message.Message, error) { // just forward the message to stdout return message.Messages{msg}, nil }, ) return router.Run(context.Background()) }, } parent.AddCommand(cmd) return cmd } ================================================ FILE: tools/mill/cmd/googlecloud.go ================================================ package cmd import ( "context" "fmt" "os" "strings" "time" "cloud.google.com/go/pubsub" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill-googlecloud/pkg/googlecloud" "github.com/ThreeDotsLabs/watermill/tools/mill/cmd/internal" "github.com/pkg/errors" "github.com/spf13/cobra" "github.com/spf13/viper" "google.golang.org/api/iterator" "gopkg.in/yaml.v2" ) var googleCloudTempSubscriptionID string var googleCloudCmd = &cobra.Command{ Use: "googlecloud", Short: "Commands for the Google Cloud Pub/Sub provider", Long: `Consume or produce messages from the Google Cloud Pub/Sub provider. Manage subscriptions. For the configuration of consuming/producing of the messages, check the help of the relevant command.`, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { err := rootCmd.PersistentPreRunE(cmd, args) if err != nil { return err } logger.Debug("Using Google Cloud Pub/Sub", nil) if cmd.Use == "consume" { subName := viper.GetString("googlecloud.consume.subscription") if subName == "" { subName, err = generateTempSubscription() if err != nil { return err } } consumer, err = googlecloud.NewSubscriber( googlecloud.SubscriberConfig{ GenerateSubscriptionName: func(topic string) string { return subName }, ProjectID: projectID(), }, logger, ) if err != nil { return err } } if cmd.Use == "produce" { producer, err = googlecloud.NewPublisher( googlecloud.PublisherConfig{ ProjectID: projectID(), }, logger, ) if err != nil { return err } } return nil }, PersistentPostRunE: func(cmd *cobra.Command, args []string) error { logger.Debug("Google Cloud Pub/Sub cleanup", nil) if googleCloudTempSubscriptionID != "" { if err := removeTempSubscription(); err != nil { return err } } return nil }, } var googleCloudSubscriptionCmd = &cobra.Command{ Use: "subscription", Short: "Manage Google Cloud Pub/Sub subscriptions", Long: `Add or remove subscriptions for the Google Cloud Pub/Sub provider.`, } var googleCloudSubscriptionAddCmd = &cobra.Command{ Use: "add ", Short: "Add a new subscription in Google Cloud Pub/Sub", Args: cobra.ExactArgs(1), ValidArgs: []string{"subscriptionID"}, RunE: func(cmd *cobra.Command, args []string) (err error) { subID := args[0] topic := viper.GetString("googlecloud.subscription.add.topic") ackDeadline := viper.GetDuration("googlecloud.subscription.add.ackDeadline") retainAcked := viper.GetBool("googlecloud.subscription.add.retainAcked") retentionDuration := viper.GetDuration("googlecloud.subscription.add.retentionDuration") // StringToString doesn't work with viper, so let's parse this manually labels := strings.Split(viper.GetString("googlecloud.subscription.add.labels"), ",") labelsMap := make(map[string]string, len(labels)) for _, l := range labels { fields := strings.Split(l, "=") if len(fields) < 2 { continue } labelsMap[fields[0]] = fields[1] } logger := logger.With(watermill.LogFields{ "subscription_id": subID, "topic": topic, "ackDeadline": ackDeadline, "retainAcked": retainAcked, "retentionDuration": retentionDuration, "labels": labelsMap, }) logger.Info("Creating new subscription", nil) defer func() { if err == nil { logger.Info("Subscription created", nil) } }() return addSubscription( subID, topic, ackDeadline, retainAcked, retentionDuration, labelsMap, ) }, } var googleCloudSubscriptionRmCmd = &cobra.Command{ Use: "rm ", Short: "Remove a subscription in Google Cloud Pub/Sub", Args: cobra.ExactArgs(1), ValidArgs: []string{"subscriptionID"}, RunE: func(cmd *cobra.Command, args []string) (err error) { subID := args[0] logger := logger.With(watermill.LogFields{ "subscription_id": subID, }) logger.Info("Removing a subscription", nil) defer func() { if err == nil { logger.Info("Subscription removed", nil) } }() return removeSubscription(subID) }, } var googleCloudSubscriptionLsCmd = &cobra.Command{ Use: "ls", Short: "List subscriptions in Google Cloud Pub/Sub. Topic may be provided optionally to filter subscriptions by topic.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) (err error) { topic := viper.GetString("googlecloud.subscription.ls.topic") verbose := viper.GetBool("googlecloud.subscription.ls.verbose") logger := logger if topic != "" { logger = logger.With(watermill.LogFields{ "topic": topic, }) } logger.Info("Listing all subscriptions", nil) return listSubscriptions(topic, logger, verbose) }, } func generateTempSubscription() (id string, err error) { defer func() { if err == nil { logger.Debug("Temp subscription created", watermill.LogFields{ "subscription_name": id, }) googleCloudTempSubscriptionID = id } }() randomID := "watermill_console_consumer_" + watermill.NewShortUUID() return randomID, addSubscription( randomID, viper.GetString("googlecloud.topic"), 10*time.Second, false, 10*time.Minute, nil, ) } func addSubscription( id string, topic string, ackDeadline time.Duration, retainAckedMessages bool, retentionDuration time.Duration, labels map[string]string, ) error { ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) defer cancel() client, err := pubsub.NewClient(ctx, projectID()) if err != nil { return errors.Wrap(err, "could not create pubsub client") } t := client.Topic(topic) exists, err := t.Exists(ctx) if err != nil { return errors.Wrap(err, "could not check if topic exists") } if !exists { t, err = client.CreateTopic(ctx, t.ID()) if err != nil { return errors.Wrap(err, "could not create topic") } } _, err = client.CreateSubscription(ctx, id, pubsub.SubscriptionConfig{ Topic: t, AckDeadline: ackDeadline, RetainAckedMessages: retainAckedMessages, RetentionDuration: retentionDuration, Labels: labels, }) if err != nil { return errors.Wrap(err, "could not create subscription") } return nil } func removeTempSubscription() (err error) { defer func() { if err == nil { logger.Debug("Temporary subscription removed", watermill.LogFields{ "subscription_name": googleCloudTempSubscriptionID, }) } }() return removeSubscription(googleCloudTempSubscriptionID) } func removeSubscription(id string) error { ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) defer cancel() client, err := pubsub.NewClient(ctx, projectID()) if err != nil { return errors.Wrap(err, "could not create pubsub client") } sub := client.Subscription(id) exists, err := sub.Exists(ctx) if err != nil { return errors.Wrap(err, "could not check if sub exists") } if !exists { return nil } return sub.Delete(ctx) } func listSubscriptions(topic string, adapter watermill.LoggerAdapter, verbose bool) error { ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) defer cancel() client, err := pubsub.NewClient(ctx, projectID()) if err != nil { return errors.Wrap(err, "could not create pubsub client") } if topic != "" { topic := client.Topic(topic) return listSubscriptionsForTopic(ctx, client, topic, logger, verbose) } it := client.Topics(ctx) noTopics := true for { topic, err := it.Next() if err == iterator.Done { if noTopics { logger.Info("No topics in project", nil) } return nil } if err != nil { return errors.Wrap(err, "could not retrieve next subscription") } noTopics = false err = listSubscriptionsForTopic(ctx, client, topic, logger, verbose) if err != nil { return errors.Wrap(err, "error listing subscriptions for topic") } } return nil } func listSubscriptionsForTopic(ctx context.Context, client *pubsub.Client, topic *pubsub.Topic, logger watermill.LoggerAdapter, verbose bool) error { noSubs := true exists, err := topic.Exists(ctx) if err != nil { return errors.Wrap(err, "could not check if topic exists") } if !exists { logger.Info("Topic does not exist", watermill.LogFields{"topic": topic.String()}) return nil } it := topic.Subscriptions(ctx) for { sub, err := it.Next() if err == iterator.Done { if noSubs { logger.Info("No subscriptions for the topic", watermill.LogFields{"topic": topic.String()}) } return nil } if err != nil { return errors.Wrap(err, "could not retrieve next subscription") } if noSubs { noSubs = false fmt.Printf("Topic %s:\n", topic.String()) } name := sub.String() config, err := sub.Config(ctx) if err != nil { return errors.Wrapf(err, "could not retrieve subscription config for subscription '%s'", name) } err = printSubscriptionInfo(name, config) if err != nil { return errors.Wrapf(err, "error printing subscription '%s'", name) } } } func printSubscriptionInfo(name string, config pubsub.SubscriptionConfig) error { b, err := yaml.Marshal(subscriptionConfig{ Name: name, PushConfig: subscriptionConfigPushConfig{ Endpoint: config.PushConfig.Endpoint, Attributes: config.PushConfig.Attributes, }, AckDeadline: config.AckDeadline, RetainAckedMessages: config.RetainAckedMessages, RetentionDuration: config.RetentionDuration, Labels: config.Labels, }) if err != nil { return err } fmt.Printf(internal.Indent(string(b), " ")) return nil } // subscriptionConfig provides a marshallable form to pubsub.SubscriptionConfig type subscriptionConfig struct { Name string PushConfig subscriptionConfigPushConfig `yaml:"push_config"` AckDeadline time.Duration `yaml:"ack_deadline"` RetainAckedMessages bool `yaml:"retain_acked_messages"` RetentionDuration time.Duration `yaml:"retention_duration"` Labels map[string]string `yaml:"labels"` } type subscriptionConfigPushConfig struct { Endpoint string Attributes map[string]string } func projectID() string { projectID := viper.GetString("googlecloud.projectID") if projectID == "" { projectID = os.Getenv("GOOGLE_CLOUD_PROJECT") } return projectID } func init() { rootCmd.AddCommand(googleCloudCmd) googleCloudCmd.PersistentFlags().StringP( "topic", "t", "", "The topic to produce messages to (produce), consume message from (consume), list from (ls) or the topic for the newly created subscription (subscription.add)", ) ensure(viper.BindPFlag("googlecloud.topic", googleCloudCmd.PersistentFlags().Lookup("topic"))) ensure(googleCloudCmd.MarkPersistentFlagRequired("topic")) consumeCmd := addConsumeCmd(googleCloudCmd, "googlecloud.topic") addProduceCmd(googleCloudCmd, "googlecloud.topic") // Cobra supports Persistent Flags which will work for this command // and all subcommands, e.g.: googleCloudCmd.PersistentFlags().String("project", "", "The projectID for Google Cloud Pub/Sub. Defaults to the GOOGLE_CLOUD_PROJECT environment variable.") ensure(viper.BindPFlag("googlecloud.projectID", googleCloudCmd.PersistentFlags().Lookup("project"))) consumeCmd.PersistentFlags().StringP( "subscription", "s", "", "The subscription for Google Cloud Pub/Sub. If left empty, a temporary subscription is created and removed when the consumer is closed", ) ensure(viper.BindPFlag("googlecloud.consume.subscription", consumeCmd.PersistentFlags().Lookup("subscription"))) googleCloudCmd.AddCommand(googleCloudSubscriptionCmd) googleCloudSubscriptionCmd.AddCommand(googleCloudSubscriptionAddCmd) googleCloudSubscriptionAddCmd.Flags().StringP("topic", "t", "", "The topic for the new subscription (required)") ensure(googleCloudSubscriptionAddCmd.MarkFlagRequired("topic")) ensure(viper.BindPFlag("googlecloud.subscription.add.topic", googleCloudSubscriptionAddCmd.Flags().Lookup("topic"))) googleCloudSubscriptionAddCmd.Flags().DurationP( "ack-deadline", "a", 10*time.Second, "How long Pub/Sub waits for the subscriber to acknowledge receipt before resending the message. Deadline time is from 10 seconds to 600 seconds", ) ensure(viper.BindPFlag("googlecloud.subscription.add.ackDeadline", googleCloudSubscriptionAddCmd.Flags().Lookup("ack-deadline"))) googleCloudSubscriptionAddCmd.Flags().Bool( "retain-acked", false, "Acknowledged messages will be kept 7 days from publication unless set otherwise in \"message retention duration\".", ) ensure(viper.BindPFlag("googlecloud.subscription.add.retainAcked", googleCloudSubscriptionAddCmd.Flags().Lookup("retain-acked"))) googleCloudSubscriptionAddCmd.Flags().Duration( "retention-duration", 7*24*time.Hour, "How long the retained messages will be kept. The allowed duration is from 10 minutes to 7 days, which is the default.", ) ensure(viper.BindPFlag("googlecloud.subscription.add.retentionDuration", googleCloudSubscriptionAddCmd.Flags().Lookup("retention-duration"))) // StringToString doesn't work correctly with viper googleCloudSubscriptionAddCmd.Flags().String( "labels", "", "The set of labels for the subscription. Format: '--labels key1=value1,key2=value2,...'", ) ensure(viper.BindPFlag("googlecloud.subscription.add.labels", googleCloudSubscriptionAddCmd.Flags().Lookup("labels"))) googleCloudSubscriptionCmd.AddCommand(googleCloudSubscriptionRmCmd) googleCloudSubscriptionCmd.AddCommand(googleCloudSubscriptionLsCmd) googleCloudSubscriptionLsCmd.Flags().StringP( "topic", "t", "", "The topic for the new subscription (optional, will list subscriptions for all topics if omitted)", ) ensure(viper.BindPFlag("googlecloud.subscription.ls.topic", googleCloudSubscriptionLsCmd.Flags().Lookup("topic"))) googleCloudSubscriptionLsCmd.Flags().BoolP( "verbose", "v", false, "will print more information, including the subscription config", ) ensure(viper.BindPFlag("googlecloud.subscription.ls.verbose", googleCloudSubscriptionLsCmd.Flags().Lookup("verbose"))) } ================================================ FILE: tools/mill/cmd/internal/indent.go ================================================ package internal import "strings" // Indent indents all lines in the given string with a given prefix. func Indent(s, prefix string) string { endsWithNewline := strings.HasSuffix(s, "\n") split := strings.Split(s, "\n") for i, ss := range split { split[i] = prefix + ss } joined := strings.Join(split, "\n") if endsWithNewline { joined += "\n" } return joined } ================================================ FILE: tools/mill/cmd/kafka.go ================================================ package cmd import ( "github.com/IBM/sarama" "github.com/spf13/cobra" "github.com/spf13/viper" "github.com/ThreeDotsLabs/watermill-kafka/v3/pkg/kafka" ) var kafkaCmd = &cobra.Command{ Use: "kafka", Short: "Commands for the kafka Pub/Sub provider", Long: `Consume or produce messages from the kafka Pub/Sub provider. For the configuration of consuming/producing of the messages, check the help of the relevant command.`, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { err := rootCmd.PersistentPreRunE(cmd, args) if err != nil { return err } logger.Debug("Using kafka pub/sub", nil) brokers := viper.GetStringSlice("kafka.brokers") if cmd.Use == "consume" { saramaSubscriberConfig := kafka.DefaultSaramaSubscriberConfig() if viper.GetBool("kafka.consume.fromBeginning") { logger.Trace("Configured sarama to consume messages from beginning", nil) // equivalent of auto.offset.reset: earliest saramaSubscriberConfig.Consumer.Offsets.Initial = sarama.OffsetOldest } consumer, err = kafka.NewSubscriber( kafka.SubscriberConfig{ Brokers: brokers, Unmarshaler: kafka.DefaultMarshaler{}, OverwriteSaramaConfig: saramaSubscriberConfig, ConsumerGroup: viper.GetString("kafka.consume.consumerGroup"), }, logger, ) if err != nil { return err } } if cmd.Use == "produce" { producer, err = kafka.NewPublisher( kafka.PublisherConfig{ Brokers: brokers, Marshaler: kafka.DefaultMarshaler{}, }, logger, ) if err != nil { return err } } return nil }, } func init() { rootCmd.AddCommand(kafkaCmd) kafkaCmd.PersistentFlags().StringP( "topic", "t", "", "The topic to produce messages to (produce) or consume message from (consume)", ) ensure(kafkaCmd.MarkPersistentFlagRequired("topic")) ensure(viper.BindPFlag("kafka.topic", kafkaCmd.PersistentFlags().Lookup("topic"))) consumeCmd := addConsumeCmd(kafkaCmd, "kafka.topic") _ = addProduceCmd(kafkaCmd, "kafka.topic") // Cobra supports Persistent Flags which will work for this command // and all subcommands, e.g.: kafkaCmd.PersistentFlags().StringSliceP("brokers", "b", nil, "A list of kafka brokers") ensure(kafkaCmd.MarkPersistentFlagRequired("brokers")) ensure(viper.BindPFlag("kafka.brokers", kafkaCmd.PersistentFlags().Lookup("brokers"))) consumeCmd.PersistentFlags().Bool("from-beginning", false, "Equivalent to auto.offset.reset: earliest") ensure(viper.BindPFlag("kafka.consume.fromBeginning", consumeCmd.PersistentFlags().Lookup("from-beginning"))) consumeCmd.PersistentFlags().StringP("consumer-group", "c", "", "The kafka consumer group. Defaults to empty.") ensure(viper.BindPFlag("kafka.consume.consumerGroup", consumeCmd.PersistentFlags().Lookup("consumer-group"))) } ================================================ FILE: tools/mill/cmd/produce.go ================================================ package cmd import ( "context" "os" "time" "github.com/pkg/errors" "github.com/spf13/cobra" "github.com/spf13/viper" "github.com/ThreeDotsLabs/watermill-io/pkg/io" "github.com/ThreeDotsLabs/watermill/message" "github.com/ThreeDotsLabs/watermill/message/router/plugin" ) // producer is initialized by parent command to the pub/sub provider of choice. var producer message.Publisher func addProduceCmd(parent *cobra.Command, topicKey string) *cobra.Command { cmd := &cobra.Command{ Use: "produce", Short: "Produce messages to a pub/sub from the stdin", Long: `Produce messages to the pub/sub of your choice from the standard input. For the configuration of particular pub/sub providers, see the help for the provider commands.`, PreRunE: func(cmd *cobra.Command, args []string) error { return nil }, RunE: func(cmd *cobra.Command, args []string) error { topic := viper.GetString(topicKey) router, err := message.NewRouter( message.RouterConfig{ CloseTimeout: 10 * time.Second, }, logger, ) if err != nil { return errors.Wrap(err, "could not create router") } router.AddPlugin(plugin.SignalsHandler) in, err := io.NewSubscriber( os.Stdin, io.SubscriberConfig{ PollInterval: time.Second, UnmarshalFunc: io.PayloadUnmarshalFunc, }, logger, ) if err != nil { return errors.Wrap(err, "could not create console subscriber") } router.AddHandler( "produce_from_stdin", "stdin", in, topic, producer, func(msg *message.Message) ([]*message.Message, error) { if string(msg.Payload) == "\n" { logger.Trace("Message is empty, don't publish", nil) return nil, nil } // just pass the message along return message.Messages{msg}, nil }, ) return router.Run(context.Background()) }, } parent.AddCommand(cmd) return cmd } ================================================ FILE: tools/mill/cmd/root.go ================================================ package cmd import ( "fmt" "os" homedir "github.com/mitchellh/go-homedir" "github.com/pkg/errors" "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/spf13/viper" yaml "gopkg.in/yaml.v2" "github.com/ThreeDotsLabs/watermill" ) var cfgFile string var logger watermill.LoggerAdapter var rootCmd = &cobra.Command{ Use: "mill", Short: "A CLI for Watermill.", Long: `A CLI for Watermill. Use console-based producer or consumer for various pub/sub providers.`, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { log := viper.GetBool("log") debug := viper.GetBool("debug") trace := viper.GetBool("trace") if log || debug || trace { logger = watermill.NewStdLogger(debug, trace) } else { logger = watermill.NopLogger{} } if err := checkRequiredFlags(cmd.Flags()); err != nil { return err } writeConfig := viper.GetString("writeConfig") if writeConfig != "" { settings := viper.AllSettings() delete(settings, "writeconfig") b, err := yaml.Marshal(settings) if err != nil { return errors.Wrap(err, "could not marshal config to yaml") } f, err := os.Create(writeConfig) if err != nil { return errors.Wrap(err, "could not create file for write") } _, err = fmt.Fprintf(f, "%s", b) if err != nil { return errors.Wrap(err, "could not write to file") } } return nil }, } // Execute adds all child commands to the root command and sets flags appropriately. // This is called by main.main(). It only needs to happen once to the rootCmd. func Execute() { if err := rootCmd.Execute(); err != nil { fmt.Println(err) os.Exit(1) } } func init() { cobra.OnInitialize(initConfig) rootCmd.PersistentFlags().SortFlags = false rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.mill.yaml)") outputFlags := pflag.NewFlagSet("output", pflag.ExitOnError) outputFlags.BoolP("log", "l", false, "If true, the logger output is sent to stderr. No logger output otherwise.") ensure(viper.BindPFlag("log", outputFlags.Lookup("log"))) outputFlags.BoolP("debug", "d", false, "If true, debug output is enabled from the logger") ensure(viper.BindPFlag("debug", outputFlags.Lookup("debug"))) outputFlags.Bool("trace", false, "If true, trace output is enabled from the logger") ensure(viper.BindPFlag("trace", outputFlags.Lookup("trace"))) outputFlags.String("write-config", "", "Write the config of the current command as yaml to the specified path") ensure(viper.BindPFlag("writeConfig", outputFlags.Lookup("write-config"))) rootCmd.PersistentFlags().AddFlagSet(outputFlags) } // initConfig reads in config file and ENV variables if set. func initConfig() { if cfgFile != "" { // Use config file from the flag. viper.SetConfigFile(cfgFile) } else { // Find home directory. home, err := homedir.Dir() if err != nil { fmt.Println(err) os.Exit(1) } viper.AddConfigPath(home) viper.SetConfigName(".mill") } // read in environment variables that match viper.AutomaticEnv() // If a config file is found, read it in. if err := viper.ReadInConfig(); err == nil { fmt.Println("Using config file:", viper.ConfigFileUsed()) } } func ensure(err error) { if err != nil { panic(err) } } func checkRequiredFlags(flags *pflag.FlagSet) error { requiredError := false flagName := "" flags.VisitAll(func(flag *pflag.Flag) { requiredAnnotation := flag.Annotations[cobra.BashCompOneRequiredFlag] if len(requiredAnnotation) == 0 { return } flagRequired := requiredAnnotation[0] == "true" if flagRequired && !flag.Changed { requiredError = true flagName = flag.Name } }) if requiredError { return errors.New("Required flag `" + flagName + "` has not been set") } return nil } ================================================ FILE: tools/mill/go.mod ================================================ module github.com/ThreeDotsLabs/watermill/tools/mill go 1.25 require ( cloud.google.com/go/pubsub v1.50.0 github.com/IBM/sarama v1.46.0 github.com/ThreeDotsLabs/watermill v1.5.1 github.com/ThreeDotsLabs/watermill-amqp/v3 v3.0.2 github.com/ThreeDotsLabs/watermill-googlecloud v1.2.6 github.com/ThreeDotsLabs/watermill-io v1.1.2 github.com/ThreeDotsLabs/watermill-kafka/v3 v3.1.0 github.com/mitchellh/go-homedir v1.1.0 github.com/pkg/errors v0.9.1 github.com/spf13/cobra v1.10.1 github.com/spf13/pflag v1.0.9 github.com/spf13/viper v1.20.1 google.golang.org/api v0.248.0 gopkg.in/yaml.v2 v2.4.0 ) require ( cloud.google.com/go v0.121.6 // indirect cloud.google.com/go/auth v0.16.5 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.8.0 // indirect cloud.google.com/go/iam v1.5.2 // indirect cloud.google.com/go/pubsub/v2 v2.0.0 // indirect github.com/cenkalti/backoff/v3 v3.2.2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dnwe/otelsarama v0.0.0-20240308230250-9388d9d40bc0 // indirect github.com/eapache/go-resiliency v1.7.0 // indirect github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 // indirect github.com/eapache/queue v1.1.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/golang/snappy v1.0.0 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jcmturner/aescts/v2 v2.0.0 // indirect github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect github.com/jcmturner/gofork v1.7.6 // indirect github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect github.com/jcmturner/rpc/v2 v2.0.3 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/lithammer/shortuuid/v3 v3.0.7 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/rabbitmq/amqp091-go v1.10.0 // indirect github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect github.com/sagikazarmark/locafero v0.10.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.14.0 // indirect github.com/spf13/cast v1.9.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect go.opentelemetry.io/otel v1.38.0 // indirect go.opentelemetry.io/otel/metric v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect golang.org/x/crypto v0.41.0 // indirect golang.org/x/net v0.43.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sync v0.16.0 // indirect golang.org/x/sys v0.35.0 // indirect golang.org/x/text v0.28.0 // indirect golang.org/x/time v0.12.0 // indirect google.golang.org/genproto v0.0.0-20250826171959-ef028d996bc1 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250826171959-ef028d996bc1 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1 // indirect google.golang.org/grpc v1.75.0 // indirect google.golang.org/protobuf v1.36.8 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) ================================================ FILE: tools/mill/go.sum ================================================ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.121.6 h1:waZiuajrI28iAf40cWgycWNgaXPO06dupuS+sgibK6c= cloud.google.com/go v0.121.6/go.mod h1:coChdst4Ea5vUpiALcYKXEpR1S9ZgXbhEzzMcMR66vI= cloud.google.com/go/auth v0.16.5 h1:mFWNQ2FEVWAliEQWpAdH80omXFokmrnbDhUS9cBywsI= cloud.google.com/go/auth v0.16.5/go.mod h1:utzRfHMP+Vv0mpOkTRQoWD2q3BatTOoWbA7gCc2dUhQ= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.8.0 h1:HxMRIbao8w17ZX6wBnjhcDkW6lTFpgcaobyVfZWqRLA= cloud.google.com/go/compute/metadata v0.8.0/go.mod h1:sYOGTp851OV9bOFJ9CH7elVvyzopvWQFNNghtDQ/Biw= cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8= cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE= cloud.google.com/go/kms v1.22.0 h1:dBRIj7+GDeeEvatJeTB19oYZNV0aj6wEqSIT/7gLqtk= cloud.google.com/go/kms v1.22.0/go.mod h1:U7mf8Sva5jpOb4bxYZdtw/9zsbIjrklYwPcvMk34AL8= cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFsS/PrE= cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY= cloud.google.com/go/pubsub v1.50.0 h1:hnYpOIxVlgVD1Z8LN7est4DQZK3K6tvZNurZjIVjUe0= cloud.google.com/go/pubsub v1.50.0/go.mod h1:Di2Y+nqXBpIS+dXUEJPQzLh8PbIQZMLE9IVUFhf2zmM= cloud.google.com/go/pubsub/v2 v2.0.0 h1:0qS6mRJ41gD1lNmM/vdm6bR7DQu6coQcVwD+VPf0Bz0= cloud.google.com/go/pubsub/v2 v2.0.0/go.mod h1:0aztFxNzVQIRSZ8vUr79uH2bS3jwLebwK6q1sgEub+E= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/IBM/sarama v1.46.0 h1:+YTM1fNd6WKMchlnLKRUB5Z0qD4M8YbvwIIPLvJD53s= github.com/IBM/sarama v1.46.0/go.mod h1:0lOcuQziJ1/mBGHkdp5uYrltqQuKQKM5O5FOWUQVVvo= github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY= github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892LARrKAdki6WtXS4= github.com/ThreeDotsLabs/watermill-amqp/v3 v3.0.2 h1:aeyFSR4SUsbszmocuFiYY13nsHorc6CXIS2Hy7+xgFU= github.com/ThreeDotsLabs/watermill-amqp/v3 v3.0.2/go.mod h1:+8tCh6VCuBcQWhfETCwzRINKQ1uyeg9moH3h7jMKxQk= github.com/ThreeDotsLabs/watermill-googlecloud v1.2.6 h1:Ll1NzWoiEXr3mtQ6APuVfMBoRNeGeLLvPcclYrpPTCA= github.com/ThreeDotsLabs/watermill-googlecloud v1.2.6/go.mod h1:74wkEkvh9NawpHArWQ7OhHnuldkFj6+J//ZMi0Fgw58= github.com/ThreeDotsLabs/watermill-io v1.1.2 h1:t3wismmE++6HbB+fDnSdvLtSKs0yYaSXRtLmyUcRyTk= github.com/ThreeDotsLabs/watermill-io v1.1.2/go.mod h1:DF6rhoPWBOeWRW/1wWjNfLkke8rZsB5BUzBox/L6fRI= github.com/ThreeDotsLabs/watermill-kafka/v3 v3.1.0 h1:DfhVM1ieq+rb+bboB7aoymUgfKsEM3UbH3noQZ6+RJ4= github.com/ThreeDotsLabs/watermill-kafka/v3 v3.1.0/go.mod h1:o1GcoF/1CSJ9JSmQzUkULvpZeO635pZe+WWrYNFlJNk= github.com/cenkalti/backoff/v3 v3.2.2 h1:cfUAAO3yvKMYKPrvhDuHSwQnhZNk/RMHKdZqKTxfm6M= github.com/cenkalti/backoff/v3 v3.2.2/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4rc0ij+ULvLYs= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dnwe/otelsarama v0.0.0-20240308230250-9388d9d40bc0 h1:R2zQhFwSCyyd7L43igYjDrH0wkC/i+QBPELuY0HOu84= github.com/dnwe/otelsarama v0.0.0-20240308230250-9388d9d40bc0/go.mod h1:2MqLKYJfjs3UriXXF9Fd0Qmh/lhxi/6tHXkqtXxyIHc= github.com/eapache/go-resiliency v1.7.0 h1:n3NRTnBn5N0Cbi/IeOHuQn9s2UwVUH7Ga0ZWcP+9JTA= github.com/eapache/go-resiliency v1.7.0/go.mod h1:5yPzW0MIvSe0JDsv0v+DvcjEv2FyD6iZYSs1ZI+iQho= github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 h1:Oy0F4ALJ04o5Qqpdz8XLIpNA3WM/iSIXqxtqo7UGVws= github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3/go.mod h1:YvSRo5mw33fLEx1+DlK6L2VV43tJt5Eyel9n9XBcR+0= github.com/eapache/queue v1.1.0 h1:YOEu7KNc61ntiQlcEeUIoDTJ2o8mQznoNvUhiigpIqc= github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg= github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8= github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw= github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg= github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.10.0 h1:FM8Cv6j2KqIhM2ZK7HZjm4mpj9NBktLgowT1aN9q5Cc= github.com/sagikazarmark/locafero v0.10.0/go.mod h1:Ieo3EUsjifvQu4NZwV5sPd4dwvu0OCgEQV7vjc9yDjw= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo= github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE= github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.einride.tech/aip v0.73.0 h1:bPo4oqBo2ZQeBKo4ZzLb1kxYXTY1ysJhpvQyfuGzvps= go.einride.tech/aip v0.73.0/go.mod h1:Mj7rFbmXEgw0dq1dqJ7JGMvYCZZVxmGOR3S4ZcV5LvQ= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/api v0.248.0 h1:hUotakSkcwGdYUqzCRc5yGYsg4wXxpkKlW5ryVqvC1Y= google.golang.org/api v0.248.0/go.mod h1:yAFUAF56Li7IuIQbTFoLwXTCI6XCFKueOlS7S9e4F9k= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= google.golang.org/genproto v0.0.0-20250826171959-ef028d996bc1 h1:Nm5SEGIguOIBDXs5rhfz2aKwEVWlgwC58UcmEnLDc8Y= google.golang.org/genproto v0.0.0-20250826171959-ef028d996bc1/go.mod h1:Jz9LrroM7Mcm+a0QrLh4UpZ1B/WhjIbqwEcUf4y08nQ= google.golang.org/genproto/googleapis/api v0.0.0-20250826171959-ef028d996bc1 h1:APHvLLYBhtZvsbnpkfknDZ7NyH4z5+ub/I0u8L3Oz6g= google.golang.org/genproto/googleapis/api v0.0.0-20250826171959-ef028d996bc1/go.mod h1:xUjFWUnWDpZ/C0Gu0qloASKFb6f8/QXiiXhSPFsD668= google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1 h1:pmJpJEvT846VzausCQ5d7KreSROcDqmO388w5YbnltA= google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1/go.mod h1:GmFNa4BdJZ2a8G+wCe9Bg3wwThLrJun751XstdJt5Og= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4= google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= ================================================ FILE: tools/mill/main.go ================================================ package main import "github.com/ThreeDotsLabs/watermill/tools/mill/cmd" func main() { cmd.Execute() } // TODO: alternative input/output modes: json, gob, protobuf... (?) ================================================ FILE: tools/pq/README.md ================================================ # pq pq is a CLI tool for working with delayed messages on poison queues. For now, it supports the PostgreSQL Pub/Sub implementation. ## Install ```bash go install github.com/ThreeDotsLabs/watermill/tools/pq@latest ``` ## Usage Set the `DATABASE_URL` environment variable to your PostgreSQL connection string. For example, to connect to the database used for the [delayed requeue example](../../_examples/real-world-examples/delayed-requeue): ```bash export DATABASE_URL="postgres://watermill:password@postgres:5432/watermill?sslmode=disable" ``` ```bash pq -backend postgres -topic requeue ``` This will use the default `watermill_` prefix, so will use the `watermill_requeue` table. If you use a custom prefix, use the `-raw-topic` flag instead: ```bash pq -backend postgres -raw-topic my_prefix_requeue ``` ## Commands - Requeue — Updates the `_watermill_delayed_until` metadata to the current time, so the message will be instantly requeued. - Ack — Removes the message from the queue (be careful — you will lose the message forever). ================================================ FILE: tools/pq/backend/postgres.go ================================================ package backend import ( "context" "encoding/json" "fmt" "os" "time" "github.com/jmoiron/sqlx" _ "github.com/lib/pq" "github.com/ThreeDotsLabs/watermill/components/delay" "github.com/ThreeDotsLabs/watermill/tools/pq/cli" ) type PostgresMessage struct { Offset int `db:"offset"` UUID string `db:"uuid"` Payload string `db:"payload"` Metadata string `db:"metadata"` } type PostgresBackend struct { db *sqlx.DB config cli.BackendConfig } func NewPostgresBackend(ctx context.Context, config cli.BackendConfig) (*PostgresBackend, error) { dbURL := os.Getenv("DATABASE_URL") if dbURL == "" { return nil, fmt.Errorf("missing DATABASE_URL") } db, err := sqlx.Connect("postgres", dbURL) if err != nil { return nil, err } return &PostgresBackend{ db: db, config: config, }, nil } func (r *PostgresBackend) AllMessages(ctx context.Context) ([]cli.Message, error) { var dbMessages []PostgresMessage err := r.db.SelectContext(ctx, &dbMessages, fmt.Sprintf(`SELECT "offset", uuid, payload, metadata FROM %v WHERE acked = false ORDER BY "offset"`, r.topic())) if err != nil { return nil, err } var messages []cli.Message for _, dbMsg := range dbMessages { var metadata map[string]string err := json.Unmarshal([]byte(dbMsg.Metadata), &metadata) if err != nil { return nil, err } msg, err := cli.NewMessage(fmt.Sprint(dbMsg.Offset), dbMsg.UUID, dbMsg.Payload, metadata) if err != nil { return nil, err } messages = append(messages, msg) } return messages, nil } func (r *PostgresBackend) Requeue(ctx context.Context, msg cli.Message) error { _, err := r.db.ExecContext(ctx, fmt.Sprintf(`UPDATE %v SET metadata = metadata::jsonb || jsonb_build_object($1::text, $2::text) WHERE "offset" = $3`, r.topic()), delay.DelayedUntilKey, time.Now().UTC().Format(time.RFC3339), msg.ID, ) if err != nil { return err } return nil } func (r *PostgresBackend) Ack(ctx context.Context, msg cli.Message) error { _, err := r.db.ExecContext(ctx, fmt.Sprintf(`UPDATE %v SET acked = true WHERE "offset" = %v`, r.topic(), msg.ID)) if err != nil { return err } return nil } func (r *PostgresBackend) topic() string { if r.config.Topic != "" { return fmt.Sprintf(`"watermill_%v"`, r.config.Topic) } return fmt.Sprintf(`"%v"`, r.config.RawTopic) } ================================================ FILE: tools/pq/cli/backend.go ================================================ package cli import ( "context" "github.com/pkg/errors" ) type BackendConfig struct { Topic string RawTopic string } func (c BackendConfig) Validate() error { if c.Topic == "" && c.RawTopic == "" { return errors.New("topic or raw topic must be provided") } if c.Topic != "" && c.RawTopic != "" { return errors.New("only one of topic or raw topic must be provided") } return nil } type BackendConstructor func(ctx context.Context, cfg BackendConfig) (Backend, error) type Backend interface { AllMessages(ctx context.Context) ([]Message, error) Requeue(ctx context.Context, msg Message) error Ack(ctx context.Context, msg Message) error } ================================================ FILE: tools/pq/cli/message.go ================================================ package cli import ( "time" "github.com/ThreeDotsLabs/watermill/components/delay" "github.com/ThreeDotsLabs/watermill/message/router/middleware" ) type Message struct { // ID is a unique message ID across the Pub/Sub's topic. ID string UUID string Payload string Metadata map[string]string OriginalTopic string DelayedUntil string DelayedFor string RequeueIn time.Duration } func NewMessage(id string, uuid string, payload string, metadata map[string]string) (Message, error) { originalTopic := metadata[middleware.PoisonedTopicKey] // Calculate the time until the message should be requeued delayedUntil, err := time.Parse(time.RFC3339, metadata[delay.DelayedUntilKey]) if err != nil { return Message{}, err } delayedFor := metadata[delay.DelayedForKey] requeueIn := delayedUntil.Sub(time.Now().UTC()).Round(time.Second) return Message{ ID: id, UUID: uuid, Payload: payload, Metadata: metadata, OriginalTopic: originalTopic, DelayedUntil: delayedUntil.String(), DelayedFor: delayedFor, RequeueIn: requeueIn, }, nil } ================================================ FILE: tools/pq/cli/model.go ================================================ package cli import ( "context" "encoding/json" "fmt" "slices" "time" "github.com/charmbracelet/bubbles/table" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "golang.org/x/exp/maps" ) var warningStyle = lipgloss.NewStyle(). Background(lipgloss.Color("196")). Align(lipgloss.Center). Padding(1, 10) var dialogStyle = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("241")). Padding(1, 4) var buttonStyle = lipgloss.NewStyle() var buttonSelectedStyle = lipgloss.NewStyle(). Background(lipgloss.Color("57")) var readOnlyMessageActions = []string{"<- Back", "Show payload"} var writeMessageActions = []string{"Requeue", "Ack (drop)"} var dialogActions = []string{"Cancel", "Confirm"} type MessagesUpdated struct { Messages []Message } type DialogResult struct { Err error } func (m Model) FetchMessages() tea.Cmd { return func() tea.Msg { for { msgs, err := m.backend.AllMessages(context.Background()) if err != nil { panic(err) } m.sub <- MessagesUpdated{ Messages: msgs, } time.Sleep(time.Second) } } } func (m Model) WaitForMessages() tea.Cmd { return func() tea.Msg { return <-m.sub } } var baseStyle = lipgloss.NewStyle(). BorderStyle(lipgloss.NormalBorder()). BorderForeground(lipgloss.Color("240")) type Model struct { backend Backend sub chan MessagesUpdated chosenMessage *Message chosenMessageGone bool table table.Model messages []Message chosenAction int currentDialog *Dialog showingPayload bool payloadViewport viewport.Model } func (m Model) Init() tea.Cmd { return tea.Batch( m.FetchMessages(), m.WaitForMessages(), ) } func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case MessagesUpdated: rows := make([]table.Row, len(msg.Messages)) for i, message := range msg.Messages { rows[i] = table.Row{ message.ID, message.UUID, message.OriginalTopic, message.DelayedFor, message.RequeueIn.String(), } } m.table.SetRows(rows) m.messages = msg.Messages // If the chosen message is no longer in the list, go back to the table. // This is to avoid accidentally making an action on a message that has been requeued or deleted. if m.chosenMessage != nil { found := false for _, message := range m.messages { if message.ID == m.chosenMessage.ID { foundMessage := message m.chosenMessage = &foundMessage found = true break } } if found { m.chosenMessageGone = false } else { if !m.chosenMessageGone { m.chosenAction = 0 } m.chosenMessageGone = true } } return m, m.WaitForMessages() } if m.chosenMessage == nil { switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "ctrl+c", "q": return m, tea.Quit case " ", "enter": c := m.table.Cursor() m.chosenAction = 0 chosenMessage := m.messages[c] m.chosenMessage = &chosenMessage m.chosenMessageGone = false } } var cmd tea.Cmd m.table, cmd = m.table.Update(msg) return m, cmd } else if m.showingPayload { switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "ctrl+c", "q": return m, tea.Quit case "esc", "backspace": m.showingPayload = false } } var cmd tea.Cmd m.payloadViewport, cmd = m.payloadViewport.Update(msg) return m, cmd } else if m.currentDialog != nil { switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "ctrl+c", "q": return m, tea.Quit case "esc", "backspace": m.currentDialog = nil case "h", "left": m.currentDialog.Choice-- if m.currentDialog.Choice < 0 { m.currentDialog.Choice = 0 } case "l", "right": m.currentDialog.Choice++ if m.currentDialog.Choice >= len(dialogActions) { m.currentDialog.Choice = len(dialogActions) - 1 } case " ", "enter": switch m.currentDialog.Choice { case 0: m.currentDialog = nil case 1: m.currentDialog.Running = true return m, m.currentDialog.Action } } case DialogResult: if msg.Err != nil { // TODO Could be handled better panic(msg.Err) } m.currentDialog = nil } return m, nil } else { messageActions := len(readOnlyMessageActions) if !m.chosenMessageGone { messageActions += len(writeMessageActions) } switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "ctrl+c", "q": return m, tea.Quit case "esc", "backspace": m.chosenMessage = nil m.chosenMessageGone = false case "j", "down": m.chosenAction++ if m.chosenAction >= messageActions { m.chosenAction = messageActions - 1 } case "k", "up": m.chosenAction-- if m.chosenAction < 0 { m.chosenAction = 0 } case " ", "enter": switch m.chosenAction { case 0: m.chosenMessage = nil m.chosenMessageGone = false case 1: // Show payload m.showingPayload = true m.payloadViewport = viewport.New(80, 20) b := lipgloss.RoundedBorder() m.payloadViewport.Style = lipgloss.NewStyle().BorderStyle(b).Padding(0, 1) payload := m.chosenMessage.Payload var jsonPayload any err := json.Unmarshal([]byte(payload), &jsonPayload) if err == nil { pretty, err := json.MarshalIndent(jsonPayload, "", " ") if err == nil { payload = string(pretty) } } m.payloadViewport.SetContent(payload) case 2: chosenMessage := *m.chosenMessage m.currentDialog = &Dialog{ Prompt: "Requeue message? It will go back to the original topic.", Action: func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() return DialogResult{ Err: m.backend.Requeue(ctx, chosenMessage), } }, } case 3: chosenMessage := *m.chosenMessage m.currentDialog = &Dialog{ Prompt: "Acknowledge message? It will be dropped from the topic.", Action: func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() return DialogResult{ Err: m.backend.Ack(ctx, chosenMessage), } }, } } } } return m, nil } } func (m Model) View() string { if m.chosenMessage == nil { return baseStyle.Render(m.table.View()) + "\n " + m.table.HelpView() + "\n" } msg := m.chosenMessage var out string if m.chosenMessageGone { out += warningStyle.Render("Read only — the message is gone.") out += "\n" } out += fmt.Sprintf( "ID: %v\nUUID: %v\nOriginal Topic: %v\nDelayed For: %v\nDelayed Until: %v\nRequeue In: %v\n\n", msg.ID, msg.UUID, msg.OriginalTopic, msg.DelayedFor, msg.DelayedUntil, msg.RequeueIn, ) if m.showingPayload { out += m.payloadViewport.View() return out } out += "Metadata:\n" keys := maps.Keys(msg.Metadata) slices.Sort(keys) for _, k := range keys { v := msg.Metadata[k] out += fmt.Sprintf(" %v: %v\n", k, v) } if m.currentDialog != nil { prompt := m.currentDialog.Prompt + "\n\n" if m.currentDialog.Running { prompt += "Running..." } else { for i, action := range dialogActions { style := buttonStyle if i == m.currentDialog.Choice { style = buttonSelectedStyle } prompt += fmt.Sprintf("%v", style.MarginLeft(13).Render(action)) } } out += dialogStyle.Render(prompt) } else { out += "\nActions:\n" messageActions := readOnlyMessageActions if !m.chosenMessageGone { messageActions = append(messageActions, writeMessageActions...) } for i, action := range messageActions { style := buttonStyle if i == m.chosenAction { style = buttonSelectedStyle } out += fmt.Sprintf("%v\n", style.MarginLeft(2).Render(action)) } } return out } func NewModel(backend Backend) Model { columns := []table.Column{ {Title: "ID", Width: 8}, {Title: "UUID", Width: 40}, {Title: "Original Topic", Width: 20}, {Title: "Delayed For", Width: 14}, {Title: "Requeue In", Width: 14}, } t := table.New( table.WithColumns(columns), table.WithFocused(true), table.WithHeight(20), ) s := table.DefaultStyles() s.Header = s.Header. BorderStyle(lipgloss.NormalBorder()). BorderForeground(lipgloss.Color("240")). BorderBottom(true). Bold(false) s.Selected = s.Selected. Foreground(lipgloss.Color("229")). Background(lipgloss.Color("57")). Bold(false) t.SetStyles(s) return Model{ backend: backend, sub: make(chan MessagesUpdated), table: t, } } type Dialog struct { Prompt string Action func() tea.Msg Choice int Running bool } ================================================ FILE: tools/pq/go.mod ================================================ module github.com/ThreeDotsLabs/watermill/tools/pq go 1.25 require ( github.com/ThreeDotsLabs/watermill v1.5.1 github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.6 github.com/charmbracelet/lipgloss v1.1.0 github.com/jmoiron/sqlx v1.4.0 github.com/lib/pq v1.10.9 github.com/pkg/errors v0.9.1 golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b ) require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/charmbracelet/colorprofile v0.3.2 // indirect github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/google/uuid v1.6.0 // indirect github.com/lithammer/shortuuid/v3 v3.0.7 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sony/gobreaker v1.0.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sync v0.16.0 // indirect golang.org/x/sys v0.35.0 // indirect golang.org/x/text v0.28.0 // indirect ) ================================================ FILE: tools/pq/go.sum ================================================ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY= github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892LARrKAdki6WtXS4= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI= github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ= github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0= golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: tools/pq/main.go ================================================ package main import ( "context" "flag" "log" "github.com/ThreeDotsLabs/watermill/tools/pq/backend" "github.com/ThreeDotsLabs/watermill/tools/pq/cli" tea "github.com/charmbracelet/bubbletea" ) var ( backendFlag = flag.String("backend", "", "backend to use") topicFlag = flag.String("topic", "", "topic to use") rawTopicFlag = flag.String("raw-topic", "", "raw topic to use") ) func main() { flag.Parse() config := cli.BackendConfig{ Topic: *topicFlag, RawTopic: *rawTopicFlag, } err := config.Validate() if err != nil { log.Fatal(err) } var b cli.Backend switch *backendFlag { case "postgres": b, err = backend.NewPostgresBackend(context.Background(), config) if err != nil { log.Fatal(err) } default: log.Fatalf("unknown backend: %s", *backendFlag) } m := cli.NewModel(b) p := tea.NewProgram(m, tea.WithAltScreen()) _, err = p.Run() if err != nil { log.Fatal(err) } } ================================================ FILE: uuid.go ================================================ package watermill import ( "crypto/rand" "github.com/google/uuid" "github.com/lithammer/shortuuid/v3" "github.com/oklog/ulid" ) // NewUUID returns a new UUID Version 4. func NewUUID() string { return uuid.New().String() } // NewShortUUID returns a new short UUID. func NewShortUUID() string { return shortuuid.New() } // NewULID returns a new ULID. func NewULID() string { return ulid.MustNew(ulid.Now(), rand.Reader).String() } ================================================ FILE: uuid_test.go ================================================ package watermill_test import ( "sync" "testing" "github.com/ThreeDotsLabs/watermill" ) func testUniqueness(t *testing.T, genFunc func() string) { producers := 100 uuidsPerProducer := 10000 if testing.Short() { producers = 10 uuidsPerProducer = 1000 } uuidsCount := producers * uuidsPerProducer uuids := make(chan string, uuidsCount) allGenerated := sync.WaitGroup{} allGenerated.Add(producers) for i := 0; i < producers; i++ { go func() { for j := 0; j < uuidsPerProducer; j++ { uuids <- genFunc() } allGenerated.Done() }() } uniqueUUIDs := make(map[string]struct{}, uuidsCount) allGenerated.Wait() close(uuids) for uuid := range uuids { if _, ok := uniqueUUIDs[uuid]; ok { t.Error(uuid, " has duplicate") } uniqueUUIDs[uuid] = struct{}{} } } func TestUUID(t *testing.T) { testUniqueness(t, watermill.NewUUID) } func TestShortUUID(t *testing.T) { testUniqueness(t, watermill.NewShortUUID) } func TestULID(t *testing.T) { testUniqueness(t, watermill.NewULID) }