diff --git a/.github/ISSUE_TEMPLATE/prepare_release.md b/.github/ISSUE_TEMPLATE/prepare_release.md index de67b3eaf..3c2e2f729 100644 --- a/.github/ISSUE_TEMPLATE/prepare_release.md +++ b/.github/ISSUE_TEMPLATE/prepare_release.md @@ -18,7 +18,7 @@ For detailed info on the release process refer to https://github.com/logos-messa All items below are to be completed by the owner of the given release. - [ ] Create release branch with major and minor only ( e.g. release/v0.X ) if it doesn't exist. -- [ ] Update the `version` field in `waku.nimble` to match the release version (e.g. `version = "0.X.0"`). +- [ ] Update the `version` field in `waku.nimble` to match the release version (e.g. `version = "0.X.0"`) **and merge it before assigning any tag** - the `release-assets` workflow gates artifact build/upload. - [ ] Assign release candidate tag to the release branch HEAD (e.g. `v0.X.0-rc.0`, `v0.X.0-rc.1`, ... `v0.X.0-rc.N`). - [ ] Generate and edit release notes in CHANGELOG.md. diff --git a/.github/workflows/ci-daily.yml b/.github/workflows/ci-daily.yml index a4cf39340..dea30ab7e 100644 --- a/.github/workflows/ci-daily.yml +++ b/.github/workflows/ci-daily.yml @@ -3,11 +3,13 @@ name: Daily logos-delivery CI on: schedule: - cron: '30 6 * * *' + workflow_dispatch: env: NPROC: 2 MAKEFLAGS: "-j${NPROC}" - NIMFLAGS: "--parallelBuild:${NPROC} --colors:off -d:chronicles_colors:none" + NIMFLAGS: "--parallelBuild:${NPROC} --colors:off -d:chronicles_colors:none -d:disableMarchNative" + NIM_PARAMS: "-d:disableMarchNative" jobs: build: @@ -77,3 +79,8 @@ jobs: }" \ "$DISCORD_WEBHOOK_URL" + # RLN end-to-end against the simulator. Defaults from tests/simulator/rln-sim.env. + rln-simulator: + uses: ./.github/workflows/ci-rln-simulator.yml + secrets: inherit + diff --git a/.github/workflows/ci-rln-simulator.yml b/.github/workflows/ci-rln-simulator.yml new file mode 100644 index 000000000..c49ae1815 --- /dev/null +++ b/.github/workflows/ci-rln-simulator.yml @@ -0,0 +1,271 @@ +name: RLN E2E — Simulator + +# Validates the full RLN flow end-to-end against logos-delivery-simulator: +# keystore generation, on-chain registration, gossipsub propagation, +# per-epoch rate-limit enforcement, and epoch-boundary recovery. +# +# Why this exists: logos-dev runs with RLN disabled, so there is no +# production traffic exercising RLN. Until RLN is enabled there, this is +# the only end-to-end coverage of the RLN + zerokit path. +# +# The image is built ON the runner and tested ON the same runner, so the +# AVX-512 portability issue in container-image.yml does not apply here. +# +# No own schedule: ci-daily.yml is the single daily entry point and calls +# this via workflow_call. workflow_dispatch allows manual runs. +# Run defaults live in tests/simulator/rln-sim.env; inputs override per-run. + +on: + workflow_call: + inputs: + branch: + type: string + default: '' + num_nodes: + type: string + default: '' + msg_limit: + type: string + default: '' + epoch_sec: + type: string + default: '' + workflow_dispatch: + inputs: + branch: + description: 'logos-delivery branch to build & test (blank = use rln-sim.env)' + type: string + default: '' + num_nodes: + description: 'Number of nwaku nodes (blank = use rln-sim.env)' + type: string + default: '' + msg_limit: + description: 'RLN_RELAY_MSG_LIMIT, must be >= contract min ~20 (blank = use rln-sim.env)' + type: string + default: '' + epoch_sec: + description: 'RLN_RELAY_EPOCH_SEC, large enough a burst cannot straddle an epoch (blank = use rln-sim.env)' + type: string + default: '' + +env: + NPROC: 2 + MAKEFLAGS: "-j2" + NIM_VERSION: '2.2.4' + NIMBLE_VERSION: '0.22.3' + +jobs: + rln-e2e: + runs-on: ubuntu-22.04 + timeout-minutes: 120 + name: rln-e2e + + steps: + # First checkout: the ref that triggered this workflow (CI branch / + # master). This is where the e2e test script and rln-sim.env live — + # the build branch may not contain them. + - name: Checkout CI ref (for the test script) + uses: actions/checkout@v4 + with: + submodules: false + + # Defaults come from tests/simulator/rln-sim.env (single source of truth); + # a non-blank input (dispatch or workflow_call) overrides the matching value. + - name: Resolve parameters + id: cfg + env: + IN_BRANCH: ${{ inputs.branch }} + IN_NUM_NODES: ${{ inputs.num_nodes }} + IN_MSG_LIMIT: ${{ inputs.msg_limit }} + IN_EPOCH_SEC: ${{ inputs.epoch_sec }} + run: | + set -euo pipefail + set -a; . tests/simulator/rln-sim.env; set +a + { + echo "branch=${IN_BRANCH:-$BRANCH}" + echo "num_nodes=${IN_NUM_NODES:-$NUM_NODES}" + echo "msg_limit=${IN_MSG_LIMIT:-$MSG_LIMIT}" + echo "epoch_sec=${IN_EPOCH_SEC:-$EPOCH_SEC}" + } >> "$GITHUB_OUTPUT" + + - name: Stash e2e test script outside the workspace + run: | + test -f tests/simulator/rln-e2e-test.py \ + || { echo "tests/simulator/rln-e2e-test.py missing on CI ref"; exit 1; } + cp tests/simulator/rln-e2e-test.py "$RUNNER_TEMP/rln-e2e-test.py" + + # Second checkout: the branch to build & test. Overwrites the workspace; + # the stashed test script in RUNNER_TEMP survives. + - name: Checkout logos-delivery (${{ steps.cfg.outputs.branch }}) + uses: actions/checkout@v4 + with: + ref: ${{ steps.cfg.outputs.branch }} + submodules: false + clean: true + + - name: Get submodules hash + id: submodules + run: echo "hash=$(git submodule status | awk '{print $1}' | sort | shasum -a 256 | sed 's/[ -]*//g')" >> $GITHUB_OUTPUT + + - name: Cache submodules + uses: actions/cache@v3 + with: + path: | + vendor/ + .git/modules + key: ${{ runner.os }}-vendor-modules-${{ steps.submodules.outputs.hash }} + + - name: Install Nim ${{ env.NIM_VERSION }} + uses: jiro4989/setup-nim-action@v2 + with: + nim-version: ${{ env.NIM_VERSION }} + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Install Nimble ${{ env.NIMBLE_VERSION }} + run: | + cd /tmp && nimble install "nimble@${{ env.NIMBLE_VERSION }}" -y + echo "$HOME/.nimble/bin" >> $GITHUB_PATH + + - name: Cache nimble deps + id: cache-nimbledeps + uses: actions/cache@v3 + with: + path: | + nimbledeps/ + nimble.paths + key: ${{ runner.os }}-nimbledeps-nimble${{ env.NIMBLE_VERSION }}-${{ hashFiles('nimble.lock', 'BearSSL.mk', 'Nat.mk') }} + + - name: Install nimble deps + if: steps.cache-nimbledeps.outputs.cache-hit != 'true' + run: | + nimble setup --localdeps -y + make rebuild-nat-libs-nimbledeps + make rebuild-bearssl-nimbledeps + touch nimbledeps/.nimble-setup + + - name: Build wakunode2 + run: | + make -j${NPROC} V=1 POSTGRES=1 \ + NIMFLAGS="-d:disableMarchNative -d:chronicles_colors:none" \ + wakunode2 + + - name: Build local Docker image + run: | + docker build -t nwaku-rln-ci:test -f docker/binaries/Dockerfile.bn.amd64 . + + - name: Clone logos-delivery-simulator + run: | + git clone --depth 1 https://github.com/logos-messaging/logos-delivery-simulator.git "$RUNNER_TEMP/logos-delivery-simulator" + + - name: Write simulator .env + working-directory: ${{ runner.temp }}/logos-delivery-simulator + run: | + cat > .env </dev/null || echo missing) + [ "$st" = "exited" ] && break + echo "deployer status: $st"; sleep 15 + done + ec=$(docker inspect logos-delivery-simulator-contract-repo-deployer-1 --format='{{.State.ExitCode}}') + echo "deployer exit code: $ec" + if [ "$ec" != "0" ]; then + docker logs logos-delivery-simulator-contract-repo-deployer-1 2>&1 | tail -50 + exit 1 + fi + + - name: Wait for nwaku fleet to register + working-directory: ${{ runner.temp }}/logos-delivery-simulator + run: | + N=${{ steps.cfg.outputs.num_nodes }} + for _ in $(seq 1 60); do + up=$(docker ps --filter 'name=logos-delivery-simulator-nwaku-' --filter 'status=running' --format '{{.Names}}' | wc -l) + echo "nwaku running: $up/$N" + [ "$up" -ge "$N" ] && break + sleep 15 + done + # nwaku-1 must reach the "registered + started" marker + timeout 300 docker logs -f logos-delivery-simulator-nwaku-1 2>&1 \ + | grep -m1 -E "Segmentation fault|Illegal instruction|Failed to register on-chain|I am a nwaku node" \ + | tee /tmp/nwaku1.verdict + grep -q "I am a nwaku node" /tmp/nwaku1.verdict + + - name: Run RLN e2e scenarios + run: | + TEST_SCRIPT="$RUNNER_TEMP/rln-e2e-test.py" + test -f "$TEST_SCRIPT" \ + || { echo "stashed test script missing at $TEST_SCRIPT"; exit 1; } + docker run --rm \ + --network logos-delivery-simulator_simulation \ + -v "$TEST_SCRIPT:/test.py:ro" \ + python:3.11-slim \ + sh -c "pip install --quiet --disable-pip-version-check requests && \ + python /test.py \ + --hostname-prefix logos-delivery-simulator-nwaku- \ + --num-nodes ${{ steps.cfg.outputs.num_nodes }} \ + --msg-limit ${{ steps.cfg.outputs.msg_limit }} \ + --epoch-sec ${{ steps.cfg.outputs.epoch_sec }} \ + --health-deadline-sec 600" + + - name: Collect logs on failure + if: failure() + working-directory: ${{ runner.temp }}/logos-delivery-simulator + run: | + mkdir -p "$RUNNER_TEMP/logs" + for c in $(docker ps -a --filter 'name=logos-delivery-simulator-' --format '{{.Names}}'); do + docker logs "$c" > "$RUNNER_TEMP/logs/$c.log" 2>&1 || true + done + + - name: Upload logs + if: failure() + uses: actions/upload-artifact@v4 + with: + name: simulator-logs + path: ${{ runner.temp }}/logs + retention-days: 7 + + - name: Tear down + if: always() + working-directory: ${{ runner.temp }}/logos-delivery-simulator + run: docker compose down -v || true + + - name: Notify Discord + if: always() + env: + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + run: | + [ -z "$DISCORD_WEBHOOK_URL" ] && exit 0 + STATUS="${{ job.status }}" + BRANCH="${{ steps.cfg.outputs.branch }}" + RUN_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" + if [ "$STATUS" = "success" ]; then COLOR=3066993; TITLE="✅ RLN E2E passed"; else COLOR=15158332; TITLE="❌ RLN E2E failed"; fi + curl -H "Content-Type: application/json" -X POST -d "{ + \"embeds\":[{\"title\":\"$TITLE\",\"color\":$COLOR, + \"fields\":[ + {\"name\":\"Branch\",\"value\":\"$BRANCH\",\"inline\":true}, + {\"name\":\"Status\",\"value\":\"$STATUS\",\"inline\":true}], + \"url\":\"$RUN_URL\", + \"footer\":{\"text\":\"Daily RLN simulator E2E\"}}]}" \ + "$DISCORD_WEBHOOK_URL" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52d20157a..9ddf904ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,8 @@ concurrency: env: NPROC: 2 MAKEFLAGS: "-j${NPROC}" - NIMFLAGS: "--parallelBuild:${NPROC} --colors:off -d:chronicles_colors:none" + NIMFLAGS: "--parallelBuild:${NPROC} --colors:off -d:chronicles_colors:none -d:disableMarchNative" + NIM_PARAMS: "-d:disableMarchNative" NIM_VERSION: '2.2.4' NIMBLE_VERSION: '0.22.3' @@ -35,6 +36,9 @@ jobs: - 'nimble.lock' - 'waku.nimble' - 'Makefile' + - 'scripts/**' + - 'flake.nix' + - 'flake.lock' - 'library/**' - 'liblogosdelivery/**' v2: @@ -43,6 +47,7 @@ jobs: - 'tools/**' - 'tests/all_tests_v2.nim' - 'tests/**' + - 'channels/**' docker: - 'docker/**' @@ -156,7 +161,7 @@ jobs: fi export MAKEFLAGS="-j1" - export NIMFLAGS="--colors:off -d:chronicles_colors:none" + export NIMFLAGS="--colors:off -d:chronicles_colors:none -d:disableMarchNative" export USE_LIBBACKTRACE=0 make V=1 POSTGRES=$postgres_enabled test @@ -176,20 +181,6 @@ jobs: secrets: inherit - js-waku-node: - needs: build-docker-image - uses: logos-messaging/logos-delivery-js/.github/workflows/test-node.yml@master - with: - nim_wakunode_image: ${{ needs.build-docker-image.outputs.image }} - test_type: node - - js-waku-node-optional: - needs: build-docker-image - uses: logos-messaging/logos-delivery-js/.github/workflows/test-node.yml@master - with: - nim_wakunode_image: ${{ needs.build-docker-image.outputs.image }} - test_type: node-optional - lint: name: "Lint" runs-on: ubuntu-22.04 diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml index fc1f819d9..77862d11b 100644 --- a/.github/workflows/release-assets.yml +++ b/.github/workflows/release-assets.yml @@ -11,7 +11,35 @@ env: NPROC: 2 jobs: + # Release gate: the pushed tag MUST exactly match waku.nimble's version, + # so every published artifact reports the correct getNodeInfo Version. + # CI cannot reject/remove a tag, so we gate artifact build & upload on + # this instead: a mismatched tag yields no released artifacts. + verify-version: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - name: Assert pushed tag equals waku.nimble version + if: startsWith(github.ref, 'refs/tags/') + run: | + set -euo pipefail + NIMBLE_VERSION=$(grep -m1 '^version = ' waku.nimble | sed -E 's/version = "([^"]+)"/\1/') + # Strip leading v and any prerelease suffix (e.g. v0.38.0-rc.1 -> + # 0.38.0) so release-candidate tags build against the same + # waku.nimble version as the final tag. + TAG_VERSION="${GITHUB_REF_NAME#v}" + BASE_VERSION="${TAG_VERSION%%-*}" + echo "tag: ${GITHUB_REF_NAME} (base ${BASE_VERSION})" + echo "waku.nimble version: ${NIMBLE_VERSION}" + if [ "${BASE_VERSION}" != "${NIMBLE_VERSION}" ]; then + echo "::error::Tag ${GITHUB_REF_NAME} (base ${BASE_VERSION}) does not match" + echo "::error::waku.nimble version (${NIMBLE_VERSION}). Bump waku.nimble before tagging." + exit 1 + fi + echo "OK: tag base matches waku.nimble." + build-and-upload: + needs: verify-version strategy: matrix: os: [ubuntu-22.04, macos-15] diff --git a/.github/workflows/version-check.yml b/.github/workflows/version-check.yml new file mode 100644 index 000000000..ee01a9f1a --- /dev/null +++ b/.github/workflows/version-check.yml @@ -0,0 +1,52 @@ +name: version check +permissions: + contents: read + +on: + pull_request: + branches: [master] + +jobs: + # PR check: waku.nimble version must be >= the nearest tag reachable from + # this branch (`git describe --tags --abbrev=0`, i.e. ancestor-aware). + # Because we check out the PR HEAD (not the simulated merge ref), a branch + # that predates a release tag does not see that tag in its history, so a + # newly pushed tag does NOT break in-flight PRs. Once the branch merges/ + # rebases past the tag, the bump is then enforced. This keeps waku.nimble + # fixed as early as possible, independent of whether a release is cut. + # The exact tag==nimble guarantee at release time lives in + # release-assets.yml, which gates artifact publishing on it. + nimble-not-behind-tag: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} + - name: Compare waku.nimble version with nearest ancestor tag + run: | + set -euo pipefail + NIMBLE_VERSION=$(grep -m1 '^version = ' waku.nimble | sed -E 's/version = "([^"]+)"/\1/') + # Nearest tag reachable from HEAD; --abbrev=0 drops the --g + # suffix so we get the bare tag (e.g. v0.38.0). `--match 'v*'` skips + # the moving `nightly` tag (auto-updated by the daily CI to point at + # master HEAD), which would otherwise be picked as the nearest tag + # and break the version-sort comparison below. + BASE_TAG=$(git describe --tags --abbrev=0 --match 'v*' 2>/dev/null || echo "") + BASE_TAG=${BASE_TAG#v} + # Compare on the base version, ignoring any -rc.N prerelease suffix. + BASE_TAG=${BASE_TAG%%-*} + echo "waku.nimble version: ${NIMBLE_VERSION}" + echo "ancestor git tag: ${BASE_TAG:-}" + if [ -z "${BASE_TAG}" ]; then + echo "No ancestor release tag; skipping." + exit 0 + fi + # lowest of the two by version sort must be the tag => nimble >= tag + LOWEST=$(printf '%s\n%s\n' "${NIMBLE_VERSION}" "${BASE_TAG}" | sort -V | head -1) + if [ "${LOWEST}" != "${BASE_TAG}" ] && [ "${NIMBLE_VERSION}" != "${BASE_TAG}" ]; then + echo "::error::waku.nimble version (${NIMBLE_VERSION}) is behind its" + echo "::error::ancestor git tag (v${BASE_TAG}). Bump 'version' in waku.nimble." + exit 1 + fi + echo "OK: waku.nimble is not behind its ancestor tag." diff --git a/.gitignore b/.gitignore index 188090b19..0f9751f9b 100644 --- a/.gitignore +++ b/.gitignore @@ -85,3 +85,8 @@ nimble.paths nimbledeps **/anvil_state/state-deployed-contracts-mint-and-approved.json +.gitnexus + +# Python bytecode from tests/simulator +__pycache__/ +*.pyc diff --git a/AGENTS.md b/AGENTS.md index 4f735f240..ff7d29a39 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,7 @@ Key architectural decisions: Resource-restricted first: Protocols differentiate between full nodes (relay) and light clients (filter, lightpush, store). Light clients can participate without maintaining full message history or relay capabilities. This explains the client/server split in protocol implementations. -Privacy through unlinkability: RLN (Rate Limiting Nullifier) provides DoS protection while preserving sender anonymity. Messages are routed through pubsub topics with automatic sharding across 8 shards. Code prioritizes metadata privacy alongside content encryption. +Privacy through unlinkability: RLN (Rate Limiting Nullifier) provides DoS protection while preserving sender anonymity. Messages are routed through pubsub topics with automatic content-topic-based sharding (shard count is configurable; generation-zero defaults to 8 shards on cluster 0). Code prioritizes metadata privacy alongside content encryption. Scalability via sharding: The network uses automatic content-topic-based sharding to distribute traffic. This is why you'll see sharding logic throughout the codebase and why pubsub topic selection is protocol-level, not application-level. @@ -36,7 +36,10 @@ See [documentation](https://docs.waku.org/learn/) for architectural details. ### Key Terminology - ENR (Ethereum Node Record): Node identity and capability advertisement - Multiaddr: libp2p addressing format (e.g., `/ip4/127.0.0.1/tcp/60000/p2p/16Uiu2...`) -- PubsubTopic: Gossipsub topic for message routing (e.g., `/waku/2/default-waku/proto`) +- PubsubTopic: Gossipsub topic for message routing (shard-based, e.g., `/waku/2/rs//`; the default is `/waku/2/rs/0/0`) + - cluster-id: network id + - shard-id: shard differentiator inside the network - drivers mesh forming. + - autosharding: network supports n (configured) shards [0..n-1], shard derived from ContentTopic - ContentTopic: Application-level message categorization (e.g., `/my-app/1/chat/proto`) - Sharding: Partitioning network traffic across topics (static or auto-sharding) - RLN (Rate Limiting Nullifier): Zero-knowledge proof system for spam prevention @@ -77,29 +80,29 @@ type WakuFilter* = ref object of LPProtocol ### Build Requirements - Nim 2.x (check `waku.nimble` for minimum version) - Rust toolchain (required for RLN dependencies) -- Build system: Make with nimbus-build-system +- Build system: Make driven by Nimble (dependencies pinned in `nimble.lock`) ### Build System -The project uses Makefile with nimbus-build-system (Status's Nim build framework): +The project uses a Makefile that drives Nimble. Dependencies are resolved from +`nimble.lock` into a local `nimbledeps/` directory (tracked by the +`NIMBLEDEPS_STAMP` target). ```bash -# Initial build (updates submodules) +# Initial build (resolves Nimble deps automatically) make wakunode2 -# After git pull, update submodules -make update - # Build with custom flags make wakunode2 NIMFLAGS="-d:chronicles_log_level=DEBUG" ``` -Note: The build system uses `--mm:refc` memory management (automatically enforced). Only relevant if compiling outside the standard build system. +Note: The build uses `--mm:refc` memory management (passed automatically by the Nimble tasks in `waku.nimble`). Only relevant if compiling outside the standard build system. ### Common Make Targets ```bash make wakunode2 # Build main node binary make test # Run all tests make testcommon # Run common tests only -make libwakuStatic # Build static C library +make libwaku # Build the legacy C library (libwaku) +make liblogosdelivery. # Build actual C FFI library make chat2 # Build chat example make install-nph # Install git hook for auto-formatting ``` @@ -127,7 +130,7 @@ suite "Waku ENR - Capabilities": test "check capabilities support": ## Given let bitfield: CapabilitiesBitfield = 0b0000_1101u8 - + ## Then check: bitfield.supportsCapability(Capabilities.Relay) @@ -135,7 +138,7 @@ suite "Waku ENR - Capabilities": ``` ### Code Formatting -Mandatory: All code must be formatted with `nph` (vendored in `vendor/nph`) +Mandatory: All code must be formatted with `nph` (installed via `make build-nph`, which fetches a pinned `nph` version with Nimble) ```bash # Format specific file make nph/waku/waku_core.nim @@ -162,7 +165,6 @@ Compile with log level: nim c -d:chronicles_log_level=TRACE myfile.nim ``` - ## Code Conventions Common pitfalls: @@ -181,8 +183,13 @@ Common pitfalls: - Exceptions: `XxxError` for CatchableError, `XxxDefect` for Defect - ref object types: `XxxRef` suffix +### Calls and Member Access +- Prefer dot call syntax for predicates: `x.isNil()` instead of `isNil(x)` +- Use parentheses for "verbs" (operations/actions): `isSome()`, `handleRequest()` +- Omit parentheses for "nouns" (properties/values): `.len`, `.high` + ### Imports Organization -Group imports: stdlib, external libs, internal modules: +Stdlib + external in one `import` block, internal modules in a separate block: ```nim import std/[options, sequtils], # stdlib @@ -214,11 +221,11 @@ proc subscribe( ): Future[FilterSubscribeResult] {.async.} = if contentTopics.len > MaxContentTopicsPerRequest: return err(FilterSubscribeError.badRequest("exceeds maximum")) - + # Handle Result with isOkOr (await wf.subscriptions.addSubscription(peerId, criteria)).isOkOr: return err(FilterSubscribeError.serviceUnavailable(error)) - + ok() ``` @@ -460,8 +467,7 @@ nim c -r \ ### Vendor Directory - Never edit files directly in vendor - it is auto-generated from git submodules -- Always run `make update` after pulling changes -- Managed by `nimbus-build-system` +- Nimble dependencies are resolved from `nimble.lock` into `nimbledeps/` ### Chronicles Performance - Log levels are configured at compile time for performance @@ -475,7 +481,7 @@ nim c -r \ ### RLN Dependencies - RLN code requires a Rust toolchain, which explains Rust imports in some modules -- Pre-built `librln` libraries are checked into the repository +- `librln` is built from the vendored `zerokit` submodule via the `librln`/`rln-deps` Make targets ## Quick Reference @@ -483,18 +489,19 @@ Language: Nim 2.x | License: MIT or Apache 2.0 ### Important Files - `Makefile` - Primary build interface -- `waku.nimble` - Package definition and build tasks (called via nimbus-build-system) -- `vendor/nimbus-build-system/` - Status's build framework +- `waku.nimble` - Package definition and build tasks (invoked by the Makefile via Nimble) +- `nimble.lock` - Pinned dependency versions resolved into `nimbledeps/` - `waku/node/waku_node.nim` - Core node implementation - `apps/wakunode2/wakunode2.nim` - Main CLI application - `waku/factory/waku_conf.nim` - Configuration types -- `library/libwaku.nim` - C bindings entry point +- `liblogosdelivery/liblogosdelivery.nim` - C bindings entry point ### Testing Entry Points - `tests/all_tests_waku.nim` - All Waku protocol tests - `tests/all_tests_wakunode2.nim` - Node application tests - `tests/all_tests_common.nim` - Common utilities tests - +#### in-flight testing +- any test can be run separately by issuing `make test tests//.nim` ### Key Dependencies - `chronos` - Async framework - `nim-results` - Result type for error handling @@ -506,4 +513,46 @@ Language: Nim 2.x | License: MIT or Apache 2.0 Note: For specific version requirements, check `waku.nimble`. + +# GitNexus — Code Intelligence +This project is indexed by GitNexus as **logos-delivery** (2076 symbols, 2564 relationships, 12 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. + +> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. + +## Always Do + +- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. +- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. +- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. +- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. +- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`. + +## Never Do + +- NEVER edit a function, class, or method without first running `gitnexus_impact` on it. +- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. +- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph. +- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope. + +## Resources + +| Resource | Use for | +|----------|---------| +| `gitnexus://repo/logos-delivery/context` | Codebase overview, check index freshness | +| `gitnexus://repo/logos-delivery/clusters` | All functional areas | +| `gitnexus://repo/logos-delivery/processes` | All execution flows | +| `gitnexus://repo/logos-delivery/process/{name}` | Step-by-step execution trace | + +## CLI + +| Task | Read this skill file | +|------|---------------------| +| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | +| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | +| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | +| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | +| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | +| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | + + diff --git a/BearSSL.mk b/BearSSL.mk index 355e46563..65e4f72a7 100644 --- a/BearSSL.mk +++ b/BearSSL.mk @@ -9,7 +9,7 @@ ## bearssl (nimbledeps) ## ########################### # Rebuilds libbearssl.a from the package installed by nimble under -# nimbledeps/pkgs2/. Used by `make update` / $(NIMBLEDEPS_STAMP). +# nimbledeps/pkgs2/. Invoked via $(NIMBLEDEPS_STAMP) / build-deps. # # BEARSSL_NIMBLEDEPS_DIR is evaluated at parse time, so targets that # depend on it must be invoked via a recursive $(MAKE) call so the sub-make @@ -29,18 +29,11 @@ else PORTABLE_BEARSSL_CFLAGS := -W -Wall -Os -fPIC endif -.PHONY: clean-bearssl-nimbledeps rebuild-bearssl-nimbledeps +.PHONY: rebuild-bearssl-nimbledeps -clean-bearssl-nimbledeps: +rebuild-bearssl-nimbledeps: ifeq ($(BEARSSL_NIMBLEDEPS_DIR),) - $(error No bearssl package found under nimbledeps/pkgs2/ — run 'make update' first) -endif - + [ -e "$(BEARSSL_CSOURCES_DIR)/build" ] && \ - "$(MAKE)" -C "$(BEARSSL_CSOURCES_DIR)" clean || true - -rebuild-bearssl-nimbledeps: | clean-bearssl-nimbledeps -ifeq ($(BEARSSL_NIMBLEDEPS_DIR),) - $(error No bearssl package found under nimbledeps/pkgs2/ — run 'make update' first) + $(error No bearssl package found under nimbledeps/pkgs2/ — run 'make build-deps' first) endif @echo "Rebuilding bearssl from $(BEARSSL_CSOURCES_DIR)" + "$(MAKE)" -C "$(BEARSSL_CSOURCES_DIR)" CFLAGS="$(PORTABLE_BEARSSL_CFLAGS)" lib \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 37220a235..2ba15fcd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## v0.38.1 (2026-05-07) + +### Changes + +- Evict peer instead of abrupt disconnect and avoid sending unnecessary store requests ([#3857](https://github.com/logos-messaging/logos-delivery/pull/3857)) ([75dbeb1b](https://github.com/logos-messaging/logos-delivery/commit/75dbeb1be785df5e61c9ab0bcf8393349b9d0f5e) and [7e59b2c2](https://github.com/logos-messaging/logos-delivery/commit/7e59b2c2)) + +- RecvService now delivers store-recovered messages via MessageReceivedEvent and includes check for missed hashes before processing ([#3805](https://github.com/logos-messaging/logos-delivery/pull/3805)) ([494ea946](https://github.com/logos-messaging/logos-delivery/commit/494ea946)) + ## v0.38.0 (2026-03-16) ### Notes diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..43c994c2d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/Makefile b/Makefile index be9e14027..ea1bf66f0 100644 --- a/Makefile +++ b/Makefile @@ -24,6 +24,7 @@ export PATH := $(HOME)/.nimble/bin:$(PATH) # NIM binary location NIM_BINARY := $(shell which nim 2>/dev/null) NPH := $(HOME)/.nimble/bin/nph +NIMBLE := $(HOME)/.nimble/bin/nimble NIMBLEDEPS_STAMP := nimbledeps/.nimble-setup # Compilation parameters @@ -42,7 +43,8 @@ endif ########## ## Main ## ########## -.PHONY: all test update clean examples deps nimble install-nim install-nimble +# The Makefile automatically bootstraps dependency setup when needed for build and test targets. +.PHONY: all test clean examples deps nimble install-nim install-nimble # default target all: | wakunode2 libwaku liblogosdelivery @@ -69,18 +71,16 @@ endif waku.nims: ln -s waku.nimble $@ -$(NIMBLEDEPS_STAMP): nimble.lock | waku.nims - $(MAKE) install-nimble - nimble setup --localdeps - $(MAKE) build-nph - $(MAKE) rebuild-bearssl-nimbledeps - $(MAKE) rebuild-nat-libs-nimbledeps +$(NIMBLEDEPS_STAMP): nimble.lock | install-nimble build-nph waku.nims + $(NIMBLE) setup --localdeps touch $@ -update: - rm -f $(NIMBLEDEPS_STAMP) - $(MAKE) $(NIMBLEDEPS_STAMP) - nimble lock +# Must be phony so the recipe always runs and the sub-make re-evaluates +# BEARSSL_NIMBLEDEPS_DIR / NAT_TRAVERSAL_NIMBLEDEPS_DIR (parse-time variables) +# after nimble setup has populated nimbledeps/. +.PHONY: build-deps +build-deps: | $(NIMBLEDEPS_STAMP) + $(MAKE) rebuild-bearssl-nimbledeps rebuild-nat-libs-nimbledeps clean: rm -rf build 2> /dev/null || true @@ -93,15 +93,14 @@ REQUIRED_NIM_VERSION := $(shell grep -E '^const RequiredNimVersion\s*=' waku. REQUIRED_NIMBLE_VERSION := $(shell grep -E '^const RequiredNimbleVersion\s*=' waku.nimble | grep -oE '"[0-9]+\.[0-9]+\.[0-9]+"' | tr -d '"') install-nim: +ifneq ($(detected_OS),Windows) scripts/install_nim.sh $(REQUIRED_NIM_VERSION) +endif install-nimble: install-nim - @nimble_ver=$$(nimble --version 2>/dev/null | head -1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1); \ - if [ "$$nimble_ver" = "$(REQUIRED_NIMBLE_VERSION)" ]; then \ - echo "nimble $(REQUIRED_NIMBLE_VERSION) already installed, skipping."; \ - else \ - cd $$(mktemp -d) && nimble install "nimble@$(REQUIRED_NIMBLE_VERSION)" -y; \ - fi +ifneq ($(detected_OS),Windows) + scripts/install_nimble.sh $(REQUIRED_NIMBLE_VERSION) +endif build: mkdir -p build @@ -176,7 +175,7 @@ deps: | nimble .PHONY: librln LIBRLN_BUILDDIR := $(CURDIR)/vendor/zerokit -LIBRLN_VERSION := v0.9.0 +LIBRLN_VERSION := v2.0.2 ifeq ($(detected_OS),Windows) LIBRLN_FILE ?= rln.lib @@ -203,7 +202,7 @@ clean: | clean-librln ################# .PHONY: testcommon -testcommon: | $(NIMBLEDEPS_STAMP) build +testcommon: | build-deps build echo -e $(BUILD_MSG) "build/$@" && \ nimble testcommon @@ -212,59 +211,59 @@ testcommon: | $(NIMBLEDEPS_STAMP) build ########## .PHONY: testwaku wakunode2 testwakunode2 example2 chat2 chat2bridge liteprotocoltester -testwaku: | $(NIMBLEDEPS_STAMP) build rln-deps librln +testwaku: | build-deps build rln-deps librln echo -e $(BUILD_MSG) "build/$@" && \ nimble test -wakunode2: | $(NIMBLEDEPS_STAMP) build deps librln +wakunode2: | build-deps build deps librln echo -e $(BUILD_MSG) "build/$@" && \ nimble wakunode2 -benchmarks: | $(NIMBLEDEPS_STAMP) build deps librln +benchmarks: | build-deps build deps librln echo -e $(BUILD_MSG) "build/$@" && \ nimble benchmarks -testwakunode2: | $(NIMBLEDEPS_STAMP) build deps librln +testwakunode2: | build-deps build deps librln echo -e $(BUILD_MSG) "build/$@" && \ nimble testwakunode2 -example2: | $(NIMBLEDEPS_STAMP) build deps librln +example2: | build-deps build deps librln echo -e $(BUILD_MSG) "build/$@" && \ nimble example2 -chat2: | $(NIMBLEDEPS_STAMP) build deps librln +chat2: | build-deps build deps librln echo -e $(BUILD_MSG) "build/$@" && \ nimble chat2 -chat2mix: | $(NIMBLEDEPS_STAMP) build deps librln +chat2mix: | build-deps build deps librln echo -e $(BUILD_MSG) "build/$@" && \ nimble chat2mix -rln-db-inspector: | $(NIMBLEDEPS_STAMP) build deps librln +rln-db-inspector: | build-deps build deps librln echo -e $(BUILD_MSG) "build/$@" && \ nimble rln_db_inspector -chat2bridge: | $(NIMBLEDEPS_STAMP) build deps librln +chat2bridge: | build-deps build deps librln echo -e $(BUILD_MSG) "build/$@" && \ nimble chat2bridge -liteprotocoltester: | $(NIMBLEDEPS_STAMP) build deps librln +liteprotocoltester: | build-deps build deps librln echo -e $(BUILD_MSG) "build/$@" && \ nimble liteprotocoltester -lightpushwithmix: | $(NIMBLEDEPS_STAMP) build deps librln +lightpushwithmix: | build-deps build deps librln echo -e $(BUILD_MSG) "build/$@" && \ nimble lightpushwithmix -api_example: | $(NIMBLEDEPS_STAMP) build deps librln +api_example: | build-deps build deps librln echo -e $(BUILD_MSG) "build/$@" && \ $(ENV_SCRIPT) nim api_example $(NIM_PARAMS) waku.nims -build/%: | $(NIMBLEDEPS_STAMP) build deps librln +build/%: | build-deps build deps librln echo -e $(BUILD_MSG) "build/$*" && \ nimble buildone $* -compile-test: | $(NIMBLEDEPS_STAMP) build deps librln +compile-test: | build-deps build deps librln echo -e $(BUILD_MSG) "$(TEST_FILE)" "\"$(TEST_NAME)\"" && \ nimble buildTest $(TEST_FILE) && \ nimble execTest $(TEST_FILE) "\"$(TEST_NAME)\"" @@ -276,11 +275,11 @@ compile-test: | $(NIMBLEDEPS_STAMP) build deps librln tools: networkmonitor wakucanary -wakucanary: | $(NIMBLEDEPS_STAMP) build deps librln +wakucanary: | build-deps build deps librln echo -e $(BUILD_MSG) "build/$@" && \ nimble wakucanary -networkmonitor: | $(NIMBLEDEPS_STAMP) build deps librln +networkmonitor: | build-deps build deps librln echo -e $(BUILD_MSG) "build/$@" && \ nimble networkmonitor @@ -424,10 +423,10 @@ else ifeq ($(detected_OS),Linux) BUILD_COMMAND := $(BUILD_COMMAND)Linux endif -libwaku: | $(NIMBLEDEPS_STAMP) librln +libwaku: | build-deps librln nimble --verbose libwaku$(BUILD_COMMAND) waku.nimble -liblogosdelivery: | $(NIMBLEDEPS_STAMP) librln +liblogosdelivery: | build-deps librln nimble --verbose liblogosdelivery$(BUILD_COMMAND) waku.nimble logosdelivery_example: | build liblogosdelivery diff --git a/Nat.mk b/Nat.mk index 90d0b2ead..1161121ba 100644 --- a/Nat.mk +++ b/Nat.mk @@ -9,7 +9,7 @@ ## nat-libs (nimbledeps) ## ########################### # Builds miniupnpc and libnatpmp from the package installed by nimble under -# nimbledeps/pkgs2/. Used by `make update` / $(NIMBLEDEPS_STAMP). +# nimbledeps/pkgs2/. Invoked via $(NIMBLEDEPS_STAMP) / build-deps. # # NAT_TRAVERSAL_NIMBLEDEPS_DIR is evaluated at parse time, so targets that # depend on it must be invoked via a recursive $(MAKE) call so the sub-make @@ -28,20 +28,11 @@ else PORTABLE_NAT_MARCH := endif -.PHONY: clean-cross-nimbledeps rebuild-nat-libs-nimbledeps +.PHONY: rebuild-nat-libs-nimbledeps -clean-cross-nimbledeps: +rebuild-nat-libs-nimbledeps: ifeq ($(NAT_TRAVERSAL_NIMBLEDEPS_DIR),) - $(error No nat_traversal package found under nimbledeps/pkgs2/ — run 'make update' first) -endif - + [ -e "$(NAT_TRAVERSAL_NIMBLEDEPS_DIR)/vendor/miniupnp/miniupnpc" ] && \ - "$(MAKE)" -C "$(NAT_TRAVERSAL_NIMBLEDEPS_DIR)/vendor/miniupnp/miniupnpc" CC=$(CC) clean $(HANDLE_OUTPUT) || true - + [ -e "$(NAT_TRAVERSAL_NIMBLEDEPS_DIR)/vendor/libnatpmp-upstream" ] && \ - "$(MAKE)" -C "$(NAT_TRAVERSAL_NIMBLEDEPS_DIR)/vendor/libnatpmp-upstream" CC=$(CC) clean $(HANDLE_OUTPUT) || true - -rebuild-nat-libs-nimbledeps: | clean-cross-nimbledeps -ifeq ($(NAT_TRAVERSAL_NIMBLEDEPS_DIR),) - $(error No nat_traversal package found under nimbledeps/pkgs2/ — run 'make update' first) + $(error No nat_traversal package found under nimbledeps/pkgs2/ — run 'make build-deps' first) endif @echo "Rebuilding nat-libs from $(NAT_TRAVERSAL_NIMBLEDEPS_DIR)" ifeq ($(OS), Windows_NT) @@ -58,4 +49,4 @@ else + "$(MAKE)" CFLAGS="-Wall -Wno-cpp -Os -fPIC $(PORTABLE_NAT_MARCH) -DENABLE_STRNATPMPERR -DNATPMP_MAX_RETRIES=4 $(CFLAGS)" \ -C "$(NAT_TRAVERSAL_NIMBLEDEPS_DIR)/vendor/libnatpmp-upstream" \ CC=$(CC) libnatpmp.a $(HANDLE_OUTPUT) -endif +endif \ No newline at end of file diff --git a/README.md b/README.md index 8833ae131..f227ea483 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,9 @@ This repository implements a set of libp2p protocols aimed to bring private communications. -- Nim implementation of [these specs](https://github.com/vacp2p/rfc-index/tree/main/waku). +- Nim implementation of [these specs](https://github.com/logos-co/logos-lips/tree/master/docs/messaging). - C library that exposes the implemented protocols. -- CLI application that allows you to run an lmn node. +- CLI application that allows you to run a logos-delivery node. - Examples. - Various tests of above. @@ -17,15 +17,20 @@ For more details see the [source code](waku/README.md) These instructions are generic. For more detailed instructions, see the source code above. +Recommended and tested toolchain versions (these are installed when you follow the build instructions below): +- Nim 2.2.4 +- Nimble 0.22.3 + ### Prerequisites -The standard developer tools, including a C compiler, GNU Make, Bash, and Git. More information on these installations can be found [here](https://docs.waku.org/guides/nwaku/build-source#install-dependencies). +The standard developer tools, including a C compiler, GNU Make, Bash, and Git. > In some distributions (Fedora linux for example), you may need to install `which` utility separately. Nimbus build system is relying on it. You'll also need an installation of Rust and its toolchain (specifically `rustc` and `cargo`). The easiest way to install these, is using `rustup`: +Rust: ```bash curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh ``` @@ -33,8 +38,7 @@ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh ### Wakunode ```bash -# The first `make` invocation will update all Git submodules. -# You'll run `make update` after each `git pull` in the future to keep those submodules updated. +# The first `make` invocation will initialize the local dependency state. make wakunode2 # Build with custom compilation flags. Do not use NIM_PARAMS unless you know what you are doing. @@ -48,12 +52,12 @@ make wakunode2 NIMFLAGS="-d:chronicles_colors:none -d:disableMarchNative" ./build/wakunode2 --help ``` To join the network, you need to know the address of at least one bootstrap node. -Please refer to the [Waku README](https://github.com/waku-org/nwaku/blob/master/waku/README.md) for more information. +Please refer to the [Waku README](https://github.com/logos-messaging/logos-delivery/blob/master/waku/README.md) for more information. For more on how to run `wakunode2`, refer to: -- [Run using binaries](https://docs.waku.org/guides/nwaku/build-source) -- [Run using docker](https://docs.waku.org/guides/nwaku/run-docker) -- [Run using docker-compose](https://docs.waku.org/guides/nwaku/run-docker-compose) +- [Run using binaries](https://docs.waku.org/run-node/build-source) +- [Run using docker](https://docs.waku.org/run-node/run-docker) +- [Run using docker-compose](https://docs.waku.org/run-node/run-docker-compose) #### Issues ##### WSL @@ -104,13 +108,9 @@ If `wakunode2.exe` isn't generated: This repository is bundled with a Nim runtime that includes the necessary dependencies for the project. Before you can utilize the runtime you'll need to build the project, as detailed in a previous section. -This will generate a `vendor` directory containing various dependencies, including the `nimbus-build-system` which has the bundled nim runtime. +This will generate a `nimbledeps/pkgs2` directory containing various dependencies. -After successfully building the project, you may bring the bundled runtime into scope by running: -```bash -source env.sh -``` -If everything went well, you should see your prompt suffixed with `[Nimbus env]$`. Now you can run `nim` commands as usual. +If everything went well, you should see your prompt suffixed with `[SuccessX]`. Now you can run `nim` commands as usual. ### Test Suite @@ -144,7 +144,7 @@ make test/tests/common/test_enr_builder.nim ``` ### Testing against `js-waku` -Refer to [js-waku repo](https://github.com/waku-org/js-waku/tree/master/packages/tests) for instructions. +Refer to [logos-delivery-js repo](https://github.com/logos-messaging/logos-delivery-js/tree/master/packages/tests) for instructions. ## Formatting @@ -175,14 +175,14 @@ Different tools and their corresponding how-to guides can be found in the `tools ### Bugs, Questions & Features -For an inquiry, or if you would like to propose new features, feel free to [open a general issue](https://github.com/waku-org/nwaku/issues/new). +For an inquiry, or if you would like to propose new features, feel free to [open a general issue](https://github.com/logos-messaging/logos-delivery/issues/new). -For bug reports, please [tag your issue with the `bug` label](https://github.com/waku-org/nwaku/issues/new). +For bug reports, please [tag your issue with the `bug` label](https://github.com/logos-messaging/logos-delivery/issues/new). -If you believe the reported issue requires critical attention, please [use the `critical` label](https://github.com/waku-org/nwaku/issues/new?labels=critical,bug) to assist with triaging. +If you believe the reported issue requires critical attention, please [use the `critical` label](https://github.com/logos-messaging/logos-delivery/issues/new?labels=critical,bug) to assist with triaging. -To get help, or participate in the conversation, join the [Waku Discord](https://discord.waku.org/) server. +To get help, or participate in the conversation, join the [Logos Discord](https://discord.gg/logosnetwork) server. ### Docs -* [REST API Documentation](https://waku-org.github.io/waku-rest-api/) +* [REST API Documentation](https://logos-messaging.github.io/logos-delivery-rest-api/) \ No newline at end of file diff --git a/apps/chat2/chat2.nim b/apps/chat2/chat2.nim index e76c7be17..282e17bfd 100644 --- a/apps/chat2/chat2.nim +++ b/apps/chat2/chat2.nim @@ -13,7 +13,8 @@ import chronos, eth/keys, bearssl, - stew/[byteutils, results], + stew/[byteutils], + results, metrics, metrics/chronos_httpserver import diff --git a/apps/chat2/config_chat2.nim b/apps/chat2/config_chat2.nim index fe7865c62..b0e38c6bc 100644 --- a/apps/chat2/config_chat2.nim +++ b/apps/chat2/config_chat2.nim @@ -140,7 +140,8 @@ type metricsServerAddress* {. desc: "Listening address of the metrics server.", - defaultValue: parseIpAddress("127.0.0.1"), + defaultValue: + IpAddress(family: IpAddressFamily.IPv4, address_v4: [127'u8, 0, 0, 1]), name: "metrics-server-address" .}: IpAddress @@ -173,7 +174,10 @@ type dnsAddrsNameServers* {. desc: "DNS name server IPs to query for DNS multiaddrs resolution. Argument may be repeated.", - defaultValue: @[parseIpAddress("1.1.1.1"), parseIpAddress("1.0.0.1")], + defaultValue: @[ + IpAddress(family: IpAddressFamily.IPv4, address_v4: [1'u8, 1, 1, 1]), + IpAddress(family: IpAddressFamily.IPv4, address_v4: [1'u8, 0, 0, 1]), + ], name: "dns-addrs-name-server" .}: seq[IpAddress] @@ -348,4 +352,4 @@ proc parseCmdArg*(T: type EthRpcUrl, s: string): T = func defaultListenAddress*(conf: Chat2Conf): IpAddress = # TODO: How should we select between IPv4 and IPv6 # Maybe there should be a config option for this. - (static parseIpAddress("0.0.0.0")) + (static IpAddress(family: IpAddressFamily.IPv4, address_v4: [0'u8, 0, 0, 0])) diff --git a/apps/chat2bridge/chat2bridge.nim b/apps/chat2bridge/chat2bridge.nim index 53eb5d04b..eeeea328b 100644 --- a/apps/chat2bridge/chat2bridge.nim +++ b/apps/chat2bridge/chat2bridge.nim @@ -1,7 +1,7 @@ {.push raises: [].} import - std/[tables, times, strutils, hashes, sequtils, json], + std/[tables, times, strutils, hashes, sequtils, json, options], chronos, confutils, chronicles, @@ -267,10 +267,16 @@ when isMainModule: else: nodev2ExtPort + let nodev2Key = + if conf.nodekey.isSome(): + conf.nodekey.get() + else: + crypto.PrivateKey.random(Secp256k1, rng[]).tryGet() + let bridge = Chat2Matterbridge.new( mbHostUri = "http://" & $initTAddress(conf.mbHostAddress, Port(conf.mbHostPort)), mbGateway = conf.mbGateway, - nodev2Key = conf.nodekey, + nodev2Key = nodev2Key, nodev2BindIp = conf.listenAddress, nodev2BindPort = Port(uint16(conf.libp2pTcpPort) + conf.portsShift), nodev2ExtIp = nodev2ExtIp, diff --git a/apps/chat2bridge/config_chat2bridge.nim b/apps/chat2bridge/config_chat2bridge.nim index abb5e329f..048fc4d87 100644 --- a/apps/chat2bridge/config_chat2bridge.nim +++ b/apps/chat2bridge/config_chat2bridge.nim @@ -1,4 +1,5 @@ import + std/options, confutils, confutils/defs, confutils/std/net, @@ -45,7 +46,7 @@ type Chat2MatterbridgeConf* = object metricsServerAddress* {. desc: "Listening address of the metrics server", - defaultValue: parseIpAddress("127.0.0.1"), + defaultValue: IpAddress(family: IpAddressFamily.IPv4, address_v4: [127'u8, 0, 0, 1]), name: "metrics-server-address" .}: IpAddress @@ -62,10 +63,8 @@ type Chat2MatterbridgeConf* = object .}: seq[string] nodekey* {. - desc: "P2P node private key as hex", - defaultValue: crypto.PrivateKey.random(Secp256k1, newRng()[]).tryGet(), - name: "nodekey" - .}: crypto.PrivateKey + desc: "P2P node private key as hex", defaultValueDesc: "random", name: "nodekey" + .}: Option[crypto.PrivateKey] store* {. desc: "Flag whether to start store protocol", defaultValue: true, name: "store" @@ -94,7 +93,7 @@ type Chat2MatterbridgeConf* = object # Matterbridge options mbHostAddress* {. desc: "Listening address of the Matterbridge host", - defaultValue: parseIpAddress("127.0.0.1"), + defaultValue: IpAddress(family: IpAddressFamily.IPv4, address_v4: [127'u8, 0, 0, 1]), name: "mb-host-address" .}: IpAddress diff --git a/apps/chat2mix/config_chat2mix.nim b/apps/chat2mix/config_chat2mix.nim index 4e5a32e6d..639e14986 100644 --- a/apps/chat2mix/config_chat2mix.nim +++ b/apps/chat2mix/config_chat2mix.nim @@ -162,7 +162,8 @@ type metricsServerAddress* {. desc: "Listening address of the metrics server.", - defaultValue: parseIpAddress("127.0.0.1"), + defaultValue: + IpAddress(family: IpAddressFamily.IPv4, address_v4: [127'u8, 0, 0, 1]), name: "metrics-server-address" .}: IpAddress @@ -194,7 +195,10 @@ type dnsDiscoveryNameServers* {. desc: "DNS name server IPs to query. Argument may be repeated.", - defaultValue: @[parseIpAddress("1.1.1.1"), parseIpAddress("1.0.0.1")], + defaultValue: @[ + IpAddress(family: IpAddressFamily.IPv4, address_v4: [1'u8, 1, 1, 1]), + IpAddress(family: IpAddressFamily.IPv4, address_v4: [1'u8, 0, 0, 1]), + ], name: "dns-discovery-name-server" .}: seq[IpAddress] diff --git a/apps/liteprotocoltester/tester_config.nim b/apps/liteprotocoltester/tester_config.nim index dee918b8c..1f4bedaa8 100644 --- a/apps/liteprotocoltester/tester_config.nim +++ b/apps/liteprotocoltester/tester_config.nim @@ -133,7 +133,7 @@ type LiteProtocolTesterConf* = object ## Tester REST service configuration restAddress* {. desc: "Listening address of the REST HTTP server.", - defaultValue: parseIpAddress("127.0.0.1"), + defaultValue: IpAddress(family: IpAddressFamily.IPv4, address_v4: [127'u8, 0, 0, 1]), name: "rest-address" .}: IpAddress diff --git a/apps/networkmonitor/networkmonitor_config.nim b/apps/networkmonitor/networkmonitor_config.nim index f67fb09a8..b5bcfbd96 100644 --- a/apps/networkmonitor/networkmonitor_config.nim +++ b/apps/networkmonitor/networkmonitor_config.nim @@ -116,7 +116,7 @@ type NetworkMonitorConf* = object metricsServerAddress* {. desc: "Listening address of the metrics server.", - defaultValue: parseIpAddress("127.0.0.1"), + defaultValue: IpAddress(family: IpAddressFamily.IPv4, address_v4: [127'u8, 0, 0, 1]), name: "metrics-server-address" .}: IpAddress diff --git a/channels/encryption/encryption.nim b/channels/encryption/encryption.nim new file mode 100644 index 000000000..5cb53be2f --- /dev/null +++ b/channels/encryption/encryption.nim @@ -0,0 +1,25 @@ +## Optional encryption hooks for the Reliable Channel API. +## +## Modelled as `RequestBroker`s: the broker pattern lets the channel +## delegate work to a provider that may live in any module without +## introducing a direct dependency. If no provider is registered the +## broker returns an error, so installing the noop providers from +## `noop_encryption` is required when the application does not want +## actual encryption. +## +## Applied per-segment after SDS processing on outgoing, and before +## SDS processing on incoming. No specific scheme is mandated. +## +## See: https://lip.logos.co/messaging/raw/reliable-channel-api.html + +import brokers/request_broker + +export request_broker + +RequestBroker: + type Encrypt* = seq[byte] + proc signature*(payload: seq[byte]): Future[Result[Encrypt, string]] {.async.} + +RequestBroker: + type Decrypt* = seq[byte] + proc signature*(payload: seq[byte]): Future[Result[Decrypt, string]] {.async.} diff --git a/channels/encryption/noop_encryption.nim b/channels/encryption/noop_encryption.nim new file mode 100644 index 000000000..f09ed9cf4 --- /dev/null +++ b/channels/encryption/noop_encryption.nim @@ -0,0 +1,18 @@ +## No-op encryption providers. Install these when the application does +## not want actual encryption so the `Encrypt` / `Decrypt` brokers have +## something to dispatch to. + +import results +import chronos +import ./encryption + +proc setNoopEncryption*() = + discard Encrypt.setProvider( + proc(payload: seq[byte]): Future[Result[Encrypt, string]] {.async.} = + return ok(Encrypt(payload)) + ) + + discard Decrypt.setProvider( + proc(payload: seq[byte]): Future[Result[Decrypt, string]] {.async.} = + return ok(Decrypt(payload)) + ) diff --git a/channels/events.nim b/channels/events.nim new file mode 100644 index 000000000..904a34dc6 --- /dev/null +++ b/channels/events.nim @@ -0,0 +1,39 @@ +## Reliable Channel event types emitted to API consumers. +## +## Lifecycle events for individual segments (sent / propagated / errored) +## are the same as the network-level ones the DeliveryService already +## emits — `requestId` is shared across layers — so we just re-export +## `waku/events/message_events` and avoid declaring duplicates. +## +## Only the channel-level `MessageReceivedEvent` carries data that has +## no analogue in the lower layer (reassembled application payload, +## senderId, channelId), so it lives here. + +import waku/events/message_events as waku_message_events +import brokers/event_broker + +import ./types as channel_types + +export waku_message_events, channel_types, event_broker + +EventBroker: + type ChannelMessageReceivedEvent* = object + channelId*: ChannelId + senderId*: SdsParticipantID + payload*: seq[byte] + +EventBroker: + ## Emitted when every segment of a channel-level `send()` reached + ## `Confirmed`. Channel-level analogue of `MessageSentEvent`; the + ## `requestId` is the channel-layer parent returned by `send()`. + type ChannelMessageSentEvent* = object + channelId*: ChannelId + requestId*: RequestId + +EventBroker: + ## Emitted when a channel-level `send()` finalises with at least one + ## segment in `Failed`. Channel-level analogue of `MessageErrorEvent`. + type ChannelMessageErrorEvent* = object + channelId*: ChannelId + requestId*: RequestId + error*: string diff --git a/channels/rate_limit_manager/rate_limit_manager.nim b/channels/rate_limit_manager/rate_limit_manager.nim new file mode 100644 index 000000000..ab5a9f67b --- /dev/null +++ b/channels/rate_limit_manager/rate_limit_manager.nim @@ -0,0 +1,80 @@ +## Rate Limit Manager for the Reliable Channel API. +## +## Tracks messages sent per RLN epoch and delays dispatch when the +## limit is approached, ensuring RLN compliance on enforcing relays. +## +## For the skeleton this is a pass-through: messages are immediately +## released as ready-to-send. Real epoch budgeting will be added later. +## +## See: https://lip.logos.co/messaging/raw/reliable-channel-api.html + +import std/times +import message +import brokers/event_broker +import brokers/broker_context + +export event_broker, broker_context +export message.SdsChannelID + +const + DefaultEpochPeriodSec* = 600 + DefaultMessagesPerEpoch* = 1 + +EventBroker: + ## Emitted by `enqueueToSend` carrying the batch of opaque message + ## blobs that may now leave the rate limiter and continue down the + ## outgoing pipeline (encryption -> dispatch). Bytes only: the rate + ## limiter is intentionally agnostic of SDS, so anything serialisable + ## can flow through it. + ## + ## `channelId` lets listeners filter to their own channel, since all + ## reliable channels share the underlying Waku node's broker context. + type ReadyToSendEvent* = ref object + channelId*: SdsChannelID + msgs*: seq[seq[byte]] + +type + RateLimitConfig* = object + enabled*: bool ## spec: rate limiting opt-in; SHOULD be true when RLN active + epochPeriodSec*: int + messagesPerEpoch*: int + + RateLimitManager* = ref object + config*: RateLimitConfig + queue*: seq[seq[byte]] + currentEpochStart*: Time + sentInCurrentEpoch*: int + channelId*: SdsChannelID ## tag for the emitted `ReadyToSendEvent` + brokerCtx: BrokerContext + +proc new*( + T: type RateLimitManager, + config: RateLimitConfig, + channelId: SdsChannelID, + brokerCtx: BrokerContext = globalBrokerContext(), +): T = + return T( + config: config, + queue: @[], + currentEpochStart: getTime(), + sentInCurrentEpoch: 0, + channelId: channelId, + brokerCtx: brokerCtx, + ) + +proc enqueueToSend*(self: RateLimitManager, msg: seq[byte]) = + ## Skeleton behaviour: enqueue and immediately release as a single + ## ready batch. Real per-epoch budgeting will park messages on + ## `self.queue` and emit only when the budget allows. + ReadyToSendEvent.emit( + self.brokerCtx, ReadyToSendEvent(channelId: self.channelId, msgs: @[msg]) + ) + +proc dequeueReady*(self: RateLimitManager): seq[seq[byte]] = + ## Returns the set of queued messages that may be dispatched now + ## without exceeding the configured rate limit. + discard + +proc resetEpoch*(self: RateLimitManager) = + self.currentEpochStart = getTime() + self.sentInCurrentEpoch = 0 diff --git a/channels/reliable_channel.nim b/channels/reliable_channel.nim new file mode 100644 index 000000000..c3fbe5d77 --- /dev/null +++ b/channels/reliable_channel.nim @@ -0,0 +1,453 @@ +## Reliable Channel type. +## +## A `ReliableChannel` orchestrates segmentation, SDS (end-to-end +## reliability), optional encryption, and rate-limited dispatch on top +## of the Messaging API for a single channel. +## +## Outgoing pipeline: Segment -> SDS -> Rate Limit -> Encrypt -> Dispatch +## Incoming pipeline: Decrypt -> SDS -> Reassemble -> Emit event +## +## Channels are owned by a `ReliableChannelManager`. Lifecycle and send +## operations are addressed by `ChannelId`, so callers only need to keep +## an opaque handle around. +## +## See: https://lip.logos.co/messaging/raw/reliable-channel-api.html + +import std/[options, sets, tables] +import results +import chronos +import bearssl/rand +import stew/byteutils +import libp2p/crypto/crypto as libp2p_crypto + +import waku/api/api +import waku/factory/waku as waku_factory +import waku/node/delivery_service/send_service +import waku/waku_core/topics + +import ./events +import ./segmentation/segmentation +import ./scalable_data_sync/scalable_data_sync +import ./rate_limit_manager/rate_limit_manager +import ./encryption/encryption + +export + api, waku_factory, events, segmentation, scalable_data_sync, rate_limit_manager, + encryption + +const LipWireReliableChannelVersion* = "RELIABLE-CHANNEL-API/1" + ## Wire-format spec marker for the Reliable Channel layer, as defined + ## in the reliable-channel-api LIP (`Wire Format / Spec Marker`). + ## A `WakuMessage` whose `meta` field does not equal these bytes is + ## not addressed to this layer and is silently dropped on ingress. + ## The trailing `/N` is the wire-format version and is bumped only + ## on breaking on-the-wire changes; implementations pin one version. + +type + SendHandler* = proc(envelope: MessageEnvelope): Future[Result[RequestId, string]] {. + async: (raises: [CatchableError]), gcsafe + .} + ## Egress dispatch boundary. Defaults to `waku.send`; tests inject a + ## fake that records calls and returns canned `RequestId`s so the + ## send state machine can be exercised end-to-end without a network. + + MessagePersistence {.pure.} = enum + Persistent + Ephemeral + + SegmentSendState {.pure.} = enum + ## Lifecycle of a single segment as tracked by the channel. The + ## messaging layer has its own richer `DeliveryState` (retries, + ## propagated-vs-validated); here we only model what's needed to + ## decide when a `channelReqId` is fully accounted for. + AwaitingRateLimit ## Pushed by `send`; not yet released by rate_limit_manager. + InFlight + ## Released by rate_limit_manager and handed to delivery_service; + ## `messagingReqId` is now set. + Confirmed ## `MessageSentEvent` arrived for `messagingReqId`. + Failed + ## `MessageErrorEvent` arrived for `messagingReqId`, or the local + ## delivery-task construction failed before any id was reachable. + + PendingMessagingRequest = object + ## One entry per segment (i.e. per messaging-layer request). The + ## relative order of `AwaitingRateLimit` entries must match the + ## order in which `rate_limit_manager` re-emits messages, which is + ## FIFO with `send()`. + channelReqId*: RequestId + ## The channel-layer parent id returned to the caller of `send()` in channel layer. + ## One channel request maps to N pending messaging requests. + messagingReqId*: Option[RequestId] + ## Per-segment messaging layer id. `none` until `onReadyToSend` assigns it. + persistenceReqType: MessagePersistence + segmentSendState*: SegmentSendState + + ReliableChannel* = ref object + ## Spec-defined public type. Fields are private so callers cannot + ## mutate internals and break invariants. Getters are added below + ## for the few values consumers may need. + sendHandler: SendHandler + channelId: ChannelId + contentTopic: ContentTopic + senderId: SdsParticipantID + rng: ref HmacDrbgContext + segmentation: SegmentationHandler + sdsHandler: SdsHandler + rateLimit: RateLimitManager + + requestIds: Table[RequestId, seq[RequestId]] + pendingMessagingRequests: seq[PendingMessagingRequest] + ## Entries are kept until the matching segment reaches a final + ## state (`Confirmed` or `Failed`); a whole channel request is + ## then pruned in one pass once all its segments are final. + brokerCtx: BrokerContext + +func getChannelId*(self: ReliableChannel): ChannelId {.inline.} = + self.channelId + +func getContentTopic*(self: ReliableChannel): ContentTopic {.inline.} = + self.contentTopic + +func getSenderId*(self: ReliableChannel): SdsParticipantID {.inline.} = + self.senderId + +func isFinal(state: SegmentSendState): bool {.inline.} = + return state in {SegmentSendState.Confirmed, SegmentSendState.Failed} + +proc pruneCompletedChannelReqs(self: ReliableChannel) = + ## Drop every `pendingMessagingRequests` entry whose `channelReqId` + ## has all of its segments in a final state. A single failing + ## segment doesn't trigger a drop on its own — we wait until siblings + ## are also accounted for, so the channel-level outcome is decided + ## from a complete picture. For each fully-final `channelReqId`, emit + ## the channel-level final event before the entries are dropped: + ## `ChannelMessageSentEvent` if every sibling Confirmed, + ## `ChannelMessageErrorEvent` if any sibling Failed. + var hasPending = initHashSet[RequestId]() + var anyFailed = initHashSet[RequestId]() + for entry in self.pendingMessagingRequests: + if not entry.segmentSendState.isFinal(): + hasPending.incl(entry.channelReqId) + elif entry.segmentSendState == SegmentSendState.Failed: + anyFailed.incl(entry.channelReqId) + + var emitted = initHashSet[RequestId]() + for entry in self.pendingMessagingRequests: + if entry.channelReqId in hasPending or entry.channelReqId in emitted: + continue + emitted.incl(entry.channelReqId) + if entry.channelReqId in anyFailed: + ChannelMessageErrorEvent.emit( + self.brokerCtx, + ChannelMessageErrorEvent( + channelId: self.channelId, + requestId: entry.channelReqId, + error: "one or more segments failed", + ), + ) + else: + ChannelMessageSentEvent.emit( + self.brokerCtx, + ChannelMessageSentEvent( + channelId: self.channelId, requestId: entry.channelReqId + ), + ) + + self.pendingMessagingRequests.keepItIf(it.channelReqId in hasPending) + +proc onMessageSent(self: ReliableChannel, messagingReqId: RequestId) = + ## Invoked from this channel's `MessageSentEvent` listener. Flips + ## the matching `InFlight` segment to `Confirmed` and prunes. The + ## listener routes every event through here; entries that don't + ## belong to this channel simply don't match and are no-ops. + self.pendingMessagingRequests.applyItIf( + it.segmentSendState == SegmentSendState.InFlight and + it.messagingReqId == some(messagingReqId) + ): + it.segmentSendState = SegmentSendState.Confirmed + self.pruneCompletedChannelReqs() + +proc onMessageError(self: ReliableChannel, messagingReqId: RequestId) = + ## Symmetric to `onMessageSent` but for `MessageErrorEvent`. + self.pendingMessagingRequests.applyItIf( + it.segmentSendState == SegmentSendState.InFlight and + it.messagingReqId == some(messagingReqId) + ): + it.segmentSendState = SegmentSendState.Failed + self.pruneCompletedChannelReqs() + +proc onReadyToSend( + self: ReliableChannel, readyToSendEvent: ReadyToSendEvent +) {.async: (raises: []).} = + ## Tail of the outgoing pipeline. Invoked from the `ReadyToSendEvent` + ## listener once `rate_limit_manager` releases a batch of opaque + ## blobs (already-encoded SDS messages): + ## + ## ... -> rate_limit_manager -> [encryption] -> dispatch + var idx = 0 + for m in readyToSendEvent.msgs: + ## The first `AwaitingRateLimit` entry in push order is the one + ## this `m` belongs to: `send()` adds one entry per segment, and + ## `rate_limit_manager` re-emits them in the same FIFO order, so + ## the two sequences advance in lockstep. Earlier entries may + ## already be `InFlight` / `Confirmed` / `Failed` because they + ## live on until every sibling of their `channelReqId` is final, + ## so we walk past those to find the next one that was awaiting for this batch. + while idx < self.pendingMessagingRequests.len and + self.pendingMessagingRequests[idx].segmentSendState != + SegmentSendState.AwaitingRateLimit + : + idx.inc() + if idx >= self.pendingMessagingRequests.len: + ## rate_limit_manager emitted more messages than we have pending — + ## should not happen given `send` pushes one entry per enqueued + ## SDS payload. Drop silently rather than corrupt state. + break + + let channelReqId = self.pendingMessagingRequests[idx].channelReqId + let isEphemeral = + self.pendingMessagingRequests[idx].persistenceReqType == + MessagePersistence.Ephemeral + + ## TODO: revisit which fields of the SDS message must be encrypted. + ## Encrypting the whole encoded blob forces every receiver to attempt + ## decryption before it can route, which breaks selective dispatch. + ## Leave routing metadata (channelId, causal-history references) in + ## clear and encrypt only the application payload. + let encRes = await Encrypt.request(m) + let encrypted = encRes.valueOr: + MessageErrorEvent.emit( + self.brokerCtx, + MessageErrorEvent( + requestId: channelReqId, messageHash: "", error: "encryption failed: " & error + ), + ) + ## Encryption failed *before* we could hand the segment to the + ## delivery layer — no `messagingReqId` was minted and no + ## `DeliveryTask` was queued on `sendService`. The delivery + ## layer will therefore never emit a `MessageSentEvent` / + ## `MessageErrorEvent` for this segment, so `onMessageError` + ## won't fire either. Advance the state machine inline so the + ## parent `channelReqId` can still be pruned once its siblings + ## are also final. + self.pendingMessagingRequests[idx].segmentSendState = SegmentSendState.Failed + idx.inc() + continue + let wireBytes = seq[byte](encrypted) + + ## The `meta` field carries the Reliable Channel wire-format spec + ## marker so the ingress side of any peer can route this WakuMessage + ## to its Reliable Channel layer. + let envelope = MessageEnvelope( + contentTopic: self.contentTopic, + payload: wireBytes, + ephemeral: isEphemeral, + meta: LipWireReliableChannelVersion.toBytes(), + ) + + ## `waku.send` is not annotated `(raises: [])`, but this listener is. + ## Convert any raise to a Result error so the state machine handles + ## both failure modes (Result.err and exception) through one path. + let sendRes = + try: + await self.sendHandler(envelope) + except CatchableError as e: + Result[RequestId, string].err("waku send raised: " & e.msg) + + let messagingReqId = sendRes.valueOr: + MessageErrorEvent.emit( + self.brokerCtx, + MessageErrorEvent( + requestId: channelReqId, messageHash: "", error: "waku send failed: " & error + ), + ) + self.pendingMessagingRequests[idx].segmentSendState = SegmentSendState.Failed + idx.inc() + continue + + self.pendingMessagingRequests[idx].messagingReqId = some(messagingReqId) + self.pendingMessagingRequests[idx].segmentSendState = SegmentSendState.InFlight + self.requestIds.mgetOrPut(channelReqId, @[]).add(messagingReqId) + idx.inc() + + self.pruneCompletedChannelReqs() + +proc send*( + self: ReliableChannel, payload: seq[byte], ephemeral: bool = false +): Result[RequestId, string] = + ## Single application-level send. The first three stages of the + ## outgoing pipeline are chained explicitly so the flow is visible + ## at a glance: + ## + ## segmentation -> sds -> rate_limit_manager + ## + ## `rate_limit_manager.enqueueToSend` emits a `ReadyToSendEvent` with + ## the SDS messages cleared for transmission; the channel's listener + ## then runs the final stage (encryption -> dispatch). The + ## `persistenceReqType` is carried alongside each segment in + ## `pendingMessagingRequests` and stamped onto the eventual + ## `MessageEnvelope`. + ## + ## The returned `RequestId` is the channel-level parent of one-or-more + ## messaging-layer `RequestId`s; the mapping is recorded in + ## `self.requestIds`. + if payload.len == 0: + return err("empty payload") + + let channelReqId = RequestId.new(self.rng) + self.requestIds[channelReqId] = @[] + + let persistenceReqType = + if ephemeral: MessagePersistence.Ephemeral else: MessagePersistence.Persistent + + for segmentBytes in self.segmentation.performSegmentation(payload): + ## Segments arrive already encoded; the segmentation module owns + ## the wire format so SDS only ever sees opaque bytes. + let sdsBytes = self.sdsHandler.wrapOutgoing( + self.channelId, self.senderId, segmentBytes + ).valueOr: + return err("SDS wrap failed: " & error) + self.pendingMessagingRequests.add( + PendingMessagingRequest( + channelReqId: channelReqId, + messagingReqId: none(RequestId), + persistenceReqType: persistenceReqType, + segmentSendState: SegmentSendState.AwaitingRateLimit, + ) + ) + self.rateLimit.enqueueToSend(sdsBytes) + + return ok(channelReqId) + +proc onMessageReceived( + self: ReliableChannel, messageHash: string, payload: seq[byte] +) {.async: (raises: []).} = + ## Ingress pipeline made visible: + ## + ## payload -> decrypt -> sds -> reassemble -> emit + ## + ## Invoked from this channel's `MessageReceivedEvent` listener, which + ## already filtered on the spec marker and on `contentTopic`. The + ## channel only sees the raw payload bytes for itself. + + ## Notice that the following "request" is implemented implicitly as a broker call to + ## the `Decrypt` request broker. + let decRes = await Decrypt.request(payload) + let plaintext = decRes.valueOr: + MessageErrorEvent.emit( + self.brokerCtx, + MessageErrorEvent( + requestId: RequestId(""), + messageHash: messageHash, + error: "decryption failed: " & error, + ), + ) + return + let plaintextBytes = seq[byte](plaintext) + + let unwrapped = self.sdsHandler.handleIncoming(plaintextBytes) + if unwrapped.isErr(): + return + + let reassembled = self.segmentation.handleIncomingSegment(unwrapped.get().content) + if reassembled.isSome(): + ## Emit on the captured `brokerCtx` (the manager's), so the + ## application listener that the manager has set up on that same + ## context picks the event up. + ChannelMessageReceivedEvent.emit( + self.brokerCtx, + ChannelMessageReceivedEvent( + channelId: self.channelId, + senderId: self.senderId, + payload: reassembled.get().payload, + ), + ) + +proc new*( + T: type ReliableChannel, + waku: Waku, + channelId: ChannelId, + contentTopic: ContentTopic, + senderId: SdsParticipantID, + segConfig: SegmentationConfig, + sdsConfig: SdsConfig, + rateConfig: RateLimitConfig, + brokerCtx: BrokerContext = globalBrokerContext(), + sendHandler: SendHandler = nil, +): T = + ## Pipeline handlers (segmentation/SDS/rate-limit) are constructed + ## inside the channel rather than handed in by the caller — they are + ## implementation details of the channel, not knobs the API consumer + ## should be wiring up. Encryption is delegated to the `Encrypt`/ + ## `Decrypt` request brokers, so the channel keeps no per-instance + ## encryption state either. + ## + ## `sendHandler` defaults to `waku.send`; tests pass a fake to drive + ## the send state machine without touching the network. + let resolvedSendHandler = + if sendHandler.isNil(): + proc( + envelope: MessageEnvelope + ): Future[Result[RequestId, string]] {.async: (raises: [CatchableError]), gcsafe.} = + return await waku.send(envelope) + else: + sendHandler + + let chn = T( + sendHandler: resolvedSendHandler, + channelId: channelId, + contentTopic: contentTopic, + senderId: senderId, + rng: libp2p_crypto.newRng(), + segmentation: SegmentationHandler.new(segConfig), + sdsHandler: SdsHandler.new(sdsConfig, senderId), + rateLimit: RateLimitManager.new(rateConfig, channelId, brokerCtx), + requestIds: initTable[RequestId, seq[RequestId]](), + pendingMessagingRequests: @[], + brokerCtx: brokerCtx, + ) + + ## Each channel owns its own egress + ingress + send-completion + ## listeners on `chn.brokerCtx`, filtered to traffic addressed to + ## this channel. Keeping the listeners (and the handler procs they + ## call) inside the channel lets `onReadyToSend` / + ## `onMessageReceived` / `onMessageSent` / `onMessageError` stay + ## private — the manager doesn't need to know about them. + discard ReadyToSendEvent.listen( + chn.brokerCtx, + proc(evt: ReadyToSendEvent): Future[void] {.async: (raises: []).} = + if evt.channelId == chn.channelId: + await chn.onReadyToSend(evt) + , + ) + + discard MessageReceivedEvent.listen( + chn.brokerCtx, + proc(evt: MessageReceivedEvent): Future[void] {.async: (raises: []).} = + ## Drop foreign traffic (non-Reliable-Channel `meta`) and traffic + ## for other channels before doing any decode work. + if string.fromBytes(evt.message.meta) != LipWireReliableChannelVersion: + return + if evt.message.contentTopic != chn.contentTopic: + return + await chn.onMessageReceived(evt.messageHash, evt.message.payload) + , + ) + + ## Send-completion events are tagged with the per-segment messaging + ## `requestId` — globally unique, so we don't need any channel filter + ## up front. The handler scans this channel's pending entries for a + ## match and is a no-op when the id belongs to a different channel. + discard MessageSentEvent.listen( + chn.brokerCtx, + proc(evt: MessageSentEvent): Future[void] {.async: (raises: []).} = + chn.onMessageSent(evt.requestId), + ) + + discard MessageErrorEvent.listen( + chn.brokerCtx, + proc(evt: MessageErrorEvent): Future[void] {.async: (raises: []).} = + chn.onMessageError(evt.requestId), + ) + + return chn diff --git a/channels/reliable_channel_manager.nim b/channels/reliable_channel_manager.nim new file mode 100644 index 000000000..747f755b4 --- /dev/null +++ b/channels/reliable_channel_manager.nim @@ -0,0 +1,141 @@ +## Reliable Channel API entry point. +## +## Owns the set of `ReliableChannel` instances and exposes lifecycle and +## send/receive operations addressed by `ChannelId`. +## +## See: https://lip.logos.co/messaging/raw/reliable-channel-api.html + +import std/tables +import results +import chronos +import stew/byteutils + +import waku/api/api +import waku/api/api_conf +import waku/events/message_events as waku_message_events +import waku/factory/waku as waku_factory +import waku/node/delivery_service/delivery_service +import waku/waku_core/topics + +import ./reliable_channel +import ./encryption/noop_encryption + +export reliable_channel + +type ReliableChannelManager* = ref object + channels: Table[ChannelId, ReliableChannel] + waku: Waku + ## Owned by the manager. The channel layer reaches the messaging + ## API through `waku.send(envelope)`; constructing DeliveryTasks + ## directly would breach the layer boundary. + brokerCtx: BrokerContext + +proc new*( + T: type ReliableChannelManager, + conf: WakuNodeConf, + brokerCtx: BrokerContext = globalBrokerContext(), +): Future[Result[T, string]] {.async.} = + ## TODO !! The proper ownership chain is: + ## ReliableChannelManager -> DeliveryService (MessagingClient) -> Waku (Kernel/Protocols) -> WakuNode, + ## and this will be implemented in the future. For now, `createNode` + ## is called here to get a Waku instance, and the WakuNode is immediately discarded. + ## This is a temporary workaround to get the API + + let waku = ?(await createNode(conf)) + + let manager = T( + channels: initTable[ChannelId, ReliableChannel](), waku: waku, brokerCtx: brokerCtx + ) + + return ok(manager) + +proc start*(self: ReliableChannelManager): Result[void, string] = + ## Bring the owned DeliveryService up. Separated from `new` so callers + ## can register encryption providers / create channels before traffic + ## starts flowing. + self.waku.deliveryService.startDeliveryService() + +proc stop*(self: ReliableChannelManager) {.async.} = + if not self.waku.isNil(): + await self.waku.deliveryService.stopDeliveryService() + +proc createReliableChannel*( + self: ReliableChannelManager, + channelId: ChannelId, + contentTopic: ContentTopic, + senderId: SdsParticipantID, + sendHandler: SendHandler = nil, +): Result[ChannelId, string] = + ## Spec entry point. The `DeliveryService` and `rng` the channel needs + ## are sourced from the owning `ReliableChannelManager` rather than + ## passed per call. Encryption is wired up through the `Encrypt`/ + ## `Decrypt` request brokers — the application installs its own + ## providers (or `setNoopEncryption()`) before traffic flows. + ## + ## Segmentation, SDS and rate-limit configs will eventually be read + ## from the node's `NodeConfig`. Defaults for now. + ## + ## `sendHandler` is left `nil` in production so the channel uses the + ## owned `waku.send`; tests pass a fake to bypass the network. + if self.channels.hasKey(channelId): + return err("channel already exists: " & channelId) + + let segConfig = SegmentationConfig( + segmentSizeBytes: DefaultSegmentSizeBytes, + enableReedSolomon: false, + persistence: nil, + ) + let sdsConfig = SdsConfig( + acknowledgementTimeoutMs: DefaultAcknowledgementTimeoutMs, + maxRetransmissions: DefaultMaxRetransmissions, + causalHistorySize: DefaultCausalHistorySize, + persistence: nil, + ) + let rateConfig = RateLimitConfig( + epochPeriodSec: DefaultEpochPeriodSec, messagesPerEpoch: DefaultMessagesPerEpoch + ) + + let chn = ReliableChannel.new( + waku = self.waku, + channelId = channelId, + contentTopic = contentTopic, + senderId = senderId, + segConfig = segConfig, + sdsConfig = sdsConfig, + rateConfig = rateConfig, + brokerCtx = self.brokerCtx, + sendHandler = sendHandler, + ) + + self.channels[channelId] = chn + return ok(channelId) + +proc closeChannel*( + self: ReliableChannelManager, channelId: ChannelId +): Result[void, string] = + ## Flush state, persist outstanding SDS buffers, release resources. + if not self.channels.hasKey(channelId): + return err("unknown channel: " & channelId) + self.channels.del(channelId) + return ok() + +proc send*( + self: ReliableChannelManager, + channelId: ChannelId, + appPayload: seq[byte], + ephemeral: bool = false, +): Result[RequestId, string] = + ## Spec-level entry point. Looks the channel up by id and delegates + ## to `ReliableChannel.send`, which exposes the visible pipeline + ## segmentation -> sds -> rate_limit_manager -> encryption. + let chn = self.channels.getOrDefault(channelId) + if chn.isNil(): + return err("unknown channel: " & channelId) + return chn.send(appPayload, ephemeral) + +## Inbound messages are not handed to the manager by direct call. Each +## `ReliableChannel` installs its own `MessageReceivedEvent` listener +## in `ReliableChannel.new`, filters by spec marker and `contentTopic`, +## and routes to its private `onMessageReceived`. This keeps the lower +## layer (MessagingAPI/Waku) unaware of the existence of ReliableChannel +## and keeps the manager out of per-channel event dispatch. diff --git a/channels/scalable_data_sync/scalable_data_sync.nim b/channels/scalable_data_sync/scalable_data_sync.nim new file mode 100644 index 000000000..30ad0e02b --- /dev/null +++ b/channels/scalable_data_sync/scalable_data_sync.nim @@ -0,0 +1,62 @@ +## Scalable Data Sync (SDS) component for the Reliable Channel API. +## +## Provides end-to-end delivery guarantees via causal history tracking, +## acknowledgements, and retransmission of unacknowledged segments. +## +## Skeleton: `wrapOutgoing` and `handleIncoming` are pass-throughs so +## the send/receive circuit can exercise the surrounding pipeline. +## Real SDS wrapping will plug in via `nim-sds` later. +## +## See: https://lip.logos.co/messaging/raw/reliable-channel-api.html + +import results +import message as sds_message + +import ./sds_persistence + +export sds_message, sds_persistence + +const + DefaultAcknowledgementTimeoutMs* = 5_000 + DefaultMaxRetransmissions* = 5 + DefaultCausalHistorySize* = 2 + +type + SdsConfig* = object + acknowledgementTimeoutMs*: int + maxRetransmissions*: int + causalHistorySize*: int + persistence*: SdsPersistence + + SdsHandler* = ref object + config*: SdsConfig + participantId*: SdsParticipantID + +proc new*( + T: type SdsHandler, + config: SdsConfig, + participantId: SdsParticipantID = SdsParticipantID(""), +): T = + return T(config: config, participantId: participantId) + +proc wrapOutgoing*( + self: SdsHandler, + channelId: SdsChannelID, + senderId: SdsParticipantID, + payload: seq[byte], +): Result[seq[byte], string] = + ## Stage 2 of the outgoing pipeline (segmentation -> sds -> rate_limit_manager -> encryption). + ## Skeleton: pass the encoded segment through unchanged. Real causal + ## history / lamport / bloom-filter population will replace this. + return ok(payload) + +proc handleIncoming*( + self: SdsHandler, msg: seq[byte] +): Result[tuple[content: seq[byte], channelId: SdsChannelID], string] = + ## Skeleton: pass the bytes through; channel id is left empty until + ## the real wire format provides it. + return ok((content: msg, channelId: SdsChannelID(""))) + +proc tickRetransmissions*(self: SdsHandler) = + ## Drives retransmissions of unacknowledged messages. + discard diff --git a/channels/scalable_data_sync/sds_persistence.nim b/channels/scalable_data_sync/sds_persistence.nim new file mode 100644 index 000000000..8089595ea --- /dev/null +++ b/channels/scalable_data_sync/sds_persistence.nim @@ -0,0 +1,25 @@ +## Persistence backend for SDS outgoing buffer and causal history. +## +## TODO (raised in PR review): this surface is duplicating concerns that +## should come from the SDS module itself. Once the SDS module exposes a +## complete persistence contract, drop this file and import that surface +## instead of re-declaring it here. + +import message + +type + SdsPersistenceKind* {.pure.} = enum + InMemory + Sqlite + + SdsPersistence* = ref object of RootObj + kind*: SdsPersistenceKind + +method storeOutgoing*(self: SdsPersistence, msg: SdsMessage) {.base.} = + discard + +method markAcknowledged*(self: SdsPersistence, messageId: SdsMessageID) {.base.} = + discard + +method unackedOlderThan*(self: SdsPersistence, ageMs: int): seq[SdsMessage] {.base.} = + discard diff --git a/channels/segmentation/segment_message_proto.nim b/channels/segmentation/segment_message_proto.nim new file mode 100644 index 000000000..f19cdc27f --- /dev/null +++ b/channels/segmentation/segment_message_proto.nim @@ -0,0 +1,34 @@ +## Wire format for a single segment, per the Reliable Channel API spec. +## +## Skeleton: encode/decode treat the segment as just its payload bytes, +## since for now we only ever produce a single segment per send. + +type SegmentMessageProto* = object + entireMessageHash*: seq[byte] ## Keccak256(original payload), 32 bytes + dataSegmentIndex*: uint32 ## zero-indexed sequence number for data segments + dataSegmentCount*: uint32 ## number of data segments (>= 1) + payload*: seq[byte] ## segment payload (data or parity shard) + paritySegmentIndex*: uint32 ## zero-based sequence number for parity segments + paritySegmentCount*: uint32 ## number of parity segments + isParity*: bool ## true for parity segments, false (default) for data segments + +proc isParityMessage*(self: SegmentMessageProto): bool = + self.isParity + +proc isValid*(self: SegmentMessageProto): bool = + ## Validates hash length (32 bytes), segment indices and counts. + discard + +proc encode*(self: SegmentMessageProto): seq[byte] = + self.payload + +proc decode*(T: type SegmentMessageProto, buf: seq[byte]): T = + T( + entireMessageHash: @[], + dataSegmentIndex: 0, + dataSegmentCount: 1, + payload: buf, + paritySegmentIndex: 0, + paritySegmentCount: 0, + isParity: false, + ) diff --git a/channels/segmentation/segmentation.nim b/channels/segmentation/segmentation.nim new file mode 100644 index 000000000..9fc7964c0 --- /dev/null +++ b/channels/segmentation/segmentation.nim @@ -0,0 +1,70 @@ +## Segmentation component for the Reliable Channel API. +## +## Splits large application payloads into transmittable segments and +## reassembles them on reception. Supports optional Reed-Solomon parity +## segments for loss recovery, as per the Reliable Channel API spec. +## +## For the skeleton everything fits in a single segment: real chunking +## and Reed-Solomon parity will be plugged in later. +## +## See: https://lip.logos.co/messaging/raw/reliable-channel-api.html + +import std/options +import ./segment_message_proto +import ./segmentation_persistence + +export segment_message_proto, segmentation_persistence + +const + DefaultSegmentSizeBytes* = 102_400 + SegmentsParityRate* = 0.125 + SegmentsReedSolomonMaxCount* = 256 + +type + SegmentationConfig* = object + segmentSizeBytes*: int + enableReedSolomon*: bool + persistence*: SegmentationPersistence + + SegmentationHandler* = ref object + config*: SegmentationConfig + + ReassemblyResult* = object + payload*: seq[byte] + entireMessageHash*: seq[byte] + +proc new*(T: type SegmentationHandler, config: SegmentationConfig): T = + return T(config: config) + +proc performSegmentation*( + self: SegmentationHandler, payload: seq[byte] +): seq[seq[byte]] = + ## Skeleton behaviour: emit exactly one segment carrying the whole + ## payload. Real chunking and Reed-Solomon parity will replace this. + let segment = SegmentMessageProto( + entireMessageHash: @[], + dataSegmentIndex: 0, + dataSegmentCount: 1, + payload: payload, + paritySegmentIndex: 0, + paritySegmentCount: 0, + isParity: false, + ) + return @[segment.encode()] + +proc handleIncomingSegment*( + self: SegmentationHandler, segmentBytes: seq[byte] +): Option[ReassemblyResult] = + ## Skeleton behaviour: every segment is already a complete message + ## (since `performSegmentation` always emits one), so just hand the + ## payload straight back. + let segment = SegmentMessageProto.decode(segmentBytes) + return some( + ReassemblyResult( + payload: segment.payload, entireMessageHash: segment.entireMessageHash + ) + ) + +proc cleanupSegments*(self: SegmentationHandler) = + ## Drop expired partial-reassembly state. + discard diff --git a/channels/segmentation/segmentation_persistence.nim b/channels/segmentation/segmentation_persistence.nim new file mode 100644 index 000000000..cc34c36d2 --- /dev/null +++ b/channels/segmentation/segmentation_persistence.nim @@ -0,0 +1,20 @@ +## Persistence backend interface for segmentation reassembly state. +## +## Allows partial reassembly state to survive process restarts. + +type + SegmentationPersistenceKind* {.pure.} = enum + InMemory + Sqlite + + SegmentationPersistence* = ref object of RootObj + kind*: SegmentationPersistenceKind + +method put*(self: SegmentationPersistence, key: seq[byte], value: seq[byte]) {.base.} = + discard + +method get*(self: SegmentationPersistence, key: seq[byte]): seq[byte] {.base.} = + discard + +method delete*(self: SegmentationPersistence, key: seq[byte]) {.base.} = + discard diff --git a/channels/types.nim b/channels/types.nim new file mode 100644 index 000000000..4070ed620 --- /dev/null +++ b/channels/types.nim @@ -0,0 +1,15 @@ +## Core identifier types for the Reliable Channel API. + +import std/hashes +import waku/api/types as api_types + +import ./scalable_data_sync/scalable_data_sync + +export scalable_data_sync +export api_types + +type ChannelId* = SdsChannelID + +proc hash*(r: RequestId): Hash = + ## Allows `RequestId` to be used as a `Table` key. + hash(string(r)) diff --git a/config.nims b/config.nims index 0f6052c9b..ebe501db8 100644 --- a/config.nims +++ b/config.nims @@ -117,7 +117,7 @@ if defined(android): switch("passL", "--sysroot=" & sysRoot) switch("cincludes", sysRoot & "/usr/include/") # begin Nimble config (version 2) +--noNimblePath when withDir(thisDir(), system.fileExists("nimble.paths")): - --noNimblePath include "nimble.paths" # end Nimble config diff --git a/examples/api_example/api_example.nim b/examples/api_example/api_example.nim index 4a7cde5db..2093a81c0 100644 --- a/examples/api_example/api_example.nim +++ b/examples/api_example/api_example.nim @@ -33,9 +33,9 @@ proc periodicSender(w: Waku): Future[void] {.async.} = return defer: - MessageSentEvent.dropListener(sentListener) - MessageErrorEvent.dropListener(errorListener) - MessagePropagatedEvent.dropListener(propagatedListener) + await MessageSentEvent.dropListener(sentListener) + await MessageErrorEvent.dropListener(errorListener) + await MessagePropagatedEvent.dropListener(propagatedListener) ## Periodically sends a Waku message every 30 seconds var counter = 0 diff --git a/flake.lock b/flake.lock index 9b5db728d..411bf2430 100644 --- a/flake.lock +++ b/flake.lock @@ -19,8 +19,7 @@ "root": { "inputs": { "nixpkgs": "nixpkgs", - "rust-overlay": "rust-overlay", - "zerokit": "zerokit" + "rust-overlay": "rust-overlay" } }, "rust-overlay": { @@ -42,49 +41,6 @@ "repo": "rust-overlay", "type": "github" } - }, - "rust-overlay_2": { - "inputs": { - "nixpkgs": [ - "zerokit", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1771211437, - "narHash": "sha256-lcNK438i4DGtyA+bPXXyVLHVmJjYpVKmpux9WASa3ro=", - "owner": "oxalica", - "repo": "rust-overlay", - "rev": "c62195b3d6e1bb11e0c2fb2a494117d3b55d410f", - "type": "github" - }, - "original": { - "owner": "oxalica", - "repo": "rust-overlay", - "type": "github" - } - }, - "zerokit": { - "inputs": { - "nixpkgs": [ - "nixpkgs" - ], - "rust-overlay": "rust-overlay_2" - }, - "locked": { - "lastModified": 1771279884, - "narHash": "sha256-tzkQPwSl4vPTUo1ixHh6NCENjsBDroMKTjifg2q8QX8=", - "owner": "vacp2p", - "repo": "zerokit", - "rev": "53b18098e6d5d046e3eb1ac338a8f4f651432477", - "type": "github" - }, - "original": { - "owner": "vacp2p", - "repo": "zerokit", - "rev": "53b18098e6d5d046e3eb1ac338a8f4f651432477", - "type": "github" - } } }, "root": "root", diff --git a/flake.nix b/flake.nix index 50b6dc0b5..b32a53455 100644 --- a/flake.nix +++ b/flake.nix @@ -17,16 +17,9 @@ url = "github:oxalica/rust-overlay"; inputs.nixpkgs.follows = "nixpkgs"; }; - - # External flake input: Zerokit pinned to a specific commit. - # Update the rev here when a new zerokit version is needed. - zerokit = { - url = "github:vacp2p/zerokit/53b18098e6d5d046e3eb1ac338a8f4f651432477"; - inputs.nixpkgs.follows = "nixpkgs"; - }; }; - outputs = { self, nixpkgs, rust-overlay, zerokit }: + outputs = { self, nixpkgs, rust-overlay }: let systems = [ "x86_64-linux" "aarch64-linux" @@ -36,6 +29,20 @@ forAllSystems = nixpkgs.lib.genAttrs systems; + lib = nixpkgs.lib; + + # Single source of truth for the semver: the `version` field of + # waku.nimble. Kept in sync with git tags by the version-check CI. + nimbleVersion = + let line = lib.findFirst (l: lib.hasPrefix "version = " l) + "version = \"unknown\"" + (lib.splitString "\n" (builtins.readFile ./waku.nimble)); + in lib.removeSuffix "\"" (lib.removePrefix "version = \"" line); + + # A flake sandbox has no .git, so `git describe` is impossible; the + # commit comes from the flake metadata instead. + shortRev = self.shortRev or self.dirtyShortRev or "dirty"; + nimbleOverlay = final: prev: { nimble = prev.nimble.overrideAttrs (_: { version = "0.22.3"; @@ -52,17 +59,98 @@ inherit system; overlays = [ (import rust-overlay) nimbleOverlay ]; }; + + # Prebuilt zerokit librln, fetched from the upstream GitHub release + # rather than compiled from source. Compiling zerokit makes Nix download + # its many crate dependencies from crates.io in one parallel burst, which + # crates.io intermittently rejects with HTTP 403 (rate limiting from the + # self-hosted runners' shared IP), breaking the nix build. The release + # ships the exact `stateless` library this project links (see + # scripts/build_rln.sh), so we use it directly — no Rust toolchain and + # no crates.io access needed. + # + # Keep `rlnVersion` aligned with `LIBRLN_VERSION` in the Makefile and the + # vendor/zerokit submodule. Each hash is the sha256 of the release tarball + # for that platform; refresh all four when bumping the version. + rlnVersion = "v2.0.2"; + rlnAssets = { + "x86_64-linux" = { triple = "x86_64-unknown-linux-gnu"; hash = "sha256-qbrUdaetYKFhjzxUP/QcwD3JHWJ8qk/tCMK3yXceIAk="; }; + "aarch64-linux" = { triple = "aarch64-unknown-linux-gnu"; hash = "sha256-s4bWrmCcNTWHNyJwV73ilWNp58ZdAVG+TAgtWN1cTQs="; }; + "x86_64-darwin" = { triple = "x86_64-apple-darwin"; hash = "sha256-ZaHP5CApN66FYY7jxwOmGcF9kJR78Fng3k1qE2W08Mk="; }; + "aarch64-darwin" = { triple = "aarch64-apple-darwin"; hash = "sha256-f2YppkPsKFdN00j+IY8fpvsebWTIb9lW/V1/vOTiVKU="; }; + }; + + mkZerokitRln = system: pkgs: + let + asset = rlnAssets.${system} or + (throw "zerokit ${rlnVersion} has no prebuilt rln asset for system '${system}'"); + in pkgs.stdenv.mkDerivation { + pname = "librln"; + version = lib.removePrefix "v" rlnVersion; + + src = pkgs.fetchurl { + url = "https://github.com/vacp2p/zerokit/releases/download/" + + "${rlnVersion}/${asset.triple}-stateless-rln.tar.gz"; + hash = asset.hash; + }; + + # The tarball lays its files out under release/. + sourceRoot = "release"; + dontConfigure = true; + dontBuild = true; + + # The release .so was linked outside Nix, so it references system + # libraries (libgcc_s, libstdc++, glibc) by bare name. autoPatchelfHook + # points those at the Nix versions so the library loads correctly when + # used by the Nix build. It does nothing for the static .a, and the + # step is skipped on macOS (dylib paths are fixed in nix/default.nix). + nativeBuildInputs = + pkgs.lib.optionals pkgs.stdenv.isLinux [ pkgs.autoPatchelfHook ]; + buildInputs = + pkgs.lib.optionals pkgs.stdenv.isLinux [ pkgs.stdenv.cc.cc.lib ]; + + installPhase = '' + runHook preInstall + mkdir -p $out/lib + cp librln.a $out/lib/ 2>/dev/null || true + cp librln.so $out/lib/ 2>/dev/null || true + cp librln.dylib $out/lib/ 2>/dev/null || true + runHook postInstall + ''; + + meta = with pkgs.lib; { + description = "Prebuilt zerokit RLN library (stateless flavor)"; + homepage = "https://github.com/vacp2p/zerokit"; + license = with licenses; [ mit asl20 ]; + platforms = builtins.attrNames rlnAssets; + }; + }; in { packages = forAllSystems (system: let pkgs = pkgsFor system; + + zerokitRln = mkZerokitRln system pkgs; + liblogosdelivery = pkgs.callPackage ./nix/default.nix { inherit pkgs; src = ./.; - zerokitRln = zerokit.packages.${system}.rln; + inherit zerokitRln; + gitVersion = "v${nimbleVersion}-g${builtins.substring 0 6 shortRev}"; + }; + + wakucanary = pkgs.callPackage ./nix/default.nix { + inherit pkgs; + src = ./.; + targets = ["wakucanary"]; + inherit zerokitRln; }; in { - inherit liblogosdelivery; + inherit liblogosdelivery wakucanary; + # Expose the prebuilt librln so downstream consumers + # (e.g. logos-delivery-module) bundle the exact same librln this + # build links against. + rln = zerokitRln; default = liblogosdelivery; } ); diff --git a/liblogosdelivery/logos_delivery_api/node_api.nim b/liblogosdelivery/logos_delivery_api/node_api.nim index 72d1c594f..c06ba1b3f 100644 --- a/liblogosdelivery/logos_delivery_api/node_api.nim +++ b/liblogosdelivery/logos_delivery_api/node_api.nim @@ -136,11 +136,11 @@ proc logosdelivery_stop_node( requireInitializedNode(ctx, "STOP_NODE"): return err(errMsg) - MessageErrorEvent.dropAllListeners(ctx.myLib[].brokerCtx) - MessageSentEvent.dropAllListeners(ctx.myLib[].brokerCtx) - MessagePropagatedEvent.dropAllListeners(ctx.myLib[].brokerCtx) - MessageReceivedEvent.dropAllListeners(ctx.myLib[].brokerCtx) - EventConnectionStatusChange.dropAllListeners(ctx.myLib[].brokerCtx) + await MessageErrorEvent.dropAllListeners(ctx.myLib[].brokerCtx) + await MessageSentEvent.dropAllListeners(ctx.myLib[].brokerCtx) + await MessagePropagatedEvent.dropAllListeners(ctx.myLib[].brokerCtx) + await MessageReceivedEvent.dropAllListeners(ctx.myLib[].brokerCtx) + await EventConnectionStatusChange.dropAllListeners(ctx.myLib[].brokerCtx) (await ctx.myLib[].stop()).isOkOr: let errMsg = $error diff --git a/nimble.lock b/nimble.lock index 0a76565c4..cd533001e 100644 --- a/nimble.lock +++ b/nimble.lock @@ -250,7 +250,7 @@ }, "confutils": { "version": "0.1.0", - "vcsRevision": "7728f6bd81a1eedcfe277d02ea85fdb805bcc05a", + "vcsRevision": "36f3115ca350f40841ac0eecc7dfa5fe7790c864", "url": "https://github.com/status-im/nim-confutils", "downloadMethod": "git", "dependencies": [ @@ -260,7 +260,22 @@ "results" ], "checksums": { - "sha1": "8bc8c30b107fdba73b677e5f257c6c42ae1cdc8e" + "sha1": "2fbe6418ddd9f79fb11a0addd7666a3e787adbe0" + } + }, + "cbor_serialization": { + "version": "0.3.0", + "vcsRevision": "1664160e04d153573373afddc552b9cbf6fbe4dc", + "url": "https://github.com/vacp2p/nim-cbor-serialization", + "downloadMethod": "git", + "dependencies": [ + "nim", + "serialization", + "stew", + "results" + ], + "checksums": { + "sha1": "ab126eae09a6e39c72972a6a0b83cb06a2ffe8f0" } }, "json_serialization": { @@ -312,6 +327,23 @@ "sha1": "8df97c45683abe2337bdff43b844c4fbcc124ca2" } }, + "brokers": { + "version": "#v2.0.1", + "vcsRevision": "2093ca4d50e581adda73fee7fd16231f990f4cbe", + "url": "https://github.com/NagyZoltanPeter/nim-brokers.git", + "downloadMethod": "git", + "dependencies": [ + "nim", + "chronos", + "results", + "chronicles", + "testutils", + "cbor_serialization" + ], + "checksums": { + "sha1": "cc74c987af94537e9d44d1b0143aa417299040c5" + } + }, "stint": { "version": "0.8.2", "vcsRevision": "470b7892561b5179ab20bd389a69217d6213fe58", diff --git a/nix/default.nix b/nix/default.nix index a9ea0f598..ec9e0542c 100644 --- a/nix/default.nix +++ b/nix/default.nix @@ -1,6 +1,8 @@ { pkgs , src , zerokitRln +, targets ? [] +, gitVersion ? "n/a" , enablePostgres ? true , enableNimDebugDlOpen ? true , chroniclesLogLevel ? null @@ -9,8 +11,11 @@ let deps = import ./deps.nix { inherit pkgs; }; + buildWakucanary = builtins.elem "wakucanary" targets; + nimDefineArgs = pkgs.lib.concatStringsSep " \\\n " ( - [ "--define:disable_libbacktrace" ] + [ "--define:disable_libbacktrace" + "--define:git_version=${gitVersion}" ] ++ pkgs.lib.optional enablePostgres "--define:postgres" ++ pkgs.lib.optional enableNimDebugDlOpen "--define:nimDebugDlOpen" ++ pkgs.lib.optional (chroniclesLogLevel != null) @@ -32,9 +37,29 @@ let if pkgs.stdenv.hostPlatform.isWindows then "dll" else if pkgs.stdenv.hostPlatform.isDarwin then "dylib" else "so"; + + # Shared `nim c` invocation. Callers vary the output, the source file and a + # few mode-specific flags (e.g. --app:lib, --noMain, --header); everything + # else (paths, defines, threading, gc, nimcache, rln linkage) is constant. + # $NAT_TRAV and $NIMCACHE are shell variables defined in buildPhase. + nimCompile = { outFile, sourceFile, extraArgs ? [] }: '' + nim c \ + --noNimblePath \ + ${pathArgs} \ + --path:$NAT_TRAV \ + --path:$NAT_TRAV/src \ + --passL:"-L${zerokitRln}/lib -lrln${pkgs.lib.optionalString pkgs.stdenv.isLinux " -lstdc++"}" \ + ${nimDefineArgs} \ + --threads:on \ + --mm:refc \ + --nimcache:$NIMCACHE \ + --out:${outFile} \ + ${pkgs.lib.concatStringsSep " \\\n " extraArgs} \ + ${sourceFile} + ''; in pkgs.stdenv.mkDerivation { - pname = "liblogosdelivery"; + pname = if buildWakucanary then "wakucanary" else "liblogosdelivery"; version = "dev"; inherit src; @@ -69,45 +94,47 @@ pkgs.stdenv.mkDerivation { make -C $NAT_TRAV/vendor/libnatpmp-upstream \ CFLAGS="-Wall -Os -fPIC -DENABLE_STRNATPMPERR -DNATPMP_MAX_RETRIES=4" libnatpmp.a + ${if buildWakucanary then '' + echo "== Building wakucanary ==" + ${nimCompile { + outFile = "build/wakucanary"; + sourceFile = "apps/wakucanary/wakucanary.nim"; + extraArgs = [ "--path:." ]; + }} + '' else '' echo "== Building liblogosdelivery (dynamic) ==" - nim c \ - --noNimblePath \ - ${pathArgs} \ - --path:$NAT_TRAV \ - --path:$NAT_TRAV/src \ - --passL:"-L${zerokitRln}/lib -lrln${pkgs.lib.optionalString pkgs.stdenv.isLinux " -lstdc++"}" \ - ${nimDefineArgs} \ - --out:build/liblogosdelivery.${libExt} \ - --app:lib \ - --threads:on \ - --opt:size \ - --noMain \ - --mm:refc \ - --header \ - --nimMainPrefix:liblogosdelivery \ - --nimcache:$NIMCACHE \ - liblogosdelivery/liblogosdelivery.nim + ${nimCompile { + outFile = "build/liblogosdelivery.${libExt}"; + sourceFile = "liblogosdelivery/liblogosdelivery.nim"; + extraArgs = [ + "--app:lib" + "--opt:size" + "--noMain" + "--header" + "--nimMainPrefix:liblogosdelivery" + ]; + }} echo "== Building liblogosdelivery (static) ==" - nim c \ - --noNimblePath \ - ${pathArgs} \ - --path:$NAT_TRAV \ - --path:$NAT_TRAV/src \ - --passL:"-L${zerokitRln}/lib -lrln${pkgs.lib.optionalString pkgs.stdenv.isLinux " -lstdc++"}" \ - ${nimDefineArgs} \ - --out:build/liblogosdelivery.a \ - --app:staticlib \ - --threads:on \ - --opt:size \ - --noMain \ - --mm:refc \ - --nimMainPrefix:liblogosdelivery \ - --nimcache:$NIMCACHE \ - liblogosdelivery/liblogosdelivery.nim + ${nimCompile { + outFile = "build/liblogosdelivery.a"; + sourceFile = "liblogosdelivery/liblogosdelivery.nim"; + extraArgs = [ + "--app:staticlib" + "--opt:size" + "--noMain" + "--nimMainPrefix:liblogosdelivery" + ]; + }} + ''} ''; - installPhase = '' + installPhase = if buildWakucanary then '' + runHook preInstall + mkdir -p $out/bin $out/lib + cp build/wakucanary $out/bin/ + runHook postInstall + '' else '' runHook preInstall mkdir -p $out/lib $out/include cp build/liblogosdelivery.${libExt} $out/lib/ 2>/dev/null || true @@ -116,21 +143,47 @@ pkgs.stdenv.mkDerivation { runHook postInstall ''; - # Bundle librln alongside liblogosdelivery so the output is self-contained. + # Bundle librln alongside the produced artifact so the output is self-contained. # Use --add-rpath (not --set-rpath) so fixupPhase's stdenv RUNPATH injection # for libstdc++ is preserved. postInstall = - pkgs.lib.optionalString pkgs.stdenv.isDarwin '' - cp ${zerokitRln}/lib/librln.dylib $out/lib/ - chmod +w $out/lib/librln.dylib $out/lib/liblogosdelivery.dylib - install_name_tool -id @rpath/liblogosdelivery.dylib $out/lib/liblogosdelivery.dylib - install_name_tool -id @rpath/librln.dylib $out/lib/librln.dylib - old=$(otool -L $out/lib/liblogosdelivery.dylib | awk 'NR>1{print $1}' | grep librln) - install_name_tool -change "$old" @rpath/librln.dylib $out/lib/liblogosdelivery.dylib - install_name_tool -add_rpath @loader_path $out/lib/liblogosdelivery.dylib - '' - + pkgs.lib.optionalString pkgs.stdenv.isLinux '' - cp ${zerokitRln}/lib/librln.so $out/lib/ - patchelf --add-rpath '$ORIGIN' $out/lib/liblogosdelivery.so - ''; + if buildWakucanary then + pkgs.lib.optionalString pkgs.stdenv.isDarwin '' + cp ${zerokitRln}/lib/librln.dylib $out/lib/ + chmod +w $out/lib/librln.dylib $out/bin/wakucanary + install_name_tool -id @rpath/librln.dylib $out/lib/librln.dylib + old=$(otool -L $out/bin/wakucanary | awk 'NR>1{print $1}' | grep librln || true) + if [ -n "$old" ]; then + install_name_tool -change "$old" @rpath/librln.dylib $out/bin/wakucanary + fi + install_name_tool -add_rpath @loader_path/../lib $out/bin/wakucanary + '' + + pkgs.lib.optionalString pkgs.stdenv.isLinux '' + cp ${zerokitRln}/lib/librln.so $out/lib/ + patchelf --add-rpath '$ORIGIN/../lib' $out/bin/wakucanary + '' + else + pkgs.lib.optionalString pkgs.stdenv.isDarwin '' + cp ${zerokitRln}/lib/librln.dylib $out/lib/ + chmod +w $out/lib/librln.dylib $out/lib/liblogosdelivery.dylib + install_name_tool -id @rpath/liblogosdelivery.dylib $out/lib/liblogosdelivery.dylib + install_name_tool -id @rpath/librln.dylib $out/lib/librln.dylib + old=$(otool -L $out/lib/liblogosdelivery.dylib | awk 'NR>1{print $1}' | grep librln) + install_name_tool -change "$old" @rpath/librln.dylib $out/lib/liblogosdelivery.dylib + install_name_tool -add_rpath @loader_path $out/lib/liblogosdelivery.dylib + '' + + pkgs.lib.optionalString pkgs.stdenv.isLinux '' + cp ${zerokitRln}/lib/librln.so $out/lib/ + patchelf --add-rpath '$ORIGIN' $out/lib/liblogosdelivery.so + ''; + + meta = with pkgs.lib; { + description = + if buildWakucanary + then "Waku network canary tool" + else "logos-delivery shared/static library"; + homepage = "https://github.com/logos-messaging/logos-delivery"; + license = licenses.mit; + platforms = platforms.unix; + }; } diff --git a/nix/deps.nix b/nix/deps.nix index 0d9986528..8e4453675 100644 --- a/nix/deps.nix +++ b/nix/deps.nix @@ -124,8 +124,15 @@ confutils = pkgs.fetchgit { url = "https://github.com/status-im/nim-confutils"; - rev = "7728f6bd81a1eedcfe277d02ea85fdb805bcc05a"; - sha256 = "18bj1ilx10jm2vmqx2wy2xl9rzy7alymi2m4n9jgpa4sbxnfh0x3"; + rev = "36f3115ca350f40841ac0eecc7dfa5fe7790c864"; + sha256 = "1vppqplwlpl7a61r8iki5hlzvhd8lnq41ixpqslv35dnm482c55j"; + fetchSubmodules = true; + }; + + cbor_serialization = pkgs.fetchgit { + url = "https://github.com/vacp2p/nim-cbor-serialization"; + rev = "1664160e04d153573373afddc552b9cbf6fbe4dc"; + sha256 = "0c1rj4fk0fcqvsf0yqhxvm8h10aww75gi4yfsjhlczh88ypywii2"; fetchSubmodules = true; }; @@ -150,6 +157,13 @@ fetchSubmodules = true; }; + brokers = pkgs.fetchgit { + url = "https://github.com/NagyZoltanPeter/nim-brokers.git"; + rev = "2093ca4d50e581adda73fee7fd16231f990f4cbe"; + sha256 = "0a4ix2q6riqfrd0hfnajisy159qdmk5imwzymppj23rwc8n7d2dx"; + fetchSubmodules = true; + }; + stint = pkgs.fetchgit { url = "https://github.com/status-im/nim-stint"; rev = "470b7892561b5179ab20bd389a69217d6213fe58"; @@ -262,6 +276,13 @@ fetchSubmodules = true; }; + sds = pkgs.fetchgit { + url = "https://github.com/logos-messaging/nim-sds.git"; + rev = "2e9a7683f0e180bf112135fae3a3803eed8490d4"; + sha256 = "1dbpvp3zhvdlfxdyggz5waga1vg3b6ndd3acfzhnx8k1wdr01c6f"; + fetchSubmodules = true; + }; + ffi = pkgs.fetchgit { url = "https://github.com/logos-messaging/nim-ffi"; rev = "06111de155253b34e47ed2aaed1d61d08d62cc1b"; diff --git a/scripts/build_rln.sh b/scripts/build_rln.sh index b36ebe807..b028885e2 100755 --- a/scripts/build_rln.sh +++ b/scripts/build_rln.sh @@ -1,8 +1,15 @@ #!/usr/bin/env bash -# This script is used to build the rln library for the current platform. -# Previously downloaded prebuilt binaries, but due to compatibility issues -# we now always build from source. +# Provides the rln static library for the current platform. +# +# If zerokit publishes a prebuilt `stateless` release asset for this platform, +# download and use it: that is faster than compiling and avoids fetching +# zerokit's many crate dependencies from crates.io. The asset is selected by +# the Rust host target triple (the platform identifier reported by rustc, +# e.g. x86_64-unknown-linux-gnu or aarch64-apple-darwin). +# +# When no matching asset exists (e.g. Windows), build from the vendored +# zerokit submodule instead. set -e @@ -15,8 +22,26 @@ output_filename=$3 [[ -z "${rln_version}" ]] && { echo "No rln version specified"; exit 1; } [[ -z "${output_filename}" ]] && { echo "No output filename specified"; exit 1; } -echo "Building RLN library from source (version ${rln_version})..." +# --- Prefer the prebuilt release asset -------------------------------------- +# Host target triple, e.g. x86_64-unknown-linux-gnu / aarch64-apple-darwin. +host_triplet=$(rustc --version --verbose | awk '/host:/{print $2}') +tarball="${host_triplet}-stateless-rln.tar.gz" +url="https://github.com/vacp2p/zerokit/releases/download/${rln_version}/${tarball}" +echo "Looking for prebuilt RLN: ${url}" +if curl --silent --fail-with-body -L "${url}" -o "${tarball}"; then + echo "Downloaded prebuilt ${tarball}" + tar -xzf "${tarball}" + mv "release/librln.a" "${output_filename}" + rm -rf "${tarball}" release + echo "Using prebuilt ${output_filename}" + exit 0 +fi +# curl --fail-with-body writes the error body to the file on HTTP failure. +rm -f "${tarball}" +echo "No prebuilt asset for ${host_triplet} at ${rln_version}; building from source." + +# --- Fall back to building from the vendored submodule ---------------------- # Check if submodule version = version in Makefile cargo metadata --format-version=1 --no-deps --manifest-path "${build_dir}/rln/Cargo.toml" @@ -33,8 +58,15 @@ if [[ "v${submodule_version}" != "${rln_version}" ]]; then exit 1 fi -# Build rln from source -cargo build --release -p rln --manifest-path "${build_dir}/rln/Cargo.toml" +# `stateless` feature: logos-delivery does not maintain a local Merkle tree +# (post-PR #3312); the contract is the source of truth and the path is fetched +# via getMerkleProof(index). The stateless build compiles out tree code. +# +# --no-default-features is required because zerokit's default features include +# `pmtree-ft` (a Merkle tree backend); `stateless` and any Merkle-tree feature +# are mutually exclusive (rln/src/lib.rs:32 compile_error). +cargo build --release -p rln --manifest-path "${build_dir}/rln/Cargo.toml" \ + --no-default-features --features stateless cp "${build_dir}/target/release/librln.a" "${output_filename}" echo "Successfully built ${output_filename}" diff --git a/scripts/install_nim.sh b/scripts/install_nim.sh index c8d0f439d..42aa88ecd 100755 --- a/scripts/install_nim.sh +++ b/scripts/install_nim.sh @@ -17,26 +17,36 @@ if [ -z "${NIM_VERSION}" ]; then exit 1 fi -# Check if the right version is already installed +NIM_DEST="${HOME}/.nim/nim-${NIM_VERSION}" + +# 1. A matching Nim is already on PATH (e.g. provided by CI's setup-nim-action, +# choosenim, or a previous run of this script). Use it as-is: installing over it +# would symlink a freshly downloaded Nim into ~/.nimble/bin (first on PATH) and +# shadow a known-good toolchain, which has caused C-backend build failures. nim_ver=$(nim --version 2>/dev/null | head -1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true) if [ "${nim_ver}" = "${NIM_VERSION}" ]; then - echo "Nim ${NIM_VERSION} already installed, skipping." + echo "Nim ${NIM_VERSION} already on PATH ($(command -v nim)), skipping install." + exit 0 +fi + +# 2. Already installed at our expected location from a previous run, but not on PATH. +# Re-link binaries into ~/.nimble/bin. +if [ -f "${NIM_DEST}/lib/system.nim" ]; then + echo "Nim ${NIM_VERSION} already installed at ${NIM_DEST}, re-linking binaries." + mkdir -p "${HOME}/.nimble/bin" + for bin_path in "${NIM_DEST}/bin/"*; do + ln -sf "${bin_path}" "${HOME}/.nimble/bin/$(basename "${bin_path}")" + done exit 0 fi if [ -n "${nim_ver}" ]; then - newer=$(printf '%s\n%s\n' "${NIM_VERSION}" "${nim_ver}" | sort -V | tail -1) - if [ "${newer}" = "${nim_ver}" ]; then - echo "WARNING: Nim ${nim_ver} is installed; this repo is validated against ${NIM_VERSION}." >&2 - echo "WARNING: The build will proceed but may behave differently." >&2 - exit 0 - fi + echo "INFO: Nim ${nim_ver} found in PATH; installing Nim ${NIM_VERSION} to ${NIM_DEST}." >&2 fi OS=$(uname -s | tr 'A-Z' 'a-z' | sed 's/darwin/macosx/') ARCH=$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/') -NIM_DEST="${HOME}/.nim/nim-${NIM_VERSION}" BINARY_URL="https://nim-lang.org/download/nim-${NIM_VERSION}-${OS}_${ARCH}.tar.xz" WORK_DIR=$(mktemp -d) trap 'rm -rf "${WORK_DIR}"' EXIT @@ -48,9 +58,7 @@ if [ "${HTTP_STATUS}" = "200" ]; then echo "Downloading pre-built binary from ${BINARY_URL}..." curl -fL "${BINARY_URL}" -o "${WORK_DIR}/nim.tar.xz" tar -xJf "${WORK_DIR}/nim.tar.xz" -C "${WORK_DIR}" - rm -rf "${NIM_DEST}" - mkdir -p "${HOME}/.nim" - cp -r "${WORK_DIR}/nim-${NIM_VERSION}" "${NIM_DEST}" + SRC_DIR="${WORK_DIR}/nim-${NIM_VERSION}" else echo "No pre-built binary found for ${OS}_${ARCH}. Building from source..." SRC_URL="https://github.com/nim-lang/Nim/archive/refs/tags/v${NIM_VERSION}.tar.gz" @@ -58,15 +66,19 @@ else tar -xzf "${WORK_DIR}/nim-src.tar.gz" -C "${WORK_DIR}" cd "${WORK_DIR}/Nim-${NIM_VERSION}" sh build_all.sh - rm -rf "${NIM_DEST}" - mkdir -p "${HOME}/.nim" - cp -r "${WORK_DIR}/Nim-${NIM_VERSION}" "${NIM_DEST}" + SRC_DIR="${WORK_DIR}/Nim-${NIM_VERSION}" fi +# rm -rf can fail with "Directory not empty" on overlay filesystems (e.g. Docker). +# Using cp -r src/. dst/ handles both cases: dst absent (clean) or partially present. +rm -rf "${NIM_DEST}" 2>/dev/null || true +mkdir -p "${NIM_DEST}" +cp -r "${SRC_DIR}/." "${NIM_DEST}/" + mkdir -p "${HOME}/.nimble/bin" for bin_path in "${NIM_DEST}/bin/"*; do ln -sf "${bin_path}" "${HOME}/.nimble/bin/$(basename "${bin_path}")" done echo "Nim ${NIM_VERSION} installed to ${NIM_DEST}" -echo "Binaries symlinked in ~/.nimble/bin — ensure it is in your PATH." +echo "Binaries symlinked in ~/.nimble/bin — ensure it is in your PATH." \ No newline at end of file diff --git a/scripts/install_nimble.sh b/scripts/install_nimble.sh new file mode 100755 index 000000000..dba2d6612 --- /dev/null +++ b/scripts/install_nimble.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Installs a specific nimble version without using `nimble install nimble`. +# +# `nimble install nimble` is inherently fragile: +# - ETXTBSY: overwriting the running nimble binary in pkgs2/ +# - JSON parse failures with older nimble versions reading packages_official.json +# +# Strategy: +# 1. If the right version is already at ~/.nimble/bin/nimble → done. +# 2. If a previously-compiled binary exists in pkgs2/ → re-link it. +# 3. Otherwise: clone the nimble git repo, init submodules, build with nim, +# and atomically replace the target (mv avoids ETXTBSY on the old binary). + +set -e + +NIMBLE_VERSION="${1:-}" +if [ -z "${NIMBLE_VERSION}" ]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +NIMBLE_BIN="${HOME}/.nimble/bin/nimble" + +# 1. Already installed at the right version? +if [ -x "${NIMBLE_BIN}" ]; then + nimble_ver=$("${NIMBLE_BIN}" --version 2>/dev/null \ + | head -1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true) + if [ "${nimble_ver}" = "${NIMBLE_VERSION}" ]; then + echo "Nimble ${NIMBLE_VERSION} already installed, skipping." + exit 0 + fi +fi + +# 2. Already compiled into pkgs2/ from a previous (possibly partial) run? +PKGS2_NIMBLE=$(ls -dt "${HOME}/.nimble/pkgs2/nimble-${NIMBLE_VERSION}-"*/nimble \ + 2>/dev/null | head -1 || true) +if [ -n "${PKGS2_NIMBLE}" ] && [ -x "${PKGS2_NIMBLE}" ]; then + echo "Nimble ${NIMBLE_VERSION} found in pkgs2, re-linking to ${NIMBLE_BIN}." + mkdir -p "${HOME}/.nimble/bin" + ln -sf "${PKGS2_NIMBLE}" "${NIMBLE_BIN}" + exit 0 +fi + +# 3. Build from source. +NIM_BIN="${HOME}/.nimble/bin/nim" +if [ ! -x "${NIM_BIN}" ]; then + NIM_BIN="$(command -v nim)" +fi + +WORK_DIR="$(mktemp -d)" +trap 'rm -rf "${WORK_DIR}"' EXIT + +echo "Cloning nimble v${NIMBLE_VERSION} with submodules..." +git clone --depth=1 --branch "v${NIMBLE_VERSION}" \ + --recurse-submodules --shallow-submodules \ + https://github.com/nim-lang/nimble.git \ + "${WORK_DIR}/nimble" + +echo "Building nimble ${NIMBLE_VERSION} with $("${NIM_BIN}" --version | head -1)..." +cd "${WORK_DIR}/nimble" +# nim reads nim.cfg / config.nims in the current dir, which sets vendor paths. +"${NIM_BIN}" c -d:release --path:src \ + -o:"${WORK_DIR}/nimble_new" src/nimble.nim + +mkdir -p "${HOME}/.nimble/bin" +# Atomic rename: avoids ETXTBSY when the old binary at NIMBLE_BIN is still running. +cp "${WORK_DIR}/nimble_new" "${NIMBLE_BIN}.new.$$" +mv -f "${NIMBLE_BIN}.new.$$" "${NIMBLE_BIN}" + +echo "Nimble ${NIMBLE_VERSION} installed to ${NIMBLE_BIN}" \ No newline at end of file diff --git a/tests/all_tests_waku.nim b/tests/all_tests_waku.nim index 879b1a55a..963a948a3 100644 --- a/tests/all_tests_waku.nim +++ b/tests/all_tests_waku.nim @@ -85,3 +85,9 @@ import ./api/test_all # Waku tools tests import ./tools/test_all + +# Persistency library tests +import ./persistency/test_all + +# Reliable Channel API tests +import ./channels/test_all diff --git a/tests/api/test_api_health.nim b/tests/api/test_api_health.nim index a83263492..907d8e075 100644 --- a/tests/api/test_api_health.nim +++ b/tests/api/test_api_health.nim @@ -2,11 +2,12 @@ import std/[options, sequtils, times] import chronos, testutils/unittests, stew/byteutils, libp2p/[switch, peerinfo] +import brokers/broker_context import ../testlib/[common, wakucore, wakunode, testasync] import waku, - waku/[waku_node, waku_core, waku_relay/protocol, common/broker/broker_context], + waku/[waku_node, waku_core, waku_relay/protocol], waku/node/health_monitor/[topic_health, health_status, protocol_health, health_report], waku/requests/health_requests, waku/requests/node_requests, @@ -43,7 +44,7 @@ proc waitForConnectionStatus( if not await future.withTimeout(TestTimeout): raiseAssert "Timeout waiting for status: " & $expected finally: - EventConnectionStatusChange.dropListener(brokerCtx, handle) + await EventConnectionStatusChange.dropListener(brokerCtx, handle) proc waitForShardHealthy( brokerCtx: BrokerContext @@ -67,7 +68,7 @@ proc waitForShardHealthy( else: raiseAssert "Timeout waiting for shard health event" finally: - EventShardTopicHealthChange.dropListener(brokerCtx, handle) + await EventShardTopicHealthChange.dropListener(brokerCtx, handle) suite "LM API health checking": var diff --git a/tests/api/test_api_receive.nim b/tests/api/test_api_receive.nim index e1bf155f3..08ae07eaf 100644 --- a/tests/api/test_api_receive.nim +++ b/tests/api/test_api_receive.nim @@ -3,6 +3,7 @@ import std/[options, sequtils, net, sets] import chronos, testutils/unittests, stew/byteutils import libp2p/[peerid, peerinfo, crypto/crypto] +import brokers/broker_context import ../testlib/[common, wakucore, wakunode, testasync] import ../waku_archive/archive_utils @@ -11,7 +12,6 @@ import waku/[ waku_node, waku_core, - common/broker/broker_context, events/message_events, waku_relay/protocol, waku_archive, @@ -52,8 +52,8 @@ proc newReceiveEventListenerManager( return manager -proc teardown(manager: ReceiveEventListenerManager) = - MessageReceivedEvent.dropListener(manager.brokerCtx, manager.receivedListener) +proc teardown(manager: ReceiveEventListenerManager) {.async.} = + await MessageReceivedEvent.dropListener(manager.brokerCtx, manager.receivedListener) proc waitForEvents( manager: ReceiveEventListenerManager, timeout: Duration @@ -138,7 +138,20 @@ suite "Messaging API, Receive Service (store recovery)": break await sleepAsync(100.milliseconds) - # publish before subscriber exists, gets archived + # create the subscriber before publishing. + # RecvService captures startTimeToCheck at construction time; the + # message's timestamp must land after that point to fall inside + # checkStore's time window. + var subscriber: Waku + lockNewGlobalBrokerContext: + subscriber = (await createNode(createApiNodeConf(numShards))).expect( + "Failed to create subscriber" + ) + (await startWaku(addr subscriber)).expect("Failed to start subscriber") + + # publish after the subscriber exists but before it connects to the + # store; the message reaches the archive but the subscriber doesn't + # see it via live relay. let missedPayload = "This message was missed".toBytes() let missedMsg = WakuMessage( payload: missedPayload, contentTopic: testTopic, version: 0, timestamp: now() @@ -159,15 +172,8 @@ suite "Messaging API, Receive Service (store recovery)": await sleepAsync(100.milliseconds) raiseAssert "Message was not archived in time" - # create subscriber - var subscriber: Waku - lockNewGlobalBrokerContext: - subscriber = (await createNode(createApiNodeConf(numShards))).expect( - "Failed to create subscriber" - ) - (await startWaku(addr subscriber)).expect("Failed to start subscriber") - - # connect subscriber to store (not publisher, so msg won't come via relay to it) + # connect subscriber to store after the message is already archived so + # gossipsub doesn't replay it via the live path await subscriber.node.connectToNodes(@[storeNodePeerInfo]) # subscribe to content topic @@ -176,7 +182,7 @@ suite "Messaging API, Receive Service (store recovery)": # listen before triggering store check let eventManager = newReceiveEventListenerManager(subscriber.brokerCtx, 1) defer: - eventManager.teardown() + await eventManager.teardown() # trigger store check, should recover and deliver via MessageReceivedEvent await subscriber.deliveryService.recvService.checkStore() diff --git a/tests/api/test_api_send.nim b/tests/api/test_api_send.nim index 43efe9555..989454030 100644 --- a/tests/api/test_api_send.nim +++ b/tests/api/test_api_send.nim @@ -2,10 +2,10 @@ import std/strutils import chronos, testutils/unittests, stew/byteutils, libp2p/[switch, peerinfo] +import brokers/broker_context import ../testlib/[common, wakucore, wakunode, testasync] import ../waku_archive/archive_utils -import - waku, waku/[waku_node, waku_core, waku_relay/protocol, common/broker/broker_context] +import waku, waku/[waku_node, waku_core, waku_relay/protocol] import waku/factory/waku_conf import tools/confutils/cli_args @@ -77,10 +77,12 @@ proc newSendEventListenerManager(brokerCtx: BrokerContext): SendEventListenerMan return manager -proc teardown(manager: SendEventListenerManager) = - MessageSentEvent.dropListener(manager.brokerCtx, manager.sentListener) - MessageErrorEvent.dropListener(manager.brokerCtx, manager.errorListener) - MessagePropagatedEvent.dropListener(manager.brokerCtx, manager.propagatedListener) +proc teardown(manager: SendEventListenerManager) {.async.} = + await MessageSentEvent.dropListener(manager.brokerCtx, manager.sentListener) + await MessageErrorEvent.dropListener(manager.brokerCtx, manager.errorListener) + await MessagePropagatedEvent.dropListener( + manager.brokerCtx, manager.propagatedListener + ) proc waitForEvents( manager: SendEventListenerManager, timeout: Duration @@ -270,7 +272,7 @@ suite "Waku API - Send": let eventManager = newSendEventListenerManager(node.brokerCtx) defer: - eventManager.teardown() + await eventManager.teardown() let envelope = MessageEnvelope.init( ContentTopic("/waku/2/default-content/proto"), "test payload" @@ -302,7 +304,7 @@ suite "Waku API - Send": let eventManager = newSendEventListenerManager(node.brokerCtx) defer: - eventManager.teardown() + await eventManager.teardown() let envelope = MessageEnvelope.init( ContentTopic("/waku/2/default-content/proto"), "test payload" @@ -332,7 +334,7 @@ suite "Waku API - Send": let eventManager = newSendEventListenerManager(node.brokerCtx) defer: - eventManager.teardown() + await eventManager.teardown() let envelope = MessageEnvelope.init( ContentTopic("/waku/2/default-content/proto"), "test payload" @@ -362,7 +364,7 @@ suite "Waku API - Send": let eventManager = newSendEventListenerManager(node.brokerCtx) defer: - eventManager.teardown() + await eventManager.teardown() let envelope = MessageEnvelope.init( ContentTopic("/waku/2/default-content/proto"), "test payload" @@ -416,7 +418,7 @@ suite "Waku API - Send": let eventManager = newSendEventListenerManager(node.brokerCtx) defer: - eventManager.teardown() + await eventManager.teardown() let envelope = MessageEnvelope.init( ContentTopic("/waku/2/default-content/proto"), "test payload" diff --git a/tests/api/test_api_subscription.nim b/tests/api/test_api_subscription.nim index f8f40218e..d6723a862 100644 --- a/tests/api/test_api_subscription.nim +++ b/tests/api/test_api_subscription.nim @@ -3,6 +3,7 @@ import std/[strutils, sequtils, net, options, sets, tables] import chronos, testutils/unittests, stew/byteutils import libp2p/[peerid, peerinfo, multiaddress, crypto/crypto] +import brokers/broker_context import ../testlib/[common, wakucore, wakunode, testasync] import @@ -10,7 +11,6 @@ import waku/[ waku_node, waku_core, - common/broker/broker_context, events/message_events, waku_relay/protocol, node/kernel_api/filter, @@ -51,8 +51,8 @@ proc newReceiveEventListenerManager( return manager -proc teardown(manager: ReceiveEventListenerManager) = - MessageReceivedEvent.dropListener(manager.brokerCtx, manager.receivedListener) +proc teardown(manager: ReceiveEventListenerManager) {.async.} = + await MessageReceivedEvent.dropListener(manager.brokerCtx, manager.receivedListener) proc waitForEvents( manager: ReceiveEventListenerManager, timeout: Duration @@ -208,7 +208,7 @@ suite "Messaging API, SubscriptionManager": let eventManager = newReceiveEventListenerManager(net.subscriber.brokerCtx, 1) defer: - eventManager.teardown() + await eventManager.teardown() discard (await net.publishToMesh(testTopic, "Hello, world!".toBytes())).expect( "Publish failed" @@ -229,7 +229,7 @@ suite "Messaging API, SubscriptionManager": let eventManager = newReceiveEventListenerManager(net.subscriber.brokerCtx, 1) defer: - eventManager.teardown() + await eventManager.teardown() discard (await net.publishToMesh(ignoredTopic, "Ghost Msg".toBytes())).expect( "Publish failed" @@ -250,7 +250,7 @@ suite "Messaging API, SubscriptionManager": let eventManager = newReceiveEventListenerManager(net.subscriber.brokerCtx, 1) defer: - eventManager.teardown() + await eventManager.teardown() discard (await net.publishToMesh(testTopic, "Should be dropped".toBytes())).expect( "Publish failed" @@ -271,7 +271,7 @@ suite "Messaging API, SubscriptionManager": let eventManager = newReceiveEventListenerManager(net.subscriber.brokerCtx, 1) defer: - eventManager.teardown() + await eventManager.teardown() net.subscriber.unsubscribe(topicA).expect("failed to unsub A") @@ -298,7 +298,7 @@ suite "Messaging API, SubscriptionManager": let eventManager = newReceiveEventListenerManager(net.subscriber.brokerCtx, 1) defer: - eventManager.teardown() + await eventManager.teardown() discard (await net.publishToMesh(glitchTopic, "Ghost Msg".toBytes())).expect( "Publish failed" @@ -322,7 +322,7 @@ suite "Messaging API, SubscriptionManager": (await net.publishToMesh(testTopic, "Msg 1".toBytes())).expect("Pub 1 failed") require await eventManager.waitForEvents(TestTimeout) - eventManager.teardown() + await eventManager.teardown() # Unsubscribe and verify teardown net.subscriber.unsubscribe(testTopic).expect("Unsub failed") @@ -332,7 +332,7 @@ suite "Messaging API, SubscriptionManager": (await net.publishToMesh(testTopic, "Ghost".toBytes())).expect("Ghost pub failed") check not await eventManager.waitForEvents(NegativeTestTimeout) - eventManager.teardown() + await eventManager.teardown() # Resubscribe (await net.subscriber.subscribe(testTopic)).expect("Resub failed") @@ -364,7 +364,7 @@ suite "Messaging API, SubscriptionManager": let eventManager = newReceiveEventListenerManager(net.subscriber.brokerCtx, 2) defer: - eventManager.teardown() + await eventManager.teardown() discard (await net.publishToMesh(topicA, "Msg on Shard A".toBytes())).expect( "Publish A failed" @@ -400,7 +400,7 @@ suite "Messaging API, SubscriptionManager": # here we just give a chance for any messages that we don't expect to arrive await sleepAsync(1.seconds) - eventManager.teardown() + await eventManager.teardown() # weak check (but catches most bugs) require eventManager.receivedMessages.len == expected.len @@ -451,7 +451,7 @@ suite "Messaging API, SubscriptionManager": let eventManager = newReceiveEventListenerManager(net.subscriber.brokerCtx, 1) defer: - eventManager.teardown() + await eventManager.teardown() discard (await net.publishToMeshAfterEdgeReady(testTopic, "Hello, edge!".toBytes())).expect( "Publish failed" @@ -472,7 +472,7 @@ suite "Messaging API, SubscriptionManager": let eventManager = newReceiveEventListenerManager(net.subscriber.brokerCtx, 1) defer: - eventManager.teardown() + await eventManager.teardown() discard (await net.publishToMesh(ignoredTopic, "Ghost Msg".toBytes())).expect( "Publish failed" @@ -493,7 +493,7 @@ suite "Messaging API, SubscriptionManager": let eventManager = newReceiveEventListenerManager(net.subscriber.brokerCtx, 1) defer: - eventManager.teardown() + await eventManager.teardown() discard (await net.publishToMesh(testTopic, "Should be dropped".toBytes())).expect( "Publish failed" @@ -517,7 +517,7 @@ suite "Messaging API, SubscriptionManager": let eventManager = newReceiveEventListenerManager(net.subscriber.brokerCtx, 1) defer: - eventManager.teardown() + await eventManager.teardown() net.subscriber.unsubscribe(topicA).expect("failed to unsub A") @@ -546,7 +546,7 @@ suite "Messaging API, SubscriptionManager": ) require await eventManager.waitForEvents(TestTimeout) - eventManager.teardown() + await eventManager.teardown() net.subscriber.unsubscribe(testTopic).expect("Unsub failed") eventManager = newReceiveEventListenerManager(net.subscriber.brokerCtx, 1) @@ -555,7 +555,7 @@ suite "Messaging API, SubscriptionManager": (await net.publishToMesh(testTopic, "Ghost".toBytes())).expect("Ghost pub failed") check not await eventManager.waitForEvents(NegativeTestTimeout) - eventManager.teardown() + await eventManager.teardown() (await net.subscriber.subscribe(testTopic)).expect("Resub failed") eventManager = newReceiveEventListenerManager(net.subscriber.brokerCtx, 1) @@ -653,7 +653,7 @@ suite "Messaging API, SubscriptionManager": require await eventManager.waitForEvents(TestTimeout) check eventManager.receivedMessages[0].payload == "Before failover".toBytes() - eventManager.teardown() + await eventManager.teardown() # Disconnect meshBuddy from edge (keeps relay mesh alive for publishing) await subscriber.node.disconnectNode(meshBuddyPeerInfo) @@ -678,7 +678,7 @@ suite "Messaging API, SubscriptionManager": require await eventManager.waitForEvents(TestTimeout) check eventManager.receivedMessages[0].payload == "After failover".toBytes() - eventManager.teardown() + await eventManager.teardown() (await subscriber.stop()).expect("Failed to stop subscriber") await meshBuddy.stop() @@ -801,7 +801,7 @@ suite "Messaging API, SubscriptionManager": require await eventManager.waitForEvents(TestTimeout) check eventManager.receivedMessages[0].payload == "After replacement".toBytes() - eventManager.teardown() + await eventManager.teardown() (await subscriber.stop()).expect("Failed to stop subscriber") await sparePeer.stop() diff --git a/tests/api/test_node_conf.nim b/tests/api/test_node_conf.nim index a29d9c96f..fb8d97708 100644 --- a/tests/api/test_node_conf.nim +++ b/tests/api/test_node_conf.nim @@ -219,6 +219,22 @@ suite "WakuNodeConf - preset integration": check: wakuConf.clusterId == 2 + test "LogosTest preset applies LogosTestConf": + ## Given + var conf = defaultWakuNodeConf().valueOr: + raiseAssert error + conf.preset = "logostest" + + ## When + let wakuConfRes = conf.toWakuConf() + + ## Then + require wakuConfRes.isOk() + let wakuConf = wakuConfRes.get() + require wakuConf.validate().isOk() + check: + wakuConf.clusterId == 2 + test "Invalid preset returns error": ## Given var conf = defaultWakuNodeConf().valueOr: diff --git a/tests/channels/test_all.nim b/tests/channels/test_all.nim new file mode 100644 index 000000000..04b448707 --- /dev/null +++ b/tests/channels/test_all.nim @@ -0,0 +1,3 @@ +{.used.} + +import ./test_reliable_channel_send_receive diff --git a/tests/channels/test_reliable_channel_send_receive.nim b/tests/channels/test_reliable_channel_send_receive.nim new file mode 100644 index 000000000..2f49182a2 --- /dev/null +++ b/tests/channels/test_reliable_channel_send_receive.nim @@ -0,0 +1,317 @@ +{.used.} + +import std/[net] +import chronos, testutils/unittests, stew/byteutils +import brokers/broker_context + +import ../testlib/[common, wakucore, wakunode, testasync] + +import waku +import waku/[waku_node, waku_core] +import waku/factory/waku_conf +import waku/events/message_events as waku_message_events +import tools/confutils/cli_args + +import channels/reliable_channel_manager +import channels/encryption/noop_encryption + +const TestTimeout = chronos.seconds(15) + +proc createApiNodeConf(): WakuNodeConf = + var conf = defaultWakuNodeConf().valueOr: + raiseAssert error + conf.mode = cli_args.WakuMode.Core + conf.listenAddress = parseIpAddress("0.0.0.0") + conf.tcpPort = Port(0) + conf.discv5UdpPort = Port(0) + conf.clusterId = 3'u16 + conf.numShardsInNetwork = 1 + conf.reliabilityEnabled = true + conf.rest = false + return conf + +suite "Reliable Channel - ingress": + asyncTest "manager dispatches marked WakuMessage to the right channel": + ## Unit test for the receive side of the API: instead of standing + ## up two libp2p nodes and a relay mesh, we drive the manager + ## directly by emitting a `MessageReceivedEvent` (the exact event + ## the DeliveryService emits when a `WakuMessage` arrives off the + ## wire). The manager must: + ## - drop traffic missing the Reliable Channel spec marker + ## - dispatch the matching channel's `onMessageReceived` + ## - emit `ChannelMessageReceivedEvent` with the payload + const + channelId = ChannelId("test-channel") + contentTopic = ContentTopic("/reliable-channel/test/proto") + let appPayload = "hello reliable channel".toBytes() + + var manager: ReliableChannelManager + var brokerCtx: BrokerContext + lockNewGlobalBrokerContext: + brokerCtx = globalBrokerContext() + manager = (await ReliableChannelManager.new(createApiNodeConf())).expect( + "Failed to create manager" + ) + + ## Noop encryption providers so the Encrypt/Decrypt brokers have + ## something to dispatch to; without this the channel falls back to + ## plaintext anyway, but installing them is the documented setup. + setNoopEncryption() + + discard manager + .createReliableChannel(channelId, contentTopic, SdsParticipantID("local")) + .expect("createReliableChannel") + + let received = newFuture[seq[byte]]("channel-message-received") + discard ChannelMessageReceivedEvent + .listen( + brokerCtx, + proc(evt: ChannelMessageReceivedEvent) {.async: (raises: []).} = + if not received.finished() and evt.channelId == channelId: + received.complete(evt.payload) + , + ) + .expect("listen ChannelMessageReceivedEvent") + + ## Build a `WakuMessage` that looks like one that came in off the + ## wire from a peer: the spec marker on `meta` plus the right content + ## topic. The manager's ingress listener should pick it up, + ## decrypt (noop), unwrap SDS (pass-through), reassemble (one + ## segment), and finally emit `ChannelMessageReceivedEvent`. + let inboundMsg = WakuMessage( + payload: appPayload, + contentTopic: contentTopic, + version: 0, + meta: LipWireReliableChannelVersion.toBytes(), + ) + + waku_message_events.MessageReceivedEvent.emit( + brokerCtx, + waku_message_events.MessageReceivedEvent(messageHash: "", message: inboundMsg), + ) + + let arrived = await received.withTimeout(TestTimeout) + check arrived + if arrived: + check received.read() == appPayload + + await manager.stop() + + asyncTest "manager drops unmarked WakuMessage": + ## Mirror of the above: same content topic, but `meta` is empty + ## (i.e. foreign traffic). The channel-level event must NOT fire. + const + channelId = ChannelId("test-channel-2") + contentTopic = ContentTopic("/reliable-channel/test/proto") + let appPayload = "foreign payload".toBytes() + + var manager: ReliableChannelManager + var brokerCtx: BrokerContext + lockNewGlobalBrokerContext: + brokerCtx = globalBrokerContext() + manager = (await ReliableChannelManager.new(createApiNodeConf())).expect( + "Failed to create manager" + ) + + setNoopEncryption() + + discard manager + .createReliableChannel(channelId, contentTopic, SdsParticipantID("local")) + .expect("createReliableChannel") + + var fired = false + discard ChannelMessageReceivedEvent + .listen( + brokerCtx, + proc(evt: ChannelMessageReceivedEvent) {.async: (raises: []).} = + if evt.channelId == channelId: + fired = true + , + ) + .expect("listen ChannelMessageReceivedEvent") + + let inboundMsg = WakuMessage( + payload: appPayload, + contentTopic: contentTopic, + version: 0, + meta: @[], ## no Reliable Channel spec marker + ) + + waku_message_events.MessageReceivedEvent.emit( + brokerCtx, + waku_message_events.MessageReceivedEvent(messageHash: "", message: inboundMsg), + ) + + ## Give the event broker a chance to fan out. + await sleepAsync(100.milliseconds) + check not fired + + await manager.stop() + +suite "Reliable Channel - send state machine": + asyncTest "MessageSentEvent finalises the channelReqId as Sent": + ## Drives the real send pipeline (`send` -> segmentation -> SDS -> + ## rate_limit -> encrypt -> dispatch) via a fake `SendHandler` that + ## returns a canned `RequestId` instead of hitting the network. + ## Emitting the delivery-layer `MessageSentEvent` must drive the + ## channel-level state machine through `Confirmed` and produce a + ## `ChannelMessageSentEvent` (channel-level terminal event) for the + ## `channelReqId` returned by `send()`. + const + channelId = ChannelId("sm-success-channel") + contentTopic = ContentTopic("/reliable-channel/test/sm-success") + fakeMsgReqId = RequestId("fake-msg-req-1") + + var manager: ReliableChannelManager + var brokerCtx: BrokerContext + lockNewGlobalBrokerContext: + brokerCtx = globalBrokerContext() + manager = (await ReliableChannelManager.new(createApiNodeConf())).expect( + "Failed to create manager" + ) + + setNoopEncryption() + + var sendCalls = 0 + let fakeSend: SendHandler = proc( + env: MessageEnvelope + ): Future[Result[RequestId, string]] {.async: (raises: [CatchableError]), gcsafe.} = + sendCalls.inc + return ok(fakeMsgReqId) + + discard manager + .createReliableChannel( + channelId, contentTopic, SdsParticipantID("local"), sendHandler = fakeSend + ) + .expect("createReliableChannel") + + let sentFut = newFuture[RequestId]("channel-sent") + discard ChannelMessageSentEvent + .listen( + brokerCtx, + proc(evt: ChannelMessageSentEvent) {.async: (raises: []).} = + if not sentFut.finished() and evt.channelId == channelId: + sentFut.complete(evt.requestId) + , + ) + .expect("listen ChannelMessageSentEvent") + + let channelReqId = manager.send(channelId, "hello".toBytes()).expect("send") + + let dispatchDeadline = Moment.now() + 1.seconds + while Moment.now() < dispatchDeadline and sendCalls == 0: + await sleepAsync(5.milliseconds) + check sendCalls == 1 + + waku_message_events.MessageSentEvent.emit( + brokerCtx, + waku_message_events.MessageSentEvent(requestId: fakeMsgReqId, messageHash: ""), + ) + + let finalised = await sentFut.withTimeout(1.seconds) + check finalised + if finalised: + check sentFut.read() == channelReqId + + await manager.stop() + + asyncTest "two independent channelReqIds are finalised independently": + ## Two `send()` calls -> two independent `channelReqId`s, each with + ## one segment under the current segmentation skeleton + ## (`performSegmentation` always emits exactly one segment). The + ## fake `SendHandler` returns distinct `messagingReqId`s; finalising + ## the first emits `ChannelMessageSentEvent` for its `channelReqId`, + ## finalising the second as a failure emits `ChannelMessageErrorEvent` + ## for the other. + const + channelId = ChannelId("sm-multi-channel") + contentTopic = ContentTopic("/reliable-channel/test/sm-multi") + + var manager: ReliableChannelManager + var brokerCtx: BrokerContext + lockNewGlobalBrokerContext: + brokerCtx = globalBrokerContext() + manager = (await ReliableChannelManager.new(createApiNodeConf())).expect( + "Failed to create manager" + ) + + setNoopEncryption() + + var msgReqIds: seq[RequestId] + let fakeSend: SendHandler = proc( + env: MessageEnvelope + ): Future[Result[RequestId, string]] {.async: (raises: [CatchableError]), gcsafe.} = + let id = RequestId("fake-msg-req-" & $(msgReqIds.len + 1)) + msgReqIds.add(id) + return ok(id) + + discard manager + .createReliableChannel( + channelId, contentTopic, SdsParticipantID("local"), sendHandler = fakeSend + ) + .expect("createReliableChannel") + + let sentFut = newFuture[RequestId]("channel-sent") + let erroredFut = newFuture[RequestId]("channel-errored") + discard ChannelMessageSentEvent + .listen( + brokerCtx, + proc(evt: ChannelMessageSentEvent) {.async: (raises: []).} = + if not sentFut.finished() and evt.channelId == channelId: + sentFut.complete(evt.requestId) + , + ) + .expect("listen ChannelMessageSentEvent") + discard ChannelMessageErrorEvent + .listen( + brokerCtx, + proc(evt: ChannelMessageErrorEvent) {.async: (raises: []).} = + if not erroredFut.finished() and evt.channelId == channelId: + erroredFut.complete(evt.requestId) + , + ) + .expect("listen ChannelMessageErrorEvent") + + let channelReqId1 = manager.send(channelId, "first".toBytes()).expect("send 1") + let channelReqId2 = manager.send(channelId, "second".toBytes()).expect("send 2") + + let dispatchDeadline = Moment.now() + 1.seconds + while Moment.now() < dispatchDeadline and msgReqIds.len < 2: + await sleepAsync(5.milliseconds) + check msgReqIds.len == 2 + + waku_message_events.MessageSentEvent.emit( + brokerCtx, + waku_message_events.MessageSentEvent(requestId: msgReqIds[0], messageHash: ""), + ) + let sentArrived = await sentFut.withTimeout(1.seconds) + check sentArrived + if sentArrived: + check sentFut.read() == channelReqId1 + ## The second `channelReqId` must NOT have finalised yet — its + ## segment is still `InFlight`. + check not erroredFut.finished() + + waku_message_events.MessageErrorEvent.emit( + brokerCtx, + waku_message_events.MessageErrorEvent( + requestId: msgReqIds[1], messageHash: "", error: "synthetic" + ), + ) + let erroredArrived = await erroredFut.withTimeout(1.seconds) + check erroredArrived + if erroredArrived: + check erroredFut.read() == channelReqId2 + + await manager.stop() + + asyncTest "TODO: channelReqId not pruned until ALL its segments are final": + ## Placeholder for the multi-sibling prune rule. Today's + ## `performSegmentation` (segmentation skeleton) always emits + ## exactly one segment per `send()`, so multiple siblings under one + ## `channelReqId` cannot be produced through the real pipeline. + ## Implement once segmentation does real chunking: send a payload + ## larger than `DefaultSegmentSizeBytes`, capture the N + ## `messagingReqId`s from a fake `SendHandler`, finalise some, and + ## assert prune only fires once every sibling is final. + skip() diff --git a/tests/common/test_all.nim b/tests/common/test_all.nim index d597a7424..1070c34e4 100644 --- a/tests/common/test_all.nim +++ b/tests/common/test_all.nim @@ -8,7 +8,4 @@ import ./test_parse_size, ./test_requestratelimiter, ./test_ratelimit_setting, - ./test_timed_map, - ./test_event_broker, - ./test_request_broker, - ./test_multi_request_broker + ./test_timed_map diff --git a/tests/common/test_event_broker.nim b/tests/common/test_event_broker.nim deleted file mode 100644 index bcd081f4f..000000000 --- a/tests/common/test_event_broker.nim +++ /dev/null @@ -1,201 +0,0 @@ -import chronos -import std/sequtils -import testutils/unittests - -import waku/common/broker/event_broker - -type ExternalDefinedEventType = object - label*: string - -EventBroker: - type IntEvent = int - -EventBroker: - type ExternalAliasEvent = distinct ExternalDefinedEventType - -EventBroker: - type SampleEvent = object - value*: int - label*: string - -EventBroker: - type BinaryEvent = object - flag*: bool - -EventBroker: - type RefEvent = ref object - payload*: seq[int] - -template waitForListeners() = - waitFor sleepAsync(1.milliseconds) - -suite "EventBroker": - test "delivers events to all listeners": - var seen: seq[(int, string)] = @[] - - discard SampleEvent.listen( - proc(evt: SampleEvent): Future[void] {.async: (raises: []).} = - seen.add((evt.value, evt.label)) - ) - - discard SampleEvent.listen( - proc(evt: SampleEvent): Future[void] {.async: (raises: []).} = - seen.add((evt.value * 2, evt.label & "!")) - ) - - let evt = SampleEvent(value: 5, label: "hi") - SampleEvent.emit(evt) - waitForListeners() - - check seen.len == 2 - check seen.anyIt(it == (5, "hi")) - check seen.anyIt(it == (10, "hi!")) - - SampleEvent.dropAllListeners() - - test "forget removes a single listener": - var counter = 0 - - let handleA = SampleEvent.listen( - proc(evt: SampleEvent): Future[void] {.async: (raises: []).} = - inc counter - ) - - let handleB = SampleEvent.listen( - proc(evt: SampleEvent): Future[void] {.async: (raises: []).} = - inc(counter, 2) - ) - - SampleEvent.dropListener(handleA.get()) - let eventVal = SampleEvent(value: 1, label: "one") - SampleEvent.emit(eventVal) - waitForListeners() - check counter == 2 - - SampleEvent.dropAllListeners() - - test "forgetAll clears every listener": - var triggered = false - - let handle1 = SampleEvent.listen( - proc(evt: SampleEvent): Future[void] {.async: (raises: []).} = - triggered = true - ) - let handle2 = SampleEvent.listen( - proc(evt: SampleEvent): Future[void] {.async: (raises: []).} = - discard - ) - - SampleEvent.dropAllListeners() - SampleEvent.emit(42, "noop") - SampleEvent.emit(label = "noop", value = 42) - waitForListeners() - check not triggered - - let freshHandle = SampleEvent.listen( - proc(evt: SampleEvent): Future[void] {.async: (raises: []).} = - discard - ) - check freshHandle.get().id > 0'u64 - SampleEvent.dropListener(freshHandle.get()) - - test "broker helpers operate via typedesc": - var toggles: seq[bool] = @[] - - let handle = BinaryEvent.listen( - proc(evt: BinaryEvent): Future[void] {.async: (raises: []).} = - toggles.add(evt.flag) - ) - - BinaryEvent(flag: true).emit() - waitForListeners() - let binaryEvent = BinaryEvent(flag: false) - BinaryEvent.emit(binaryEvent) - waitForListeners() - - check toggles == @[true, false] - BinaryEvent.dropAllListeners() - - test "ref typed event": - var counter: int = 0 - - let handle = RefEvent.listen( - proc(evt: RefEvent): Future[void] {.async: (raises: []).} = - for n in evt.payload: - counter += n - ) - - RefEvent(payload: @[1, 2, 3]).emit() - waitForListeners() - RefEvent.emit(payload = @[4, 5, 6]) - waitForListeners() - - check counter == 21 # 1+2+3 + 4+5+6 - RefEvent.dropAllListeners() - - test "supports BrokerContext-scoped listeners": - SampleEvent.dropAllListeners() - - let ctxA = NewBrokerContext() - let ctxB = NewBrokerContext() - - var seenA: seq[int] = @[] - var seenB: seq[int] = @[] - - discard SampleEvent.listen( - ctxA, - proc(evt: SampleEvent): Future[void] {.async: (raises: []).} = - seenA.add(evt.value), - ) - - discard SampleEvent.listen( - ctxB, - proc(evt: SampleEvent): Future[void] {.async: (raises: []).} = - seenB.add(evt.value), - ) - - SampleEvent.emit(ctxA, SampleEvent(value: 1, label: "a")) - SampleEvent.emit(ctxB, SampleEvent(value: 2, label: "b")) - waitForListeners() - - check seenA == @[1] - check seenB == @[2] - - SampleEvent.dropAllListeners(ctxA) - SampleEvent.emit(ctxA, SampleEvent(value: 3, label: "a2")) - SampleEvent.emit(ctxB, SampleEvent(value: 4, label: "b2")) - waitForListeners() - - check seenA == @[1] - check seenB == @[2, 4] - - SampleEvent.dropAllListeners(ctxB) - - test "supports non-object event types (auto-distinct)": - var seen: seq[int] = @[] - - discard IntEvent.listen( - proc(evt: IntEvent): Future[void] {.async: (raises: []).} = - seen.add(int(evt)) - ) - - IntEvent.emit(IntEvent(42)) - waitForListeners() - - check seen == @[42] - IntEvent.dropAllListeners() - - test "supports externally-defined type aliases (auto-distinct)": - var seen: seq[string] = @[] - - discard ExternalAliasEvent.listen( - proc(evt: ExternalAliasEvent): Future[void] {.async: (raises: []).} = - let base = ExternalDefinedEventType(evt) - seen.add(base.label) - ) - - ExternalAliasEvent.emit(ExternalAliasEvent(ExternalDefinedEventType(label: "x"))) - waitForListeners() - - check seen == @["x"] - ExternalAliasEvent.dropAllListeners() diff --git a/tests/common/test_multi_request_broker.nim b/tests/common/test_multi_request_broker.nim deleted file mode 100644 index 39ed90eea..000000000 --- a/tests/common/test_multi_request_broker.nim +++ /dev/null @@ -1,343 +0,0 @@ -{.used.} - -import testutils/unittests -import chronos -import std/sequtils -import std/strutils - -import waku/common/broker/multi_request_broker - -MultiRequestBroker: - type NoArgResponse = object - label*: string - - proc signatureFetch*(): Future[Result[NoArgResponse, string]] {.async.} - -MultiRequestBroker: - type ArgResponse = object - id*: string - - proc signatureFetch*( - suffix: string, numsuffix: int - ): Future[Result[ArgResponse, string]] {.async.} - -MultiRequestBroker: - type DualResponse = ref object - note*: string - suffix*: string - - proc signatureBase*(): Future[Result[DualResponse, string]] {.async.} - proc signatureWithInput*( - suffix: string - ): Future[Result[DualResponse, string]] {.async.} - -type ExternalBaseType = string - -MultiRequestBroker: - type NativeIntResponse = int - - proc signatureFetch*(): Future[Result[NativeIntResponse, string]] {.async.} - -MultiRequestBroker: - type ExternalAliasResponse = ExternalBaseType - - proc signatureFetch*(): Future[Result[ExternalAliasResponse, string]] {.async.} - -MultiRequestBroker: - type AlreadyDistinctResponse = distinct int - - proc signatureFetch*(): Future[Result[AlreadyDistinctResponse, string]] {.async.} - -suite "MultiRequestBroker": - test "aggregates zero-argument providers": - discard NoArgResponse.setProvider( - proc(): Future[Result[NoArgResponse, string]] {.async.} = - ok(NoArgResponse(label: "one")) - ) - - discard NoArgResponse.setProvider( - proc(): Future[Result[NoArgResponse, string]] {.async.} = - discard catch: - await sleepAsync(1.milliseconds) - ok(NoArgResponse(label: "two")) - ) - - let responses = waitFor NoArgResponse.request() - check responses.get().len == 2 - check responses.get().anyIt(it.label == "one") - check responses.get().anyIt(it.label == "two") - - NoArgResponse.clearProviders() - - test "aggregates argument providers": - discard ArgResponse.setProvider( - proc(suffix: string, num: int): Future[Result[ArgResponse, string]] {.async.} = - ok(ArgResponse(id: suffix & "-a-" & $num)) - ) - - discard ArgResponse.setProvider( - proc(suffix: string, num: int): Future[Result[ArgResponse, string]] {.async.} = - ok(ArgResponse(id: suffix & "-b-" & $num)) - ) - - let keyed = waitFor ArgResponse.request("topic", 1) - check keyed.get().len == 2 - check keyed.get().anyIt(it.id == "topic-a-1") - check keyed.get().anyIt(it.id == "topic-b-1") - - ArgResponse.clearProviders() - - test "clearProviders resets both provider lists": - discard DualResponse.setProvider( - proc(): Future[Result[DualResponse, string]] {.async.} = - ok(DualResponse(note: "base", suffix: "")) - ) - - discard DualResponse.setProvider( - proc(suffix: string): Future[Result[DualResponse, string]] {.async.} = - ok(DualResponse(note: "base" & suffix, suffix: suffix)) - ) - - let noArgs = waitFor DualResponse.request() - check noArgs.get().len == 1 - - let param = waitFor DualResponse.request("-extra") - check param.get().len == 1 - check param.get()[0].suffix == "-extra" - - DualResponse.clearProviders() - - let emptyNoArgs = waitFor DualResponse.request() - check emptyNoArgs.get().len == 0 - - let emptyWithArgs = waitFor DualResponse.request("-extra") - check emptyWithArgs.get().len == 0 - - test "request returns empty seq when no providers registered": - let empty = waitFor NoArgResponse.request() - check empty.get().len == 0 - - test "failed providers will fail the request": - NoArgResponse.clearProviders() - discard NoArgResponse.setProvider( - proc(): Future[Result[NoArgResponse, string]] {.async.} = - err("boom") - ) - - discard NoArgResponse.setProvider( - proc(): Future[Result[NoArgResponse, string]] {.async.} = - ok(NoArgResponse(label: "survivor")) - ) - - let filtered = waitFor NoArgResponse.request() - check filtered.isErr() - - NoArgResponse.clearProviders() - - test "deduplicates identical zero-argument providers": - NoArgResponse.clearProviders() - var invocations = 0 - let sharedHandler = proc(): Future[Result[NoArgResponse, string]] {.async.} = - inc invocations - ok(NoArgResponse(label: "dup")) - - let first = NoArgResponse.setProvider(sharedHandler) - let second = NoArgResponse.setProvider(sharedHandler) - - check first.get().id == second.get().id - check first.get().kind == second.get().kind - - let dupResponses = waitFor NoArgResponse.request() - check dupResponses.get().len == 1 - check invocations == 1 - - NoArgResponse.clearProviders() - - test "removeProvider deletes registered handlers": - var removedCalled = false - var keptCalled = false - - let removable = NoArgResponse.setProvider( - proc(): Future[Result[NoArgResponse, string]] {.async.} = - removedCalled = true - ok(NoArgResponse(label: "removed")) - ) - - discard NoArgResponse.setProvider( - proc(): Future[Result[NoArgResponse, string]] {.async.} = - keptCalled = true - ok(NoArgResponse(label: "kept")) - ) - - NoArgResponse.removeProvider(removable.get()) - - let afterRemoval = (waitFor NoArgResponse.request()).valueOr: - assert false, "request failed" - @[] - check afterRemoval.len == 1 - check afterRemoval[0].label == "kept" - check not removedCalled - check keptCalled - - NoArgResponse.clearProviders() - - test "removeProvider works for argument signatures": - var invoked: seq[string] = @[] - - discard ArgResponse.setProvider( - proc(suffix: string, num: int): Future[Result[ArgResponse, string]] {.async.} = - invoked.add("first" & suffix) - ok(ArgResponse(id: suffix & "-one-" & $num)) - ) - - let handle = ArgResponse.setProvider( - proc(suffix: string, num: int): Future[Result[ArgResponse, string]] {.async.} = - invoked.add("second" & suffix) - ok(ArgResponse(id: suffix & "-two-" & $num)) - ) - - ArgResponse.removeProvider(handle.get()) - - let single = (waitFor ArgResponse.request("topic", 1)).valueOr: - assert false, "request failed" - @[] - check single.len == 1 - check single[0].id == "topic-one-1" - check invoked == @["firsttopic"] - - ArgResponse.clearProviders() - - test "catches exception from providers and report error": - let firstHandler = NoArgResponse.setProvider( - proc(): Future[Result[NoArgResponse, string]] {.async.} = - raise newException(ValueError, "first handler raised") - ) - - discard NoArgResponse.setProvider( - proc(): Future[Result[NoArgResponse, string]] {.async.} = - ok(NoArgResponse(label: "just ok")) - ) - - let afterException = waitFor NoArgResponse.request() - check afterException.isErr() - check afterException.error().contains("first handler raised") - - NoArgResponse.clearProviders() - - test "ref providers returning nil fail request": - DualResponse.clearProviders() - - test "supports native request types": - NativeIntResponse.clearProviders() - - discard NativeIntResponse.setProvider( - proc(): Future[Result[NativeIntResponse, string]] {.async.} = - ok(NativeIntResponse(1)) - ) - - discard NativeIntResponse.setProvider( - proc(): Future[Result[NativeIntResponse, string]] {.async.} = - ok(NativeIntResponse(2)) - ) - - let res = waitFor NativeIntResponse.request() - check res.isOk() - check res.get().len == 2 - check res.get().anyIt(int(it) == 1) - check res.get().anyIt(int(it) == 2) - - NativeIntResponse.clearProviders() - - test "supports external request types": - ExternalAliasResponse.clearProviders() - - discard ExternalAliasResponse.setProvider( - proc(): Future[Result[ExternalAliasResponse, string]] {.async.} = - ok(ExternalAliasResponse("hello")) - ) - - let res = waitFor ExternalAliasResponse.request() - check res.isOk() - check res.get().len == 1 - check ExternalBaseType(res.get()[0]) == "hello" - - ExternalAliasResponse.clearProviders() - - test "supports already-distinct request types": - AlreadyDistinctResponse.clearProviders() - - discard AlreadyDistinctResponse.setProvider( - proc(): Future[Result[AlreadyDistinctResponse, string]] {.async.} = - ok(AlreadyDistinctResponse(7)) - ) - - let res = waitFor AlreadyDistinctResponse.request() - check res.isOk() - check res.get().len == 1 - check int(res.get()[0]) == 7 - - AlreadyDistinctResponse.clearProviders() - - test "context-aware providers are isolated": - NoArgResponse.clearProviders() - let ctxA = NewBrokerContext() - let ctxB = NewBrokerContext() - - discard NoArgResponse.setProvider( - ctxA, - proc(): Future[Result[NoArgResponse, string]] {.async.} = - ok(NoArgResponse(label: "a")), - ) - discard NoArgResponse.setProvider( - ctxB, - proc(): Future[Result[NoArgResponse, string]] {.async.} = - ok(NoArgResponse(label: "b")), - ) - - let resA = waitFor NoArgResponse.request(ctxA) - check resA.isOk() - check resA.get().len == 1 - check resA.get()[0].label == "a" - - let resB = waitFor NoArgResponse.request(ctxB) - check resB.isOk() - check resB.get().len == 1 - check resB.get()[0].label == "b" - - let resDefault = waitFor NoArgResponse.request() - check resDefault.isOk() - check resDefault.get().len == 0 - - NoArgResponse.clearProviders(ctxA) - let clearedA = waitFor NoArgResponse.request(ctxA) - check clearedA.isOk() - check clearedA.get().len == 0 - - let stillB = waitFor NoArgResponse.request(ctxB) - check stillB.isOk() - check stillB.get().len == 1 - check stillB.get()[0].label == "b" - - NoArgResponse.clearProviders(ctxB) - - discard DualResponse.setProvider( - proc(): Future[Result[DualResponse, string]] {.async.} = - let nilResponse: DualResponse = nil - ok(nilResponse) - ) - - let zeroArg = waitFor DualResponse.request() - check zeroArg.isErr() - - DualResponse.clearProviders() - - discard DualResponse.setProvider( - proc(suffix: string): Future[Result[DualResponse, string]] {.async.} = - let nilResponse: DualResponse = nil - ok(nilResponse) - ) - - let withInput = waitFor DualResponse.request("-extra") - check withInput.isErr() - - DualResponse.clearProviders() diff --git a/tests/common/test_request_broker.nim b/tests/common/test_request_broker.nim deleted file mode 100644 index b1e16979b..000000000 --- a/tests/common/test_request_broker.nim +++ /dev/null @@ -1,675 +0,0 @@ -{.used.} - -import testutils/unittests -import chronos -import std/strutils - -import waku/common/broker/request_broker - -## --------------------------------------------------------------------------- -## Async-mode brokers + tests -## --------------------------------------------------------------------------- - -RequestBroker: - type SimpleResponse = object - value*: string - - proc signatureFetch*(): Future[Result[SimpleResponse, string]] {.async.} - -RequestBroker: - type KeyedResponse = object - key*: string - payload*: string - - proc signatureFetchWithKey*( - key: string, subKey: int - ): Future[Result[KeyedResponse, string]] {.async.} - -RequestBroker: - type DualResponse = object - note*: string - count*: int - - proc signatureNoInput*(): Future[Result[DualResponse, string]] {.async.} - proc signatureWithInput*( - suffix: string - ): Future[Result[DualResponse, string]] {.async.} - -RequestBroker(async): - type ImplicitResponse = ref object - note*: string - -static: - doAssert typeof(SimpleResponse.request()) is Future[Result[SimpleResponse, string]] - -suite "RequestBroker macro (async mode)": - test "serves zero-argument providers": - check SimpleResponse - .setProvider( - proc(): Future[Result[SimpleResponse, string]] {.async.} = - ok(SimpleResponse(value: "hi")) - ) - .isOk() - - let res = waitFor SimpleResponse.request() - check res.isOk() - check res.value.value == "hi" - - SimpleResponse.clearProvider() - - test "zero-argument request errors when unset": - let res = waitFor SimpleResponse.request() - check res.isErr() - check res.error.contains("no zero-arg provider") - - test "serves input-based providers": - var seen: seq[string] = @[] - check KeyedResponse - .setProvider( - proc( - key: string, subKey: int - ): Future[Result[KeyedResponse, string]] {.async.} = - seen.add(key) - ok(KeyedResponse(key: key, payload: key & "-payload+" & $subKey)) - ) - .isOk() - - let res = waitFor KeyedResponse.request("topic", 1) - check res.isOk() - check res.value.key == "topic" - check res.value.payload == "topic-payload+1" - check seen == @["topic"] - - KeyedResponse.clearProvider() - - test "catches provider exception": - check KeyedResponse - .setProvider( - proc( - key: string, subKey: int - ): Future[Result[KeyedResponse, string]] {.async.} = - raise newException(ValueError, "simulated failure") - ) - .isOk() - - let res = waitFor KeyedResponse.request("neglected", 11) - check res.isErr() - check res.error.contains("simulated failure") - - KeyedResponse.clearProvider() - - test "input request errors when unset": - let res = waitFor KeyedResponse.request("foo", 2) - check res.isErr() - check res.error.contains("input signature") - - test "supports both provider types simultaneously": - check DualResponse - .setProvider( - proc(): Future[Result[DualResponse, string]] {.async.} = - ok(DualResponse(note: "base", count: 1)) - ) - .isOk() - - check DualResponse - .setProvider( - proc(suffix: string): Future[Result[DualResponse, string]] {.async.} = - ok(DualResponse(note: "base" & suffix, count: suffix.len)) - ) - .isOk() - - let noInput = waitFor DualResponse.request() - check noInput.isOk() - check noInput.value.note == "base" - - let withInput = waitFor DualResponse.request("-extra") - check withInput.isOk() - check withInput.value.note == "base-extra" - check withInput.value.count == 6 - - DualResponse.clearProvider() - - test "clearProvider resets both entries": - check DualResponse - .setProvider( - proc(): Future[Result[DualResponse, string]] {.async.} = - ok(DualResponse(note: "temp", count: 0)) - ) - .isOk() - DualResponse.clearProvider() - - let res = waitFor DualResponse.request() - check res.isErr() - - test "implicit zero-argument provider works by default": - check ImplicitResponse - .setProvider( - proc(): Future[Result[ImplicitResponse, string]] {.async.} = - ok(ImplicitResponse(note: "auto")) - ) - .isOk() - - let res = waitFor ImplicitResponse.request() - check res.isOk() - - ImplicitResponse.clearProvider() - check res.value.note == "auto" - - test "implicit zero-argument request errors when unset": - let res = waitFor ImplicitResponse.request() - check res.isErr() - check res.error.contains("no zero-arg provider") - - test "no provider override": - check DualResponse - .setProvider( - proc(): Future[Result[DualResponse, string]] {.async.} = - ok(DualResponse(note: "base", count: 1)) - ) - .isOk() - - check DualResponse - .setProvider( - proc(suffix: string): Future[Result[DualResponse, string]] {.async.} = - ok(DualResponse(note: "base" & suffix, count: suffix.len)) - ) - .isOk() - - let overrideProc = proc(): Future[Result[DualResponse, string]] {.async.} = - ok(DualResponse(note: "something else", count: 1)) - - check DualResponse.setProvider(overrideProc).isErr() - - let noInput = waitFor DualResponse.request() - check noInput.isOk() - check noInput.value.note == "base" - - let stillResponse = waitFor DualResponse.request(" still works") - check stillResponse.isOk() - check stillResponse.value.note.contains("base still works") - - DualResponse.clearProvider() - - let noResponse = waitFor DualResponse.request() - check noResponse.isErr() - check noResponse.error.contains("no zero-arg provider") - - let noResponseArg = waitFor DualResponse.request("Should not work") - check noResponseArg.isErr() - check noResponseArg.error.contains("no provider") - - check DualResponse.setProvider(overrideProc).isOk() - - let nowSuccWithOverride = waitFor DualResponse.request() - check nowSuccWithOverride.isOk() - check nowSuccWithOverride.value.note == "something else" - check nowSuccWithOverride.value.count == 1 - - DualResponse.clearProvider() - - test "supports keyed providers (async, zero-arg)": - SimpleResponse.clearProvider() - - check SimpleResponse - .setProvider( - proc(): Future[Result[SimpleResponse, string]] {.async.} = - ok(SimpleResponse(value: "default")) - ) - .isOk() - - check SimpleResponse - .setProvider( - BrokerContext(0x11111111'u32), - proc(): Future[Result[SimpleResponse, string]] {.async.} = - ok(SimpleResponse(value: "one")), - ) - .isOk() - - check SimpleResponse - .setProvider( - BrokerContext(0x22222222'u32), - proc(): Future[Result[SimpleResponse, string]] {.async.} = - ok(SimpleResponse(value: "two")), - ) - .isOk() - - let defaultRes = waitFor SimpleResponse.request() - check defaultRes.isOk() - check defaultRes.value.value == "default" - - let res1 = waitFor SimpleResponse.request(BrokerContext(0x11111111'u32)) - check res1.isOk() - check res1.value.value == "one" - - let res2 = waitFor SimpleResponse.request(BrokerContext(0x22222222'u32)) - check res2.isOk() - check res2.value.value == "two" - - let missing = waitFor SimpleResponse.request(BrokerContext(0x33333333'u32)) - check missing.isErr() - check missing.error.contains("no provider registered for broker context") - - check SimpleResponse - .setProvider( - BrokerContext(0x11111111'u32), - proc(): Future[Result[SimpleResponse, string]] {.async.} = - ok(SimpleResponse(value: "dup")), - ) - .isErr() - - SimpleResponse.clearProvider() - - test "supports keyed providers (async, with args)": - KeyedResponse.clearProvider() - - check KeyedResponse - .setProvider( - proc( - key: string, subKey: int - ): Future[Result[KeyedResponse, string]] {.async.} = - ok(KeyedResponse(key: "default-" & key, payload: $subKey)) - ) - .isOk() - - check KeyedResponse - .setProvider( - BrokerContext(0xABCDEF01'u32), - proc( - key: string, subKey: int - ): Future[Result[KeyedResponse, string]] {.async.} = - ok(KeyedResponse(key: "k1-" & key, payload: "p" & $subKey)), - ) - .isOk() - - check KeyedResponse - .setProvider( - BrokerContext(0xABCDEF02'u32), - proc( - key: string, subKey: int - ): Future[Result[KeyedResponse, string]] {.async.} = - ok(KeyedResponse(key: "k2-" & key, payload: "q" & $subKey)), - ) - .isOk() - - let d = waitFor KeyedResponse.request("topic", 7) - check d.isOk() - check d.value.key == "default-topic" - - let k1 = waitFor KeyedResponse.request(BrokerContext(0xABCDEF01'u32), "topic", 7) - check k1.isOk() - check k1.value.key == "k1-topic" - check k1.value.payload == "p7" - - let k2 = waitFor KeyedResponse.request(BrokerContext(0xABCDEF02'u32), "topic", 7) - check k2.isOk() - check k2.value.key == "k2-topic" - check k2.value.payload == "q7" - - let miss = waitFor KeyedResponse.request(BrokerContext(0xDEADBEEF'u32), "topic", 7) - check miss.isErr() - check miss.error.contains("no provider registered for broker context") - - KeyedResponse.clearProvider() - -## --------------------------------------------------------------------------- -## Sync-mode brokers + tests -## --------------------------------------------------------------------------- - -RequestBroker(sync): - type SimpleResponseSync = object - value*: string - - proc signatureFetch*(): Result[SimpleResponseSync, string] - -RequestBroker(sync): - type KeyedResponseSync = object - key*: string - payload*: string - - proc signatureFetchWithKey*( - key: string, subKey: int - ): Result[KeyedResponseSync, string] - -RequestBroker(sync): - type DualResponseSync = object - note*: string - count*: int - - proc signatureNoInput*(): Result[DualResponseSync, string] - proc signatureWithInput*(suffix: string): Result[DualResponseSync, string] - -RequestBroker(sync): - type ImplicitResponseSync = ref object - note*: string - -static: - doAssert typeof(SimpleResponseSync.request()) is Result[SimpleResponseSync, string] - doAssert not ( - typeof(SimpleResponseSync.request()) is Future[Result[SimpleResponseSync, string]] - ) - doAssert typeof(KeyedResponseSync.request("topic", 1)) is - Result[KeyedResponseSync, string] - -suite "RequestBroker macro (sync mode)": - test "serves zero-argument providers (sync)": - check SimpleResponseSync - .setProvider( - proc(): Result[SimpleResponseSync, string] = - ok(SimpleResponseSync(value: "hi")) - ) - .isOk() - - let res = SimpleResponseSync.request() - check res.isOk() - check res.value.value == "hi" - - SimpleResponseSync.clearProvider() - - test "zero-argument request errors when unset (sync)": - let res = SimpleResponseSync.request() - check res.isErr() - check res.error.contains("no zero-arg provider") - - test "serves input-based providers (sync)": - var seen: seq[string] = @[] - check KeyedResponseSync - .setProvider( - proc(key: string, subKey: int): Result[KeyedResponseSync, string] = - seen.add(key) - ok(KeyedResponseSync(key: key, payload: key & "-payload+" & $subKey)) - ) - .isOk() - - let res = KeyedResponseSync.request("topic", 1) - check res.isOk() - check res.value.key == "topic" - check res.value.payload == "topic-payload+1" - check seen == @["topic"] - - KeyedResponseSync.clearProvider() - - test "catches provider exception (sync)": - check KeyedResponseSync - .setProvider( - proc(key: string, subKey: int): Result[KeyedResponseSync, string] = - raise newException(ValueError, "simulated failure") - ) - .isOk() - - let res = KeyedResponseSync.request("neglected", 11) - check res.isErr() - check res.error.contains("simulated failure") - - KeyedResponseSync.clearProvider() - - test "input request errors when unset (sync)": - let res = KeyedResponseSync.request("foo", 2) - check res.isErr() - check res.error.contains("input signature") - - test "supports both provider types simultaneously (sync)": - check DualResponseSync - .setProvider( - proc(): Result[DualResponseSync, string] = - ok(DualResponseSync(note: "base", count: 1)) - ) - .isOk() - - check DualResponseSync - .setProvider( - proc(suffix: string): Result[DualResponseSync, string] = - ok(DualResponseSync(note: "base" & suffix, count: suffix.len)) - ) - .isOk() - - let noInput = DualResponseSync.request() - check noInput.isOk() - check noInput.value.note == "base" - - let withInput = DualResponseSync.request("-extra") - check withInput.isOk() - check withInput.value.note == "base-extra" - check withInput.value.count == 6 - - DualResponseSync.clearProvider() - - test "clearProvider resets both entries (sync)": - check DualResponseSync - .setProvider( - proc(): Result[DualResponseSync, string] = - ok(DualResponseSync(note: "temp", count: 0)) - ) - .isOk() - DualResponseSync.clearProvider() - - let res = DualResponseSync.request() - check res.isErr() - - test "implicit zero-argument provider works by default (sync)": - check ImplicitResponseSync - .setProvider( - proc(): Result[ImplicitResponseSync, string] = - ok(ImplicitResponseSync(note: "auto")) - ) - .isOk() - - let res = ImplicitResponseSync.request() - check res.isOk() - - ImplicitResponseSync.clearProvider() - check res.value.note == "auto" - - test "implicit zero-argument request errors when unset (sync)": - let res = ImplicitResponseSync.request() - check res.isErr() - check res.error.contains("no zero-arg provider") - - test "implicit zero-argument provider raises error (sync)": - check ImplicitResponseSync - .setProvider( - proc(): Result[ImplicitResponseSync, string] = - raise newException(ValueError, "simulated failure") - ) - .isOk() - - let res = ImplicitResponseSync.request() - check res.isErr() - check res.error.contains("simulated failure") - - ImplicitResponseSync.clearProvider() - - test "supports keyed providers (sync, zero-arg)": - SimpleResponseSync.clearProvider() - - check SimpleResponseSync - .setProvider( - proc(): Result[SimpleResponseSync, string] = - ok(SimpleResponseSync(value: "default")) - ) - .isOk() - - check SimpleResponseSync - .setProvider( - BrokerContext(0x10101010'u32), - proc(): Result[SimpleResponseSync, string] = - ok(SimpleResponseSync(value: "ten")), - ) - .isOk() - - let defaultRes = SimpleResponseSync.request() - check defaultRes.isOk() - check defaultRes.value.value == "default" - - let keyedRes = SimpleResponseSync.request(BrokerContext(0x10101010'u32)) - check keyedRes.isOk() - check keyedRes.value.value == "ten" - - let miss = SimpleResponseSync.request(BrokerContext(0x20202020'u32)) - check miss.isErr() - check miss.error.contains("no provider registered for broker context") - - SimpleResponseSync.clearProvider() - - test "supports keyed providers (sync, with args)": - KeyedResponseSync.clearProvider() - - check KeyedResponseSync - .setProvider( - proc(key: string, subKey: int): Result[KeyedResponseSync, string] = - ok(KeyedResponseSync(key: "default-" & key, payload: $subKey)) - ) - .isOk() - - check KeyedResponseSync - .setProvider( - BrokerContext(0xA0A0A0A0'u32), - proc(key: string, subKey: int): Result[KeyedResponseSync, string] = - ok(KeyedResponseSync(key: "k-" & key, payload: "p" & $subKey)), - ) - .isOk() - - let d = KeyedResponseSync.request("topic", 2) - check d.isOk() - check d.value.key == "default-topic" - - let keyed = KeyedResponseSync.request(BrokerContext(0xA0A0A0A0'u32), "topic", 2) - check keyed.isOk() - check keyed.value.key == "k-topic" - check keyed.value.payload == "p2" - - let miss = KeyedResponseSync.request(BrokerContext(0xB0B0B0B0'u32), "topic", 2) - check miss.isErr() - check miss.error.contains("no provider registered for broker context") - - KeyedResponseSync.clearProvider() - -## --------------------------------------------------------------------------- -## POD / external type brokers + tests (distinct/alias behavior) -## --------------------------------------------------------------------------- - -type ExternalDefinedTypeAsync = object - label*: string - -type ExternalDefinedTypeSync = object - label*: string - -type ExternalDefinedTypeShared = object - label*: string - -RequestBroker: - type PodResponse = int - - proc signatureFetch*(): Future[Result[PodResponse, string]] {.async.} - -RequestBroker: - type ExternalAliasedResponse = ExternalDefinedTypeAsync - - proc signatureFetch*(): Future[Result[ExternalAliasedResponse, string]] {.async.} - -RequestBroker(sync): - type ExternalAliasedResponseSync = ExternalDefinedTypeSync - - proc signatureFetch*(): Result[ExternalAliasedResponseSync, string] - -RequestBroker(sync): - type DistinctStringResponseA = distinct string - -RequestBroker(sync): - type DistinctStringResponseB = distinct string - -RequestBroker(sync): - type ExternalDistinctResponseA = distinct ExternalDefinedTypeShared - -RequestBroker(sync): - type ExternalDistinctResponseB = distinct ExternalDefinedTypeShared - -suite "RequestBroker macro (POD/external types)": - test "supports non-object response types (async)": - check PodResponse - .setProvider( - proc(): Future[Result[PodResponse, string]] {.async.} = - ok(PodResponse(123)) - ) - .isOk() - - let res = waitFor PodResponse.request() - check res.isOk() - check int(res.value) == 123 - - PodResponse.clearProvider() - - test "supports aliased external types (async)": - check ExternalAliasedResponse - .setProvider( - proc(): Future[Result[ExternalAliasedResponse, string]] {.async.} = - ok(ExternalAliasedResponse(ExternalDefinedTypeAsync(label: "ext"))) - ) - .isOk() - - let res = waitFor ExternalAliasedResponse.request() - check res.isOk() - check ExternalDefinedTypeAsync(res.value).label == "ext" - - ExternalAliasedResponse.clearProvider() - - test "supports aliased external types (sync)": - check ExternalAliasedResponseSync - .setProvider( - proc(): Result[ExternalAliasedResponseSync, string] = - ok(ExternalAliasedResponseSync(ExternalDefinedTypeSync(label: "ext"))) - ) - .isOk() - - let res = ExternalAliasedResponseSync.request() - check res.isOk() - check ExternalDefinedTypeSync(res.value).label == "ext" - - ExternalAliasedResponseSync.clearProvider() - - test "distinct response types avoid overload ambiguity (sync)": - check DistinctStringResponseA - .setProvider( - proc(): Result[DistinctStringResponseA, string] = - ok(DistinctStringResponseA("a")) - ) - .isOk() - - check DistinctStringResponseB - .setProvider( - proc(): Result[DistinctStringResponseB, string] = - ok(DistinctStringResponseB("b")) - ) - .isOk() - - check ExternalDistinctResponseA - .setProvider( - proc(): Result[ExternalDistinctResponseA, string] = - ok(ExternalDistinctResponseA(ExternalDefinedTypeShared(label: "ea"))) - ) - .isOk() - - check ExternalDistinctResponseB - .setProvider( - proc(): Result[ExternalDistinctResponseB, string] = - ok(ExternalDistinctResponseB(ExternalDefinedTypeShared(label: "eb"))) - ) - .isOk() - - let resA = DistinctStringResponseA.request() - let resB = DistinctStringResponseB.request() - check resA.isOk() - check resB.isOk() - check string(resA.value) == "a" - check string(resB.value) == "b" - - let resEA = ExternalDistinctResponseA.request() - let resEB = ExternalDistinctResponseB.request() - check resEA.isOk() - check resEB.isOk() - check ExternalDefinedTypeShared(resEA.value).label == "ea" - check ExternalDefinedTypeShared(resEB.value).label == "eb" - - DistinctStringResponseA.clearProvider() - DistinctStringResponseB.clearProvider() - ExternalDistinctResponseA.clearProvider() - ExternalDistinctResponseB.clearProvider() diff --git a/tests/node/peer_manager/peer_store/test_migrations.nim b/tests/node/peer_manager/peer_store/test_migrations.nim index a20d065ec..d6b86a15b 100644 --- a/tests/node/peer_manager/peer_store/test_migrations.nim +++ b/tests/node/peer_manager/peer_store/test_migrations.nim @@ -1,11 +1,11 @@ -import std/[options], stew/results, testutils/unittests +import std/[options], results, testutils/unittests import waku/node/peer_manager/peer_store/migrations, ../../waku_archive/archive_utils, ../../testlib/[simple_mock] -import std/[tables, strutils, os], stew/results, chronicles +import std/[tables, strutils, os], results, chronicles import waku/common/databases/db_sqlite, waku/common/databases/common diff --git a/tests/node/peer_manager/peer_store/test_peer_storage.nim b/tests/node/peer_manager/peer_store/test_peer_storage.nim index c8a479178..871df8644 100644 --- a/tests/node/peer_manager/peer_store/test_peer_storage.nim +++ b/tests/node/peer_manager/peer_store/test_peer_storage.nim @@ -1,4 +1,4 @@ -import stew/results, testutils/unittests +import results, testutils/unittests import waku/node/peer_manager/peer_store/peer_storage, waku/waku_core/peers diff --git a/tests/node/test_wakunode_health_monitor.nim b/tests/node/test_wakunode_health_monitor.nim index 8a3ddd104..08f641a75 100644 --- a/tests/node/test_wakunode_health_monitor.nim +++ b/tests/node/test_wakunode_health_monitor.nim @@ -2,6 +2,7 @@ import std/[json, options, sequtils, strutils, tables], testutils/unittests, chronos, results +import brokers/broker_context import waku/[ @@ -23,7 +24,6 @@ import events/health_events, events/peer_events, waku_archive, - common/broker/broker_context, ] import ../testlib/[wakunode, wakucore], ../waku_archive/archive_utils @@ -277,7 +277,7 @@ suite "Health Monitor - events": await nodeA.connectToNodes(@[nodeB.switch.peerInfo.toRemotePeerInfo()]) let metadataOk = await metadataFut.withTimeout(TestConnectivityTimeLimit) - WakuPeerEvent.dropListener(nodeA.brokerCtx, metadataLis) + await WakuPeerEvent.dropListener(nodeA.brokerCtx, metadataLis) require metadataOk let connectTimeLimit = Moment.now() + TestConnectivityTimeLimit @@ -380,7 +380,7 @@ suite "Health Monitor - events": await nodeA.connectToNodes(@[nodeB.switch.peerInfo.toRemotePeerInfo()]) let metadataOk = await metadataFut.withTimeout(TestConnectivityTimeLimit) - WakuPeerEvent.dropListener(nodeA.brokerCtx, metadataLis) + await WakuPeerEvent.dropListener(nodeA.brokerCtx, metadataLis) require metadataOk var deadline = Moment.now() + TestConnectivityTimeLimit @@ -413,7 +413,7 @@ suite "Health Monitor - events": subMgr.subscribe(contentTopic).expect("Failed to subscribe") let shardHealthOk = await shardHealthFut.withTimeout(TestConnectivityTimeLimit) - EventShardTopicHealthChange.dropListener(nodeA.brokerCtx, shardHealthLis) + await EventShardTopicHealthChange.dropListener(nodeA.brokerCtx, shardHealthLis) check shardHealthOk == true check subMgr.edgeFilterSubStates.len > 0 diff --git a/tests/node/test_wakunode_relay_rln.nim b/tests/node/test_wakunode_relay_rln.nim index c8ca9b43d..3a2a8a67c 100644 --- a/tests/node/test_wakunode_relay_rln.nim +++ b/tests/node/test_wakunode_relay_rln.nim @@ -2,7 +2,7 @@ import std/[tempfiles, strutils, options], - stew/results, + results, testutils/unittests, chronos, libp2p/switch, diff --git a/tests/persistency/test_all.nim b/tests/persistency/test_all.nim new file mode 100644 index 000000000..194977692 --- /dev/null +++ b/tests/persistency/test_all.nim @@ -0,0 +1,9 @@ +{.used.} + +import ./test_keys +import ./test_backend +import ./test_lifecycle +import ./test_facade +import ./test_encoding +import ./test_string_lookup +import ./test_singleton diff --git a/tests/persistency/test_backend.nim b/tests/persistency/test_backend.nim new file mode 100644 index 000000000..e5689d95f --- /dev/null +++ b/tests/persistency/test_backend.nim @@ -0,0 +1,195 @@ +{.used.} + +import std/options +import results +import testutils/unittests +import waku/persistency/[types, keys, backend_sqlite] + +template str(b: seq[byte]): string = + var s = newString(b.len) + for i, x in b: + s[i] = char(x) + s + +proc payload(s: string): seq[byte] = + result = newSeq[byte](s.len) + for i, c in s: + result[i] = byte(c) + +suite "Persistency SQLite backend": + test "open in-memory backend and round-trip a single value": + let b = openBackendInMemory().get() + defer: + b.close() + + b + .applyOps( + [ + TxOp( + category: "msg", + key: key("c1", 1'i64), + kind: txPut, + payload: payload("hello"), + ) + ] + ) + .get() + + let got = b.getOne("msg", key("c1", 1'i64)).get() + check got.isSome + check str(got.get) == "hello" + + check b.existsOne("msg", key("c1", 1'i64)).get() + check not b.existsOne("msg", key("c1", 2'i64)).get() + + test "INSERT OR REPLACE overwrites payload for the same key": + let b = openBackendInMemory().get() + defer: + b.close() + let k = key("c1", 1'i64) + b.applyOps([TxOp(category: "msg", key: k, kind: txPut, payload: payload("v1"))]).get() + b.applyOps([TxOp(category: "msg", key: k, kind: txPut, payload: payload("v2"))]).get() + check str(b.getOne("msg", k).get().get) == "v2" + + test "deleteOne reports whether the row existed": + let b = openBackendInMemory().get() + defer: + b.close() + let k = key("c1", 1'i64) + check not b.deleteOne("msg", k).get() + b.applyOps([TxOp(category: "msg", key: k, kind: txPut, payload: payload("x"))]).get() + check b.deleteOne("msg", k).get() + check not b.existsOne("msg", k).get() + + test "applyOps batches multiple ops atomically": + let b = openBackendInMemory().get() + defer: + b.close() + b + .applyOps( + [ + TxOp( + category: "msg", key: key("c1", 1'i64), kind: txPut, payload: payload("a") + ), + TxOp( + category: "msg", key: key("c1", 2'i64), kind: txPut, payload: payload("b") + ), + TxOp( + category: "msg", key: key("c1", 3'i64), kind: txPut, payload: payload("c") + ), + ] + ) + .get() + check b.countRange("msg", prefixRange(key("c1"))).get() == 3 + + test "scanRange ascending yields rows in key order": + let b = openBackendInMemory().get() + defer: + b.close() + let inserts = @[5'i64, 1, 4, 2, 3] + var ops: seq[TxOp] = @[] + for i in inserts: + ops.add( + TxOp(category: "msg", key: key("c1", i), kind: txPut, payload: payload($i)) + ) + b.applyOps(ops).get() + + let rows = b.scanRange("msg", prefixRange(key("c1"))).get() + check rows.len == 5 + var seenOrder: seq[string] + for r in rows: + seenOrder.add(str(r.payload)) + check seenOrder == @["1", "2", "3", "4", "5"] + + test "scanRange descending yields rows in reverse key order": + let b = openBackendInMemory().get() + defer: + b.close() + for i in [1'i64, 2, 3]: + b + .applyOps( + [TxOp(category: "msg", key: key("c1", i), kind: txPut, payload: payload($i))] + ) + .get() + let rows = b.scanRange("msg", prefixRange(key("c1")), reverse = true).get() + check rows.len == 3 + check str(rows[0].payload) == "3" + check str(rows[2].payload) == "1" + + test "scanRange respects half-open [start, stop) bounds": + let b = openBackendInMemory().get() + defer: + b.close() + for i in [1'i64, 2, 3, 4, 5]: + b + .applyOps( + [TxOp(category: "msg", key: key("c1", i), kind: txPut, payload: payload($i))] + ) + .get() + let rng = KeyRange(start: key("c1", 2'i64), stop: key("c1", 4'i64)) + let rows = b.scanRange("msg", rng).get() + check rows.len == 2 # 2 and 3, not 4 + check str(rows[0].payload) == "2" + check str(rows[1].payload) == "3" + + test "scanRange with empty stop is open-ended": + let b = openBackendInMemory().get() + defer: + b.close() + for i in [1'i64, 2, 3]: + b + .applyOps( + [TxOp(category: "msg", key: key("c1", i), kind: txPut, payload: payload($i))] + ) + .get() + let rng = KeyRange(start: key("c1", 2'i64), stop: rawKey(@[])) + let rows = b.scanRange("msg", rng).get() + check rows.len == 2 + check str(rows[1].payload) == "3" + + test "categories isolate keyspaces": + let b = openBackendInMemory().get() + defer: + b.close() + let k = key("c1", 1'i64) + b + .applyOps( + [ + TxOp(category: "log", key: k, kind: txPut, payload: payload("log-1")), + TxOp( + category: "outgoing", key: k, kind: txPut, payload: payload("outgoing-1") + ), + ] + ) + .get() + check str(b.getOne("log", k).get().get) == "log-1" + check str(b.getOne("outgoing", k).get().get) == "outgoing-1" + check b.countRange("log", prefixRange(key("c1"))).get() == 1 + check b.countRange("outgoing", prefixRange(key("c1"))).get() == 1 + + test "txDelete inside a batch removes the row": + let b = openBackendInMemory().get() + defer: + b.close() + let k = key("c1", 1'i64) + b + .applyOps( + [ + TxOp(category: "msg", key: k, kind: txPut, payload: payload("v")), + TxOp(category: "msg", key: k, kind: txDelete), + ] + ) + .get() + check not b.existsOne("msg", k).get() + + test "missing key returns none": + let b = openBackendInMemory().get() + defer: + b.close() + check b.getOne("msg", key("nope")).get().isNone + + test "countRange of empty category is zero": + let b = openBackendInMemory().get() + defer: + b.close() + check b.countRange("msg", prefixRange(key("c1"))).get() == 0 diff --git a/tests/persistency/test_encoding.nim b/tests/persistency/test_encoding.nim new file mode 100644 index 000000000..22bd58209 --- /dev/null +++ b/tests/persistency/test_encoding.nim @@ -0,0 +1,154 @@ +{.used.} + +import std/[algorithm, options, os, times] +import chronos, results +import testutils/unittests +import waku/persistency/persistency + +# Reusable byte-wise comparator (Key has its own `<`, but we sometimes +# want to sort `seq[Key]` here without relying on it for double-checking). +proc cmpBytes(a, b: Key): int = + let ab = bytes(a) + let bb = bytes(b) + let n = min(ab.len, bb.len) + for i in 0 ..< n: + if ab[i] != bb[i]: + return cmp(ab[i], bb[i]) + cmp(ab.len, bb.len) + +template str(b: seq[byte]): string = + var s = newString(b.len) + for i, x in b: + s[i] = char(x) + s + +# Shared payload types used by multiple tests. +type + Mood = enum + moodCalm + moodHappy + moodAngry + + Header = object + sender: string + epoch: int64 + + Msg = object + header: Header + mood: Mood + body: seq[byte] + +suite "Persistency generic encoding": + # ── Key macro: composite types ──────────────────────────────────────── + + test "key macro accepts plain tuples": + let k1 = key(("ch", 1'i64)) + let k2 = key("ch", 1'i64) + # A plain tuple is encoded field-by-field, so the result is identical + # to passing the fields directly. + check k1 == k2 + + test "key macro accepts named tuples": + type Coord = tuple[lane: string, seqNum: int64] + let k = key((lane: "a", seqNum: 7'i64)) + let kFlat = key("a", 7'i64) + check k == kFlat + + test "key macro accepts a user object": + let k1 = key(Header(sender: "alice", epoch: 5'i64)) + let k2 = key("alice", 5'i64) + check k1 == k2 + + test "key macro accepts nested object inside another arg": + let k1 = key("v1", Header(sender: "alice", epoch: 5'i64)) + let k2 = key("v1", "alice", 5'i64) + check k1 == k2 + + test "key macro encodes enums": + let k1 = key(moodAngry) + let k2 = key(int64(ord(moodAngry))) + check k1 == k2 + + test "toKey is equivalent to single-arg key()": + check toKey("x") == key("x") + check toKey(42'i64) == key(42'i64) + check toKey(Header(sender: "a", epoch: 1)) == key("a", 1'i64) + + test "tuple-encoded keys preserve field-major sort order": + let inputs = @[ + key(("a", 0'i64)), + key(("a", 1'i64)), + key(("a", int64.high)), + key(("b", int64.low)), + key(("b", 0'i64)), + ] + var shuffled = @[inputs[3], inputs[0], inputs[4], inputs[2], inputs[1]] + shuffled.sort(cmpBytes) + check shuffled == inputs + + test "embedded Key encodes verbatim": + let inner = key("a", 7'i64) + let outer = key("prefix", inner) + # Expanded: bytes of "prefix" + raw bytes of inner. + let expanded = key("prefix", "a", 7'i64) + check outer == expanded + + # ── Payload macro / toPayload ───────────────────────────────────────── + + test "toPayload encodes primitives": + check str(toPayload("hi")).len == 4 # 2-byte len prefix + 2 chars + check toPayload(42'i64).len == 8 + check toPayload(true) == @[1'u8] + check toPayload(false) == @[0'u8] + + test "toPayload encodes objects field-by-field": + let m = Msg( + header: Header(sender: "alice", epoch: 9'i64), + mood: moodHappy, + body: @[0xAA'u8, 0xBB, 0xCC], + ) + let p = toPayload(m) + let pManual = payload("alice", 9'i64, int64(ord(moodHappy)), @[0xAA'u8, 0xBB, 0xCC]) + check p == pManual + + test "payload macro concatenates parts": + let p = payload("v1", 1'i64, @[0xDE'u8, 0xAD]) + # Same as building each piece separately. + var expected: seq[byte] = @[] + encodePart(expected, "v1") + encodePart(expected, 1'i64) + encodePart(expected, @[0xDE'u8, 0xAD]) + check p == expected + + # ── End-to-end through the facade ───────────────────────────────────── + + asyncTest "persistEncoded round-trips a struct through SQLite": + let root = getTempDir() / ("persistency_enc_" & $epochTime().int) + removeDir(root) + defer: + removeDir(root) + let p = Persistency.instance(root).get() + defer: + Persistency.reset() + let job = p.openJob("t").get() + + let m = Msg( + header: Header(sender: "alice", epoch: 1'i64), + mood: moodHappy, + body: @[1'u8, 2, 3], + ) + let k = key("channel-42", m.header.epoch) + await job.persistEncoded("msg", k, m) + + # Poll for the row, then read it back as raw bytes. + let deadline = epochTime() + 1.0 + var got: Option[seq[byte]] + while epochTime() < deadline: + let r = await job.get("msg", k) + check r.isOk + got = r.get() + if got.isSome: + break + await sleepAsync(chronos.milliseconds(2)) + check got.isSome + check got.get == toPayload(m) diff --git a/tests/persistency/test_facade.nim b/tests/persistency/test_facade.nim new file mode 100644 index 000000000..5b5f9eac1 --- /dev/null +++ b/tests/persistency/test_facade.nim @@ -0,0 +1,196 @@ +{.used.} + +import std/[options, os, strutils, times] +import chronos, results +import testutils/unittests +import waku/persistency/persistency + +proc payload(s: string): seq[byte] = + result = newSeq[byte](s.len) + for i, c in s: + result[i] = byte(c) + +template str(b: seq[byte]): string = + var s = newString(b.len) + for i, x in b: + s[i] = char(x) + s + +proc tmpRoot(label: string): string = + let p = getTempDir() / ("persistency_facade_" & label & "_" & $epochTime().int) + removeDir(p) + p + +# Bounded poll on exists() to bridge the documented persist->read race. +proc waitUntilExists( + t: Job, category: string, k: Key, timeoutMs = 1000 +): Future[bool] {.async.} = + let deadline = epochTime() + (timeoutMs.float / 1000.0) + while epochTime() < deadline: + let r = await t.exists(category, k) + if r.isOk and r.get(): + return true + await sleepAsync(chronos.milliseconds(2)) + return false + +suite "Persistency facade": + asyncTest "persistPut then get round-trips": + let root = tmpRoot("put_get") + defer: + removeDir(root) + let p = Persistency.instance(root).get() + defer: + Persistency.reset() + let t = p.openJob("t").get() + + let k = key("c", 1'i64) + await t.persistPut("msg", k, payload("hi")) + let ckOk1 = await t.waitUntilExists("msg", k) + check ckOk1 + + let aw1 = await t.get("msg", k) + let got = aw1.get() + check got.isSome + check str(got.get) == "hi" + + asyncTest "persist (batch) is atomic and visible together": + let root = tmpRoot("batch") + defer: + removeDir(root) + let p = Persistency.instance(root).get() + defer: + Persistency.reset() + let t = p.openJob("t").get() + + var ops: seq[TxOp] + for i in 1'i64 .. 4: + ops.add( + TxOp(category: "msg", key: key("c", i), kind: txPut, payload: payload($i)) + ) + await t.persist(ops) + let ckOk2 = await t.waitUntilExists("msg", key("c", 4'i64)) + check ckOk2 + + let aw2 = await t.count("msg", prefixRange(key("c"))) + let cnt = aw2.get() + check cnt == 4 + + asyncTest "scanPrefix returns rows in key order": + let root = tmpRoot("scan") + defer: + removeDir(root) + let p = Persistency.instance(root).get() + defer: + Persistency.reset() + let t = p.openJob("t").get() + + for i in [3'i64, 1, 4, 1, 5, 9, 2]: + await t.persistPut("msg", key("c", i), payload($i)) + let ckOk3 = await t.waitUntilExists("msg", key("c", 9'i64)) + check ckOk3 + + let aw3 = await t.scanPrefix("msg", key("c")) + let rows = aw3.get() + # 7 ops with duplicate key i=1 -> 6 distinct rows + check rows.len == 6 + + var seenOrder: seq[int] + for r in rows: + seenOrder.add(parseInt(str(r.payload))) + check seenOrder == @[1, 2, 3, 4, 5, 9] + + asyncTest "scanPrefix reverse=true returns rows in reverse order": + let root = tmpRoot("scan_rev") + defer: + removeDir(root) + let p = Persistency.instance(root).get() + defer: + Persistency.reset() + let t = p.openJob("t").get() + + for i in 1'i64 .. 3: + await t.persistPut("msg", key("c", i), payload($i)) + let ckOk4 = await t.waitUntilExists("msg", key("c", 3'i64)) + check ckOk4 + + let aw4 = await t.scanPrefix("msg", key("c"), reverse = true) + let rows = aw4.get() + check rows.len == 3 + check str(rows[0].payload) == "3" + check str(rows[2].payload) == "1" + + asyncTest "deleteAcked round-trips and reports row presence": + let root = tmpRoot("delete") + defer: + removeDir(root) + let p = Persistency.instance(root).get() + defer: + Persistency.reset() + let t = p.openJob("t").get() + + let k = key("c", 1'i64) + let aw5 = await t.deleteAcked("msg", k) + let miss = aw5.get() + check miss == false + + await t.persistPut("msg", k, payload("v")) + let ckOk5 = await t.waitUntilExists("msg", k) + check ckOk5 + + let aw6 = await t.deleteAcked("msg", k) + let hit = aw6.get() + check hit == true + let aw7 = await t.exists("msg", k) + check aw7.get() == false + + asyncTest "persistDelete fire-and-forget removes the row": + let root = tmpRoot("fadel") + defer: + removeDir(root) + let p = Persistency.instance(root).get() + defer: + Persistency.reset() + let t = p.openJob("t").get() + + let k = key("c", 1'i64) + await t.persistPut("msg", k, payload("v")) + let ckOk6 = await t.waitUntilExists("msg", k) + check ckOk6 + await t.persistDelete("msg", k) + # Poll for absence. + let deadline = epochTime() + 1.0 + var gone = false + while epochTime() < deadline: + let aw8 = await t.exists("msg", k) + if not aw8.get(): + gone = true + break + await sleepAsync(chronos.milliseconds(2)) + check gone + + asyncTest "two jobs do not see each other's data via the facade": + let root = tmpRoot("iso") + defer: + removeDir(root) + let p = Persistency.instance(root).get() + defer: + Persistency.reset() + let a = p.openJob("a").get() + let b = p.openJob("b").get() + + let k = key("c", 1'i64) + await a.persistPut("msg", k, payload("A")) + await b.persistPut("msg", k, payload("B")) + let ckOk7 = await a.waitUntilExists("msg", k) + check ckOk7 + let ckOk8 = await b.waitUntilExists("msg", k) + check ckOk8 + + let aw9 = await a.get("msg", k) + check str(aw9.get().get) == "A" + let aw10 = await b.get("msg", k) + check str(aw10.get().get) == "B" + let aw11 = await a.count("msg", prefixRange(key("c"))) + check aw11.get() == 1 + let aw12 = await b.count("msg", prefixRange(key("c"))) + check aw12.get() == 1 diff --git a/tests/persistency/test_keys.nim b/tests/persistency/test_keys.nim new file mode 100644 index 000000000..e33020849 --- /dev/null +++ b/tests/persistency/test_keys.nim @@ -0,0 +1,135 @@ +{.used.} + +import std/[algorithm, sequtils] +import testutils/unittests +import waku/persistency/[types, keys] + +proc cmpBytes(a, b: Key): int = + let ab = bytes(a) + let bb = bytes(b) + let n = min(ab.len, bb.len) + for i in 0 ..< n: + if ab[i] != bb[i]: + return cmp(ab[i], bb[i]) + cmp(ab.len, bb.len) + +suite "Persistency keys": + test "string components sort by length, then byte order": + var ks = @[key("ab"), key(""), key("a"), key("aa"), key("b")] + ks.sort(cmpBytes) + # length-prefix encoding => shorter strings always sort before longer + # ones; same-length strings sort in byte order. + check ks == @[key(""), key("a"), key("b"), key("aa"), key("ab")] + + test "same-length strings sort in byte order": + var ks = @[key("delta"), key("alpha"), key("gamma"), key("bravo")] + ks.sort(cmpBytes) + check ks == @[key("alpha"), key("bravo"), key("delta"), key("gamma")] + + test "int64 sign-flip preserves order across negative/zero/positive": + let inputs = @[ + key("c", int64.low), + key("c", -2'i64), + key("c", -1'i64), + key("c", 0'i64), + key("c", 1'i64), + key("c", 2'i64), + key("c", int64.high), + ] + var shuffled = inputs + # rotate so the natural order is not the input order + shuffled = @[ + shuffled[3], + shuffled[6], + shuffled[0], + shuffled[5], + shuffled[1], + shuffled[4], + shuffled[2], + ] + shuffled.sort(cmpBytes) + check shuffled == inputs + + test "uint64 big-endian preserves order": + let inputs = @[ + key("u", 0'u64), + key("u", 1'u64), + key("u", 256'u64), + key("u", 1_000_000'u64), + key("u", uint64.high - 1), + key("u", uint64.high), + ] + var shuffled = @[inputs[3], inputs[0], inputs[5], inputs[2], inputs[1], inputs[4]] + shuffled.sort(cmpBytes) + check shuffled == inputs + + test "composite (string, string) tuple ordering": + # First component "a" / "b" — both length 1, so byte order applies. + # Second components grouped by first; within each group, again + # length-then-byte: "" (len 0) < "a","z" (len 1) < "ab" (len 2). + let inputs = @[ + key("a", ""), + key("a", "a"), + key("a", "z"), + key("a", "ab"), + key("b", ""), + key("b", "a"), + ] + var shuffled = inputs.reversed() + shuffled.sort(cmpBytes) + check shuffled == inputs + + test "composite (string, int64) tuple ordering": + let inputs = @[ + key("a", int64.low), + key("a", -1'i64), + key("a", 0'i64), + key("a", 1'i64), + key("b", int64.low), + key("b", 0'i64), + ] + var shuffled = inputs.reversed() + shuffled.sort(cmpBytes) + check shuffled == inputs + + test "shorter composite key precedes longer one sharing its prefix": + check key("a") < key("a", 0'i64) + check key("a") < key("a", "") + check key("a", "x") < key("a", "x", "y") + + test "Key equality is byte-wise": + check key("a", 1'i64) == key("a", 1'i64) + check not (key("a", 1'i64) == key("a", 2'i64)) + + test "prefixRange.start equals prefix": + let r = prefixRange(key("a")) + check r.start == key("a") + + test "prefixRange.stop excludes the prefix and admits all extensions": + let r = prefixRange(key("a")) + let extensions = @[ + key("a"), + key("a", 0'i64), + key("a", int64.high), + key("a", "x"), + key("a", uint64.high), + ] + for k in extensions: + check r.start <= k + check k < r.stop + + test "prefixRange.stop excludes siblings outside the prefix": + let r = prefixRange(key("a")) + # "b" has the same encoded length as "a" but a higher last byte, so it + # should be at-or-above the exclusive stop. + check not (key("b") < r.stop) + # "ab" has more bytes — its 2-byte length prefix bumps it past stop. + check not (key("ab") < r.stop) + # The empty key sits before the start. + check key("") < r.start + + test "prefixRange handles all-0xFF prefix as open-ended": + let prefix = rawKey(@[0xFF'u8, 0xFF, 0xFF]) + let r = prefixRange(prefix) + check r.start == prefix + check bytes(r.stop).len == 0 diff --git a/tests/persistency/test_lifecycle.nim b/tests/persistency/test_lifecycle.nim new file mode 100644 index 000000000..6b1a6ee60 --- /dev/null +++ b/tests/persistency/test_lifecycle.nim @@ -0,0 +1,302 @@ +{.used.} + +import std/[options, os, times] +import chronos, results +import testutils/unittests +import brokers/[event_broker, request_broker] +import waku/persistency/persistency +import waku/persistency/backend_comm + +proc payloadBytes(s: string): seq[byte] = + result = newSeq[byte](s.len) + for i, c in s: + result[i] = byte(c) + +template str(b: seq[byte]): string = + var s = newString(b.len) + for i, x in b: + s[i] = char(x) + s + +proc tmpRoot(label: string): string = + let p = getTempDir() / ("persistency_test_" & label & "_" & $epochTime().int) + removeDir(p) + p + +# Cross-thread persist: emit a PersistEvent then poll until the row shows up +# via KvExists. The PersistEvent listener is fire-and-forget, so reads +# immediately after emit are racy by design (documented in v1). +proc pollExists( + t: Job, category: string, k: Key, timeoutMs = 1000 +): Future[bool] {.async.} = + let deadline = epochTime() + (timeoutMs.float / 1000.0) + while epochTime() < deadline: + let r = await KvExists.request(t.context, category, k) + if r.isOk and r.get().value: + return true + await sleepAsync(chronos.milliseconds(2)) + return false + +suite "Persistency lifecycle": + test "Persistency.instance accepts a pre-existing rootDir": + let root = tmpRoot("preexisting") + defer: + removeDir(root) + createDir(root) # pretend a previous run left it + let marker = root / "do-not-touch.txt" + writeFile(marker, "hi") + defer: + removeFile(marker) + + let p = Persistency.instance(root).get() + defer: + Persistency.reset() + # The pre-existing file is untouched. + check fileExists(marker) + check readFile(marker) == "hi" + + test "Persistency.instance refuses a non-directory path": + let root = tmpRoot("collision") + defer: + removeFile(root) + writeFile(root, "im a file not a dir") # collide with rootDir name + let r = Persistency.instance(root) + check r.isErr + check r.error.kind == peInvalidArgument + + test "Persistency.instance defers rootDir creation until first openJob": + let root = tmpRoot("lazy") + defer: + removeDir(root) + check not dirExists(root) + + let p = Persistency.instance(root).get() + defer: + Persistency.reset() + # instance() must not have touched the filesystem + check not dirExists(root) + + discard p.openJob("first").get() + # first openJob materialises the directory + check dirExists(root) + + test "Persistency.instance refuses a path whose ancestor is not a directory": + let parent = tmpRoot("bad-parent") + defer: + removeFile(parent) + writeFile(parent, "not a directory") + let root = parent / "child" + let r = Persistency.instance(root) + check r.isErr + check r.error.kind == peInvalidArgument + + asyncTest "openJob reuses an existing DB file across processes-of-one": + let root = tmpRoot("reopen") + defer: + removeDir(root) + + # First "session": write something then close. + block firstSession: + let p = Persistency.instance(root).get() + let j = p.openJob("persist").get() + await j.persistPut("msg", key("c", 1'i64), payloadBytes("v1")) + let ckOk1 = await j.pollExists("msg", key("c", 1'i64)) + check ckOk1 + Persistency.reset() + + check fileExists(root / "persist.db") + + # Second "session": reopen and read the data back. + block secondSession: + let p = Persistency.instance(root).get() + defer: + Persistency.reset() + let j = p.openJob("persist").get() + let aw1 = await KvGet.request(j.context, "msg", key("c", 1'i64)) + let got = aw1.get() + check got.value.isSome + check str(got.value.get) == "v1" + + test "openJob is idempotent within a session": + let root = tmpRoot("idem") + defer: + removeDir(root) + let p = Persistency.instance(root).get() + defer: + Persistency.reset() + let a = p.openJob("same").get() + let b = p.openJob("same").get() + check a.id == b.id + check a.context == b.context + + test "openJob materialises rootDir and launches a worker": + let root = tmpRoot("basic") + defer: + removeDir(root) + + let p = Persistency.instance(root).get() + defer: + Persistency.reset() + + let t = p.openJob("alpha").get() + check t.id == "alpha" + check t.running + check fileExists(root / "alpha.db") + + asyncTest "persist then read round-trips via brokers": + let root = tmpRoot("rw") + defer: + removeDir(root) + let p = Persistency.instance(root).get() + defer: + Persistency.reset() + let t = p.openJob("t1").get() + + let k = key("c", 1'i64) + let ev = PersistEvent( + ops: @[TxOp(category: "msg", key: k, kind: txPut, payload: payloadBytes("hello"))] + ) + await PersistEvent.emit(t.context, ev) + let ckOk2 = await t.pollExists("msg", k) + check ckOk2 + + let aw2 = await KvGet.request(t.context, "msg", k) + let got = aw2.get() + check got.value.isSome + check str(got.value.get) == "hello" + + asyncTest "two jobs run in parallel with isolated DBs": + let root = tmpRoot("isolation") + defer: + removeDir(root) + let p = Persistency.instance(root).get() + defer: + Persistency.reset() + + let a = p.openJob("alpha").get() + let b = p.openJob("beta").get() + check a.context != b.context + + let k = key("shared", 1'i64) + await PersistEvent.emit( + a.context, + PersistEvent( + ops: @[ + TxOp( + category: "msg", key: k, kind: txPut, payload: payloadBytes("from-alpha") + ) + ] + ), + ) + await PersistEvent.emit( + b.context, + PersistEvent( + ops: @[ + TxOp(category: "msg", key: k, kind: txPut, payload: payloadBytes("from-beta")) + ] + ), + ) + let ckOk3 = await a.pollExists("msg", k) + check ckOk3 + let ckOk4 = await b.pollExists("msg", k) + check ckOk4 + + let aw3 = await KvGet.request(a.context, "msg", k) + let aGot = aw3.get() + let aw4 = await KvGet.request(b.context, "msg", k) + let bGot = aw4.get() + check str(aGot.value.get) == "from-alpha" + check str(bGot.value.get) == "from-beta" + + # Each job has its own DB file. + check fileExists(root / "alpha.db") + check fileExists(root / "beta.db") + + asyncTest "closeJob joins the worker and frees the slot": + let root = tmpRoot("close") + defer: + removeDir(root) + let p = Persistency.instance(root).get() + defer: + Persistency.reset() + + let t = p.openJob("x").get() + let ctx = t.context + p.closeJob("x") + check not t.running + + # After close, requests on the old context have no provider. + let r = await KvExists.request(ctx, "msg", key("k")) + check r.isErr + + test "dropJob removes the DB file": + let root = tmpRoot("drop") + defer: + removeDir(root) + let p = Persistency.instance(root).get() + defer: + Persistency.reset() + discard p.openJob("ephemeral").get() + check fileExists(root / "ephemeral.db") + p.dropJob("ephemeral") + check not fileExists(root / "ephemeral.db") + + asyncTest "scan and count over a range": + let root = tmpRoot("scan") + defer: + removeDir(root) + let p = Persistency.instance(root).get() + defer: + Persistency.reset() + let t = p.openJob("t").get() + + var ops: seq[TxOp] + for i in 1'i64 .. 5: + ops.add( + TxOp(category: "msg", key: key("c", i), kind: txPut, payload: payloadBytes($i)) + ) + await PersistEvent.emit(t.context, PersistEvent(ops: ops)) + # Wait for the last insert to land. + let ckOk5 = await t.pollExists("msg", key("c", 5'i64)) + check ckOk5 + + let rng = prefixRange(key("c")) + let aw5 = await KvCount.request(t.context, "msg", rng) + let cnt = aw5.get() + check cnt.n == 5 + + let aw6 = await KvScan.request(t.context, "msg", rng, false) + let scn = aw6.get() + check scn.rows.len == 5 + check str(scn.rows[0].payload) == "1" + check str(scn.rows[4].payload) == "5" + + asyncTest "acked delete reports whether the row existed": + let root = tmpRoot("delete") + defer: + removeDir(root) + let p = Persistency.instance(root).get() + defer: + Persistency.reset() + let t = p.openJob("t").get() + + let k = key("d", 1'i64) + let aw7 = await KvDelete.request(t.context, "msg", k) + let r1 = aw7.get() + check r1.existed == false + + await PersistEvent.emit( + t.context, + PersistEvent( + ops: @[TxOp(category: "msg", key: k, kind: txPut, payload: payloadBytes("v"))] + ), + ) + let ckOk6 = await t.pollExists("msg", k) + check ckOk6 + + let aw8 = await KvDelete.request(t.context, "msg", k) + let r2 = aw8.get() + check r2.existed == true + let aw9 = await KvExists.request(t.context, "msg", k) + let r3 = aw9.get() + check r3.value == false diff --git a/tests/persistency/test_singleton.nim b/tests/persistency/test_singleton.nim new file mode 100644 index 000000000..f17841611 --- /dev/null +++ b/tests/persistency/test_singleton.nim @@ -0,0 +1,79 @@ +{.used.} + +import std/[os, strutils, times] +import chronos, results +import testutils/unittests +import brokers/multi_request_broker +import waku/persistency/persistency + +proc tmpRoot(label: string): string = + let p = getTempDir() / ("persistency_singleton_" & label & "_" & $epochTime().int) + removeDir(p) + p + +suite "Persistency singleton": + test "instance(rootDir) is idempotent with the same rootDir": + let root = tmpRoot("idem") + defer: + removeDir(root) + defer: + Persistency.reset() + + let p1 = Persistency.instance(root).get() + let p2 = Persistency.instance(root).get() + check p1 == p2 + + test "instance(rootDir) refuses re-init with a different rootDir": + let rootA = tmpRoot("a") + let rootB = tmpRoot("b") + defer: + removeDir(rootA) + defer: + removeDir(rootB) + defer: + Persistency.reset() + + discard Persistency.instance(rootA).get() + let r = Persistency.instance(rootB) + check r.isErr + check r.error.kind == peInvalidArgument + + test "no-arg instance() fails before init, succeeds after": + let root = tmpRoot("noarg") + defer: + removeDir(root) + defer: + Persistency.reset() + + let before = Persistency.instance() + check before.isErr + check before.error.kind == peClosed + + discard Persistency.instance(root).get() + let after = Persistency.instance() + check after.isOk + + test "reset() makes the next instance() target a different rootDir": + let rootA = tmpRoot("rs-a") + let rootB = tmpRoot("rs-b") + defer: + removeDir(rootA) + defer: + removeDir(rootB) + defer: + Persistency.reset() + + let pA = Persistency.instance(rootA).get() + check pA.rootDir == rootA + Persistency.reset() + + let pB = Persistency.instance(rootB).get() + check pB.rootDir == rootB + check pA != pB + + test "reset() is idempotent": + defer: + Persistency.reset() + Persistency.reset() + Persistency.reset() + check Persistency.instance().isErr diff --git a/tests/persistency/test_string_lookup.nim b/tests/persistency/test_string_lookup.nim new file mode 100644 index 000000000..11ac5fed3 --- /dev/null +++ b/tests/persistency/test_string_lookup.nim @@ -0,0 +1,184 @@ +{.used.} + +import std/[options, os, times] +import chronos, results +import testutils/unittests +import waku/persistency/persistency + +proc payloadBytes(s: string): seq[byte] = + result = newSeq[byte](s.len) + for i, c in s: + result[i] = byte(c) + +template str(b: seq[byte]): string = + var s = newString(b.len) + for i, x in b: + s[i] = char(x) + s + +proc tmpRoot(label: string): string = + let p = getTempDir() / ("persistency_lookup_" & label & "_" & $epochTime().int) + removeDir(p) + p + +# Bridge the persist->read race (writes are fire-and-forget in v1). +proc waitUntilExists( + p: Persistency, jobId, category: string, k: Key, timeoutMs = 1000 +): Future[bool] {.async.} = + let deadline = epochTime() + (timeoutMs.float / 1000.0) + while epochTime() < deadline: + let r = await p.exists(jobId, category, k) + if r.isOk and r.get(): + return true + await sleepAsync(chronos.milliseconds(2)) + return false + +suite "Persistency string-id lookup": + test "job(p, id) returns peJobNotFound when not open": + let root = tmpRoot("notfound") + defer: + removeDir(root) + let p = Persistency.instance(root).get() + defer: + Persistency.reset() + + let r = p.job("nope") + check r.isErr + check r.error.kind == peJobNotFound + + test "job(p, id) returns the Job after openJob": + let root = tmpRoot("found") + defer: + removeDir(root) + let p = Persistency.instance(root).get() + defer: + Persistency.reset() + + let opened = p.openJob("alpha").get() + let looked = p.job("alpha").get() + check looked.id == "alpha" + check looked == opened # same ref, no need to peek at .context + + test "hasJob mirrors p.job()": + let root = tmpRoot("has") + defer: + removeDir(root) + let p = Persistency.instance(root).get() + defer: + Persistency.reset() + + check not p.hasJob("x") + discard p.openJob("x") + check p.hasJob("x") + p.closeJob("x") + check not p.hasJob("x") + + test "subscript [] returns the open Job": + let root = tmpRoot("subscript") + defer: + removeDir(root) + let p = Persistency.instance(root).get() + defer: + Persistency.reset() + discard p.openJob("a").get() + let j = p["a"] + check j.id == "a" + + asyncTest "string-lookup persistPut + get round-trips without a Job ref": + let root = tmpRoot("rw") + defer: + removeDir(root) + let p = Persistency.instance(root).get() + defer: + Persistency.reset() + discard p.openJob("svc").get() + + let k = key("c", 1'i64) + await p.persistPut("svc", "msg", k, payloadBytes("hello")) + let ckOk1 = await p.waitUntilExists("svc", "msg", k) + check ckOk1 + + let aw1 = await p.get("svc", "msg", k) + let got = aw1.get() + check got.isSome + check str(got.get) == "hello" + + asyncTest "string-lookup reads short-circuit with peJobNotFound": + let root = tmpRoot("missingread") + defer: + removeDir(root) + let p = Persistency.instance(root).get() + defer: + Persistency.reset() + + let g = await p.get("nope", "msg", key("k")) + check g.isErr + check g.error.kind == peJobNotFound + + let c = await p.count("nope", "msg", prefixRange(key("k"))) + check c.isErr + check c.error.kind == peJobNotFound + + let d = await p.deleteAcked("nope", "msg", key("k")) + check d.isErr + check d.error.kind == peJobNotFound + + asyncTest "string-lookup writes to an unknown job are dropped, not raised": + let root = tmpRoot("missingwrite") + defer: + removeDir(root) + let p = Persistency.instance(root).get() + defer: + Persistency.reset() + + # Should not raise and should not leak any state. + await p.persistPut("ghost", "msg", key("k"), payloadBytes("v")) + await p.persistDelete("ghost", "msg", key("k")) + await p.persistEncoded("ghost", "msg", key("k"), 42'i64) + check not p.hasJob("ghost") + + asyncTest "string-lookup persistEncoded round-trips a struct": + let root = tmpRoot("encoded") + defer: + removeDir(root) + type Item = object + tag: string + n: int64 + + let p = Persistency.instance(root).get() + defer: + Persistency.reset() + discard p.openJob("e").get() + + let k = key("items", 1'i64) + await p.persistEncoded("e", "msg", k, Item(tag: "alpha", n: 7)) + let ckOk2 = await p.waitUntilExists("e", "msg", k) + check ckOk2 + + let aw2 = await p.get("e", "msg", k) + let got = aw2.get() + check got.isSome + check got.get == toPayload(Item(tag: "alpha", n: 7)) + + asyncTest "string-lookup scan returns the same rows as Job-form": + let root = tmpRoot("scan") + defer: + removeDir(root) + let p = Persistency.instance(root).get() + defer: + Persistency.reset() + let j = p.openJob("s").get() + + for i in 1'i64 .. 3: + await p.persistPut("s", "msg", key("c", i), payloadBytes($i)) + let ckOk3 = await p.waitUntilExists("s", "msg", key("c", 3'i64)) + check ckOk3 + + let aw3 = await p.scanPrefix("s", "msg", key("c")) + let viaId = aw3.get() + let aw4 = await j.scanPrefix("msg", key("c")) + let viaRef = aw4.get() + check viaId.len == viaRef.len + for i in 0 ..< viaId.len: + check viaId[i].key == viaRef[i].key + check viaId[i].payload == viaRef[i].payload diff --git a/tests/simulator/rln-e2e-test.py b/tests/simulator/rln-e2e-test.py new file mode 100755 index 000000000..4248ee1f6 --- /dev/null +++ b/tests/simulator/rln-e2e-test.py @@ -0,0 +1,388 @@ +#!/usr/bin/env python3 +""" +RLN end-to-end test against a running logos-delivery-simulator stack. + +Designed to run as a sidecar container on the simulator's Docker network so +hostnames like `logos-delivery-simulator-nwaku-1` resolve via Docker DNS. + +Scenarios covered (in order): + 1. HEALTH - every node responds to /debug/v1/info with an enrUri + 2. SUBSCRIBE - every node REST-subscribes to the pubsub topic + 3. WITHIN_LIMIT - every node concurrently sends msg_limit messages -> 200 + 4. PROPAGATION - one sender's message lands in all peers' inboxes + 5. OVER_LIMIT - one extra message per node -> 500 (rate-limit hit) + 6. EPOCH_RESET - after epoch_sec, every node can send 1 more -> 200 + 7. SAME_MESSAGE_ID - sending same message_id twice in same epoch is the + slashable signal (verified by checking node logs) + +Exit code: + 0 = all scenarios passed + N = number of scenarios that failed + +Usage (typical): + docker run --rm \\ + --network logos-delivery-simulator_simulation \\ + -v /path/to/rln-e2e-test.py:/test.py \\ + python:3.11-slim \\ + sh -c 'pip install --quiet requests && python /test.py \\ + --hostname-prefix logos-delivery-simulator-nwaku- \\ + --num-nodes 30 --msg-limit 30 --epoch-sec 15' +""" + +import argparse +import base64 +import concurrent.futures as cf +import json +import os +import sys +import time +import urllib.parse +from dataclasses import dataclass +from typing import Optional + +import requests + +PUBSUB_TOPIC = "/waku/2/rs/66/0" +CONTENT_TOPIC = "/rln-test/1/probe/proto" + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- + +def url_of(host: str, port: int = 8645) -> str: + return f"http://{host}:{port}" + + +def waku_publish(node_url: str, payload: bytes, timeout: float = 5.0) -> int: + body = { + "payload": base64.b64encode(payload).decode("ascii"), + "contentTopic": CONTENT_TOPIC, + "version": 1, + "timestamp": time.time_ns(), + } + enc = urllib.parse.quote(PUBSUB_TOPIC, safe="") + try: + r = requests.post( + f"{node_url}/relay/v1/messages/{enc}", + json=body, + timeout=timeout, + headers={"content-type": "application/json"}, + ) + return r.status_code + except requests.RequestException: + return -1 + + +def waku_subscribe(node_url: str, timeout: float = 5.0) -> int: + try: + r = requests.post( + f"{node_url}/relay/v1/subscriptions", + json=[PUBSUB_TOPIC], + timeout=timeout, + headers={"content-type": "application/json"}, + ) + return r.status_code + except requests.RequestException: + return -1 + + +def waku_get_messages(node_url: str, timeout: float = 5.0) -> Optional[list]: + enc = urllib.parse.quote(PUBSUB_TOPIC, safe="") + try: + r = requests.get( + f"{node_url}/relay/v1/messages/{enc}", + timeout=timeout, + ) + if r.status_code != 200: + return None + return r.json() + except (requests.RequestException, json.JSONDecodeError): + return None + + +def node_healthy(node_url: str, timeout: float = 3.0) -> bool: + try: + r = requests.get(f"{node_url}/debug/v1/info", timeout=timeout) + return r.status_code == 200 and "enrUri" in r.json() + except (requests.RequestException, json.JSONDecodeError): + return False + + +# --------------------------------------------------------------------------- +# scenarios +# --------------------------------------------------------------------------- + +@dataclass +class Result: + name: str + ok: bool + detail: str = "" + + def __str__(self) -> str: + status = "PASS" if self.ok else "FAIL" + s = f"[{status}] {self.name}" + if self.detail: + s += f" — {self.detail}" + return s + + +def scenario_health(nodes: list[str], deadline_sec: int = 120) -> Result: + """Every node must be reachable within deadline_sec.""" + start = time.time() + unhealthy = list(nodes) + while time.time() - start < deadline_sec and unhealthy: + with cf.ThreadPoolExecutor(max_workers=min(32, len(unhealthy))) as ex: + results = list(ex.map(node_healthy, [url_of(n) for n in unhealthy])) + unhealthy = [n for n, ok in zip(unhealthy, results) if not ok] + if unhealthy: + time.sleep(3) + return Result( + "HEALTH", + not unhealthy, + f"{len(nodes) - len(unhealthy)}/{len(nodes)} healthy" + + (f"; failing: {unhealthy[:5]}" if unhealthy else ""), + ) + + +def scenario_subscribe(nodes: list[str]) -> Result: + """REST-subscribe every node to the pubsub topic so GETs return cached msgs.""" + with cf.ThreadPoolExecutor(max_workers=min(32, len(nodes))) as ex: + codes = list(ex.map(waku_subscribe, [url_of(n) for n in nodes])) + bad = [(n, c) for n, c in zip(nodes, codes) if c != 200] + return Result( + "SUBSCRIBE", + not bad, + f"{len(nodes) - len(bad)}/{len(nodes)} subscribed" + + (f"; failing: {bad[:5]}" if bad else ""), + ) + + +def _send_n(node_url: str, n: int) -> list[int]: + codes = [] + for i in range(n): + codes.append(waku_publish(node_url, f"probe-{i}".encode())) + return codes + + +def _burst_until_blocked(node_url: str, msg_limit: int, overshoot: int = 3): + """Send msg_limit+overshoot messages back-to-back, fast, recording codes. + Designed to complete inside a single epoch — keep epoch_sec large enough + that this burst can't straddle an epoch boundary. + + Returns (n_200, n_500, n_transport_err, two_hundred_after_block) where + two_hundred_after_block flags a 200 appearing AFTER the first 500 (i.e. + quota reset mid-burst => epoch straddle).""" + codes = [] + for i in range(msg_limit + overshoot): + codes.append(waku_publish(node_url, f"burst-{i}".encode(), timeout=10.0)) + n_200 = sum(c == 200 for c in codes) + n_500 = sum(c == 500 for c in codes) + n_err = sum(c not in (200, 500) for c in codes) # -1, 4xx transient, etc. + first_block_idx = next((i for i, c in enumerate(codes) if c == 500), None) + two_hundred_after_block = ( + first_block_idx is not None + and any(c == 200 for c in codes[first_block_idx + 1:]) + ) + return n_200, n_500, n_err, two_hundred_after_block + + +def _publish_until_ok(node_url: str, attempts: int = 20, spacing: float = 5.0) -> bool: + """Retry a single publish until it returns 200 or attempts run out. + Tolerates the post-startup window where discv5/gossipsub mesh is still + forming and the RLN publish path transiently 500s.""" + for _ in range(attempts): + if waku_publish(node_url, b"warmup", timeout=10.0) == 200: + return True + time.sleep(spacing) + return False + + +def scenario_warmup(nodes: list[str], attempts: int = 20) -> Result: + """Readiness gate: every node must successfully publish at least once. + This absorbs mesh-formation churn so PROPAGATION/RATE_LIMIT aren't + judging a not-yet-connected fleet. Consumes 1 nonce/node — well within + msg_limit, and RATE_LIMIT's tolerance accounts for it.""" + with cf.ThreadPoolExecutor(max_workers=min(8, len(nodes))) as ex: + ready = list(ex.map(lambda n: _publish_until_ok(url_of(n), attempts), nodes)) + not_ready = [n for n, ok in zip(nodes, ready) if not ok] + return Result( + "WARMUP", + not not_ready, + f"{len(nodes) - len(not_ready)}/{len(nodes)} nodes publishing" + + (f"; never ready: {not_ready[:5]}" if not_ready else ""), + ) + + +def scenario_rate_limit(nodes: list[str], msg_limit: int, tolerance: int = 3) -> Result: + """Per-node burst of msg_limit+3 messages within one epoch. + + The RLN invariant being checked: + (a) a node must NEVER publish more than msg_limit in one epoch, and + (b) the node must enforce a 500 ceiling once the quota is exhausted. + + Transient HTTP errors under concurrent load can lower the accepted count + below msg_limit — that does NOT violate the invariant, so we accept + successes in [msg_limit - tolerance, msg_limit]. successes > msg_limit OR + a 200 after the first 500 means the epoch rolled mid-burst (raise + RLN_RELAY_EPOCH_SEC) — reported as a timing skew, not an RLN failure.""" + # Cap concurrency: firing len(nodes)*(msg_limit+3) publishes all at once + # saturates small CI runners (2 vCPU) and causes publish-path timeouts + # that masquerade as rate-limit failures. + with cf.ThreadPoolExecutor(max_workers=min(5, len(nodes))) as ex: + per_node = list( + ex.map(lambda n: _burst_until_blocked(url_of(n), msg_limit), nodes) + ) + + rate_failures = [] # genuine RLN misbehaviour + timing_skews = [] # epoch straddled mid-burst — inconclusive + for node, (n_200, n_500, n_err, after_block) in zip(nodes, per_node): + if n_200 > msg_limit or after_block: + timing_skews.append( + (node, f"{n_200} ok, epoch rolled mid-burst (raise epoch_sec)") + ) + elif n_500 == 0: + rate_failures.append((node, f"no 500 ceiling ({n_200} ok, {n_err} err)")) + elif n_200 < msg_limit - tolerance: + rate_failures.append( + (node, f"only {n_200}/{msg_limit} ok ({n_err} transport err)") + ) + + if timing_skews and not rate_failures: + return Result( + "RATE_LIMIT", + False, + f"INCONCLUSIVE (timing) — raise RLN_RELAY_EPOCH_SEC; " + f"{len(timing_skews)} node(s) straddled an epoch: {timing_skews[:3]}", + ) + ok = not rate_failures and not timing_skews + good = len(nodes) - len(rate_failures) - len(timing_skews) + return Result( + "RATE_LIMIT", + ok, + f"{good}/{len(nodes)} nodes enforced <= {msg_limit} then 500 " + f"(tolerance {tolerance} for transport noise)" + + (f"; rate failures: {rate_failures[:3]}" if rate_failures else "") + + (f"; timing skews: {timing_skews[:3]}" if timing_skews else ""), + ) + + +def scenario_propagation( + sender: str, receivers: list[str], settle_sec: int = 5 +) -> Result: + """Send one message on `sender`, expect it visible in every receiver's + REST inbox within settle_sec.""" + marker = f"propagation-marker-{time.time_ns()}".encode() + code = waku_publish(url_of(sender), marker) + if code != 200: + return Result("PROPAGATION", False, f"sender publish returned {code}") + + time.sleep(settle_sec) + missing = [] + with cf.ThreadPoolExecutor(max_workers=min(32, len(receivers))) as ex: + inboxes = list(ex.map(waku_get_messages, [url_of(r) for r in receivers])) + + encoded_marker = base64.b64encode(marker).decode().rstrip("=") + for r, inbox in zip(receivers, inboxes): + if inbox is None: + missing.append((r, "GET failed")) + continue + # Look for our marker payload in any message + found = any( + (m.get("payload") or "").rstrip("=") == encoded_marker + for m in inbox + ) + if not found: + missing.append((r, f"{len(inbox)} msgs, marker not present")) + + return Result( + "PROPAGATION", + not missing, + f"{len(receivers) - len(missing)}/{len(receivers)} receivers got the message" + + (f"; missing on {missing[:3]}" if missing else ""), + ) + + +def scenario_epoch_reset(nodes: list[str], epoch_sec: int) -> Result: + """After epoch_sec + slack, each node can send 1 more message — expect 200.""" + sleep_s = epoch_sec + 3 + print(f" sleeping {sleep_s}s for epoch reset...") + time.sleep(sleep_s) + with cf.ThreadPoolExecutor(max_workers=len(nodes)) as ex: + codes = list( + ex.map( + lambda n: waku_publish(url_of(n), b"post-epoch"), + nodes, + ) + ) + bad = [(n, c) for n, c in zip(nodes, codes) if c != 200] + return Result( + "EPOCH_RESET", + not bad, + f"{sum(c == 200 for c in codes)}/{len(nodes)} returned 200 after epoch reset" + + (f"; failing: {bad[:3]}" if bad else ""), + ) + + +# --------------------------------------------------------------------------- +# main +# --------------------------------------------------------------------------- + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--hostname-prefix", default="logos-delivery-simulator-nwaku-") + ap.add_argument("--num-nodes", type=int, default=30) + ap.add_argument("--msg-limit", type=int, default=30, + help="Must match RLN_RELAY_MSG_LIMIT in simulator .env") + ap.add_argument("--epoch-sec", type=int, default=15, + help="Must match RLN_RELAY_EPOCH_SEC in simulator .env") + ap.add_argument("--health-deadline-sec", type=int, default=180) + args = ap.parse_args() + + nodes = [f"{args.hostname_prefix}{i}" for i in range(1, args.num_nodes + 1)] + print(f"Testing {len(nodes)} nodes: {nodes[0]} … {nodes[-1]}") + print(f"Config: msg_limit={args.msg_limit}, epoch_sec={args.epoch_sec}") + print() + + results: list[Result] = [] + + def run(scenario_fn, *fn_args, **fn_kwargs) -> bool: + r = scenario_fn(*fn_args, **fn_kwargs) + results.append(r) + print(r) + return r.ok + + if not run(scenario_health, nodes, deadline_sec=args.health_deadline_sec): + print("\nABORTING — nodes never reached healthy state.") + return _summarize(results) + + if not run(scenario_subscribe, nodes): + print("\nABORTING — could not subscribe nodes to pubsub topic.") + return _summarize(results) + + # Readiness gate: wait out mesh-formation churn before judging behaviour. + if not run(scenario_warmup, nodes): + print("\nABORTING — fleet never reached a publishable state.") + return _summarize(results) + + run(scenario_propagation, nodes[0], nodes[1:]) + # Rate limit: per-node burst, asserts exactly msg_limit then 500. + # Requires epoch_sec large enough that the burst can't straddle an epoch. + run(scenario_rate_limit, nodes, args.msg_limit) + run(scenario_epoch_reset, nodes, args.epoch_sec) + + return _summarize(results) + + +def _summarize(results: list[Result]) -> int: + print() + print("=" * 64) + passed = sum(r.ok for r in results) + print(f" {passed}/{len(results)} scenarios passed") + for r in results: + print(f" {r}") + print("=" * 64) + return len(results) - passed + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/simulator/rln-sim.env b/tests/simulator/rln-sim.env new file mode 100644 index 000000000..ec86f1eea --- /dev/null +++ b/tests/simulator/rln-sim.env @@ -0,0 +1,6 @@ +# Source of truth for the RLN simulator E2E run (ci-rln-simulator.yml). +# workflow_dispatch inputs override any value here per-run (blank input = use this file). +BRANCH=master +NUM_NODES=6 +MSG_LIMIT=30 +EPOCH_SEC=120 diff --git a/tests/test_peer_manager.nim b/tests/test_peer_manager.nim index f78c3831f..608889d32 100644 --- a/tests/test_peer_manager.nim +++ b/tests/test_peer_manager.nim @@ -54,6 +54,44 @@ procSuite "Peer Manager": nodes[0].peerManager.switch.peerStore.connectedness(nodes[1].peerInfo.peerId) == Connectedness.Connected + asyncTest "Peer manager tracks active store request state": + let nodes = toSeq(0 ..< 2).mapIt( + newTestWakuNode(generateSecp256k1Key(), parseIpAddress("0.0.0.0"), Port(0)) + ) + + await allFutures(nodes.mapIt(it.start())) + await allFutures(nodes.mapIt(it.mountRelay())) + + let peerId = nodes[1].peerInfo.peerId + require ( + await nodes[0].peerManager.connectPeer(nodes[1].peerInfo.toRemotePeerInfo()) + ) + await sleepAsync(chronos.milliseconds(500)) + + nodes[0].peerManager.addActiveStoreRequest(peerId) + check: + nodes[0].peerManager.hasActiveStoreRequest(peerId) + + await nodes[0].peerManager.evictPeer(peerId) + await sleepAsync(chronos.milliseconds(100)) + + check: + nodes[0].peerManager.switch.peerStore.connectedness(peerId) == + Connectedness.Connected + + nodes[0].peerManager.removeActiveStoreRequest(peerId) + check: + not nodes[0].peerManager.hasActiveStoreRequest(peerId) + + await nodes[0].peerManager.evictPeer(peerId) + await sleepAsync(chronos.milliseconds(100)) + + check: + nodes[0].peerManager.switch.peerStore.connectedness(peerId) != + Connectedness.Connected + + await allFutures(nodes.mapIt(it.stop())) + asyncTest "dialPeer() works": # Create 2 nodes let nodes = toSeq(0 ..< 2).mapIt( diff --git a/tests/test_utils_compat.nim b/tests/test_utils_compat.nim index 121efa4a5..1394982ef 100644 --- a/tests/test_utils_compat.nim +++ b/tests/test_utils_compat.nim @@ -1,7 +1,7 @@ {.used.} import testutils/unittests -import stew/results, waku/waku_core/message, waku/waku_core/time, ./testlib/common +import results, waku/waku_core/message, waku/waku_core/time, ./testlib/common suite "Waku Payload": test "Encode/Decode waku message with timestamp": diff --git a/tests/waku_enr/test_sharding.nim b/tests/waku_enr/test_sharding.nim index 344436d0e..789f8faec 100644 --- a/tests/waku_enr/test_sharding.nim +++ b/tests/waku_enr/test_sharding.nim @@ -1,7 +1,7 @@ {.used.} import - stew/results, + results, chronos, testutils/unittests, libp2p/crypto/crypto as libp2p_keys, diff --git a/tests/waku_lightpush_legacy/lightpush_utils.nim b/tests/waku_lightpush_legacy/lightpush_utils.nim index 11c4bf929..d5602173a 100644 --- a/tests/waku_lightpush_legacy/lightpush_utils.nim +++ b/tests/waku_lightpush_legacy/lightpush_utils.nim @@ -1,6 +1,9 @@ {.used.} -import std/options, chronos, libp2p/crypto/crypto +import std/options, chronos, chronicles, libp2p/crypto/crypto + +logScope: + topics = "test waku_lightpush_legacy" import waku/node/peer_manager, diff --git a/tests/waku_relay/utils.nim b/tests/waku_relay/utils.nim index 4e958a4ea..069600106 100644 --- a/tests/waku_relay/utils.nim +++ b/tests/waku_relay/utils.nim @@ -8,17 +8,13 @@ import libp2p/switch, libp2p/protocols/pubsub/pubsub +import brokers/broker_context + from std/times import epochTime import waku/[ - waku_relay, - node/waku_node, - node/peer_manager, - waku_core, - waku_node, - waku_rln_relay, - common/broker/broker_context, + waku_relay, node/waku_node, node/peer_manager, waku_core, waku_node, waku_rln_relay ], ../waku_store/store_utils, ../waku_archive/archive_utils, diff --git a/tests/waku_rln_relay/rln/buffer_utils.nim b/tests/waku_rln_relay/rln/buffer_utils.nim index e38cc5c17..a5ef921f1 100644 --- a/tests/waku_rln_relay/rln/buffer_utils.nim +++ b/tests/waku_rln_relay/rln/buffer_utils.nim @@ -1,11 +1,4 @@ -import waku/waku_rln_relay/rln/rln_interface - -proc `==`*(a: Buffer, b: seq[uint8]): bool = - if a.len != uint(b.len): - return false - - let bufferArray = cast[ptr UncheckedArray[uint8]](a.ptr) - for i in 0 ..< b.len: - if bufferArray[i] != b[i]: - return false - return true +# buffer_utils.nim — intentionally empty. +# The v0.9 Buffer type and toBuffer helper were removed in the zerokit v2.0.1 +# migration. This file is kept as a placeholder so that any future test imports +# do not break the build; the content that was here is no longer needed. diff --git a/tests/waku_rln_relay/rln/test_rln_interface.nim b/tests/waku_rln_relay/rln/test_rln_interface.nim index 7aedf587f..7b8ea3878 100644 --- a/tests/waku_rln_relay/rln/test_rln_interface.nim +++ b/tests/waku_rln_relay/rln/test_rln_interface.nim @@ -1,17 +1,36 @@ -import testutils/unittests +import testutils/unittests, results -import waku/waku_rln_relay/rln/rln_interface, ./buffer_utils +import waku/waku_rln_relay/rln/rln_interface +import waku/waku_rln_relay/rln/wrappers -suite "Buffer": - suite "toBuffer": +suite "Vec_uint8": + suite "toVecUint8": test "valid": # Given let bytes: seq[byte] = @[0x01, 0x02, 0x03] - # When - let buffer = bytes.toBuffer() + # When — wrap as a Vec_uint8 view then read the bytes back + var vec = toVecUint8(bytes) + let roundtrip = vecToSeq(vec) - # Then - let expectedBuffer: seq[uint8] = @[1, 2, 3] + # Then — byte values are preserved check: - buffer == expectedBuffer + roundtrip == bytes + +suite "RlnConfig": + suite "createRLNInstance": + test "ok": + # When we create the RLN instance (stateless build — no tree_depth arg) + let rlnRes = createRLNInstance() + + # Then it succeeds + check: + rlnRes.isOk() + + test "default": + # When we create the RLN instance + let rlnRes = createRLNInstance() + + # Then it succeeds + check: + rlnRes.isOk() diff --git a/tests/waku_rln_relay/rln/test_wrappers.nim b/tests/waku_rln_relay/rln/test_wrappers.nim index 29e24aae5..8cd9251c0 100644 --- a/tests/waku_rln_relay/rln/test_wrappers.nim +++ b/tests/waku_rln_relay/rln/test_wrappers.nim @@ -1,37 +1,6 @@ -import - std/options, - testutils/unittests, - chronicles, - chronos, - eth/keys, - bearssl, - stew/[results], - metrics, - metrics/chronos_httpserver +import testutils/unittests, results -import - waku/waku_rln_relay, - waku/waku_rln_relay/rln, - waku/waku_rln_relay/rln/wrappers, - ./waku_rln_relay_utils, - ../../testlib/[simple_mock, assertions], - ../../waku_keystore/utils, - ../../testlib/testutils - -from std/times import epochTime - -const Empty32Array = default(array[32, byte]) - -proc valid(x: seq[byte]): bool = - if x.len != 32: - error "Length should be 32", length = x.len - return false - - if x == Empty32Array: - error "Should not be empty array", array = x - return false - - return true +import waku/waku_rln_relay/rln, waku/waku_rln_relay/rln/wrappers, ./waku_rln_relay_utils suite "membershipKeyGen": test "ok": @@ -41,60 +10,20 @@ suite "membershipKeyGen": # Then it contains valid identity credentials let identityCredentials = identityCredentialsRes.get() + proc nonEmpty(x: seq[byte]): bool = + x.len == 32 and x != newSeq[byte](32) + check: - identityCredentials.idTrapdoor.valid() - identityCredentials.idNullifier.valid() - identityCredentials.idSecretHash.valid() - identityCredentials.idCommitment.valid() - - test "done is false": - # Given the key_gen function fails - let backup = key_gen - mock(key_gen): - proc keyGenMock(ctx: ptr RLN, output_buffer: ptr Buffer): bool = - return false - - keyGenMock - - # When we generate the membership keys - let identityCredentialsRes = membershipKeyGen() - - # Then it fails - check: - identityCredentialsRes.error() == "error in key generation" - - # Cleanup - mock(key_gen): - backup - - test "generatedKeys length is not 128": - # Given the key_gen function succeeds with wrong values - let backup = key_gen - mock(key_gen): - proc keyGenMock(ctx: ptr RLN, output_buffer: ptr Buffer): bool = - echo "# RUNNING MOCK" - output_buffer.len = 0 - output_buffer.ptr = cast[ptr uint8](newSeq[byte](0)) - return true - - keyGenMock - - # When we generate the membership keys - let identityCredentialsRes = membershipKeyGen() - - # Then it fails - check: - identityCredentialsRes.error() == "keysBuffer is of invalid length" - - # Cleanup - mock(key_gen): - backup + identityCredentials.idTrapdoor.nonEmpty() + identityCredentials.idNullifier.nonEmpty() + identityCredentials.idSecretHash.nonEmpty() + identityCredentials.idCommitment.nonEmpty() suite "RlnConfig": suite "createRLNInstance": test "ok": - # When we create the RLN instance - let rlnRes: RLNResult = createRLNInstance(15) + # When we create the RLN instance (stateless build — no tree_depth arg) + let rlnRes = createRLNInstance() # Then it succeeds check: @@ -102,30 +31,8 @@ suite "RlnConfig": test "default": # When we create the RLN instance - let rlnRes: RLNResult = createRLNInstance() + let rlnRes = createRLNInstance() # Then it succeeds check: rlnRes.isOk() - - test "new_circuit fails": - # Given the new_circuit function fails - let backup = new_circuit - mock(new_circuit): - proc newCircuitMock( - tree_height: uint, input_buffer: ptr Buffer, ctx: ptr (ptr RLN) - ): bool = - return false - - newCircuitMock - - # When we create the RLN instance - let rlnRes: RLNResult = createRLNInstance(15) - - # Then it fails - check: - rlnRes.error() == "error in parameters generation" - - # Cleanup - mock(new_circuit): - backup diff --git a/tests/waku_rln_relay/test_rln_group_manager_onchain.nim b/tests/waku_rln_relay/test_rln_group_manager_onchain.nim index 29da94129..6b5b81532 100644 --- a/tests/waku_rln_relay/test_rln_group_manager_onchain.nim +++ b/tests/waku_rln_relay/test_rln_group_manager_onchain.nim @@ -3,7 +3,7 @@ {.push raises: [].} import - std/[options, sequtils, deques, random, locks, osproc], + std/[options, sequtils, deques, random, locks, osproc, algorithm], results, stew/byteutils, testutils/unittests, @@ -253,6 +253,9 @@ suite "Onchain group manager": manager.merkleProofCache = newSeq[byte](640) for i in 0 ..< 640: manager.merkleProofCache[i] = byte(rand(255)) + # chunk[0] becomes the MSB after reversal in group_manager; must be < 0x30 + for i in 0 ..< 20: + manager.merkleProofCache[i * 32] = 0 let messageBytes = "Hello".toBytes() @@ -335,6 +338,9 @@ suite "Onchain group manager": manager.merkleProofCache = newSeq[byte](640) for i in 0 ..< 640: manager.merkleProofCache[i] = byte(rand(255)) + # chunk[0] becomes the MSB after reversal in group_manager; must be < 0x30 + for i in 0 ..< 20: + manager.merkleProofCache[i * 32] = 0 let epoch = default(Epoch) info "epoch in bytes", epochHex = epoch.inHex() @@ -419,3 +425,81 @@ suite "Onchain group manager": check: isReady == true + + test "proof roundtrip: generateRlnProofWithWitness -> verifyRlnProof": + ## Smoke test: proof gen -> wire serialize -> deserialize -> ffi_verify_with_roots. + let credentials = generateCredentials() + + (waitFor manager.init()).isOkOr: + raiseAssert $error + + (waitFor manager.register(credentials, UserMessageLimit(20))).isOkOr: + assert false, "register failed: " & error + + discard waitFor manager.updateRoots() + let roots = manager.validRoots.items().toSeq() + require: + roots.len > 0 + + let proofElements = (waitFor manager.fetchMerkleProofElements()).valueOr: + raiseAssert "fetchMerkleProofElements failed: " & error + + let signal = "Hello, RLN!".toBytes() + let epoch = default(Epoch) + + # Build RLNWitnessInput the same way group_manager.generateProof does. + var pathElements = newSeq[byte]() + for i in 0 ..< proofElements.len div 32: + pathElements.add(proofElements[i * 32 .. (i + 1) * 32 - 1].reversed()) + + let xCfr = hashToFieldLe(signal).valueOr: + raiseAssert "hashToFieldLe failed: " & error + defer: + ffi_cfr_free(xCfr) + let x = cfrToBytesLe(xCfr).valueOr: + raiseAssert "cfrToBytesLe failed: " & error + + let extNullifier = generateExternalNullifier(epoch, DefaultRlnIdentifier).valueOr: + raiseAssert "generateExternalNullifier failed: " & error + + let witness = RLNWitnessInput( + identity_secret: seqToField(credentials.idSecretHash), + user_message_limit: uint64ToField(uint64(UserMessageLimit(20))), + message_id: uint64ToField(uint64(MessageId(1))), + path_elements: pathElements, + identity_path_index: uint64ToIndex(manager.membershipIndex.get(), 20), + x: x, + external_nullifier: extNullifier, + ) + + # Step 1: generate proof via the FFI wrapper + let proof = generateRlnProofWithWitness( + manager.rlnInstance, witness, epoch, DefaultRlnIdentifier + ).valueOr: + raiseAssert "generateRlnProofWithWitness failed: " & error + + let zeroField = default(array[32, byte]) + check: + proof.merkleRoot != zeroField + proof.nullifier != zeroField + + # Step 2: serialize -> deserialize -> verify (the actual roundtrip) + let verified = verifyRlnProof(manager.rlnInstance, proof, signal, roots).valueOr: + raiseAssert "verifyRlnProof failed: " & error + check verified == true + + # Step 3: wrong signal -> x mismatch -> false + let wrongSignalVerified = verifyRlnProof( + manager.rlnInstance, proof, "wrong".toBytes(), roots + ).valueOr: + raiseAssert "verifyRlnProof (wrong signal) failed: " & error + check wrongSignalVerified == false + + # Step 4: bad root -> root not in set -> false + # byte[31] in LE is the MSB; 0x01 < 0x30 so this is a canonical field element. + var badRoot: MerkleNode + for i in 0 ..< 32: + badRoot[i] = 0x01 + let badRootVerified = verifyRlnProof(manager.rlnInstance, proof, signal, @[badRoot]).valueOr: + raiseAssert "verifyRlnProof (bad root) failed: " & error + check badRootVerified == false diff --git a/tests/waku_rln_relay/test_waku_rln_relay.nim b/tests/waku_rln_relay/test_waku_rln_relay.nim index 08d3daedb..7694b8112 100644 --- a/tests/waku_rln_relay/test_waku_rln_relay.nim +++ b/tests/waku_rln_relay/test_waku_rln_relay.nim @@ -1,13 +1,16 @@ {.used.} import - std/[options, os, sequtils, tempfiles, strutils, osproc], + std/[options, os, sequtils, tempfiles, strutils, osproc, algorithm], stew/byteutils, testutils/unittests, chronos, chronicles, stint, libp2p/crypto/crypto + +import brokers/broker_context + import waku/[ waku_core, @@ -15,7 +18,6 @@ import waku_rln_relay/rln, waku_rln_relay/protocol_metrics, waku_keystore, - common/broker/broker_context, ], ./rln/waku_rln_relay_utils, ./utils_onchain, @@ -34,23 +36,16 @@ suite "Waku rln relay": teardown: stopAnvil(anvilProc) - test "key_gen Nim Wrappers": - let merkleDepth: csize_t = 20 + test "ffi_extended_key_gen raw FFI": + # When we call the raw key-generation FFI + var vec = ffi_extended_key_gen() - # keysBufferPtr will hold the generated identity credential i.e., id trapdoor, nullifier, secret hash and commitment - var keysBuffer: Buffer - let - keysBufferPtr = addr(keysBuffer) - done = key_gen(keysBufferPtr, true) - require: - # check whether the keys are generated successfully - done - - let generatedKeys = cast[ptr array[4 * 32, byte]](keysBufferPtr.`ptr`)[] + # Then it returns exactly 4 field elements + # (idTrapdoor, idNullifier, idSecretHash, idCommitment — each 32 bytes) + defer: + ffi_vec_cfr_free(vec) check: - # the id trapdoor, nullifier, secert hash and commitment together are 4*32 bytes - generatedKeys.len == 4 * 32 - info "generated keys: ", generatedKeys + int(ffi_vec_cfr_len(addr vec)) == 4 test "membership Key Generation": let idCredentialsRes = membershipKeyGen() @@ -78,18 +73,22 @@ suite "Waku rln relay": rlnInstance.isOk() let rln = rlnInstance.get() - # prepare the input - let msg = @[ - "126f4c026cd731979365f79bd345a46d673c5a3f6f588bdc718e6356d02b6fdc".toBytes(), - "1f0e5db2b69d599166ab16219a97b82b662085c93220382b39f9f911d3b943b1".toBytes(), - ] + # prepare the input — hex-decoded then reversed to little-endian field elements + let + left = hexToSeqByte( + "126f4c026cd731979365f79bd345a46d673c5a3f6f588bdc718e6356d02b6fdc" + ) + .reversed() + right = hexToSeqByte( + "1f0e5db2b69d599166ab16219a97b82b662085c93220382b39f9f911d3b943b1" + ) + .reversed() - let hashRes = poseidon(msg) + let hashRes = poseidon(left, right) - # Value taken from zerokit check: hashRes.isOk() - "28a15a991fe3d2a014485c7fa905074bfb55c0909112f865ded2be0a26a932c3" == + "180543bc9afb81d9c2282df9c9946f87b4596cf6d3fec2cc32b6637427685353" == hashRes.get().inHex() test "RateLimitProof Protobuf encode/init test": diff --git a/tests/waku_rln_relay/test_wakunode_rln_relay.nim b/tests/waku_rln_relay/test_wakunode_rln_relay.nim index 19a47e1aa..414a445fa 100644 --- a/tests/waku_rln_relay/test_wakunode_rln_relay.nim +++ b/tests/waku_rln_relay/test_wakunode_rln_relay.nim @@ -7,13 +7,14 @@ import chronicles, chronos, libp2p/switch, - libp2p/protocols/pubsub/pubsub + libp2p/protocols/pubsub/pubsub, + brokers/broker_context + import waku/[waku_core, waku_node, waku_rln_relay], ../testlib/[wakucore, futures, wakunode, testutils], ./utils_onchain, - ./rln/waku_rln_relay_utils, - waku/common/broker/broker_context + ./rln/waku_rln_relay_utils from std/times import epochTime diff --git a/tests/waku_store/test_client.nim b/tests/waku_store/test_client.nim index ec462eb56..d9c94a10c 100644 --- a/tests/waku_store/test_client.nim +++ b/tests/waku_store/test_client.nim @@ -1,6 +1,6 @@ {.used.} -import std/options, testutils/unittests, chronos, libp2p/crypto/crypto +import std/[options, sets], testutils/unittests, chronos, libp2p/crypto/crypto import waku/[node/peer_manager, waku_core, waku_store, waku_store/client, common/paging], @@ -223,3 +223,24 @@ suite "Store Client": not await handlerFuture.withTimeout(FUTURE_TIMEOUT) queryResponse.isErr() queryResponse.error.kind == ErrorCode.PEER_DIAL_FAILURE + + asyncTest "queryToAny shuffles peers across calls": + # Register several fake store peers (no servers running) so every dial + # fails. PEER_DIAL_FAILURE carries the peerId of the last peer tried in + # the shuffled order, so observing different "last" peerIds across calls + # confirms shuffle is active inside queryToAny. + for _ in 0 ..< 3: + let fakeSwitch = newTestSwitch() + let peerInfo = fakeSwitch.peerInfo.toRemotePeerInfo() + peerInfo.protocols = @[WakuStoreCodec] + clientSwitch.peerStore.addPeer(peerInfo) + + var observedLastPeers: HashSet[string] + for _ in 0 ..< 20: + let res = await client.queryToAny(storeQuery) + check: + res.isErr() + res.error.kind == ErrorCode.PEER_DIAL_FAILURE + observedLastPeers.incl(res.error.address) + + check observedLastPeers.len >= 2 diff --git a/tests/wakunode_rest/test_rest_relay.nim b/tests/wakunode_rest/test_rest_relay.nim index b791da29f..a98b75520 100644 --- a/tests/wakunode_rest/test_rest_relay.nim +++ b/tests/wakunode_rest/test_rest_relay.nim @@ -7,6 +7,7 @@ import presto, presto/client as presto_client, libp2p/crypto/crypto +import brokers/broker_context import waku/[ common/base64, @@ -21,7 +22,6 @@ import rest_api/endpoint/relay/client as relay_rest_client, waku_relay, waku_rln_relay, - common/broker/broker_context, ], ../testlib/wakucore, ../testlib/wakunode, diff --git a/tools/confutils/cli_args.nim b/tools/confutils/cli_args.nim index bcb9e851f..d4b7185f2 100644 --- a/tools/confutils/cli_args.nim +++ b/tools/confutils/cli_args.nim @@ -168,7 +168,7 @@ type WakuNodeConf* = object preset* {. desc: - "Network preset to use. 'twn' is The RLN-protected Waku Network (cluster 1). 'logos.dev' is the Logos Dev Network (cluster 2). Overrides other values.", + "Network preset to use. 'twn' is The RLN-protected Waku Network (cluster 1). 'logos.dev' is the Logos Dev Network (cluster 2). 'logos.test' is the Logos Test Network (cluster 2). Overrides other values.", defaultValue: "", name: "preset" .}: string @@ -717,6 +717,12 @@ hence would have reachability issues.""", name: "rate-limit" .}: seq[string] + localStoragePath* {. + desc: "Path to store local data.", + defaultValue: "./data", + name: "local-storage-path" + .}: string + ## Parsing # NOTE: Keys are different in nim-libp2p @@ -960,6 +966,8 @@ proc toNetworkConf( ok(some(NetworkConf.TheWakuNetworkConf())) of "logos.dev", "logosdev": ok(some(NetworkConf.LogosDevConf())) + of "logos.test", "logostest": + ok(some(NetworkConf.LogosTestConf())) else: err("Invalid --preset value passed: " & lcPreset) @@ -1145,6 +1153,8 @@ proc toWakuConf*(n: WakuNodeConf): ConfResult[WakuConf] = if n.rateLimits.len > 0: b.rateLimitConf.withRateLimits(n.rateLimits) + b.withLocalStoragePath(n.localStoragePath) + if n.enableKadDiscovery.isSome(): b.kademliaDiscoveryConf.withEnabled(n.enableKadDiscovery.get()) b.kademliaDiscoveryConf.withBootstrapNodes(n.kadBootstrapNodes) diff --git a/tools/gen-nix-deps.sh b/tools/gen-nix-deps.sh index 9bb43e638..d24641ecd 100755 --- a/tools/gen-nix-deps.sh +++ b/tools/gen-nix-deps.sh @@ -36,7 +36,10 @@ fi echo "[*] Generating $OUTFILE from $LOCKFILE" mkdir -p "$(dirname "$OUTFILE")" -cat > "$OUTFILE" <<'EOF' +TMPFILE=$(mktemp) +trap 'rm -f "$TMPFILE"' EXIT + +cat > "$TMPFILE" <<'EOF' # AUTOGENERATED from nimble.lock — do not edit manually. # Regenerate with: ./tools/gen-nix-deps.sh nimble.lock nix/deps.nix { pkgs }: @@ -62,7 +65,7 @@ jq -c ' --fetch-submodules \ | jq -r '.sha256') - cat >> "$OUTFILE" <> "$TMPFILE" <> "$OUTFILE" <<'EOF' +cat >> "$TMPFILE" <<'EOF' } EOF +mv "$TMPFILE" "$OUTFILE" echo "[✓] Wrote $OUTFILE" diff --git a/vendor/zerokit b/vendor/zerokit index a4bb3feb5..5e64cb882 160000 --- a/vendor/zerokit +++ b/vendor/zerokit @@ -1 +1 @@ -Subproject commit a4bb3feb5054e6fd24827adf204493e6e173437b +Subproject commit 5e64cb8822bee65eed6cf459f95ae72b80c6ba63 diff --git a/waku.nimble b/waku.nimble index f944aaae1..da5b87eb6 100644 --- a/waku.nimble +++ b/waku.nimble @@ -4,7 +4,7 @@ import os mode = ScriptMode.Verbose ### Package -version = "0.37.4" +version = "0.38.1" author = "Status Research & Development GmbH" description = "Waku, Private P2P Messaging for Resource-Restricted Devices" license = "MIT or Apache License 2.0" @@ -63,6 +63,16 @@ requires "https://github.com/logos-messaging/nim-ffi" requires "https://github.com/logos-messaging/nim-sds.git#2e9a7683f0e180bf112135fae3a3803eed8490d4" +# brokers: pinned by URL+commit rather than the bare `brokers >= 2.0.1` +# form because the nim-lang/packages registry entry for `brokers` only +# carries metadata for the original v0.1.0 publication. Until that +# registry entry is refreshed, the local SAT solver enumerates "0.1.0" +# as the only available version and cannot satisfy `>= 2.0.1`. The URL +# pin below bypasses the registry and locks the exact commit of the +# v2.0.1 tag. Revert to the bare form once nim-lang/packages is +# updated. +requires "https://github.com/NagyZoltanPeter/nim-brokers.git#v2.0.1" + requires "https://github.com/vacp2p/nim-lsquic" requires "https://github.com/vacp2p/nim-jwt.git#057ec95eb5af0eea9c49bfe9025b3312c95dc5f2" diff --git a/waku/README.md b/waku/README.md index ed3887a09..d9f160cb5 100644 --- a/waku/README.md +++ b/waku/README.md @@ -45,15 +45,16 @@ Setting up a `wakunode2` on the smallest [digital ocean](https://docs.digitaloce make test ``` -To run a specific test. +To run a specific test file or test case: ```bash -# Get a shell with the right environment variables set -./env.sh bash -# Run a specific test -nim c -r ./tests/test_waku_filter_legacy.nim +# Run all tests in a specific file +make test tests/waku_filter_v2/test_waku_filter.nim + +# Run a specific test case within a file +make test tests/waku_filter_v2/test_waku_filter.nim "specific test name" ``` -You can also alter compile options. For example, if you want a less verbose output you can do the following. For more, refer to the [compiler flags](https://nim-lang.org/docs/nimc.html#compiler-usage) and [chronicles documentation](https://github.com/status-im/nim-chronicles#compile-time-configuration). +Alternatively, you can invoke the Nim compiler directly. For more on available flags, refer to the [compiler flags](https://nim-lang.org/docs/nimc.html#compiler-usage) and [chronicles documentation](https://github.com/status-im/nim-chronicles#compile-time-configuration). ```bash nim c -r -d:chronicles_log_level=WARN --verbosity=0 --hints=off ./tests/waku_filter_v2/test_waku_filter.nim @@ -231,7 +232,4 @@ However, they can be used for local testing purposes: mkdir -p ./ssl_dir/ openssl req -x509 -newkey rsa:4096 -keyout ./ssl_dir/key.pem -out ./ssl_dir/cert.pem -sha256 -nodes wakunode2 --websocket-secure-support=true --websocket-secure-key-path="./ssl_dir/key.pem" --websocket-secure-cert-path="./ssl_dir/cert.pem" -``` - - - +``` \ No newline at end of file diff --git a/waku/api/types.nim b/waku/api/types.nim index 9eae503c8..2b7edd616 100644 --- a/waku/api/types.nim +++ b/waku/api/types.nim @@ -11,6 +11,10 @@ type contentTopic*: ContentTopic payload*: seq[byte] ephemeral*: bool + meta*: seq[byte] + ## Opaque wire-format marker carried on the underlying WakuMessage. + ## Higher layers (e.g. Reliable Channel) stamp this so peers can route + ## ingress traffic to their corresponding layer. Empty by default. RequestId* = distinct string @@ -34,12 +38,18 @@ proc init*( contentTopic: ContentTopic, payload: seq[byte] | string, ephemeral: bool = false, + meta: seq[byte] = @[], ): MessageEnvelope = when payload is seq[byte]: - MessageEnvelope(contentTopic: contentTopic, payload: payload, ephemeral: ephemeral) + MessageEnvelope( + contentTopic: contentTopic, payload: payload, ephemeral: ephemeral, meta: meta + ) else: MessageEnvelope( - contentTopic: contentTopic, payload: payload.toBytes(), ephemeral: ephemeral + contentTopic: contentTopic, + payload: payload.toBytes(), + ephemeral: ephemeral, + meta: meta, ) proc toWakuMessage*(envelope: MessageEnvelope): WakuMessage = @@ -48,6 +58,7 @@ proc toWakuMessage*(envelope: MessageEnvelope): WakuMessage = contentTopic: envelope.contentTopic, payload: envelope.payload, ephemeral: envelope.ephemeral, + meta: envelope.meta, timestamp: getNowInNanosecondTime(), ) diff --git a/waku/common/broker/broker_context.nim b/waku/common/broker/broker_context.nim deleted file mode 100644 index 483a2e3a7..000000000 --- a/waku/common/broker/broker_context.nim +++ /dev/null @@ -1,68 +0,0 @@ -{.push raises: [].} - -import std/[strutils, concurrency/atomics], chronos - -type BrokerContext* = distinct uint32 - -func `==`*(a, b: BrokerContext): bool = - uint32(a) == uint32(b) - -func `!=`*(a, b: BrokerContext): bool = - uint32(a) != uint32(b) - -func `$`*(bc: BrokerContext): string = - toHex(uint32(bc), 8) - -const DefaultBrokerContext* = BrokerContext(0xCAFFE14E'u32) - -# Global broker context accessor. -# -# NOTE: This intentionally creates a *single* active BrokerContext per process -# (per event loop thread). Use only if you accept serialization of all broker -# context usage through the lock. -var globalBrokerContextLock {.threadvar.}: AsyncLock -globalBrokerContextLock = newAsyncLock() -var globalBrokerContextValue {.threadvar.}: BrokerContext -globalBrokerContextValue = DefaultBrokerContext -proc globalBrokerContext*(): BrokerContext = - ## Returns the currently active global broker context. - ## - ## This is intentionally lock-free; callers should use it inside - ## `withNewGlobalBrokerContext` / `withGlobalBrokerContext`. - globalBrokerContextValue - -var gContextCounter: Atomic[uint32] - -proc NewBrokerContext*(): BrokerContext = - var nextId = gContextCounter.fetchAdd(1, moRelaxed) - if nextId == uint32(DefaultBrokerContext): - nextId = gContextCounter.fetchAdd(1, moRelaxed) - return BrokerContext(nextId) - -template lockGlobalBrokerContext*(brokerCtx: BrokerContext, body: untyped): untyped = - ## Runs `body` while holding the global broker context lock with the provided - ## `brokerCtx` installed as the globally accessible context. - ## - ## This template is intended for use from within `chronos` async procs. - block: - await noCancel(globalBrokerContextLock.acquire()) - let previousBrokerCtx = globalBrokerContextValue - globalBrokerContextValue = brokerCtx - try: - body - finally: - globalBrokerContextValue = previousBrokerCtx - try: - globalBrokerContextLock.release() - except AsyncLockError: - doAssert false, "globalBrokerContextLock.release(): lock not held" - -template lockNewGlobalBrokerContext*(body: untyped): untyped = - ## Runs `body` while holding the global broker context lock with a freshly - ## generated broker context installed as the global accessor. - ## - ## The previous global broker context (if any) is restored on exit. - lockGlobalBrokerContext(NewBrokerContext()): - body - -{.pop.} diff --git a/waku/common/broker/event_broker.nim b/waku/common/broker/event_broker.nim deleted file mode 100644 index 3fd10cea2..000000000 --- a/waku/common/broker/event_broker.nim +++ /dev/null @@ -1,411 +0,0 @@ -## EventBroker -## ------------------- -## EventBroker represents a reactive decoupling pattern, that -## allows event-driven development without -## need for direct dependencies in between emitters and listeners. -## Worth considering using it in a single or many emitters to many listeners scenario. -## -## Generates a standalone, type-safe event broker for the declared type. -## The macro exports the value type itself plus a broker companion that manages -## listeners via thread-local storage. -## -## Type definitions: -## - Inline `object` / `ref object` definitions are supported. -## - Native types, aliases, and externally-defined types are also supported. -## In that case, EventBroker will automatically wrap the declared RHS type in -## `distinct` unless you already used `distinct`. -## This keeps event types unique even when multiple brokers share the same -## underlying base type. -## -## Default vs. context aware use: -## Every generated broker is a thread-local global instance. This means EventBroker -## enables decoupled event exchange threadwise. -## -## Sometimes we use brokers inside a context (e.g. within a component that has many -## modules or subsystems). If you instantiate multiple such components in a single -## thread, and each component must have its own listener set for the same EventBroker -## type, you can use context-aware EventBroker. -## -## Context awareness is supported through the `BrokerContext` argument for -## `listen`, `emit`, `dropListener`, and `dropAllListeners`. -## Listener stores are kept separate per broker context. -## -## Default broker context is defined as `DefaultBrokerContext`. If you don't need -## context awareness, you can keep using the interfaces without the context -## argument, which operate on `DefaultBrokerContext`. -## -## Usage: -## Declare your desired event type inside an `EventBroker` macro, add any number of fields.: -## ```nim -## EventBroker: -## type TypeName = object -## field1*: FieldType -## field2*: AnotherFieldType -## ``` -## -## After this, you can register async listeners anywhere in your code with -## `TypeName.listen(...)`, which returns a handle to the registered listener. -## Listeners are async procs or lambdas that take a single argument of the event type. -## Any number of listeners can be registered in different modules. -## -## Events can be emitted from anywhere with no direct dependency on the listeners by -## calling `TypeName.emit(...)` with an instance of the event type. -## This will asynchronously notify all registered listeners with the emitted event. -## -## Whenever you no longer need a listener (or your object instance that listen to the event goes out of scope), -## you can remove it from the broker with the handle returned by `listen`. -## This is done by calling `TypeName.dropListener(handle)`. -## Alternatively, you can remove all registered listeners through `TypeName.dropAllListeners()`. -## -## -## Example: -## ```nim -## EventBroker: -## type GreetingEvent = object -## text*: string -## -## let handle = GreetingEvent.listen( -## proc(evt: GreetingEvent): Future[void] {.async.} = -## echo evt.text -## ) -## GreetingEvent.emit(text= "hi") -## GreetingEvent.dropListener(handle) -## ``` - -## Example (non-object event type): -## ```nim -## EventBroker: -## type CounterEvent = int # exported as: `distinct int` -## -## discard CounterEvent.listen( -## proc(evt: CounterEvent): Future[void] {.async.} = -## echo int(evt) -## ) -## CounterEvent.emit(CounterEvent(42)) -## ``` - -import std/[macros, tables] -import chronos, chronicles, results -import ./helper/broker_utils, broker_context - -export chronicles, results, chronos, broker_context - -macro EventBroker*(body: untyped): untyped = - when defined(eventBrokerDebug): - echo body.treeRepr - let parsed = parseSingleTypeDef(body, "EventBroker", collectFieldInfo = true) - let typeIdent = parsed.typeIdent - let objectDef = parsed.objectDef - let fieldNames = parsed.fieldNames - let fieldTypes = parsed.fieldTypes - let hasInlineFields = parsed.hasInlineFields - - let exportedTypeIdent = postfix(copyNimTree(typeIdent), "*") - let sanitized = sanitizeIdentName(typeIdent) - let typeNameLit = newLit($typeIdent) - let handlerProcIdent = ident(sanitized & "ListenerProc") - let listenerHandleIdent = ident(sanitized & "Listener") - let brokerTypeIdent = ident(sanitized & "Broker") - let exportedHandlerProcIdent = postfix(copyNimTree(handlerProcIdent), "*") - let exportedListenerHandleIdent = postfix(copyNimTree(listenerHandleIdent), "*") - let exportedBrokerTypeIdent = postfix(copyNimTree(brokerTypeIdent), "*") - let bucketTypeIdent = ident(sanitized & "CtxBucket") - let findBucketIdxIdent = ident(sanitized & "FindBucketIdx") - let getOrCreateBucketIdxIdent = ident(sanitized & "GetOrCreateBucketIdx") - let accessProcIdent = ident("access" & sanitized & "Broker") - let globalVarIdent = ident("g" & sanitized & "Broker") - let listenImplIdent = ident("register" & sanitized & "Listener") - let dropListenerImplIdent = ident("drop" & sanitized & "Listener") - let dropAllListenersImplIdent = ident("dropAll" & sanitized & "Listeners") - let emitImplIdent = ident("emit" & sanitized & "Value") - let listenerTaskIdent = ident("notify" & sanitized & "Listener") - - result = newStmtList() - - result.add( - quote do: - type - `exportedTypeIdent` = `objectDef` - `exportedListenerHandleIdent` = object - id*: uint64 - - `exportedHandlerProcIdent` = - proc(event: `typeIdent`): Future[void] {.async: (raises: []), gcsafe.} - `bucketTypeIdent` = object - brokerCtx: BrokerContext - listeners: Table[uint64, `handlerProcIdent`] - nextId: uint64 - - `exportedBrokerTypeIdent` = ref object - buckets: seq[`bucketTypeIdent`] - - ) - - result.add( - quote do: - var `globalVarIdent` {.threadvar.}: `brokerTypeIdent` - ) - - result.add( - quote do: - proc `accessProcIdent`(): `brokerTypeIdent` = - if `globalVarIdent`.isNil(): - new(`globalVarIdent`) - `globalVarIdent`.buckets = @[ - `bucketTypeIdent`( - brokerCtx: DefaultBrokerContext, - listeners: initTable[uint64, `handlerProcIdent`](), - nextId: 1'u64, - ) - ] - `globalVarIdent` - - ) - - result.add( - quote do: - proc `findBucketIdxIdent`( - broker: `brokerTypeIdent`, brokerCtx: BrokerContext - ): int = - if brokerCtx == DefaultBrokerContext: - return 0 - for i in 1 ..< broker.buckets.len: - if broker.buckets[i].brokerCtx == brokerCtx: - return i - return -1 - - proc `getOrCreateBucketIdxIdent`( - broker: `brokerTypeIdent`, brokerCtx: BrokerContext - ): int = - let idx = `findBucketIdxIdent`(broker, brokerCtx) - if idx >= 0: - return idx - broker.buckets.add( - `bucketTypeIdent`( - brokerCtx: brokerCtx, - listeners: initTable[uint64, `handlerProcIdent`](), - nextId: 1'u64, - ) - ) - return broker.buckets.high - - proc `listenImplIdent`( - brokerCtx: BrokerContext, handler: `handlerProcIdent` - ): Result[`listenerHandleIdent`, string] = - if handler.isNil(): - return err("Must provide a non-nil event handler") - var broker = `accessProcIdent`() - - let bucketIdx = `getOrCreateBucketIdxIdent`(broker, brokerCtx) - if broker.buckets[bucketIdx].nextId == 0'u64: - broker.buckets[bucketIdx].nextId = 1'u64 - - if broker.buckets[bucketIdx].nextId == high(uint64): - error "Cannot add more listeners: ID space exhausted", - nextId = $broker.buckets[bucketIdx].nextId - return err("Cannot add more listeners, listener ID space exhausted") - - let newId = broker.buckets[bucketIdx].nextId - inc broker.buckets[bucketIdx].nextId - broker.buckets[bucketIdx].listeners[newId] = handler - return ok(`listenerHandleIdent`(id: newId)) - - ) - - result.add( - quote do: - proc `dropListenerImplIdent`( - brokerCtx: BrokerContext, handle: `listenerHandleIdent` - ) = - if handle.id == 0'u64: - return - var broker = `accessProcIdent`() - - let bucketIdx = `findBucketIdxIdent`(broker, brokerCtx) - if bucketIdx < 0: - return - - if broker.buckets[bucketIdx].listeners.len == 0: - return - broker.buckets[bucketIdx].listeners.del(handle.id) - if brokerCtx != DefaultBrokerContext and - broker.buckets[bucketIdx].listeners.len == 0: - broker.buckets.delete(bucketIdx) - - ) - - result.add( - quote do: - proc `dropAllListenersImplIdent`(brokerCtx: BrokerContext) = - var broker = `accessProcIdent`() - - let bucketIdx = `findBucketIdxIdent`(broker, brokerCtx) - if bucketIdx < 0: - return - if broker.buckets[bucketIdx].listeners.len > 0: - broker.buckets[bucketIdx].listeners.clear() - if brokerCtx != DefaultBrokerContext: - broker.buckets.delete(bucketIdx) - - ) - - result.add( - quote do: - proc listen*( - _: typedesc[`typeIdent`], handler: `handlerProcIdent` - ): Result[`listenerHandleIdent`, string] = - return `listenImplIdent`(DefaultBrokerContext, handler) - - proc listen*( - _: typedesc[`typeIdent`], - brokerCtx: BrokerContext, - handler: `handlerProcIdent`, - ): Result[`listenerHandleIdent`, string] = - return `listenImplIdent`(brokerCtx, handler) - - ) - - result.add( - quote do: - proc dropListener*(_: typedesc[`typeIdent`], handle: `listenerHandleIdent`) = - `dropListenerImplIdent`(DefaultBrokerContext, handle) - - proc dropListener*( - _: typedesc[`typeIdent`], - brokerCtx: BrokerContext, - handle: `listenerHandleIdent`, - ) = - `dropListenerImplIdent`(brokerCtx, handle) - - proc dropAllListeners*(_: typedesc[`typeIdent`]) = - `dropAllListenersImplIdent`(DefaultBrokerContext) - - proc dropAllListeners*(_: typedesc[`typeIdent`], brokerCtx: BrokerContext) = - `dropAllListenersImplIdent`(brokerCtx) - - ) - - result.add( - quote do: - proc `listenerTaskIdent`( - callback: `handlerProcIdent`, event: `typeIdent` - ) {.async: (raises: []), gcsafe.} = - if callback.isNil(): - return - try: - await callback(event) - except Exception: - error "Failed to execute event listener", error = getCurrentExceptionMsg() - - proc `emitImplIdent`( - brokerCtx: BrokerContext, event: `typeIdent` - ): Future[void] {.async: (raises: []), gcsafe.} = - when compiles(event.isNil()): - if event.isNil(): - error "Cannot emit uninitialized event object", eventType = `typeNameLit` - return - let broker = `accessProcIdent`() - let bucketIdx = `findBucketIdxIdent`(broker, brokerCtx) - if bucketIdx < 0: - # nothing to do as nobody is listening - return - if broker.buckets[bucketIdx].listeners.len == 0: - return - var callbacks: seq[`handlerProcIdent`] = @[] - for cb in broker.buckets[bucketIdx].listeners.values: - callbacks.add(cb) - for cb in callbacks: - asyncSpawn `listenerTaskIdent`(cb, event) - - proc emit*(event: `typeIdent`) = - asyncSpawn `emitImplIdent`(DefaultBrokerContext, event) - - proc emit*(_: typedesc[`typeIdent`], event: `typeIdent`) = - asyncSpawn `emitImplIdent`(DefaultBrokerContext, event) - - proc emit*( - _: typedesc[`typeIdent`], brokerCtx: BrokerContext, event: `typeIdent` - ) = - asyncSpawn `emitImplIdent`(brokerCtx, event) - - ) - - if hasInlineFields: - # Typedesc emit constructor overloads for inline object/ref object types. - var emitCtorParams = newTree(nnkFormalParams, newEmptyNode()) - let typedescParamType = - newTree(nnkBracketExpr, ident("typedesc"), copyNimTree(typeIdent)) - emitCtorParams.add( - newTree(nnkIdentDefs, ident("_"), typedescParamType, newEmptyNode()) - ) - for i in 0 ..< fieldNames.len: - emitCtorParams.add( - newTree( - nnkIdentDefs, - copyNimTree(fieldNames[i]), - copyNimTree(fieldTypes[i]), - newEmptyNode(), - ) - ) - - var emitCtorExpr = newTree(nnkObjConstr, copyNimTree(typeIdent)) - for i in 0 ..< fieldNames.len: - emitCtorExpr.add( - newTree( - nnkExprColonExpr, copyNimTree(fieldNames[i]), copyNimTree(fieldNames[i]) - ) - ) - - let emitCtorCallDefault = - newCall(copyNimTree(emitImplIdent), ident("DefaultBrokerContext"), emitCtorExpr) - let emitCtorBodyDefault = quote: - asyncSpawn `emitCtorCallDefault` - - let typedescEmitProcDefault = newTree( - nnkProcDef, - postfix(ident("emit"), "*"), - newEmptyNode(), - newEmptyNode(), - emitCtorParams, - newEmptyNode(), - newEmptyNode(), - emitCtorBodyDefault, - ) - result.add(typedescEmitProcDefault) - - var emitCtorParamsCtx = newTree(nnkFormalParams, newEmptyNode()) - emitCtorParamsCtx.add( - newTree(nnkIdentDefs, ident("_"), typedescParamType, newEmptyNode()) - ) - emitCtorParamsCtx.add( - newTree(nnkIdentDefs, ident("brokerCtx"), ident("BrokerContext"), newEmptyNode()) - ) - for i in 0 ..< fieldNames.len: - emitCtorParamsCtx.add( - newTree( - nnkIdentDefs, - copyNimTree(fieldNames[i]), - copyNimTree(fieldTypes[i]), - newEmptyNode(), - ) - ) - - let emitCtorCallCtx = - newCall(copyNimTree(emitImplIdent), ident("brokerCtx"), copyNimTree(emitCtorExpr)) - let emitCtorBodyCtx = quote: - asyncSpawn `emitCtorCallCtx` - - let typedescEmitProcCtx = newTree( - nnkProcDef, - postfix(ident("emit"), "*"), - newEmptyNode(), - newEmptyNode(), - emitCtorParamsCtx, - newEmptyNode(), - newEmptyNode(), - emitCtorBodyCtx, - ) - result.add(typedescEmitProcCtx) - - when defined(eventBrokerDebug): - echo result.repr diff --git a/waku/common/broker/helper/broker_utils.nim b/waku/common/broker/helper/broker_utils.nim deleted file mode 100644 index 90f2055d3..000000000 --- a/waku/common/broker/helper/broker_utils.nim +++ /dev/null @@ -1,206 +0,0 @@ -import std/macros - -type ParsedBrokerType* = object - ## Result of parsing the single `type` definition inside a broker macro body. - ## - ## - `typeIdent`: base identifier for the declared type name - ## - `objectDef`: exported type definition RHS (inline object fields exported; - ## non-object types wrapped in `distinct` unless already distinct) - ## - `isRefObject`: true only for inline `ref object` definitions - ## - `hasInlineFields`: true for inline `object` / `ref object` - ## - `fieldNames`/`fieldTypes`: populated only when `collectFieldInfo = true` - typeIdent*: NimNode - objectDef*: NimNode - isRefObject*: bool - hasInlineFields*: bool - fieldNames*: seq[NimNode] - fieldTypes*: seq[NimNode] - -proc sanitizeIdentName*(node: NimNode): string = - var raw = $node - var sanitizedName = newStringOfCap(raw.len) - for ch in raw: - case ch - of 'A' .. 'Z', 'a' .. 'z', '0' .. '9', '_': - sanitizedName.add(ch) - else: - sanitizedName.add('_') - sanitizedName - -proc ensureFieldDef*(node: NimNode) = - if node.kind != nnkIdentDefs or node.len < 3: - error("Expected field definition of the form `name: Type`", node) - let typeSlot = node.len - 2 - if node[typeSlot].kind == nnkEmpty: - error("Field `" & $node[0] & "` must declare a type", node) - -proc exportIdentNode*(node: NimNode): NimNode = - case node.kind - of nnkIdent: - postfix(copyNimTree(node), "*") - of nnkPostfix: - node - else: - error("Unsupported identifier form in field definition", node) - -proc baseTypeIdent*(defName: NimNode): NimNode = - case defName.kind - of nnkIdent: - defName - of nnkAccQuoted: - if defName.len != 1: - error("Unsupported quoted identifier", defName) - defName[0] - of nnkPostfix: - baseTypeIdent(defName[1]) - of nnkPragmaExpr: - baseTypeIdent(defName[0]) - else: - error("Unsupported type name in broker definition", defName) - -proc ensureDistinctType*(rhs: NimNode): NimNode = - ## For PODs / aliases / externally-defined types, wrap in `distinct` unless - ## it's already distinct. - if rhs.kind == nnkDistinctTy: - return copyNimTree(rhs) - newTree(nnkDistinctTy, copyNimTree(rhs)) - -proc cloneParams*(params: seq[NimNode]): seq[NimNode] = - ## Deep copy parameter definitions so they can be inserted in multiple places. - result = @[] - for param in params: - result.add(copyNimTree(param)) - -proc collectParamNames*(params: seq[NimNode]): seq[NimNode] = - ## Extract all identifier symbols declared across IdentDefs nodes. - result = @[] - for param in params: - assert param.kind == nnkIdentDefs - for i in 0 ..< param.len - 2: - let nameNode = param[i] - if nameNode.kind == nnkEmpty: - continue - result.add(ident($nameNode)) - -proc parseSingleTypeDef*( - body: NimNode, - macroName: string, - allowRefToNonObject = false, - collectFieldInfo = false, -): ParsedBrokerType = - ## Parses exactly one `type` definition from a broker macro body. - ## - ## Supported RHS: - ## - inline `object` / `ref object` (fields are auto-exported) - ## - non-object types / aliases / externally-defined types (wrapped in `distinct`) - ## - optionally: `ref SomeType` when `allowRefToNonObject = true` - var typeIdent: NimNode = nil - var objectDef: NimNode = nil - var isRefObject = false - var hasInlineFields = false - var fieldNames: seq[NimNode] = @[] - var fieldTypes: seq[NimNode] = @[] - - for stmt in body: - if stmt.kind != nnkTypeSection: - continue - for def in stmt: - if def.kind != nnkTypeDef: - continue - if not typeIdent.isNil(): - error("Only one type may be declared inside " & macroName, def) - typeIdent = baseTypeIdent(def[0]) - let rhs = def[2] - - case rhs.kind - of nnkObjectTy: - let recList = rhs[2] - if recList.kind != nnkRecList: - error(macroName & " object must declare a standard field list", rhs) - var exportedRecList = newTree(nnkRecList) - for field in recList: - case field.kind - of nnkIdentDefs: - ensureFieldDef(field) - if collectFieldInfo: - let fieldTypeNode = field[field.len - 2] - for i in 0 ..< field.len - 2: - let baseFieldIdent = baseTypeIdent(field[i]) - fieldNames.add(copyNimTree(baseFieldIdent)) - fieldTypes.add(copyNimTree(fieldTypeNode)) - var cloned = copyNimTree(field) - for i in 0 ..< cloned.len - 2: - cloned[i] = exportIdentNode(cloned[i]) - exportedRecList.add(cloned) - of nnkEmpty: - discard - else: - error( - macroName & " object definition only supports simple field declarations", - field, - ) - objectDef = newTree( - nnkObjectTy, copyNimTree(rhs[0]), copyNimTree(rhs[1]), exportedRecList - ) - isRefObject = false - hasInlineFields = true - of nnkRefTy: - if rhs.len != 1: - error(macroName & " ref type must have a single base", rhs) - if rhs[0].kind == nnkObjectTy: - let obj = rhs[0] - let recList = obj[2] - if recList.kind != nnkRecList: - error(macroName & " object must declare a standard field list", obj) - var exportedRecList = newTree(nnkRecList) - for field in recList: - case field.kind - of nnkIdentDefs: - ensureFieldDef(field) - if collectFieldInfo: - let fieldTypeNode = field[field.len - 2] - for i in 0 ..< field.len - 2: - let baseFieldIdent = baseTypeIdent(field[i]) - fieldNames.add(copyNimTree(baseFieldIdent)) - fieldTypes.add(copyNimTree(fieldTypeNode)) - var cloned = copyNimTree(field) - for i in 0 ..< cloned.len - 2: - cloned[i] = exportIdentNode(cloned[i]) - exportedRecList.add(cloned) - of nnkEmpty: - discard - else: - error( - macroName & " object definition only supports simple field declarations", - field, - ) - let exportedObjectType = newTree( - nnkObjectTy, copyNimTree(obj[0]), copyNimTree(obj[1]), exportedRecList - ) - objectDef = newTree(nnkRefTy, exportedObjectType) - isRefObject = true - hasInlineFields = true - elif allowRefToNonObject: - ## `ref SomeType` (SomeType can be defined elsewhere) - objectDef = ensureDistinctType(rhs) - isRefObject = false - hasInlineFields = false - else: - error(macroName & " ref object must wrap a concrete object definition", rhs) - else: - ## Non-object type / alias. - objectDef = ensureDistinctType(rhs) - isRefObject = false - hasInlineFields = false - - if typeIdent.isNil(): - error(macroName & " body must declare exactly one type", body) - - result = ParsedBrokerType( - typeIdent: typeIdent, - objectDef: objectDef, - isRefObject: isRefObject, - hasInlineFields: hasInlineFields, - fieldNames: fieldNames, - fieldTypes: fieldTypes, - ) diff --git a/waku/common/broker/multi_request_broker.nim b/waku/common/broker/multi_request_broker.nim deleted file mode 100644 index 2baa19940..000000000 --- a/waku/common/broker/multi_request_broker.nim +++ /dev/null @@ -1,743 +0,0 @@ -## MultiRequestBroker -## -------------------- -## MultiRequestBroker represents a proactive decoupling pattern, that -## allows defining request-response style interactions between modules without -## need for direct dependencies in between. -## Worth considering using it for use cases where you need to collect data from multiple providers. -## -## Generates a standalone, type-safe request broker for the declared type. -## The macro exports the value type itself plus a broker companion that manages -## providers via thread-local storage. -## -## Unlike `RequestBroker`, every call to `request` fan-outs to every registered -## provider and returns all collected responses. -## The request succeeds only if all providers succeed, otherwise it fails. -## -## Type definitions: -## - Inline `object` / `ref object` definitions are supported. -## - Native types, aliases, and externally-defined types are also supported. -## In that case, MultiRequestBroker will automatically wrap the declared RHS -## type in `distinct` unless you already used `distinct`. -## This keeps request types unique even when multiple brokers share the same -## underlying base type. -## -## Default vs. context aware use: -## Every generated broker is a thread-local global instance. -## Sometimes you want multiple independent provider sets for the same request -## type within the same thread (e.g. multiple components). For that, you can use -## context-aware MultiRequestBroker. -## -## Context awareness is supported through the `BrokerContext` argument for -## `setProvider`, `request`, `removeProvider`, and `clearProviders`. -## Provider stores are kept separate per broker context. -## -## Default broker context is defined as `DefaultBrokerContext`. If you don't -## need context awareness, you can keep using the interfaces without the context -## argument, which operate on `DefaultBrokerContext`. -## -## Usage: -## -## Declare collectable request data type inside a `MultiRequestBroker` macro, add any number of fields: -## ```nim -## MultiRequestBroker: -## type TypeName = object -## field1*: Type1 -## field2*: Type2 -## -## ## Define the request and provider signature, that is enforced at compile time. -## proc signature*(): Future[Result[TypeName, string]] {.async: (raises: []).} -## -## ## Also possible to define signature with arbitrary input arguments. -## proc signature*(arg1: ArgType, arg2: AnotherArgType): Future[Result[TypeName, string]] {.async: (raises: []).} -## -## ``` -## -## You can register a request processor (provider) anywhere without the need to -## know who will request. -## Register provider functions with `TypeName.setProvider(...)`. -## Providers are async procs or lambdas that return `Future[Result[TypeName, string]]`. -## `setProvider` returns a handle (or an error) that can later be used to remove -## the provider. - -## Requests can be made from anywhere with no direct dependency on the provider(s) -## by calling `TypeName.request()` (with arguments respecting the declared signature). -## This will asynchronously call all registered providers and return the collected -## responses as `Future[Result[seq[TypeName], string]]`. -## -## Whenever you don't want to process requests anymore (or your object instance that provides the request goes out of scope), -## you can remove it from the broker with `TypeName.removeProvider(handle)`. -## Alternatively, you can remove all registered providers through `TypeName.clearProviders()`. -## -## Example: -## ```nim -## MultiRequestBroker: -## type Greeting = object -## text*: string -## -## ## Define the request and provider signature, that is enforced at compile time. -## proc signature*(): Future[Result[Greeting, string]] {.async: (raises: []).} -## -## ## Also possible to define signature with arbitrary input arguments. -## proc signature*(lang: string): Future[Result[Greeting, string]] {.async: (raises: []).} -## -## ... -## let handle = Greeting.setProvider( -## proc(): Future[Result[Greeting, string]] {.async: (raises: []).} = -## ok(Greeting(text: "hello")) -## ) -## -## let anotherHandle = Greeting.setProvider( -## proc(): Future[Result[Greeting, string]] {.async: (raises: []).} = -## ok(Greeting(text: "szia")) -## ) -## -## let responses = (await Greeting.request()).valueOr(@[Greeting(text: "default")]) -## -## echo responses.len -## Greeting.clearProviders() -## ``` -## If no `signature` proc is declared, a zero-argument form is generated -## automatically, so the caller only needs to provide the type definition. - -import std/[macros, strutils, tables, sugar] -import chronos -import results -import ./helper/broker_utils -import ./broker_context - -export results, chronos, broker_context - -proc isReturnTypeValid(returnType, typeIdent: NimNode): bool = - ## Accept Future[Result[TypeIdent, string]] as the contract. - if returnType.kind != nnkBracketExpr or returnType.len != 2: - return false - if returnType[0].kind != nnkIdent or not returnType[0].eqIdent("Future"): - return false - let inner = returnType[1] - if inner.kind != nnkBracketExpr or inner.len != 3: - return false - if inner[0].kind != nnkIdent or not inner[0].eqIdent("Result"): - return false - if inner[1].kind != nnkIdent or not inner[1].eqIdent($typeIdent): - return false - inner[2].kind == nnkIdent and inner[2].eqIdent("string") - -proc makeProcType(returnType: NimNode, params: seq[NimNode]): NimNode = - var formal = newTree(nnkFormalParams) - formal.add(returnType) - for param in params: - formal.add(param) - - let pragmas = quote: - {.async.} - - newTree(nnkProcTy, formal, pragmas) - -macro MultiRequestBroker*(body: untyped): untyped = - when defined(requestBrokerDebug): - echo body.treeRepr - let parsed = parseSingleTypeDef(body, "MultiRequestBroker") - let typeIdent = parsed.typeIdent - let objectDef = parsed.objectDef - let isRefObject = parsed.isRefObject - - when defined(requestBrokerDebug): - echo "MultiRequestBroker generating type: ", $typeIdent - - let exportedTypeIdent = postfix(copyNimTree(typeIdent), "*") - let sanitized = sanitizeIdentName(typeIdent) - let typeNameLit = newLit($typeIdent) - let isRefObjectLit = newLit(isRefObject) - let uint64Ident = ident("uint64") - let providerKindIdent = ident(sanitized & "ProviderKind") - let providerHandleIdent = ident(sanitized & "ProviderHandle") - let exportedProviderHandleIdent = postfix(copyNimTree(providerHandleIdent), "*") - let bucketTypeIdent = ident(sanitized & "CtxBucket") - let findBucketIdxIdent = ident(sanitized & "FindBucketIdx") - let getOrCreateBucketIdxIdent = ident(sanitized & "GetOrCreateBucketIdx") - let zeroKindIdent = ident("pk" & sanitized & "NoArgs") - let argKindIdent = ident("pk" & sanitized & "WithArgs") - var zeroArgSig: NimNode = nil - var zeroArgProviderName: NimNode = nil - var zeroArgFieldName: NimNode = nil - var argSig: NimNode = nil - var argParams: seq[NimNode] = @[] - var argProviderName: NimNode = nil - var argFieldName: NimNode = nil - - for stmt in body: - case stmt.kind - of nnkProcDef: - let procName = stmt[0] - let procNameIdent = - case procName.kind - of nnkIdent: - procName - of nnkPostfix: - procName[1] - else: - procName - let procNameStr = $procNameIdent - if not procNameStr.startsWith("signature"): - error("Signature proc names must start with `signature`", procName) - let params = stmt.params - if params.len == 0: - error("Signature must declare a return type", stmt) - let returnType = params[0] - if not isReturnTypeValid(returnType, typeIdent): - error( - "Signature must return Future[Result[`" & $typeIdent & "`, string]]", stmt - ) - let paramCount = params.len - 1 - if paramCount == 0: - if zeroArgSig != nil: - error("Only one zero-argument signature is allowed", stmt) - zeroArgSig = stmt - zeroArgProviderName = ident(sanitizeIdentName(typeIdent) & "ProviderNoArgs") - zeroArgFieldName = ident("providerNoArgs") - elif paramCount >= 1: - if argSig != nil: - error("Only one argument-based signature is allowed", stmt) - argSig = stmt - argParams = @[] - for idx in 1 ..< params.len: - let paramDef = params[idx] - if paramDef.kind != nnkIdentDefs: - error( - "Signature parameter must be a standard identifier declaration", paramDef - ) - let paramTypeNode = paramDef[paramDef.len - 2] - if paramTypeNode.kind == nnkEmpty: - error("Signature parameter must declare a type", paramDef) - var hasName = false - for i in 0 ..< paramDef.len - 2: - if paramDef[i].kind != nnkEmpty: - hasName = true - if not hasName: - error("Signature parameter must declare a name", paramDef) - argParams.add(copyNimTree(paramDef)) - argProviderName = ident(sanitizeIdentName(typeIdent) & "ProviderWithArgs") - argFieldName = ident("providerWithArgs") - of nnkTypeSection, nnkEmpty: - discard - else: - error("Unsupported statement inside MultiRequestBroker definition", stmt) - - if zeroArgSig.isNil() and argSig.isNil(): - zeroArgSig = newEmptyNode() - zeroArgProviderName = ident(sanitizeIdentName(typeIdent) & "ProviderNoArgs") - zeroArgFieldName = ident("providerNoArgs") - - var typeSection = newTree(nnkTypeSection) - typeSection.add(newTree(nnkTypeDef, exportedTypeIdent, newEmptyNode(), objectDef)) - - var kindEnum = newTree(nnkEnumTy, newEmptyNode()) - if not zeroArgSig.isNil(): - kindEnum.add(zeroKindIdent) - if not argSig.isNil(): - kindEnum.add(argKindIdent) - typeSection.add(newTree(nnkTypeDef, providerKindIdent, newEmptyNode(), kindEnum)) - - var handleRecList = newTree(nnkRecList) - handleRecList.add(newTree(nnkIdentDefs, ident("id"), uint64Ident, newEmptyNode())) - handleRecList.add( - newTree(nnkIdentDefs, ident("kind"), providerKindIdent, newEmptyNode()) - ) - typeSection.add( - newTree( - nnkTypeDef, - exportedProviderHandleIdent, - newEmptyNode(), - newTree(nnkObjectTy, newEmptyNode(), newEmptyNode(), handleRecList), - ) - ) - - let returnType = quote: - Future[Result[`typeIdent`, string]] - - if not zeroArgSig.isNil(): - let procType = makeProcType(returnType, @[]) - typeSection.add(newTree(nnkTypeDef, zeroArgProviderName, newEmptyNode(), procType)) - if not argSig.isNil(): - let procType = makeProcType(returnType, cloneParams(argParams)) - typeSection.add(newTree(nnkTypeDef, argProviderName, newEmptyNode(), procType)) - - var bucketRecList = newTree(nnkRecList) - bucketRecList.add( - newTree(nnkIdentDefs, ident("brokerCtx"), ident("BrokerContext"), newEmptyNode()) - ) - if not zeroArgSig.isNil(): - bucketRecList.add( - newTree( - nnkIdentDefs, - zeroArgFieldName, - newTree(nnkBracketExpr, ident("seq"), zeroArgProviderName), - newEmptyNode(), - ) - ) - if not argSig.isNil(): - bucketRecList.add( - newTree( - nnkIdentDefs, - argFieldName, - newTree(nnkBracketExpr, ident("seq"), argProviderName), - newEmptyNode(), - ) - ) - typeSection.add( - newTree( - nnkTypeDef, - bucketTypeIdent, - newEmptyNode(), - newTree(nnkObjectTy, newEmptyNode(), newEmptyNode(), bucketRecList), - ) - ) - - var brokerRecList = newTree(nnkRecList) - brokerRecList.add( - newTree( - nnkIdentDefs, - ident("buckets"), - newTree(nnkBracketExpr, ident("seq"), bucketTypeIdent), - newEmptyNode(), - ) - ) - let brokerTypeIdent = ident(sanitizeIdentName(typeIdent) & "Broker") - typeSection.add( - newTree( - nnkTypeDef, - brokerTypeIdent, - newEmptyNode(), - newTree( - nnkRefTy, newTree(nnkObjectTy, newEmptyNode(), newEmptyNode(), brokerRecList) - ), - ) - ) - result = newStmtList() - result.add(typeSection) - - let globalVarIdent = ident("g" & sanitizeIdentName(typeIdent) & "Broker") - let accessProcIdent = ident("access" & sanitizeIdentName(typeIdent) & "Broker") - result.add( - quote do: - var `globalVarIdent` {.threadvar.}: `brokerTypeIdent` - - proc `findBucketIdxIdent`( - broker: `brokerTypeIdent`, brokerCtx: BrokerContext - ): int = - if brokerCtx == DefaultBrokerContext: - return 0 - for i in 1 ..< broker.buckets.len: - if broker.buckets[i].brokerCtx == brokerCtx: - return i - return -1 - - proc `getOrCreateBucketIdxIdent`( - broker: `brokerTypeIdent`, brokerCtx: BrokerContext - ): int = - let idx = `findBucketIdxIdent`(broker, brokerCtx) - if idx >= 0: - return idx - broker.buckets.add(`bucketTypeIdent`(brokerCtx: brokerCtx)) - return broker.buckets.high - - proc `accessProcIdent`(): `brokerTypeIdent` = - if `globalVarIdent`.isNil(): - new(`globalVarIdent`) - `globalVarIdent`.buckets = - @[`bucketTypeIdent`(brokerCtx: DefaultBrokerContext)] - return `globalVarIdent` - - ) - - var clearBody = newStmtList() - if not zeroArgSig.isNil(): - result.add( - quote do: - proc setProvider*( - _: typedesc[`typeIdent`], - brokerCtx: BrokerContext, - handler: `zeroArgProviderName`, - ): Result[`providerHandleIdent`, string] = - if handler.isNil(): - return err("Provider handler must be provided") - let broker = `accessProcIdent`() - let bucketIdx = `getOrCreateBucketIdxIdent`(broker, brokerCtx) - for i, existing in broker.buckets[bucketIdx].`zeroArgFieldName`: - if not existing.isNil() and existing == handler: - return ok(`providerHandleIdent`(id: uint64(i + 1), kind: `zeroKindIdent`)) - broker.buckets[bucketIdx].`zeroArgFieldName`.add(handler) - return ok( - `providerHandleIdent`( - id: uint64(broker.buckets[bucketIdx].`zeroArgFieldName`.len), - kind: `zeroKindIdent`, - ) - ) - - proc setProvider*( - _: typedesc[`typeIdent`], handler: `zeroArgProviderName` - ): Result[`providerHandleIdent`, string] = - return setProvider(`typeIdent`, DefaultBrokerContext, handler) - - ) - result.add( - quote do: - proc request*( - _: typedesc[`typeIdent`], brokerCtx: BrokerContext - ): Future[Result[seq[`typeIdent`], string]] {.async: (raises: []), gcsafe.} = - var aggregated: seq[`typeIdent`] = @[] - let broker = `accessProcIdent`() - let bucketIdx = `findBucketIdxIdent`(broker, brokerCtx) - if bucketIdx < 0: - return ok(aggregated) - let providers = broker.buckets[bucketIdx].`zeroArgFieldName` - if providers.len == 0: - return ok(aggregated) - # var providersFut: seq[Future[Result[`typeIdent`, string]]] = collect: - var providersFut = collect(newSeq): - for provider in providers: - if provider.isNil(): - continue - provider() - - let catchable = catch: - await allFinished(providersFut) - - catchable.isOkOr: - return err("Some provider(s) failed:" & error.msg) - - for fut in catchable.get(): - if fut.failed(): - return err("Some provider(s) failed:" & fut.error.msg) - elif fut.finished(): - let providerResult = fut.value() - if providerResult.isOk: - let providerValue = providerResult.get() - when `isRefObjectLit`: - if providerValue.isNil(): - return err( - "MultiRequestBroker(" & `typeNameLit` & - "): provider returned nil result" - ) - aggregated.add(providerValue) - else: - return err("Some provider(s) failed:" & providerResult.error) - - return ok(aggregated) - - proc request*( - _: typedesc[`typeIdent`] - ): Future[Result[seq[`typeIdent`], string]] = - return request(`typeIdent`, DefaultBrokerContext) - - ) - if not argSig.isNil(): - result.add( - quote do: - proc setProvider*( - _: typedesc[`typeIdent`], - brokerCtx: BrokerContext, - handler: `argProviderName`, - ): Result[`providerHandleIdent`, string] = - if handler.isNil(): - return err("Provider handler must be provided") - let broker = `accessProcIdent`() - let bucketIdx = `getOrCreateBucketIdxIdent`(broker, brokerCtx) - for i, existing in broker.buckets[bucketIdx].`argFieldName`: - if not existing.isNil() and existing == handler: - return ok(`providerHandleIdent`(id: uint64(i + 1), kind: `argKindIdent`)) - broker.buckets[bucketIdx].`argFieldName`.add(handler) - return ok( - `providerHandleIdent`( - id: uint64(broker.buckets[bucketIdx].`argFieldName`.len), - kind: `argKindIdent`, - ) - ) - - proc setProvider*( - _: typedesc[`typeIdent`], handler: `argProviderName` - ): Result[`providerHandleIdent`, string] = - return setProvider(`typeIdent`, DefaultBrokerContext, handler) - - ) - let requestParamDefs = cloneParams(argParams) - let argNameIdents = collectParamNames(requestParamDefs) - let providerSym = genSym(nskLet, "providerVal") - var providerCall = newCall(providerSym) - for argName in argNameIdents: - providerCall.add(argName) - var formalParams = newTree(nnkFormalParams) - formalParams.add( - quote do: - Future[Result[seq[`typeIdent`], string]] - ) - formalParams.add( - newTree( - nnkIdentDefs, - ident("_"), - newTree(nnkBracketExpr, ident("typedesc"), copyNimTree(typeIdent)), - newEmptyNode(), - ) - ) - formalParams.add( - newTree(nnkIdentDefs, ident("brokerCtx"), ident("BrokerContext"), newEmptyNode()) - ) - for paramDef in requestParamDefs: - formalParams.add(paramDef) - let requestPragmas = quote: - {.async: (raises: []), gcsafe.} - let requestBody = quote: - var aggregated: seq[`typeIdent`] = @[] - let broker = `accessProcIdent`() - let bucketIdx = `findBucketIdxIdent`(broker, brokerCtx) - if bucketIdx < 0: - return ok(aggregated) - let providers = broker.buckets[bucketIdx].`argFieldName` - if providers.len == 0: - return ok(aggregated) - var providersFut = collect(newSeq): - for provider in providers: - if provider.isNil(): - continue - let `providerSym` = provider - `providerCall` - let catchable = catch: - await allFinished(providersFut) - catchable.isOkOr: - return err("Some provider(s) failed:" & error.msg) - for fut in catchable.get(): - if fut.failed(): - return err("Some provider(s) failed:" & fut.error.msg) - elif fut.finished(): - let providerResult = fut.value() - if providerResult.isOk: - let providerValue = providerResult.get() - when `isRefObjectLit`: - if providerValue.isNil(): - return err( - "MultiRequestBroker(" & `typeNameLit` & - "): provider returned nil result" - ) - aggregated.add(providerValue) - else: - return err("Some provider(s) failed:" & providerResult.error) - return ok(aggregated) - - result.add( - newTree( - nnkProcDef, - postfix(ident("request"), "*"), - newEmptyNode(), - newEmptyNode(), - formalParams, - requestPragmas, - newEmptyNode(), - requestBody, - ) - ) - - # Backward-compatible default-context overload (no brokerCtx parameter). - var formalParamsDefault = newTree(nnkFormalParams) - formalParamsDefault.add( - quote do: - Future[Result[seq[`typeIdent`], string]] - ) - formalParamsDefault.add( - newTree( - nnkIdentDefs, - ident("_"), - newTree(nnkBracketExpr, ident("typedesc"), copyNimTree(typeIdent)), - newEmptyNode(), - ) - ) - for paramDef in requestParamDefs: - formalParamsDefault.add(copyNimTree(paramDef)) - - var wrapperCall = newCall(ident("request")) - wrapperCall.add(copyNimTree(typeIdent)) - wrapperCall.add(ident("DefaultBrokerContext")) - for argName in argNameIdents: - wrapperCall.add(copyNimTree(argName)) - - result.add( - newTree( - nnkProcDef, - postfix(ident("request"), "*"), - newEmptyNode(), - newEmptyNode(), - formalParamsDefault, - newEmptyNode(), - newEmptyNode(), - newStmtList(newTree(nnkReturnStmt, wrapperCall)), - ) - ) - let removeHandleCtxSym = genSym(nskParam, "handle") - let removeHandleDefaultSym = genSym(nskParam, "handle") - - when true: - # Generate clearProviders / removeProvider with macro-time knowledge about which - # provider lists exist (zero-arg and/or arg providers). - if not zeroArgSig.isNil() and not argSig.isNil(): - result.add( - quote do: - proc clearProviders*(_: typedesc[`typeIdent`], brokerCtx: BrokerContext) = - let broker = `accessProcIdent`() - if broker.isNil(): - return - let bucketIdx = `findBucketIdxIdent`(broker, brokerCtx) - if bucketIdx < 0: - return - broker.buckets[bucketIdx].`zeroArgFieldName`.setLen(0) - broker.buckets[bucketIdx].`argFieldName`.setLen(0) - if brokerCtx != DefaultBrokerContext: - broker.buckets.delete(bucketIdx) - - proc clearProviders*(_: typedesc[`typeIdent`]) = - clearProviders(`typeIdent`, DefaultBrokerContext) - - proc removeProvider*( - _: typedesc[`typeIdent`], - brokerCtx: BrokerContext, - `removeHandleCtxSym`: `providerHandleIdent`, - ) = - if `removeHandleCtxSym`.id == 0'u64: - return - let broker = `accessProcIdent`() - if broker.isNil(): - return - let bucketIdx = `findBucketIdxIdent`(broker, brokerCtx) - if bucketIdx < 0: - return - - if `removeHandleCtxSym`.kind == `zeroKindIdent`: - let idx = int(`removeHandleCtxSym`.id) - 1 - if idx >= 0 and idx < broker.buckets[bucketIdx].`zeroArgFieldName`.len: - broker.buckets[bucketIdx].`zeroArgFieldName`[idx] = nil - elif `removeHandleCtxSym`.kind == `argKindIdent`: - let idx = int(`removeHandleCtxSym`.id) - 1 - if idx >= 0 and idx < broker.buckets[bucketIdx].`argFieldName`.len: - broker.buckets[bucketIdx].`argFieldName`[idx] = nil - - if brokerCtx != DefaultBrokerContext: - var hasAny = false - for p in broker.buckets[bucketIdx].`zeroArgFieldName`: - if not p.isNil(): - hasAny = true - break - if not hasAny: - for p in broker.buckets[bucketIdx].`argFieldName`: - if not p.isNil(): - hasAny = true - break - if not hasAny: - broker.buckets.delete(bucketIdx) - - proc removeProvider*( - _: typedesc[`typeIdent`], `removeHandleDefaultSym`: `providerHandleIdent` - ) = - removeProvider(`typeIdent`, DefaultBrokerContext, `removeHandleDefaultSym`) - - ) - elif not zeroArgSig.isNil(): - result.add( - quote do: - proc clearProviders*(_: typedesc[`typeIdent`], brokerCtx: BrokerContext) = - let broker = `accessProcIdent`() - if broker.isNil(): - return - let bucketIdx = `findBucketIdxIdent`(broker, brokerCtx) - if bucketIdx < 0: - return - broker.buckets[bucketIdx].`zeroArgFieldName`.setLen(0) - if brokerCtx != DefaultBrokerContext: - broker.buckets.delete(bucketIdx) - - proc clearProviders*(_: typedesc[`typeIdent`]) = - clearProviders(`typeIdent`, DefaultBrokerContext) - - proc removeProvider*( - _: typedesc[`typeIdent`], - brokerCtx: BrokerContext, - `removeHandleCtxSym`: `providerHandleIdent`, - ) = - if `removeHandleCtxSym`.id == 0'u64: - return - let broker = `accessProcIdent`() - if broker.isNil(): - return - let bucketIdx = `findBucketIdxIdent`(broker, brokerCtx) - if bucketIdx < 0: - return - if `removeHandleCtxSym`.kind != `zeroKindIdent`: - return - let idx = int(`removeHandleCtxSym`.id) - 1 - if idx >= 0 and idx < broker.buckets[bucketIdx].`zeroArgFieldName`.len: - broker.buckets[bucketIdx].`zeroArgFieldName`[idx] = nil - if brokerCtx != DefaultBrokerContext: - var hasAny = false - for p in broker.buckets[bucketIdx].`zeroArgFieldName`: - if not p.isNil(): - hasAny = true - break - if not hasAny: - broker.buckets.delete(bucketIdx) - - proc removeProvider*( - _: typedesc[`typeIdent`], `removeHandleDefaultSym`: `providerHandleIdent` - ) = - removeProvider(`typeIdent`, DefaultBrokerContext, `removeHandleDefaultSym`) - - ) - else: - result.add( - quote do: - proc clearProviders*(_: typedesc[`typeIdent`], brokerCtx: BrokerContext) = - let broker = `accessProcIdent`() - if broker.isNil(): - return - let bucketIdx = `findBucketIdxIdent`(broker, brokerCtx) - if bucketIdx < 0: - return - broker.buckets[bucketIdx].`argFieldName`.setLen(0) - if brokerCtx != DefaultBrokerContext: - broker.buckets.delete(bucketIdx) - - proc clearProviders*(_: typedesc[`typeIdent`]) = - clearProviders(`typeIdent`, DefaultBrokerContext) - - proc removeProvider*( - _: typedesc[`typeIdent`], - brokerCtx: BrokerContext, - `removeHandleCtxSym`: `providerHandleIdent`, - ) = - if `removeHandleCtxSym`.id == 0'u64: - return - let broker = `accessProcIdent`() - if broker.isNil(): - return - let bucketIdx = `findBucketIdxIdent`(broker, brokerCtx) - if bucketIdx < 0: - return - if `removeHandleCtxSym`.kind != `argKindIdent`: - return - let idx = int(`removeHandleCtxSym`.id) - 1 - if idx >= 0 and idx < broker.buckets[bucketIdx].`argFieldName`.len: - broker.buckets[bucketIdx].`argFieldName`[idx] = nil - if brokerCtx != DefaultBrokerContext: - var hasAny = false - for p in broker.buckets[bucketIdx].`argFieldName`: - if not p.isNil(): - hasAny = true - break - if not hasAny: - broker.buckets.delete(bucketIdx) - - proc removeProvider*( - _: typedesc[`typeIdent`], `removeHandleDefaultSym`: `providerHandleIdent` - ) = - removeProvider(`typeIdent`, DefaultBrokerContext, `removeHandleDefaultSym`) - - ) - - when defined(requestBrokerDebug): - echo result.repr diff --git a/waku/common/broker/request_broker.nim b/waku/common/broker/request_broker.nim deleted file mode 100644 index 46f7d7d16..000000000 --- a/waku/common/broker/request_broker.nim +++ /dev/null @@ -1,841 +0,0 @@ -## RequestBroker -## -------------------- -## RequestBroker represents a proactive decoupling pattern, that -## allows defining request-response style interactions between modules without -## need for direct dependencies in between. -## Worth considering using it in a single provider, many requester scenario. -## -## Provides a declarative way to define an immutable value type together with a -## thread-local broker that can register an asynchronous or synchronous provider, -## dispatch typed requests and clear provider. -## -## For consideration use `sync` mode RequestBroker when you need to provide simple value(s) -## where there is no long-running async operation involved. -## Typically it act as a accessor for the local state of generic setting. -## -## `async` mode is better to be used when you request date that may involve some long IO operation -## or action. -## -## Default vs. context aware use: -## Every generated broker is a thread-local global instance. This means each RequestBroker enables decoupled -## data exchange threadwise. Sometimes we use brokers inside a context - like inside a component that has many modules or subsystems. -## In case you would instantiate multiple such components in a single thread, and each component must has its own provider for the same RequestBroker type, -## in order to avoid provider collision, you can use context aware RequestBroker. -## Context awareness is supported through the `BrokerContext` argument for `setProvider`, `request`, `clearProvider` interfaces. -## Suce use requires generating a new unique `BrokerContext` value per component instance, and spread it to all modules using the brokers. -## Example, store the `BrokerContext` as a field inside the top level component instance, and spread around at initialization of the subcomponents.. -## -## Default broker context is defined as `DefaultBrokerContext` constant. But if you don't need context awareness, you can use the -## interfaces without context argument. -## -## Usage: -## Declare your desired request type inside a `RequestBroker` macro, add any number of fields. -## Define the provider signature, that is enforced at compile time. -## -## ```nim -## RequestBroker: -## type TypeName = object -## field1*: FieldType -## field2*: AnotherFieldType -## -## proc signature*(): Future[Result[TypeName, string]] -## ## Also possible to define signature with arbitrary input arguments. -## proc signature*(arg1: ArgType, arg2: AnotherArgType): Future[Result[TypeName, string]] -## -## ``` -## -## Sync mode (no `async` / `Future`) can be generated with: -## -## ```nim -## RequestBroker(sync): -## type TypeName = object -## field1*: FieldType -## -## proc signature*(): Result[TypeName, string] -## proc signature*(arg1: ArgType): Result[TypeName, string] -## ``` -## -## Note: When the request type is declared as a native type / alias / externally-defined -## type (i.e. not an inline `object` / `ref object` definition), RequestBroker -## will wrap it in `distinct` automatically unless you already used `distinct`. -## This avoids overload ambiguity when multiple brokers share the same -## underlying base type (Nim overload resolution does not consider return type). -## -## This means that for non-object request types you typically: -## - construct values with an explicit cast/constructor, e.g. `MyType("x")` -## - unwrap with a cast when needed, e.g. `string(myVal)` or `BaseType(myVal)` -## -## Example (native response type): -## ```nim -## RequestBroker(sync): -## type MyCount = int # exported as: `distinct int` -## -## MyCount.setProvider(proc(): Result[MyCount, string] = ok(MyCount(42))) -## let res = MyCount.request() -## if res.isOk(): -## let raw = int(res.get()) -## ``` -## -## Example (externally-defined type): -## ```nim -## type External = object -## label*: string -## -## RequestBroker: -## type MyExternal = External # exported as: `distinct External` -## -## MyExternal.setProvider( -## proc(): Future[Result[MyExternal, string]] {.async.} = -## ok(MyExternal(External(label: "hi"))) -## ) -## let res = await MyExternal.request() -## if res.isOk(): -## let base = External(res.get()) -## echo base.label -## ``` -## The 'TypeName' object defines the requestable data (but also can be seen as request for action with return value). -## The 'signature' proc defines the provider(s) signature, that is enforced at compile time. -## One signature can be with no arguments, another with any number of arguments - where the input arguments are -## not related to the request type - but alternative inputs for the request to be processed. -## -## After this, you can register a provider anywhere in your code with -## `TypeName.setProvider(...)`, which returns error if already having a provider. -## Providers are async procs/lambdas in default mode and sync procs in sync mode. -## -## Providers are stored as a broker-context keyed list: -## - the default provider is always stored at index 0 (reserved broker context: 0) -## - additional providers can be registered under arbitrary non-zero broker contexts -## -## The original `setProvider(handler)` / `request(...)` APIs continue to operate -## on the default provider (broker context 0) for backward compatibility. -## -## Requests can be made from anywhere with no direct dependency on the provider by -## calling `TypeName.request()` - with arguments respecting the signature(s). -## In async mode, this returns a Future[Result[TypeName, string]]. In sync mode, it returns Result[TypeName, string]. -## -## Whenever you no want to process requests (or your object instance that provides the request goes out of scope), -## you can remove it from the broker with `TypeName.clearProvider()`. -## -## -## Example: -## ```nim -## RequestBroker: -## type Greeting = object -## text*: string -## -## ## Define the request and provider signature, that is enforced at compile time. -## proc signature*(): Future[Result[Greeting, string]] {.async.} -## -## ## Also possible to define signature with arbitrary input arguments. -## proc signature*(lang: string): Future[Result[Greeting, string]] {.async.} -## -## ... -## Greeting.setProvider( -## proc(): Future[Result[Greeting, string]] {.async.} = -## ok(Greeting(text: "hello")) -## ) -## let res = await Greeting.request() -## -## -## ... -## # using native type as response for a synchronous request. -## RequestBroker(sync): -## type NeedThatInfo = string -## -##... -## NeedThatInfo.setProvider( -## proc(): Result[NeedThatInfo, string] = -## ok("this is the info you wanted") -## ) -## let res = NeedThatInfo.request().valueOr: -## echo "not ok due to: " & error -## NeedThatInfo(":-(") -## -## echo string(res) -## ``` -## If no `signature` proc is declared, a zero-argument form is generated -## automatically, so the caller only needs to provide the type definition. - -import std/[macros, strutils] -from std/sequtils import keepItIf -import chronos -import results -import ./helper/broker_utils, broker_context - -export results, chronos, keepItIf, broker_context - -proc errorFuture[T](message: string): Future[Result[T, string]] {.inline.} = - ## Build a future that is already completed with an error result. - let fut = newFuture[Result[T, string]]("request_broker.errorFuture") - fut.complete(err(Result[T, string], message)) - fut - -type RequestBrokerMode = enum - rbAsync - rbSync - -proc isAsyncReturnTypeValid(returnType, typeIdent: NimNode): bool = - ## Accept Future[Result[TypeIdent, string]] as the contract. - if returnType.kind != nnkBracketExpr or returnType.len != 2: - return false - if returnType[0].kind != nnkIdent or not returnType[0].eqIdent("Future"): - return false - let inner = returnType[1] - if inner.kind != nnkBracketExpr or inner.len != 3: - return false - if inner[0].kind != nnkIdent or not inner[0].eqIdent("Result"): - return false - if inner[1].kind != nnkIdent or not inner[1].eqIdent($typeIdent): - return false - inner[2].kind == nnkIdent and inner[2].eqIdent("string") - -proc isSyncReturnTypeValid(returnType, typeIdent: NimNode): bool = - ## Accept Result[TypeIdent, string] as the contract. - if returnType.kind != nnkBracketExpr or returnType.len != 3: - return false - if returnType[0].kind != nnkIdent or not returnType[0].eqIdent("Result"): - return false - if returnType[1].kind != nnkIdent or not returnType[1].eqIdent($typeIdent): - return false - returnType[2].kind == nnkIdent and returnType[2].eqIdent("string") - -proc isReturnTypeValid(returnType, typeIdent: NimNode, mode: RequestBrokerMode): bool = - case mode - of rbAsync: - isAsyncReturnTypeValid(returnType, typeIdent) - of rbSync: - isSyncReturnTypeValid(returnType, typeIdent) - -proc makeProcType( - returnType: NimNode, params: seq[NimNode], mode: RequestBrokerMode -): NimNode = - var formal = newTree(nnkFormalParams) - formal.add(returnType) - for param in params: - formal.add(param) - case mode - of rbAsync: - let pragmas = newTree(nnkPragma, ident("async")) - newTree(nnkProcTy, formal, pragmas) - of rbSync: - let raisesPragma = newTree( - nnkExprColonExpr, ident("raises"), newTree(nnkBracket, ident("CatchableError")) - ) - let pragmas = newTree(nnkPragma, raisesPragma, ident("gcsafe")) - newTree(nnkProcTy, formal, pragmas) - -proc parseMode(modeNode: NimNode): RequestBrokerMode = - ## Parses the mode selector for the 2-argument macro overload. - ## Supported spellings: `sync` / `async` (case-insensitive). - let raw = ($modeNode).strip().toLowerAscii() - case raw - of "sync": - rbSync - of "async": - rbAsync - else: - error("RequestBroker mode must be `sync` or `async` (default is async)", modeNode) - -proc generateRequestBroker(body: NimNode, mode: RequestBrokerMode): NimNode = - when defined(requestBrokerDebug): - echo body.treeRepr - echo "RequestBroker mode: ", $mode - let parsed = parseSingleTypeDef(body, "RequestBroker", allowRefToNonObject = true) - let typeIdent = parsed.typeIdent - let objectDef = parsed.objectDef - - when defined(requestBrokerDebug): - echo "RequestBroker generating type: ", $typeIdent - - let exportedTypeIdent = postfix(copyNimTree(typeIdent), "*") - let typeDisplayName = sanitizeIdentName(typeIdent) - let typeNameLit = newLit(typeDisplayName) - var zeroArgSig: NimNode = nil - var zeroArgProviderName: NimNode = nil - var argSig: NimNode = nil - var argParams: seq[NimNode] = @[] - var argProviderName: NimNode = nil - - for stmt in body: - case stmt.kind - of nnkProcDef: - let procName = stmt[0] - let procNameIdent = - case procName.kind - of nnkIdent: - procName - of nnkPostfix: - procName[1] - else: - procName - let procNameStr = $procNameIdent - if not procNameStr.startsWith("signature"): - error("Signature proc names must start with `signature`", procName) - let params = stmt.params - if params.len == 0: - error("Signature must declare a return type", stmt) - let returnType = params[0] - if not isReturnTypeValid(returnType, typeIdent, mode): - case mode - of rbAsync: - error( - "Signature must return Future[Result[`" & $typeIdent & "`, string]]", stmt - ) - of rbSync: - error("Signature must return Result[`" & $typeIdent & "`, string]", stmt) - let paramCount = params.len - 1 - if paramCount == 0: - if zeroArgSig != nil: - error("Only one zero-argument signature is allowed", stmt) - zeroArgSig = stmt - zeroArgProviderName = ident(sanitizeIdentName(typeIdent) & "ProviderNoArgs") - elif paramCount >= 1: - if argSig != nil: - error("Only one argument-based signature is allowed", stmt) - argSig = stmt - argParams = @[] - for idx in 1 ..< params.len: - let paramDef = params[idx] - if paramDef.kind != nnkIdentDefs: - error( - "Signature parameter must be a standard identifier declaration", paramDef - ) - let paramTypeNode = paramDef[paramDef.len - 2] - if paramTypeNode.kind == nnkEmpty: - error("Signature parameter must declare a type", paramDef) - var hasName = false - for i in 0 ..< paramDef.len - 2: - if paramDef[i].kind != nnkEmpty: - hasName = true - if not hasName: - error("Signature parameter must declare a name", paramDef) - argParams.add(copyNimTree(paramDef)) - argProviderName = ident(sanitizeIdentName(typeIdent) & "ProviderWithArgs") - of nnkTypeSection, nnkEmpty: - discard - else: - error("Unsupported statement inside RequestBroker definition", stmt) - - if zeroArgSig.isNil() and argSig.isNil(): - zeroArgSig = newEmptyNode() - zeroArgProviderName = ident(sanitizeIdentName(typeIdent) & "ProviderNoArgs") - - var typeSection = newTree(nnkTypeSection) - typeSection.add(newTree(nnkTypeDef, exportedTypeIdent, newEmptyNode(), objectDef)) - - let returnType = - case mode - of rbAsync: - quote: - Future[Result[`typeIdent`, string]] - of rbSync: - quote: - Result[`typeIdent`, string] - - if not zeroArgSig.isNil(): - let procType = makeProcType(returnType, @[], mode) - typeSection.add(newTree(nnkTypeDef, zeroArgProviderName, newEmptyNode(), procType)) - if not argSig.isNil(): - let procType = makeProcType(returnType, cloneParams(argParams), mode) - typeSection.add(newTree(nnkTypeDef, argProviderName, newEmptyNode(), procType)) - - var brokerRecList = newTree(nnkRecList) - if not zeroArgSig.isNil(): - let zeroArgProvidersFieldName = ident("providersNoArgs") - let zeroArgProvidersTupleTy = newTree( - nnkTupleTy, - newTree(nnkIdentDefs, ident("brokerCtx"), ident("BrokerContext"), newEmptyNode()), - newTree(nnkIdentDefs, ident("handler"), zeroArgProviderName, newEmptyNode()), - ) - let zeroArgProvidersSeqTy = - newTree(nnkBracketExpr, ident("seq"), zeroArgProvidersTupleTy) - brokerRecList.add( - newTree( - nnkIdentDefs, zeroArgProvidersFieldName, zeroArgProvidersSeqTy, newEmptyNode() - ) - ) - if not argSig.isNil(): - let argProvidersFieldName = ident("providersWithArgs") - let argProvidersTupleTy = newTree( - nnkTupleTy, - newTree(nnkIdentDefs, ident("brokerCtx"), ident("BrokerContext"), newEmptyNode()), - newTree(nnkIdentDefs, ident("handler"), argProviderName, newEmptyNode()), - ) - let argProvidersSeqTy = newTree(nnkBracketExpr, ident("seq"), argProvidersTupleTy) - brokerRecList.add( - newTree(nnkIdentDefs, argProvidersFieldName, argProvidersSeqTy, newEmptyNode()) - ) - let brokerTypeIdent = ident(sanitizeIdentName(typeIdent) & "Broker") - let brokerTypeDef = newTree( - nnkTypeDef, - brokerTypeIdent, - newEmptyNode(), - newTree(nnkObjectTy, newEmptyNode(), newEmptyNode(), brokerRecList), - ) - typeSection.add(brokerTypeDef) - result = newStmtList() - result.add(typeSection) - - let globalVarIdent = ident("g" & sanitizeIdentName(typeIdent) & "Broker") - let accessProcIdent = ident("access" & sanitizeIdentName(typeIdent) & "Broker") - - var brokerNewBody = newStmtList() - if not zeroArgSig.isNil(): - brokerNewBody.add( - quote do: - result.providersNoArgs = - @[(brokerCtx: DefaultBrokerContext, handler: default(`zeroArgProviderName`))] - ) - if not argSig.isNil(): - brokerNewBody.add( - quote do: - result.providersWithArgs = - @[(brokerCtx: DefaultBrokerContext, handler: default(`argProviderName`))] - ) - - var brokerInitChecks = newStmtList() - if not zeroArgSig.isNil(): - brokerInitChecks.add( - quote do: - if `globalVarIdent`.providersNoArgs.len == 0: - `globalVarIdent` = `brokerTypeIdent`.new() - ) - if not argSig.isNil(): - brokerInitChecks.add( - quote do: - if `globalVarIdent`.providersWithArgs.len == 0: - `globalVarIdent` = `brokerTypeIdent`.new() - ) - - result.add( - quote do: - var `globalVarIdent` {.threadvar.}: `brokerTypeIdent` - - proc new(_: type `brokerTypeIdent`): `brokerTypeIdent` = - result = `brokerTypeIdent`() - `brokerNewBody` - - proc `accessProcIdent`(): var `brokerTypeIdent` = - `brokerInitChecks` - `globalVarIdent` - - ) - - var clearBodyKeyed = newStmtList() - let brokerCtxParamIdent = ident("brokerCtx") - if not zeroArgSig.isNil(): - let zeroArgProvidersFieldName = ident("providersNoArgs") - result.add( - quote do: - proc setProvider*( - _: typedesc[`typeIdent`], handler: `zeroArgProviderName` - ): Result[void, string] = - if not `accessProcIdent`().`zeroArgProvidersFieldName`[0].handler.isNil(): - return err("Zero-arg provider already set") - `accessProcIdent`().`zeroArgProvidersFieldName`[0].handler = handler - return ok() - - ) - - result.add( - quote do: - proc setProvider*( - _: typedesc[`typeIdent`], - brokerCtx: BrokerContext, - handler: `zeroArgProviderName`, - ): Result[void, string] = - if brokerCtx == DefaultBrokerContext: - return setProvider(`typeIdent`, handler) - - for entry in `accessProcIdent`().`zeroArgProvidersFieldName`: - if entry.brokerCtx == brokerCtx: - return err( - "RequestBroker(" & `typeNameLit` & - "): provider already set for broker context " & $brokerCtx - ) - - `accessProcIdent`().`zeroArgProvidersFieldName`.add( - (brokerCtx: brokerCtx, handler: handler) - ) - return ok() - - ) - clearBodyKeyed.add( - quote do: - if `brokerCtxParamIdent` == DefaultBrokerContext: - `accessProcIdent`().`zeroArgProvidersFieldName`[0].handler = - default(`zeroArgProviderName`) - else: - `accessProcIdent`().`zeroArgProvidersFieldName`.keepItIf( - it.brokerCtx != `brokerCtxParamIdent` - ) - ) - case mode - of rbAsync: - result.add( - quote do: - proc request*( - _: typedesc[`typeIdent`] - ): Future[Result[`typeIdent`, string]] {.async: (raises: []).} = - return await request(`typeIdent`, DefaultBrokerContext) - - ) - - result.add( - quote do: - proc request*( - _: typedesc[`typeIdent`], brokerCtx: BrokerContext - ): Future[Result[`typeIdent`, string]] {.async: (raises: []).} = - var provider: `zeroArgProviderName` - if brokerCtx == DefaultBrokerContext: - provider = `accessProcIdent`().`zeroArgProvidersFieldName`[0].handler - else: - for entry in `accessProcIdent`().`zeroArgProvidersFieldName`: - if entry.brokerCtx == brokerCtx: - provider = entry.handler - break - - if provider.isNil(): - if brokerCtx == DefaultBrokerContext: - return err( - "RequestBroker(" & `typeNameLit` & "): no zero-arg provider registered" - ) - return err( - "RequestBroker(" & `typeNameLit` & - "): no provider registered for broker context " & $brokerCtx - ) - - let catchedRes = catch: - await provider() - - if catchedRes.isErr(): - return err( - "RequestBroker(" & `typeNameLit` & "): provider threw exception: " & - catchedRes.error.msg - ) - - let providerRes = catchedRes.get() - if providerRes.isOk(): - let resultValue = providerRes.get() - when compiles(resultValue.isNil()): - if resultValue.isNil(): - return err( - "RequestBroker(" & `typeNameLit` & "): provider returned nil result" - ) - return providerRes - - ) - of rbSync: - result.add( - quote do: - proc request*( - _: typedesc[`typeIdent`] - ): Result[`typeIdent`, string] {.gcsafe, raises: [].} = - return request(`typeIdent`, DefaultBrokerContext) - - ) - - result.add( - quote do: - proc request*( - _: typedesc[`typeIdent`], brokerCtx: BrokerContext - ): Result[`typeIdent`, string] {.gcsafe, raises: [].} = - var provider: `zeroArgProviderName` - if brokerCtx == DefaultBrokerContext: - provider = `accessProcIdent`().`zeroArgProvidersFieldName`[0].handler - else: - for entry in `accessProcIdent`().`zeroArgProvidersFieldName`: - if entry.brokerCtx == brokerCtx: - provider = entry.handler - break - - if provider.isNil(): - if brokerCtx == DefaultBrokerContext: - return err( - "RequestBroker(" & `typeNameLit` & "): no zero-arg provider registered" - ) - return err( - "RequestBroker(" & `typeNameLit` & - "): no provider registered for broker context " & $brokerCtx - ) - - var providerRes: Result[`typeIdent`, string] - try: - providerRes = provider() - except CatchableError as e: - return err( - "RequestBroker(" & `typeNameLit` & "): provider threw exception: " & - e.msg - ) - - if providerRes.isOk(): - let resultValue = providerRes.get() - when compiles(resultValue.isNil()): - if resultValue.isNil(): - return err( - "RequestBroker(" & `typeNameLit` & "): provider returned nil result" - ) - return providerRes - - ) - if not argSig.isNil(): - let argProvidersFieldName = ident("providersWithArgs") - result.add( - quote do: - proc setProvider*( - _: typedesc[`typeIdent`], handler: `argProviderName` - ): Result[void, string] = - if not `accessProcIdent`().`argProvidersFieldName`[0].handler.isNil(): - return err("Provider already set") - `accessProcIdent`().`argProvidersFieldName`[0].handler = handler - return ok() - - ) - - result.add( - quote do: - proc setProvider*( - _: typedesc[`typeIdent`], - brokerCtx: BrokerContext, - handler: `argProviderName`, - ): Result[void, string] = - if brokerCtx == DefaultBrokerContext: - return setProvider(`typeIdent`, handler) - - for entry in `accessProcIdent`().`argProvidersFieldName`: - if entry.brokerCtx == brokerCtx: - return err( - "RequestBroker(" & `typeNameLit` & - "): provider already set for broker context " & $brokerCtx - ) - - `accessProcIdent`().`argProvidersFieldName`.add( - (brokerCtx: brokerCtx, handler: handler) - ) - return ok() - - ) - clearBodyKeyed.add( - quote do: - if `brokerCtxParamIdent` == DefaultBrokerContext: - `accessProcIdent`().`argProvidersFieldName`[0].handler = - default(`argProviderName`) - else: - `accessProcIdent`().`argProvidersFieldName`.keepItIf( - it.brokerCtx != `brokerCtxParamIdent` - ) - ) - let requestParamDefs = cloneParams(argParams) - let argNameIdents = collectParamNames(requestParamDefs) - var formalParams = newTree(nnkFormalParams) - formalParams.add(copyNimTree(returnType)) - formalParams.add( - newTree( - nnkIdentDefs, - ident("_"), - newTree(nnkBracketExpr, ident("typedesc"), copyNimTree(typeIdent)), - newEmptyNode(), - ) - ) - for paramDef in requestParamDefs: - formalParams.add(paramDef) - - let requestPragmas = - case mode - of rbAsync: - quote: - {.async: (raises: []).} - of rbSync: - quote: - {.gcsafe, raises: [].} - - var forwardCall = newCall(ident("request")) - forwardCall.add(copyNimTree(typeIdent)) - forwardCall.add(ident("DefaultBrokerContext")) - for argName in argNameIdents: - forwardCall.add(argName) - - var requestBody = newStmtList() - case mode - of rbAsync: - requestBody.add( - quote do: - return await `forwardCall` - ) - of rbSync: - requestBody.add( - quote do: - return `forwardCall` - ) - - result.add( - newTree( - nnkProcDef, - postfix(ident("request"), "*"), - newEmptyNode(), - newEmptyNode(), - formalParams, - requestPragmas, - newEmptyNode(), - requestBody, - ) - ) - - # Keyed request variant for the argument-based signature. - let requestParamDefsKeyed = cloneParams(argParams) - let argNameIdentsKeyed = collectParamNames(requestParamDefsKeyed) - let providerSymKeyed = genSym(nskVar, "provider") - var formalParamsKeyed = newTree(nnkFormalParams) - formalParamsKeyed.add(copyNimTree(returnType)) - formalParamsKeyed.add( - newTree( - nnkIdentDefs, - ident("_"), - newTree(nnkBracketExpr, ident("typedesc"), copyNimTree(typeIdent)), - newEmptyNode(), - ) - ) - formalParamsKeyed.add( - newTree(nnkIdentDefs, ident("brokerCtx"), ident("BrokerContext"), newEmptyNode()) - ) - for paramDef in requestParamDefsKeyed: - formalParamsKeyed.add(paramDef) - - let requestPragmasKeyed = requestPragmas - var providerCallKeyed = newCall(providerSymKeyed) - for argName in argNameIdentsKeyed: - providerCallKeyed.add(argName) - - var requestBodyKeyed = newStmtList() - requestBodyKeyed.add( - quote do: - var `providerSymKeyed`: `argProviderName` - if brokerCtx == DefaultBrokerContext: - `providerSymKeyed` = `accessProcIdent`().`argProvidersFieldName`[0].handler - else: - for entry in `accessProcIdent`().`argProvidersFieldName`: - if entry.brokerCtx == brokerCtx: - `providerSymKeyed` = entry.handler - break - ) - requestBodyKeyed.add( - quote do: - if `providerSymKeyed`.isNil(): - if brokerCtx == DefaultBrokerContext: - return err( - "RequestBroker(" & `typeNameLit` & - "): no provider registered for input signature" - ) - return err( - "RequestBroker(" & `typeNameLit` & - "): no provider registered for broker context " & $brokerCtx - ) - ) - - case mode - of rbAsync: - requestBodyKeyed.add( - quote do: - let catchedRes = catch: - await `providerCallKeyed` - if catchedRes.isErr(): - return err( - "RequestBroker(" & `typeNameLit` & "): provider threw exception: " & - catchedRes.error.msg - ) - - let providerRes = catchedRes.get() - if providerRes.isOk(): - let resultValue = providerRes.get() - when compiles(resultValue.isNil()): - if resultValue.isNil(): - return err( - "RequestBroker(" & `typeNameLit` & "): provider returned nil result" - ) - return providerRes - ) - of rbSync: - requestBodyKeyed.add( - quote do: - var providerRes: Result[`typeIdent`, string] - try: - providerRes = `providerCallKeyed` - except CatchableError as e: - return err( - "RequestBroker(" & `typeNameLit` & "): provider threw exception: " & e.msg - ) - - if providerRes.isOk(): - let resultValue = providerRes.get() - when compiles(resultValue.isNil()): - if resultValue.isNil(): - return err( - "RequestBroker(" & `typeNameLit` & "): provider returned nil result" - ) - return providerRes - ) - - result.add( - newTree( - nnkProcDef, - postfix(ident("request"), "*"), - newEmptyNode(), - newEmptyNode(), - formalParamsKeyed, - requestPragmasKeyed, - newEmptyNode(), - requestBodyKeyed, - ) - ) - - block: - var formalParamsClearKeyed = newTree(nnkFormalParams) - formalParamsClearKeyed.add(newEmptyNode()) - formalParamsClearKeyed.add( - newTree( - nnkIdentDefs, - ident("_"), - newTree(nnkBracketExpr, ident("typedesc"), copyNimTree(typeIdent)), - newEmptyNode(), - ) - ) - formalParamsClearKeyed.add( - newTree(nnkIdentDefs, brokerCtxParamIdent, ident("BrokerContext"), newEmptyNode()) - ) - - result.add( - newTree( - nnkProcDef, - postfix(ident("clearProvider"), "*"), - newEmptyNode(), - newEmptyNode(), - formalParamsClearKeyed, - newEmptyNode(), - newEmptyNode(), - clearBodyKeyed, - ) - ) - - result.add( - quote do: - proc clearProvider*(_: typedesc[`typeIdent`]) = - clearProvider(`typeIdent`, DefaultBrokerContext) - - ) - - when defined(requestBrokerDebug): - echo result.repr - - return result - -macro RequestBroker*(body: untyped): untyped = - ## Default (async) mode. - generateRequestBroker(body, rbAsync) - -macro RequestBroker*(mode: untyped, body: untyped): untyped = - ## Explicit mode selector. - ## Example: - ## RequestBroker(sync): - ## type Foo = object - ## proc signature*(): Result[Foo, string] - generateRequestBroker(body, parseMode(mode)) diff --git a/waku/events/delivery_events.nim b/waku/events/delivery_events.nim index f27f02721..5730335e0 100644 --- a/waku/events/delivery_events.nim +++ b/waku/events/delivery_events.nim @@ -1,4 +1,5 @@ -import waku/waku_core/[message/message, message/digest], waku/common/broker/event_broker +import brokers/event_broker +import waku/waku_core/[message/message, message/digest] EventBroker: type OnFilterSubscribeEvent* = object diff --git a/waku/events/events.nim b/waku/events/events.nim index 46dd4fdd3..5a3c0c748 100644 --- a/waku/events/events.nim +++ b/waku/events/events.nim @@ -1,3 +1,3 @@ -import ./[message_events, delivery_events, health_events, peer_events] +import ./[message_events, delivery_events, health_events, peer_events, lifecycle_events] -export message_events, delivery_events, health_events, peer_events +export message_events, delivery_events, health_events, peer_events, lifecycle_events diff --git a/waku/events/health_events.nim b/waku/events/health_events.nim index 1e6decedb..95912941e 100644 --- a/waku/events/health_events.nim +++ b/waku/events/health_events.nim @@ -1,4 +1,4 @@ -import waku/common/broker/event_broker +import brokers/event_broker import waku/api/types import waku/node/health_monitor/[protocol_health, topic_health] diff --git a/waku/events/message_events.nim b/waku/events/message_events.nim index 677a4a433..b45f91249 100644 --- a/waku/events/message_events.nim +++ b/waku/events/message_events.nim @@ -1,5 +1,5 @@ -import waku/[api/types, waku_core/message, waku_core/topics, common/broker/event_broker] - +import brokers/event_broker +import waku/[api/types, waku_core/message, waku_core/topics] export types EventBroker: diff --git a/waku/events/peer_events.nim b/waku/events/peer_events.nim index dd02841f7..7eed309b3 100644 --- a/waku/events/peer_events.nim +++ b/waku/events/peer_events.nim @@ -1,4 +1,4 @@ -import waku/common/broker/event_broker +import brokers/event_broker import libp2p/switch type WakuPeerEventKind* {.pure.} = enum diff --git a/waku/factory/builder.nim b/waku/factory/builder.nim index 87b0db492..4212cb92d 100644 --- a/waku/factory/builder.nim +++ b/waku/factory/builder.nim @@ -8,15 +8,16 @@ import libp2p/builders, libp2p/nameresolving/nameresolver, libp2p/transports/wstransport, - libp2p/protocols/connectivity/relay/relay + libp2p/protocols/connectivity/relay/relay, + brokers/broker_context + import ../waku_enr, ../discovery/waku_discv5, ../waku_node, ../node/peer_manager, ../common/rate_limit/setting, - ../common/utils/parse_size_units, - ../common/broker/broker_context + ../common/utils/parse_size_units type WakuNodeBuilder* = object # General diff --git a/waku/factory/conf_builder/waku_conf_builder.nim b/waku/factory/conf_builder/waku_conf_builder.nim index b13910ea3..ceecf7f00 100644 --- a/waku/factory/conf_builder/waku_conf_builder.nim +++ b/waku/factory/conf_builder/waku_conf_builder.nim @@ -17,6 +17,7 @@ import waku_core/message/default_values, waku_core/topics/pubsub_topic, waku_enr/capabilities, + persistency/persistency, ], tools/confutils/entry_nodes @@ -165,6 +166,8 @@ type WakuConfBuilder* = object circuitRelayClient: Option[bool] p2pReliability: Option[bool] + localStoragePath: Option[string] + proc init*(T: type WakuConfBuilder): WakuConfBuilder = WakuConfBuilder( dnsDiscoveryConf: DnsDiscoveryConfBuilder.init(), @@ -301,6 +304,9 @@ proc withRelayShardedPeerManagement*( proc withP2pReliability*(b: var WakuConfBuilder, p2pReliability: bool) = b.p2pReliability = some(p2pReliability) +proc withLocalStoragePath*(b: var WakuConfBuilder, localStoragePath: string) = + b.localStoragePath = some(localStoragePath) + proc withExtMultiAddrs*(builder: var WakuConfBuilder, extMultiAddrs: seq[string]) = builder.extMultiAddrs = concat(builder.extMultiAddrs, extMultiAddrs) @@ -754,6 +760,7 @@ proc build*( relayShardedPeerManagement: relayShardedPeerManagement, p2pReliability: builder.p2pReliability.get(DefaultP2pReliability), wakuFlags: wakuFlags, + localStoragePath: builder.localStoragePath.get(DefaultStoragePath), ) ?wakuConf.validate() diff --git a/waku/factory/networks_config.nim b/waku/factory/networks_config.nim index 731b5fd84..1fb065a4b 100644 --- a/waku/factory/networks_config.nim +++ b/waku/factory/networks_config.nim @@ -93,6 +93,35 @@ proc LogosDevConf*(T: type NetworkConf): NetworkConf = ], ) +# cluster-id=2 (Logos Test Network) +# Cluster configuration for the Logos Test Network. +proc LogosTestConf*(T: type NetworkConf): NetworkConf = + const ZeroChainId = 0'u256 + return NetworkConf( + maxMessageSize: "150KiB", + clusterId: 2, + rlnRelay: false, + rlnRelayEthContractAddress: "", + rlnRelayDynamic: false, + rlnRelayChainId: ZeroChainId, + rlnEpochSizeSec: 0, + rlnRelayUserMessageLimit: 0, + shardingConf: ShardingConf(kind: AutoSharding, numShardsInCluster: 8), + enableKadDiscovery: true, + mix: true, + p2pReliability: true, + discv5Discovery: true, + discv5BootstrapNodes: @[], + entryNodes: @[ + "/dns4/node-01.do-ams3.logos.test.status.im/tcp/30303/p2p/16Uiu2HAmQ9X2xDfPG3uL77V9piYDhjq14JhKCtcmNYsTMKNqrKCj", + "/dns4/node-02.do-ams3.logos.test.status.im/tcp/30303/p2p/16Uiu2HAmB8NYprrfQrgWVzsJtYWkfjsXbmJEGNMG6othXsQ53BwG", + "/dns4/node-01.gc-us-central1-a.logos.test.status.im/tcp/30303/p2p/16Uiu2HAmF8WtwGPmeGHgYAX2277jHgy5cW9F7zsB8EqUjBZQAZQ3", + "/dns4/node-02.gc-us-central1-a.logos.test.status.im/tcp/30303/p2p/16Uiu2HAmUuXhUW9bdJpzN1kfDziFiUZo4bszTk66cvr7uuyCHXR7", + "/dns4/node-01.ac-cn-hongkong-c.logos.test.status.im/tcp/30303/p2p/16Uiu2HAmL3oU95jh1BZHozn3uNhx8HEneirgr8M1jEAapzXGDqRF", + "/dns4/node-02.ac-cn-hongkong-c.logos.test.status.im/tcp/30303/p2p/16Uiu2HAm28CoBZjpyxsanC8tQpbvZ7bZJnVYuB1EgFzb571qpWsV", + ], + ) + proc validateShards*( shardingConf: ShardingConf, shards: seq[uint16] ): Result[void, string] = diff --git a/waku/factory/waku.nim b/waku/factory/waku.nim index 395841130..6a5567f8c 100644 --- a/waku/factory/waku.nim +++ b/waku/factory/waku.nim @@ -18,6 +18,7 @@ import presto, metrics, metrics/chronos_httpserver, + brokers/broker_context, waku/[ waku_core, waku_node, @@ -30,7 +31,6 @@ import waku_enr/multiaddr, api/types, common/logging, - common/broker/broker_context, node/peer_manager, node/health_monitor, node/waku_metrics, @@ -46,6 +46,7 @@ import factory/node_factory, factory/internal_config, factory/app_callbacks, + persistency/persistency, ], ./waku_conf, ./waku_state_info @@ -393,6 +394,12 @@ proc startWaku*(waku: ptr Waku): Future[Result[void, string]] {.async: (raises: else: waku[].dynamicBootstrapNodes = dynamicBootstrapNodesRes.get() + ## Initialize persistency singleton instance - we don't need the instance itself here, + ## but this ensures it's initialized before any store job starts. + discard Persistency.instance(conf.localStoragePath).valueOr: + error "Failed to initialize persistency instance", error = $error + return err("Failed to initialize persistency instance: " & $error) + (await startNode(waku.node, waku.conf, waku.dynamicBootstrapNodes)).isOkOr: return err("error while calling startNode: " & $error) @@ -523,6 +530,8 @@ proc stop*(waku: Waku): Future[Result[void, string]] {.async: (raises: []).} = try: waku.healthMonitor.setOverallHealth(HealthStatus.SHUTTING_DOWN) + Persistency.reset() + if not waku.metricsServer.isNil(): await waku.metricsServer.stop() diff --git a/waku/factory/waku_conf.nim b/waku/factory/waku_conf.nim index 4934faccc..9edc12a44 100644 --- a/waku/factory/waku_conf.nim +++ b/waku/factory/waku_conf.nim @@ -152,6 +152,8 @@ type WakuConf* {.requiresInit.} = ref object p2pReliability*: bool + localStoragePath*: string + proc logConf*(conf: WakuConf) = info "Configuration: Enabled protocols", relay = conf.relay, diff --git a/waku/factory/waku_state_info.nim b/waku/factory/waku_state_info.nim index 397b90d6d..5796e04f5 100644 --- a/waku/factory/waku_state_info.nim +++ b/waku/factory/waku_state_info.nim @@ -5,7 +5,7 @@ ## accessible through the debug API. import std/[tables, sequtils, strutils] -import metrics, eth/p2p/discoveryv5/enr, libp2p/peerid +import metrics, eth/p2p/discoveryv5/enr, libp2p/peerid, stew/byteutils import waku/[waku_node, net/bound_ports] type @@ -16,6 +16,7 @@ type MyENR MyPeerId MyBoundPorts + MyMixPubKey WakuStateInfo* {.requiresInit.} = object node: WakuNode @@ -46,6 +47,11 @@ proc getNodeInfoItem*(self: WakuStateInfo, infoItemId: NodeInfoId): string = return $PeerId(self.node.peerId()) of NodeInfoId.MyBoundPorts: return $self.node.ports + of NodeInfoId.MyMixPubKey: + ## Empty when the mix protocol is not mounted on this node. + if self.node.wakuMix.isNil(): + return "" + return self.node.wakuMix.pubKey.to0xHex() else: return "unknown info item id" diff --git a/waku/node/delivery_service/recv_service/recv_service.nim b/waku/node/delivery_service/recv_service/recv_service.nim index 64f4d683d..899f80f71 100644 --- a/waku/node/delivery_service/recv_service/recv_service.nim +++ b/waku/node/delivery_service/recv_service/recv_service.nim @@ -5,6 +5,7 @@ import std/[tables, sequtils, options, sets] import chronos, chronicles, libp2p/utility import ../[subscription_manager] +import brokers/broker_context import waku/[ waku_core, @@ -14,7 +15,6 @@ import waku_core/topics, events/message_events, waku_node, - common/broker/broker_context, ] const StoreCheckPeriod = chronos.minutes(5) ## How often to perform store queries @@ -70,20 +70,30 @@ proc getMissingMsgsFromStore( ) ) -proc processIncomingMessageOfInterest( +proc processIncomingMessage( self: RecvService, pubsubTopic: string, message: WakuMessage ): bool = - ## Deduplicate (by hash), store (saves in recently-seen messages) and emit - ## the MAPI MessageReceivedEvent for every unique incoming message. - ## Returns true if the message was new and the MessageReceivedEvent was properly emitted. + ## Return false if the incoming message is from a non-subscribed topic, + ## or if the message is a duplicate (recently-seen). Otherwise, save it as + ## recently-seen, emit a MessageReceivedEvent, and return true. + + if not self.subscriptionManager.isSubscribed(pubsubTopic, message.contentTopic): + trace "skipping message as I am not subscribed", + shard = pubsubTopic, contentTopic = message.contentTopic + return false let msgHash = computeMessageHash(pubsubTopic, message) - if not self.recentReceivedMsgs.anyIt(it.msgHash == msgHash): - let rxMsg = RecvMessage(msgHash: msgHash, rxTime: message.timestamp) - self.recentReceivedMsgs.add(rxMsg) - MessageReceivedEvent.emit(self.brokerCtx, msgHash.to0xHex(), message) - return true - return false + if self.recentReceivedMsgs.anyIt(it.msgHash == msgHash): + trace "skipping duplicate message", + shard = pubsubTopic, + contentTopic = message.contentTopic, + msg_hash = msgHash.to0xHex() + return false + + let rxMsg = RecvMessage(msgHash: msgHash, rxTime: message.timestamp) + self.recentReceivedMsgs.add(rxMsg) + MessageReceivedEvent.emit(self.brokerCtx, msgHash.to0xHex(), message) + return true proc checkStore*(self: RecvService) {.async.} = ## Checks the store for messages that were not received directly and @@ -121,7 +131,7 @@ proc checkStore*(self: RecvService) {.async.} = let missingMsgsRet = await self.getMissingMsgsFromStore(missedHashes) if missingMsgsRet.isOk(): for msgTuple in missingMsgsRet.get(): - if self.processIncomingMessageOfInterest(msgTuple.pubsubTopic, msgTuple.msg): + if self.processIncomingMessage(msgTuple.pubsubTopic, msgTuple.msg): info "recv service store-recovered message", msg_hash = shortLog(msgTuple.hash), pubsubTopic = msgTuple.pubsubTopic else: @@ -163,20 +173,13 @@ proc startRecvService*(self: RecvService) = self.seenMsgListener = MessageSeenEvent.listen( self.brokerCtx, proc(event: MessageSeenEvent) {.async: (raises: []).} = - if not self.subscriptionManager.isSubscribed( - event.topic, event.message.contentTopic - ): - trace "skipping message as I am not subscribed", - shard = event.topic, contenttopic = event.message.contentTopic - return - - discard self.processIncomingMessageOfInterest(event.topic, event.message), + discard self.processIncomingMessage(event.topic, event.message), ).valueOr: error "Failed to set MessageSeenEvent listener", error = error quit(QuitFailure) proc stopRecvService*(self: RecvService) {.async.} = - MessageSeenEvent.dropListener(self.brokerCtx, self.seenMsgListener) + await MessageSeenEvent.dropListener(self.brokerCtx, self.seenMsgListener) if not self.msgCheckerHandler.isNil(): await self.msgCheckerHandler.cancelAndWait() self.msgCheckerHandler = nil diff --git a/waku/node/delivery_service/send_service/delivery_task.nim b/waku/node/delivery_service/send_service/delivery_task.nim index 0ff151f6e..aa1dc17d7 100644 --- a/waku/node/delivery_service/send_service/delivery_task.nim +++ b/waku/node/delivery_service/send_service/delivery_task.nim @@ -1,6 +1,6 @@ import std/[options, times], chronos +import brokers/broker_context import waku/waku_core, waku/api/types, waku/requests/node_requests -import waku/common/broker/broker_context type DeliveryState* {.pure.} = enum Entry diff --git a/waku/node/delivery_service/send_service/lightpush_processor.nim b/waku/node/delivery_service/send_service/lightpush_processor.nim index 40a754757..7a9f65c71 100644 --- a/waku/node/delivery_service/send_service/lightpush_processor.nim +++ b/waku/node/delivery_service/send_service/lightpush_processor.nim @@ -1,11 +1,7 @@ import chronicles, chronos, results import std/options - -import - waku/node/peer_manager, - waku/waku_core, - waku/waku_lightpush/[common, client, rpc], - waku/common/broker/broker_context +import brokers/broker_context +import waku/node/peer_manager, waku/waku_core, waku/waku_lightpush/[common, client, rpc] import ./[delivery_task, send_processor] diff --git a/waku/node/delivery_service/send_service/relay_processor.nim b/waku/node/delivery_service/send_service/relay_processor.nim index 833d15845..e06b664fb 100644 --- a/waku/node/delivery_service/send_service/relay_processor.nim +++ b/waku/node/delivery_service/send_service/relay_processor.nim @@ -1,8 +1,8 @@ import std/options import chronos, chronicles +import brokers/broker_context import waku/[waku_core], waku/waku_lightpush/[common, rpc] import waku/requests/health_requests -import waku/common/broker/broker_context import waku/api/types import ./[delivery_task, send_processor] diff --git a/waku/node/delivery_service/send_service/send_processor.nim b/waku/node/delivery_service/send_service/send_processor.nim index 0108eacd0..3782b9d4e 100644 --- a/waku/node/delivery_service/send_service/send_processor.nim +++ b/waku/node/delivery_service/send_service/send_processor.nim @@ -1,6 +1,6 @@ import chronos +import brokers/broker_context import ./delivery_task -import waku/common/broker/broker_context {.push raises: [].} diff --git a/waku/node/delivery_service/send_service/send_service.nim b/waku/node/delivery_service/send_service/send_service.nim index a3c44bc0c..88ec802cf 100644 --- a/waku/node/delivery_service/send_service/send_service.nim +++ b/waku/node/delivery_service/send_service/send_service.nim @@ -3,6 +3,7 @@ import std/[sequtils, tables, options] import chronos, chronicles, libp2p/utility +import brokers/broker_context import ./[send_processor, relay_processor, lightpush_processor, delivery_task], ../[subscription_manager], @@ -17,7 +18,6 @@ import waku_lightpush/client, waku_lightpush/callbacks, events/message_events, - common/broker/broker_context, ] logScope: @@ -26,7 +26,7 @@ logScope: # This useful util is missing from sequtils, this extends applyIt with predicate... template applyItIf*(varSeq, pred, op: untyped) = for i in low(varSeq) .. high(varSeq): - let it {.inject.} = varSeq[i] + var it {.inject.} = varSeq[i] if pred: op varSeq[i] = it @@ -225,9 +225,12 @@ proc evaluateAndCleanUp(self: SendService) = it.state != DeliveryState.FailedToDeliver ) - # remove propagated ephemeral messages as no store check is possible + # remove propagated messages when no store confirmation will follow self.taskCache.keepItIf( - not (it.isEphemeral() and it.state == DeliveryState.SuccessfullyPropagated) + not ( + it.state == DeliveryState.SuccessfullyPropagated and + (it.isEphemeral() or not self.checkStoreForMessages) + ) ) proc trySendMessages(self: SendService) {.async.} = diff --git a/waku/node/delivery_service/subscription_manager.nim b/waku/node/delivery_service/subscription_manager.nim index f00d9024c..393a61eae 100644 --- a/waku/node/delivery_service/subscription_manager.nim +++ b/waku/node/delivery_service/subscription_manager.nim @@ -1,5 +1,7 @@ import std/[sequtils, sets, tables, options, strutils], chronos, chronicles, results import libp2p/[peerid, peerinfo] +import brokers/broker_context + import waku/[ waku_core, @@ -10,7 +12,6 @@ import waku_filter_v2/common as filter_common, waku_filter_v2/client as filter_client, waku_filter_v2/protocol as filter_protocol, - common/broker/broker_context, events/health_events, events/peer_events, requests/health_requests, @@ -61,7 +62,16 @@ type SubscriptionManager* = ref object of RootObj iterator subscribedTopics*( self: SubscriptionManager ): (PubsubTopic, HashSet[ContentTopic]) = + ## Iterate over all subscribed content topics, batched per shard. + ## This is guaranteed to return a non-empty `topics` (content topics) list on iteration. + for pubsub, topics in self.contentTopicSubs.pairs: + # We are iterating over subscribed content topics; if we are subscribed to + # a shard but have no subscription (interest) for any content topic in that + # shard, then avoid triggering an iteration that doesn't advance the intent + # to iterate over content topic subscriptions. + if topics.len == 0: + continue yield (pubsub, topics) proc edgeFilterPeerCount*(sm: SubscriptionManager, shard: PubsubTopic): int = @@ -521,7 +531,7 @@ proc stopEdgeFilterLoops(self: SubscriptionManager) {.async: (raises: []).} = if not fut.finished: await fut.cancelAndWait() - WakuPeerEvent.dropListener(self.node.brokerCtx, self.peerEventListener) + await WakuPeerEvent.dropListener(self.node.brokerCtx, self.peerEventListener) # --------------------------------------------------------------------------- # SubscriptionManager Lifecycle (calls Edge behavior above) diff --git a/waku/node/health_monitor/node_health_monitor.nim b/waku/node/health_monitor/node_health_monitor.nim index c92dc1aaf..c652f7cea 100644 --- a/waku/node/health_monitor/node_health_monitor.nim +++ b/waku/node/health_monitor/node_health_monitor.nim @@ -725,8 +725,10 @@ proc stopHealthMonitor*(hm: NodeHealthMonitor) {.async.} = if not isNil(hm.eventLoopMonitorFut): await hm.eventLoopMonitorFut.cancelAndWait() - WakuPeerEvent.dropListener(hm.node.brokerCtx, hm.peerEventListener) - EventShardTopicHealthChange.dropListener(hm.node.brokerCtx, hm.shardHealthListener) + await WakuPeerEvent.dropListener(hm.node.brokerCtx, hm.peerEventListener) + await EventShardTopicHealthChange.dropListener( + hm.node.brokerCtx, hm.shardHealthListener + ) if not isNil(hm.node.wakuRelay) and not isNil(hm.relayObserver): hm.node.wakuRelay.removeObserver(hm.relayObserver) diff --git a/waku/node/kernel_api/relay.nim b/waku/node/kernel_api/relay.nim index fe46f5bd2..f1b80cf19 100644 --- a/waku/node/kernel_api/relay.nim +++ b/waku/node/kernel_api/relay.nim @@ -16,7 +16,8 @@ import libp2p/builders, libp2p/transports/tcptransport, libp2p/transports/wstransport, - libp2p/utility + libp2p/utility, + brokers/broker_context import waku/[ @@ -29,7 +30,6 @@ import waku_rln_relay, node/waku_node, node/peer_manager, - common/broker/broker_context, events/message_events, ] diff --git a/waku/node/peer_manager/peer_manager.nim b/waku/node/peer_manager/peer_manager.nim index e3eb8d75b..6602c049b 100644 --- a/waku/node/peer_manager/peer_manager.nim +++ b/waku/node/peer_manager/peer_manager.nim @@ -8,6 +8,9 @@ import chronicles, metrics, libp2p/[multistream, muxers/muxer, nameresolving/nameresolver, peerstore], + brokers/broker_context + +import waku/[ waku_core, waku_relay, @@ -21,7 +24,6 @@ import common/enr, common/callbacks, common/utils/parse_size_units, - common/broker/broker_context, node/health_monitor/online_monitor, ], ./peer_store/peer_storage, @@ -107,6 +109,7 @@ type PeerManager* = ref object of RootObj online: bool ## state managed by online_monitor module getShards: GetShards maxConnections: int + activeStoreRequests*: Table[PeerId, int] #~~~~~~~~~~~~~~~~~~~# # Helper Functions # @@ -169,6 +172,23 @@ proc addPeer*( proc getPeer*(pm: PeerManager, peerId: PeerId): RemotePeerInfo = return pm.switch.peerStore.getPeer(peerId) +proc addActiveStoreRequest*(pm: PeerManager, peerId: PeerId) {.gcsafe.} = + pm.activeStoreRequests.mgetOrPut(peerId, 0).inc() + +proc removeActiveStoreRequest*(pm: PeerManager, peerId: PeerId) {.gcsafe.} = + let count = pm.activeStoreRequests.getOrDefault(peerId, 0) + if count == 0: + return + + let newCount = count - 1 + if newCount <= 0: + pm.activeStoreRequests.del(peerId) + else: + pm.activeStoreRequests[peerId] = newCount + +proc hasActiveStoreRequest*(pm: PeerManager, peerId: PeerId): bool {.gcsafe.} = + pm.activeStoreRequests.contains(peerId) + proc loadFromStorage(pm: PeerManager) {.gcsafe.} = ## Load peers from storage, if available @@ -519,6 +539,15 @@ proc connectedPeers*( return (inPeers, outPeers) +proc evictPeer*(pm: PeerManager, peerId: PeerId) {.async.} = + ## Policy-based eviction (relay-peer limit, IP colocation, pruning). + ## Skips the disconnect when the peer has an in-flight store request to + ## avoid aborting active store requests. + if pm.hasActiveStoreRequest(peerId): + trace "skipping peer eviction: active store request", peerId = peerId + return + await pm.switch.disconnect(peerId) + proc capablePeers*(pm: PeerManager, protocol: string): (seq[PeerId], seq[PeerId]) = ## Returns the PeerIds of peers with an active socket connection. ## If a protocol is specified, it returns peers that have identified @@ -770,11 +799,11 @@ proc onPeerEvent(pm: PeerManager, peerId: PeerId, event: PeerEvent) {.async.} = let inRelayPeers = pm.connectedPeers(WakuRelayCodec)[0] if inRelayPeers.len > pm.inRelayPeersTarget and peerStore.hasPeer(peerId, WakuRelayCodec): - info "disconnecting relay peer because reached max num in-relay peers", + info "relay peer limit reached, evicting peer", peerId = peerId, inRelayPeers = inRelayPeers.len, inRelayPeersTarget = pm.inRelayPeersTarget - await pm.switch.disconnect(peerId) + await pm.evictPeer(peerId) ## Apply max ip colocation limit if (let ip = pm.getPeerIp(peerId); ip.isSome()): @@ -787,7 +816,7 @@ proc onPeerEvent(pm: PeerManager, peerId: PeerId, event: PeerEvent) {.async.} = if pm.colocationLimit != 0 and peersBehindIp.len > pm.colocationLimit: for peerId in peersBehindIp[0 ..< (peersBehindIp.len - pm.colocationLimit)]: info "Pruning connection due to ip colocation", peerId = peerId, ip = ip - asyncSpawn(pm.switch.disconnect(peerId)) + asyncSpawn(pm.evictPeer(peerId)) peerStore.delete(peerId) WakuPeerEvent.emit(pm.brokerCtx, peerId, WakuPeerEventKind.EventConnected) @@ -1100,7 +1129,7 @@ proc pruneInRelayConns(pm: PeerManager, amount: int) {.async.} = for p in inRelayPeers[0 ..< connsToPrune]: trace "Pruning Peer", Peer = $p - asyncSpawn(pm.switch.disconnect(p)) + asyncSpawn(pm.evictPeer(p)) proc addExtPeerEventHandler*( pm: PeerManager, eventHandler: PeerEventHandler, eventKind: PeerEventKind @@ -1214,6 +1243,7 @@ proc new*( pm.serviceSlots = initTable[string, RemotePeerInfo]() pm.ipTable = initTable[string, seq[PeerId]]() + pm.activeStoreRequests = initTable[PeerId, int]() if not storage.isNil(): trace "found persistent peer storage" diff --git a/waku/node/waku_node.nim b/waku/node/waku_node.nim index 7cd334b53..26a2b5a57 100644 --- a/waku/node/waku_node.nim +++ b/waku/node/waku_node.nim @@ -24,7 +24,9 @@ import libp2p/utility, libp2p/utils/offsettedseq, libp2p/protocols/mix, - libp2p/protocols/mix/mix_protocol + libp2p/protocols/mix/mix_protocol, + brokers/broker_context, + brokers/request_broker import waku/[ @@ -53,8 +55,6 @@ import common/rate_limit/setting, common/callbacks, common/nimchronos, - common/broker/broker_context, - common/broker/request_broker, waku_mix, requests/node_requests, requests/health_requests, diff --git a/waku/persistency/backend_comm.nim b/waku/persistency/backend_comm.nim new file mode 100644 index 000000000..dd7e71297 --- /dev/null +++ b/waku/persistency/backend_comm.nim @@ -0,0 +1,161 @@ +## Cross-thread broker declarations for the persistency library. +## +## One EventBroker (writes, fire-and-forget) and five RequestBrokers (reads +## + acked delete). All in multi-thread (mt) mode: the listener / provider runs on the +## job's storage thread; callers on any thread reach it via the shared +## BrokerContext owned by the Job. +## +## ## Error type, important +## +## nim-brokers' RequestBroker macro hard-codes the response shape as +## `Future[Result[ResponseType, string]]` — the error channel is `string`, +## not our `PersistencyError`. We honour the broker contract here and lift +## back to `PersistencyError` at the public facade (persistency.nim). The +## convention for the broker-level string is `": "` so the +## facade can reconstruct the `PersistencyErrorKind`. +## +## ## Response shapes +## +## The five Kv* types are *response* objects (the value the provider +## returns). Per-request inputs sit on the `signature` proc parameters. + +{.push raises: [].} + +import std/[options, strutils] +import chronos, results +import brokers/[event_broker, request_broker, broker_context] +import brokers/internal/mt_codec +import ./types + +export broker_context + +# ── mt codec overloads for non-POD library types ──────────────────────── +# +# brokers 2.0.0's mtMarshalValue / mtUnmarshalValue handle scalars, enums, +# strings, seqs, arrays, and plain object/tuple recursion -- but they do +# not see through `distinct seq[byte]`, nor do they know how to dispatch +# a variant (case) object. We provide explicit overloads for the types +# that appear in our broker payloads. + +proc mtMarshalValue*( + buf: ptr UncheckedArray[byte], cap: int, value: Key, pos: var int +): bool {.gcsafe.} = + ## Encode a Key as the raw seq[byte] it wraps. + mtMarshalValue(buf, cap, bytes(value), pos) + +proc mtUnmarshalValue*( + buf: ptr UncheckedArray[byte], len: int, value: var Key, pos: var int +): bool {.gcsafe.} = + var s: seq[byte] + if not mtUnmarshalValue(buf, len, s, pos): + return false + value = Key(s) + return true + +proc mtMarshalValue*( + buf: ptr UncheckedArray[byte], cap: int, value: TxOp, pos: var int +): bool {.gcsafe.} = + ## TxOp is a case object: write the discriminator, then only the + ## fields that belong to the active branch. + if not mtMarshalValue(buf, cap, value.category, pos): + return false + if not mtMarshalValue(buf, cap, value.key, pos): + return false + let kind = uint8(ord(value.kind)) + if not mtMarshalValue(buf, cap, kind, pos): + return false + case value.kind + of txPut: + if not mtMarshalValue(buf, cap, value.payload, pos): + return false + of txDelete: + discard + return true + +proc mtUnmarshalValue*( + buf: ptr UncheckedArray[byte], len: int, value: var TxOp, pos: var int +): bool {.gcsafe.} = + var + category: string + key: Key + kindByte: uint8 + if not mtUnmarshalValue(buf, len, category, pos): + return false + if not mtUnmarshalValue(buf, len, key, pos): + return false + if not mtUnmarshalValue(buf, len, kindByte, pos): + return false + case TxOpKind(kindByte) + of txPut: + var payload: seq[byte] + if not mtUnmarshalValue(buf, len, payload, pos): + return false + value = TxOp(category: category, key: key, kind: txPut, payload: payload) + of txDelete: + value = TxOp(category: category, key: key, kind: txDelete) + return true + +EventBroker(mt): + type PersistEvent* = object + ops*: seq[TxOp] + +RequestBroker(mt): + type KvGet* = object + value*: Option[seq[byte]] + + proc signature*(category: string, key: Key): Future[Result[KvGet, string]] {.async.} + +RequestBroker(mt): + type KvExists* = object + value*: bool + + proc signature*( + category: string, key: Key + ): Future[Result[KvExists, string]] {.async.} + +RequestBroker(mt): + type KvScan* = object + rows*: seq[KvRow] + + proc signature*( + category: string, range: KeyRange, reverse: bool + ): Future[Result[KvScan, string]] {.async.} + +RequestBroker(mt): + type KvCount* = object + n*: int + + proc signature*( + category: string, range: KeyRange + ): Future[Result[KvCount, string]] {.async.} + +RequestBroker(mt): + type KvDelete* = object + existed*: bool + + proc signature*( + category: string, key: Key + ): Future[Result[KvDelete, string]] {.async.} + +# ── string<->PersistencyError boundary helpers ────────────────────────── + +const ErrSep = ": " + +proc encodeErr*(e: PersistencyError): string = + ## Encode a PersistencyError into the broker's string channel. The facade + ## decodes via `decodeErr`. + $e.kind & ErrSep & e.msg + +proc decodeErr*(s: string): PersistencyError = + ## Inverse of encodeErr. Falls back to peBackend if the prefix is missing. + let idx = s.find(ErrSep) + if idx < 0: + return persistencyErr(peBackend, s) + let head = s[0 ..< idx] + let tail = s[idx + ErrSep.len .. ^1] + for k in PersistencyErrorKind: + if $k == head: + return persistencyErr(k, tail) + persistencyErr(peBackend, s) + +{.pop.} diff --git a/waku/persistency/backend_sqlite.nim b/waku/persistency/backend_sqlite.nim new file mode 100644 index 000000000..6851febc1 --- /dev/null +++ b/waku/persistency/backend_sqlite.nim @@ -0,0 +1,247 @@ +## Synchronous SQLite backend for the persistency library. +## +## Plain procs against a SqliteDatabase connection. Phase 3 wraps these in +## per-job storage threads driven by brokers; phase 2 verifies the SQL +## itself against an in-memory database. + +import std/options +import results, sqlite3_abi +import ../common/databases/[common, db_sqlite] +import ./[types, schema] + +type + KvBackend* = ref object + db*: SqliteDatabase + putStmt: SqliteStmt[(seq[byte], seq[byte], seq[byte]), void] + deleteStmt: SqliteStmt[(seq[byte], seq[byte]), void] + + RowHandler = proc(s: ptr sqlite3_stmt) {.gcsafe, raises: [].} + +proc toErr(msg: string): PersistencyError {.inline.} = + persistencyErr(peBackend, msg) + +proc catBytes(category: string): seq[byte] = + var buf = newSeq[byte](category.len) + for i, c in category: + buf[i] = byte(c) + return buf + +proc keyBytes(key: Key): seq[byte] {.inline.} = + bytes(key) + +proc readBlob(s: ptr sqlite3_stmt, col: cint): seq[byte] = + let n = sqlite3_column_bytes(s, col) + var buf = newSeq[byte](n) + if n > 0: + let src = cast[ptr UncheckedArray[byte]](sqlite3_column_blob(s, col)) + for i in 0 ..< n: + buf[i] = src[i] + return buf + +proc bindBlob(s: ptr sqlite3_stmt, n: cint, val: seq[byte]): cint = + if val.len > 0: + sqlite3_bind_blob(s, n, unsafeAddr val[0], val.len.cint, SQLITE_TRANSIENT) + else: + sqlite3_bind_blob(s, n, nil, 0.cint, SQLITE_TRANSIENT) + +proc runRead( + db: SqliteDatabase, sql: string, params: openArray[seq[byte]], onRow: RowHandler +): Result[void, PersistencyError] = + var s: ptr sqlite3_stmt + let rc = sqlite3_prepare_v2(db.env, sql.cstring, sql.len.cint, addr s, nil) + if rc != SQLITE_OK: + return err(toErr("prepare: " & $sqlite3_errstr(rc))) + defer: + discard sqlite3_finalize(s) + + for i, p in params: + let bc = bindBlob(s, cint(i + 1), p) + if bc != SQLITE_OK: + return err(toErr("bind: " & $sqlite3_errstr(bc))) + + while true: + let v = sqlite3_step(s) + case v + of SQLITE_ROW: + onRow(s) + of SQLITE_DONE: + break + else: + return err(toErr("step: " & $sqlite3_errstr(v))) + return ok() + +proc prepareStatements(b: KvBackend): DatabaseResult[void] = + b.putStmt = ?b.db.prepareStmt( + "INSERT OR REPLACE INTO kv(category, key, payload) VALUES (?, ?, ?);", + (seq[byte], seq[byte], seq[byte]), + void, + ) + b.deleteStmt = ?b.db.prepareStmt( + "DELETE FROM kv WHERE category = ? AND key = ?;", (seq[byte], seq[byte]), void + ) + return ok() + +proc openBackend*(path: string): Result[KvBackend, PersistencyError] = + let dbRes = SqliteDatabase.new(path) + if dbRes.isErr: + return err(toErr("open " & path & " failed: " & dbRes.error)) + let db = dbRes.get() + + applyPragmas(db).isOkOr: + return err(toErr(error)) + ensureSchema(db).isOkOr: + return err(toErr(error)) + + let b = KvBackend(db: db) + prepareStatements(b).isOkOr: + return err(toErr(error)) + return ok(b) + +proc openBackendInMemory*(): Result[KvBackend, PersistencyError] = + ## Convenience for tests. + let dbRes = SqliteDatabase.new(":memory:") + if dbRes.isErr: + return err(toErr("open :memory: failed: " & dbRes.error)) + let db = dbRes.get() + + applyPragmas(db).isOkOr: + return err(toErr(error)) + ensureSchema(db).isOkOr: + return err(toErr(error)) + + let b = KvBackend(db: db) + prepareStatements(b).isOkOr: + return err(toErr(error)) + return ok(b) + +proc close*(b: KvBackend) = + if b.db != nil: + dispose(b.putStmt) + dispose(b.deleteStmt) + b.db.close() + b.db = nil + +proc applyOne(b: KvBackend, op: TxOp): Result[void, PersistencyError] = + case op.kind + of txPut: + let r = b.putStmt.exec((catBytes(op.category), keyBytes(op.key), op.payload)) + if r.isErr: + return err(toErr("put failed: " & r.error)) + of txDelete: + let r = b.deleteStmt.exec((catBytes(op.category), keyBytes(op.key))) + if r.isErr: + return err(toErr("delete failed: " & r.error)) + return ok() + +proc execSql(b: KvBackend, sql: string): Result[void, PersistencyError] = + let r = b.db.query(sql, NoopRowHandler) + if r.isErr: + return err(toErr(sql & ": " & r.error)) + return ok() + +proc applyOps*(b: KvBackend, ops: openArray[TxOp]): Result[void, PersistencyError] = + ## Single op = auto-commit. Multiple ops = BEGIN IMMEDIATE / COMMIT, with + ## ROLLBACK on first failure. This is the single source of truth for write + ## SQL — Phase 3's PersistEvent listener calls straight into here. + if ops.len == 0: + return ok() + if ops.len == 1: + return b.applyOne(ops[0]) + + ?b.execSql("BEGIN IMMEDIATE;") + for op in ops: + let r = b.applyOne(op) + if r.isErr: + discard b.execSql("ROLLBACK;") + return r + ?b.execSql("COMMIT;") + return ok() + +proc getOne*( + b: KvBackend, category: string, key: Key +): Result[Option[seq[byte]], PersistencyError] = + var found: Option[seq[byte]] = none(seq[byte]) + proc onRow(rs: ptr sqlite3_stmt) {.gcsafe, raises: [].} = + found = some(readBlob(rs, 0.cint)) + + ?b.db.runRead( + "SELECT payload FROM kv WHERE category = ? AND key = ? LIMIT 1;", + [catBytes(category), keyBytes(key)], + onRow, + ) + return ok(found) + +proc existsOne*( + b: KvBackend, category: string, key: Key +): Result[bool, PersistencyError] = + var present = false + proc onRow(rs: ptr sqlite3_stmt) {.gcsafe, raises: [].} = + present = true + + ?b.db.runRead( + "SELECT 1 FROM kv WHERE category = ? AND key = ? LIMIT 1;", + [catBytes(category), keyBytes(key)], + onRow, + ) + return ok(present) + +proc deleteOne*( + b: KvBackend, category: string, key: Key +): Result[bool, PersistencyError] = + ## Returns true if a row was actually removed. + let existed = ?b.existsOne(category, key) + if not existed: + return ok(false) + let r = b.deleteStmt.exec((catBytes(category), keyBytes(key))) + if r.isErr: + return err(toErr("delete: " & r.error)) + return ok(true) + +proc scanRange*( + b: KvBackend, category: string, range: KeyRange, reverse = false +): Result[seq[KvRow], PersistencyError] = + let openEnded = bytes(range.stop).len == 0 + let direction = if reverse: "DESC" else: "ASC" + let sql = + if openEnded: + "SELECT key, payload FROM kv WHERE category = ? AND key >= ? ORDER BY key " & + direction & ";" + else: + "SELECT key, payload FROM kv WHERE category = ? AND key >= ? AND key < ? ORDER BY key " & + direction & ";" + + var rows: seq[KvRow] = @[] + proc onRow(rs: ptr sqlite3_stmt) {.gcsafe, raises: [].} = + let k = readBlob(rs, 0.cint) + let p = readBlob(rs, 1.cint) + rows.add((rawKey(k), p)) + + if openEnded: + ?b.db.runRead(sql, [catBytes(category), keyBytes(range.start)], onRow) + else: + ?b.db.runRead( + sql, [catBytes(category), keyBytes(range.start), keyBytes(range.stop)], onRow + ) + return ok(rows) + +proc countRange*( + b: KvBackend, category: string, range: KeyRange +): Result[int, PersistencyError] = + let openEnded = bytes(range.stop).len == 0 + let sql = + if openEnded: + "SELECT COUNT(*) FROM kv WHERE category = ? AND key >= ?;" + else: + "SELECT COUNT(*) FROM kv WHERE category = ? AND key >= ? AND key < ?;" + + var n: int64 = 0 + proc onRow(rs: ptr sqlite3_stmt) {.gcsafe, raises: [].} = + n = sqlite3_column_int64(rs, 0.cint) + + if openEnded: + ?b.db.runRead(sql, [catBytes(category), keyBytes(range.start)], onRow) + else: + ?b.db.runRead( + sql, [catBytes(category), keyBytes(range.start), keyBytes(range.stop)], onRow + ) + return ok(int(n)) diff --git a/waku/persistency/backend_thread.nim b/waku/persistency/backend_thread.nim new file mode 100644 index 000000000..e32e5c209 --- /dev/null +++ b/waku/persistency/backend_thread.nim @@ -0,0 +1,271 @@ +## Internal per-job storage thread. +## +## Exposes two operations to ``persistency.nim``: +## * ``startStorageThread(ctx, dbPath)`` — spawn one worker, block until +## it signals ready (or error). Returns a ``JobRuntime``. +## * ``stopStorageThread(rt)`` — signal shutdown, join, free. +## +## The worker: +## 1. installs the supplied BrokerContext on its threadvar +## 2. opens the SQLite backend (creating the file + schema if absent) +## 3. registers the PersistEvent listener and the 5 RequestBroker +## providers under that context +## 4. runs the chronos event loop until shutdown is signalled +## 5. clears providers + listeners, closes the backend +## +## The arg struct lives in shared memory (``allocShared0``). The dbPath is +## carried as a shared cstring buffer rather than a Nim string to avoid +## refc ref-count traffic across threads. The arg is freed by +## ``stopStorageThread`` after ``joinThread`` returns. + +import std/[options, os] +import std/atomics # std/concurrency/atomics is the same module in Nim 2.2 +import chronos, chronicles, results +import brokers/[event_broker, request_broker, broker_context] +import ./[types, backend_comm, backend_sqlite] + +export broker_context, backend_comm + +logScope: + topics = "persistency thread" + +type + ReadyState {.pure.} = enum + Pending = 0 + Ready = 1 + Error = 2 + + StorageThreadArg = object + ctx: BrokerContext + dbPath: cstring ## allocShared0'd; freed in closeJob + dbPathLen: int ## bytes including the trailing NUL + shutdownFlag: Atomic[int] + readyFlag: Atomic[int] ## values from ReadyState + errBuf: array[256, char] ## last error message, NUL-terminated + + StorageThread = Thread[ptr StorageThreadArg] + +# ── arg helpers ───────────────────────────────────────────────────────── + +proc allocArg(ctx: BrokerContext, dbPath: string): ptr StorageThreadArg = + let arg = cast[ptr StorageThreadArg](allocShared0(sizeof(StorageThreadArg))) + arg.ctx = ctx + arg.dbPathLen = dbPath.len + 1 + arg.dbPath = cast[cstring](allocShared0(arg.dbPathLen)) + if dbPath.len > 0: + copyMem(arg.dbPath, unsafeAddr dbPath[0], dbPath.len) + return arg + +proc freeArg(a: ptr StorageThreadArg) = + if a.isNil(): + return + if a.dbPath != nil: + deallocShared(a.dbPath) + deallocShared(a) + +proc recordErr(a: ptr StorageThreadArg, msg: string) = + let n = min(msg.len, a.errBuf.len - 1) + for i in 0 ..< n: + a.errBuf[i] = msg[i] + a.errBuf[n] = '\0' + a.readyFlag.store(int(ReadyState.Error), moRelease) + +proc errMsg(a: ptr StorageThreadArg): string = + $cast[cstring](a.errBuf[0].addr) + +# ── provider closures ─────────────────────────────────────────────────── + +proc encode(e: PersistencyError): string = + encodeErr(e) + +template unwrapErr(r: untyped): string = + ## Disambiguates Result's `error` accessor from chronicles' `error` macro + ## by binding through an explicitly-typed local before stringifying. + block: + let pe: PersistencyError = r.error() + encode(pe) + +proc registerProviders(backend: KvBackend, ctx: BrokerContext): Result[void, string] = + ## Wires the 5 RequestBroker providers + the PersistEvent listener. + ## All closures capture `backend` by reference (it lives for the entire + ## thread lifetime). + + proc onGet(category: string, key: Key): Future[Result[KvGet, string]] {.async.} = + let r = backend.getOne(category, key) + if r.isErr: + return err(unwrapErr(r)) + return ok(KvGet(value: r.get())) + + proc onExists( + category: string, key: Key + ): Future[Result[KvExists, string]] {.async.} = + let r = backend.existsOne(category, key) + if r.isErr: + return err(unwrapErr(r)) + return ok(KvExists(value: r.get())) + + proc onScan( + category: string, range: KeyRange, reverse: bool + ): Future[Result[KvScan, string]] {.async.} = + let r = backend.scanRange(category, range, reverse) + if r.isErr: + return err(unwrapErr(r)) + return ok(KvScan(rows: r.get())) + + proc onCount( + category: string, range: KeyRange + ): Future[Result[KvCount, string]] {.async.} = + let r = backend.countRange(category, range) + if r.isErr: + return err(unwrapErr(r)) + return ok(KvCount(n: r.get())) + + proc onDelete( + category: string, key: Key + ): Future[Result[KvDelete, string]] {.async.} = + let r = backend.deleteOne(category, key) + if r.isErr: + return err(unwrapErr(r)) + return ok(KvDelete(existed: r.get())) + + # PersistEvent listener — fire-and-forget; we log on backend failure + # because the caller has no return channel. + proc onPersist(ev: PersistEvent): Future[void] {.async: (raises: []).} = + let r = backend.applyOps(ev.ops) + if r.isErr: + let pe: PersistencyError = r.error() + error "PersistEvent applyOps failed", + error = pe.msg, kind = $pe.kind, opCount = ev.ops.len + + KvGet.setProvider(ctx, onGet).isOkOr: + return err("KvGet.setProvider: " & error) + + let existsRes = KvExists.setProvider(ctx, onExists) + if existsRes.isErr: + return err("KvExists.setProvider: " & existsRes.error()) + + let scanRes = KvScan.setProvider(ctx, onScan) + if scanRes.isErr: + return err("KvScan.setProvider: " & scanRes.error()) + + let countRes = KvCount.setProvider(ctx, onCount) + if countRes.isErr: + return err("KvCount.setProvider: " & countRes.error()) + + let delRes = KvDelete.setProvider(ctx, onDelete) + if delRes.isErr: + return err("KvDelete.setProvider: " & delRes.error()) + + let listenRes = PersistEvent.listen(ctx, onPersist) + if listenRes.isErr: + return err("PersistEvent.listen: " & listenRes.error()) + + return ok() + +proc clearProviders(ctx: BrokerContext) = + KvGet.clearProvider(ctx) + KvExists.clearProvider(ctx) + KvScan.clearProvider(ctx) + KvCount.clearProvider(ctx) + KvDelete.clearProvider(ctx) + PersistEvent.dropAllListeners(ctx) + +# ── thread proc ───────────────────────────────────────────────────────── + +proc storageThreadMain(arg: ptr StorageThreadArg) {.thread.} = + ## Worker thread entrypoint. Errors during setup are surfaced via + ## arg.errBuf + readyFlag=ReadyState.Error; the spawning thread checks both. + + setThreadBrokerContext(arg.ctx) + + let path = $arg.dbPath + + let backendRes = + try: + openBackend(path) + except CatchableError as e: + arg.recordErr("openBackend raised: " & e.msg) + return + if backendRes.isErr: + arg.recordErr("openBackend: " & backendRes.error.msg) + return + let backend = backendRes.get() + + let regRes = + try: + registerProviders(backend, arg.ctx) + except CatchableError as e: + backend.close() + arg.recordErr("registerProviders raised: " & e.msg) + return + if regRes.isErr: + backend.close() + arg.recordErr(regRes.error) + return + + arg.readyFlag.store(int(ReadyState.Ready), moRelease) + + proc awaitShutdown() {.async.} = + while arg.shutdownFlag.load(moAcquire) != 1: + try: + await sleepAsync(milliseconds(10)) + except CatchableError: + discard + + try: + waitFor awaitShutdown() + except CatchableError as e: + error "storage thread loop crashed", err = e.msg + + clearProviders(arg.ctx) + backend.close() + +# ── lifecycle ─────────────────────────────────────────────────────────── + +type JobRuntime* = ref object + ## Opaque per-job runtime owned by `persistency.nim`. Holds the typed + ## Thread handle + shared arg pointer so closeJob can shut the worker + ## down. Created by `startStorageThread` and torn down by + ## `stopStorageThread`. + arg*: ptr StorageThreadArg + thread*: StorageThread + +proc startStorageThread*( + ctx: BrokerContext, dbPath: string +): Result[JobRuntime, PersistencyError] = + ## Spawn a storage worker for one job. Blocks until the worker either + ## signals ready (returns the runtime) or signals error (joins, frees, + ## returns peBackend with the worker's error message). + let arg = allocArg(ctx, dbPath) + arg.shutdownFlag.store(0, moRelease) + arg.readyFlag.store(int(ReadyState.Pending), moRelease) + + var rt = JobRuntime(arg: arg) + try: + createThread(rt.thread, storageThreadMain, arg) + except ResourceExhaustedError as e: + freeArg(arg) + return err(persistencyErr(peBackend, "createThread: " & e.msg)) + + # Spin-wait for ready or error. The thread does its setup synchronously + # before signaling, so this is bounded by SQLite open time. + while true: + let s = arg.readyFlag.load(moAcquire) + if s == int(ReadyState.Ready): + return ok(rt) + if s == int(ReadyState.Error): + let msg = errMsg(arg) + joinThread(rt.thread) + freeArg(arg) + return err(persistencyErr(peBackend, msg)) + sleep(1) + +proc stopStorageThread*(rt: JobRuntime) = + ## Signal shutdown, join the worker, free the shared arg. Idempotent in + ## the sense that it tolerates a nil arg (already stopped). + if rt == nil or rt.arg == nil: + return + rt.arg.shutdownFlag.store(1, moRelease) + joinThread(rt.thread) + freeArg(rt.arg) + rt.arg = nil diff --git a/waku/persistency/keys.nim b/waku/persistency/keys.nim new file mode 100644 index 000000000..6a3199b8c --- /dev/null +++ b/waku/persistency/keys.nim @@ -0,0 +1,180 @@ +## Composite-key encoding. +## +## Keys are byte-wise lexicographically comparable so SQLite's BLOB +## ordering reproduces tuple ordering of the original components. Each +## component contributes a self-delimiting, sort-stable byte sequence +## through an `encodePart` overload; the generic fallback recurses through +## `tuple | object` fields, so any user type whose fields are themselves +## encodable can be used as a key part without ceremony. +## +## ## Encoding by type +## +## | Nim type | Bytes emitted | +## |-------------------------|------------------------------------------------------------------| +## | `string`, `openArray[byte]` | 2-byte BE length prefix + payload bytes (max 65535 bytes) | +## | `int64`, `int`, .. | XOR with 0x8000_0000_0000_0000 then 8-byte BE (sign-flip) | +## | `uint64`, `uint32`, .. | 8-byte BE | +## | `bool` | 1 byte (0/1) | +## | `byte`, `char` | 1 byte | +## | `enum E` | sign-flipped 8-byte BE of `ord(v).int64` | +## | `Key` | raw bytes (lets you embed a pre-built key inside another) | +## | `tuple | object` | each field encoded in declaration order, concatenated | +## +## ## Sort-order caveats +## +## - Length-prefixed strings sort by **length first, then byte order**. For +## uniform-length components (channel ids, hashes) this is identical to +## natural lex order; for variable-length text it is not. +## - `int64.low < -1 < 0 < 1 < int64.high` after byte comparison thanks to +## the sign flip. +## - Tuple/object ordering is component-major: field 0 dominates field 1 +## dominates field 2, like a multi-column ORDER BY. +## +## ## Building keys +## +## `key(...)` is a variadic macro that calls `encodePart` per argument. It +## accepts mixed types in one call: +## +## ```nim +## let k = key("channel-42", 1'i64) +## let k2 = key("channel-42", (epoch: 1'i64, seqNum: 7'u64)) +## let k3 = key(myEnumValue, myObject) +## ``` +## +## For a single value, `toKey(v)` is the simpler form (same semantics). + +{.push raises: [].} + +import std/macros +import ./types + +const + StringLenMax* = 0xFFFF + SignFlip = 0x8000_0000_0000_0000'u64 + +# ── Low-level byte helpers ────────────────────────────────────────────── + +proc appendBE16(buf: var seq[byte], v: uint16) = + buf.add(byte((v shr 8) and 0xFF'u16)) + buf.add(byte(v and 0xFF'u16)) + +proc appendBE64(buf: var seq[byte], v: uint64) = + for shift in countdown(56, 0, 8): + buf.add(byte((v shr shift) and 0xFF'u64)) + +# ── encodePart: primitives ────────────────────────────────────────────── + +proc encodePart*(dest: var seq[byte], s: string) = + doAssert s.len <= StringLenMax, "string component exceeds 65535 bytes" + appendBE16(dest, uint16(s.len)) + for c in s: + dest.add(byte(c)) + +proc encodePart*(dest: var seq[byte], raw: openArray[byte]) = + doAssert raw.len <= StringLenMax, "byte component exceeds 65535 bytes" + appendBE16(dest, uint16(raw.len)) + for b in raw: + dest.add(b) + +proc encodePart*(dest: var seq[byte], i: int64) = + appendBE64(dest, cast[uint64](i) xor SignFlip) + +proc encodePart*(dest: var seq[byte], u: uint64) = + appendBE64(dest, u) + +proc encodePart*(dest: var seq[byte], i: int) {.inline.} = + encodePart(dest, i.int64) + +proc encodePart*(dest: var seq[byte], i: int32) {.inline.} = + encodePart(dest, i.int64) + +proc encodePart*(dest: var seq[byte], i: int16) {.inline.} = + encodePart(dest, i.int64) + +proc encodePart*(dest: var seq[byte], i: int8) {.inline.} = + encodePart(dest, i.int64) + +proc encodePart*(dest: var seq[byte], u: uint32) {.inline.} = + encodePart(dest, u.uint64) + +proc encodePart*(dest: var seq[byte], u: uint16) {.inline.} = + encodePart(dest, u.uint64) + +proc encodePart*(dest: var seq[byte], b: bool) = + dest.add(if b: 1'u8 else: 0'u8) + +proc encodePart*(dest: var seq[byte], b: byte) = + dest.add(b) + +proc encodePart*(dest: var seq[byte], c: char) = + dest.add(byte(c)) + +proc encodePart*(dest: var seq[byte], k: Key) = + ## Embed an already-encoded Key (e.g. a pre-built prefix) verbatim. + for b in bytes(k): + dest.add(b) + +# ── encodePart: generic structural fallback ───────────────────────────── + +proc encodePart*[E: enum](dest: var seq[byte], v: E) {.inline.} = + encodePart(dest, int64(ord(v))) + +proc encodePart*[T: tuple | object](dest: var seq[byte], v: T) = + ## Walks the type's fields in declaration order. Each field must itself + ## have an `encodePart` overload (primitive, Key, or another struct). + for f in fields(v): + encodePart(dest, f) + +# ── Public Key constructors ───────────────────────────────────────────── + +proc add*[T](k: var Key, v: T) = + ## In-place key extension. Equivalent to writing `encodePart` against the + ## underlying byte buffer. + var buf = seq[byte](k) + encodePart(buf, v) + k = Key(buf) + +proc toKey*[T](v: T): Key = + ## Single-value Key constructor. Equivalent to `key(v)`. + var buf: seq[byte] = @[] + encodePart(buf, v) + return Key(buf) + +macro key*(parts: varargs[typed]): Key = + ## Variadic Key builder. Accepts any mix of types for which `encodePart` + ## resolves -- including tuples and objects via the structural fallback. + ## + ## ```nim + ## key() # empty Key + ## key("ch", 1'i64) # 2-component + ## key("ch", (1'i64, 7'u64)) # nested tuple flattens + ## ``` + let bufSym = genSym(nskVar, "keyBuf") + var body = newStmtList() + body.add quote do: + var `bufSym`: seq[byte] = @[] + for p in parts: + body.add quote do: + encodePart(`bufSym`, `p`) + body.add quote do: + Key(`bufSym`) + return newBlockStmt(body) + +# ── Range helpers ─────────────────────────────────────────────────────── + +proc prefixRange*(prefix: Key): KeyRange = + ## Build [prefix, prefix++) — a half-open range that captures every key + ## starting with `prefix`. If `prefix` is all 0xFF, the upper bound is + ## empty (open-ended); the backend treats `stop.len == 0` as "no upper + ## bound". + var stop = bytes(prefix) + var i = stop.len - 1 + while i >= 0: + if stop[i] != 0xFF'u8: + stop[i] = stop[i] + 1'u8 + stop.setLen(i + 1) + return KeyRange(start: prefix, stop: Key(stop)) + dec i + return KeyRange(start: prefix, stop: Key(@[])) + +{.pop.} diff --git a/waku/persistency/payload.nim b/waku/persistency/payload.nim new file mode 100644 index 000000000..222de4177 --- /dev/null +++ b/waku/persistency/payload.nim @@ -0,0 +1,53 @@ +## Generic payload encoding. +## +## Symmetric with `keys.nim`: reuses the same `encodePart` family so any +## Nim type composable from primitives + tuples/objects can be turned +## into a `seq[byte]` for storage. Unlike keys, payloads do **not** need +## byte-wise lex order — but using the same encoder keeps the system +## small. If a tenant needs a different on-disk format (CBOR, protobuf, +## SSZ, ...) they can write their own `toPayload` overload or pass an +## already-encoded `seq[byte]` to `persistPut`. +## +## ```nim +## # Primitives: +## let p1 = payload("hello") # length-prefixed string bytes +## let p2 = payload(42'i64) # 8 bytes, sign-flipped BE +## +## # Composites: +## type Msg = object +## sender: string +## epoch: int64 +## body: seq[byte] +## let p3 = toPayload(Msg(sender: "alice", epoch: 7, body: @[1'u8, 2, 3])) +## +## # Variadic when you want multiple values back-to-back: +## let p4 = payload("v1", 1'i64, body) +## ``` + +{.push raises: [].} + +import std/macros +import ./keys + +export keys.encodePart + +proc toPayload*[T](v: T): seq[byte] = + ## Single-value payload constructor. Equivalent to `payload(v)`. + var buf: seq[byte] = @[] + encodePart(buf, v) + return buf + +macro payload*(parts: varargs[typed]): seq[byte] = + ## Variadic payload builder. Same encoder as `key(...)`; only the return + ## type differs. + let bufSym = genSym(nskVar, "payloadBuf") + var body = newStmtList() + body.add quote do: + var `bufSym`: seq[byte] = @[] + for p in parts: + body.add quote do: + encodePart(`bufSym`, `p`) + body.add bufSym + return newBlockStmt(body) + +{.pop.} diff --git a/waku/persistency/persistency.nim b/waku/persistency/persistency.nim new file mode 100644 index 000000000..916f3ac8b --- /dev/null +++ b/waku/persistency/persistency.nim @@ -0,0 +1,433 @@ +## Public facade and main driver types for the persistency library. +## +## ``Persistency`` is the per-root coordinator; one instance owns one +## directory and any number of named jobs. ``Job`` is the per-job handle: +## one tenant, one DB file, one worker thread, one BrokerContext. +## +## ## Two ways to drive a job +## +## **By Job ref** — capture the handle from `openJob` and call methods on +## it. Cheapest, no map lookup per call: +## +## ```nim +## let p = Persistency.instance("/var/lib/wakustore").get() +## let j = p.openJob("alpha").get() +## await j.persistPut("msg", k, payload) +## let v = await j.get("msg", k) +## ``` +## +## **By job id string** — useful when the caller doesn't want to thread +## the ``Job`` ref around (config-driven services, RPC dispatchers). The +## Job must still have been opened previously; the string-form procs look +## it up in `Persistency.jobs`: +## +## ```nim +## discard p.openJob("alpha") +## await p.persistPut("alpha", "msg", k, payload) # logs and resolves if not open +## let v = await p.get("alpha", "msg", k) # Result, peJobNotFound if missing +## ``` +## +## ## Drain semantics +## +## Writes return a ``Future[void]`` that resolves once the PersistEvent +## has been pushed onto the worker thread's channel — **not** once the +## SQL has run. The listener is still fire-and-forget on the SQL side, so +## a read issued immediately after an awaited write is still racy by +## design in v1. To bridge the race: +## * use ``deleteAcked`` (it round-trips through the read path), or +## * poll ``exists`` until it returns true, or +## * yield with ``await sleepAsync(...)``. + +{.push raises: [].} + +import std/[locks, options, os, sequtils, tables] +import chronos, chronicles, results +import brokers/[event_broker, request_broker, broker_context] +import ./[types, keys, payload, backend_comm, backend_thread] + +export types, keys, payload + +logScope: + topics = "persistency" + +const DefaultStoragePath* = "./data" + +# ── Driver types ──────────────────────────────────────────────────────── + +type + Job* = ref object + ## Per-job handle. Owns its BrokerContext and the worker thread that + ## services it. Created and torn down via `Persistency.openJob` / + ## `Persistency.closeJob`. + id*: string + context*: BrokerContext + runtime: JobRuntime ## internal — managed by openJob/closeJob + running*: bool + + Persistency* = ref object + ## Per-root coordinator. One Persistency instance manages a directory + ## of per-job SQLite files at ``rootDir/.db``. + rootDir*: string + jobs*: Table[string, Job] + +# ── Singleton state ───────────────────────────────────────────────────── +# +# Persistency is a process-wide singleton: one rootDir at a time. The +# `instance` factory is the only public constructor; `new` below is +# private and skips the singleton bookkeeping (used internally and never +# called twice with conflicting rootDirs). + +var + gPersistency {.global.}: Persistency + gPersistencyLock {.global.}: Lock + +once: + gPersistencyLock.initLock() + +# ── Lifecycle ─────────────────────────────────────────────────────────── + +proc dbPathFor(p: Persistency, jobId: string): string = + p.rootDir / (jobId & ".db") + +proc new(T: type Persistency, rootDir: string): Result[T, PersistencyError] = + ## Private. Build a Persistency value without touching the singleton + ## slot. Validates ``rootDir`` but does **not** create it — directory + ## materialisation is deferred to the first ``openJob`` call. Semantics: + ## + ## * If ``rootDir`` is empty, returns ``peInvalidArgument``. + ## * If ``rootDir`` exists and is a directory, accept it. + ## * If ``rootDir`` exists but is not a directory, returns + ## ``peInvalidArgument``. + ## * If ``rootDir`` does not exist, walk up the parent chain: the first + ## existing ancestor must be a directory; otherwise returns + ## ``peInvalidArgument``. This catches "obviously broken" paths early + ## without actually touching the filesystem. + if rootDir.len == 0: + return err(persistencyErr(peInvalidArgument, "rootDir is empty")) + if fileExists(rootDir) and not dirExists(rootDir): + return err( + persistencyErr( + peInvalidArgument, "rootDir exists and is not a directory: " & rootDir + ) + ) + if not dirExists(rootDir): + var parent = parentDir(rootDir) + while parent.len > 0 and not dirExists(parent): + if fileExists(parent): + return err( + persistencyErr( + peInvalidArgument, + "rootDir ancestor exists and is not a directory: " & parent, + ) + ) + parent = parentDir(parent) + return ok(T(rootDir: rootDir, jobs: initTable[string, Job]())) + +proc ensureRootDir(p: Persistency): Result[void, PersistencyError] = + ## Materialise ``rootDir`` on demand. Idempotent; called from + ## ``openJob`` so an unused Persistency leaves no directory behind. + if dirExists(p.rootDir): + return ok() + try: + createDir(p.rootDir) + except OSError, IOError: + return + err(persistencyErr(peBackend, "createDir failed: " & getCurrentExceptionMsg())) + return ok() + +proc reset*(T: type Persistency) {.gcsafe.} = + ## Tear down the singleton: close every open job, clear the Teardown + ## provider, and free the slot so a subsequent ``Persistency.instance`` + ## starts fresh. Idempotent. Tests use this in `defer`;. + {.cast(gcsafe).}: + acquire(gPersistencyLock) + defer: + release(gPersistencyLock) + if gPersistency != nil: + let p = gPersistency + gPersistency = nil + p.close() + +proc instance*( + T: type Persistency, rootDir: string +): Result[T, PersistencyError] {.gcsafe.} = + ## Get-or-init the process-wide Persistency singleton. + ## + ## * First call: validates ``rootDir`` (without creating it) and + ## registers the Teardown handler. The directory itself is created + ## lazily by the first ``openJob`` call, so a Persistency that never + ## opens a job leaves no filesystem footprint. + ## * Later calls with the same ``rootDir``: returns the live instance + ## (idempotent). + ## * Later calls with a different ``rootDir``: returns + ## ``peInvalidArgument`` — the singleton can only be re-targeted via + ## ``Persistency.reset`` (or by the Teardown shutdown flow). + {.cast(gcsafe).}: + acquire(gPersistencyLock) + defer: + release(gPersistencyLock) + + if gPersistency != nil: + if gPersistency.rootDir == rootDir: + return ok(gPersistency) + return err( + persistencyErr( + peInvalidArgument, + "Persistency already initialised with rootDir " & gPersistency.rootDir & + "; cannot re-init with " & rootDir, + ) + ) + + let p = ?Persistency.new(rootDir) + gPersistency = p + return ok(p) + +proc instance*(T: type Persistency): Result[T, PersistencyError] {.gcsafe.} = + ## No-args form: succeeds only if the singleton is already initialised. + ## Use this from services that must not be the first to touch + ## persistency. + {.cast(gcsafe).}: + acquire(gPersistencyLock) + defer: + release(gPersistencyLock) + if gPersistency.isNil: + return err(persistencyErr(peClosed, "Persistency not initialised")) + return ok(gPersistency) + +proc openJob*(p: Persistency, jobId: string): Result[Job, PersistencyError] = + ## Open-or-create a job under this Persistency. + ## + ## * If the job is already open in this process, the existing ``Job`` + ## ref is returned (idempotent). + ## * Otherwise ``rootDir`` is materialised on demand (created with + ## missing parents on first use; no-op on subsequent calls), a worker + ## thread is spawned, and the SQLite file at + ## ``/.db`` is opened. If the file does not exist it + ## is created and the schema initialised; if it already exists it is + ## reopened in place and its data is preserved. + let existing = p.jobs.getOrDefault(jobId, nil) + if existing != nil: + return ok(existing) + + ?p.ensureRootDir() + + let ctx = NewBrokerContext() + let rt = ?startStorageThread(ctx, dbPathFor(p, jobId)) + let job = Job(id: jobId, context: ctx, runtime: rt, running: true) + p.jobs[jobId] = job + return ok(job) + +proc closeJob*(p: Persistency, jobId: string) = + ## Stop the worker, join its thread, and forget the job. No-op if the + ## job isn't open. + let job = p.jobs.getOrDefault(jobId, nil) + if job == nil: + return + stopStorageThread(job.runtime) + job.runtime = nil + job.running = false + p.jobs.del(jobId) + +proc close*(p: Persistency) = + ## Close every open job. Idempotent. + var ids: seq[string] + for id in p.jobs.keys: + ids.add(id) + for id in ids: + p.closeJob(id) + +proc dropJob*(p: Persistency, jobId: string) = + ## Close the job if open, then delete its DB file (plus -wal / -shm + ## sidecars). Best-effort: a missing file is not an error. + p.closeJob(jobId) + let path = dbPathFor(p, jobId) + for suffix in ["", "-wal", "-shm"]: + try: + removeFile(path & suffix) + except OSError, IOError: + discard + +# ── String lookup ─────────────────────────────────────────────────────── + +proc job*(p: Persistency, jobId: string): Result[Job, PersistencyError] = + ## Look up an already-open job. Returns ``peJobNotFound`` if no such + ## job has been opened (``openJob`` first). + let j = p.jobs.getOrDefault(jobId, nil) + if j != nil: + return ok(j) + else: + return err(persistencyErr(peJobNotFound, "no open job with id: " & jobId)) + +proc `[]`*(p: Persistency, jobId: string): Job {.raises: [KeyError].} = + ## Subscript sugar for `job` — raises ``KeyError`` if the job isn't + ## open. Prefer `job(p, id)` when you want a typed error. + p.jobs[jobId] + +proc hasJob*(p: Persistency, jobId: string): bool {.inline.} = + p.jobs.hasKey(jobId) + +# ── Writes (fire-and-forget) — Job form ───────────────────────────────── + +proc persist*(t: Job, ops: seq[TxOp]): Future[void] {.async.} = + ## Emit a batched persist event. The handler treats >1 ops as a single + ## BEGIN IMMEDIATE/COMMIT transaction (see backend_sqlite.applyOps). + await PersistEvent.emit(t.context, PersistEvent(ops: ops)) + +proc persist*(t: Job, op: TxOp): Future[void] {.async.} = + await persist(t, @[op]) + +proc persistPut*( + t: Job, category: string, key: Key, payload: seq[byte] +): Future[void] {.async.} = + await persist(t, TxOp(category: category, key: key, kind: txPut, payload: payload)) + +proc persistDelete*(t: Job, category: string, key: Key): Future[void] {.async.} = + await persist(t, TxOp(category: category, key: key, kind: txDelete)) + +proc persistEncoded*[T]( + t: Job, category: string, key: Key, value: T +): Future[void] {.async.} = + ## Convenience: encode `value` via `toPayload` and put it. Use the raw + ## `persistPut(..., seq[byte])` form when you already have bytes + ## (e.g. an externally-produced CBOR blob). + await persistPut(t, category, key, toPayload(value)) + +# ── Writes (fire-and-forget) — string-lookup form ─────────────────────── +# +# These look up the Job by id and dispatch. If the job isn't open we log +# a warning and drop the write — consistent with the fire-and-forget +# contract; the caller has no return channel to inspect. + +proc jobOrWarn(p: Persistency, jobId: string): Job = + ## Lookup helper for the fire-and-forget write paths. Returns nil and + ## logs a warning if the job isn't open. Isolated as a non-generic proc + ## so chronicles' `warn` macro expands cleanly (it doesn't, when called + ## from inside a generic proc's body). + let job = p.jobs.getOrDefault(jobId, nil) + if job.isNil(): + warn "persistency: write dropped, job not open", jobId + return job + +template withJobOrWarn(p: Persistency, jobId: string, j, body: untyped) = + let `j` = p.jobOrWarn(jobId) + if not `j`.isNil(): + body + +proc persist*(p: Persistency, jobId: string, ops: seq[TxOp]): Future[void] {.async.} = + let j = p.jobOrWarn(jobId) + if not j.isNil(): + await j.persist(ops) + +proc persist*(p: Persistency, jobId: string, op: TxOp): Future[void] {.async.} = + await p.persist(jobId, @[op]) + +proc persistPut*( + p: Persistency, jobId: string, category: string, key: Key, payload: seq[byte] +): Future[void] {.async.} = + let j = p.jobOrWarn(jobId) + if not j.isNil(): + await j.persistPut(category, key, payload) + +proc persistDelete*( + p: Persistency, jobId: string, category: string, key: Key +): Future[void] {.async.} = + let j = p.jobOrWarn(jobId) + if not j.isNil(): + await j.persistDelete(category, key) + +proc persistEncoded*[T]( + p: Persistency, jobId: string, category: string, key: Key, value: T +): Future[void] {.async.} = + let j = p.jobOrWarn(jobId) + if not j.isNil(): + await j.persistEncoded(category, key, value) + +# ── Reads (async, typed errors) — Job form ────────────────────────────── + +template liftErr(s: string): PersistencyError = + decodeErr(s) + +proc get*( + t: Job, category: string, key: Key +): Future[Result[Option[seq[byte]], PersistencyError]] {.async.} = + let r = (await KvGet.request(t.context, category, key)).valueOr: + return err(liftErr(error)) + return ok(r.value) + +proc exists*( + t: Job, category: string, key: Key +): Future[Result[bool, PersistencyError]] {.async.} = + let r = (await KvExists.request(t.context, category, key)).valueOr: + return err(liftErr(error)) + return ok(r.value) + +proc scan*( + t: Job, category: string, range: KeyRange, reverse = false +): Future[Result[seq[KvRow], PersistencyError]] {.async.} = + let r = (await KvScan.request(t.context, category, range, reverse)).valueOr: + return err(liftErr(error)) + return ok(r.rows) + +proc scanPrefix*( + t: Job, category: string, prefix: Key, reverse = false +): Future[Result[seq[KvRow], PersistencyError]] {.async.} = + let rng = prefixRange(prefix) + let r = (await KvScan.request(t.context, category, rng, reverse)).valueOr: + return err(liftErr(error)) + return ok(r.rows) + +proc count*( + t: Job, category: string, range: KeyRange +): Future[Result[int, PersistencyError]] {.async.} = + let r = (await KvCount.request(t.context, category, range)).valueOr: + return err(liftErr(error)) + return ok(r.n) + +proc deleteAcked*( + t: Job, category: string, key: Key +): Future[Result[bool, PersistencyError]] {.async.} = + ## Goes through the read path so the caller learns whether a row was + ## actually removed. + let r = (await KvDelete.request(t.context, category, key)).valueOr: + return err(liftErr(error)) + return ok(r.existed) + +# ── Reads (async, typed errors) — string-lookup form ──────────────────── + +proc get*( + p: Persistency, jobId: string, category: string, key: Key +): Future[Result[Option[seq[byte]], PersistencyError]] {.async.} = + let j = ?p.job(jobId) + return await j.get(category, key) + +proc exists*( + p: Persistency, jobId: string, category: string, key: Key +): Future[Result[bool, PersistencyError]] {.async.} = + let j = ?p.job(jobId) + return await j.exists(category, key) + +proc scan*( + p: Persistency, jobId: string, category: string, range: KeyRange, reverse = false +): Future[Result[seq[KvRow], PersistencyError]] {.async.} = + let j = ?p.job(jobId) + return await j.scan(category, range, reverse) + +proc scanPrefix*( + p: Persistency, jobId: string, category: string, prefix: Key, reverse = false +): Future[Result[seq[KvRow], PersistencyError]] {.async.} = + let j = ?p.job(jobId) + return await j.scanPrefix(category, prefix, reverse) + +proc count*( + p: Persistency, jobId: string, category: string, range: KeyRange +): Future[Result[int, PersistencyError]] {.async.} = + let j = ?p.job(jobId) + return await j.count(category, range) + +proc deleteAcked*( + p: Persistency, jobId: string, category: string, key: Key +): Future[Result[bool, PersistencyError]] {.async.} = + let j = ?p.job(jobId) + return await j.deleteAcked(category, key) + +{.pop.} diff --git a/waku/persistency/schema.nim b/waku/persistency/schema.nim new file mode 100644 index 000000000..1d13014f2 --- /dev/null +++ b/waku/persistency/schema.nim @@ -0,0 +1,58 @@ +## SQL schema and pragma setup for the persistency library. +## +## Single uniform schema per job DB file: +## kv(category BLOB, key BLOB, payload BLOB) PRIMARY KEY (category, key) +## WITHOUT ROWID +## +## category is declared BLOB (not TEXT) so it round-trips via the existing +## sqlite3_abi binding helpers (which do not yet expose bind_text). SQLite +## compares BLOBs byte-wise, which is exactly the ordering we want. + +{.push raises: [].} + +import results +import ../common/databases/[common, db_sqlite] + +const + PersistencyUserVersion* = 1'i64 + + CreateKvTableSql* = """ + CREATE TABLE IF NOT EXISTS kv ( + category BLOB NOT NULL, + key BLOB NOT NULL, + payload BLOB NOT NULL, + PRIMARY KEY (category, key) + ) WITHOUT ROWID; + """ + + ApplyPragmasSql* = """ + PRAGMA synchronous = NORMAL; + PRAGMA temp_store = MEMORY; + PRAGMA busy_timeout = 5000; + PRAGMA foreign_keys = OFF; + """ + +proc applyPragmas*(db: SqliteDatabase): DatabaseResult[void] = + ## Apply the connection-level pragmas. journal_mode=WAL is already set by + ## SqliteDatabase.new. + for stmt in [ + "PRAGMA synchronous = NORMAL;", "PRAGMA temp_store = MEMORY;", + "PRAGMA busy_timeout = 5000;", "PRAGMA foreign_keys = OFF;", + ]: + db.query(stmt, NoopRowHandler).isOkOr: + return err("pragma failed: " & stmt & ": " & error) + return ok() + +proc ensureSchema*(db: SqliteDatabase): DatabaseResult[void] = + db.query(CreateKvTableSql, NoopRowHandler).isOkOr: + return err("create kv table failed: " & error) + + let userVersion = ?db.getUserVersion() + if userVersion == 0: + ?db.setUserVersion(PersistencyUserVersion) + elif userVersion != PersistencyUserVersion: + return err( + "incompatible persistency user_version: got " & $userVersion & ", expected " & + $PersistencyUserVersion + ) + return ok() diff --git a/waku/persistency/types.nim b/waku/persistency/types.nim new file mode 100644 index 000000000..4c4c2de3f --- /dev/null +++ b/waku/persistency/types.nim @@ -0,0 +1,81 @@ +## Core types for the logos-delivery persistency library. +## +## The library is backend-neutral CRUD: jobs own their domain ports and +## map them onto the primitives exposed in persistency.nim. See +## persistency.nim for the public facade and brokers.nim for the +## cross-thread plumbing. + +{.push raises: [].} + +type + Key* = distinct seq[byte] + + KeyRange* = object + start*: Key + stop*: Key ## exclusive; an empty `stop` means "no upper bound" + + KvRow* = tuple[key: Key, payload: seq[byte]] + + TxOpKind* = enum + txPut + txDelete + + TxOp* = object + category*: string + key*: Key + case kind*: TxOpKind + of txPut: + payload*: seq[byte] + of txDelete: + discard + + PersistencyErrorKind* = enum + peBackend + peClosed + peInvalidArgument + peTimeout + peJobNotFound + + PersistencyError* = object + kind*: PersistencyErrorKind + msg*: string + backendCode*: int + +proc bytes*(k: Key): lent seq[byte] {.inline.} = + seq[byte](k) + +proc len*(k: Key): int {.inline.} = + seq[byte](k).len + +proc `==`*(a, b: Key): bool {.inline.} = + seq[byte](a) == seq[byte](b) + +proc `<`*(a, b: Key): bool = + let ab = seq[byte](a) + let bb = seq[byte](b) + let n = min(ab.len, bb.len) + for i in 0 ..< n: + if ab[i] != bb[i]: + return ab[i] < bb[i] + return ab.len < bb.len + +proc `<=`*(a, b: Key): bool {.inline.} = + a == b or a < b + +proc rawKey*(b: openArray[byte]): Key = + var s = newSeq[byte](b.len) + for i, v in b: + s[i] = v + return Key(s) + +proc rawKey*(b: sink seq[byte]): Key {.inline.} = + Key(b) + +proc persistencyErr*( + kind: PersistencyErrorKind, msg: string, backendCode = 0 +): PersistencyError {.inline.} = + PersistencyError(kind: kind, msg: msg, backendCode: backendCode) + +proc `$`*(e: PersistencyError): string = + "PersistencyError(" & $e.kind & ": " & e.msg & + (if e.backendCode != 0: ", code=" & $e.backendCode else: "") & ")" diff --git a/waku/requests/health_requests.nim b/waku/requests/health_requests.nim index c3a0ce286..d48b3278f 100644 --- a/waku/requests/health_requests.nim +++ b/waku/requests/health_requests.nim @@ -1,4 +1,4 @@ -import waku/common/broker/request_broker +import brokers/request_broker import waku/api/types import waku/node/health_monitor/[protocol_health, topic_health, health_report] diff --git a/waku/requests/node_requests.nim b/waku/requests/node_requests.nim index a4ccc6de4..93c6b1159 100644 --- a/waku/requests/node_requests.nim +++ b/waku/requests/node_requests.nim @@ -1,5 +1,5 @@ import std/options -import waku/common/broker/[request_broker, multi_request_broker] +import brokers/[request_broker, multi_request_broker] import waku/waku_core/[topics] RequestBroker(sync): diff --git a/waku/requests/rln_requests.nim b/waku/requests/rln_requests.nim index 8b61f9fcd..ffd747bed 100644 --- a/waku/requests/rln_requests.nim +++ b/waku/requests/rln_requests.nim @@ -1,4 +1,5 @@ -import waku/common/broker/request_broker, waku/waku_core/message/message +import brokers/request_broker +import waku/waku_core/message/message RequestBroker: type RequestGenerateRlnProof* = object diff --git a/waku/waku_filter_v2/client.nim b/waku/waku_filter_v2/client.nim index 265bf5e7b..7798f41b7 100644 --- a/waku/waku_filter_v2/client.nim +++ b/waku/waku_filter_v2/client.nim @@ -8,10 +8,11 @@ import chronos, libp2p/protocols/protocol, bearssl/rand, - stew/byteutils + stew/byteutils, + brokers/broker_context + import - waku/ - [node/peer_manager, waku_core, events/delivery_events, common/broker/broker_context], + waku/[node/peer_manager, waku_core, events/delivery_events], ./common, ./protocol_metrics, ./rpc_codec, diff --git a/waku/waku_relay/protocol.nim b/waku/waku_relay/protocol.nim index e7b2c99cb..d0b1ddb48 100644 --- a/waku/waku_relay/protocol.nim +++ b/waku/waku_relay/protocol.nim @@ -16,7 +16,8 @@ import libp2p/protocols/pubsub/gossipsub, libp2p/protocols/pubsub/rpc/messages, libp2p/stream/connection, - libp2p/switch + libp2p/switch, + brokers/broker_context import waku/waku_core, @@ -24,7 +25,6 @@ import waku/requests/health_requests, waku/events/health_events, ./message_id, - waku/common/broker/broker_context, waku/events/peer_events from waku/waku_core/codecs import WakuRelayCodec @@ -526,7 +526,7 @@ method stop*(w: WakuRelay) {.async: (raises: []).} = info "stop" await procCall GossipSub(w).stop() - WakuPeerEvent.dropListener(w.brokerCtx, w.peerEventListener) + await WakuPeerEvent.dropListener(w.brokerCtx, w.peerEventListener) if not w.topicHealthLoopHandle.isNil(): await w.topicHealthLoopHandle.cancelAndWait() diff --git a/waku/waku_rln_relay/constants.nim b/waku/waku_rln_relay/constants.nim index 3e4757537..8532abaaa 100644 --- a/waku/waku_rln_relay/constants.nim +++ b/waku/waku_rln_relay/constants.nim @@ -25,8 +25,6 @@ const # the size of poseidon hash output as the number hex digits HashHexSize* = int(HashBitSize / 4) -const DefaultRlnTreePath* = "rln_tree.db" - const # pre-processed "rln/waku-rln-relay/v2.0.0" to array[32, byte] DefaultRlnIdentifier*: RlnIdentifier = [ diff --git a/waku/waku_rln_relay/conversion_utils.nim b/waku/waku_rln_relay/conversion_utils.nim index 4a168ebeb..fc130621b 100644 --- a/waku/waku_rln_relay/conversion_utils.nim +++ b/waku/waku_rln_relay/conversion_utils.nim @@ -75,48 +75,6 @@ proc serialize*( ) return output -proc serialize*(witness: RLNWitnessInput): seq[byte] = - ## Serializes the RLN witness into a byte array following zerokit's expected format. - ## The serialized format includes: - ## - identity_secret (32 bytes, little-endian with zero padding) - ## - user_message_limit (32 bytes, little-endian with zero padding) - ## - message_id (32 bytes, little-endian with zero padding) - ## - merkle tree depth (8 bytes, little-endian) = path_elements.len / 32 - ## - path_elements (each 32 bytes, ordered bottom-to-top) - ## - merkle tree depth again (8 bytes, little-endian) - ## - identity_path_index (sequence of bits as bytes, 0 = left, 1 = right) - ## - x (32 bytes, little-endian with zero padding) - ## - external_nullifier (32 bytes, little-endian with zero padding) - var buffer: seq[byte] - buffer.add(@(witness.identity_secret)) - buffer.add(@(witness.user_message_limit)) - buffer.add(@(witness.message_id)) - buffer.add(toBytes(uint64(witness.path_elements.len / 32), Endianness.littleEndian)) - for element in witness.path_elements: - buffer.add(element) - buffer.add(toBytes(uint64(witness.path_elements.len / 32), Endianness.littleEndian)) - buffer.add(witness.identity_path_index) - buffer.add(@(witness.x)) - buffer.add(@(witness.external_nullifier)) - return buffer - -proc serialize*(proof: RateLimitProof, data: openArray[byte]): seq[byte] = - ## a private proc to convert RateLimitProof and data to a byte seq - ## this conversion is used in the proof verification proc - ## [ proof<128> | root<32> | epoch<32> | share_x<32> | share_y<32> | nullifier<32> | rln_identifier<32> | signal_len<8> | signal ] - let lenPrefMsg = encodeLengthPrefix(@data) - var proofBytes = concat( - @(proof.proof), - @(proof.merkleRoot), - @(proof.externalNullifier), - @(proof.shareX), - @(proof.shareY), - @(proof.nullifier), - lenPrefMsg, - ) - - return proofBytes - # Serializes a sequence of MerkleNodes proc serialize*(roots: seq[MerkleNode]): seq[byte] = var rootsBytes: seq[byte] = @[] diff --git a/waku/waku_rln_relay/group_manager/on_chain/group_manager.nim b/waku/waku_rln_relay/group_manager/on_chain/group_manager.nim index 38c533029..02317a056 100644 --- a/waku/waku_rln_relay/group_manager/on_chain/group_manager.nim +++ b/waku/waku_rln_relay/group_manager/on_chain/group_manager.nim @@ -11,7 +11,7 @@ import stint, json, std/[strutils, tables, algorithm, strformat], - stew/[byteutils, arrayops], + stew/byteutils, sequtils import @@ -327,7 +327,7 @@ proc getRootFromProofAndIndex( # it's currently not used anywhere, but can be used to verify the root from the proof and index # Compute leaf hash from idCommitment and messageLimit let messageLimitField = uint64ToField(g.userMessageLimit.get()) - var hash = poseidon(@[g.idCredentials.get().idCommitment, @messageLimitField]).valueOr: + var hash = poseidon(g.idCredentials.get().idCommitment, @messageLimitField).valueOr: return err("Failed to compute leaf hash: " & error) for i in 0 ..< bits.len: @@ -335,9 +335,9 @@ proc getRootFromProofAndIndex( let hashRes = if bits[i] == 0: - poseidon(@[@hash, sibling]) + poseidon(@hash, sibling) else: - poseidon(@[sibling, @hash]) + poseidon(sibling, @hash) hash = hashRes.valueOr: return err("Failed to compute poseidon hash: " & error) @@ -373,7 +373,12 @@ method generateProof*( let chunk = g.merkleProofCache[i * 32 .. (i + 1) * 32 - 1] path_elements.add(chunk.reversed()) - let x = keccak.keccak256.digest(data) + let xCfr = hashToFieldLe(data).valueOr: + return err("Failed to hash signal to field: " & error) + defer: + ffi_cfr_free(xCfr) + let x = cfrToBytesLe(xCfr).valueOr: + return err("Failed to serialize signal hash: " & error) let extNullifier = generateExternalNullifier(epoch, rlnIdentifier).valueOr: return err("Failed to compute external nullifier: " & error) @@ -388,57 +393,8 @@ method generateProof*( external_nullifier: extNullifier, ) - let serializedWitness = serialize(witness) - - var input_witness_buffer = toBuffer(serializedWitness) - - # Generate the proof using the zerokit API - var output_witness_buffer: Buffer - let witness_success = generate_proof_with_witness( - g.rlnInstance, addr input_witness_buffer, addr output_witness_buffer - ) - - if not witness_success: - return err("Failed to generate proof") - - # Parse the proof into a RateLimitProof object - var proofValue = cast[ptr array[320, byte]](output_witness_buffer.`ptr`) - let proofBytes: array[320, byte] = proofValue[] - - ## Parse the proof as [ proof<128> | root<32> | external_nullifier<32> | share_x<32> | share_y<32> | nullifier<32> ] - let - proofOffset = 128 - rootOffset = proofOffset + 32 - externalNullifierOffset = rootOffset + 32 - shareXOffset = externalNullifierOffset + 32 - shareYOffset = shareXOffset + 32 - nullifierOffset = shareYOffset + 32 - - var - zkproof: ZKSNARK - proofRoot, shareX, shareY: MerkleNode - externalNullifier: ExternalNullifier - nullifier: Nullifier - - discard zkproof.copyFrom(proofBytes[0 .. proofOffset - 1]) - discard proofRoot.copyFrom(proofBytes[proofOffset .. rootOffset - 1]) - discard - externalNullifier.copyFrom(proofBytes[rootOffset .. externalNullifierOffset - 1]) - discard shareX.copyFrom(proofBytes[externalNullifierOffset .. shareXOffset - 1]) - discard shareY.copyFrom(proofBytes[shareXOffset .. shareYOffset - 1]) - discard nullifier.copyFrom(proofBytes[shareYOffset .. nullifierOffset - 1]) - - # Create the RateLimitProof object - let output = RateLimitProof( - proof: zkproof, - merkleRoot: proofRoot, - externalNullifier: externalNullifier, - epoch: epoch, - rlnIdentifier: rlnIdentifier, - shareX: shareX, - shareY: shareY, - nullifier: nullifier, - ) + let output = generateRlnProofWithWitness(g.rlnInstance, witness, epoch, rlnIdentifier).valueOr: + return err("Failed to generate proof: " & error) info "Proof generated successfully", proof = output @@ -449,34 +405,12 @@ method generateProof*( method verifyProof*( g: OnchainGroupManager, input: seq[byte], proof: RateLimitProof ): GroupManagerResult[bool] {.gcsafe.} = - ## -- Verifies an RLN rate-limit proof against the set of valid Merkle roots -- - - var normalizedProof = proof - - let externalNullifier = generateExternalNullifier(proof.epoch, proof.rlnIdentifier).valueOr: - return err("Failed to compute external nullifier: " & error) - normalizedProof.externalNullifier = externalNullifier - - let proofBytes = serialize(normalizedProof, input) - let proofBuffer = proofBytes.toBuffer() - - let rootsBytes = serialize(g.validRoots.items().toSeq()) - let rootsBuffer = rootsBytes.toBuffer() - - var validProof: bool # out-param - let ffiOk = verify_with_roots( - g.rlnInstance, # RLN context created at init() - addr proofBuffer, # (proof + signal) - addr rootsBuffer, # valid Merkle roots - addr validProof # will be set by the FFI call - , - ) - - if not ffiOk: - return err("could not verify the proof") - else: - info "Proof verified successfully" + let validProof = verifyRlnProof( + g.rlnInstance, proof, input, g.validRoots.items().toSeq() + ).valueOr: + return err("could not verify the proof: " & error) + info "Proof verified", isValid = validProof return ok(validProof) method onRegister*(g: OnchainGroupManager, cb: OnRegisterCallback) {.gcsafe.} = @@ -623,6 +557,10 @@ method stop*(g: OnchainGroupManager): Future[void] {.async, gcsafe.} = g.ethRpc.get().ondisconnect = nil await g.ethRpc.get().close() + if not g.rlnInstance.isNil: + ffi_rln_free(g.rlnInstance) + g.rlnInstance = nil + g.initialized = false method isReady*(g: OnchainGroupManager): Future[bool] {.async.} = diff --git a/waku/waku_rln_relay/protocol_metrics.nim b/waku/waku_rln_relay/protocol_metrics.nim index 1551f022e..2cea329fe 100644 --- a/waku/waku_rln_relay/protocol_metrics.nim +++ b/waku/waku_rln_relay/protocol_metrics.nim @@ -56,7 +56,7 @@ declarePublicGauge( ) declarePublicGauge( waku_rln_membership_insertion_duration_seconds, - "time taken to insert a new member into the local merkle tree", + "time taken to process a new membership registration", ) declarePublicGauge( waku_rln_membership_credentials_import_duration_seconds, diff --git a/waku/waku_rln_relay/rln/rln_interface.nim b/waku/waku_rln_relay/rln/rln_interface.nim index 0bb0ef6b0..612d1a2cc 100644 --- a/waku/waku_rln_relay/rln/rln_interface.nim +++ b/waku/waku_rln_relay/rln/rln_interface.nim @@ -1,168 +1,378 @@ -## Nim wrappers for the functions defined in librln +## Nim wrappers for librln (zerokit v2.0.2, safer-ffi typed handles). +## +## Built against the `stateless` zerokit feature: tree-mutation FFI is not +## bound here because logos-delivery does not maintain a local Merkle tree +## (post-PR #3312); the WakuRlnV2 contract is the source of truth and the +## per-index Merkle path is fetched via getMerkleProof(index). +## +## Memory model: every CResult.err must be checked with `hasError` and +## consumed via `consumeError`. Every CFr / Vec_CFr / Vec_uint8 returned by +## the FFI owns memory the caller must release with the corresponding +## ffi_*_free. Use `defer:` immediately after acquisition. +## +## Wire format (v2.0.2 single-message-id): +## RLNProof: [ 0x00 | proof<128> | RLNProofValues(0x00) ] +## RLNProofValues: [ 0x00 | root<32> | external_nullifier<32> | +## x<32> | y<32> | nullifier<32> ] +## Total RLNProof byte size: 1 + 128 + 1 + 5*32 = 290 bytes. + +import results import ../protocol_types -{.push raises: [].} +{.push raises: [], gcsafe.} -## Buffer struct is taken from -# https://github.com/celo-org/celo-threshold-bls-rs/blob/master/crates/threshold-bls-ffi/src/ffi.rs -type Buffer* = object - `ptr`*: ptr uint8 - len*: uint +# --- Types ------------------------------------------------------------------ -proc toBuffer*(x: openArray[byte]): Buffer = - ## converts the input to a Buffer object - ## the Buffer object is used to communicate data with the rln lib - var temp = @x - let baseAddr = cast[pointer](x) - let output = Buffer(`ptr`: cast[ptr uint8](baseAddr), len: uint(temp.len)) - return output +type + CSize = csize_t -###################################################################### -## RLN Zerokit module APIs -###################################################################### + CFr* = object ## opaque ark_bn254::Fr handle + FFI_RLNProof* = object + FFI_RLNPartialProof* = object + FFI_RLNWitnessInput* = object + FFI_RLNPartialWitnessInput* = object + FFI_RLNProofValues* = object -#-------------------------------- zkSNARKs operations ----------------------------------------- -proc key_gen*( - output_buffer: ptr Buffer, is_little_endian: bool -): bool {.importc: "extended_key_gen".} + Vec_CFr* = object + dataPtr*: ptr CFr + len*: CSize + cap*: CSize -## generates identity trapdoor, identity nullifier, identity secret hash and id commitment tuple serialized inside output_buffer as | identity_trapdoor<32> | identity_nullifier<32> | identity_secret_hash<32> | id_commitment<32> | -## identity secret hash is the poseidon hash of [identity_trapdoor, identity_nullifier] -## id commitment is the poseidon hash of the identity secret hash -## the return bool value indicates the success or failure of the operation + Vec_uint8* = object + dataPtr*: ptr uint8 + len*: CSize + cap*: CSize -proc seeded_key_gen*( - input_buffer: ptr Buffer, output_buffer: ptr Buffer, is_little_endian: bool -): bool {.importc: "seeded_extended_key_gen".} + # CResult variants — safer-ffi lowers Result to a struct of + # (ok: T-or-null, err: Vec_uint8-or-null). Exactly one is populated. + CBoolResult* = object + ok*: bool + err*: Vec_uint8 -## generates identity trapdoor, identity nullifier, identity secret hash and id commitment tuple serialized inside output_buffer as | identity_trapdoor<32> | identity_nullifier<32> | identity_secret_hash<32> | id_commitment<32> | using ChaCha20 -## seeded with an arbitrary long seed serialized in input_buffer -## The input seed provided by the user is hashed using Keccak256 before being passed to ChaCha20 as seed. -## identity secret hash is the poseidon hash of [identity_trapdoor, identity_nullifier] -## id commitment is the poseidon hash of the identity secret hash -# use_little_endian: if true, uses big or little endian for serialization (default: true) -## the return bool value indicates the success or failure of the operation + CResultRLNPtrVecU8* = object + ok*: ptr RLN + err*: Vec_uint8 -proc generate_proof*( - ctx: ptr RLN, input_buffer: ptr Buffer, output_buffer: ptr Buffer -): bool {.importc: "generate_rln_proof".} + CResultCFrPtrVecU8* = object + ok*: ptr CFr + err*: Vec_uint8 -## rln-v2 -## input_buffer has to be serialized as [ identity_secret<32> | identity_index<8> | user_message_limit<32> | message_id<32> | external_nullifier<32> | signal_len<8> | signal ] -## output_buffer holds the proof data and should be parsed as [ proof<128> | root<32> | external_nullifier<32> | share_x<32> | share_y<32> | nullifier<32> ] -## rln-v1 -## input_buffer has to be serialized as [ id_key<32> | id_index<8> | epoch<32> | signal_len<8> | signal ] -## output_buffer holds the proof data and should be parsed as [ proof<128> | root<32> | epoch<32> | share_x<32> | share_y<32> | nullifier<32> | rln_identifier<32> ] -## integers wrapped in <> indicate value sizes in bytes -## the return bool value indicates the success or failure of the operation + CResultProofPtrVecU8* = object + ok*: ptr FFI_RLNProof + err*: Vec_uint8 -proc generate_proof_with_witness*( - ctx: ptr RLN, input_buffer: ptr Buffer, output_buffer: ptr Buffer -): bool {.importc: "generate_rln_proof_with_witness".} + CResultPartialProofPtrVecU8* = object + ok*: ptr FFI_RLNPartialProof + err*: Vec_uint8 -## rln-v2 -## "witness" term refer to collection of secret inputs with proper serialization -## input_buffer has to be serialized as [ identity_secret<32> | user_message_limit<32> | message_id<32> | path_elements> | identity_path_index> | x<32> | external_nullifier<32> ] -## output_buffer holds the proof data and should be parsed as [ proof<128> | root<32> | external_nullifier<32> | share_x<32> | share_y<32> | nullifier<32> ] -## rln-v1 -## input_buffer has to be serialized as [ id_key<32> | path_elements> | identity_path_index> | x<32> | epoch<32> | rln_identifier<32> ] -## output_buffer holds the proof data and should be parsed as [ proof<128> | root<32> | epoch<32> | share_x<32> | share_y<32> | nullifier<32> | rln_identifier<32> ] -## integers wrapped in <> indicate value sizes in bytes -## path_elements and identity_path_index serialize a merkle proof and are vectors of elements of 32 and 1 bytes respectively -## the return bool value indicates the success or failure of the operation + CResultWitnessInputPtrVecU8* = object + ok*: ptr FFI_RLNWitnessInput + err*: Vec_uint8 -proc verify*( - ctx: ptr RLN, proof_buffer: ptr Buffer, proof_is_valid_ptr: ptr bool -): bool {.importc: "verify_rln_proof".} + CResultPartialWitnessInputPtrVecU8* = object + ok*: ptr FFI_RLNPartialWitnessInput + err*: Vec_uint8 -## rln-v2 -## proof_buffer has to be serialized as [ proof<128> | root<32> | external_nullifier<32> | share_x<32> | share_y<32> | nullifier<32> | signal_len<8> | signal ] -## rln-v1 -## ## proof_buffer has to be serialized as [ proof<128> | root<32> | epoch<32> | share_x<32> | share_y<32> | nullifier<32> | rln_identifier<32> | signal_len<8> | signal ] -## the return bool value indicates the success or failure of the call to the verify function -## the verification of the zk proof is available in proof_is_valid_ptr, where a value of true indicates success and false a failure + CResultVecCFrVecU8* = object + ok*: Vec_CFr + err*: Vec_uint8 -proc verify_with_roots*( - ctx: ptr RLN, - proof_buffer: ptr Buffer, - roots_buffer: ptr Buffer, - proof_is_valid_ptr: ptr bool, -): bool {.importc: "verify_with_roots".} + CResultVecU8VecU8* = object + ok*: Vec_uint8 + err*: Vec_uint8 -## rln-v2 -## proof_buffer has to be serialized as [ proof<128> | root<32> | external_nullifier<32> | share_x<32> | share_y<32> | nullifier<32> | signal_len<8> | signal ] -## rln-v1 -## proof_buffer has to be serialized as [ proof<128> | root<32> | epoch<32> | share_x<32> | share_y<32> | nullifier<32> | rln_identifier<32> | signal_len<8> | signal ] -## roots_buffer contains the concatenation of 32 bytes long serializations in little endian of root values -## the return bool value indicates the success or failure of the call to the verify function -## the verification of the zk proof is available in proof_is_valid_ptr, where a value of true indicates success and false a failure +const + FieldElementSize* = 32 + ZksnarkProofSize* = 128 + ## Single-message-id serialized RLNProof size: outer version + proof + ## + inner RLNProofValues (inner version + 5 field elements). + RlnProofWireSize* = 1 + ZksnarkProofSize + 1 + 5 * FieldElementSize -proc zk_prove*( - ctx: ptr RLN, input_buffer: ptr Buffer, output_buffer: ptr Buffer -): bool {.importc: "prove".} +# FFI declarations — source of truth: vendor/zerokit/rln/src/ffi/{ffi_rln,ffi_utils}.rs -## Computes the zkSNARK proof and stores it in output_buffer for input values stored in input_buffer -## rln-v2 -## input_buffer is serialized as input_data as [ identity_secret<32> | user_message_limit<32> | message_id<32> | path_elements> | identity_path_index> | x<32> | external_nullifier<32> ] -## rln-v1 -## input_buffer is serialized as input_data as [ id_key<32> | path_elements> | identity_path_index> | x<32> | epoch<32> | rln_identifier<32> ] -## output_buffer holds the proof data and should be parsed as [ proof<128> ] -## path_elements and indentity_path elements serialize a merkle proof for id_key and are vectors of elements of 32 and 1 bytes, respectively (not. Vec<>). -## x is the x coordinate of the Shamir's secret share for which the proof is computed -## epoch is the input epoch (equivalently, the nullifier) -## the return bool value indicates the success or failure of the operation +# --- RLN instance lifecycle (stateless variants) -------------------------- -proc zk_verify*( - ctx: ptr RLN, proof_buffer: ptr Buffer, proof_is_valid_ptr: ptr bool -): bool {.importc: "verify".} +proc ffi_rln_new*(): CResultRLNPtrVecU8 {.importc: "ffi_rln_new", cdecl.} -## Verifies the zkSNARK proof passed in proof_buffer -## input_buffer is serialized as input_data as [ proof<128> ] -## the verification of the zk proof is available in proof_is_valid_ptr, where a value of true indicates success and false a failure -## the return bool value indicates the success or failure of the operation +proc ffi_rln_new_with_params*( + zkey_data: ptr Vec_uint8, graph_data: ptr Vec_uint8 +): CResultRLNPtrVecU8 {.importc: "ffi_rln_new_with_params", cdecl.} -#-------------------------------- Common procedures ------------------------------------------- -# stateful version -proc new_circuit*( - tree_depth: uint, input_buffer: ptr Buffer, ctx: ptr (ptr RLN) -): bool {.importc: "new".} +proc ffi_rln_free*(rln: ptr RLN) {.importc: "ffi_rln_free", cdecl.} -## creates an instance of rln object as defined by the zerokit RLN lib -## input_buffer contains a serialization of the path where the circuit resources can be found (.r1cs, .wasm, .zkey and optionally the verification_key.json) -## ctx holds the final created rln object -## the return bool value indicates the success or failure of the operation +# --- Keygen --------------------------------------------------------------- -# stateless version -proc new_circuit*(ctx: ptr (ptr RLN)): bool {.importc: "new".} +proc ffi_extended_key_gen*(): Vec_CFr {.importc: "ffi_extended_key_gen", cdecl.} -proc new_circuit_from_data*( - zkey_buffer: ptr Buffer, graph_buffer: ptr Buffer, ctx: ptr (ptr RLN) -): bool {.importc: "new_with_params".} +proc ffi_seeded_extended_key_gen*( + seed: ptr Vec_uint8 +): Vec_CFr {.importc: "ffi_seeded_extended_key_gen", cdecl.} -## creates an instance of rln object as defined by the zerokit RLN lib by passing the required inputs as byte arrays -## zkey_buffer contains the bytes read from the .zkey proving key -## graph_buffer contains the bytes read from the graph data file -## ctx holds the final created rln object -## the return bool value indicates the success or failure of the operation +# --- Witness construction ------------------------------------------------- -#-------------------------------- Hashing utils ------------------------------------------- +proc ffi_rln_witness_input_new*( + identity_secret: ptr CFr, + user_message_limit: ptr CFr, + message_id: ptr CFr, + path_elements: ptr Vec_CFr, + identity_path_index: ptr Vec_uint8, + x: ptr CFr, + external_nullifier: ptr CFr, +): CResultWitnessInputPtrVecU8 {.importc: "ffi_rln_witness_input_new", cdecl.} -proc sha256*( - input_buffer: ptr Buffer, output_buffer: ptr Buffer, is_little_endian: bool -): bool {.importc: "hash".} +proc ffi_rln_witness_input_free*( + witness: ptr FFI_RLNWitnessInput +) {.importc: "ffi_rln_witness_input_free", cdecl.} -## it hashes (sha256) the plain text supplied in inputs_buffer and then maps it to a field element -## this proc is used to map arbitrary signals to field element for the sake of proof generation -## inputs_buffer holds the hash input as a byte seq -## the hash output is generated and populated inside output_buffer -## the output_buffer contains 32 bytes hash output +proc ffi_rln_partial_witness_input_new*( + identity_secret: ptr CFr, + user_message_limit: ptr CFr, + path_elements: ptr Vec_CFr, + identity_path_index: ptr Vec_uint8, +): CResultPartialWitnessInputPtrVecU8 {. + importc: "ffi_rln_partial_witness_input_new", cdecl +.} -proc poseidon*( - input_buffer: ptr Buffer, output_buffer: ptr Buffer, is_little_endian: bool -): bool {.importc: "poseidon_hash".} +proc ffi_rln_partial_witness_input_free*( + witness: ptr FFI_RLNPartialWitnessInput +) {.importc: "ffi_rln_partial_witness_input_free", cdecl.} -## it hashes (poseidon) the plain text supplied in inputs_buffer -## this proc is used to compute the identity secret hash, and external nullifier -## inputs_buffer holds the hash input as a byte seq -## the hash output is generated and populated inside output_buffer -## the output_buffer contains 32 bytes hash output +# --- Proof generation ----------------------------------------------------- +# safer-ffi's repr_c::Box lands on the Nim side as `ptr ptr T`. Call sites +# pass `addr handle` where `handle` is `ptr T`. + +proc ffi_generate_rln_proof*( + rln: ptr ptr RLN, witness: ptr ptr FFI_RLNWitnessInput +): CResultProofPtrVecU8 {.importc: "ffi_generate_rln_proof", cdecl.} + +proc ffi_generate_partial_zk_proof*( + rln: ptr ptr RLN, partial_witness: ptr ptr FFI_RLNPartialWitnessInput +): CResultPartialProofPtrVecU8 {.importc: "ffi_generate_partial_zk_proof", cdecl.} + +proc ffi_finish_rln_proof*( + rln: ptr ptr RLN, + partial_proof: ptr ptr FFI_RLNPartialProof, + witness: ptr ptr FFI_RLNWitnessInput, +): CResultProofPtrVecU8 {.importc: "ffi_finish_rln_proof", cdecl.} + +# --- Verification --------------------------------------------------------- + +proc ffi_verify_with_roots*( + rln: ptr ptr RLN, proof: ptr ptr FFI_RLNProof, roots: ptr Vec_CFr, x: ptr CFr +): CBoolResult {.importc: "ffi_verify_with_roots", cdecl.} + +# --- Proof serialization -------------------------------------------------- + +proc ffi_rln_proof_to_bytes_le*( + proof: ptr ptr FFI_RLNProof +): CResultVecU8VecU8 {.importc: "ffi_rln_proof_to_bytes_le", cdecl.} + +proc ffi_bytes_le_to_rln_proof*( + bytes: ptr Vec_uint8 +): CResultProofPtrVecU8 {.importc: "ffi_bytes_le_to_rln_proof", cdecl.} + +# v2.0.2: construct an RLNProof directly from its field elements (single +# message-id variant), avoiding the manual 290-byte wire layout. +proc ffi_rln_proof_new*( + groth16Bytes: ptr Vec_uint8, + root: ptr CFr, + externalNullifier: ptr CFr, + x: ptr CFr, + y: ptr CFr, + nullifier: ptr CFr, +): CResultProofPtrVecU8 {.importc: "ffi_rln_proof_new", cdecl.} + +proc ffi_rln_proof_free*(p: ptr FFI_RLNProof) {.importc: "ffi_rln_proof_free", cdecl.} + +proc ffi_rln_partial_proof_to_bytes_le*( + partial_proof: ptr ptr FFI_RLNPartialProof +): CResultVecU8VecU8 {.importc: "ffi_rln_partial_proof_to_bytes_le", cdecl.} + +proc ffi_bytes_le_to_rln_partial_proof*( + bytes: ptr Vec_uint8 +): CResultPartialProofPtrVecU8 {.importc: "ffi_bytes_le_to_rln_partial_proof", cdecl.} + +proc ffi_rln_partial_proof_free*( + p: ptr FFI_RLNPartialProof +) {.importc: "ffi_rln_partial_proof_free", cdecl.} + +# --- Proof values (extract root / x / y / nullifier from a proof) --------- + +proc ffi_rln_proof_get_values*( + proof: ptr ptr FFI_RLNProof +): ptr FFI_RLNProofValues {.importc: "ffi_rln_proof_get_values", cdecl.} + +proc ffi_rln_proof_values_get_root*( + pv: ptr ptr FFI_RLNProofValues +): ptr CFr {.importc: "ffi_rln_proof_values_get_root", cdecl.} + +proc ffi_rln_proof_values_get_x*( + pv: ptr ptr FFI_RLNProofValues +): ptr CFr {.importc: "ffi_rln_proof_values_get_x", cdecl.} + +proc ffi_rln_proof_values_get_external_nullifier*( + pv: ptr ptr FFI_RLNProofValues +): ptr CFr {.importc: "ffi_rln_proof_values_get_external_nullifier", cdecl.} + +proc ffi_rln_proof_values_get_y*( + pv: ptr ptr FFI_RLNProofValues +): CResultCFrPtrVecU8 {.importc: "ffi_rln_proof_values_get_y", cdecl.} + +proc ffi_rln_proof_values_get_nullifier*( + pv: ptr ptr FFI_RLNProofValues +): CResultCFrPtrVecU8 {.importc: "ffi_rln_proof_values_get_nullifier", cdecl.} + +proc ffi_rln_proof_values_free*( + pv: ptr FFI_RLNProofValues +) {.importc: "ffi_rln_proof_values_free", cdecl.} + +# --- Slashing ------------------------------------------------------------- + +proc ffi_compute_id_secret*( + share1_x: ptr CFr, share1_y: ptr CFr, share2_x: ptr CFr, share2_y: ptr CFr +): CResultCFrPtrVecU8 {.importc: "ffi_compute_id_secret", cdecl.} + +# --- Primitives: CFr ------------------------------------------------------ + +proc ffi_cfr_zero*(): ptr CFr {.importc: "ffi_cfr_zero", cdecl.} + +proc ffi_cfr_to_bytes_le*( + cfr: ptr CFr +): Vec_uint8 {.importc: "ffi_cfr_to_bytes_le", cdecl.} + +proc ffi_bytes_le_to_cfr*( + bytes: ptr Vec_uint8 +): CResultCFrPtrVecU8 {.importc: "ffi_bytes_le_to_cfr", cdecl.} + +proc ffi_cfr_free*(cfr: ptr CFr) {.importc: "ffi_cfr_free", cdecl.} + +# --- Primitives: Vec_CFr -------------------------------------------------- + +proc ffi_vec_cfr_new*(capacity: CSize): Vec_CFr {.importc: "ffi_vec_cfr_new", cdecl.} + +proc ffi_vec_cfr_push*( + v: ptr Vec_CFr, cfr: ptr CFr +) {.importc: "ffi_vec_cfr_push", cdecl.} + +proc ffi_vec_cfr_len*(v: ptr Vec_CFr): CSize {.importc: "ffi_vec_cfr_len", cdecl.} + +proc ffi_vec_cfr_get*( + v: ptr Vec_CFr, i: CSize +): ptr CFr {.importc: "ffi_vec_cfr_get", cdecl.} + +proc ffi_vec_cfr_free*(v: Vec_CFr) {.importc: "ffi_vec_cfr_free", cdecl.} + +# --- Primitives: Vec_uint8 ------------------------------------------------ + +proc ffi_vec_u8_free*(v: Vec_uint8) {.importc: "ffi_vec_u8_free", cdecl.} + +proc ffi_c_string_free*(s: Vec_uint8) {.importc: "ffi_c_string_free", cdecl.} + +# --- Hash helpers --------------------------------------------------------- + +proc ffi_hash_to_field_le*( + input: ptr Vec_uint8 +): ptr CFr {.importc: "ffi_hash_to_field_le", cdecl.} + +proc ffi_poseidon_hash_pair*( + a: ptr CFr, b: ptr CFr +): ptr CFr {.importc: "ffi_poseidon_hash_pair", cdecl.} + +# --- Memory-hygiene helpers ------------------------------------------------- + +proc hasError*(data: Vec_uint8): bool = + not data.dataPtr.isNil + +proc asString*(data: Vec_uint8): string = + if data.dataPtr.isNil or data.len == 0: + return "" + result = newString(int(data.len)) + copyMem(addr result[0], data.dataPtr, int(data.len)) + +proc consumeError*(prefix: string, data: Vec_uint8): string = + ## Read an error string out of a Rust-owned Vec_uint8 AND free it. + let msg = asString(data) + if hasError(data): + ffi_c_string_free(data) + if prefix.len == 0: + msg + elif msg.len == 0: + prefix + else: + prefix & msg + +proc toVecUint8*(data: openArray[byte]): Vec_uint8 = + ## Wrap Nim-owned bytes as a Vec_uint8 view. NOTE: the resulting Vec_uint8 + ## must NOT be passed to ffi_vec_u8_free — Nim retains ownership. + if data.len == 0: + return Vec_uint8(dataPtr: nil, len: 0, cap: 0) + Vec_uint8( + dataPtr: cast[ptr uint8](unsafeAddr data[0]), + len: CSize(data.len), + cap: CSize(data.len), + ) + +proc vecToSeq*(data: Vec_uint8): seq[byte] = + result = newSeq[byte](int(data.len)) + if result.len > 0: + copyMem(addr result[0], data.dataPtr, result.len) + +proc seqToFixed32*(data: openArray[byte]): RlnRelayResult[array[32, byte]] = + if data.len != FieldElementSize: + return err("Expected 32 bytes, got " & $data.len) + var output: array[32, byte] + copyMem(addr output[0], unsafeAddr data[0], FieldElementSize) + ok(output) + +proc cfrToBytesLe*(cfr: ptr CFr): RlnRelayResult[array[32, byte]] = + let bytes = ffi_cfr_to_bytes_le(cfr) + defer: + ffi_vec_u8_free(bytes) + if int(bytes.len) != FieldElementSize: + return err("Invalid field byte length: " & $bytes.len) + seqToFixed32(vecToSeq(bytes)) + +proc bytesToCfrLe*(data: openArray[byte]): RlnRelayResult[ptr CFr] = + ## Allocate a ptr CFr from raw bytes. Caller MUST ffi_cfr_free(x). + var vec = toVecUint8(data) + let res = ffi_bytes_le_to_cfr(addr vec) + if not res.ok.isNil: + return ok(res.ok) + err(consumeError("Failed to convert bytes to field: ", res.err)) + +proc cfrResultToBytes*( + res: CResultCFrPtrVecU8, prefix: string +): RlnRelayResult[array[32, byte]] = + ## Consume a CResultCFrPtrVecU8: read bytes if ok, free the CFr, or + ## propagate the error (also freeing the error string). + if res.ok.isNil: + return err(consumeError(prefix, res.err)) + defer: + ffi_cfr_free(res.ok) + cfrToBytesLe(res.ok) + +proc hashToFieldLe*(data: openArray[byte]): RlnRelayResult[ptr CFr] = + ## Caller MUST ffi_cfr_free the returned ptr. + var vec = toVecUint8(data) + let cfr = ffi_hash_to_field_le(addr vec) + if cfr.isNil: + return err("Failed to hash to field") + ok(cfr) + +proc poseidonPairLe*(a, b: openArray[byte]): RlnRelayResult[array[32, byte]] = + ## Poseidon hash of exactly two 32-byte field elements (little-endian). + ## zerokit v2 FFI only exposes pair-input Poseidon; unary is not supported. + let aPtr = bytesToCfrLe(a).valueOr: + return err(error) + defer: + ffi_cfr_free(aPtr) + let bPtr = bytesToCfrLe(b).valueOr: + return err(error) + defer: + ffi_cfr_free(bPtr) + let cfr = ffi_poseidon_hash_pair(aPtr, bPtr) + if cfr.isNil: + return err("Poseidon hash failed") + defer: + ffi_cfr_free(cfr) + cfrToBytesLe(cfr) diff --git a/waku/waku_rln_relay/rln/wrappers.nim b/waku/waku_rln_relay/rln/wrappers.nim index f6f001d70..4fc8c1542 100644 --- a/waku/waku_rln_relay/rln/wrappers.nim +++ b/waku/waku_rln_relay/rln/wrappers.nim @@ -1,140 +1,149 @@ -import std/json -import - chronicles, - options, - eth/keys, - stew/[arrayops, byteutils, endians2], - stint, - results, - std/[sequtils, strutils, tables], - nimcrypto/keccak as keccak +import chronicles, eth/keys, stew/[arrayops, endians2], stint, results import ./rln_interface, ../conversion_utils, ../protocol_types, ../protocol_metrics import ../../waku_core, ../../waku_keystore +{.push raises: [], gcsafe.} + logScope: topics = "waku rln_relay ffi" -proc membershipKeyGen*(): RlnRelayResult[IdentityCredential] = - ## generates a IdentityCredential that can be used for the registration into the rln membership contract - ## Returns an error if the key generation fails +# Forward decl; body defined below. +proc generateExternalNullifier*( + epoch: Epoch, rlnIdentifier: RlnIdentifier +): RlnRelayResult[ExternalNullifier] - # keysBufferPtr will hold the generated identity tuple i.e., trapdoor, nullifier, secret hash and commitment - var - keysBuffer: Buffer - keysBufferPtr = addr(keysBuffer) - done = key_gen(keysBufferPtr, true) +proc toRootVec(validRoots: seq[MerkleNode]): RlnRelayResult[Vec_CFr] = + ## Caller MUST ffi_vec_cfr_free the returned Vec_CFr. + var roots = ffi_vec_cfr_new(csize_t(validRoots.len)) + for root in validRoots: + let cfr = bytesToCfrLe(root).valueOr: + ffi_vec_cfr_free(roots) + return err("failed call to bytesToCfrLe in toRootVec: " & error) + ffi_vec_cfr_push(addr roots, cfr) + ffi_cfr_free(cfr) + ok(roots) - # check whether the keys are generated successfully - if (done == false): - return err("error in key generation") +proc proofPtrToRateLimitProof( + proofPtr: ptr FFI_RLNProof, epoch: Epoch, rlnIdentifier: RlnIdentifier +): RlnRelayResult[RateLimitProof] = + var proofHandle = proofPtr + let proofBytesRes = ffi_rln_proof_to_bytes_le(addr proofHandle) + if hasError(proofBytesRes.err): + return err(consumeError("Failed to serialize proof: ", proofBytesRes.err)) + defer: + ffi_vec_u8_free(proofBytesRes.ok) - if (keysBuffer.len != 4 * 32): - return err("keysBuffer is of invalid length") + let serialized = vecToSeq(proofBytesRes.ok) + if serialized.len < RlnProofWireSize: + return err("Serialized proof too short: " & $serialized.len) - var generatedKeys = cast[ptr array[4 * 32, byte]](keysBufferPtr.`ptr`)[] - # the public and secret keys together are 64 bytes + let proofValues = ffi_rln_proof_get_values(addr proofHandle) + if proofValues.isNil(): + return err("Failed to extract proof values") + defer: + ffi_rln_proof_values_free(proofValues) - # TODO define a separate proc to decode the generated keys to the secret and public components - var - idTrapdoor: array[32, byte] - idNullifier: array[32, byte] - idSecretHash: array[32, byte] - idCommitment: array[32, byte] - for (i, x) in idTrapdoor.mpairs: - x = generatedKeys[i + 0 * 32] - for (i, x) in idNullifier.mpairs: - x = generatedKeys[i + 1 * 32] - for (i, x) in idSecretHash.mpairs: - x = generatedKeys[i + 2 * 32] - for (i, x) in idCommitment.mpairs: - x = generatedKeys[i + 3 * 32] + var output: RateLimitProof + output.epoch = epoch + output.rlnIdentifier = rlnIdentifier - var identityCredential = IdentityCredential( - idTrapdoor: @idTrapdoor, - idNullifier: @idNullifier, - idSecretHash: @idSecretHash, - idCommitment: @idCommitment, + # zkSNARK bytes: skip the leading version byte, take 128. + copyMem(addr output.proof[0], unsafeAddr serialized[1], ZksnarkProofSize) + + var pvHandle = proofValues + + let rootPtr = ffi_rln_proof_values_get_root(addr pvHandle) + if rootPtr.isNil(): + return err("Failed to read proof root") + defer: + ffi_cfr_free(rootPtr) + output.merkleRoot = cfrToBytesLe(rootPtr).valueOr: + return + err("failed call to cfrToBytesLe (root) in proofPtrToRateLimitProof: " & error) + + let xPtr = ffi_rln_proof_values_get_x(addr pvHandle) + if xPtr.isNil(): + return err("Failed to read proof x") + defer: + ffi_cfr_free(xPtr) + output.shareX = cfrToBytesLe(xPtr).valueOr: + return + err("failed call to cfrToBytesLe (shareX) in proofPtrToRateLimitProof: " & error) + + let yRes = ffi_rln_proof_values_get_y(addr pvHandle) + output.shareY = cfrResultToBytes(yRes, "Failed to read proof y: ").valueOr: + return err(error) + + let nullifierRes = ffi_rln_proof_values_get_nullifier(addr pvHandle) + output.nullifier = cfrResultToBytes(nullifierRes, "Failed to read proof nullifier: ").valueOr: + return err(error) + + let extNullPtr = ffi_rln_proof_values_get_external_nullifier(addr pvHandle) + if extNullPtr.isNil(): + return err("Failed to read proof external nullifier") + defer: + ffi_cfr_free(extNullPtr) + output.externalNullifier = cfrToBytesLe(extNullPtr).valueOr: + return err( + "failed call to cfrToBytesLe (externalNullifier) in proofPtrToRateLimitProof: " & + error + ) + + ok(output) + +proc parseCredentialVec(vec: var Vec_CFr): RlnRelayResult[IdentityCredential] = + ## Vec_CFr order: idTrapdoor, idNullifier, idSecretHash, idCommitment. + if int(ffi_vec_cfr_len(addr vec)) != 4: + return err("Unexpected credential element count") + + template readField(idx: int): seq[byte] = + let f = ffi_vec_cfr_get(addr vec, csize_t(idx)) + if f.isNil(): + return err("Missing credential field from zerokit") + let bytes = cfrToBytesLe(f).valueOr: + return err("failed call to cfrToBytesLe in parseCredentialVec: " & error) + @bytes + + let idTrapdoor = readField(0) + let idNullifier = readField(1) + let idSecretHash = readField(2) + let idCommitment = readField(3) + + return ok( + IdentityCredential( + idTrapdoor: idTrapdoor, + idNullifier: idNullifier, + idSecretHash: idSecretHash, + idCommitment: idCommitment, + ) ) - return ok(identityCredential) - -type RlnTreeConfig = ref object of RootObj - cache_capacity: int - mode: string - compression: bool - flush_every_ms: int - -type RlnConfig = ref object of RootObj - resources_folder: string - tree_config: RlnTreeConfig - -proc `%`(c: RlnConfig): JsonNode = - ## wrapper around the generic JObject constructor. - ## We don't need to have a separate proc for the tree_config field - let tree_config = %{ - "cache_capacity": %c.tree_config.cache_capacity, - "mode": %c.tree_config.mode, - "compression": %c.tree_config.compression, - "flush_every_ms": %c.tree_config.flush_every_ms, - } - return %[("resources_folder", %c.resources_folder), ("tree_config", %tree_config)] +proc membershipKeyGen*(): RlnRelayResult[IdentityCredential] = + var vec = ffi_extended_key_gen() + defer: + ffi_vec_cfr_free(vec) + parseCredentialVec(vec) proc createRLNInstanceLocal(): RLNResult = - ## generates an instance of RLN - ## An RLN instance supports both zkSNARKs logics and Merkle tree data structure and operations - ## Returns an error if the instance creation fails - - let rln_config = RlnConfig( - resources_folder: "tree_height_/", - tree_config: RlnTreeConfig( - cache_capacity: 15_000, - mode: "high_throughput", - compression: false, - flush_every_ms: 500, - ), - ) - - var serialized_rln_config = $(%rln_config) - - var - rlnInstance: ptr RLN - merkleDepth: csize_t = uint(20) - configBuffer = - serialized_rln_config.toOpenArrayByte(0, serialized_rln_config.high).toBuffer() - - # create an instance of RLN - let res = new_circuit(merkleDepth, addr configBuffer, addr rlnInstance) - # check whether the circuit parameters are generated successfully - if (res == false): - info "error in parameters generation" - return err("error in parameters generation") - return ok(rlnInstance) + ## Creates a stateless RLN instance (no local Merkle tree). + let res = ffi_rln_new() + if res.ok.isNil(): + let msg = consumeError("error in parameters generation: ", res.err) + info "error in parameters generation", err = msg + return err(msg) + ok(res.ok) proc createRLNInstance*(): RLNResult = - ## Wraps the rln instance creation for metrics - ## Returns an error if the instance creation fails + ## Wraps createRLNInstanceLocal with metrics timing. var res: RLNResult waku_rln_instance_creation_duration_seconds.nanosecondTime: res = createRLNInstanceLocal() return res -proc poseidon*(data: seq[seq[byte]]): RlnRelayResult[array[32, byte]] = - ## a thin layer on top of the Nim wrapper of the poseidon hasher - var inputBytes = serialize(data) - var - hashInputBuffer = inputBytes.toBuffer() - outputBuffer: Buffer # will holds the hash output - - let hashSuccess = poseidon(addr hashInputBuffer, addr outputBuffer, true) - - # check whether the hash call is done successfully - if not hashSuccess: - return err("error in poseidon hash") - - let output = cast[ptr array[32, byte]](outputBuffer.`ptr`)[] - - return ok(output) +proc poseidon*(left, right: seq[byte]): RlnRelayResult[array[32, byte]] = + ## Poseidon hash of exactly 2 inputs; zerokit v2 FFI only exposes the pair variant. + poseidonPairLe(left, right) proc toLeaf*(rateCommitment: RateCommitment): RlnRelayResult[seq[byte]] = let idCommitment = rateCommitment.idCommitment @@ -147,7 +156,7 @@ proc toLeaf*(rateCommitment: RateCommitment): RlnRelayResult[seq[byte]] = return err( "could not convert the user message limit to bytes: " & getCurrentExceptionMsg() ) - let leaf = poseidon(@[@idCommitment, @userMessageLimit]).valueOr: + let leaf = poseidon(@idCommitment, @userMessageLimit).valueOr: return err("could not convert the rate commitment to a leaf") var retLeaf = newSeq[byte](leaf.len) for i in 0 ..< leaf.len: @@ -165,11 +174,24 @@ proc toLeaves*(rateCommitments: seq[RateCommitment]): RlnRelayResult[seq[seq[byt proc generateExternalNullifier*( epoch: Epoch, rlnIdentifier: RlnIdentifier ): RlnRelayResult[ExternalNullifier] = - let epochHash = keccak.keccak256.digest(@(epoch)) - let rlnIdentifierHash = keccak.keccak256.digest(@(rlnIdentifier)) - let externalNullifier = poseidon(@[@(epochHash), @(rlnIdentifierHash)]).valueOr: - return err("Failed to compute external nullifier: " & error) - return ok(externalNullifier) + ## externalNullifier = Poseidon(H(epoch), H(rlnIdentifier)); H = ffi_hash_to_field_le. + let epochFr = hashToFieldLe(@epoch).valueOr: + return err("Failed to hash epoch to field: " & error) + defer: + ffi_cfr_free(epochFr) + let rlnIdFr = hashToFieldLe(@rlnIdentifier).valueOr: + return err("Failed to hash rlnIdentifier to field: " & error) + defer: + ffi_cfr_free(rlnIdFr) + let cfr = ffi_poseidon_hash_pair(epochFr, rlnIdFr) + if cfr.isNil(): + return err("Failed to compute external nullifier") + defer: + ffi_cfr_free(cfr) + cfrToBytesLe(cfr).mapErr( + proc(e: string): string = + "Failed to serialize external nullifier: " & e + ) proc extractMetadata*(proof: RateLimitProof): RlnRelayResult[ProofMetadata] = let externalNullifier = generateExternalNullifier(proof.epoch, proof.rlnIdentifier).valueOr: @@ -182,3 +204,178 @@ proc extractMetadata*(proof: RateLimitProof): RlnRelayResult[ProofMetadata] = externalNullifier: externalNullifier, ) ) + +proc buildPathElementsVec( + pathElements: seq[byte], depth: int +): RlnRelayResult[Vec_CFr] = + ## Caller MUST ffi_vec_cfr_free the returned Vec_CFr. + var vec = ffi_vec_cfr_new(csize_t(depth)) + for i in 0 ..< depth: + let start = i * FieldElementSize + let element = bytesToCfrLe( + pathElements.toOpenArray(start, start + FieldElementSize - 1) + ).valueOr: + ffi_vec_cfr_free(vec) + return err( + "failed call to bytesToCfrLe (path element) in buildPathElementsVec: " & error + ) + ffi_vec_cfr_push(addr vec, element) + ffi_cfr_free(element) + ok(vec) + +proc buildWitnessInput( + witness: RLNWitnessInput +): RlnRelayResult[ptr FFI_RLNWitnessInput] = + ## ffi_rln_witness_input_new copies all inputs, so the intermediate CFrs/vecs + ## are freed here. Caller MUST ffi_rln_witness_input_free the returned handle. + let depth = witness.identity_path_index.len + if witness.path_elements.len != depth * FieldElementSize: + return err( + "Invalid Merkle path: expected " & $(depth * FieldElementSize) & " bytes for " & + $depth & " levels, got " & $witness.path_elements.len + ) + + var pathElementsVec = buildPathElementsVec(witness.path_elements, depth).valueOr: + return err("failed call to buildPathElementsVec in buildWitnessInput: " & error) + defer: + ffi_vec_cfr_free(pathElementsVec) + + var pathIndexVec = toVecUint8(witness.identity_path_index) + + let identitySecret = bytesToCfrLe(witness.identity_secret).valueOr: + return err( + "failed call to bytesToCfrLe (identity_secret) in buildWitnessInput: " & error + ) + defer: + ffi_cfr_free(identitySecret) + let userLimit = bytesToCfrLe(witness.user_message_limit).valueOr: + return err( + "failed call to bytesToCfrLe (user_message_limit) in buildWitnessInput: " & error + ) + defer: + ffi_cfr_free(userLimit) + let messageIdFr = bytesToCfrLe(witness.message_id).valueOr: + return + err("failed call to bytesToCfrLe (message_id) in buildWitnessInput: " & error) + defer: + ffi_cfr_free(messageIdFr) + let xFr = bytesToCfrLe(witness.x).valueOr: + return err("failed call to bytesToCfrLe (x) in buildWitnessInput: " & error) + defer: + ffi_cfr_free(xFr) + let externalNullifierFr = bytesToCfrLe(witness.external_nullifier).valueOr: + return err( + "failed call to bytesToCfrLe (external_nullifier) in buildWitnessInput: " & error + ) + defer: + ffi_cfr_free(externalNullifierFr) + + let witnessRes = ffi_rln_witness_input_new( + identitySecret, + userLimit, + messageIdFr, + addr pathElementsVec, + addr pathIndexVec, + xFr, + externalNullifierFr, + ) + if witnessRes.ok.isNil(): + return err( + consumeError("Failed to create witness in buildWitnessInput: ", witnessRes.err) + ) + return ok(witnessRes.ok) + +proc generateRlnProofWithWitness*( + rlnInstance: ptr RLN, + witness: RLNWitnessInput, + epoch: Epoch, + rlnIdentifier: RlnIdentifier, +): RlnRelayResult[RateLimitProof] = + let witnessHandle = buildWitnessInput(witness).valueOr: + return + err("failed call to buildWitnessInput in generateRlnProofWithWitness: " & error) + defer: + ffi_rln_witness_input_free(witnessHandle) + + var ctx = rlnInstance + var wh = witnessHandle + let proofRes = ffi_generate_rln_proof(addr ctx, addr wh) + if proofRes.ok.isNil(): + return err(consumeError("Failed to generate RLN proof: ", proofRes.err)) + defer: + ffi_rln_proof_free(proofRes.ok) + + return proofPtrToRateLimitProof(proofRes.ok, epoch, rlnIdentifier) + +proc buildRlnProof( + proof: RateLimitProof, externalNullifier: ExternalNullifier +): RlnRelayResult[ptr FFI_RLNProof] = + ## ffi_rln_proof_new copies all inputs, so the intermediate CFrs are freed + ## here. Caller MUST ffi_rln_proof_free the returned handle. + var groth16Vec = toVecUint8(proof.proof) + let rootFr = bytesToCfrLe(proof.merkleRoot).valueOr: + return err("failed call to bytesToCfrLe (root) in buildRlnProof: " & error) + defer: + ffi_cfr_free(rootFr) + let extNullFr = bytesToCfrLe(externalNullifier).valueOr: + return + err("failed call to bytesToCfrLe (externalNullifier) in buildRlnProof: " & error) + defer: + ffi_cfr_free(extNullFr) + let shareXFr = bytesToCfrLe(proof.shareX).valueOr: + return err("failed call to bytesToCfrLe (shareX) in buildRlnProof: " & error) + defer: + ffi_cfr_free(shareXFr) + let shareYFr = bytesToCfrLe(proof.shareY).valueOr: + return err("failed call to bytesToCfrLe (shareY) in buildRlnProof: " & error) + defer: + ffi_cfr_free(shareYFr) + let nullifierFr = bytesToCfrLe(proof.nullifier).valueOr: + return err("failed call to bytesToCfrLe (nullifier) in buildRlnProof: " & error) + defer: + ffi_cfr_free(nullifierFr) + + let proofRes = ffi_rln_proof_new( + addr groth16Vec, rootFr, extNullFr, shareXFr, shareYFr, nullifierFr + ) + if proofRes.ok.isNil(): + return + err(consumeError("Failed to build RLN proof in buildRlnProof: ", proofRes.err)) + return ok(proofRes.ok) + +proc verifyRlnProof*( + rlnInstance: ptr RLN, + proof: RateLimitProof, + signal: openArray[byte], + validRoots: seq[MerkleNode], +): RlnRelayResult[bool] = + if validRoots.len == 0: + return err("verifyRlnProof requires at least one valid root (stateless mode)") + + # externalNullifier isn't a protobuf wire field, so a received proof has it + # zeroed; recompute from epoch + rlnIdentifier. + let externalNullifier = generateExternalNullifier(proof.epoch, proof.rlnIdentifier).valueOr: + return err("failed call to generateExternalNullifier in verifyRlnProof: " & error) + + let proofHandlePtr = buildRlnProof(proof, externalNullifier).valueOr: + return err("failed call to buildRlnProof in verifyRlnProof: " & error) + defer: + ffi_rln_proof_free(proofHandlePtr) + + let xFr = hashToFieldLe(signal).valueOr: + return err("failed call to hashToFieldLe (signal) in verifyRlnProof: " & error) + defer: + ffi_cfr_free(xFr) + + var roots = toRootVec(validRoots).valueOr: + return err("failed call to toRootVec in verifyRlnProof: " & error) + defer: + ffi_vec_cfr_free(roots) + + var ctx = rlnInstance + var proofHandle = proofHandlePtr + let verifyRes = ffi_verify_with_roots(addr ctx, addr proofHandle, addr roots, xFr) + # zerokit FFI quirk: err is non-nil for all failures; free it and return the bool. + if hasError(verifyRes.err): + ffi_c_string_free(verifyRes.err) + return ok(verifyRes.ok) diff --git a/waku/waku_rln_relay/rln_relay.nim b/waku/waku_rln_relay/rln_relay.nim index ac128b5bc..7c36300b2 100644 --- a/waku/waku_rln_relay/rln_relay.nim +++ b/waku/waku_rln_relay/rln_relay.nim @@ -13,7 +13,9 @@ import libp2p/protocols/pubsub/rpc/messages, libp2p/protocols/pubsub/pubsub, results, - stew/[byteutils, arrayops] + stew/[byteutils, arrayops], + brokers/broker_context + import ./group_manager, ./rln, @@ -30,7 +32,6 @@ import waku_core, requests/rln_requests, waku_keystore, - common/broker/broker_context, ] logScope: diff --git a/waku/waku_store/client.nim b/waku/waku_store/client.nim index 5b261af47..b49662811 100644 --- a/waku/waku_store/client.nim +++ b/waku/waku_store/client.nim @@ -1,7 +1,7 @@ {.push raises: [].} import - std/[options, tables, sequtils, algorithm], + std/[options, tables, sequtils, algorithm, random], results, chronicles, chronos, @@ -33,7 +33,9 @@ proc sendStoreRequest( ): Future[StoreQueryResult] {.async, gcsafe.} = var req = request + self.peerManager.addActiveStoreRequest(connection.peerId) defer: + self.peerManager.removeActiveStoreRequest(connection.peerId) await connection.closeWithEof() if req.requestId == "": @@ -98,7 +100,8 @@ proc queryToAny*( if peers.len == 0: return err(StoreError(kind: BAD_RESPONSE, cause: "no service store peer connected")) - # Shuffle to distribute load and limit retries + # Shuffle to distribute load across store peers and limit retries + shuffle(peers) let peersToTry = peers[0 ..< min(peers.len, MaxQueryRetries)] var lastError: StoreError diff --git a/waku/waku_store/protocol.nim b/waku/waku_store/protocol.nim index 891c6a93c..17b7fb214 100644 --- a/waku/waku_store/protocol.nim +++ b/waku/waku_store/protocol.nim @@ -93,7 +93,9 @@ proc initProtocolHandler(self: WakuStore) = var resBuf: StoreResp var queryDuration: float + self.peerManager.addActiveStoreRequest(conn.peerId) defer: + self.peerManager.removeActiveStoreRequest(conn.peerId) await conn.closeWithEof() self.requestRateLimiter.checkUsageLimit(WakuStoreCodec, conn):