Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 11d959d0bd | |||
| 8a4ec953b4 | |||
| 39282c8a59 | |||
| a74106d665 | |||
| d34b398eed | |||
| b402d95e47 | |||
| 8309a89ba7 | |||
| 9ed1d80b7f | |||
| 4bd8663126 | |||
| f7ff3a4bd7 | |||
| 8f22eb2097 | |||
| 6b59fa6328 | |||
| 070464f2eb | |||
| bbcaa00f0b | |||
| 28c47ab435 | |||
| 6bd0143de4 | |||
| 8b5231fa0d | |||
| a15856bdac | |||
| b22fe98ab0 | |||
| a4ded3c008 | |||
| c446b8596a | |||
| be9d348660 | |||
| 046f80591b | |||
| 57c2c0b822 | |||
| e02bd99a43 | |||
| c45dbadd78 | |||
| f86b1deff8 | |||
| 64d03f0b2d | |||
| a410e07425 | |||
| 07953a7608 | |||
| e7a7460191 | |||
| 833c85f4ac | |||
| f0ef42fe3f | |||
| 9947635855 | |||
| f70d50933d | |||
| edf139d488 | |||
| 101a506eda | |||
| 7c0ad28f6e |
123
.claude/skills/nostr-nip-sync.md
Normal file
123
.claude/skills/nostr-nip-sync.md
Normal file
@@ -0,0 +1,123 @@
|
||||
---
|
||||
name: nostr-nip-sync
|
||||
description: Check upstream NIP changes in ./docs/nips and assess required updates to our Elixir Nostr server implementation.
|
||||
scope: project
|
||||
disable-model-invocation: false
|
||||
tags:
|
||||
- elixir
|
||||
- nostr
|
||||
- protocol
|
||||
- maintenance
|
||||
triggers:
|
||||
- "nostr nips"
|
||||
- "sync with upstream nips"
|
||||
- "check for protocol changes"
|
||||
- "review nip updates"
|
||||
---
|
||||
|
||||
You are an assistant responsible for keeping this Elixir-based Nostr server aligned with the upstream Nostr Implementation Possibilities (NIPs) specification.
|
||||
|
||||
## Goal
|
||||
|
||||
When invoked, you will:
|
||||
1. Detect upstream changes to the NIPs repository mirrored as a git submodule at `./docs/nips/`.
|
||||
2. Understand what those changes mean for a Nostr relay implementation.
|
||||
3. Decide whether they require updates to our Elixir server code, configuration, or documentation.
|
||||
4. Propose concrete implementation tasks (modules to touch, new tests, migrations, etc.) or explicitly state that no changes are required.
|
||||
|
||||
The user may optionally pass additional arguments describing context or constraints. Treat all trailing arguments as a free-form description of extra requirements or focus areas.
|
||||
|
||||
## Repository assumptions
|
||||
|
||||
- This project is an Elixir Nostr relay / server.
|
||||
- The upstream NIPs repository is included as a git submodule at `./docs/nips/`, tracking `nostr-protocol/nips` on GitHub.[web:27]
|
||||
- Our implementation aims to conform to all NIPs listed in our project documentation and `CLAUDE.md`, not necessarily every NIP in existence.
|
||||
- Our project uses standard Elixir project structure (`mix.exs`, `lib/`, `test/`, etc.).
|
||||
|
||||
If any of these assumptions appear false based on the actual repository layout, first correct your mental model and explicitly call that out to the user before proceeding.
|
||||
|
||||
## High-level workflow
|
||||
|
||||
When this skill is invoked, follow this procedure:
|
||||
|
||||
1. **Gather local context**
|
||||
- Read `CLAUDE.md` for an overview of the server’s purpose, supported NIPs, architecture, and key modules.
|
||||
- Inspect `mix.exs` and the `lib/` directory to understand the main supervision tree, key contexts, and modules related to Nostr protocol handling (parsing, persistence, filters, subscriptions, etc.).
|
||||
- Look for any existing documentation about supported NIPs (e.g. `docs/`, `README.md`, `SUPPORT.md`, or `NIPS.md`).
|
||||
|
||||
2. **Inspect the NIPs submodule**
|
||||
- Open `./docs/nips/` and identify:
|
||||
- The current git commit (`HEAD`) of the submodule.
|
||||
- The previous commit referenced by the parent repo (if accessible via diff or git history).
|
||||
- If you cannot run `git` commands directly, approximate by:
|
||||
- Listing recently modified NIP files in `./docs/nips/`.
|
||||
- Comparing file contents where possible (old vs new) if the repository history is available locally.
|
||||
- Summarize which NIPs have changed:
|
||||
- New NIPs added.
|
||||
- Existing NIPs significantly modified.
|
||||
- NIPs deprecated, withdrawn, or marked as superseded.
|
||||
|
||||
3. **Map NIP changes to implementation impact**
|
||||
For each changed NIP:
|
||||
|
||||
- Identify the NIP’s purpose (e.g. basic protocol flow, new event kinds, tags, message types, relay behaviours).[web:27]
|
||||
- Determine which aspects of our server may be affected:
|
||||
- Event validation and data model (schemas, changesets, database schema and migrations).
|
||||
- Message types and subscription protocol (WebSocket handlers, filters, back-pressure logic).
|
||||
- Authentication, rate limiting, and relay policy configuration.
|
||||
- New or changed tags, fields, or event kinds that must be supported or rejected.
|
||||
- Operational behaviours like deletions, expirations, and command results.
|
||||
- Check our codebase for references to the relevant NIP ID (e.g. `NIP-01`, `NIP-11`, etc.), related constants, or modules named after the feature (e.g. `Deletion`, `Expiration`, `RelayInfo`, `DMs`).
|
||||
|
||||
4. **Decide if changes are required**
|
||||
For each NIP change, decide between:
|
||||
|
||||
- **No action required**
|
||||
- The change is editorial, clarificatory, or fully backward compatible.
|
||||
- Our current behaviour already matches or exceeds the new requirements.
|
||||
|
||||
- **Implementation update recommended**
|
||||
- The NIP introduces a new mandatory requirement for compliant relays.
|
||||
- The NIP deprecates or changes behaviour we currently rely on.
|
||||
- The NIP adds new event kinds, tags, fields, or message flows that we intend to support.
|
||||
|
||||
- **Design decision required**
|
||||
- The NIP is optional or experimental and may not align with our goals.
|
||||
- The change requires non-trivial architectural decisions or policy updates.
|
||||
|
||||
Be explicit about your reasoning, citing concrete NIP sections and relevant code locations when possible.
|
||||
|
||||
5. **Produce an actionable report**
|
||||
|
||||
Output a structured report with these sections:
|
||||
|
||||
1. `Summary of upstream NIP changes`
|
||||
- Bullet list of changed NIPs with short descriptions.
|
||||
2. `Impact on our server`
|
||||
- For each NIP: whether it is **No action**, **Update recommended**, or **Design decision required**, with a brief justification.
|
||||
3. `Proposed implementation tasks`
|
||||
- Concrete tasks formatted as a checklist with suggested modules/files, e.g.:
|
||||
- `[ ] Add support for new event kind XYZ from NIP-XX in \`lib/nostr/events/\`.`
|
||||
- `[ ] Extend validation for tag ABC per NIP-YY in \`lib/nostr/validation/\` and tests in \`test/nostr/\`.`
|
||||
4. `Open questions / assumptions`
|
||||
- Items where you are not confident and need human confirmation.
|
||||
|
||||
When relevant, include short Elixir code skeletons or diff-style snippets to illustrate changes, but keep them focused and idiomatic.
|
||||
|
||||
## Behavioural guidelines
|
||||
|
||||
- **Be conservative with breaking changes.** If a NIP change might break existing clients or stored data, call that out clearly and recommend a migration strategy.
|
||||
- **Prefer incremental steps.** Propose small, well-scoped PR-sized tasks rather than a monolithic refactor.
|
||||
- **Respect project conventions.** Match the existing style, naming, and architectural patterns described in `CLAUDE.md` and evident in `lib/` and `test/`.
|
||||
- **Keep humans in the loop.** When unsure about how strictly to adhere to a new or optional NIP, surface the tradeoffs and suggest options instead of silently choosing one.
|
||||
|
||||
## Invocation examples
|
||||
|
||||
The skill should handle invocations like:
|
||||
|
||||
- `/nostr-nip-sync`
|
||||
- `/nostr-nip-sync check for new NIPs that affect DMs or deletion semantics`
|
||||
- `/nostr-nip-sync review changes since last submodule update and propose concrete tasks`
|
||||
|
||||
When the user provides extra text after the command, treat it as guidance for what to prioritize in your analysis (e.g. performance, privacy, specific NIPs, or components).
|
||||
|
||||
@@ -13,6 +13,7 @@ POOL_SIZE=20
|
||||
# PARRHESIA_POLICIES_AUTH_REQUIRED_FOR_WRITES=false
|
||||
# PARRHESIA_POLICIES_AUTH_REQUIRED_FOR_READS=false
|
||||
# PARRHESIA_POLICIES_MIN_POW_DIFFICULTY=0
|
||||
# PARRHESIA_SYNC_RELAY_GUARD=false
|
||||
# PARRHESIA_FEATURES_VERIFY_EVENT_SIGNATURES=true
|
||||
# PARRHESIA_METRICS_ENABLED_ON_MAIN_ENDPOINT=true
|
||||
# PARRHESIA_METRICS_PRIVATE_NETWORKS_ONLY=true
|
||||
|
||||
17
.github/workflows/ci.yaml
vendored
17
.github/workflows/ci.yaml
vendored
@@ -55,8 +55,11 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Init submodules
|
||||
run: |
|
||||
git submodule init marmot-ts
|
||||
git submodule update --recursive
|
||||
|
||||
- name: Set up Elixir + OTP
|
||||
uses: erlef/setup-beam@v1
|
||||
@@ -69,6 +72,11 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Install just
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y just
|
||||
|
||||
# Cache deps/ directory — keyed on mix.lock
|
||||
- name: Cache Mix deps
|
||||
uses: actions/cache@v4
|
||||
@@ -115,7 +123,8 @@ jobs:
|
||||
|
||||
- name: Run Node Sync E2E tests
|
||||
if: ${{ matrix.main }}
|
||||
run: mix test.node_sync_e2e
|
||||
run: just e2e node-sync
|
||||
|
||||
- name: Run Marmot E2E tests
|
||||
run: mix test.marmot_e2e
|
||||
if: ${{ matrix.main }}
|
||||
run: just e2e marmot
|
||||
|
||||
16
.github/workflows/release.yaml
vendored
16
.github/workflows/release.yaml
vendored
@@ -54,8 +54,11 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Init submodules
|
||||
run: |
|
||||
git submodule init marmot-ts
|
||||
git submodule update --recursive
|
||||
|
||||
- name: Set up Elixir + OTP
|
||||
uses: erlef/setup-beam@v1
|
||||
@@ -68,6 +71,11 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Install just
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y just
|
||||
|
||||
- name: Cache Mix deps
|
||||
uses: actions/cache@v4
|
||||
id: deps-cache
|
||||
@@ -113,10 +121,10 @@ jobs:
|
||||
run: mix test --color
|
||||
|
||||
- name: Run Node Sync E2E
|
||||
run: mix test.node_sync_e2e
|
||||
run: just e2e node-sync
|
||||
|
||||
- name: Run Marmot E2E
|
||||
run: mix test.marmot_e2e
|
||||
run: just e2e marmot
|
||||
|
||||
- name: Check for unused locked deps
|
||||
run: |
|
||||
|
||||
6
.gitmodules
vendored
6
.gitmodules
vendored
@@ -1,3 +1,9 @@
|
||||
[submodule "marmot-ts"]
|
||||
path = marmot-ts
|
||||
url = https://github.com/marmot-protocol/marmot-ts.git
|
||||
[submodule "docs/nips"]
|
||||
path = docs/nips
|
||||
url = https://github.com/nostr-protocol/nips.git
|
||||
[submodule "nix/nostr-bench"]
|
||||
path = nix/nostr-bench
|
||||
url = ssh://gitea@git.teralink.net:10322/self/nostr-bench.git
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
This is a Nostr server written using Elixir and PostgreSQL.
|
||||
|
||||
NOTE: Nostr and NIP specs are available in `~/nostr/` and `~/nips/`.
|
||||
NOTE: NIP specs are available in `./docs/nips/`.
|
||||
|
||||
## Project guidelines
|
||||
|
||||
- Use `mix precommit` alias when you are done with all changes and fix any pending issues
|
||||
- Use the already included and available `:req` (`Req`) library for HTTP requests, **avoid** `:httpoison`, `:tesla`, and `:httpc`.
|
||||
- Use semantic prefixes in commit messages (feat:, fix:, docs:, chore:, test:, build:, ci:, bench:, dev:)
|
||||
|
||||
<!-- usage-rules-start -->
|
||||
|
||||
|
||||
18
CHANGELOG.md
Normal file
18
CHANGELOG.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [0.7.0] - 2026-03-20
|
||||
|
||||
First beta release!
|
||||
|
||||
### Added
|
||||
- Configurable WebSocket keepalive support in `Parrhesia.Web.Connection`:
|
||||
- server-initiated `PING` frames
|
||||
- `PONG` timeout handling with connection close on timeout
|
||||
- New runtime limit settings:
|
||||
- `:websocket_ping_interval_seconds` (`PARRHESIA_LIMITS_WEBSOCKET_PING_INTERVAL_SECONDS`)
|
||||
- `:websocket_pong_timeout_seconds` (`PARRHESIA_LIMITS_WEBSOCKET_PONG_TIMEOUT_SECONDS`)
|
||||
|
||||
### Changed
|
||||
- NIP-42 challenge validation now uses constant-time comparison via `Plug.Crypto.secure_compare/2`.
|
||||
25
LICENSE
Normal file
25
LICENSE
Normal file
@@ -0,0 +1,25 @@
|
||||
BSD 2-Clause License
|
||||
|
||||
Copyright (c) 2026, Steffen Beyer <steffen@beyer.io>
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
128
README.md
128
README.md
@@ -4,12 +4,14 @@
|
||||
|
||||
Parrhesia is a Nostr relay server written in Elixir/OTP.
|
||||
|
||||
**BETA CONDITION – BREAKING CHANGES MAY STILL HAPPEN!**
|
||||
|
||||
Supported storage backends:
|
||||
|
||||
- PostgreSQL, which is the primary and production-oriented backend
|
||||
- in-memory storage, which is useful for tests, local experiments, and benchmarks
|
||||
|
||||
**ALPHA CONDITION – BREAKING CHANGES MIGHT HAPPEN!**
|
||||
Advanced Nostr features:
|
||||
|
||||
- Advanced Querying: Full-text search (NIP-50) and COUNT queries (NIP-45).
|
||||
- Secure Messaging: First-class support for Marmot MLS-encrypted groups and NIP-17/44/59 gift-wrapped DMs.
|
||||
@@ -48,10 +50,23 @@ Current `supported_nips` list:
|
||||
- Elixir `~> 1.18`
|
||||
- Erlang/OTP 28
|
||||
- PostgreSQL (18 used in the dev environment; 16+ recommended)
|
||||
- [`just`](https://github.com/casey/just) for the command runner used in this repo
|
||||
- Docker or Podman plus Docker Compose support if you want to run the published container image
|
||||
|
||||
---
|
||||
|
||||
## Command runner (`just`)
|
||||
|
||||
This repo includes a `justfile` that provides a grouped command/subcommand CLI over common mix tasks and scripts.
|
||||
|
||||
```bash
|
||||
just
|
||||
just help bench
|
||||
just help e2e
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Run locally
|
||||
|
||||
### 1) Prepare the database
|
||||
@@ -97,9 +112,9 @@ ws://localhost:4413/relay
|
||||
Primary test entrypoints:
|
||||
|
||||
- `mix test` for the ExUnit suite
|
||||
- `mix test.marmot_e2e` for the Marmot client end-to-end suite
|
||||
- `mix test.node_sync_e2e` for the two-node relay sync end-to-end suite
|
||||
- `mix test.node_sync_docker_e2e` for the release-image Docker two-node relay sync suite
|
||||
- `just e2e marmot` for the Marmot client end-to-end suite
|
||||
- `just e2e node-sync` for the two-node relay sync end-to-end suite
|
||||
- `just e2e node-sync-docker` for the release-image Docker two-node relay sync suite
|
||||
|
||||
The node-sync harnesses are driven by:
|
||||
|
||||
@@ -108,7 +123,7 @@ The node-sync harnesses are driven by:
|
||||
- [`scripts/node_sync_e2e.exs`](./scripts/node_sync_e2e.exs)
|
||||
- [`compose.node-sync-e2e.yaml`](./compose.node-sync-e2e.yaml)
|
||||
|
||||
`mix test.node_sync_e2e` runs two real Parrhesia nodes against separate PostgreSQL databases, verifies catch-up and live sync, restarts one node, and verifies persisted resume behavior. `mix test.node_sync_docker_e2e` runs the same scenario against the release Docker image.
|
||||
`just e2e node-sync` runs two real Parrhesia nodes against separate PostgreSQL databases, verifies catch-up and live sync, restarts one node, and verifies persisted resume behavior. `just e2e node-sync-docker` runs the same scenario against the release Docker image.
|
||||
|
||||
GitHub CI currently runs the non-Docker node-sync e2e on the main Linux matrix job. The Docker node-sync e2e remains an explicit/manual check because it depends on release-image build/runtime fidelity and a working Docker host.
|
||||
|
||||
@@ -124,6 +139,8 @@ The intended in-process surface is `Parrhesia.API.*`, especially:
|
||||
- `Parrhesia.API.Admin` for management operations
|
||||
- `Parrhesia.API.Identity`, `Parrhesia.API.ACL`, and `Parrhesia.API.Sync` for relay identity, protected sync ACLs, and outbound relay sync
|
||||
|
||||
For host-managed HTTP/WebSocket ingress mounting, use `Parrhesia.Plug`.
|
||||
|
||||
Start with:
|
||||
|
||||
- [`docs/LOCAL_API.md`](./docs/LOCAL_API.md) for the embedding model and a minimal host setup
|
||||
@@ -131,17 +148,30 @@ Start with:
|
||||
|
||||
Important caveats for host applications:
|
||||
|
||||
- Parrhesia is still alpha; expect some public API and config churn.
|
||||
- Parrhesia is beta software; expect some API and config churn as the runtime stabilizes.
|
||||
- Parrhesia currently assumes a single runtime per BEAM node and uses globally registered process names.
|
||||
- The defaults in this repo's `config/*.exs` are not imported automatically when Parrhesia is used as a dependency. A host app must set `config :parrhesia, ...` explicitly.
|
||||
- The host app is responsible for migrating Parrhesia's schema, for example with `Parrhesia.Release.migrate()` or `mix ecto.migrate -r Parrhesia.Repo`.
|
||||
|
||||
If you only want the in-process API and not the HTTP/WebSocket edge, configure:
|
||||
### Official embedding boundary
|
||||
|
||||
For embedded use, the stable boundaries are:
|
||||
|
||||
- `Parrhesia.API.*` for in-process publish/query/admin/sync operations
|
||||
- `Parrhesia.Plug` for host-managed HTTP/WebSocket ingress mounting
|
||||
|
||||
If your host app owns the public HTTPS endpoint, keep this as the baseline runtime config:
|
||||
|
||||
```elixir
|
||||
config :parrhesia, :listeners, %{}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `listeners: %{}` disables Parrhesia-managed HTTP/WebSocket ingress (`/relay`, `/management`, `/metrics`, etc.).
|
||||
- Mount `Parrhesia.Plug` in your host endpoint/router when you still want Parrhesia ingress under the host's single HTTPS surface.
|
||||
- `Parrhesia.Web.*` modules remain internal runtime wiring. Use `Parrhesia.Plug` as the documented mount API.
|
||||
|
||||
The config reference below still applies when embedded. That is the primary place to document basic setup and runtime configuration changes.
|
||||
|
||||
---
|
||||
@@ -234,6 +264,7 @@ CSV env vars use comma-separated values. Boolean env vars accept `1/0`, `true/fa
|
||||
| `:nip66` | config-file driven | see table below | Built-in NIP-66 discovery / monitor publisher |
|
||||
| `:sync.path` | `PARRHESIA_SYNC_PATH` | `nil` | Optional path to sync peer config |
|
||||
| `:sync.start_workers?` | `PARRHESIA_SYNC_START_WORKERS` | `true` | Start outbound sync workers on boot |
|
||||
| `:sync.relay_guard` | `PARRHESIA_SYNC_RELAY_GUARD` | `false` | Suppress multi-node re-fanout for sync-originated events |
|
||||
| `:limits` | `PARRHESIA_LIMITS_*` | see table below | Runtime override group |
|
||||
| `:policies` | `PARRHESIA_POLICIES_*` | see table below | Runtime override group |
|
||||
| `:listeners` | config-file driven | see notes below | Ingress listeners with bind, transport, feature, auth, network, and baseline ACL settings |
|
||||
@@ -353,6 +384,8 @@ Parrhesia treats NIP-43 invite requests as synthetic relay output, not stored cl
|
||||
| `:max_event_ingest_per_window` | `PARRHESIA_LIMITS_MAX_EVENT_INGEST_PER_WINDOW` | `120` |
|
||||
| `:event_ingest_window_seconds` | `PARRHESIA_LIMITS_EVENT_INGEST_WINDOW_SECONDS` | `1` |
|
||||
| `:auth_max_age_seconds` | `PARRHESIA_LIMITS_AUTH_MAX_AGE_SECONDS` | `600` |
|
||||
| `:websocket_ping_interval_seconds` | `PARRHESIA_LIMITS_WEBSOCKET_PING_INTERVAL_SECONDS` | `30` |
|
||||
| `:websocket_pong_timeout_seconds` | `PARRHESIA_LIMITS_WEBSOCKET_PONG_TIMEOUT_SECONDS` | `10` |
|
||||
| `:max_outbound_queue` | `PARRHESIA_LIMITS_MAX_OUTBOUND_QUEUE` | `256` |
|
||||
| `:outbound_drain_batch_size` | `PARRHESIA_LIMITS_OUTBOUND_DRAIN_BATCH_SIZE` | `64` |
|
||||
| `:outbound_overflow_strategy` | `PARRHESIA_LIMITS_OUTBOUND_OVERFLOW_STRATEGY` | `:close` |
|
||||
@@ -538,33 +571,82 @@ Notes:
|
||||
|
||||
## Benchmark
|
||||
|
||||
The benchmark compares two Parrhesia profiles, one backed by PostgreSQL and one backed by the in-memory adapter, against [`strfry`](https://github.com/hoytech/strfry) and [`nostr-rs-relay`](https://sr.ht/~gheartsfield/nostr-rs-relay/) using [`nostr-bench`](https://github.com/rnostr/nostr-bench). Benchmark runs also lift Parrhesia's relay-side limits by default so the benchmark client, not server guardrails, is the main bottleneck.
|
||||
The benchmark compares two Parrhesia profiles, one backed by PostgreSQL and one backed by the in-memory adapter, against [`strfry`](https://github.com/hoytech/strfry) and [`nostr-rs-relay`](https://sr.ht/~gheartsfield/nostr-rs-relay/) using [`nostr-bench`](https://github.com/rnostr/nostr-bench). The cloud benchmark target set also includes [`nostream`](https://github.com/Cameri/nostream) and [`Haven`](https://github.com/bitvora/haven). Benchmark runs also lift Parrhesia's relay-side limits by default so the benchmark client, not server guardrails, is the main bottleneck.
|
||||
|
||||
`mix bench` is a sequential mixed-workload benchmark, not an isolated per-endpoint microbenchmark. Each relay instance runs `connect`, then `echo`, then `event`, then `req` against the same live process, so later phases measure against state and load created by earlier phases.
|
||||
`just bench compare` is a sequential mixed-workload benchmark, not an isolated per-endpoint microbenchmark. Each relay instance runs `connect`, then `echo`, then `event`, then `req` against the same live process, so later phases measure against state and load created by earlier phases.
|
||||
|
||||
Run it with:
|
||||
|
||||
```bash
|
||||
mix bench
|
||||
just bench compare
|
||||
```
|
||||
|
||||
Current comparison results from [BENCHMARK.md](./BENCHMARK.md):
|
||||
### Cloud benchmark (Hetzner Cloud)
|
||||
|
||||
| metric | parrhesia-pg | parrhesia-mem | nostr-rs-relay | mem/pg | nostr-rs/pg |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: |
|
||||
| connect avg latency (ms) ↓ | 9.33 | 7.67 | 7.00 | **0.82x** | **0.75x** |
|
||||
| connect max latency (ms) ↓ | 12.33 | 9.67 | 10.33 | **0.78x** | **0.84x** |
|
||||
| echo throughput (TPS) ↑ | 64030.33 | 93656.33 | 140767.00 | **1.46x** | **2.20x** |
|
||||
| echo throughput (MiB/s) ↑ | 35.07 | 51.27 | 77.07 | **1.46x** | **2.20x** |
|
||||
| event throughput (TPS) ↑ | 5015.33 | 1505.33 | 2293.67 | 0.30x | 0.46x |
|
||||
| event throughput (MiB/s) ↑ | 3.40 | 1.00 | 1.50 | 0.29x | 0.44x |
|
||||
| req throughput (TPS) ↑ | 6416.33 | 14566.67 | 3035.67 | **2.27x** | 0.47x |
|
||||
| req throughput (MiB/s) ↑ | 42.43 | 94.23 | 19.23 | **2.22x** | 0.45x |
|
||||
For distributed runs (one server node + multiple client nodes), use:
|
||||
|
||||
```bash
|
||||
just bench cloud
|
||||
# or: ./scripts/run_bench_cloud.sh
|
||||
```
|
||||
|
||||
or invoke the orchestrator directly:
|
||||
|
||||
```bash
|
||||
node scripts/cloud_bench_orchestrate.mjs
|
||||
```
|
||||
|
||||
Prerequisites:
|
||||
|
||||
- [`hcloud`](https://github.com/hetznercloud/cli) CLI installed
|
||||
- Hetzner Cloud token exported as `HCLOUD_TOKEN`
|
||||
- local `docker`, `git`, `ssh`, and `scp` available
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
export HCLOUD_TOKEN=...
|
||||
just bench cloud-quick
|
||||
# or: ./scripts/run_bench_cloud.sh --quick
|
||||
```
|
||||
|
||||
Outputs:
|
||||
|
||||
- raw client logs per run: `bench/cloud_artifacts/<run_id>/...`
|
||||
- JSONL history entries (local + cloud): `bench/history.jsonl`
|
||||
|
||||
Useful history/render commands:
|
||||
|
||||
```bash
|
||||
# List available machines and runs in history
|
||||
just bench list
|
||||
|
||||
# Regenerate chart + README table for a machine
|
||||
just bench update <machine_id>
|
||||
|
||||
# Regenerate from all machines
|
||||
just bench update all
|
||||
```
|
||||
|
||||
Current comparison results:
|
||||
|
||||
| metric | parrhesia-pg | parrhesia-mem | strfry | nostr-rs-relay | mem/pg | strfry/pg | nostr-rs-relay/pg |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| connect avg latency (ms) ↓ | 34.67 | 43.33 | 2.67 | 2.67 | 1.25x | **0.08x** | **0.08x** |
|
||||
| connect max latency (ms) ↓ | 61.67 | 74.67 | 4.67 | 4.00 | 1.21x | **0.08x** | **0.06x** |
|
||||
| echo throughput (TPS) ↑ | 72441.00 | 62704.67 | 61189.33 | 152654.33 | 0.87x | 0.84x | **2.11x** |
|
||||
| echo throughput (MiB/s) ↑ | 39.67 | 34.30 | 34.20 | 83.63 | 0.86x | 0.86x | **2.11x** |
|
||||
| event throughput (TPS) ↑ | 1897.33 | 1370.00 | 3426.67 | 772.67 | 0.72x | **1.81x** | 0.41x |
|
||||
| event throughput (MiB/s) ↑ | 1.23 | 0.87 | 2.20 | 0.50 | 0.70x | **1.78x** | 0.41x |
|
||||
| req throughput (TPS) ↑ | 13.33 | 47.00 | 1811.33 | 878.33 | **3.52x** | **135.85x** | **65.88x** |
|
||||
| req throughput (MiB/s) ↑ | 0.03 | 0.17 | 11.77 | 2.40 | **5.00x** | **353.00x** | **72.00x** |
|
||||
|
||||
Higher is better for `↑` metrics. Lower is better for `↓` metrics.
|
||||
|
||||
(Results from a Linux container on a 6-core Intel i5-8400T with NVMe drive, PostgreSQL 18)
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Development quality checks
|
||||
@@ -578,11 +660,11 @@ mix precommit
|
||||
Additional external CLI end-to-end checks with `nak`:
|
||||
|
||||
```bash
|
||||
mix test.nak_e2e
|
||||
just e2e nak
|
||||
```
|
||||
|
||||
For Marmot client end-to-end checks (TypeScript/Node suite using `marmot-ts`, included in `precommit`):
|
||||
|
||||
```bash
|
||||
mix test.marmot_e2e
|
||||
just e2e marmot
|
||||
```
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
# fragment generated by the data-prep step that defines the actual plot
|
||||
# directives (handling variable server columns).
|
||||
|
||||
set terminal svg enhanced size 1200,900 font "sans,11"
|
||||
set terminal svg enhanced size 1200,900 font "sans-serif,11" background "#f3f4f6"
|
||||
set output output_file
|
||||
|
||||
set style data linespoints
|
||||
|
||||
611
bench/chart.svg
611
bench/chart.svg
File diff suppressed because it is too large
Load Diff
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 72 KiB |
@@ -1 +1,5 @@
|
||||
{"timestamp":"2026-03-18T20:13:21Z","machine_id":"squirrel","git_tag":"v0.5.0","git_commit":"970cee2","runs":3,"servers":{"parrhesia-pg":{"connect_avg_ms":9.333333333333334,"connect_max_ms":12.333333333333334,"echo_tps":64030.333333333336,"echo_mibs":35.06666666666666,"event_tps":5015.333333333333,"event_mibs":3.4,"req_tps":6416.333333333333,"req_mibs":42.43333333333334},"parrhesia-memory":{"connect_avg_ms":7.666666666666667,"connect_max_ms":9.666666666666666,"echo_tps":93656.33333333333,"echo_mibs":51.26666666666667,"event_tps":1505.3333333333333,"event_mibs":1,"req_tps":14566.666666666666,"req_mibs":94.23333333333335},"nostr-rs-relay":{"connect_avg_ms":7,"connect_max_ms":10.333333333333334,"echo_tps":140767,"echo_mibs":77.06666666666666,"event_tps":2293.6666666666665,"event_mibs":1.5,"req_tps":3035.6666666666665,"req_mibs":19.23333333333333}}}
|
||||
{"schema_version":2,"timestamp":"2026-03-18T21:35:03Z","machine_id":"agent","git_tag":"v0.6.0","git_commit":"7b337d9","runs":3,"versions":{"parrhesia":"0.6.0","strfry":"strfry 1.0.4 (nixpkgs)","nostr-rs-relay":"nostr-rs-relay 0.9.0","nostr-bench":"nostr-bench 0.4.0"},"servers":{"parrhesia-pg":{"connect_avg_ms":26.666666666666668,"connect_max_ms":45.333333333333336,"echo_tps":68100.33333333333,"echo_mibs":37.233333333333334,"event_tps":1647.3333333333333,"event_mibs":1.0666666666666667,"req_tps":3576.6666666666665,"req_mibs":18.833333333333332},"parrhesia-memory":{"connect_avg_ms":14.666666666666666,"connect_max_ms":24.333333333333332,"echo_tps":55978,"echo_mibs":30.633333333333336,"event_tps":882,"event_mibs":0.5666666666666668,"req_tps":6888,"req_mibs":36.06666666666666},"strfry":{"connect_avg_ms":3,"connect_max_ms":4.666666666666667,"echo_tps":67718.33333333333,"echo_mibs":37.86666666666667,"event_tps":3548.3333333333335,"event_mibs":2.3,"req_tps":1808,"req_mibs":11.699999999999998},"nostr-rs-relay":{"connect_avg_ms":2,"connect_max_ms":3.3333333333333335,"echo_tps":166178,"echo_mibs":91.03333333333335,"event_tps":787,"event_mibs":0.5,"req_tps":860.6666666666666,"req_mibs":2.4}},"run_id":"local-2026-03-18T21:35:03Z-agent-7b337d9","source":{"kind":"local","git_tag":"v0.6.0","git_commit":"7b337d9"},"infra":{"provider":"local"},"bench":{"runs":3,"targets":["parrhesia-pg","parrhesia-memory","strfry","nostr-rs-relay"]}}
|
||||
{"schema_version":2,"timestamp":"2026-03-18T22:14:37Z","machine_id":"agent","git_tag":"v0.2.0","git_commit":"b20dbf6","runs":3,"versions":{"parrhesia":"0.2.0","strfry":"strfry 1.0.4 (nixpkgs)","nostr-rs-relay":"nostr-rs-relay 0.9.0","nostr-bench":"nostr-bench 0.4.0"},"servers":{"parrhesia-pg":{"connect_avg_ms":14.666666666666666,"connect_max_ms":25.666666666666668,"echo_tps":77133,"echo_mibs":42.233333333333334,"event_tps":1602.6666666666667,"event_mibs":1.0666666666666667,"req_tps":2418,"req_mibs":12.5},"parrhesia-memory":{"connect_avg_ms":9,"connect_max_ms":16,"echo_tps":64218.333333333336,"echo_mibs":35.166666666666664,"event_tps":1578.3333333333333,"event_mibs":1,"req_tps":2431.3333333333335,"req_mibs":12.633333333333333},"strfry":{"connect_avg_ms":3.3333333333333335,"connect_max_ms":6,"echo_tps":63682.666666666664,"echo_mibs":35.6,"event_tps":3477.3333333333335,"event_mibs":2.2333333333333334,"req_tps":1804,"req_mibs":11.733333333333334},"nostr-rs-relay":{"connect_avg_ms":2.6666666666666665,"connect_max_ms":4.333333333333333,"echo_tps":160009,"echo_mibs":87.63333333333333,"event_tps":762,"event_mibs":0.4666666666666666,"req_tps":831,"req_mibs":2.2333333333333334}},"run_id":"local-2026-03-18T22:14:37Z-agent-b20dbf6","source":{"kind":"local","git_tag":"v0.2.0","git_commit":"b20dbf6"},"infra":{"provider":"local"},"bench":{"runs":3,"targets":["parrhesia-pg","parrhesia-memory","strfry","nostr-rs-relay"]}}
|
||||
{"schema_version":2,"timestamp":"2026-03-18T22:22:12Z","machine_id":"agent","git_tag":"v0.3.0","git_commit":"8c8d5a8","runs":3,"versions":{"parrhesia":"0.3.0","strfry":"strfry 1.0.4 (nixpkgs)","nostr-rs-relay":"nostr-rs-relay 0.9.0","nostr-bench":"nostr-bench 0.4.0"},"servers":{"parrhesia-pg":{"connect_avg_ms":13,"connect_max_ms":21.666666666666668,"echo_tps":70703.33333333333,"echo_mibs":38.7,"event_tps":1970.6666666666667,"event_mibs":1.3,"req_tps":3614,"req_mibs":20.966666666666665},"parrhesia-memory":{"connect_avg_ms":13,"connect_max_ms":22.333333333333332,"echo_tps":60452.333333333336,"echo_mibs":33.1,"event_tps":1952.6666666666667,"event_mibs":1.3,"req_tps":3616,"req_mibs":20.766666666666666},"strfry":{"connect_avg_ms":3.6666666666666665,"connect_max_ms":6,"echo_tps":63128.666666666664,"echo_mibs":35.300000000000004,"event_tps":3442,"event_mibs":2.2333333333333334,"req_tps":1804,"req_mibs":11.699999999999998},"nostr-rs-relay":{"connect_avg_ms":2,"connect_max_ms":3.3333333333333335,"echo_tps":164995.33333333334,"echo_mibs":90.36666666666667,"event_tps":761.6666666666666,"event_mibs":0.5,"req_tps":846.3333333333334,"req_mibs":2.333333333333333}},"run_id":"local-2026-03-18T22:22:12Z-agent-8c8d5a8","source":{"kind":"local","git_tag":"v0.3.0","git_commit":"8c8d5a8"},"infra":{"provider":"local"},"bench":{"runs":3,"targets":["parrhesia-pg","parrhesia-memory","strfry","nostr-rs-relay"]}}
|
||||
{"schema_version":2,"timestamp":"2026-03-18T22:30:08Z","machine_id":"agent","git_tag":"v0.4.0","git_commit":"b86b5db","runs":3,"versions":{"parrhesia":"0.4.0","strfry":"strfry 1.0.4 (nixpkgs)","nostr-rs-relay":"nostr-rs-relay 0.9.0","nostr-bench":"nostr-bench 0.4.0"},"servers":{"parrhesia-pg":{"connect_avg_ms":11.333333333333334,"connect_max_ms":20.666666666666668,"echo_tps":69139.33333333333,"echo_mibs":37.833333333333336,"event_tps":1938.6666666666667,"event_mibs":1.3,"req_tps":4619.666666666667,"req_mibs":26.266666666666666},"parrhesia-memory":{"connect_avg_ms":10,"connect_max_ms":17.333333333333332,"echo_tps":62715.333333333336,"echo_mibs":34.333333333333336,"event_tps":1573,"event_mibs":1.0333333333333334,"req_tps":4768,"req_mibs":23.733333333333334},"strfry":{"connect_avg_ms":3.3333333333333335,"connect_max_ms":6,"echo_tps":60956.666666666664,"echo_mibs":34.06666666666667,"event_tps":3380.6666666666665,"event_mibs":2.2,"req_tps":1820.3333333333333,"req_mibs":11.800000000000002},"nostr-rs-relay":{"connect_avg_ms":2.6666666666666665,"connect_max_ms":4.333333333333333,"echo_tps":161165.33333333334,"echo_mibs":88.26666666666665,"event_tps":768,"event_mibs":0.5,"req_tps":847.3333333333334,"req_mibs":2.3000000000000003}},"run_id":"local-2026-03-18T22:30:08Z-agent-b86b5db","source":{"kind":"local","git_tag":"v0.4.0","git_commit":"b86b5db"},"infra":{"provider":"local"},"bench":{"runs":3,"targets":["parrhesia-pg","parrhesia-memory","strfry","nostr-rs-relay"]}}
|
||||
{"schema_version":2,"timestamp":"2026-03-18T22:36:37Z","machine_id":"agent","git_tag":"v0.5.0","git_commit":"e557eba","runs":3,"versions":{"parrhesia":"0.5.0","strfry":"strfry 1.0.4 (nixpkgs)","nostr-rs-relay":"nostr-rs-relay 0.9.0","nostr-bench":"nostr-bench 0.4.0"},"servers":{"parrhesia-pg":{"connect_avg_ms":34.666666666666664,"connect_max_ms":61.666666666666664,"echo_tps":72441,"echo_mibs":39.666666666666664,"event_tps":1897.3333333333333,"event_mibs":1.2333333333333334,"req_tps":13.333333333333334,"req_mibs":0.03333333333333333},"parrhesia-memory":{"connect_avg_ms":43.333333333333336,"connect_max_ms":74.66666666666667,"echo_tps":62704.666666666664,"echo_mibs":34.300000000000004,"event_tps":1370,"event_mibs":0.8666666666666667,"req_tps":47,"req_mibs":0.16666666666666666},"strfry":{"connect_avg_ms":2.6666666666666665,"connect_max_ms":4.666666666666667,"echo_tps":61189.333333333336,"echo_mibs":34.2,"event_tps":3426.6666666666665,"event_mibs":2.2,"req_tps":1811.3333333333333,"req_mibs":11.766666666666666},"nostr-rs-relay":{"connect_avg_ms":2.6666666666666665,"connect_max_ms":4,"echo_tps":152654.33333333334,"echo_mibs":83.63333333333333,"event_tps":772.6666666666666,"event_mibs":0.5,"req_tps":878.3333333333334,"req_mibs":2.4}},"run_id":"local-2026-03-18T22:36:37Z-agent-e557eba","source":{"kind":"local","git_tag":"v0.5.0","git_commit":"e557eba"},"infra":{"provider":"local"},"bench":{"runs":3,"targets":["parrhesia-pg","parrhesia-memory","strfry","nostr-rs-relay"]}}
|
||||
|
||||
@@ -39,7 +39,8 @@ config :parrhesia,
|
||||
],
|
||||
sync: [
|
||||
path: nil,
|
||||
start_workers?: true
|
||||
start_workers?: true,
|
||||
relay_guard: false
|
||||
],
|
||||
limits: [
|
||||
max_frame_bytes: 1_048_576,
|
||||
@@ -57,6 +58,8 @@ config :parrhesia,
|
||||
max_event_ingest_per_window: 120,
|
||||
event_ingest_window_seconds: 1,
|
||||
auth_max_age_seconds: 600,
|
||||
websocket_ping_interval_seconds: 30,
|
||||
websocket_pong_timeout_seconds: 10,
|
||||
max_outbound_queue: 256,
|
||||
outbound_drain_batch_size: 64,
|
||||
outbound_overflow_strategy: :close,
|
||||
|
||||
@@ -161,6 +161,7 @@ if config_env() == :prod do
|
||||
retention_defaults = Application.get_env(:parrhesia, :retention, [])
|
||||
features_defaults = Application.get_env(:parrhesia, :features, [])
|
||||
acl_defaults = Application.get_env(:parrhesia, :acl, [])
|
||||
sync_defaults = Application.get_env(:parrhesia, :sync, [])
|
||||
|
||||
default_pool_size = Keyword.get(repo_defaults, :pool_size, 32)
|
||||
default_queue_target = Keyword.get(repo_defaults, :queue_target, 1_000)
|
||||
@@ -277,6 +278,16 @@ if config_env() == :prod do
|
||||
"PARRHESIA_LIMITS_AUTH_MAX_AGE_SECONDS",
|
||||
Keyword.get(limits_defaults, :auth_max_age_seconds, 600)
|
||||
),
|
||||
websocket_ping_interval_seconds:
|
||||
int_env.(
|
||||
"PARRHESIA_LIMITS_WEBSOCKET_PING_INTERVAL_SECONDS",
|
||||
Keyword.get(limits_defaults, :websocket_ping_interval_seconds, 30)
|
||||
),
|
||||
websocket_pong_timeout_seconds:
|
||||
int_env.(
|
||||
"PARRHESIA_LIMITS_WEBSOCKET_PONG_TIMEOUT_SECONDS",
|
||||
Keyword.get(limits_defaults, :websocket_pong_timeout_seconds, 10)
|
||||
),
|
||||
max_outbound_queue:
|
||||
int_env.(
|
||||
"PARRHESIA_LIMITS_MAX_OUTBOUND_QUEUE",
|
||||
@@ -738,7 +749,12 @@ if config_env() == :prod do
|
||||
start_workers?:
|
||||
bool_env.(
|
||||
"PARRHESIA_SYNC_START_WORKERS",
|
||||
Keyword.get(Application.get_env(:parrhesia, :sync, []), :start_workers?, true)
|
||||
Keyword.get(sync_defaults, :start_workers?, true)
|
||||
),
|
||||
relay_guard:
|
||||
bool_env.(
|
||||
"PARRHESIA_SYNC_RELAY_GUARD",
|
||||
Keyword.get(sync_defaults, :relay_guard, false)
|
||||
)
|
||||
],
|
||||
moderation_cache_enabled:
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
vips,
|
||||
}: let
|
||||
pname = "parrhesia";
|
||||
version = "0.6.0";
|
||||
version = "0.8.0";
|
||||
|
||||
beamPackages = beam.packages.erlang_28.extend (
|
||||
final: _prev: {
|
||||
|
||||
48
devenv.lock
48
devenv.lock
@@ -3,10 +3,10 @@
|
||||
"devenv": {
|
||||
"locked": {
|
||||
"dir": "src/modules",
|
||||
"lastModified": 1768736080,
|
||||
"lastModified": 1774475276,
|
||||
"owner": "cachix",
|
||||
"repo": "devenv",
|
||||
"rev": "efa86311444852d24137d14964b449075522d489",
|
||||
"rev": "f8ca2c061ec2feceee1cf1c5e52c92f58b6aec9c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -40,10 +40,10 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1767281941,
|
||||
"lastModified": 1774104215,
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"rev": "f0927703b7b1c8d97511c4116eb9b4ec6645a0fa",
|
||||
"rev": "f799ae951fde0627157f40aec28dec27b22076d0",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -73,11 +73,14 @@
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"inputs": {
|
||||
"nixpkgs-src": "nixpkgs-src"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1767052823,
|
||||
"lastModified": 1774287239,
|
||||
"owner": "cachix",
|
||||
"repo": "devenv-nixpkgs",
|
||||
"rev": "538a5124359f0b3d466e1160378c87887e3b51a4",
|
||||
"rev": "fa7125ea7f1ae5430010a6e071f68375a39bd24c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -87,11 +90,44 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-src": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1769922788,
|
||||
"narHash": "sha256-H3AfG4ObMDTkTJYkd8cz1/RbY9LatN5Mk4UF48VuSXc=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "207d15f1a6603226e1e223dc79ac29c7846da32e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nostr-bench-src": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1774020724,
|
||||
"owner": "serpent213",
|
||||
"repo": "nostr-bench",
|
||||
"rev": "8561b84864ce1269b26304808c64219471999caf",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "serpent213",
|
||||
"repo": "nostr-bench",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"devenv": "devenv",
|
||||
"git-hooks": "git-hooks",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"nostr-bench-src": "nostr-bench-src",
|
||||
"pre-commit-hooks": [
|
||||
"git-hooks"
|
||||
]
|
||||
|
||||
26
devenv.nix
26
devenv.nix
@@ -73,11 +73,14 @@ in {
|
||||
vips.overrideAttrs (oldAttrs: {
|
||||
buildInputs = oldAttrs.buildInputs ++ [mozjpeg];
|
||||
});
|
||||
nostr-bench = pkgs.callPackage ./nix/nostr-bench.nix {};
|
||||
nostr-bench = pkgs.callPackage ./nix/nostr-bench.nix {
|
||||
nostrBenchSrc = inputs.nostr-bench-src;
|
||||
};
|
||||
in
|
||||
with pkgs;
|
||||
[
|
||||
just
|
||||
# Mix NIFs
|
||||
gcc
|
||||
git
|
||||
gnumake
|
||||
@@ -85,6 +88,8 @@ in {
|
||||
automake
|
||||
libtool
|
||||
pkg-config
|
||||
# for tests
|
||||
openssl
|
||||
# Nix code formatter
|
||||
alejandra
|
||||
# i18n
|
||||
@@ -97,14 +102,18 @@ in {
|
||||
mermaid-cli
|
||||
# Nostr CLI client
|
||||
nak
|
||||
websocat
|
||||
# Nostr relay benchmark client
|
||||
nostr-bench
|
||||
# Nostr reference servers
|
||||
nostr-rs-relay
|
||||
# Benchmark graph
|
||||
gnuplot
|
||||
# Cloud benchmarks
|
||||
hcloud
|
||||
]
|
||||
++ lib.optionals pkgs.stdenv.hostPlatform.isx86_64 [
|
||||
# Nostr reference servers
|
||||
strfry
|
||||
];
|
||||
|
||||
@@ -180,6 +189,21 @@ in {
|
||||
|
||||
# https://devenv.sh/scripts/
|
||||
enterShell = ''
|
||||
cleanup_stale_git_hook_legacy() {
|
||||
hooks_dir="$(git rev-parse --git-path hooks 2>/dev/null)" || return 0
|
||||
|
||||
for legacy_hook in "$hooks_dir"/*.legacy; do
|
||||
[ -e "$legacy_hook" ] || continue
|
||||
|
||||
if grep -Fq "File generated by pre-commit: https://pre-commit.com" "$legacy_hook"; then
|
||||
rm -f "$legacy_hook"
|
||||
echo "Removed stale legacy git hook: $legacy_hook"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
cleanup_stale_git_hook_legacy
|
||||
|
||||
echo
|
||||
elixir --version
|
||||
echo
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
inputs:
|
||||
nixpkgs:
|
||||
url: github:cachix/devenv-nixpkgs/rolling
|
||||
nostr-bench-src:
|
||||
url: github:serpent213/nostr-bench
|
||||
flake: false
|
||||
|
||||
# If you're using non-OSS software, you can set allowUnfree to true.
|
||||
# allowUnfree: true
|
||||
|
||||
344
docs/BETA_REVIEW.md
Normal file
344
docs/BETA_REVIEW.md
Normal file
@@ -0,0 +1,344 @@
|
||||
# Parrhesia Beta: Production-Readiness Gap Assessment
|
||||
|
||||
**Date:** 2026-03-20
|
||||
**Version:** 0.7.0
|
||||
**Scope:** Delta analysis from beta promotion — what stands between this codebase and confident public-facing production deployment.
|
||||
|
||||
---
|
||||
|
||||
## Production Readiness Scorecard
|
||||
|
||||
| # | Dimension | Rating | Summary |
|
||||
|---|----------------------------------|--------|----------------------------------------------|
|
||||
| 1 | Operational Resilience | 🟡 | Graceful shutdown partial; no DB circuit-breaking |
|
||||
| 2 | Multi-Node / Clustering | 🟡 | Best-effort only; acceptable for single-node prod |
|
||||
| 3 | Load & Capacity Characterisation | 🟡 | Benchmarks exist but no defined capacity model |
|
||||
| 4 | Deployment & Infrastructure | 🟡 | Strong Nix/Docker base; missing runbooks and migration strategy |
|
||||
| 5 | Security Hardening | 🟢 | Solid for production with reverse proxy |
|
||||
| 6 | Data Integrity & Consistency | 🟢 | Transaction-wrapped writes with dedup; minor multi-node edge cases |
|
||||
| 7 | Observability Completeness | 🟡 | Excellent metrics; no dashboards, alerts, or tracing |
|
||||
| 8 | Technical Debt (Prod Impact) | 🟡 | Manageable; connection.ex size is the main concern |
|
||||
|
||||
---
|
||||
|
||||
## 1. Operational Resilience — 🟡
|
||||
|
||||
### What's good
|
||||
|
||||
- **No `Process.sleep` on any hot path.** Zero occurrences in `lib/`. Clean async message passing throughout.
|
||||
- **WebSocket keepalive** implemented: 30s ping, 10s pong timeout, auto-close on timeout.
|
||||
- **Outbound queue backpressure** well-designed: bounded queue (256 default), configurable overflow strategy (`:close`/`:drop_oldest`/`:drop_newest`), pressure telemetry at 75% threshold.
|
||||
- **Connection isolation:** Each WebSocket is a separate process; one crash does not propagate.
|
||||
- **Graceful connection close on shutdown:** `handle_info({:EXIT, _, :shutdown}, ...)` drains outbound frames before closing with code 1012 ("service restart"). This is good.
|
||||
|
||||
### Gaps
|
||||
|
||||
**G1.1 — No DB circuit-breaking or backoff on PostgreSQL unavailability.**
|
||||
Ecto's connection pool (`db_connection`/`DBConnection`) will queue checkout requests up to `queue_target` (1000ms) / `queue_interval` (5000ms), then raise `DBConnection.ConnectionError`. These errors propagate as storage failures in the ingest path and return NOTICE errors to clients. However:
|
||||
- There is no circuit breaker to fast-reject requests when the DB is known-down, meaning every ingest/query attempt during an outage burns a pool checkout timeout slot.
|
||||
- On DB recovery, all queued checkouts may succeed simultaneously (thundering herd).
|
||||
- **Impact:** During a PostgreSQL failover (typically 10–30s), connection processes pile up waiting on the pool. Latency spikes for all connected clients. Memory pressure from queued processes.
|
||||
- **Mitigation:** Ecto's built-in queue management provides partial protection. For a relay with ≤1000 concurrent connections this is likely survivable without circuit-breaking. For higher connection counts, consider a fast-fail wrapper around storage calls when the pool reports consecutive failures.
|
||||
|
||||
**G1.2 — Metrics scrape on the hot path.**
|
||||
`/metrics` calls `TelemetryMetricsPrometheus.Core.scrape/1` synchronously within the HTTP request handler. This serialises metric aggregation and formatting. If the Prometheus reporter's internal state is large (many unique tag combinations), scraping can take 10–100ms. This runs on a Bandit acceptor process — it does not block WebSocket connections directly, but a slow scrape under high cardinality could make the health endpoint unresponsive if metrics and health share the same listener.
|
||||
- **Current mitigation:** Metrics can be isolated to a dedicated listener via `PARRHESIA_METRICS_ENDPOINT_*` config. If deployed this way, impact is isolated.
|
||||
- **Recommendation:** Document the dedicated metrics listener as required for production. Consider adding a scrape timeout guard.
|
||||
|
||||
**G1.3 — Supervisor shutdown timeout is OTP default (5s).**
|
||||
The `Parrhesia.Runtime` supervisor uses `:one_for_one` strategy with default child shutdown specs. Bandit listeners have their own shutdown behavior, but there is no explicit `shutdown: N` on the endpoint child spec. Under load with many connections, 5s may not be enough to drain all outbound queues.
|
||||
- **Recommendation:** Set explicit `shutdown: 15_000` on `Parrhesia.Web.Endpoint` child spec. Bandit supports graceful drain on listener stop.
|
||||
|
||||
---
|
||||
|
||||
## 2. Multi-Node / Clustering — 🟡
|
||||
|
||||
### Current state
|
||||
|
||||
Per `docs/CLUSTER.md`, clustering is **implemented but explicitly best-effort and untested**:
|
||||
|
||||
- `:pg`-based process groups for cross-node fanout.
|
||||
- No automatic cluster discovery (no libcluster).
|
||||
- ETS subscription index is node-local.
|
||||
- No durable inter-node transport; no replay on reconnect.
|
||||
- No explicit acknowledgement between nodes.
|
||||
|
||||
### Assessment for production
|
||||
|
||||
**For single-node production deployment: not a blocker.** The clustering code is unconditionally started (`MultiNode` joins `:pg` on init) but with a single node, `get_members/0` returns only self, and the `Enum.reject(&(&1 == self()))` filter means no remote sends occur. No performance overhead.
|
||||
|
||||
**For multi-node production: not ready.** Key issues:
|
||||
- **Subscription inconsistency on netsplit:** Events ingested on node A during a split are never delivered to subscribers on node B. No catch-up mechanism exists. Clients must reconnect and re-query to recover.
|
||||
- **Node departure drops subscriptions silently:** When a node leaves the cluster, subscribers on that node lose their connections (normal). Subscribers on other nodes are unaffected. But events that were in-flight from the departed node are lost.
|
||||
- **No cluster health observability:** No metrics for inter-node fanout lag, message drops, or membership changes.
|
||||
|
||||
**Recommendation for initial production:** Deploy single-node. Clustering is a Phase B concern per the documented roadmap.
|
||||
|
||||
---
|
||||
|
||||
## 3. Load & Capacity Characterisation — 🟡
|
||||
|
||||
### What exists
|
||||
|
||||
- `LoadSoakTest` asserts p95 fanout enqueue/drain < 25ms.
|
||||
- `bench/` directory with `nostr-bench` submodule for external load testing.
|
||||
- Cloud bench orchestration scripts (`scripts/cloud_bench_orchestrate.mjs`, `scripts/cloud_bench_server.sh`).
|
||||
|
||||
### Gaps
|
||||
|
||||
**G3.1 — No documented capacity model.**
|
||||
There is no documented answer to: "How many connections / events per second can one node handle before degradation?" The `LoadSoakTest` runs locally with synthetic data — useful for regression detection but not representative of production traffic patterns.
|
||||
|
||||
**G3.2 — Multi-filter query scaling is in-memory dedup.**
|
||||
`Postgres.Events.query/3` runs each filter as a separate SQL query, collects all results into memory, and deduplicates with `deduplicate_events/1` (Map.update accumulation). With many overlapping filters or high-cardinality results, this could produce significant memory pressure per-request.
|
||||
- At realistic scales (< 10 filters, < 1000 results per filter), this is fine.
|
||||
- At adversarial scales (32 subscriptions × large result sets), a single REQ could allocate substantial memory.
|
||||
- **Current mitigation:** `max_tag_values_per_filter` (128) and query `LIMIT` bounds exist. The risk is bounded but not eliminated.
|
||||
|
||||
**G3.3 — No query performance benchmarks against large datasets.**
|
||||
No evidence of testing against 100M+ events with monthly partitions. Partition pruning is implemented, but query plans may degrade if the partition list grows large (PostgreSQL planner overhead scales with partition count).
|
||||
|
||||
**Recommendation:** Before production, run `nostr-bench` at target load (e.g., 500 concurrent connections, 100 events/sec ingest, 1000 active subscriptions) and document the resulting latency profile. This becomes the baseline capacity model.
|
||||
|
||||
---
|
||||
|
||||
## 4. Deployment & Infrastructure Readiness — 🟡
|
||||
|
||||
### What's good
|
||||
|
||||
- **Docker image via Nix:** Non-root user (65534:65534), minimal base, cacerts bundled, SSL_CERT_FILE set. This is production-quality container hygiene.
|
||||
- **OTP release:** `mix release` with `Parrhesia.Release.migrate/0` for safe migration execution.
|
||||
- **CI pipeline:** Multi-matrix testing (OTP 27/28, Elixir 1.18/1.19), format/credo/unused deps checks, E2E tests.
|
||||
- **Environment-based configuration:** All critical settings overridable via `PARRHESIA_*` env vars in `runtime.exs`.
|
||||
- **Secrets:** No secrets committed. DB credentials via `DATABASE_URL`, identity key via env or file path.
|
||||
|
||||
### Gaps
|
||||
|
||||
**G4.1 — No zero-downtime migration strategy.**
|
||||
`Parrhesia.Release.migrate/0` runs `Ecto.Migrator.run/4` with `:up`. Under replicated deployments (rolling update with 2+ instances), there is no advisory lock or migration guard — two instances starting simultaneously could race on migrations. Ecto's default migrator uses `pg_advisory_lock` via `Ecto.Migration.Runner`, so this is actually safe for PostgreSQL. However:
|
||||
- **DDL migrations (CREATE INDEX CONCURRENTLY, ALTER TABLE) need careful handling.** The existing migrations use standard `CREATE TABLE` and `CREATE INDEX` which acquire ACCESS EXCLUSIVE locks. Running these against a live database will block reads and writes for the duration.
|
||||
- **Recommendation:** For production, migrations should be run as a separate step before deploying new code (the compose.yaml already has a `migrate` service — extend this pattern).
|
||||
|
||||
**G4.2 — No operational runbooks.**
|
||||
There are no documented procedures for:
|
||||
- Rolling restart / blue-green deploy
|
||||
- Partition pruning and retention tuning
|
||||
- Runtime pubkey banning (the NIP-86 management API exists but isn't documented for ops use)
|
||||
- DB failover response
|
||||
- Scaling (horizontal or vertical)
|
||||
|
||||
**G4.3 — No health check in Docker image.**
|
||||
The Nix-built Docker image has no `HEALTHCHECK` instruction. The `/health` and `/ready` endpoints exist but aren't wired into container orchestration.
|
||||
- **Recommendation:** Add `HEALTHCHECK CMD curl -f http://localhost:4413/ready || exit 1` to the Docker image definition, or document the readiness endpoint for Kubernetes probes.
|
||||
|
||||
**G4.4 — No disaster recovery plan.**
|
||||
No documented RTO/RPO. If the primary DB is lost, recovery depends entirely on external backup infrastructure. The relay has no built-in data export or snapshot capability.
|
||||
|
||||
---
|
||||
|
||||
## 5. Security Hardening — 🟢
|
||||
|
||||
### Assessment
|
||||
|
||||
The security posture is solid for production behind a reverse proxy:
|
||||
|
||||
- **TLS:** Full support for server, mutual, and proxy-terminated TLS modes. Cipher suite selection (strong/compatible). Certificate pin verification.
|
||||
- **Rate limiting:** Three layers — relay-wide (10k/s), per-IP (1k/s), per-connection (120/s). All configurable.
|
||||
- **Metrics endpoint:** Access-controlled via `metrics_allowed?/2` — supports private-network-only restriction and bearer token auth. Tested.
|
||||
- **NIP-42 auth:** Constant-time comparison via `Plug.Crypto.secure_compare/2` (addressed in beta).
|
||||
- **NIP-98:** Replay protection, event freshness check (< 60s), signature verification.
|
||||
- **Input validation:** Binary field length constraints at DB level (migration 7). Event size limits at WebSocket frame level.
|
||||
- **IP controls:** Trusted proxy CIDR configuration, X-Forwarded-For parsing, IP blocklist table.
|
||||
- **Audit logging:** `management_audit_logs` table tracks admin actions.
|
||||
- **No secrets in git.** Environment variable or file-path based secret injection.
|
||||
|
||||
### Minor considerations (not blocking)
|
||||
|
||||
- No integration with external threat intel feeds or IP reputation services. This is an infrastructure concern, not an application concern.
|
||||
- DDoS mitigation assumed to be at load balancer / CDN layer. Application-level rate limiting is defense-in-depth, not primary.
|
||||
- **Recommendation:** Document the expected deployment topology (Caddy/Nginx → Parrhesia) and which security controls are expected at each layer.
|
||||
|
||||
---
|
||||
|
||||
## 6. Data Integrity & Consistency — 🟢
|
||||
|
||||
### What's good
|
||||
|
||||
- **Duplicate event prevention:** Two-layer defence:
|
||||
1. `event_ids` table with unique PK on `id` — `INSERT ... ON CONFLICT DO NOTHING`.
|
||||
2. If `inserted == 0`, transaction rolls back with `:duplicate_event`.
|
||||
3. Separate unique index on `events.id` as belt-and-suspenders.
|
||||
- **Atomic writes:** `put_event/2` wraps `insert_event_id!`, `insert_event!`, `insert_tags!`, and `upsert_state_tables!` in a single `Repo.transaction/1`. Partial writes (event without tags) cannot occur.
|
||||
- **Replaceable/addressable event state:** Upsert logic in state tables with correct conflict resolution (higher `created_at` wins, then lower `id` as tiebreaker via `candidate_wins_state?/2`).
|
||||
|
||||
### Minor considerations
|
||||
|
||||
**G6.1 — Expiration worker concurrency on multi-node.**
|
||||
`ExpirationWorker` runs `Repo.delete_all/1` against all expired events. If two nodes run this worker against the same database, both execute the same DELETE query. PostgreSQL handles this safely (the second DELETE finds 0 rows), and the worker is idempotent. **Not a problem.**
|
||||
|
||||
**G6.2 — Partition pruning and sync.**
|
||||
`PartitionRetentionWorker.drop_partition/1` drops entire monthly partitions. If negentropy sync is in progress against events in that partition, the sync session's cached refs become stale. The session would fail or return incomplete results.
|
||||
- **Impact:** Low. Partition drops are infrequent (daily check, at most 1 per run). Negentropy sessions are short-lived (60s idle timeout).
|
||||
- **Recommendation:** No action needed for initial production. If operating as a sync source relay, consider pausing sync during partition drops.
|
||||
|
||||
---
|
||||
|
||||
## 7. Observability Completeness — 🟡
|
||||
|
||||
### What's good
|
||||
|
||||
Metrics coverage is comprehensive — 34+ distinct metrics covering:
|
||||
- Ingest: event count by outcome/reason, duration distribution
|
||||
- Query: request count, duration, result cardinality
|
||||
- Fanout: duration, candidates considered, events enqueued, batch size
|
||||
- Connection: outbound queue depth/pressure/overflow/drop, mailbox depth
|
||||
- Rate limiting: hit count by scope
|
||||
- DB: query count/total_time/queue_time/query_time/decode_time/idle_time by repo role
|
||||
- Maintenance: expiration purge count/duration, partition retention drops/duration
|
||||
- VM: memory (total/processes/system/atom/binary/ets)
|
||||
- Listener: active connections, active subscriptions
|
||||
|
||||
Readiness endpoint checks critical process liveness. Health endpoint for basic reachability.
|
||||
|
||||
### Gaps
|
||||
|
||||
**G7.1 — No dashboards or alerting rules.**
|
||||
The metrics exist but there are no Grafana dashboard JSON files, no Prometheus alerting rules, and no documented alert thresholds. An operator deploying this relay would need to build observability from scratch.
|
||||
- **Recommendation:** Ship a `deploy/grafana/` directory with a dashboard JSON and a `deploy/prometheus/alerts.yml` with rules for:
|
||||
- `parrhesia_db_query_queue_time_ms` p95 > 100ms (pool saturation)
|
||||
- `parrhesia_connection_outbound_queue_overflow_count` rate > 0 (clients being dropped)
|
||||
- `parrhesia_rate_limit_hits_count` rate sustained > threshold (potential abuse)
|
||||
- `parrhesia_vm_memory_total_bytes` > 80% of available
|
||||
- Listener connection count approaching `max_connections`
|
||||
|
||||
**G7.2 — No distributed tracing or request correlation IDs.**
|
||||
Events flow through validate → policy → persist → fanout without a correlation ID tying the stages together. Log-based debugging of "why didn't this event reach subscriber X" requires manual PID correlation across log lines.
|
||||
- **Impact:** Tolerable for initial production at moderate scale. Becomes painful at high event rates.
|
||||
|
||||
**G7.3 — No synthetic monitoring.**
|
||||
No built-in probe that ingests a canary event and verifies it arrives at a subscriber. End-to-end relay health depends on external monitoring.
|
||||
- **Recommendation:** This is best implemented as an external tool. Not blocking.
|
||||
|
||||
---
|
||||
|
||||
## 8. Technical Debt with Production Impact — 🟡
|
||||
|
||||
### G8.1 — `connection.ex` at 2,116 lines
|
||||
|
||||
This module is the per-connection state machine handling EVENT, REQ, CLOSE, AUTH, COUNT, NEG-*, keepalive, outbound queue management, rate limiting, and all associated telemetry. It is the single most critical file for production incident response.
|
||||
|
||||
**Production risk:** During a production incident involving connection behavior, an on-call engineer needs to quickly navigate this module. At 2,116 lines with interleaved concerns (protocol parsing, policy enforcement, queue management, telemetry emission), this slows incident response.
|
||||
|
||||
**Recommendation (M-sized effort):** Extract into focused modules:
|
||||
- `Connection.Ingest` — EVENT handling and policy application
|
||||
- `Connection.Subscription` — REQ/CLOSE management and initial query streaming
|
||||
- `Connection.OutboundQueue` — queue/drain/overflow logic
|
||||
- `Connection.Keepalive` — ping/pong state machine
|
||||
|
||||
The main `Connection` module would become an orchestrator delegating to these. This is a refactor-only change with no behavioral impact.
|
||||
|
||||
### G8.2 — Multi-filter in-memory dedup
|
||||
|
||||
`deduplicate_events/1` accumulates all query results into a Map before deduplication. With 32 subscriptions (the max) and generous limits, worst case is:
|
||||
- 32 filters × 5000 result limit = 160,000 events loaded into memory per REQ.
|
||||
|
||||
Each event struct is ~500 bytes minimum, so ~80MB per pathological request. This is bounded but could be weaponised by an attacker sending many concurrent REQs with overlapping filters.
|
||||
|
||||
**Current mitigation:** Per-connection subscription limit (32) and query result limits bound the damage. Per-IP rate limiting adds friction.
|
||||
|
||||
**Recommendation:** Not blocking for production. Monitor `parrhesia.query.results.count` distribution. If p99 > 10,000, investigate query patterns.
|
||||
|
||||
### G8.3 — Per-pubkey rate limiting absent
|
||||
|
||||
Rate limiting is currently per-IP and relay-wide. An attacker using a botnet (many IPs, one pubkey) bypasses IP-based limits. Per-pubkey rate limiting would catch this.
|
||||
|
||||
**Impact:** Medium for a public relay; low for an invite-only (NIP-43) relay.
|
||||
|
||||
**Recommendation (S-sized effort):** Add a per-pubkey event ingest limiter similar to `IPEventIngestLimiter`, keyed by `event.pubkey`. Apply after signature verification but before storage.
|
||||
|
||||
### G8.4 — Negentropy session memory ceiling
|
||||
|
||||
Negentropy session bounds:
|
||||
- Max 10,000 total sessions (`@default_max_total_sessions`)
|
||||
- Max 8 per connection (`@default_max_sessions_per_owner`)
|
||||
- Max 50,000 items per session (`@default_max_items_per_session`)
|
||||
- 60s idle timeout with 10s sweep interval
|
||||
|
||||
Worst case: 10,000 sessions × 50,000 items × ~40 bytes/ref = ~20GB. This is the theoretical maximum under adversarial session creation.
|
||||
|
||||
**Realistic ceiling:** The `open/6` path runs a DB query bounded by `max_items_per_session + 1`. At 50k items, this query itself provides backpressure (it takes time). An attacker would need 10,000 concurrent connections each opening 8 sessions, each returning 50k results. The relay-wide connection limit and rate limiting make this implausible in practice.
|
||||
|
||||
**Recommendation:** Reduce `@default_max_items_per_session` to 10,000 for production (reduces theoretical ceiling to ~4GB). This is a config change, not a code change.
|
||||
|
||||
---
|
||||
|
||||
## Critical Path to Production
|
||||
|
||||
Ordered by priority. Items above the line are required before production traffic; items below are strongly recommended.
|
||||
|
||||
| # | Work Item | Dimension | Effort |
|
||||
|---|-----------|-----------|--------|
|
||||
| 1 | Set explicit shutdown timeout on Endpoint child spec | Operational | S |
|
||||
| 2 | Document dedicated metrics listener as production requirement | Operational | S |
|
||||
| 3 | Add HEALTHCHECK to Docker image or document K8s probes | Deployment | S |
|
||||
| 4 | Run capacity benchmark at target load and document results | Load | M |
|
||||
| 5 | Ship Grafana dashboard + Prometheus alert rules | Observability | M |
|
||||
| 6 | Write operational runbook (deploy, rollback, ban, failover) | Deployment | M |
|
||||
| 7 | Document migration strategy (run before deploy, not during) | Deployment | S |
|
||||
| --- | --- | --- | --- |
|
||||
| 8 | Add per-pubkey rate limiting | Security | S |
|
||||
| 9 | Reduce default negentropy items-per-session to 10k | Security | S |
|
||||
| 10 | Extract connection.ex into sub-modules | Debt | M |
|
||||
| 11 | Add request correlation IDs to event lifecycle | Observability | M |
|
||||
| 12 | Add DB pool health fast-fail wrapper | Operational | M |
|
||||
|
||||
---
|
||||
|
||||
## Production Risk Register
|
||||
|
||||
| ID | Risk | Likelihood | Impact | Mitigation |
|
||||
|----|------|-----------|--------|------------|
|
||||
| R1 | PostgreSQL failover causes latency spike for all connections | Medium | High | G1.1: Ecto queue management provides partial protection. Add pool health telemetry alerting. Consider circuit breaker at high connection counts. |
|
||||
| R2 | Slow /metrics scrape blocks health checks | Low | Medium | G1.2: Deploy dedicated metrics listener (already supported). |
|
||||
| R3 | Ungraceful shutdown drops in-flight events | Low | Medium | G1.3: Set explicit shutdown timeout. Connection drain logic already exists. |
|
||||
| R4 | Multi-IP spam campaign bypasses rate limiting | Medium | Medium | G8.3: Add per-pubkey rate limiter. NIP-43 invite-only mode mitigates for private relays. |
|
||||
| R5 | Large REQ with many overlapping filters causes memory spike | Low | Medium | G8.2: Bounded by existing limits. Monitor query result cardinality. |
|
||||
| R6 | No alerting means silent degradation | Medium | High | G7.1: Ship dashboard and alert rules before production. |
|
||||
| R7 | DDL migration blocks reads during rolling deploy | Low | High | G4.1: Run migrations as separate pre-deploy step. |
|
||||
| R8 | Adversarial negentropy session creation exhausts memory | Low | High | G8.4: Reduce max items per session. Existing session limits provide protection. |
|
||||
| R9 | No runbooks slows incident response | Medium | Medium | G4.2: Write runbooks for common ops tasks. |
|
||||
| R10 | connection.ex complexity slows debugging | Medium | Low | G8.1: Extract sub-modules. Not urgent but improves maintainability. |
|
||||
|
||||
---
|
||||
|
||||
## Final Verdict
|
||||
|
||||
### 🟡 Ready for Limited Production
|
||||
|
||||
**Constraints for initial deployment:**
|
||||
|
||||
1. **Single-node only.** Multi-node clustering is best-effort and should not be relied upon for production traffic. Deploy one node with a properly sized PostgreSQL instance.
|
||||
|
||||
2. **Behind a reverse proxy.** Deploy behind Caddy, Nginx, or a cloud load balancer for TLS termination, DDoS mitigation, and connection limits. Document the expected topology.
|
||||
|
||||
3. **Moderate traffic cap.** Without a validated capacity model, start with conservative limits:
|
||||
- ≤ 2,000 concurrent WebSocket connections
|
||||
- ≤ 500 events/second ingest rate
|
||||
- Monitor `db.query.queue_time.ms` p95 and `connection.outbound_queue.overflow.count` as scaling signals.
|
||||
|
||||
4. **Observability must be deployed alongside.** The metrics exist but dashboards and alerts do not. Do not go live without at minimum:
|
||||
- Prometheus scraping the dedicated metrics listener
|
||||
- Alerts on DB queue time, outbound queue overflow, and VM memory
|
||||
- Log aggregation with ERROR-level alerts
|
||||
|
||||
5. **Migrations run pre-deploy.** Use the existing compose.yaml `migrate` service pattern. Never run migrations as part of application startup in a multi-replica deployment.
|
||||
|
||||
**What's strong:**
|
||||
- OTP supervision architecture is clean and fault-isolated
|
||||
- Data integrity layer is well-designed (transactional writes, dedup, constraint enforcement)
|
||||
- Security posture is production-appropriate
|
||||
- Telemetry coverage is comprehensive
|
||||
- Container image follows best practices
|
||||
- No blocking issues in the hot path (no sleeps, no synchronous calls, bounded queues)
|
||||
|
||||
**The codebase is architecturally sound for production.** The gaps are operational (runbooks, dashboards, capacity planning) rather than structural. A focused sprint addressing items 1–7 from the critical path would clear the way for a controlled production launch.
|
||||
@@ -3,8 +3,8 @@
|
||||
Parrhesia can run as a normal standalone relay application, but it also exposes a stable
|
||||
in-process API for Elixir callers that want to embed the relay inside a larger OTP system.
|
||||
|
||||
This document describes that embedding surface. The runtime is still alpha, so treat the API
|
||||
as usable but not yet frozen.
|
||||
This document describes that embedding surface. The runtime is now beta, so treat the API
|
||||
as usable with minor churn possible while it stabilizes.
|
||||
|
||||
## What embedding means today
|
||||
|
||||
@@ -14,6 +14,7 @@ Embedding currently means:
|
||||
- the host app provides `config :parrhesia, ...` explicitly
|
||||
- the host app migrates the Parrhesia database schema
|
||||
- callers interact with the relay through `Parrhesia.API.*`
|
||||
- host-managed HTTP/WebSocket ingress is mounted through `Parrhesia.Plug`
|
||||
|
||||
Current operational assumptions:
|
||||
|
||||
@@ -63,9 +64,14 @@ config :parrhesia, ecto_repos: [Parrhesia.Repo]
|
||||
|
||||
Notes:
|
||||
|
||||
- Set `listeners: %{}` if you only want the in-process API and no HTTP/WebSocket ingress.
|
||||
- If you do want ingress, copy the listener shape from the config reference in
|
||||
[README.md](../README.md).
|
||||
- `listeners: %{}` is the official embedding pattern when your host app owns the HTTPS edge.
|
||||
- `listeners: %{}` disables Parrhesia-managed ingress (`/relay`, `/management`, `/metrics`, etc.).
|
||||
- Mount `Parrhesia.Plug` from the host app when you still want Parrhesia ingress behind that same
|
||||
HTTPS edge.
|
||||
- `Parrhesia.Web.*` modules are internal runtime wiring. Treat `Parrhesia.Plug` as the stable
|
||||
mount API.
|
||||
- If you prefer Parrhesia-managed ingress instead, copy the listener shape from the config
|
||||
reference in [README.md](../README.md).
|
||||
- Production runtime overrides still use the `PARRHESIA_*` environment variables described in
|
||||
[README.md](../README.md).
|
||||
|
||||
@@ -77,6 +83,27 @@ Parrhesia.Release.migrate()
|
||||
|
||||
In development, `mix ecto.migrate -r Parrhesia.Repo` works too.
|
||||
|
||||
## Mounting `Parrhesia.Plug` from a host app
|
||||
|
||||
When `listeners: %{}` is set, you can still expose Parrhesia ingress by mounting `Parrhesia.Plug`
|
||||
in your host endpoint/router and passing an explicit listener config:
|
||||
|
||||
```elixir
|
||||
forward "/nostr", Parrhesia.Plug,
|
||||
listener: %{
|
||||
id: :public,
|
||||
transport: %{scheme: :https, tls: %{mode: :proxy_terminated}},
|
||||
proxy: %{trusted_cidrs: ["10.0.0.0/8"], honor_x_forwarded_for: true},
|
||||
features: %{
|
||||
nostr: %{enabled: true},
|
||||
admin: %{enabled: true},
|
||||
metrics: %{enabled: true, access: %{private_networks_only: true}}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use the same listener schema documented in [README.md](../README.md).
|
||||
|
||||
## Starting the runtime
|
||||
|
||||
In the common case, letting OTP start the `:parrhesia` application is enough.
|
||||
|
||||
1
docs/nips
Submodule
1
docs/nips
Submodule
Submodule docs/nips added at 3492eb1aff
351
docs/slop/ALPHA_REVIEW.md
Normal file
351
docs/slop/ALPHA_REVIEW.md
Normal file
@@ -0,0 +1,351 @@
|
||||
# Parrhesia Alpha Code Review
|
||||
|
||||
**Reviewer:** Claude Opus 4.6 (automated review)
|
||||
**Date:** 2026-03-20
|
||||
**Version:** 0.6.0
|
||||
**Scope:** Full codebase review across 8 dimensions for alpha-to-beta promotion decision
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
Parrhesia is a well-architected Nostr relay with mature OTP supervision design, comprehensive telemetry, and solid protocol implementation covering 15+ NIPs. The codebase demonstrates strong Elixir idioms — heavy ETS usage for hot paths, process monitoring for cleanup, and async-first patterns. The test suite (58 files, ~8K LOC) covers critical protocol paths including signature verification, malformed input rejection, and database integration.
|
||||
|
||||
**Most critical gaps:** No WebSocket-level ping/pong keepalives, no constant-time comparison for NIP-42 challenge validation, and the `effective_filter_limit` function can return `nil` when called outside the standard API path (though the default config sets `max_filter_limit: 500`). Property-based testing is severely underutilised despite `stream_data` being a dependency.
|
||||
|
||||
**Recommendation:** **Promote with conditions** — the codebase is production-quality for beta with two items to address first.
|
||||
|
||||
---
|
||||
|
||||
## 2. Dimension-by-Dimension Findings
|
||||
|
||||
### 2.1 Elixir Code Quality
|
||||
|
||||
**Rating: ✅ Good**
|
||||
|
||||
**Supervision tree:** Single-rooted `Parrhesia.Runtime` supervisor with `:one_for_one` strategy. 12 child supervisors/workers, each with appropriate isolation. Storage, subscriptions, auth, sync, policy, tasks, and web endpoint each have their own supervisor subtree. Restart strategies are correct — no `rest_for_one` or `one_for_all` where unnecessary.
|
||||
|
||||
**GenServer usage:** Idiomatic throughout. State-heavy GenServers (Subscriptions.Index, Auth.Challenges, Negentropy.Sessions) properly monitor owner processes and clean up via `:DOWN` handlers. Stateless dispatchers (Fanout.Dispatcher) use `cast` appropriately. No unnecessary `call` serialisation found.
|
||||
|
||||
**ETS usage:** 9+ ETS tables with appropriate access patterns:
|
||||
- Config cache: `:public`, `read_concurrency: true` — correct for hot-path reads
|
||||
- Rate limiters: `write_concurrency: true` — correct for high-throughput counters
|
||||
- Subscription indices: `:protected`, `read_concurrency: true` — correct (only Index GenServer writes)
|
||||
|
||||
**Error handling:** Consistent `{:ok, _}` / `{:error, _}` tuples throughout the API layer. No bare `throw` found. Connection handler wraps external calls in try/catch to prevent cascade failures. Rate limiter fallback returns `:ok` on service unavailability (availability over correctness — documented trade-off).
|
||||
|
||||
**Pattern matching:** Exhaustive in message handling. Protocol decoder has explicit clauses for each message type with a catch-all error clause. Event validator chains `with` clauses with explicit error atoms.
|
||||
|
||||
**Potential bottlenecks:**
|
||||
- `Subscriptions.Index` is a single GenServer handling all subscription mutations. Reads go through ETS (fast), but `upsert`/`remove` operations serialise through the GenServer. At very high subscription churn (thousands of REQ/CLOSE per second), this could become a bottleneck. Acceptable for beta.
|
||||
- `Negentropy.Sessions` holds all session state in process memory. Capped at 10K sessions with idle sweep — adequate.
|
||||
|
||||
**Module structure:** Clean separation of concerns. `web/` for transport, `protocol/` for parsing/validation, `api/` for business logic, `storage/` for persistence with adapter pattern, `policy/` for authorisation. 99% of modules have `@moduledoc` and `@spec` annotations.
|
||||
|
||||
**Findings:**
|
||||
- `connection.ex` is 1,925 lines — large but cohesive (per-connection state machine). Consider extracting queue management into a submodule in future.
|
||||
- `storage/adapters/postgres/events.ex` is the heaviest module — handles normalisation, queries, and tag indexing. Well-factored internally.
|
||||
|
||||
---
|
||||
|
||||
### 2.2 Nostr Protocol Correctness (NIPs Compliance)
|
||||
|
||||
**Rating: ✅ Good**
|
||||
|
||||
**NIPs implemented (advertised via NIP-11):**
|
||||
NIP-1, NIP-9, NIP-11, NIP-13, NIP-17, NIP-40, NIP-42, NIP-44, NIP-45, NIP-50, NIP-59, NIP-62, NIP-70, NIP-77, NIP-86, NIP-98. Conditional: NIP-43, NIP-66.
|
||||
|
||||
**NIP-01 (Core Protocol):**
|
||||
- Event structure: 7 required fields validated (`id`, `pubkey`, `created_at`, `kind`, `tags`, `content`, `sig`)
|
||||
- Event ID: SHA-256 over canonical JSON serialisation `[0, pubkey, created_at, kind, tags, content]` — verified in `validate_id_hash/1`
|
||||
- Signature: BIP-340 Schnorr via `lib_secp256k1` — `Secp256k1.schnorr_valid?(id_bin, sig_bin, pubkey_bin)`
|
||||
- Filters: `ids`, `authors`, `kinds`, `since`, `until`, `limit`, `search`, `#<letter>` tag filters — all implemented
|
||||
- Messages: EVENT, REQ, CLOSE, NOTICE, OK, EOSE, COUNT, AUTH — all implemented with correct response format
|
||||
- Subscription IDs: validated as non-empty strings, max 64 chars
|
||||
|
||||
**Event ID verification:** Correct. Computes SHA-256 over `JSON.encode!([0, pubkey_hex, created_at, kind, tags, content])` and compares against claimed `id`. Binary decoding verified.
|
||||
|
||||
**Signature verification:** Uses `lib_secp256k1` (wrapper around Bitcoin Core's libsecp256k1). Schnorr verification correct per BIP-340. Can be disabled via feature flag (appropriate for testing/development).
|
||||
|
||||
**Malformed event rejection:** Comprehensive. Invalid hex, wrong byte lengths, future timestamps (>15 min), non-integer kinds, non-string content, non-array tags — all produce specific error atoms returned via OK message with `false` status.
|
||||
|
||||
**Filter application:** Correct implementation. Each dimension (ids, authors, kinds, since, until, tags, search) is an independent predicate; all must match for a filter to match. Multiple filters are OR'd together (`matches_any?`).
|
||||
|
||||
**Spec deviations:**
|
||||
- Tag filter values capped at 128 per filter (configurable) — stricter than spec but reasonable
|
||||
- Max 16 filters per REQ (configurable) — reasonable relay policy
|
||||
- Max 256 tags per event — reasonable relay policy
|
||||
- Search is substring/FTS, not regex — spec doesn't mandate regex
|
||||
|
||||
---
|
||||
|
||||
### 2.3 WebSocket Handling
|
||||
|
||||
**Rating: ⚠️ Needs Work**
|
||||
|
||||
**Backpressure: ✅ Excellent.**
|
||||
Three-tier strategy with configurable overflow behaviour:
|
||||
1. `:close` (default) — closes connection on queue overflow with NOTICE
|
||||
2. `:drop_newest` — silently drops incoming events
|
||||
3. `:drop_oldest` — drops oldest queued event, enqueues new
|
||||
|
||||
Queue depth defaults: max 256, drain batch size 64. Pressure monitoring at 75% threshold emits telemetry. Batch draining prevents thundering herd.
|
||||
|
||||
**Connection cleanup: ✅ Solid.**
|
||||
`terminate/1` removes subscriptions from global index, unsubscribes from streams, clears auth challenges, decrements connection stats. Process monitors in Index/Challenges/Sessions provide backup cleanup on unexpected exits.
|
||||
|
||||
**Message size limits: ✅ Enforced.**
|
||||
- Frame size: 1MB default (`max_frame_bytes`), checked before JSON parsing
|
||||
- Event size: 256KB default (`max_event_bytes`), checked before DB write
|
||||
- Both configurable via environment variables
|
||||
|
||||
**Per-connection subscription limit: ✅ Enforced.**
|
||||
Default 32 subscriptions per connection. Checked before opening new REQ. Returns CLOSED with `rate-limited:` prefix.
|
||||
|
||||
**Ping/pong keepalives: ⚠️ Not implemented (optional).**
|
||||
No server-initiated WebSocket PING frames. RFC 6455 §5.5.2 makes PING optional ("MAY be sent"), and NIP-01 does not require keepalives. Bandit correctly responds to client-initiated PINGs per spec. However, server-side pings are a production best practice for detecting dead connections behind NAT/proxies — without them, the server relies on TCP-level detection which can take minutes.
|
||||
|
||||
**Per-connection event ingest rate limiting: ✅ Implemented.**
|
||||
Default 120 events/second per connection with sliding window. Per-IP limiting at 1,000 events/second. Relay-wide cap at 10,000 events/second.
|
||||
|
||||
---
|
||||
|
||||
### 2.4 PostgreSQL / Database Layer
|
||||
|
||||
**Rating: ✅ Good**
|
||||
|
||||
**Schema design:**
|
||||
- Events table range-partitioned by `created_at` (monthly partitions)
|
||||
- Normalised `event_tags` table with composite FK to events
|
||||
- JSONB `tags` column for efficient serialisation (denormalised copy of normalised tags)
|
||||
- Binary storage for pubkeys/IDs/sigs with CHECK constraints on byte lengths
|
||||
|
||||
**Indexes:** Comprehensive and appropriate:
|
||||
- `events(kind, created_at DESC)` — kind queries
|
||||
- `events(pubkey, created_at DESC)` — author queries
|
||||
- `events(created_at DESC)` — time-range queries
|
||||
- `events(id)` — direct lookup
|
||||
- `events(expires_at) WHERE expires_at IS NOT NULL` — expiration pruning
|
||||
- `event_tags(name, value, event_created_at DESC)` — tag filtering
|
||||
- GIN index on `to_tsvector('simple', content)` — full-text search
|
||||
- GIN trigram index on `content` — fuzzy search
|
||||
|
||||
**Tag queries:** Efficient. Uses `EXISTS (SELECT 1 FROM event_tags ...)` subqueries with parameterised values. Primary tag filter applied first, remaining filters chained. No N+1 — bulk tag insert via `Repo.insert_all`.
|
||||
|
||||
**Connection pools:** Write pool (32 default) and optional separate read pool (32 default). Queue target/interval configured at 1000ms/5000ms. Pool sizing configurable via environment variables.
|
||||
|
||||
**Bounded results:** `max_filter_limit` defaults to 500 in config. Applied at query level via `LIMIT` clause. Post-query `Enum.take` as safety net. The `effective_filter_limit/2` function can return `nil` if both filter and opts lack a limit — but the standard API path always passes `max_filter_limit` from config.
|
||||
|
||||
**Event expiration:** NIP-40 `expiration` tags extracted during normalisation. `ExpirationWorker` runs every 30 seconds, executing `DELETE FROM events WHERE expires_at IS NOT NULL AND expires_at <= now()`. Index on `expires_at` makes this efficient.
|
||||
|
||||
**Partition management:** `PartitionRetentionWorker` ensures partitions exist 2 months ahead, drops oldest partitions based on configurable retention window and max DB size. Limited to 1 drop per run to avoid I/O spikes. DDL operations use `CREATE TABLE IF NOT EXISTS` and validate identifiers against `^[a-zA-Z_][a-zA-Z0-9_]*$` to prevent SQL injection.
|
||||
|
||||
**Migrations:** Non-destructive. No `DROP COLUMN` or lock-heavy operations on existing tables. Additive indexes use `CREATE INDEX IF NOT EXISTS`. JSONB column added with data backfill in separate migration.
|
||||
|
||||
**Findings:**
|
||||
- Multi-filter queries use union + in-memory deduplication rather than SQL `UNION`. For queries with many filters returning large result sets, this could spike memory. Acceptable for beta given the 500-result default limit.
|
||||
- Replaceable/addressable state upserts use raw SQL CTEs — parameterised, no injection risk, but harder to maintain than Ecto queries.
|
||||
|
||||
---
|
||||
|
||||
### 2.5 Security
|
||||
|
||||
**Rating: ⚠️ Needs Work (one item)**
|
||||
|
||||
**Input validation: ✅ Thorough.**
|
||||
12-step validation pipeline for events. Hex decoding with explicit byte lengths. Tag structure validation. Content type checking. All before any DB write.
|
||||
|
||||
**Rate limiting: ✅ Well-designed.**
|
||||
Three tiers: per-connection (120/s), per-IP (1,000/s), relay-wide (10,000/s). ETS-backed with atomic counters. Configurable via environment variables. Telemetry on rate limit hits.
|
||||
|
||||
**SQL injection: ✅ No risks found.**
|
||||
All queries use Ecto parameterisation or `fragment` with positional placeholders. Raw SQL uses `$1, $2, ...` params. Partition identifier creation validates against regex before string interpolation.
|
||||
|
||||
**Amplification protection: ✅ Adequate.**
|
||||
- Giftwrap (kind 1059) queries blocked for unauthenticated users, restricted to recipients for authenticated users
|
||||
- `max_filter_limit: 500` bounds result sets
|
||||
- Frame/event size limits prevent memory exhaustion
|
||||
- Rate limiting prevents query flooding
|
||||
|
||||
**NIP-42 authentication: ⚠️ Minor issue.**
|
||||
Challenge generation uses `:crypto.strong_rand_bytes(16)` — correct. Challenge stored per-connection with process monitor cleanup — correct. However, challenge comparison uses standard Erlang binary equality (`==`), not constant-time comparison. While the 16-byte random challenge makes timing attacks impractical, using `Plug.Crypto.secure_compare/2` would be best practice.
|
||||
|
||||
**NIP-98 HTTP auth: ✅ Solid.**
|
||||
Base64-decoded event validated for kind (27235), freshness (±60s), method binding, URL binding, and signature. Replay cache prevents token reuse within TTL window.
|
||||
|
||||
**Moderation: ✅ Complete.**
|
||||
Banned pubkeys, allowed pubkeys, banned events, blocked IPs. ETS cache with lazy loading from PostgreSQL. ACL rules with principal/capability/match structure. Protected event enforcement (NIP-70).
|
||||
|
||||
---
|
||||
|
||||
### 2.6 Observability & Operability
|
||||
|
||||
**Rating: ✅ Good**
|
||||
|
||||
**Telemetry: ✅ Excellent.**
|
||||
25+ metrics covering ingest (count, duration, outcomes), queries (count, duration, result size), fanout (duration, batch size), connections (queue depth, pressure, overflow), rate limiting (hits by scope), process health (mailbox depth), database (queue time, query time, decode time), maintenance (expiration, partition retention), and VM memory.
|
||||
|
||||
**Prometheus endpoint: ✅ Implemented.**
|
||||
`/metrics` endpoint with access control. Histogram buckets configured for each metric type. Tagged metrics for cardinality management.
|
||||
|
||||
**Health check: ✅ Implemented.**
|
||||
`/ready` endpoint checks Subscriptions.Index, Auth.Challenges, Negentropy.Sessions (if enabled), and all PostgreSQL repos.
|
||||
|
||||
**Configuration: ✅ Fully externalised.**
|
||||
100+ config keys with environment variable overrides in `config/runtime.exs`. Helpers for `int_env`, `bool_env`, `csv_env`, `json_env`, `infinity_or_int_env`, `ipv4_env`. No hardcoded ports, DB URLs, or limits in application code.
|
||||
|
||||
**Documentation: ✅ Strong.**
|
||||
99% of modules have `@moduledoc`. Public APIs have `@spec` annotations. Error types defined as type unions. Architecture docs in `docs/` covering clustering, sync, and local API.
|
||||
|
||||
**Logging: ⚠️ Functional but basic.**
|
||||
Uses standard `Logger.error/warning` with `inspect()` formatting. No structured/JSON logging. Adequate for beta, but not ideal for log aggregation platforms.
|
||||
|
||||
---
|
||||
|
||||
### 2.7 Testing
|
||||
|
||||
**Rating: ✅ Good**
|
||||
|
||||
**Test suite:** 58 test files, ~8,000 lines of code.
|
||||
|
||||
**Unit tests:** Protocol validation, event validator (including signature verification), filter matching, auth challenges, NIP-98 replay cache, connection policy, event policy, config, negentropy engine/message/sessions.
|
||||
|
||||
**Integration tests:** 36 files using PostgreSQL sandbox. Event lifecycle (insert, query, update, delete), adapter contract tests, query plan regression tests, partition management, binary identifier constraints.
|
||||
|
||||
**Protocol edge cases tested:**
|
||||
- Invalid Schnorr signatures (when verification enabled)
|
||||
- Malformed JSON → `:invalid_json`
|
||||
- Invalid event structure → specific error atoms
|
||||
- Unknown filter keys → `:invalid_filter_key`
|
||||
- Tag filter value limits (128 max)
|
||||
- NIP-43 malformed relay access events, stale join requests
|
||||
- Marmot-specific validation (encoding tags, base64 content)
|
||||
- NIP-66 discovery event validation
|
||||
|
||||
**E2E tests:** WebSocket connection tests, TLS E2E, NAK CLI conformance, proxy IP extraction.
|
||||
|
||||
**Load test:** `LoadSoakTest` verifying p95 fanout latency under 25ms.
|
||||
|
||||
**Property-based tests: ⚠️ Minimal.**
|
||||
Single file (`FilterPropertyTest`) using `stream_data` for author filter membership. `stream_data` is a dependency but barely used. Significant opportunity to add property tests for event ID computation, filter boundary conditions, and tag parsing.
|
||||
|
||||
**Missing coverage:**
|
||||
- Cluster failover / multi-node crash recovery
|
||||
- Connection pool exhaustion under load
|
||||
- WebSocket frame fragmentation
|
||||
- Concurrent subscription mutation stress
|
||||
- Byzantine negentropy scenarios
|
||||
|
||||
---
|
||||
|
||||
### 2.8 Dependencies
|
||||
|
||||
**Rating: ✅ Good**
|
||||
|
||||
| Dependency | Version | Status |
|
||||
|---|---|---|
|
||||
| `bandit` | 1.10.3 | Current, actively maintained |
|
||||
| `plug` | 1.19.1 | Current, security-patched |
|
||||
| `ecto_sql` | 3.13.5 | Current |
|
||||
| `postgrex` | 0.22.0 | Current |
|
||||
| `lib_secp256k1` | 0.7.1 | Stable, wraps Bitcoin Core's libsecp256k1 |
|
||||
| `req` | 0.5.17 | Current |
|
||||
| `telemetry_metrics_prometheus` | 1.1.0 | Current |
|
||||
| `websockex` | 0.4.x | Test-only, stable |
|
||||
| `stream_data` | 1.3.0 | Current |
|
||||
| `credo` | 1.7.x | Dev-only, current |
|
||||
|
||||
**Cryptographic library assessment:** `lib_secp256k1` wraps libsecp256k1, the battle-tested C library from Bitcoin Core. Used only for Schnorr signature verification (BIP-340). Appropriate and trustworthy.
|
||||
|
||||
**No outdated or unmaintained dependencies.** No known CVEs in current dependency versions.
|
||||
|
||||
---
|
||||
|
||||
## 3. Top 5 Issues to Fix Before Beta
|
||||
|
||||
### 1. Add WebSocket Ping/Pong Keepalives
|
||||
**Severity:** High
|
||||
**Impact:** Long-lived subscriptions through proxies/NAT silently disconnect; server accumulates dead connections and leaked subscriptions until process monitor triggers (which requires the TCP connection to fully close).
|
||||
**Fix:** Implement periodic WebSocket PING frames (e.g., every 30s) in the connection handler. Close connections that don't respond within a timeout.
|
||||
**Files:** `lib/parrhesia/web/connection.ex`
|
||||
|
||||
### 2. Use Constant-Time Comparison for NIP-42 Challenges
|
||||
**Severity:** Medium (low practical risk due to 16-byte random challenge, but best practice)
|
||||
**Impact:** Theoretical timing side-channel on challenge validation.
|
||||
**Fix:** Replace `challenge == stored_challenge` with `Plug.Crypto.secure_compare(challenge, stored_challenge)` in `Auth.Challenges`.
|
||||
**Files:** `lib/parrhesia/auth/challenges.ex`
|
||||
|
||||
### 3. Expand Property-Based Testing
|
||||
**Severity:** Medium
|
||||
**Impact:** Undiscovered edge cases in event validation, filter matching, and tag parsing. `stream_data` is already a dependency but only used in one test file.
|
||||
**Fix:** Add property tests for: event ID computation with random payloads, filter boundary conditions (since/until edge cases), tag parsing with adversarial input, subscription index correctness under random insert/delete sequences.
|
||||
**Files:** `test/parrhesia/protocol/`
|
||||
|
||||
### 4. Add Structured Logging
|
||||
**Severity:** Low-Medium
|
||||
**Impact:** Log aggregation (ELK, Datadog, Grafana Loki) requires structured output. Current plaintext logs are adequate for development but make production debugging harder at scale.
|
||||
**Fix:** Add JSON log formatter (e.g., `LoggerJSON` or custom formatter). Include connection ID, subscription ID, and event kind as structured fields.
|
||||
**Files:** `config/config.exs`, new formatter module
|
||||
|
||||
### 5. Add Server-Initiated WebSocket PING Frames
|
||||
**Severity:** Low (not spec-required)
|
||||
**Impact:** RFC 6455 §5.5.2 makes PING optional, and NIP-01 does not require keepalives. However, without server-initiated pings, dead connections behind NAT/proxies are only detected via TCP-level timeouts (which can take minutes), during which subscriptions and state remain allocated.
|
||||
**Fix:** Consider periodic PING frames (e.g., every 30s) in the connection handler to proactively detect dead connections.
|
||||
**Files:** `lib/parrhesia/web/connection.ex`
|
||||
|
||||
---
|
||||
|
||||
## 4. Nice-to-Haves for Beta
|
||||
|
||||
1. **Extract queue management** from `connection.ex` (1,925 lines) into a dedicated `Parrhesia.Web.OutboundQueue` module for maintainability.
|
||||
|
||||
2. **Add request correlation IDs** to WebSocket connections for log tracing across the event lifecycle (ingest → validation → storage → fanout).
|
||||
|
||||
3. **SQL UNION for multi-filter queries** instead of in-memory deduplication. Would reduce memory spikes for queries with many filters, though the 500-result limit mitigates this.
|
||||
|
||||
4. **Slow query telemetry** — flag database queries exceeding a configurable threshold (e.g., 100ms) via dedicated telemetry event.
|
||||
|
||||
5. **Connection idle timeout** — close WebSocket connections with no activity for a configurable period (e.g., 30 minutes), independent of ping/pong.
|
||||
|
||||
6. **Per-pubkey rate limiting** — current rate limiting is per-connection and per-IP. A determined attacker could use multiple IPs. Per-pubkey limiting would add a layer but requires NIP-42 auth.
|
||||
|
||||
7. **OpenTelemetry integration** — for distributed tracing across multi-relay deployments.
|
||||
|
||||
8. **Negentropy session memory bounds** — while capped at 10K sessions, large filter sets within sessions could still consume significant memory. Consider per-session size limits.
|
||||
|
||||
---
|
||||
|
||||
## 5. Promotion Recommendation
|
||||
|
||||
### ⚠️ Promote with Conditions
|
||||
|
||||
**The codebase meets beta promotion criteria with one condition:**
|
||||
|
||||
**Condition: Use constant-time comparison for NIP-42 challenge validation.**
|
||||
While the practical risk is low (16-byte random challenge), this is a security best practice that takes one line to fix (`Plug.Crypto.secure_compare/2`).
|
||||
|
||||
**Criteria assessment:**
|
||||
|
||||
| Criterion | Status |
|
||||
|---|---|
|
||||
| NIP-01 correctly and fully implemented | ✅ Yes |
|
||||
| Event signature and ID verification cryptographically correct | ✅ Yes (lib_secp256k1 / BIP-340) |
|
||||
| No ❌ blocking in Security | ✅ No blockers (one minor item) |
|
||||
| No ❌ blocking in WebSocket handling | ✅ No blockers (ping/pong optional per RFC 6455) |
|
||||
| System does not crash or leak resources under normal load | ✅ Process monitors, queue management, rate limiting |
|
||||
| Working test suite covering critical protocol path | ✅ 58 files, 8K LOC, protocol edge cases covered |
|
||||
| Basic operability (config externalised, logs meaningful) | ✅ 100+ config keys, telemetry, Prometheus, health check |
|
||||
|
||||
**What's strong:**
|
||||
- OTP design is production-grade
|
||||
- Protocol implementation is comprehensive and spec-compliant
|
||||
- Security posture is solid (rate limiting, input validation, ACLs, moderation)
|
||||
- Telemetry coverage is excellent
|
||||
- Test suite covers critical paths
|
||||
- Dependencies are current and trustworthy
|
||||
|
||||
**What needs attention post-beta:**
|
||||
- Expand property-based testing
|
||||
- Add structured logging for production observability
|
||||
- Consider per-pubkey rate limiting
|
||||
- Connection handler module size (1,925 lines)
|
||||
10
flake.nix
10
flake.nix
@@ -2,6 +2,8 @@
|
||||
description = "Parrhesia Nostr relay";
|
||||
|
||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
# Can be reenabled once patched nostr-bench is up on GitHub
|
||||
# inputs.self.submodules = true;
|
||||
|
||||
outputs = {nixpkgs, ...}: let
|
||||
systems = [
|
||||
@@ -18,12 +20,18 @@
|
||||
packages = forAllSystems (
|
||||
system: let
|
||||
pkgs = import nixpkgs {inherit system;};
|
||||
pkgsLinux = import nixpkgs {system = "x86_64-linux";};
|
||||
lib = pkgs.lib;
|
||||
parrhesia = pkgs.callPackage ./default.nix {};
|
||||
nostrBench = pkgs.callPackage ./nix/nostr-bench.nix {};
|
||||
in
|
||||
{
|
||||
default = parrhesia;
|
||||
inherit parrhesia;
|
||||
inherit parrhesia nostrBench;
|
||||
|
||||
# Uses x86_64-linux pkgs so it can cross-build via a remote
|
||||
# builder even when the host is aarch64-darwin.
|
||||
nostrBenchStaticX86_64Musl = pkgsLinux.callPackage ./nix/nostr-bench.nix {staticX86_64Musl = true;};
|
||||
}
|
||||
// lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux {
|
||||
dockerImage = pkgs.dockerTools.buildLayeredImage {
|
||||
|
||||
79
justfile
Normal file
79
justfile
Normal file
@@ -0,0 +1,79 @@
|
||||
set shell := ["bash", "-euo", "pipefail", "-c"]
|
||||
|
||||
repo_root := justfile_directory()
|
||||
|
||||
# Show curated command help (same as `just help`).
|
||||
default:
|
||||
@just help
|
||||
|
||||
# Show top-level or topic-specific help.
|
||||
help topic="":
|
||||
@cd "{{repo_root}}" && ./scripts/just_help.sh "{{topic}}"
|
||||
|
||||
# Raw e2e harness commands.
|
||||
e2e subcommand *args:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "{{repo_root}}"
|
||||
subcommand="{{subcommand}}"
|
||||
|
||||
if [[ -z "$subcommand" || "$subcommand" == "help" ]]; then
|
||||
just help e2e
|
||||
elif [[ "$subcommand" == "nak" ]]; then
|
||||
./scripts/run_nak_e2e.sh {{args}}
|
||||
elif [[ "$subcommand" == "marmot" ]]; then
|
||||
./scripts/run_marmot_e2e.sh {{args}}
|
||||
elif [[ "$subcommand" == "node-sync" ]]; then
|
||||
./scripts/run_node_sync_e2e.sh {{args}}
|
||||
elif [[ "$subcommand" == "node-sync-docker" ]]; then
|
||||
./scripts/run_node_sync_docker_e2e.sh {{args}}
|
||||
elif [[ "$subcommand" == "suite" ]]; then
|
||||
if [[ -z "{{args}}" ]]; then
|
||||
echo "usage: just e2e suite <suite-name> <command> [args...]" >&2
|
||||
exit 1
|
||||
fi
|
||||
./scripts/run_e2e_suite.sh {{args}}
|
||||
else
|
||||
echo "Unknown e2e subcommand: $subcommand" >&2
|
||||
just help e2e
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Benchmark flows (local/cloud/history + direct relay targets).
|
||||
bench subcommand *args:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "{{repo_root}}"
|
||||
subcommand="{{subcommand}}"
|
||||
|
||||
if [[ -z "$subcommand" || "$subcommand" == "help" ]]; then
|
||||
just help bench
|
||||
elif [[ "$subcommand" == "compare" ]]; then
|
||||
./scripts/run_bench_compare.sh {{args}}
|
||||
elif [[ "$subcommand" == "collect" ]]; then
|
||||
./scripts/run_bench_collect.sh {{args}}
|
||||
elif [[ "$subcommand" == "update" ]]; then
|
||||
./scripts/run_bench_update.sh {{args}}
|
||||
elif [[ "$subcommand" == "list" ]]; then
|
||||
./scripts/run_bench_update.sh --list {{args}}
|
||||
elif [[ "$subcommand" == "at" ]]; then
|
||||
if [[ -z "{{args}}" ]]; then
|
||||
echo "usage: just bench at <git-ref>" >&2
|
||||
exit 1
|
||||
fi
|
||||
./scripts/run_bench_at_ref.sh {{args}}
|
||||
elif [[ "$subcommand" == "cloud" ]]; then
|
||||
./scripts/run_bench_cloud.sh {{args}}
|
||||
elif [[ "$subcommand" == "cloud-quick" ]]; then
|
||||
./scripts/run_bench_cloud.sh --quick {{args}}
|
||||
elif [[ "$subcommand" == "relay" ]]; then
|
||||
./scripts/run_nostr_bench.sh {{args}}
|
||||
elif [[ "$subcommand" == "relay-strfry" ]]; then
|
||||
./scripts/run_nostr_bench_strfry.sh {{args}}
|
||||
elif [[ "$subcommand" == "relay-nostr-rs" ]]; then
|
||||
./scripts/run_nostr_bench_nostr_rs_relay.sh {{args}}
|
||||
else
|
||||
echo "Unknown bench subcommand: $subcommand" >&2
|
||||
just help bench
|
||||
exit 1
|
||||
fi
|
||||
@@ -3,6 +3,7 @@ defmodule Parrhesia do
|
||||
Parrhesia is a Nostr relay runtime that can run standalone or as an embedded OTP service.
|
||||
|
||||
For embedded use, the main developer-facing surface is `Parrhesia.API.*`.
|
||||
For host-managed HTTP/WebSocket ingress mounting, use `Parrhesia.Plug`.
|
||||
Start with:
|
||||
|
||||
- `Parrhesia.API.Events`
|
||||
@@ -11,6 +12,7 @@ defmodule Parrhesia do
|
||||
- `Parrhesia.API.Identity`
|
||||
- `Parrhesia.API.ACL`
|
||||
- `Parrhesia.API.Sync`
|
||||
- `Parrhesia.Plug`
|
||||
|
||||
The host application is responsible for:
|
||||
|
||||
|
||||
@@ -71,6 +71,9 @@ defmodule Parrhesia.API.ACL do
|
||||
|
||||
`opts[:context]` defaults to an empty `Parrhesia.API.RequestContext`, which means protected
|
||||
subjects will fail with `{:error, :auth_required}` until authenticated pubkeys are present.
|
||||
|
||||
Local callers bypass ACL enforcement entirely. ACL is intended to protect external sync traffic,
|
||||
not trusted in-process calls.
|
||||
"""
|
||||
@spec check(atom(), map(), keyword()) :: :ok | {:error, term()}
|
||||
def check(capability, subject, opts \\ [])
|
||||
@@ -80,13 +83,8 @@ defmodule Parrhesia.API.ACL do
|
||||
context = Keyword.get(opts, :context, %RequestContext{})
|
||||
|
||||
with {:ok, normalized_capability} <- normalize_capability(capability),
|
||||
{:ok, normalized_context} <- normalize_context(context),
|
||||
{:ok, protected_filters} <- protected_filters() do
|
||||
if protected_subject?(normalized_capability, subject, protected_filters) do
|
||||
authorize_subject(normalized_capability, subject, normalized_context)
|
||||
else
|
||||
:ok
|
||||
end
|
||||
{:ok, normalized_context} <- normalize_context(context) do
|
||||
maybe_authorize_subject(normalized_capability, subject, normalized_context)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -134,6 +132,18 @@ defmodule Parrhesia.API.ACL do
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_authorize_subject(_capability, _subject, %RequestContext{caller: :local}), do: :ok
|
||||
|
||||
defp maybe_authorize_subject(capability, subject, %RequestContext{} = context) do
|
||||
with {:ok, protected_filters} <- protected_filters() do
|
||||
if protected_subject?(capability, subject, protected_filters) do
|
||||
authorize_subject(capability, subject, context)
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp list_rules_for_capability(capability) do
|
||||
Storage.acl().list_rules(%{}, principal_type: :pubkey, capability: capability)
|
||||
end
|
||||
|
||||
@@ -87,7 +87,7 @@ defmodule Parrhesia.API.Events do
|
||||
end
|
||||
|
||||
Dispatcher.dispatch(event)
|
||||
maybe_publish_multi_node(event)
|
||||
maybe_publish_multi_node(event, context)
|
||||
|
||||
{:ok,
|
||||
%PublishResult{
|
||||
@@ -312,9 +312,15 @@ defmodule Parrhesia.API.Events do
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_publish_multi_node(event) do
|
||||
defp maybe_publish_multi_node(event, %RequestContext{} = context) do
|
||||
relay_guard? = Parrhesia.Config.get([:sync, :relay_guard], false)
|
||||
|
||||
if relay_guard? and context.caller == :sync do
|
||||
:ok
|
||||
else
|
||||
MultiNode.publish(event)
|
||||
:ok
|
||||
end
|
||||
catch
|
||||
:exit, _reason -> :ok
|
||||
end
|
||||
|
||||
@@ -67,7 +67,16 @@ defmodule Parrhesia.Auth.Challenges do
|
||||
end
|
||||
|
||||
def handle_call({:valid?, owner_pid, challenge}, _from, state) do
|
||||
{:reply, Map.get(state.entries, owner_pid) == challenge, state}
|
||||
valid? =
|
||||
case Map.get(state.entries, owner_pid) do
|
||||
stored_challenge when is_binary(stored_challenge) ->
|
||||
Plug.Crypto.secure_compare(stored_challenge, challenge)
|
||||
|
||||
_other ->
|
||||
false
|
||||
end
|
||||
|
||||
{:reply, valid?, state}
|
||||
end
|
||||
|
||||
def handle_call({:clear, owner_pid}, _from, state) do
|
||||
|
||||
113
lib/parrhesia/plug.ex
Normal file
113
lib/parrhesia/plug.ex
Normal file
@@ -0,0 +1,113 @@
|
||||
defmodule Parrhesia.Plug do
|
||||
@moduledoc """
|
||||
Official Plug interface for mounting Parrhesia HTTP/WebSocket ingress in a host app.
|
||||
|
||||
This plug serves the same route surface as the built-in listener endpoint:
|
||||
|
||||
- `GET /health`
|
||||
- `GET /ready`
|
||||
- `GET /relay` (NIP-11 + websocket transport)
|
||||
- `POST /management`
|
||||
- `GET /metrics`
|
||||
|
||||
## Options
|
||||
|
||||
* `:listener` - listener configuration used to authorize and serve requests.
|
||||
Supported values:
|
||||
* an atom listener id from `config :parrhesia, :listeners` (for example `:public`)
|
||||
* a listener config map/keyword list (same schema as `:listeners` entries)
|
||||
|
||||
When a host app owns the HTTPS edge, a common pattern is:
|
||||
|
||||
config :parrhesia, :listeners, %{}
|
||||
|
||||
and mount `Parrhesia.Plug` with an explicit `:listener` map.
|
||||
"""
|
||||
|
||||
@behaviour Plug
|
||||
|
||||
alias Parrhesia.Web.Listener
|
||||
alias Parrhesia.Web.Router
|
||||
|
||||
@type listener_option :: atom() | map() | keyword()
|
||||
@type option :: {:listener, listener_option()}
|
||||
|
||||
@spec init([option()]) :: keyword()
|
||||
@impl Plug
|
||||
def init(opts) do
|
||||
opts = Keyword.validate!(opts, listener: :public)
|
||||
listener = opts |> Keyword.fetch!(:listener) |> resolve_listener!()
|
||||
[listener: listener]
|
||||
end
|
||||
|
||||
@spec call(Plug.Conn.t(), keyword()) :: Plug.Conn.t()
|
||||
@impl Plug
|
||||
def call(conn, opts) do
|
||||
conn
|
||||
|> Listener.put_conn(opts)
|
||||
|> Router.call([])
|
||||
end
|
||||
|
||||
defp resolve_listener!(listener_id) when is_atom(listener_id) do
|
||||
listeners = Application.get_env(:parrhesia, :listeners, %{})
|
||||
|
||||
case lookup_listener_by_id(listeners, listener_id) do
|
||||
nil ->
|
||||
raise ArgumentError,
|
||||
"listener #{inspect(listener_id)} not found in config :parrhesia, :listeners; " <>
|
||||
"configure it there or pass :listener as a map"
|
||||
|
||||
listener ->
|
||||
Listener.from_opts(listener: listener)
|
||||
end
|
||||
end
|
||||
|
||||
defp resolve_listener!(listener) when is_map(listener) do
|
||||
Listener.from_opts(listener: listener)
|
||||
end
|
||||
|
||||
defp resolve_listener!(listener) when is_list(listener) do
|
||||
if Keyword.keyword?(listener) do
|
||||
Listener.from_opts(listener: Map.new(listener))
|
||||
else
|
||||
raise ArgumentError,
|
||||
":listener keyword list must be a valid keyword configuration"
|
||||
end
|
||||
end
|
||||
|
||||
defp resolve_listener!(other) do
|
||||
raise ArgumentError,
|
||||
":listener must be an atom id, map, or keyword list, got: #{inspect(other)}"
|
||||
end
|
||||
|
||||
defp lookup_listener_by_id(listeners, listener_id) when is_map(listeners) do
|
||||
case Map.fetch(listeners, listener_id) do
|
||||
{:ok, listener} when is_map(listener) ->
|
||||
Map.put_new(listener, :id, listener_id)
|
||||
|
||||
{:ok, listener} when is_list(listener) ->
|
||||
listener |> Map.new() |> Map.put_new(:id, listener_id)
|
||||
|
||||
_other ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
defp lookup_listener_by_id(listeners, listener_id) when is_list(listeners) do
|
||||
case Enum.find(listeners, fn
|
||||
{id, _listener} -> id == listener_id
|
||||
_other -> false
|
||||
end) do
|
||||
{^listener_id, listener} when is_map(listener) ->
|
||||
Map.put_new(listener, :id, listener_id)
|
||||
|
||||
{^listener_id, listener} when is_list(listener) ->
|
||||
listener |> Map.new() |> Map.put_new(:id, listener_id)
|
||||
|
||||
_other ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
defp lookup_listener_by_id(_listeners, _listener_id), do: nil
|
||||
end
|
||||
@@ -22,7 +22,17 @@ defmodule Parrhesia.Storage.Adapters.Memory.Store do
|
||||
audit_logs: []
|
||||
}
|
||||
|
||||
def ensure_started, do: start_store()
|
||||
def start_link(opts \\ []) do
|
||||
name = Keyword.get(opts, :name, @name)
|
||||
Agent.start_link(&init_state/0, name: name)
|
||||
end
|
||||
|
||||
def ensure_started do
|
||||
case Process.whereis(@name) do
|
||||
pid when is_pid(pid) -> :ok
|
||||
nil -> start_store()
|
||||
end
|
||||
end
|
||||
|
||||
def put_event(event_id, event) when is_binary(event_id) and is_map(event) do
|
||||
:ok = ensure_started()
|
||||
@@ -159,28 +169,72 @@ defmodule Parrhesia.Storage.Adapters.Memory.Store do
|
||||
defp normalize_reduce_result(next_acc), do: next_acc
|
||||
|
||||
def get(fun) do
|
||||
:ok = ensure_started()
|
||||
Agent.get(@name, fun)
|
||||
with_store(fn pid -> Agent.get(pid, fun) end)
|
||||
end
|
||||
|
||||
def update(fun) do
|
||||
:ok = ensure_started()
|
||||
Agent.update(@name, fun)
|
||||
with_store(fn pid -> Agent.update(pid, fun) end)
|
||||
end
|
||||
|
||||
def get_and_update(fun) do
|
||||
:ok = ensure_started()
|
||||
Agent.get_and_update(@name, fun)
|
||||
with_store(fn pid -> Agent.get_and_update(pid, fun) end)
|
||||
end
|
||||
|
||||
defp start_store do
|
||||
case Agent.start_link(&init_state/0, name: @name) do
|
||||
{:ok, _pid} -> :ok
|
||||
{:error, {:already_started, _pid}} -> :ok
|
||||
{:error, reason} -> {:error, reason}
|
||||
case start_link() do
|
||||
{:ok, _pid} ->
|
||||
:ok
|
||||
|
||||
{:error, {:already_started, pid}} ->
|
||||
if Process.alive?(pid) do
|
||||
:ok
|
||||
else
|
||||
wait_for_store_exit(pid)
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp with_store(fun, attempts \\ 2)
|
||||
|
||||
defp with_store(fun, attempts) when attempts > 0 do
|
||||
:ok = ensure_started()
|
||||
|
||||
case Process.whereis(@name) do
|
||||
pid when is_pid(pid) ->
|
||||
try do
|
||||
fun.(pid)
|
||||
catch
|
||||
:exit, reason ->
|
||||
if noproc_exit?(reason) and attempts > 1 do
|
||||
with_store(fun, attempts - 1)
|
||||
else
|
||||
exit(reason)
|
||||
end
|
||||
end
|
||||
|
||||
nil ->
|
||||
with_store(fun, attempts - 1)
|
||||
end
|
||||
end
|
||||
|
||||
defp with_store(_fun, 0), do: exit(:noproc)
|
||||
|
||||
defp wait_for_store_exit(pid) do
|
||||
ref = Process.monitor(pid)
|
||||
|
||||
receive do
|
||||
{:DOWN, ^ref, :process, ^pid, _reason} -> start_store()
|
||||
after
|
||||
100 -> start_store()
|
||||
end
|
||||
end
|
||||
|
||||
defp noproc_exit?({:noproc, _details}), do: true
|
||||
defp noproc_exit?(_reason), do: false
|
||||
|
||||
defp init_state do
|
||||
ensure_tables_started()
|
||||
|
||||
|
||||
@@ -13,11 +13,19 @@ defmodule Parrhesia.Storage.Supervisor do
|
||||
|
||||
@impl true
|
||||
def init(_init_arg) do
|
||||
children = moderation_cache_children() ++ PostgresRepos.started_repos()
|
||||
children =
|
||||
memory_store_children() ++ moderation_cache_children() ++ PostgresRepos.started_repos()
|
||||
|
||||
Supervisor.init(children, strategy: :one_for_one)
|
||||
end
|
||||
|
||||
defp memory_store_children do
|
||||
case Application.get_env(:parrhesia, :storage, [])[:backend] do
|
||||
:memory -> [Parrhesia.Storage.Adapters.Memory.Store]
|
||||
_other -> []
|
||||
end
|
||||
end
|
||||
|
||||
defp moderation_cache_children do
|
||||
if PostgresRepos.postgres_enabled?() and
|
||||
Application.get_env(:parrhesia, :moderation_cache_enabled, true) do
|
||||
|
||||
@@ -137,9 +137,16 @@ defmodule Parrhesia.TestSupport.TLSCerts do
|
||||
end
|
||||
|
||||
defp openssl!(args) do
|
||||
case System.cmd("/usr/bin/openssl", args, stderr_to_stdout: true) do
|
||||
case System.cmd(openssl_executable!(), args, stderr_to_stdout: true) do
|
||||
{output, 0} -> output
|
||||
{output, status} -> raise "openssl failed with status #{status}: #{output}"
|
||||
end
|
||||
end
|
||||
|
||||
defp openssl_executable! do
|
||||
case System.find_executable("openssl") do
|
||||
nil -> raise "openssl executable not found in PATH"
|
||||
path -> path
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -30,7 +30,11 @@ defmodule Parrhesia.Web.Connection do
|
||||
@default_event_ingest_rate_limit 120
|
||||
@default_event_ingest_window_seconds 1
|
||||
@default_auth_max_age_seconds 600
|
||||
@default_websocket_ping_interval_seconds 30
|
||||
@default_websocket_pong_timeout_seconds 10
|
||||
@drain_outbound_queue :drain_outbound_queue
|
||||
@websocket_keepalive_ping :websocket_keepalive_ping
|
||||
@websocket_keepalive_timeout :websocket_keepalive_timeout
|
||||
@outbound_queue_pressure_threshold 0.75
|
||||
|
||||
@marmot_kinds MapSet.new([
|
||||
@@ -72,6 +76,10 @@ defmodule Parrhesia.Web.Connection do
|
||||
event_ingest_window_started_at_ms: 0,
|
||||
event_ingest_count: 0,
|
||||
auth_max_age_seconds: @default_auth_max_age_seconds,
|
||||
websocket_ping_interval_seconds: @default_websocket_ping_interval_seconds,
|
||||
websocket_pong_timeout_seconds: @default_websocket_pong_timeout_seconds,
|
||||
websocket_keepalive_timeout_timer_ref: nil,
|
||||
websocket_awaiting_pong_payload: nil,
|
||||
track_population?: true
|
||||
|
||||
@type overflow_strategy :: :close | :drop_oldest | :drop_newest
|
||||
@@ -109,6 +117,10 @@ defmodule Parrhesia.Web.Connection do
|
||||
event_ingest_window_started_at_ms: integer(),
|
||||
event_ingest_count: non_neg_integer(),
|
||||
auth_max_age_seconds: pos_integer(),
|
||||
websocket_ping_interval_seconds: non_neg_integer(),
|
||||
websocket_pong_timeout_seconds: pos_integer(),
|
||||
websocket_keepalive_timeout_timer_ref: reference() | nil,
|
||||
websocket_awaiting_pong_payload: binary() | nil,
|
||||
track_population?: boolean()
|
||||
}
|
||||
|
||||
@@ -117,7 +129,8 @@ defmodule Parrhesia.Web.Connection do
|
||||
maybe_configure_exit_trapping(opts)
|
||||
auth_challenges = auth_challenges(opts)
|
||||
|
||||
state = %__MODULE__{
|
||||
state =
|
||||
%__MODULE__{
|
||||
listener: Listener.from_opts(opts),
|
||||
transport_identity: transport_identity(opts),
|
||||
max_subscriptions_per_connection: max_subscriptions_per_connection(opts),
|
||||
@@ -138,8 +151,11 @@ defmodule Parrhesia.Web.Connection do
|
||||
event_ingest_window_seconds: event_ingest_window_seconds(opts),
|
||||
event_ingest_window_started_at_ms: System.monotonic_time(:millisecond),
|
||||
auth_max_age_seconds: auth_max_age_seconds(opts),
|
||||
websocket_ping_interval_seconds: websocket_ping_interval_seconds(opts),
|
||||
websocket_pong_timeout_seconds: websocket_pong_timeout_seconds(opts),
|
||||
track_population?: track_population?(opts)
|
||||
}
|
||||
|> maybe_schedule_next_websocket_ping()
|
||||
|
||||
:ok = maybe_track_connection_open(state)
|
||||
Telemetry.emit_process_mailbox_depth(:connection)
|
||||
@@ -180,6 +196,17 @@ defmodule Parrhesia.Web.Connection do
|
||||
|> emit_connection_mailbox_depth()
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_control({payload, [opcode: :pong]}, %__MODULE__{} = state) when is_binary(payload) do
|
||||
{:ok, maybe_acknowledge_websocket_pong(state, payload)}
|
||||
|> emit_connection_mailbox_depth()
|
||||
end
|
||||
|
||||
def handle_control({_payload, [opcode: :ping]}, %__MODULE__{} = state) do
|
||||
{:ok, state}
|
||||
|> emit_connection_mailbox_depth()
|
||||
end
|
||||
|
||||
defp handle_decoded_message({:event, event}, state), do: handle_event_ingest(state, event)
|
||||
|
||||
defp handle_decoded_message({:req, subscription_id, filters}, state),
|
||||
@@ -291,6 +318,24 @@ defmodule Parrhesia.Web.Connection do
|
||||
end
|
||||
end
|
||||
|
||||
def handle_info(@websocket_keepalive_ping, %__MODULE__{} = state) do
|
||||
state
|
||||
|> maybe_schedule_next_websocket_ping()
|
||||
|> maybe_send_websocket_keepalive_ping()
|
||||
|> emit_connection_mailbox_depth()
|
||||
end
|
||||
|
||||
def handle_info({@websocket_keepalive_timeout, payload}, %__MODULE__{} = state)
|
||||
when is_binary(payload) do
|
||||
if websocket_keepalive_timeout_payload?(state, payload) do
|
||||
{:stop, :normal, {1001, "keepalive timeout"}, state}
|
||||
|> emit_connection_mailbox_depth()
|
||||
else
|
||||
{:ok, state}
|
||||
|> emit_connection_mailbox_depth()
|
||||
end
|
||||
end
|
||||
|
||||
def handle_info({:EXIT, _from, :shutdown}, %__MODULE__{} = state) do
|
||||
close_with_drained_outbound_frames(state)
|
||||
|> emit_connection_mailbox_depth()
|
||||
@@ -313,6 +358,7 @@ defmodule Parrhesia.Web.Connection do
|
||||
:ok = maybe_unsubscribe_all_stream_subscriptions(state)
|
||||
:ok = maybe_remove_index_owner(state)
|
||||
:ok = maybe_clear_auth_challenge(state)
|
||||
:ok = cancel_websocket_keepalive_timers(state)
|
||||
:ok
|
||||
end
|
||||
|
||||
@@ -1229,6 +1275,93 @@ defmodule Parrhesia.Web.Connection do
|
||||
%__MODULE__{state | drain_scheduled?: true}
|
||||
end
|
||||
|
||||
defp maybe_schedule_next_websocket_ping(
|
||||
%__MODULE__{websocket_ping_interval_seconds: interval_seconds} = state
|
||||
)
|
||||
when interval_seconds <= 0,
|
||||
do: state
|
||||
|
||||
defp maybe_schedule_next_websocket_ping(
|
||||
%__MODULE__{websocket_ping_interval_seconds: interval_seconds} = state
|
||||
) do
|
||||
_timer_ref = Process.send_after(self(), @websocket_keepalive_ping, interval_seconds * 1_000)
|
||||
state
|
||||
end
|
||||
|
||||
defp maybe_send_websocket_keepalive_ping(
|
||||
%__MODULE__{websocket_ping_interval_seconds: interval_seconds} = state
|
||||
)
|
||||
when interval_seconds <= 0,
|
||||
do: {:ok, state}
|
||||
|
||||
defp maybe_send_websocket_keepalive_ping(
|
||||
%__MODULE__{websocket_awaiting_pong_payload: awaiting_payload} = state
|
||||
)
|
||||
when is_binary(awaiting_payload),
|
||||
do: {:ok, state}
|
||||
|
||||
defp maybe_send_websocket_keepalive_ping(%__MODULE__{} = state) do
|
||||
payload = Base.encode16(:crypto.strong_rand_bytes(8), case: :lower)
|
||||
|
||||
timeout_timer_ref =
|
||||
Process.send_after(
|
||||
self(),
|
||||
{@websocket_keepalive_timeout, payload},
|
||||
state.websocket_pong_timeout_seconds * 1_000
|
||||
)
|
||||
|
||||
next_state =
|
||||
%__MODULE__{
|
||||
state
|
||||
| websocket_keepalive_timeout_timer_ref: timeout_timer_ref,
|
||||
websocket_awaiting_pong_payload: payload
|
||||
}
|
||||
|
||||
{:push, {:ping, payload}, next_state}
|
||||
end
|
||||
|
||||
defp websocket_keepalive_timeout_payload?(
|
||||
%__MODULE__{websocket_awaiting_pong_payload: awaiting_payload},
|
||||
payload
|
||||
)
|
||||
when is_binary(awaiting_payload) and is_binary(payload) do
|
||||
Plug.Crypto.secure_compare(awaiting_payload, payload)
|
||||
end
|
||||
|
||||
defp websocket_keepalive_timeout_payload?(_state, _payload), do: false
|
||||
|
||||
defp maybe_acknowledge_websocket_pong(
|
||||
%__MODULE__{websocket_awaiting_pong_payload: awaiting_payload} = state,
|
||||
payload
|
||||
)
|
||||
when is_binary(awaiting_payload) and is_binary(payload) do
|
||||
if Plug.Crypto.secure_compare(awaiting_payload, payload) do
|
||||
:ok = cancel_timer(state.websocket_keepalive_timeout_timer_ref)
|
||||
|
||||
%__MODULE__{
|
||||
state
|
||||
| websocket_keepalive_timeout_timer_ref: nil,
|
||||
websocket_awaiting_pong_payload: nil
|
||||
}
|
||||
else
|
||||
state
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_acknowledge_websocket_pong(%__MODULE__{} = state, _payload), do: state
|
||||
|
||||
defp cancel_websocket_keepalive_timers(%__MODULE__{} = state) do
|
||||
:ok = cancel_timer(state.websocket_keepalive_timeout_timer_ref)
|
||||
:ok
|
||||
end
|
||||
|
||||
defp cancel_timer(timer_ref) when is_reference(timer_ref) do
|
||||
_ = Process.cancel_timer(timer_ref, async: true, info: false)
|
||||
:ok
|
||||
end
|
||||
|
||||
defp cancel_timer(_timer_ref), do: :ok
|
||||
|
||||
defp emit_outbound_queue_depth(state, metadata \\ %{}) do
|
||||
depth = state.outbound_queue_size
|
||||
|
||||
@@ -1769,6 +1902,64 @@ defmodule Parrhesia.Web.Connection do
|
||||
|> Keyword.get(:auth_max_age_seconds, @default_auth_max_age_seconds)
|
||||
end
|
||||
|
||||
defp websocket_ping_interval_seconds(opts) when is_list(opts) do
|
||||
opts
|
||||
|> Keyword.get(:websocket_ping_interval_seconds)
|
||||
|> normalize_websocket_ping_interval_seconds()
|
||||
end
|
||||
|
||||
defp websocket_ping_interval_seconds(opts) when is_map(opts) do
|
||||
opts
|
||||
|> Map.get(:websocket_ping_interval_seconds)
|
||||
|> normalize_websocket_ping_interval_seconds()
|
||||
end
|
||||
|
||||
defp websocket_ping_interval_seconds(_opts), do: configured_websocket_ping_interval_seconds()
|
||||
|
||||
defp normalize_websocket_ping_interval_seconds(value)
|
||||
when is_integer(value) and value >= 0,
|
||||
do: value
|
||||
|
||||
defp normalize_websocket_ping_interval_seconds(_value),
|
||||
do: configured_websocket_ping_interval_seconds()
|
||||
|
||||
defp configured_websocket_ping_interval_seconds do
|
||||
case Application.get_env(:parrhesia, :limits, [])
|
||||
|> Keyword.get(:websocket_ping_interval_seconds) do
|
||||
value when is_integer(value) and value >= 0 -> value
|
||||
_other -> @default_websocket_ping_interval_seconds
|
||||
end
|
||||
end
|
||||
|
||||
defp websocket_pong_timeout_seconds(opts) when is_list(opts) do
|
||||
opts
|
||||
|> Keyword.get(:websocket_pong_timeout_seconds)
|
||||
|> normalize_websocket_pong_timeout_seconds()
|
||||
end
|
||||
|
||||
defp websocket_pong_timeout_seconds(opts) when is_map(opts) do
|
||||
opts
|
||||
|> Map.get(:websocket_pong_timeout_seconds)
|
||||
|> normalize_websocket_pong_timeout_seconds()
|
||||
end
|
||||
|
||||
defp websocket_pong_timeout_seconds(_opts), do: configured_websocket_pong_timeout_seconds()
|
||||
|
||||
defp normalize_websocket_pong_timeout_seconds(value)
|
||||
when is_integer(value) and value > 0,
|
||||
do: value
|
||||
|
||||
defp normalize_websocket_pong_timeout_seconds(_value),
|
||||
do: configured_websocket_pong_timeout_seconds()
|
||||
|
||||
defp configured_websocket_pong_timeout_seconds do
|
||||
case Application.get_env(:parrhesia, :limits, [])
|
||||
|> Keyword.get(:websocket_pong_timeout_seconds) do
|
||||
value when is_integer(value) and value > 0 -> value
|
||||
_other -> @default_websocket_pong_timeout_seconds
|
||||
end
|
||||
end
|
||||
|
||||
defp track_population?(opts) when is_list(opts), do: Keyword.get(opts, :track_population?, true)
|
||||
defp track_population?(opts) when is_map(opts), do: Map.get(opts, :track_population?, true)
|
||||
defp track_population?(_opts), do: true
|
||||
|
||||
@@ -177,7 +177,7 @@ defmodule Parrhesia.Web.Listener do
|
||||
ip: listener.bind.ip,
|
||||
port: listener.bind.port,
|
||||
scheme: scheme,
|
||||
plug: {Parrhesia.Web.ListenerPlug, listener: listener}
|
||||
plug: {Parrhesia.Plug, listener: listener}
|
||||
] ++
|
||||
TLS.bandit_options(listener.transport.tls) ++
|
||||
[thousand_island_options: thousand_island_options] ++
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
defmodule Parrhesia.Web.ListenerPlug do
|
||||
@moduledoc false
|
||||
|
||||
alias Parrhesia.Web.Listener
|
||||
alias Parrhesia.Web.Router
|
||||
|
||||
def init(opts), do: opts
|
||||
|
||||
def call(conn, opts) do
|
||||
conn
|
||||
|> Listener.put_conn(opts)
|
||||
|> Router.call([])
|
||||
end
|
||||
end
|
||||
45
mix.exs
45
mix.exs
@@ -4,13 +4,15 @@ defmodule Parrhesia.MixProject do
|
||||
def project do
|
||||
[
|
||||
app: :parrhesia,
|
||||
version: "0.6.0",
|
||||
version: "0.8.0",
|
||||
elixir: "~> 1.18",
|
||||
elixirc_paths: elixirc_paths(Mix.env()),
|
||||
start_permanent: Mix.env() == :prod,
|
||||
deps: deps(),
|
||||
aliases: aliases(),
|
||||
docs: docs()
|
||||
docs: docs(),
|
||||
description: description(),
|
||||
package: package()
|
||||
]
|
||||
end
|
||||
|
||||
@@ -26,7 +28,11 @@ defmodule Parrhesia.MixProject do
|
||||
defp elixirc_paths(_env), do: ["lib"]
|
||||
|
||||
def cli do
|
||||
[preferred_envs: [precommit: :test, bench: :test, "bench.update": :test]]
|
||||
[
|
||||
preferred_envs: [
|
||||
precommit: :test
|
||||
]
|
||||
]
|
||||
end
|
||||
|
||||
# Run "mix help deps" to learn about dependencies.
|
||||
@@ -48,15 +54,17 @@ defmodule Parrhesia.MixProject do
|
||||
{:telemetry_poller, "~> 1.0"},
|
||||
{:telemetry_metrics_prometheus, "~> 1.1"},
|
||||
|
||||
# Runtime: outbound WebSocket client (sync transport)
|
||||
{:websockex, "~> 0.4"},
|
||||
|
||||
# Test tooling
|
||||
{:stream_data, "~> 1.0", only: :test},
|
||||
{:websockex, "~> 0.4"},
|
||||
|
||||
# Project tooling
|
||||
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
|
||||
{:ex_doc, "~> 0.34", only: :dev, runtime: false},
|
||||
{:deps_changelog, "~> 0.3"},
|
||||
{:igniter, "~> 0.6", only: [:dev, :test]}
|
||||
{:deps_changelog, "~> 0.3", only: :dev, runtime: false},
|
||||
{:igniter, "~> 0.6", only: [:dev, :test], runtime: false}
|
||||
]
|
||||
end
|
||||
|
||||
@@ -66,13 +74,6 @@ defmodule Parrhesia.MixProject do
|
||||
"ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
|
||||
"ecto.reset": ["ecto.drop", "ecto.setup"],
|
||||
test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"],
|
||||
"test.nak_e2e": ["cmd ./scripts/run_nak_e2e.sh"],
|
||||
"test.marmot_e2e": ["cmd ./scripts/run_marmot_e2e.sh"],
|
||||
"test.node_sync_e2e": ["cmd ./scripts/run_node_sync_e2e.sh"],
|
||||
"test.node_sync_docker_e2e": ["cmd ./scripts/run_node_sync_docker_e2e.sh"],
|
||||
bench: ["cmd ./scripts/run_bench_compare.sh"],
|
||||
"bench.update": ["cmd ./scripts/run_bench_update.sh"],
|
||||
# cov: ["cmd mix coveralls.lcov"],
|
||||
lint: ["format --check-formatted", "credo"],
|
||||
precommit: [
|
||||
"format",
|
||||
@@ -80,12 +81,22 @@ defmodule Parrhesia.MixProject do
|
||||
"credo --strict --all",
|
||||
"deps.unlock --unused",
|
||||
"test",
|
||||
# "test.nak_e2e",
|
||||
"test.marmot_e2e"
|
||||
"cmd just e2e marmot"
|
||||
]
|
||||
]
|
||||
end
|
||||
|
||||
defp description do
|
||||
"Nostr event relay with WebSocket fanout, sync, and access control"
|
||||
end
|
||||
|
||||
defp package do
|
||||
[
|
||||
licenses: ["BSD-2-Clause"],
|
||||
links: %{"Gitea" => "https://git.teralink.net/tribes/parrhesia"}
|
||||
]
|
||||
end
|
||||
|
||||
defp docs do
|
||||
[
|
||||
main: "readme",
|
||||
@@ -95,8 +106,7 @@ defmodule Parrhesia.MixProject do
|
||||
"docs/LOCAL_API.md",
|
||||
"docs/SYNC.md",
|
||||
"docs/ARCH.md",
|
||||
"docs/CLUSTER.md",
|
||||
"BENCHMARK.md"
|
||||
"docs/CLUSTER.md"
|
||||
],
|
||||
groups_for_modules: [
|
||||
"Embedded API": [
|
||||
@@ -113,6 +123,7 @@ defmodule Parrhesia.MixProject do
|
||||
],
|
||||
Runtime: [
|
||||
Parrhesia,
|
||||
Parrhesia.Plug,
|
||||
Parrhesia.Release,
|
||||
Parrhesia.Runtime
|
||||
]
|
||||
|
||||
2
mix.lock
2
mix.lock
@@ -17,7 +17,7 @@
|
||||
"finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"},
|
||||
"glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"},
|
||||
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
|
||||
"igniter": {:hex, :igniter, "0.7.4", "b5f9dd512eb1e672f1c141b523142b5b4602fcca231df5b4e362999df4b88e14", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "971b240ee916a06b1af56381a262d9eeaff9610eddc299d61a213cd7a9d79efd"},
|
||||
"igniter": {:hex, :igniter, "0.7.6", "687d622c735e020f13cf480c83d0fce1cc899f4fbed547f5254b960ea82d3525", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "424f41a41273fce0f7424008405ee073b5bd06359ca9396e841f83a669c01619"},
|
||||
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
|
||||
"lib_secp256k1": {:hex, :lib_secp256k1, "0.7.1", "53cad778b8da3a29e453a7a477517d99fb5f13f615c8050eb2db8fd1dce7a1db", [:make, :mix], [{:elixir_make, "~> 0.9", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "78bdd3661a17448aff5aeec5ca74c8ddbc09b01f0ecfa3ba1aba3e8ae47ab2b3"},
|
||||
"makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
|
||||
|
||||
1
nix/nostr-bench
Submodule
1
nix/nostr-bench
Submodule
Submodule nix/nostr-bench added at 8561b84864
2333
nix/nostr-bench-static.Cargo.lock
generated
Normal file
2333
nix/nostr-bench-static.Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,36 @@
|
||||
{
|
||||
lib,
|
||||
fetchFromGitHub,
|
||||
rustPlatform,
|
||||
}:
|
||||
rustPlatform.buildRustPackage rec {
|
||||
pkgsCross,
|
||||
runCommand,
|
||||
staticX86_64Musl ? false,
|
||||
nostrBenchSrc ? ./nostr-bench,
|
||||
}: let
|
||||
selectedRustPlatform =
|
||||
if staticX86_64Musl
|
||||
then pkgsCross.musl64.rustPlatform
|
||||
else rustPlatform;
|
||||
|
||||
# Keep the submodule path as-is so devenv can evaluate it correctly.
|
||||
# `lib.cleanSource` treats submodule contents as untracked in this context.
|
||||
srcBase = nostrBenchSrc;
|
||||
|
||||
src =
|
||||
if staticX86_64Musl
|
||||
then
|
||||
runCommand "nostr-bench-static-src" {} ''
|
||||
cp -r ${srcBase} $out
|
||||
chmod -R u+w $out
|
||||
cp ${./nostr-bench-static.Cargo.lock} $out/Cargo.lock
|
||||
''
|
||||
else srcBase;
|
||||
in
|
||||
selectedRustPlatform.buildRustPackage (
|
||||
{
|
||||
pname = "nostr-bench";
|
||||
version = "0.4.0";
|
||||
version = "0.5.0-parrhesia";
|
||||
|
||||
src = fetchFromGitHub {
|
||||
owner = "rnostr";
|
||||
repo = pname;
|
||||
rev = "d3ab701512b7c871707b209ef3f934936e407963";
|
||||
hash = "sha256-F2qg1veO1iNlVUKf1b/MV+vexiy4Tt+w2aikDDbp7tU=";
|
||||
};
|
||||
|
||||
cargoHash = "sha256-mh9UdYhZl6JVJEeDFnY5BjfcK+PrWBBEn1Qh7/ZX17k=";
|
||||
inherit src;
|
||||
|
||||
doCheck = false;
|
||||
|
||||
@@ -25,4 +41,13 @@ rustPlatform.buildRustPackage rec {
|
||||
mainProgram = "nostr-bench";
|
||||
platforms = platforms.unix;
|
||||
};
|
||||
}
|
||||
}
|
||||
// lib.optionalAttrs staticX86_64Musl {
|
||||
cargoHash = "sha256-098BUjDLiezoFXs7fF+w7NQM+DPPfHMo1HGx3nV2UZM=";
|
||||
CARGO_BUILD_TARGET = "x86_64-unknown-linux-musl";
|
||||
RUSTFLAGS = "-C target-feature=+crt-static";
|
||||
}
|
||||
// lib.optionalAttrs (!staticX86_64Musl) {
|
||||
cargoHash = "sha256-x2pnxJL2nwni4cpSaUerDOfhdcTKJTyABYjPm96dAC0=";
|
||||
}
|
||||
)
|
||||
|
||||
1417
package-lock.json
generated
1417
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@mariozechner/pi-coding-agent": "^0.57.1"
|
||||
"devDependencies": {
|
||||
"@mariozechner/pi-coding-agent": "^0.60.0"
|
||||
}
|
||||
}
|
||||
|
||||
92
scripts/cloud_bench_client.sh
Executable file
92
scripts/cloud_bench_client.sh
Executable file
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
relay_url="${1:-}"
|
||||
mode="${2:-all}"
|
||||
|
||||
if [[ -z "$relay_url" ]]; then
|
||||
echo "usage: cloud-bench-client.sh <relay-url> [connect|echo|event|req|seed|all]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
bench_bin="${NOSTR_BENCH_BIN:-/usr/local/bin/nostr-bench}"
|
||||
bench_threads="${PARRHESIA_BENCH_THREADS:-0}"
|
||||
client_nofile="${PARRHESIA_BENCH_CLIENT_NOFILE:-262144}"
|
||||
|
||||
ulimit -n "${client_nofile}" >/dev/null 2>&1 || true
|
||||
|
||||
run_connect() {
|
||||
echo "==> nostr-bench connect ${relay_url}"
|
||||
"$bench_bin" connect --json \
|
||||
-c "${PARRHESIA_BENCH_CONNECT_COUNT:-200}" \
|
||||
-r "${PARRHESIA_BENCH_CONNECT_RATE:-100}" \
|
||||
-k "${PARRHESIA_BENCH_KEEPALIVE_SECONDS:-5}" \
|
||||
-t "${bench_threads}" \
|
||||
"${relay_url}"
|
||||
}
|
||||
|
||||
run_echo() {
|
||||
echo "==> nostr-bench echo ${relay_url}"
|
||||
"$bench_bin" echo --json \
|
||||
-c "${PARRHESIA_BENCH_ECHO_COUNT:-100}" \
|
||||
-r "${PARRHESIA_BENCH_ECHO_RATE:-50}" \
|
||||
-k "${PARRHESIA_BENCH_KEEPALIVE_SECONDS:-5}" \
|
||||
-t "${bench_threads}" \
|
||||
--size "${PARRHESIA_BENCH_ECHO_SIZE:-512}" \
|
||||
"${relay_url}"
|
||||
}
|
||||
|
||||
run_event() {
|
||||
echo "==> nostr-bench event ${relay_url}"
|
||||
"$bench_bin" event --json \
|
||||
-c "${PARRHESIA_BENCH_EVENT_COUNT:-100}" \
|
||||
-r "${PARRHESIA_BENCH_EVENT_RATE:-50}" \
|
||||
-k "${PARRHESIA_BENCH_KEEPALIVE_SECONDS:-5}" \
|
||||
-t "${bench_threads}" \
|
||||
--send-strategy "${PARRHESIA_BENCH_EVENT_SEND_STRATEGY:-pipelined}" \
|
||||
--inflight "${PARRHESIA_BENCH_EVENT_INFLIGHT:-32}" \
|
||||
--ack-timeout "${PARRHESIA_BENCH_EVENT_ACK_TIMEOUT:-30}" \
|
||||
"${relay_url}"
|
||||
}
|
||||
|
||||
run_req() {
|
||||
echo "==> nostr-bench req ${relay_url}"
|
||||
"$bench_bin" req --json \
|
||||
-c "${PARRHESIA_BENCH_REQ_COUNT:-100}" \
|
||||
-r "${PARRHESIA_BENCH_REQ_RATE:-50}" \
|
||||
-k "${PARRHESIA_BENCH_KEEPALIVE_SECONDS:-5}" \
|
||||
-t "${bench_threads}" \
|
||||
--limit "${PARRHESIA_BENCH_REQ_LIMIT:-10}" \
|
||||
"${relay_url}"
|
||||
}
|
||||
|
||||
run_seed() {
|
||||
local target_accepted="${PARRHESIA_BENCH_SEED_TARGET_ACCEPTED:-}"
|
||||
|
||||
if [[ -z "$target_accepted" ]]; then
|
||||
echo "PARRHESIA_BENCH_SEED_TARGET_ACCEPTED must be set for seed mode" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
echo "==> nostr-bench seed ${relay_url}"
|
||||
"$bench_bin" seed --json \
|
||||
--target-accepted "$target_accepted" \
|
||||
-c "${PARRHESIA_BENCH_SEED_CONNECTION_COUNT:-512}" \
|
||||
-r "${PARRHESIA_BENCH_SEED_CONNECTION_RATE:-512}" \
|
||||
-k "${PARRHESIA_BENCH_SEED_KEEPALIVE_SECONDS:-0}" \
|
||||
-t "${bench_threads}" \
|
||||
--send-strategy "${PARRHESIA_BENCH_SEED_SEND_STRATEGY:-pipelined}" \
|
||||
--inflight "${PARRHESIA_BENCH_SEED_INFLIGHT:-128}" \
|
||||
--ack-timeout "${PARRHESIA_BENCH_SEED_ACK_TIMEOUT:-20}" \
|
||||
"${relay_url}"
|
||||
}
|
||||
|
||||
case "$mode" in
|
||||
connect) run_connect ;;
|
||||
echo) run_echo ;;
|
||||
event) run_event ;;
|
||||
req) run_req ;;
|
||||
seed) run_seed ;;
|
||||
all) run_connect; echo; run_echo; echo; run_event; echo; run_req ;;
|
||||
*) echo "unknown mode: $mode" >&2; exit 1 ;;
|
||||
esac
|
||||
148
scripts/cloud_bench_monitoring.mjs
Normal file
148
scripts/cloud_bench_monitoring.mjs
Normal file
@@ -0,0 +1,148 @@
|
||||
// cloud_bench_monitoring.mjs — Prometheus + node_exporter setup and metrics collection.
|
||||
//
|
||||
// Installs monitoring on ephemeral benchmark VMs, collects all Prometheus
|
||||
// metrics for a given time window via the HTTP API, and stores them as
|
||||
// JSON artifacts.
|
||||
|
||||
// Generate prometheus.yml scrape config.
|
||||
export function makePrometheusConfig({ clientIps = [] } = {}) {
|
||||
const targets = [
|
||||
{
|
||||
job_name: "node-server",
|
||||
static_configs: [{ targets: ["localhost:9100"] }],
|
||||
},
|
||||
{
|
||||
job_name: "relay",
|
||||
metrics_path: "/metrics",
|
||||
static_configs: [{ targets: ["localhost:4413"] }],
|
||||
},
|
||||
];
|
||||
|
||||
if (clientIps.length > 0) {
|
||||
targets.push({
|
||||
job_name: "node-clients",
|
||||
static_configs: [{ targets: clientIps.map((ip) => `${ip}:9100`) }],
|
||||
});
|
||||
}
|
||||
|
||||
const config = {
|
||||
global: {
|
||||
scrape_interval: "5s",
|
||||
evaluation_interval: "15s",
|
||||
},
|
||||
scrape_configs: targets,
|
||||
};
|
||||
|
||||
// Produce minimal YAML by hand (avoids adding a yaml dep).
|
||||
const lines = [
|
||||
"global:",
|
||||
" scrape_interval: 5s",
|
||||
" evaluation_interval: 15s",
|
||||
"",
|
||||
"scrape_configs:",
|
||||
];
|
||||
|
||||
for (const sc of targets) {
|
||||
lines.push(` - job_name: '${sc.job_name}'`);
|
||||
if (sc.metrics_path) {
|
||||
lines.push(` metrics_path: '${sc.metrics_path}'`);
|
||||
}
|
||||
lines.push(" static_configs:");
|
||||
for (const st of sc.static_configs) {
|
||||
lines.push(" - targets:");
|
||||
for (const t of st.targets) {
|
||||
lines.push(` - '${t}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
// Install Prometheus + node_exporter on server, node_exporter on clients.
|
||||
// `ssh` is an async function matching the sshExec(ip, keyPath, cmd, opts) signature.
|
||||
export async function installMonitoring({ serverIp, clientIps = [], keyPath, ssh }) {
|
||||
const prometheusYml = makePrometheusConfig({ clientIps });
|
||||
|
||||
// Server: install prometheus + node-exporter, write config, start
|
||||
console.log("[monitoring] installing prometheus + node-exporter on server");
|
||||
await ssh(serverIp, keyPath, [
|
||||
"export DEBIAN_FRONTEND=noninteractive",
|
||||
"apt-get update -qq",
|
||||
"apt-get install -y -qq prometheus prometheus-node-exporter >/dev/null 2>&1",
|
||||
].join(" && "));
|
||||
|
||||
// Write prometheus config
|
||||
const escapedYml = prometheusYml.replace(/'/g, "'\\''");
|
||||
await ssh(serverIp, keyPath, `cat > /etc/prometheus/prometheus.yml <<'PROMEOF'\n${prometheusYml}PROMEOF`);
|
||||
|
||||
// Restart prometheus with the new config, ensure node-exporter is running
|
||||
await ssh(serverIp, keyPath, [
|
||||
"systemctl restart prometheus",
|
||||
"systemctl enable --now prometheus-node-exporter",
|
||||
].join(" && "));
|
||||
|
||||
// Clients: install node-exporter only (in parallel)
|
||||
if (clientIps.length > 0) {
|
||||
console.log(`[monitoring] installing node-exporter on ${clientIps.length} client(s)`);
|
||||
await Promise.all(
|
||||
clientIps.map((ip) =>
|
||||
ssh(ip, keyPath, [
|
||||
"export DEBIAN_FRONTEND=noninteractive",
|
||||
"apt-get update -qq",
|
||||
"apt-get install -y -qq prometheus-node-exporter >/dev/null 2>&1",
|
||||
"systemctl enable --now prometheus-node-exporter",
|
||||
].join(" && "))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Wait for Prometheus to start scraping
|
||||
console.log("[monitoring] waiting for Prometheus to initialise");
|
||||
await ssh(serverIp, keyPath,
|
||||
'for i in $(seq 1 30); do curl -sf http://localhost:9090/api/v1/query?query=up >/dev/null 2>&1 && exit 0; sleep 1; done; echo "prometheus not ready" >&2; exit 1'
|
||||
);
|
||||
|
||||
console.log("[monitoring] monitoring active");
|
||||
}
|
||||
|
||||
// Collect all Prometheus metrics for a time window.
|
||||
// Returns the raw Prometheus API response JSON (matrix result type).
|
||||
export async function collectMetrics({ serverIp, startTime, endTime, step = 5 }) {
|
||||
const params = new URLSearchParams({
|
||||
query: '{__name__=~".+"}',
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
step: String(step),
|
||||
});
|
||||
|
||||
const url = `http://${serverIp}:9090/api/v1/query_range?${params}`;
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, { signal: AbortSignal.timeout(60_000) });
|
||||
if (!resp.ok) {
|
||||
console.error(`[monitoring] Prometheus query failed: ${resp.status} ${resp.statusText}`);
|
||||
return null;
|
||||
}
|
||||
const body = await resp.json();
|
||||
if (body.status !== "success") {
|
||||
console.error(`[monitoring] Prometheus query error: ${body.error || "unknown"}`);
|
||||
return null;
|
||||
}
|
||||
return body.data;
|
||||
} catch (err) {
|
||||
console.error(`[monitoring] metrics collection failed: ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Stop monitoring daemons on server and clients.
|
||||
export async function stopMonitoring({ serverIp, clientIps = [], keyPath, ssh }) {
|
||||
const allIps = [serverIp, ...clientIps];
|
||||
await Promise.all(
|
||||
allIps.map((ip) =>
|
||||
ssh(ip, keyPath, "systemctl stop prometheus prometheus-node-exporter 2>/dev/null; true").catch(() => {})
|
||||
)
|
||||
);
|
||||
console.log("[monitoring] monitoring stopped");
|
||||
}
|
||||
2302
scripts/cloud_bench_orchestrate.mjs
Executable file
2302
scripts/cloud_bench_orchestrate.mjs
Executable file
File diff suppressed because it is too large
Load Diff
285
scripts/cloud_bench_results.mjs
Normal file
285
scripts/cloud_bench_results.mjs
Normal file
@@ -0,0 +1,285 @@
|
||||
// cloud_bench_results.mjs — benchmark output parsing and result aggregation.
|
||||
//
|
||||
// Extracted from cloud_bench_orchestrate.mjs to keep the orchestrator focused
|
||||
// on provisioning and execution flow.
|
||||
|
||||
export function parseNostrBenchSections(output) {
|
||||
const lines = output.split(/\r?\n/);
|
||||
let section = null;
|
||||
const parsed = {};
|
||||
|
||||
for (const lineRaw of lines) {
|
||||
const line = lineRaw.trim();
|
||||
const header = line.match(/^==>\s+nostr-bench\s+(connect|echo|event|req|seed)\s+/);
|
||||
if (header) {
|
||||
section = header[1];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!line.startsWith("{")) continue;
|
||||
|
||||
try {
|
||||
const json = JSON.parse(line);
|
||||
if (!section) continue;
|
||||
|
||||
if (section === "seed" && json?.type === "seed_final") {
|
||||
parsed.seed_final = json;
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = parsed[section];
|
||||
if (!existing) {
|
||||
parsed[section] = json;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existing?.type === "final" && json?.type !== "final") {
|
||||
continue;
|
||||
}
|
||||
|
||||
parsed[section] = json;
|
||||
} catch {
|
||||
// ignore noisy non-json lines
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function mean(values) {
|
||||
const valid = values.filter((v) => Number.isFinite(v));
|
||||
if (valid.length === 0) return NaN;
|
||||
return valid.reduce((a, b) => a + b, 0) / valid.length;
|
||||
}
|
||||
|
||||
export function sum(values) {
|
||||
const valid = values.filter((v) => Number.isFinite(v));
|
||||
if (valid.length === 0) return NaN;
|
||||
return valid.reduce((a, b) => a + b, 0);
|
||||
}
|
||||
|
||||
export function throughputFromSection(section, options = {}) {
|
||||
const { preferAccepted = false } = options;
|
||||
|
||||
const elapsedMs = Number(section?.elapsed ?? NaN);
|
||||
const accepted = Number(section?.message_stats?.accepted ?? NaN);
|
||||
const complete = Number(section?.message_stats?.complete ?? NaN);
|
||||
const effectiveCount =
|
||||
preferAccepted && Number.isFinite(accepted)
|
||||
? accepted
|
||||
: complete;
|
||||
const totalBytes = Number(section?.message_stats?.size ?? NaN);
|
||||
|
||||
const cumulativeTps =
|
||||
Number.isFinite(elapsedMs) && elapsedMs > 0 && Number.isFinite(effectiveCount)
|
||||
? effectiveCount / (elapsedMs / 1000)
|
||||
: NaN;
|
||||
|
||||
const cumulativeMibs =
|
||||
Number.isFinite(elapsedMs) && elapsedMs > 0 && Number.isFinite(totalBytes)
|
||||
? totalBytes / (1024 * 1024) / (elapsedMs / 1000)
|
||||
: NaN;
|
||||
|
||||
const sampleTps = Number(
|
||||
preferAccepted
|
||||
? section?.accepted_tps ?? section?.tps
|
||||
: section?.tps,
|
||||
);
|
||||
const sampleMibs = Number(section?.size ?? NaN);
|
||||
|
||||
return {
|
||||
tps: Number.isFinite(cumulativeTps) ? cumulativeTps : sampleTps,
|
||||
mibs: Number.isFinite(cumulativeMibs) ? cumulativeMibs : sampleMibs,
|
||||
};
|
||||
}
|
||||
|
||||
function messageCounter(section, field) {
|
||||
const value = Number(section?.message_stats?.[field]);
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
export function metricFromSections(sections) {
|
||||
const connect = sections?.connect?.connect_stats?.success_time || {};
|
||||
const echo = throughputFromSection(sections?.echo || {});
|
||||
const eventSection = sections?.event || {};
|
||||
const event = throughputFromSection(eventSection, { preferAccepted: true });
|
||||
const req = throughputFromSection(sections?.req || {});
|
||||
|
||||
return {
|
||||
connect_avg_ms: Number(connect.avg ?? NaN),
|
||||
connect_max_ms: Number(connect.max ?? NaN),
|
||||
echo_tps: echo.tps,
|
||||
echo_mibs: echo.mibs,
|
||||
event_tps: event.tps,
|
||||
event_mibs: event.mibs,
|
||||
event_notice: messageCounter(eventSection, "notice"),
|
||||
event_auth_challenge: messageCounter(eventSection, "auth_challenge"),
|
||||
event_reply_unrecognized: messageCounter(eventSection, "reply_unrecognized"),
|
||||
event_ack_timeout: messageCounter(eventSection, "ack_timeout"),
|
||||
req_tps: req.tps,
|
||||
req_mibs: req.mibs,
|
||||
};
|
||||
}
|
||||
|
||||
export function summariseFlatResults(results) {
|
||||
const byServer = new Map();
|
||||
|
||||
for (const runEntry of results) {
|
||||
const serverName = runEntry.target;
|
||||
if (!byServer.has(serverName)) {
|
||||
byServer.set(serverName, []);
|
||||
}
|
||||
|
||||
const clientSamples = (runEntry.clients || [])
|
||||
.filter((clientResult) => clientResult.status === "ok")
|
||||
.map((clientResult) => metricFromSections(clientResult.sections || {}));
|
||||
|
||||
if (clientSamples.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
byServer.get(serverName).push({
|
||||
connect_avg_ms: mean(clientSamples.map((s) => s.connect_avg_ms)),
|
||||
connect_max_ms: mean(clientSamples.map((s) => s.connect_max_ms)),
|
||||
echo_tps: sum(clientSamples.map((s) => s.echo_tps)),
|
||||
echo_mibs: sum(clientSamples.map((s) => s.echo_mibs)),
|
||||
event_tps: sum(clientSamples.map((s) => s.event_tps)),
|
||||
event_mibs: sum(clientSamples.map((s) => s.event_mibs)),
|
||||
event_notice: sum(clientSamples.map((s) => s.event_notice)),
|
||||
event_auth_challenge: sum(clientSamples.map((s) => s.event_auth_challenge)),
|
||||
event_reply_unrecognized: sum(clientSamples.map((s) => s.event_reply_unrecognized)),
|
||||
event_ack_timeout: sum(clientSamples.map((s) => s.event_ack_timeout)),
|
||||
req_tps: sum(clientSamples.map((s) => s.req_tps)),
|
||||
req_mibs: sum(clientSamples.map((s) => s.req_mibs)),
|
||||
});
|
||||
}
|
||||
|
||||
const metricKeys = [
|
||||
"connect_avg_ms",
|
||||
"connect_max_ms",
|
||||
"echo_tps",
|
||||
"echo_mibs",
|
||||
"event_tps",
|
||||
"event_mibs",
|
||||
"event_notice",
|
||||
"event_auth_challenge",
|
||||
"event_reply_unrecognized",
|
||||
"event_ack_timeout",
|
||||
"req_tps",
|
||||
"req_mibs",
|
||||
];
|
||||
|
||||
const out = {};
|
||||
for (const [serverName, runSamples] of byServer.entries()) {
|
||||
const summary = {};
|
||||
for (const key of metricKeys) {
|
||||
summary[key] = mean(runSamples.map((s) => s[key]));
|
||||
}
|
||||
out[serverName] = summary;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export function summarisePhasedResults(results) {
|
||||
const byServer = new Map();
|
||||
|
||||
for (const entry of results) {
|
||||
if (!byServer.has(entry.target)) byServer.set(entry.target, []);
|
||||
const phases = entry.phases;
|
||||
if (!phases) continue;
|
||||
|
||||
const sample = {};
|
||||
|
||||
// connect
|
||||
const connectClients = (phases.connect?.clients || [])
|
||||
.filter((c) => c.status === "ok")
|
||||
.map((c) => metricFromSections(c.sections || {}));
|
||||
if (connectClients.length > 0) {
|
||||
sample.connect_avg_ms = mean(connectClients.map((s) => s.connect_avg_ms));
|
||||
sample.connect_max_ms = mean(connectClients.map((s) => s.connect_max_ms));
|
||||
}
|
||||
|
||||
// echo
|
||||
const echoClients = (phases.echo?.clients || [])
|
||||
.filter((c) => c.status === "ok")
|
||||
.map((c) => metricFromSections(c.sections || {}));
|
||||
if (echoClients.length > 0) {
|
||||
sample.echo_tps = sum(echoClients.map((s) => s.echo_tps));
|
||||
sample.echo_mibs = sum(echoClients.map((s) => s.echo_mibs));
|
||||
}
|
||||
|
||||
// Per-level req and event metrics
|
||||
for (const level of ["cold", "warm", "hot"]) {
|
||||
const phase = phases[level] || (level === "cold" ? phases.empty : undefined);
|
||||
if (!phase) continue;
|
||||
|
||||
const reqClients = (phase.req?.clients || [])
|
||||
.filter((c) => c.status === "ok")
|
||||
.map((c) => metricFromSections(c.sections || {}));
|
||||
if (reqClients.length > 0) {
|
||||
sample[`req_${level}_tps`] = sum(reqClients.map((s) => s.req_tps));
|
||||
sample[`req_${level}_mibs`] = sum(reqClients.map((s) => s.req_mibs));
|
||||
}
|
||||
|
||||
const eventClients = (phase.event?.clients || [])
|
||||
.filter((c) => c.status === "ok")
|
||||
.map((c) => metricFromSections(c.sections || {}));
|
||||
if (eventClients.length > 0) {
|
||||
sample[`event_${level}_tps`] = sum(eventClients.map((s) => s.event_tps));
|
||||
sample[`event_${level}_mibs`] = sum(eventClients.map((s) => s.event_mibs));
|
||||
sample[`event_${level}_notice`] = sum(eventClients.map((s) => s.event_notice));
|
||||
sample[`event_${level}_auth_challenge`] = sum(
|
||||
eventClients.map((s) => s.event_auth_challenge),
|
||||
);
|
||||
sample[`event_${level}_reply_unrecognized`] = sum(
|
||||
eventClients.map((s) => s.event_reply_unrecognized),
|
||||
);
|
||||
sample[`event_${level}_ack_timeout`] = sum(
|
||||
eventClients.map((s) => s.event_ack_timeout),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
byServer.get(entry.target).push(sample);
|
||||
}
|
||||
|
||||
const out = {};
|
||||
for (const [name, samples] of byServer.entries()) {
|
||||
if (samples.length === 0) continue;
|
||||
const allKeys = new Set(samples.flatMap((s) => Object.keys(s)));
|
||||
const summary = {};
|
||||
for (const key of allKeys) {
|
||||
summary[key] = mean(samples.map((s) => s[key]).filter((v) => v !== undefined));
|
||||
}
|
||||
out[name] = summary;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export function summariseServersFromResults(results) {
|
||||
const isPhased = results.some((r) => r.mode === "phased");
|
||||
return isPhased ? summarisePhasedResults(results) : summariseFlatResults(results);
|
||||
}
|
||||
|
||||
// Count events successfully written by event benchmarks across all clients.
|
||||
export function countEventsWritten(clientResults) {
|
||||
let total = 0;
|
||||
for (const cr of clientResults) {
|
||||
if (cr.status !== "ok") continue;
|
||||
const eventSection = cr.sections?.event;
|
||||
if (!eventSection?.message_stats) continue;
|
||||
|
||||
const accepted = Number(eventSection.message_stats.accepted);
|
||||
if (Number.isFinite(accepted)) {
|
||||
total += Math.max(0, accepted);
|
||||
continue;
|
||||
}
|
||||
|
||||
const complete = Number(eventSection.message_stats.complete) || 0;
|
||||
const error = Number(eventSection.message_stats.error) || 0;
|
||||
total += Math.max(0, complete - error);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
645
scripts/cloud_bench_server.sh
Executable file
645
scripts/cloud_bench_server.sh
Executable file
@@ -0,0 +1,645 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PARRHESIA_IMAGE="${PARRHESIA_IMAGE:-parrhesia:latest}"
|
||||
POSTGRES_IMAGE="${POSTGRES_IMAGE:-postgres:18}"
|
||||
STRFRY_IMAGE="${STRFRY_IMAGE:-ghcr.io/hoytech/strfry:latest}"
|
||||
NOSTR_RS_IMAGE="${NOSTR_RS_IMAGE:-scsibug/nostr-rs-relay:latest}"
|
||||
NOSTREAM_REPO="${NOSTREAM_REPO:-https://github.com/Cameri/nostream.git}"
|
||||
NOSTREAM_REF="${NOSTREAM_REF:-main}"
|
||||
NOSTREAM_REDIS_IMAGE="${NOSTREAM_REDIS_IMAGE:-redis:7.0.5-alpine3.16}"
|
||||
HAVEN_IMAGE="${HAVEN_IMAGE:-holgerhatgarkeinenode/haven-docker:latest}"
|
||||
HAVEN_RELAY_URL="${HAVEN_RELAY_URL:-127.0.0.1:3355}"
|
||||
|
||||
NOSTREAM_SECRET="0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
HAVEN_OWNER_NPUB="npub1utx00neqgqln72j22kej3ux7803c2k986henvvha4thuwfkper4s7r50e8"
|
||||
|
||||
cleanup_containers() {
|
||||
docker rm -f parrhesia pg strfry nostr-rs nostream nostream-db nostream-cache haven >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
ensure_benchnet() {
|
||||
docker network create benchnet >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
wait_http() {
|
||||
local url="$1"
|
||||
local timeout="${2:-60}"
|
||||
local log_container="${3:-}"
|
||||
|
||||
for _ in $(seq 1 "$timeout"); do
|
||||
if curl -fsS "$url" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [[ -n "$log_container" ]]; then
|
||||
docker logs --tail 200 "$log_container" >&2 || true
|
||||
fi
|
||||
|
||||
echo "Timed out waiting for HTTP endpoint: $url" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_pg() {
|
||||
local timeout="${1:-90}"
|
||||
for _ in $(seq 1 "$timeout"); do
|
||||
if docker exec pg pg_isready -U parrhesia -d parrhesia >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
docker logs --tail 200 pg >&2 || true
|
||||
echo "Timed out waiting for Postgres" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_nostream_pg() {
|
||||
local timeout="${1:-90}"
|
||||
for _ in $(seq 1 "$timeout"); do
|
||||
if docker exec nostream-db pg_isready -U nostr_ts_relay -d nostr_ts_relay >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
docker logs --tail 200 nostream-db >&2 || true
|
||||
echo "Timed out waiting for nostream Postgres" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_nostream_redis() {
|
||||
local timeout="${1:-60}"
|
||||
for _ in $(seq 1 "$timeout"); do
|
||||
if docker exec nostream-cache redis-cli -a nostr_ts_relay ping >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
docker logs --tail 200 nostream-cache >&2 || true
|
||||
echo "Timed out waiting for nostream Redis" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_port() {
|
||||
local port="$1"
|
||||
local timeout="${2:-60}"
|
||||
local log_container="${3:-}"
|
||||
|
||||
for _ in $(seq 1 "$timeout"); do
|
||||
if ss -ltn | grep -q ":${port} "; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [[ -n "$log_container" ]]; then
|
||||
docker logs --tail 200 "$log_container" >&2 || true
|
||||
fi
|
||||
|
||||
echo "Timed out waiting for port: $port" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
clamp() {
|
||||
local value="$1"
|
||||
local min="$2"
|
||||
local max="$3"
|
||||
|
||||
if (( value < min )); then
|
||||
echo "$min"
|
||||
elif (( value > max )); then
|
||||
echo "$max"
|
||||
else
|
||||
echo "$value"
|
||||
fi
|
||||
}
|
||||
|
||||
derive_resource_tuning() {
|
||||
local mem_kb
|
||||
mem_kb="$(awk '/MemTotal:/ {print $2}' /proc/meminfo 2>/dev/null || true)"
|
||||
|
||||
if [[ -z "$mem_kb" || ! "$mem_kb" =~ ^[0-9]+$ ]]; then
|
||||
mem_kb=4194304
|
||||
fi
|
||||
|
||||
HOST_MEM_MB=$((mem_kb / 1024))
|
||||
HOST_CPU_CORES=$(nproc 2>/dev/null || echo 2)
|
||||
|
||||
local computed_pg_max_connections=$((HOST_CPU_CORES * 50))
|
||||
local computed_pg_shared_buffers_mb=$((HOST_MEM_MB / 4))
|
||||
local computed_pg_effective_cache_size_mb=$((HOST_MEM_MB * 3 / 4))
|
||||
local computed_pg_maintenance_work_mem_mb=$((HOST_MEM_MB / 16))
|
||||
local computed_pg_max_wal_size_gb=$((HOST_MEM_MB / 8192))
|
||||
|
||||
computed_pg_max_connections=$(clamp "$computed_pg_max_connections" 200 1000)
|
||||
computed_pg_shared_buffers_mb=$(clamp "$computed_pg_shared_buffers_mb" 512 32768)
|
||||
computed_pg_effective_cache_size_mb=$(clamp "$computed_pg_effective_cache_size_mb" 1024 98304)
|
||||
computed_pg_maintenance_work_mem_mb=$(clamp "$computed_pg_maintenance_work_mem_mb" 256 2048)
|
||||
computed_pg_max_wal_size_gb=$(clamp "$computed_pg_max_wal_size_gb" 4 64)
|
||||
|
||||
local computed_pg_min_wal_size_gb=$((computed_pg_max_wal_size_gb / 4))
|
||||
computed_pg_min_wal_size_gb=$(clamp "$computed_pg_min_wal_size_gb" 1 16)
|
||||
|
||||
local computed_pg_work_mem_mb=$(((HOST_MEM_MB - computed_pg_shared_buffers_mb) / (computed_pg_max_connections * 3)))
|
||||
computed_pg_work_mem_mb=$(clamp "$computed_pg_work_mem_mb" 4 128)
|
||||
|
||||
local computed_parrhesia_pool_size=$((HOST_CPU_CORES * 8))
|
||||
computed_parrhesia_pool_size=$(clamp "$computed_parrhesia_pool_size" 20 200)
|
||||
|
||||
local computed_nostream_db_min_pool_size=$((HOST_CPU_CORES * 4))
|
||||
computed_nostream_db_min_pool_size=$(clamp "$computed_nostream_db_min_pool_size" 16 128)
|
||||
|
||||
local computed_nostream_db_max_pool_size=$((HOST_CPU_CORES * 16))
|
||||
computed_nostream_db_max_pool_size=$(clamp "$computed_nostream_db_max_pool_size" 64 512)
|
||||
|
||||
if (( computed_nostream_db_max_pool_size < computed_nostream_db_min_pool_size )); then
|
||||
computed_nostream_db_max_pool_size="$computed_nostream_db_min_pool_size"
|
||||
fi
|
||||
|
||||
local computed_redis_maxmemory_mb=$((HOST_MEM_MB / 3))
|
||||
computed_redis_maxmemory_mb=$(clamp "$computed_redis_maxmemory_mb" 256 65536)
|
||||
|
||||
PG_MAX_CONNECTIONS="${PG_MAX_CONNECTIONS:-$computed_pg_max_connections}"
|
||||
PG_SHARED_BUFFERS_MB="${PG_SHARED_BUFFERS_MB:-$computed_pg_shared_buffers_mb}"
|
||||
PG_EFFECTIVE_CACHE_SIZE_MB="${PG_EFFECTIVE_CACHE_SIZE_MB:-$computed_pg_effective_cache_size_mb}"
|
||||
PG_MAINTENANCE_WORK_MEM_MB="${PG_MAINTENANCE_WORK_MEM_MB:-$computed_pg_maintenance_work_mem_mb}"
|
||||
PG_WORK_MEM_MB="${PG_WORK_MEM_MB:-$computed_pg_work_mem_mb}"
|
||||
PG_MIN_WAL_SIZE_GB="${PG_MIN_WAL_SIZE_GB:-$computed_pg_min_wal_size_gb}"
|
||||
PG_MAX_WAL_SIZE_GB="${PG_MAX_WAL_SIZE_GB:-$computed_pg_max_wal_size_gb}"
|
||||
PARRHESIA_POOL_SIZE="${PARRHESIA_POOL_SIZE:-$computed_parrhesia_pool_size}"
|
||||
NOSTREAM_DB_MIN_POOL_SIZE="${NOSTREAM_DB_MIN_POOL_SIZE:-$computed_nostream_db_min_pool_size}"
|
||||
NOSTREAM_DB_MAX_POOL_SIZE="${NOSTREAM_DB_MAX_POOL_SIZE:-$computed_nostream_db_max_pool_size}"
|
||||
REDIS_MAXMEMORY_MB="${REDIS_MAXMEMORY_MB:-$computed_redis_maxmemory_mb}"
|
||||
|
||||
PG_TUNING_ARGS=(
|
||||
-c max_connections="$PG_MAX_CONNECTIONS"
|
||||
-c shared_buffers="${PG_SHARED_BUFFERS_MB}MB"
|
||||
-c effective_cache_size="${PG_EFFECTIVE_CACHE_SIZE_MB}MB"
|
||||
-c maintenance_work_mem="${PG_MAINTENANCE_WORK_MEM_MB}MB"
|
||||
-c work_mem="${PG_WORK_MEM_MB}MB"
|
||||
-c min_wal_size="${PG_MIN_WAL_SIZE_GB}GB"
|
||||
-c max_wal_size="${PG_MAX_WAL_SIZE_GB}GB"
|
||||
-c checkpoint_completion_target=0.9
|
||||
-c wal_compression=on
|
||||
)
|
||||
|
||||
echo "[server] resource profile: mem_mb=$HOST_MEM_MB cpu_cores=$HOST_CPU_CORES"
|
||||
echo "[server] postgres tuning: max_connections=$PG_MAX_CONNECTIONS shared_buffers=${PG_SHARED_BUFFERS_MB}MB effective_cache_size=${PG_EFFECTIVE_CACHE_SIZE_MB}MB work_mem=${PG_WORK_MEM_MB}MB"
|
||||
echo "[server] app tuning: parrhesia_pool=$PARRHESIA_POOL_SIZE nostream_db_pool=${NOSTREAM_DB_MIN_POOL_SIZE}-${NOSTREAM_DB_MAX_POOL_SIZE} redis_maxmemory=${REDIS_MAXMEMORY_MB}MB"
|
||||
}
|
||||
|
||||
tune_nostream_settings() {
|
||||
local settings_path="/root/nostream-config/settings.yaml"
|
||||
|
||||
if [[ ! -f "$settings_path" ]]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
python3 - "$settings_path" <<'PY'
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
path = pathlib.Path(sys.argv[1])
|
||||
text = path.read_text(encoding="utf-8")
|
||||
|
||||
def replace_after(marker: str, old: str, new: str) -> None:
|
||||
global text
|
||||
marker_idx = text.find(marker)
|
||||
if marker_idx == -1:
|
||||
return
|
||||
|
||||
old_idx = text.find(old, marker_idx)
|
||||
if old_idx == -1:
|
||||
return
|
||||
|
||||
text = text[:old_idx] + new + text[old_idx + len(old):]
|
||||
|
||||
text = text.replace(" remoteIpHeader: x-forwarded-for", " # remoteIpHeader disabled for direct bench traffic")
|
||||
|
||||
text = text.replace(
|
||||
" connection:\\n rateLimits:\\n - period: 1000\\n rate: 12\\n - period: 60000\\n rate: 48",
|
||||
" connection:\\n rateLimits:\\n - period: 1000\\n rate: 300\\n - period: 60000\\n rate: 12000",
|
||||
)
|
||||
|
||||
replace_after("description: 30 admission checks/min or 1 check every 2 seconds", "rate: 30", "rate: 3000")
|
||||
replace_after("description: 6 events/min for event kinds 0, 3, 40 and 41", "rate: 6", "rate: 600")
|
||||
replace_after("description: 12 events/min for event kinds 1, 2, 4 and 42", "rate: 12", "rate: 1200")
|
||||
replace_after("description: 30 events/min for event kind ranges 5-7 and 43-49", "rate: 30", "rate: 3000")
|
||||
replace_after("description: 24 events/min for replaceable events and parameterized replaceable", "rate: 24", "rate: 2400")
|
||||
replace_after("description: 60 events/min for ephemeral events", "rate: 60", "rate: 6000")
|
||||
replace_after("description: 720 events/hour for all events", "rate: 720", "rate: 72000")
|
||||
replace_after("description: 240 raw messages/min", "rate: 240", "rate: 120000")
|
||||
|
||||
text = text.replace("maxSubscriptions: 10", "maxSubscriptions: 512")
|
||||
text = text.replace("maxFilters: 10", "maxFilters: 128")
|
||||
text = text.replace("maxFilterValues: 2500", "maxFilterValues: 100000")
|
||||
text = text.replace("maxLimit: 5000", "maxLimit: 50000")
|
||||
|
||||
path.write_text(text, encoding="utf-8")
|
||||
PY
|
||||
}
|
||||
|
||||
common_parrhesia_env=()
|
||||
common_parrhesia_env+=( -e PARRHESIA_ENABLE_EXPIRATION_WORKER=0 )
|
||||
common_parrhesia_env+=( -e PARRHESIA_ENABLE_PARTITION_RETENTION_WORKER=0 )
|
||||
common_parrhesia_env+=( -e PARRHESIA_PUBLIC_MAX_CONNECTIONS=infinity )
|
||||
common_parrhesia_env+=( -e PARRHESIA_LIMITS_MAX_FRAME_BYTES=16777216 )
|
||||
common_parrhesia_env+=( -e PARRHESIA_LIMITS_MAX_EVENT_BYTES=4194304 )
|
||||
common_parrhesia_env+=( -e PARRHESIA_LIMITS_MAX_FILTERS_PER_REQ=1024 )
|
||||
common_parrhesia_env+=( -e PARRHESIA_LIMITS_MAX_FILTER_LIMIT=100000 )
|
||||
common_parrhesia_env+=( -e PARRHESIA_LIMITS_MAX_TAGS_PER_EVENT=4096 )
|
||||
common_parrhesia_env+=( -e PARRHESIA_LIMITS_MAX_TAG_VALUES_PER_FILTER=4096 )
|
||||
common_parrhesia_env+=( -e PARRHESIA_LIMITS_IP_MAX_EVENT_INGEST_PER_WINDOW=1000000 )
|
||||
common_parrhesia_env+=( -e PARRHESIA_LIMITS_RELAY_MAX_EVENT_INGEST_PER_WINDOW=1000000 )
|
||||
common_parrhesia_env+=( -e PARRHESIA_LIMITS_MAX_SUBSCRIPTIONS_PER_CONNECTION=4096 )
|
||||
common_parrhesia_env+=( -e PARRHESIA_LIMITS_MAX_EVENT_FUTURE_SKEW_SECONDS=31536000 )
|
||||
common_parrhesia_env+=( -e PARRHESIA_LIMITS_MAX_EVENT_INGEST_PER_WINDOW=1000000 )
|
||||
common_parrhesia_env+=( -e PARRHESIA_LIMITS_AUTH_MAX_AGE_SECONDS=31536000 )
|
||||
common_parrhesia_env+=( -e PARRHESIA_LIMITS_MAX_OUTBOUND_QUEUE=65536 )
|
||||
common_parrhesia_env+=( -e PARRHESIA_LIMITS_OUTBOUND_DRAIN_BATCH_SIZE=4096 )
|
||||
common_parrhesia_env+=( -e PARRHESIA_LIMITS_MAX_NEGENTROPY_PAYLOAD_BYTES=1048576 )
|
||||
common_parrhesia_env+=( -e PARRHESIA_LIMITS_MAX_NEGENTROPY_SESSIONS_PER_CONNECTION=256 )
|
||||
common_parrhesia_env+=( -e PARRHESIA_LIMITS_MAX_NEGENTROPY_TOTAL_SESSIONS=100000 )
|
||||
common_parrhesia_env+=( -e PARRHESIA_LIMITS_MAX_NEGENTROPY_ITEMS_PER_SESSION=1000000 )
|
||||
|
||||
cmd="${1:-}"
|
||||
if [[ -z "$cmd" ]]; then
|
||||
echo "usage: cloud-bench-server.sh <start-*|wipe-data-*|count-data-*|cleanup>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
derive_resource_tuning
|
||||
|
||||
case "$cmd" in
|
||||
start-parrhesia-pg)
|
||||
cleanup_containers
|
||||
docker network create benchnet >/dev/null 2>&1 || true
|
||||
|
||||
docker run -d --name pg --network benchnet \
|
||||
--ulimit nofile=262144:262144 \
|
||||
-e POSTGRES_DB=parrhesia \
|
||||
-e POSTGRES_USER=parrhesia \
|
||||
-e POSTGRES_PASSWORD=parrhesia \
|
||||
"$POSTGRES_IMAGE" \
|
||||
"${PG_TUNING_ARGS[@]}" >/dev/null
|
||||
|
||||
wait_pg 90
|
||||
|
||||
docker run --rm --network benchnet \
|
||||
-e DATABASE_URL=ecto://parrhesia:parrhesia@pg:5432/parrhesia \
|
||||
"$PARRHESIA_IMAGE" \
|
||||
eval "Parrhesia.Release.migrate()"
|
||||
|
||||
docker run -d --name parrhesia --network benchnet \
|
||||
--ulimit nofile=262144:262144 \
|
||||
-p 4413:4413 \
|
||||
-e DATABASE_URL=ecto://parrhesia:parrhesia@pg:5432/parrhesia \
|
||||
-e POOL_SIZE="$PARRHESIA_POOL_SIZE" \
|
||||
"${common_parrhesia_env[@]}" \
|
||||
"$PARRHESIA_IMAGE" >/dev/null
|
||||
|
||||
wait_http "http://127.0.0.1:4413/health" 120 parrhesia
|
||||
;;
|
||||
|
||||
start-parrhesia-memory)
|
||||
cleanup_containers
|
||||
|
||||
docker run -d --name parrhesia \
|
||||
--ulimit nofile=262144:262144 \
|
||||
-p 4413:4413 \
|
||||
-e PARRHESIA_STORAGE_BACKEND=memory \
|
||||
-e PARRHESIA_MODERATION_CACHE_ENABLED=0 \
|
||||
"${common_parrhesia_env[@]}" \
|
||||
"$PARRHESIA_IMAGE" >/dev/null
|
||||
|
||||
wait_http "http://127.0.0.1:4413/health" 120 parrhesia
|
||||
;;
|
||||
|
||||
start-strfry)
|
||||
cleanup_containers
|
||||
|
||||
rm -rf /root/strfry-data
|
||||
mkdir -p /root/strfry-data/strfry
|
||||
cat > /root/strfry.conf <<'EOF'
|
||||
# generated by cloud bench script
|
||||
db = "/data/strfry"
|
||||
relay {
|
||||
bind = "0.0.0.0"
|
||||
port = 7777
|
||||
nofiles = 131072
|
||||
}
|
||||
EOF
|
||||
|
||||
docker run -d --name strfry \
|
||||
--ulimit nofile=262144:262144 \
|
||||
-p 7777:7777 \
|
||||
-v /root/strfry.conf:/etc/strfry.conf:ro \
|
||||
-v /root/strfry-data:/data \
|
||||
"$STRFRY_IMAGE" \
|
||||
--config /etc/strfry.conf relay >/dev/null
|
||||
|
||||
wait_port 7777 60 strfry
|
||||
;;
|
||||
|
||||
start-nostr-rs-relay)
|
||||
cleanup_containers
|
||||
|
||||
cat > /root/nostr-rs.toml <<'EOF'
|
||||
[database]
|
||||
engine = "sqlite"
|
||||
|
||||
[network]
|
||||
address = "0.0.0.0"
|
||||
port = 8080
|
||||
ping_interval = 120
|
||||
|
||||
[options]
|
||||
reject_future_seconds = 1800
|
||||
|
||||
[limits]
|
||||
messages_per_sec = 5000
|
||||
subscriptions_per_min = 6000
|
||||
max_event_bytes = 1048576
|
||||
max_ws_message_bytes = 16777216
|
||||
max_ws_frame_bytes = 16777216
|
||||
broadcast_buffer = 65536
|
||||
event_persist_buffer = 16384
|
||||
limit_scrapers = false
|
||||
EOF
|
||||
|
||||
docker run -d --name nostr-rs \
|
||||
--ulimit nofile=262144:262144 \
|
||||
-p 8080:8080 \
|
||||
-v /root/nostr-rs.toml:/usr/src/app/config.toml:ro \
|
||||
"$NOSTR_RS_IMAGE" >/dev/null
|
||||
|
||||
wait_http "http://127.0.0.1:8080/" 60 nostr-rs
|
||||
;;
|
||||
|
||||
start-nostream)
|
||||
cleanup_containers
|
||||
ensure_benchnet
|
||||
|
||||
if [[ ! -d /root/nostream-src/.git ]]; then
|
||||
git clone --depth 1 "$NOSTREAM_REPO" /root/nostream-src >/dev/null
|
||||
fi
|
||||
|
||||
git -C /root/nostream-src fetch --depth 1 origin "$NOSTREAM_REF" >/dev/null 2>&1 || true
|
||||
if git -C /root/nostream-src rev-parse --verify FETCH_HEAD >/dev/null 2>&1; then
|
||||
git -C /root/nostream-src checkout --force FETCH_HEAD >/dev/null
|
||||
else
|
||||
git -C /root/nostream-src checkout --force "$NOSTREAM_REF" >/dev/null
|
||||
fi
|
||||
|
||||
nostream_ref_marker=/root/nostream-src/.bench_ref
|
||||
should_build_nostream=0
|
||||
if ! docker image inspect nostream:bench >/dev/null 2>&1; then
|
||||
should_build_nostream=1
|
||||
elif [[ ! -f "$nostream_ref_marker" ]] || [[ "$(cat "$nostream_ref_marker")" != "$NOSTREAM_REF" ]]; then
|
||||
should_build_nostream=1
|
||||
fi
|
||||
|
||||
if [[ "$should_build_nostream" == "1" ]]; then
|
||||
docker build -t nostream:bench /root/nostream-src >/dev/null
|
||||
printf '%s\n' "$NOSTREAM_REF" > "$nostream_ref_marker"
|
||||
fi
|
||||
|
||||
mkdir -p /root/nostream-config
|
||||
if [[ ! -f /root/nostream-config/settings.yaml ]]; then
|
||||
cp /root/nostream-src/resources/default-settings.yaml /root/nostream-config/settings.yaml
|
||||
fi
|
||||
|
||||
tune_nostream_settings
|
||||
|
||||
docker run -d --name nostream-db --network benchnet \
|
||||
--ulimit nofile=262144:262144 \
|
||||
-e POSTGRES_DB=nostr_ts_relay \
|
||||
-e POSTGRES_USER=nostr_ts_relay \
|
||||
-e POSTGRES_PASSWORD=nostr_ts_relay \
|
||||
"$POSTGRES_IMAGE" \
|
||||
"${PG_TUNING_ARGS[@]}" >/dev/null
|
||||
|
||||
wait_nostream_pg 90
|
||||
|
||||
docker run -d --name nostream-cache --network benchnet \
|
||||
"$NOSTREAM_REDIS_IMAGE" \
|
||||
redis-server \
|
||||
--loglevel warning \
|
||||
--requirepass nostr_ts_relay \
|
||||
--maxmemory "${REDIS_MAXMEMORY_MB}mb" \
|
||||
--maxmemory-policy noeviction >/dev/null
|
||||
|
||||
wait_nostream_redis 60
|
||||
|
||||
docker run --rm --network benchnet \
|
||||
-e DB_HOST=nostream-db \
|
||||
-e DB_PORT=5432 \
|
||||
-e DB_USER=nostr_ts_relay \
|
||||
-e DB_PASSWORD=nostr_ts_relay \
|
||||
-e DB_NAME=nostr_ts_relay \
|
||||
-v /root/nostream-src/migrations:/code/migrations:ro \
|
||||
-v /root/nostream-src/knexfile.js:/code/knexfile.js:ro \
|
||||
node:18-alpine3.16 \
|
||||
sh -lc 'cd /code && npm install --no-save --quiet knex@2.4.0 pg@8.8.0 && npx knex migrate:latest'
|
||||
|
||||
docker run -d --name nostream --network benchnet \
|
||||
--ulimit nofile=262144:262144 \
|
||||
-p 8008:8008 \
|
||||
-e SECRET="$NOSTREAM_SECRET" \
|
||||
-e RELAY_PORT=8008 \
|
||||
-e NOSTR_CONFIG_DIR=/home/node/.nostr \
|
||||
-e DB_HOST=nostream-db \
|
||||
-e DB_PORT=5432 \
|
||||
-e DB_USER=nostr_ts_relay \
|
||||
-e DB_PASSWORD=nostr_ts_relay \
|
||||
-e DB_NAME=nostr_ts_relay \
|
||||
-e DB_MIN_POOL_SIZE="$NOSTREAM_DB_MIN_POOL_SIZE" \
|
||||
-e DB_MAX_POOL_SIZE="$NOSTREAM_DB_MAX_POOL_SIZE" \
|
||||
-e DB_ACQUIRE_CONNECTION_TIMEOUT=60000 \
|
||||
-e REDIS_HOST=nostream-cache \
|
||||
-e REDIS_PORT=6379 \
|
||||
-e REDIS_USER=default \
|
||||
-e REDIS_PASSWORD=nostr_ts_relay \
|
||||
-v /root/nostream-config:/home/node/.nostr:ro \
|
||||
nostream:bench >/dev/null
|
||||
|
||||
wait_port 8008 180 nostream
|
||||
;;
|
||||
|
||||
start-haven)
|
||||
cleanup_containers
|
||||
|
||||
rm -rf /root/haven-bench
|
||||
mkdir -p /root/haven-bench/db
|
||||
mkdir -p /root/haven-bench/blossom
|
||||
mkdir -p /root/haven-bench/templates/static
|
||||
|
||||
if [[ ! -f /root/haven-bench/templates/index.html ]]; then
|
||||
cat > /root/haven-bench/templates/index.html <<'EOF'
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Haven</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Haven</h1>
|
||||
</body>
|
||||
</html>
|
||||
EOF
|
||||
fi
|
||||
|
||||
printf '[]\n' > /root/haven-bench/relays_import.json
|
||||
printf '[]\n' > /root/haven-bench/relays_blastr.json
|
||||
printf '[]\n' > /root/haven-bench/blacklisted_npubs.json
|
||||
printf '[]\n' > /root/haven-bench/whitelisted_npubs.json
|
||||
|
||||
cat > /root/haven-bench/haven.env <<EOF
|
||||
OWNER_NPUB=$HAVEN_OWNER_NPUB
|
||||
RELAY_URL=$HAVEN_RELAY_URL
|
||||
RELAY_PORT=3355
|
||||
RELAY_BIND_ADDRESS=0.0.0.0
|
||||
DB_ENGINE=badger
|
||||
LMDB_MAPSIZE=0
|
||||
BLOSSOM_PATH=blossom/
|
||||
PRIVATE_RELAY_NAME=Private Relay
|
||||
PRIVATE_RELAY_NPUB=$HAVEN_OWNER_NPUB
|
||||
PRIVATE_RELAY_DESCRIPTION=Private relay for benchmarking
|
||||
PRIVATE_RELAY_ICON=https://example.com/icon.png
|
||||
PRIVATE_RELAY_EVENT_IP_LIMITER_TOKENS_PER_INTERVAL=1000
|
||||
PRIVATE_RELAY_EVENT_IP_LIMITER_INTERVAL=1
|
||||
PRIVATE_RELAY_EVENT_IP_LIMITER_MAX_TOKENS=5000
|
||||
PRIVATE_RELAY_ALLOW_EMPTY_FILTERS=true
|
||||
PRIVATE_RELAY_ALLOW_COMPLEX_FILTERS=true
|
||||
PRIVATE_RELAY_CONNECTION_RATE_LIMITER_TOKENS_PER_INTERVAL=500
|
||||
PRIVATE_RELAY_CONNECTION_RATE_LIMITER_INTERVAL=1
|
||||
PRIVATE_RELAY_CONNECTION_RATE_LIMITER_MAX_TOKENS=2000
|
||||
CHAT_RELAY_NAME=Chat Relay
|
||||
CHAT_RELAY_NPUB=$HAVEN_OWNER_NPUB
|
||||
CHAT_RELAY_DESCRIPTION=Chat relay for benchmarking
|
||||
CHAT_RELAY_ICON=https://example.com/icon.png
|
||||
CHAT_RELAY_EVENT_IP_LIMITER_TOKENS_PER_INTERVAL=1000
|
||||
CHAT_RELAY_EVENT_IP_LIMITER_INTERVAL=1
|
||||
CHAT_RELAY_EVENT_IP_LIMITER_MAX_TOKENS=5000
|
||||
CHAT_RELAY_ALLOW_EMPTY_FILTERS=true
|
||||
CHAT_RELAY_ALLOW_COMPLEX_FILTERS=true
|
||||
CHAT_RELAY_CONNECTION_RATE_LIMITER_TOKENS_PER_INTERVAL=500
|
||||
CHAT_RELAY_CONNECTION_RATE_LIMITER_INTERVAL=1
|
||||
CHAT_RELAY_CONNECTION_RATE_LIMITER_MAX_TOKENS=2000
|
||||
OUTBOX_RELAY_NAME=Outbox Relay
|
||||
OUTBOX_RELAY_NPUB=$HAVEN_OWNER_NPUB
|
||||
OUTBOX_RELAY_DESCRIPTION=Outbox relay for benchmarking
|
||||
OUTBOX_RELAY_ICON=https://example.com/icon.png
|
||||
OUTBOX_RELAY_EVENT_IP_LIMITER_TOKENS_PER_INTERVAL=1000
|
||||
OUTBOX_RELAY_EVENT_IP_LIMITER_INTERVAL=1
|
||||
OUTBOX_RELAY_EVENT_IP_LIMITER_MAX_TOKENS=5000
|
||||
OUTBOX_RELAY_ALLOW_EMPTY_FILTERS=true
|
||||
OUTBOX_RELAY_ALLOW_COMPLEX_FILTERS=true
|
||||
OUTBOX_RELAY_CONNECTION_RATE_LIMITER_TOKENS_PER_INTERVAL=500
|
||||
OUTBOX_RELAY_CONNECTION_RATE_LIMITER_INTERVAL=1
|
||||
OUTBOX_RELAY_CONNECTION_RATE_LIMITER_MAX_TOKENS=2000
|
||||
INBOX_RELAY_NAME=Inbox Relay
|
||||
INBOX_RELAY_NPUB=$HAVEN_OWNER_NPUB
|
||||
INBOX_RELAY_DESCRIPTION=Inbox relay for benchmarking
|
||||
INBOX_RELAY_ICON=https://example.com/icon.png
|
||||
INBOX_RELAY_EVENT_IP_LIMITER_TOKENS_PER_INTERVAL=1000
|
||||
INBOX_RELAY_EVENT_IP_LIMITER_INTERVAL=1
|
||||
INBOX_RELAY_EVENT_IP_LIMITER_MAX_TOKENS=5000
|
||||
INBOX_RELAY_ALLOW_EMPTY_FILTERS=true
|
||||
INBOX_RELAY_ALLOW_COMPLEX_FILTERS=true
|
||||
INBOX_RELAY_CONNECTION_RATE_LIMITER_TOKENS_PER_INTERVAL=500
|
||||
INBOX_RELAY_CONNECTION_RATE_LIMITER_INTERVAL=1
|
||||
INBOX_RELAY_CONNECTION_RATE_LIMITER_MAX_TOKENS=2000
|
||||
INBOX_PULL_INTERVAL_SECONDS=600
|
||||
IMPORT_START_DATE=2023-01-20
|
||||
IMPORT_OWNER_NOTES_FETCH_TIMEOUT_SECONDS=60
|
||||
IMPORT_TAGGED_NOTES_FETCH_TIMEOUT_SECONDS=120
|
||||
IMPORT_SEED_RELAYS_FILE=/app/relays_import.json
|
||||
BACKUP_PROVIDER=none
|
||||
BACKUP_INTERVAL_HOURS=24
|
||||
BLASTR_RELAYS_FILE=/app/relays_blastr.json
|
||||
BLASTR_TIMEOUT_SECONDS=5
|
||||
WOT_DEPTH=3
|
||||
WOT_MINIMUM_FOLLOWERS=0
|
||||
WOT_FETCH_TIMEOUT_SECONDS=30
|
||||
WOT_REFRESH_INTERVAL=24h
|
||||
WHITELISTED_NPUBS_FILE=
|
||||
BLACKLISTED_NPUBS_FILE=
|
||||
HAVEN_LOG_LEVEL=INFO
|
||||
EOF
|
||||
|
||||
chmod -R a+rwX /root/haven-bench
|
||||
|
||||
docker run -d --name haven \
|
||||
--ulimit nofile=262144:262144 \
|
||||
-p 3355:3355 \
|
||||
--env-file /root/haven-bench/haven.env \
|
||||
-v /root/haven-bench/db:/app/db \
|
||||
-v /root/haven-bench/blossom:/app/blossom \
|
||||
-v /root/haven-bench/templates:/app/templates \
|
||||
-v /root/haven-bench/relays_import.json:/app/relays_import.json \
|
||||
-v /root/haven-bench/relays_blastr.json:/app/relays_blastr.json \
|
||||
-v /root/haven-bench/blacklisted_npubs.json:/app/blacklisted_npubs.json \
|
||||
-v /root/haven-bench/whitelisted_npubs.json:/app/whitelisted_npubs.json \
|
||||
"$HAVEN_IMAGE" >/dev/null
|
||||
|
||||
wait_port 3355 120 haven
|
||||
;;
|
||||
|
||||
wipe-data-parrhesia-pg)
|
||||
docker exec pg psql -U parrhesia -d parrhesia -c \
|
||||
"TRUNCATE event_ids, event_tags, events, replaceable_event_state, addressable_event_state CASCADE"
|
||||
;;
|
||||
|
||||
wipe-data-parrhesia-memory)
|
||||
docker restart parrhesia
|
||||
wait_http "http://127.0.0.1:4413/health" 120 parrhesia
|
||||
;;
|
||||
|
||||
wipe-data-strfry)
|
||||
docker stop strfry
|
||||
rm -rf /root/strfry-data/strfry/*
|
||||
docker start strfry
|
||||
wait_port 7777 60 strfry
|
||||
;;
|
||||
|
||||
wipe-data-nostr-rs-relay)
|
||||
docker rm -f nostr-rs
|
||||
docker run -d --name nostr-rs \
|
||||
--ulimit nofile=262144:262144 \
|
||||
-p 8080:8080 \
|
||||
-v /root/nostr-rs.toml:/usr/src/app/config.toml:ro \
|
||||
"$NOSTR_RS_IMAGE" >/dev/null
|
||||
wait_http "http://127.0.0.1:8080/" 60 nostr-rs
|
||||
;;
|
||||
|
||||
wipe-data-nostream)
|
||||
docker exec nostream-db psql -U nostr_ts_relay -d nostr_ts_relay -c \
|
||||
"TRUNCATE events CASCADE"
|
||||
;;
|
||||
|
||||
wipe-data-haven)
|
||||
docker stop haven
|
||||
rm -rf /root/haven-bench/db/*
|
||||
docker start haven
|
||||
wait_port 3355 120 haven
|
||||
;;
|
||||
|
||||
count-data-parrhesia-pg)
|
||||
docker exec pg psql -U parrhesia -d parrhesia -At -c "SELECT count(*) FROM events"
|
||||
;;
|
||||
|
||||
count-data-nostream)
|
||||
docker exec nostream-db psql -U nostr_ts_relay -d nostr_ts_relay -At -c "SELECT count(*) FROM events"
|
||||
;;
|
||||
|
||||
cleanup)
|
||||
cleanup_containers
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "unknown command: $cmd" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
88
scripts/just_help.sh
Executable file
88
scripts/just_help.sh
Executable file
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
topic="${1:-}"
|
||||
|
||||
if [[ -z "$topic" || "$topic" == "root" || "$topic" == "all" ]]; then
|
||||
cat <<'EOF'
|
||||
Parrhesia command runner
|
||||
|
||||
Usage:
|
||||
just <group> <subcommand> [args...]
|
||||
just help [group]
|
||||
|
||||
Command groups:
|
||||
e2e <subcommand> End-to-end harness entrypoints
|
||||
bench <subcommand> Benchmark tasks (local/cloud/history/chart)
|
||||
|
||||
Notes:
|
||||
- Keep using mix aliases for core project workflows:
|
||||
mix setup
|
||||
mix test
|
||||
mix lint
|
||||
mix precommit
|
||||
|
||||
Examples:
|
||||
just help bench
|
||||
just e2e marmot
|
||||
just e2e node-sync
|
||||
just bench compare
|
||||
just bench cloud --clients 3
|
||||
EOF
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$topic" == "e2e" ]]; then
|
||||
cat <<'EOF'
|
||||
E2E commands
|
||||
|
||||
just e2e nak CLI e2e tests via nak
|
||||
just e2e marmot Marmot client e2e tests
|
||||
just e2e node-sync Local two-node sync harness
|
||||
just e2e node-sync-docker Docker two-node sync harness
|
||||
|
||||
Advanced:
|
||||
just e2e suite <name> <cmd...>
|
||||
-> runs scripts/run_e2e_suite.sh directly
|
||||
EOF
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$topic" == "bench" ]]; then
|
||||
cat <<'EOF'
|
||||
Benchmark commands
|
||||
|
||||
just bench compare Local benchmark comparison table only
|
||||
just bench collect Append run results to bench/history.jsonl
|
||||
just bench update Regenerate chart + README table
|
||||
just bench list List machines/runs from history
|
||||
just bench at <git-ref> Run collect benchmark at git ref
|
||||
just bench cloud [args...] Cloud benchmark wrapper
|
||||
just bench cloud-quick Cloud smoke profile
|
||||
|
||||
Cloud tip:
|
||||
just bench cloud --yes --datacenter auto
|
||||
-> auto-pick cheapest compatible DC and skip interactive confirmation
|
||||
|
||||
Cloud defaults:
|
||||
targets = parrhesia-pg,parrhesia-memory,strfry,nostr-rs-relay,nostream,haven
|
||||
|
||||
Relay-target helpers:
|
||||
just bench relay [all|connect|echo|event|req] [nostr-bench-args...]
|
||||
just bench relay-strfry [...]
|
||||
just bench relay-nostr-rs [...]
|
||||
|
||||
Examples:
|
||||
just bench compare
|
||||
just bench collect
|
||||
just bench update --machine all
|
||||
just bench at v0.5.0
|
||||
just bench cloud --clients 3
|
||||
just bench cloud --targets parrhesia-pg,nostream,haven --nostream-ref main
|
||||
EOF
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Unknown help topic: $topic" >&2
|
||||
echo "Run: just help" >&2
|
||||
exit 1
|
||||
@@ -2,12 +2,14 @@ defmodule NodeSyncE2E.RelayClient do
|
||||
use WebSockex
|
||||
|
||||
def start_link(url, owner, opts \\ []) do
|
||||
WebSockex.start_link(
|
||||
url,
|
||||
__MODULE__,
|
||||
owner,
|
||||
Keyword.put(opts, :handle_initial_conn_failure, true)
|
||||
)
|
||||
ws_opts =
|
||||
opts
|
||||
|> Keyword.put_new(:handle_initial_conn_failure, true)
|
||||
|> Keyword.put_new(:async, true)
|
||||
|> Keyword.put_new(:socket_connect_timeout, 2_000)
|
||||
|> Keyword.put_new(:socket_recv_timeout, 2_000)
|
||||
|
||||
WebSockex.start_link(url, __MODULE__, owner, ws_opts)
|
||||
end
|
||||
|
||||
def send_json(pid, payload) do
|
||||
@@ -163,6 +165,84 @@ defmodule NodeSyncE2E.Runner do
|
||||
end
|
||||
end
|
||||
|
||||
defp dispatch("filter-selectivity", config, opts) do
|
||||
with {:ok, state_file} <- fetch_state_file(opts),
|
||||
state = load_state(state_file),
|
||||
:ok <- ensure_run_matches(config, state),
|
||||
:ok <- ensure_nodes_ready(config),
|
||||
:ok <- wait_for_sync_connected(config, config.node_b, config.server_id),
|
||||
{:ok, non_matching_event} <- publish_non_matching_event(config, config.node_a),
|
||||
_ = Process.sleep(2_000),
|
||||
:ok <- ensure_event_absent(config, config.node_b, non_matching_event["id"]),
|
||||
:ok <-
|
||||
save_state(state_file, %{
|
||||
"run_id" => config.run_id,
|
||||
"resource" => config.resource,
|
||||
"server_id" => config.server_id,
|
||||
"non_matching_event_id" => non_matching_event["id"]
|
||||
}) do
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp dispatch("sync-stop-restart", config, opts) do
|
||||
with {:ok, state_file} <- fetch_state_file(opts),
|
||||
state = load_state(state_file),
|
||||
:ok <- ensure_run_matches(config, state),
|
||||
:ok <- ensure_nodes_ready(config),
|
||||
{:ok, %{"ok" => true}} <-
|
||||
management_call(config, config.node_b, "sync_stop_server", %{"id" => config.server_id}),
|
||||
{:ok, while_stopped_event} <-
|
||||
publish_phase_event(config, config.node_a, "while-stopped"),
|
||||
_ = Process.sleep(2_000),
|
||||
:ok <- ensure_event_absent(config, config.node_b, while_stopped_event["id"]),
|
||||
{:ok, %{"ok" => true}} <-
|
||||
management_call(config, config.node_b, "sync_start_server", %{
|
||||
"id" => config.server_id
|
||||
}),
|
||||
:ok <- wait_for_sync_connected(config, config.node_b, config.server_id),
|
||||
:ok <- wait_for_event(config, config.node_b, while_stopped_event["id"]),
|
||||
:ok <-
|
||||
save_state(state_file, %{
|
||||
"run_id" => config.run_id,
|
||||
"resource" => config.resource,
|
||||
"server_id" => config.server_id,
|
||||
"while_stopped_event_id" => while_stopped_event["id"]
|
||||
}) do
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp dispatch("bidirectional-sync", config, opts) do
|
||||
with {:ok, state_file} <- fetch_state_file(opts),
|
||||
state = load_state(state_file),
|
||||
:ok <- ensure_run_matches(config, state),
|
||||
{:ok, node_a_pubkey} <- fetch_state_value(state, "node_a_pubkey"),
|
||||
{:ok, node_b_pubkey} <- fetch_state_value(state, "node_b_pubkey"),
|
||||
:ok <- ensure_nodes_ready(config),
|
||||
:ok <- ensure_acl(config, config.node_b, node_a_pubkey, "sync_read", config.filter),
|
||||
:ok <-
|
||||
ensure_acl(config, config.node_b, config.client_pubkey, "sync_write", config.filter),
|
||||
:ok <- ensure_acl(config, config.node_a, node_b_pubkey, "sync_write", config.filter),
|
||||
:ok <-
|
||||
ensure_acl(config, config.node_a, config.client_pubkey, "sync_read", config.filter),
|
||||
reverse_server_id = "node-b-upstream",
|
||||
:ok <- configure_reverse_sync(config, node_b_pubkey, reverse_server_id),
|
||||
:ok <- wait_for_sync_connected(config, config.node_a, reverse_server_id),
|
||||
{:ok, bidir_event} <- publish_phase_event(config, config.node_b, "bidirectional"),
|
||||
:ok <- wait_for_event(config, config.node_a, bidir_event["id"]),
|
||||
:ok <-
|
||||
save_state(state_file, %{
|
||||
"run_id" => config.run_id,
|
||||
"resource" => config.resource,
|
||||
"server_id" => config.server_id,
|
||||
"reverse_server_id" => reverse_server_id,
|
||||
"bidir_event_id" => bidir_event["id"]
|
||||
}) do
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp dispatch(other, _config, _opts), do: {:error, {:unknown_command, other}}
|
||||
|
||||
defp fetch_state_file(opts) do
|
||||
@@ -334,6 +414,77 @@ defmodule NodeSyncE2E.Runner do
|
||||
%{"mode" => "disabled", "pins" => []}
|
||||
end
|
||||
|
||||
defp configure_reverse_sync(config, node_b_pubkey, reverse_server_id) do
|
||||
params = %{
|
||||
"id" => reverse_server_id,
|
||||
"url" => config.node_b.sync_url,
|
||||
"enabled?" => true,
|
||||
"auth_pubkey" => node_b_pubkey,
|
||||
"filters" => [config.filter],
|
||||
"tls" => sync_tls_config(config.node_b.sync_url)
|
||||
}
|
||||
|
||||
with {:ok, _server} <- management_call(config, config.node_a, "sync_put_server", params),
|
||||
{:ok, %{"ok" => true}} <-
|
||||
management_call(config, config.node_a, "sync_start_server", %{
|
||||
"id" => reverse_server_id
|
||||
}) do
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp publish_non_matching_event(config, node) do
|
||||
event =
|
||||
%{
|
||||
"created_at" => System.system_time(:second),
|
||||
"kind" => 5001,
|
||||
"tags" => [
|
||||
["r", config.resource],
|
||||
["t", @subsystem_tag],
|
||||
["run", config.run_id],
|
||||
["phase", "filter-selectivity"]
|
||||
],
|
||||
"content" => "filter-selectivity:#{config.run_id}"
|
||||
}
|
||||
|> sign_event!(config.client_private_key)
|
||||
|
||||
with {:ok, client} <- RelayClient.start_link(node.websocket_url, self()),
|
||||
:ok <- await_client_connect(client) do
|
||||
try do
|
||||
case publish_event(client, node.relay_auth_url, config.client_private_key, event) do
|
||||
:ok -> {:ok, event}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
after
|
||||
RelayClient.close(client)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp ensure_event_absent(config, node, event_id) do
|
||||
filter = %{
|
||||
"kinds" => [5001],
|
||||
"#r" => [config.resource],
|
||||
"ids" => [event_id],
|
||||
"limit" => 1
|
||||
}
|
||||
|
||||
case query_events(node, config.client_private_key, filter) do
|
||||
{:ok, []} ->
|
||||
:ok
|
||||
|
||||
{:ok, events} when is_list(events) ->
|
||||
if Enum.any?(events, &(&1["id"] == event_id)) do
|
||||
{:error, {:unexpected_replication, event_id}}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, {:event_absence_query_failed, reason}}
|
||||
end
|
||||
end
|
||||
|
||||
defp publish_phase_event(config, node, phase) do
|
||||
event =
|
||||
%{
|
||||
@@ -428,7 +579,8 @@ defmodule NodeSyncE2E.Runner do
|
||||
[filter],
|
||||
[],
|
||||
false,
|
||||
nil
|
||||
nil,
|
||||
0
|
||||
)
|
||||
after
|
||||
RelayClient.close(client)
|
||||
@@ -444,10 +596,14 @@ defmodule NodeSyncE2E.Runner do
|
||||
filters,
|
||||
events,
|
||||
authenticated?,
|
||||
auth_event_id
|
||||
auth_event_id,
|
||||
auth_attempts
|
||||
) do
|
||||
receive do
|
||||
{:node_sync_e2e_relay_client, ^client, :frame, ["AUTH", challenge]} ->
|
||||
if auth_attempts >= 5 do
|
||||
{:error, :too_many_auth_challenges}
|
||||
else
|
||||
auth_event =
|
||||
auth_event(relay_auth_url, challenge)
|
||||
|> sign_event!(private_key)
|
||||
@@ -462,8 +618,10 @@ defmodule NodeSyncE2E.Runner do
|
||||
filters,
|
||||
events,
|
||||
authenticated?,
|
||||
auth_event["id"]
|
||||
auth_event["id"],
|
||||
auth_attempts + 1
|
||||
)
|
||||
end
|
||||
|
||||
{:node_sync_e2e_relay_client, ^client, :frame, ["OK", event_id, true, _message]}
|
||||
when event_id == auth_event_id ->
|
||||
@@ -477,7 +635,8 @@ defmodule NodeSyncE2E.Runner do
|
||||
filters,
|
||||
events,
|
||||
true,
|
||||
nil
|
||||
nil,
|
||||
auth_attempts
|
||||
)
|
||||
|
||||
{:node_sync_e2e_relay_client, ^client, :frame, ["OK", event_id, false, message]}
|
||||
@@ -493,7 +652,8 @@ defmodule NodeSyncE2E.Runner do
|
||||
filters,
|
||||
[event | events],
|
||||
authenticated?,
|
||||
auth_event_id
|
||||
auth_event_id,
|
||||
auth_attempts
|
||||
)
|
||||
|
||||
{:node_sync_e2e_relay_client, ^client, :frame, ["EOSE", ^subscription_id]} ->
|
||||
@@ -514,7 +674,8 @@ defmodule NodeSyncE2E.Runner do
|
||||
filters,
|
||||
events,
|
||||
authenticated?,
|
||||
auth_event_id
|
||||
auth_event_id,
|
||||
auth_attempts
|
||||
)
|
||||
|
||||
true ->
|
||||
@@ -838,9 +999,12 @@ defmodule NodeSyncE2E.Runner do
|
||||
defp format_reason({:missing_state_value, key}),
|
||||
do: "state file is missing #{key}"
|
||||
|
||||
defp format_reason({:unexpected_replication, event_id}),
|
||||
do: "event #{event_id} should not have replicated but was found"
|
||||
|
||||
defp format_reason(:missing_command),
|
||||
do:
|
||||
"usage: elixir scripts/node_sync_e2e.exs <bootstrap|publish-resume|verify-resume> --state-file <path>"
|
||||
"usage: elixir scripts/node_sync_e2e.exs <bootstrap|publish-resume|verify-resume|filter-selectivity|sync-stop-restart|bidirectional-sync> --state-file <path>"
|
||||
|
||||
defp format_reason(:missing_state_file),
|
||||
do: "--state-file is required"
|
||||
|
||||
185
scripts/nostr_seed.mjs
Normal file
185
scripts/nostr_seed.mjs
Normal file
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Nostr event seeder — generates and publishes events matching nostr-bench
|
||||
// patterns to a relay via WebSocket.
|
||||
//
|
||||
// Standalone:
|
||||
// node scripts/nostr_seed.mjs --url ws://127.0.0.1:4413/relay --count 10000 [--concurrency 8]
|
||||
//
|
||||
// As module:
|
||||
// import { seedEvents } from './nostr_seed.mjs';
|
||||
// const result = await seedEvents({ url, count, concurrency: 8 });
|
||||
|
||||
import { generateSecretKey, getPublicKey, finalizeEvent } from "nostr-tools/pure";
|
||||
import WebSocket from "ws";
|
||||
import { parseArgs } from "node:util";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// Matches nostr-bench util.rs:48-66 exactly.
|
||||
function generateBenchEvent() {
|
||||
const sk = generateSecretKey();
|
||||
const pk = getPublicKey(sk);
|
||||
const benchTag = `nostr-bench-${Math.floor(Math.random() * 1000)}`;
|
||||
|
||||
return finalizeEvent(
|
||||
{
|
||||
kind: 1,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
content: "This is a message from nostr-bench client",
|
||||
tags: [
|
||||
["p", pk],
|
||||
[
|
||||
"e",
|
||||
"378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7",
|
||||
],
|
||||
["t", "nostr-bench-"],
|
||||
["t", benchTag],
|
||||
],
|
||||
},
|
||||
sk,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed exactly `count` events to a relay.
|
||||
*
|
||||
* Opens `concurrency` parallel WebSocket connections, generates events
|
||||
* matching nostr-bench patterns, sends them, and waits for OK acks.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} opts.url Relay WebSocket URL
|
||||
* @param {number} opts.count Number of events to send
|
||||
* @param {number} [opts.concurrency] Parallel connections (default 8)
|
||||
* @param {function} [opts.onProgress] Called with (acked_so_far) periodically
|
||||
* @returns {Promise<{sent: number, acked: number, errors: number, elapsed_ms: number}>}
|
||||
*/
|
||||
export async function seedEvents({ url, count, concurrency = 8, onProgress }) {
|
||||
if (count <= 0) return { sent: 0, acked: 0, errors: 0, elapsed_ms: 0 };
|
||||
|
||||
const startMs = Date.now();
|
||||
let sent = 0;
|
||||
let acked = 0;
|
||||
let errors = 0;
|
||||
let nextToSend = 0;
|
||||
|
||||
// Claim the next event index atomically (single-threaded, but clear intent).
|
||||
function claimNext() {
|
||||
if (nextToSend >= count) return -1;
|
||||
return nextToSend++;
|
||||
}
|
||||
|
||||
async function runWorker() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(url);
|
||||
let pendingResolve = null;
|
||||
let closed = false;
|
||||
|
||||
ws.on("error", (err) => {
|
||||
if (!closed) {
|
||||
closed = true;
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
closed = true;
|
||||
if (pendingResolve) pendingResolve();
|
||||
resolve();
|
||||
});
|
||||
|
||||
ws.on("open", () => {
|
||||
sendNext();
|
||||
});
|
||||
|
||||
ws.on("message", (data) => {
|
||||
const msg = data.toString();
|
||||
if (msg.includes('"OK"')) {
|
||||
if (msg.includes("true")) {
|
||||
acked++;
|
||||
} else {
|
||||
errors++;
|
||||
}
|
||||
if (onProgress && (acked + errors) % 10000 === 0) {
|
||||
onProgress(acked);
|
||||
}
|
||||
sendNext();
|
||||
}
|
||||
});
|
||||
|
||||
function sendNext() {
|
||||
if (closed) return;
|
||||
const idx = claimNext();
|
||||
if (idx < 0) {
|
||||
closed = true;
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
const event = generateBenchEvent();
|
||||
sent++;
|
||||
ws.send(JSON.stringify(["EVENT", event]));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const workers = [];
|
||||
const actualConcurrency = Math.min(concurrency, count);
|
||||
for (let i = 0; i < actualConcurrency; i++) {
|
||||
workers.push(runWorker());
|
||||
}
|
||||
|
||||
await Promise.allSettled(workers);
|
||||
|
||||
return {
|
||||
sent,
|
||||
acked,
|
||||
errors,
|
||||
elapsed_ms: Date.now() - startMs,
|
||||
};
|
||||
}
|
||||
|
||||
// CLI entrypoint
|
||||
const isMain =
|
||||
process.argv[1] &&
|
||||
fileURLToPath(import.meta.url).endsWith(process.argv[1].replace(/^.*\//, ""));
|
||||
|
||||
if (isMain) {
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
url: { type: "string" },
|
||||
count: { type: "string" },
|
||||
concurrency: { type: "string", default: "8" },
|
||||
},
|
||||
strict: false,
|
||||
});
|
||||
|
||||
if (!values.url || !values.count) {
|
||||
console.error(
|
||||
"usage: node scripts/nostr_seed.mjs --url ws://... --count <n> [--concurrency <n>]",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const count = Number(values.count);
|
||||
const concurrency = Number(values.concurrency);
|
||||
|
||||
if (!Number.isInteger(count) || count < 1) {
|
||||
console.error("--count must be a positive integer");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[seed] seeding ${count} events to ${values.url} (concurrency=${concurrency})`,
|
||||
);
|
||||
|
||||
const result = await seedEvents({
|
||||
url: values.url,
|
||||
count,
|
||||
concurrency,
|
||||
onProgress: (n) => {
|
||||
process.stdout.write(`\r[seed] ${n}/${count} acked`);
|
||||
},
|
||||
});
|
||||
|
||||
if (count > 500) process.stdout.write("\n");
|
||||
console.log(JSON.stringify(result));
|
||||
}
|
||||
125
scripts/run_bench_at_ref.sh
Executable file
125
scripts/run_bench_at_ref.sh
Executable file
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
usage:
|
||||
./scripts/run_bench_at_ref.sh <git-ref>
|
||||
|
||||
Runs benchmarks for a specific git ref (tag, commit, or branch) and appends
|
||||
the results to bench/history.jsonl.
|
||||
|
||||
Uses a temporary worktree to avoid disrupting your current working directory.
|
||||
|
||||
Arguments:
|
||||
git-ref Git reference to benchmark (e.g., v0.4.0, abc1234, HEAD~3)
|
||||
|
||||
Environment:
|
||||
PARRHESIA_BENCH_RUNS Number of runs (default: 3)
|
||||
PARRHESIA_BENCH_MACHINE_ID Machine identifier (default: hostname -s)
|
||||
|
||||
All PARRHESIA_BENCH_* knobs from run_bench_compare.sh are forwarded.
|
||||
|
||||
Example:
|
||||
# Benchmark a specific tag
|
||||
./scripts/run_bench_at_ref.sh v0.4.0
|
||||
|
||||
# Benchmark a commit
|
||||
./scripts/run_bench_at_ref.sh abc1234
|
||||
|
||||
# Quick single-run benchmark
|
||||
PARRHESIA_BENCH_RUNS=1 ./scripts/run_bench_at_ref.sh v0.3.0
|
||||
EOF
|
||||
}
|
||||
|
||||
if [[ $# -eq 0 || "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
GIT_REF="$1"
|
||||
|
||||
# --- Validate ref exists -----------------------------------------------------
|
||||
|
||||
if ! git rev-parse --verify "$GIT_REF" >/dev/null 2>&1; then
|
||||
echo "Error: git ref '$GIT_REF' not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RESOLVED_COMMIT="$(git rev-parse "$GIT_REF")"
|
||||
echo "Benchmarking ref: $GIT_REF ($RESOLVED_COMMIT)"
|
||||
echo
|
||||
|
||||
# --- Setup worktree ----------------------------------------------------------
|
||||
|
||||
WORKTREE_DIR="$(mktemp -d)"
|
||||
trap 'echo "Cleaning up worktree..."; git worktree remove --force "$WORKTREE_DIR" 2>/dev/null || rm -rf "$WORKTREE_DIR"' EXIT
|
||||
|
||||
echo "Creating temporary worktree at $WORKTREE_DIR"
|
||||
git worktree add --detach "$WORKTREE_DIR" "$GIT_REF" >/dev/null 2>&1
|
||||
|
||||
# --- Run benchmark in worktree -----------------------------------------------
|
||||
|
||||
cd "$WORKTREE_DIR"
|
||||
|
||||
# Always copy latest benchmark scripts to ensure consistency
|
||||
echo "Copying latest benchmark infrastructure from current..."
|
||||
mkdir -p scripts bench
|
||||
cp "$ROOT_DIR/scripts/run_bench_collect.sh" scripts/
|
||||
cp "$ROOT_DIR/scripts/run_bench_compare.sh" scripts/
|
||||
cp "$ROOT_DIR/scripts/run_nostr_bench.sh" scripts/
|
||||
if [[ -f "$ROOT_DIR/scripts/run_nostr_bench_strfry.sh" ]]; then
|
||||
cp "$ROOT_DIR/scripts/run_nostr_bench_strfry.sh" scripts/
|
||||
fi
|
||||
if [[ -f "$ROOT_DIR/scripts/run_nostr_bench_nostr_rs_relay.sh" ]]; then
|
||||
cp "$ROOT_DIR/scripts/run_nostr_bench_nostr_rs_relay.sh" scripts/
|
||||
fi
|
||||
echo
|
||||
|
||||
echo "Installing dependencies..."
|
||||
mix deps.get
|
||||
echo
|
||||
|
||||
echo "Running benchmark in worktree..."
|
||||
echo
|
||||
|
||||
RUNS="${PARRHESIA_BENCH_RUNS:-3}"
|
||||
|
||||
# Run the benchmark collect script which will append to history.jsonl
|
||||
PARRHESIA_BENCH_RUNS="$RUNS" \
|
||||
PARRHESIA_BENCH_MACHINE_ID="${PARRHESIA_BENCH_MACHINE_ID:-}" \
|
||||
./scripts/run_bench_collect.sh
|
||||
|
||||
# --- Copy results back -------------------------------------------------------
|
||||
|
||||
echo
|
||||
echo "Copying results back to main repository..."
|
||||
|
||||
if [[ -f "$WORKTREE_DIR/bench/history.jsonl" ]]; then
|
||||
HISTORY_FILE="$ROOT_DIR/bench/history.jsonl"
|
||||
|
||||
# Get the last line from worktree history (the one just added)
|
||||
NEW_ENTRY="$(tail -n 1 "$WORKTREE_DIR/bench/history.jsonl")"
|
||||
|
||||
# Check if this exact entry already exists in the main history
|
||||
if grep -Fxq "$NEW_ENTRY" "$HISTORY_FILE" 2>/dev/null; then
|
||||
echo "Note: Entry already exists in $HISTORY_FILE, skipping append"
|
||||
else
|
||||
echo "Appending result to $HISTORY_FILE"
|
||||
echo "$NEW_ENTRY" >> "$HISTORY_FILE"
|
||||
fi
|
||||
else
|
||||
echo "Warning: No history.jsonl found in worktree" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Done --------------------------------------------------------------------
|
||||
|
||||
echo
|
||||
echo "Benchmark complete for $GIT_REF"
|
||||
echo
|
||||
echo "To regenerate the chart with updated history:"
|
||||
echo " # The chart generation reads from bench/history.jsonl"
|
||||
echo " # You can manually trigger chart regeneration if needed"
|
||||
131
scripts/run_bench_cloud.sh
Executable file
131
scripts/run_bench_cloud.sh
Executable file
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
usage:
|
||||
./scripts/run_bench_cloud.sh [options] [-- extra args for cloud_bench_orchestrate.mjs]
|
||||
|
||||
Thin wrapper around scripts/cloud_bench_orchestrate.mjs.
|
||||
|
||||
Behavior:
|
||||
- Forwards args directly to the orchestrator.
|
||||
- Adds convenience aliases:
|
||||
--image IMAGE -> --parrhesia-image IMAGE
|
||||
- Adds smoke defaults when --quick is set (unless already provided):
|
||||
--server-type cx23
|
||||
--client-type cx23
|
||||
--clients 1
|
||||
--connect-count 20
|
||||
--connect-rate 20
|
||||
--echo-count 20
|
||||
--echo-rate 20
|
||||
--echo-size 512
|
||||
--event-count 20
|
||||
--event-rate 20
|
||||
--req-count 20
|
||||
--req-rate 20
|
||||
--req-limit 10
|
||||
--keepalive-seconds 2
|
||||
|
||||
Flags handled by this wrapper:
|
||||
--quick
|
||||
--image IMAGE
|
||||
-h, --help
|
||||
|
||||
Everything else is passed through unchanged.
|
||||
|
||||
Examples:
|
||||
just bench cloud
|
||||
just bench cloud --quick
|
||||
just bench cloud --clients 2 --targets parrhesia-memory
|
||||
just bench cloud --image ghcr.io/owner/parrhesia:latest --threads 4
|
||||
just bench cloud --no-monitoring
|
||||
just bench cloud --yes --datacenter auto
|
||||
EOF
|
||||
}
|
||||
|
||||
has_opt() {
|
||||
local key="$1"
|
||||
shift
|
||||
local arg
|
||||
for arg in "$@"; do
|
||||
if [[ "$arg" == "$key" || "$arg" == "$key="* ]]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
add_default_if_missing() {
|
||||
local key="$1"
|
||||
local value="$2"
|
||||
if ! has_opt "$key" "${ORCH_ARGS[@]}"; then
|
||||
ORCH_ARGS+=("$key" "$value")
|
||||
fi
|
||||
}
|
||||
|
||||
ORCH_ARGS=()
|
||||
QUICK=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
--quick)
|
||||
QUICK=1
|
||||
ORCH_ARGS+=("--quick")
|
||||
shift
|
||||
;;
|
||||
--image)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
echo "Missing value for --image" >&2
|
||||
exit 1
|
||||
fi
|
||||
ORCH_ARGS+=("--parrhesia-image" "$2")
|
||||
shift 2
|
||||
;;
|
||||
--)
|
||||
shift
|
||||
ORCH_ARGS+=("$@")
|
||||
break
|
||||
;;
|
||||
*)
|
||||
ORCH_ARGS+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$QUICK" == "1" ]]; then
|
||||
add_default_if_missing "--server-type" "cx23"
|
||||
add_default_if_missing "--client-type" "cx23"
|
||||
add_default_if_missing "--clients" "1"
|
||||
|
||||
add_default_if_missing "--connect-count" "20"
|
||||
add_default_if_missing "--connect-rate" "20"
|
||||
add_default_if_missing "--echo-count" "20"
|
||||
add_default_if_missing "--echo-rate" "20"
|
||||
add_default_if_missing "--echo-size" "512"
|
||||
add_default_if_missing "--event-count" "20"
|
||||
add_default_if_missing "--event-rate" "20"
|
||||
add_default_if_missing "--req-count" "20"
|
||||
add_default_if_missing "--req-rate" "20"
|
||||
add_default_if_missing "--req-limit" "10"
|
||||
add_default_if_missing "--keepalive-seconds" "2"
|
||||
fi
|
||||
|
||||
CMD=(node scripts/cloud_bench_orchestrate.mjs "${ORCH_ARGS[@]}")
|
||||
|
||||
printf 'Running cloud bench:\n %q' "${CMD[0]}"
|
||||
for ((i=1; i<${#CMD[@]}; i++)); do
|
||||
printf ' %q' "${CMD[$i]}"
|
||||
done
|
||||
printf '\n\n'
|
||||
|
||||
"${CMD[@]}"
|
||||
115
scripts/run_bench_collect.sh
Executable file
115
scripts/run_bench_collect.sh
Executable file
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
usage:
|
||||
./scripts/run_bench_collect.sh
|
||||
|
||||
Runs the benchmark suite and appends results to bench/history.jsonl.
|
||||
Does NOT update README.md or regenerate chart.svg.
|
||||
|
||||
Use run_bench_update.sh to update the chart and README from collected data.
|
||||
|
||||
Environment:
|
||||
PARRHESIA_BENCH_RUNS Number of runs (default: 3)
|
||||
PARRHESIA_BENCH_MACHINE_ID Machine identifier (default: hostname -s)
|
||||
|
||||
All PARRHESIA_BENCH_* knobs from run_bench_compare.sh are forwarded.
|
||||
|
||||
Example:
|
||||
# Collect benchmark data
|
||||
./scripts/run_bench_collect.sh
|
||||
|
||||
# Later, update chart and README
|
||||
./scripts/run_bench_update.sh
|
||||
EOF
|
||||
}
|
||||
|
||||
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- Configuration -----------------------------------------------------------
|
||||
|
||||
BENCH_DIR="$ROOT_DIR/bench"
|
||||
HISTORY_FILE="$BENCH_DIR/history.jsonl"
|
||||
|
||||
MACHINE_ID="${PARRHESIA_BENCH_MACHINE_ID:-$(hostname -s)}"
|
||||
GIT_TAG="$(git describe --tags --abbrev=0 2>/dev/null || echo 'untagged')"
|
||||
GIT_COMMIT="$(git rev-parse --short=7 HEAD)"
|
||||
TIMESTAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
RUNS="${PARRHESIA_BENCH_RUNS:-3}"
|
||||
|
||||
mkdir -p "$BENCH_DIR"
|
||||
|
||||
WORK_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK_DIR"' EXIT
|
||||
|
||||
JSON_OUT="$WORK_DIR/bench_summary.json"
|
||||
RAW_OUTPUT="$WORK_DIR/bench_output.txt"
|
||||
|
||||
# --- Phase 1: Run benchmarks -------------------------------------------------
|
||||
|
||||
echo "Running ${RUNS}-run benchmark suite..."
|
||||
|
||||
PARRHESIA_BENCH_RUNS="$RUNS" \
|
||||
BENCH_JSON_OUT="$JSON_OUT" \
|
||||
./scripts/run_bench_compare.sh 2>&1 | tee "$RAW_OUTPUT"
|
||||
|
||||
if [[ ! -f "$JSON_OUT" ]]; then
|
||||
echo "Benchmark JSON output not found at $JSON_OUT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Phase 2: Append to history ----------------------------------------------
|
||||
|
||||
echo "Appending to history..."
|
||||
|
||||
node - "$JSON_OUT" "$TIMESTAMP" "$MACHINE_ID" "$GIT_TAG" "$GIT_COMMIT" "$RUNS" "$HISTORY_FILE" <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
|
||||
const [, , jsonOut, timestamp, machineId, gitTag, gitCommit, runsStr, historyFile] = process.argv;
|
||||
|
||||
const { versions, ...servers } = JSON.parse(fs.readFileSync(jsonOut, "utf8"));
|
||||
|
||||
const entry = {
|
||||
schema_version: 2,
|
||||
timestamp,
|
||||
run_id: `local-${timestamp}-${machineId}-${gitCommit}`,
|
||||
machine_id: machineId,
|
||||
git_tag: gitTag,
|
||||
git_commit: gitCommit,
|
||||
runs: Number(runsStr),
|
||||
source: {
|
||||
kind: "local",
|
||||
mode: "run_bench_collect",
|
||||
git_ref: gitTag,
|
||||
git_tag: gitTag,
|
||||
git_commit: gitCommit,
|
||||
},
|
||||
infra: {
|
||||
provider: "local",
|
||||
},
|
||||
versions: versions || {},
|
||||
servers,
|
||||
};
|
||||
|
||||
fs.appendFileSync(historyFile, JSON.stringify(entry) + "\n", "utf8");
|
||||
console.log(" entry: " + gitTag + " (" + gitCommit + ") on " + machineId);
|
||||
NODE
|
||||
|
||||
# --- Done ---------------------------------------------------------------------
|
||||
|
||||
echo
|
||||
echo "Benchmark data collected and appended to $HISTORY_FILE"
|
||||
echo
|
||||
echo "To update chart and README with collected data:"
|
||||
echo " ./scripts/run_bench_update.sh"
|
||||
echo
|
||||
echo "To update for a specific machine:"
|
||||
echo " ./scripts/run_bench_update.sh <machine_id>"
|
||||
@@ -298,7 +298,8 @@ for run in $(seq 1 "$RUNS"); do
|
||||
|
||||
done
|
||||
|
||||
node - "$WORK_DIR" "$RUNS" "$HAS_STRFRY" "$HAS_NOSTR_RS" <<'NODE'
|
||||
node - "$WORK_DIR" "$RUNS" "$HAS_STRFRY" "$HAS_NOSTR_RS" \
|
||||
"$PARRHESIA_VERSION" "$STRFRY_VERSION" "$NOSTR_RS_RELAY_VERSION" "$NOSTR_BENCH_VERSION" <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
@@ -306,6 +307,10 @@ const workDir = process.argv[2];
|
||||
const runs = Number(process.argv[3]);
|
||||
const hasStrfry = process.argv[4] === "1";
|
||||
const hasNostrRs = process.argv[5] === "1";
|
||||
const parrhesiaVersion = process.argv[6] || "";
|
||||
const strfryVersion = process.argv[7] || "";
|
||||
const nostrRsRelayVersion = process.argv[8] || "";
|
||||
const nostrBenchVersion = process.argv[9] || "";
|
||||
|
||||
function parseLog(filePath) {
|
||||
const content = fs.readFileSync(filePath, "utf8");
|
||||
@@ -502,6 +507,12 @@ if (process.env.BENCH_JSON_OUT) {
|
||||
};
|
||||
}
|
||||
|
||||
const versions = { parrhesia: parrhesiaVersion };
|
||||
if (hasStrfry && strfryVersion) versions.strfry = strfryVersion;
|
||||
if (hasNostrRs && nostrRsRelayVersion) versions["nostr-rs-relay"] = nostrRsRelayVersion;
|
||||
if (nostrBenchVersion) versions["nostr-bench"] = nostrBenchVersion;
|
||||
jsonSummary.versions = versions;
|
||||
|
||||
fs.writeFileSync(
|
||||
process.env.BENCH_JSON_OUT,
|
||||
JSON.stringify(jsonSummary, null, 2) + "\n",
|
||||
|
||||
@@ -7,198 +7,368 @@ cd "$ROOT_DIR"
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
usage:
|
||||
./scripts/run_bench_update.sh
|
||||
./scripts/run_bench_update.sh [machine_id|all]
|
||||
./scripts/run_bench_update.sh --machine <machine_id|all> [--run-id <run_id>]
|
||||
./scripts/run_bench_update.sh --list
|
||||
|
||||
Runs the benchmark suite (3 runs by default), then:
|
||||
1) Appends structured results to bench/history.jsonl
|
||||
2) Generates bench/chart.svg via gnuplot
|
||||
3) Updates the comparison table in README.md
|
||||
Regenerates bench/chart.svg and updates the benchmark table in README.md
|
||||
from collected data in bench/history.jsonl.
|
||||
|
||||
Environment:
|
||||
PARRHESIA_BENCH_RUNS Number of runs (default: 3)
|
||||
PARRHESIA_BENCH_MACHINE_ID Machine identifier (default: hostname -s)
|
||||
|
||||
All PARRHESIA_BENCH_* knobs from run_bench_compare.sh are forwarded.
|
||||
Options:
|
||||
--machine <id|all> Filter by machine_id (default: hostname -s)
|
||||
--run-id <id> Filter to an exact run_id
|
||||
--history-file <path> History JSONL file (default: bench/history.jsonl)
|
||||
--list List available machines and runs, then exit
|
||||
-h, --help
|
||||
EOF
|
||||
}
|
||||
|
||||
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- Configuration -----------------------------------------------------------
|
||||
|
||||
BENCH_DIR="$ROOT_DIR/bench"
|
||||
HISTORY_FILE="$BENCH_DIR/history.jsonl"
|
||||
CHART_FILE="$BENCH_DIR/chart.svg"
|
||||
GNUPLOT_TEMPLATE="$BENCH_DIR/chart.gnuplot"
|
||||
README_FILE="$ROOT_DIR/README.md"
|
||||
|
||||
MACHINE_ID="${PARRHESIA_BENCH_MACHINE_ID:-$(hostname -s)}"
|
||||
GIT_TAG="$(git describe --tags --abbrev=0 2>/dev/null || echo 'untagged')"
|
||||
GIT_COMMIT="$(git rev-parse --short=7 HEAD)"
|
||||
TIMESTAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
RUNS="${PARRHESIA_BENCH_RUNS:-3}"
|
||||
MACHINE_ID="$(hostname -s)"
|
||||
RUN_ID=""
|
||||
LIST_ONLY=0
|
||||
POSITIONAL_MACHINE=""
|
||||
|
||||
mkdir -p "$BENCH_DIR"
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
--machine)
|
||||
MACHINE_ID="$2"
|
||||
shift 2
|
||||
;;
|
||||
--run-id)
|
||||
RUN_ID="$2"
|
||||
shift 2
|
||||
;;
|
||||
--history-file)
|
||||
HISTORY_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--list)
|
||||
LIST_ONLY=1
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
if [[ -z "$POSITIONAL_MACHINE" ]]; then
|
||||
POSITIONAL_MACHINE="$1"
|
||||
shift
|
||||
else
|
||||
echo "Unexpected argument: $1" >&2
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -n "$POSITIONAL_MACHINE" ]]; then
|
||||
MACHINE_ID="$POSITIONAL_MACHINE"
|
||||
fi
|
||||
|
||||
if [[ ! -f "$HISTORY_FILE" ]]; then
|
||||
echo "Error: No history file found at $HISTORY_FILE" >&2
|
||||
echo "Run ./scripts/run_bench_collect.sh or ./scripts/run_bench_cloud.sh first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$LIST_ONLY" == "1" ]]; then
|
||||
node - "$HISTORY_FILE" <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
|
||||
const [, , historyFile] = process.argv;
|
||||
|
||||
const entries = fs.readFileSync(historyFile, "utf8")
|
||||
.split("\n")
|
||||
.filter((l) => l.trim().length > 0)
|
||||
.map((l) => JSON.parse(l));
|
||||
|
||||
if (entries.length === 0) {
|
||||
console.log("No entries in history file.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
entries.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
||||
|
||||
const machines = new Map();
|
||||
for (const e of entries) {
|
||||
const machineId = e.machine_id || "unknown";
|
||||
const prev = machines.get(machineId);
|
||||
if (!prev) {
|
||||
machines.set(machineId, { count: 1, latest: e });
|
||||
} else {
|
||||
prev.count += 1;
|
||||
if ((e.timestamp || "") > (prev.latest.timestamp || "")) prev.latest = e;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Machines:");
|
||||
console.log(" machine_id entries latest_timestamp latest_tag");
|
||||
for (const [machineId, info] of [...machines.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
|
||||
const id = machineId.padEnd(34, " ");
|
||||
const count = String(info.count).padStart(7, " ");
|
||||
const ts = (info.latest.timestamp || "").padEnd(24, " ");
|
||||
const tag = info.latest.git_tag || "";
|
||||
console.log(` ${id} ${count} ${ts} ${tag}`);
|
||||
}
|
||||
|
||||
console.log("\nRuns (newest first):");
|
||||
console.log(" timestamp run_id machine_id source git_tag targets");
|
||||
for (const e of entries) {
|
||||
const ts = (e.timestamp || "").slice(0, 19).padEnd(24, " ");
|
||||
const runId = (e.run_id || "").slice(0, 36).padEnd(36, " ");
|
||||
const machineId = (e.machine_id || "").slice(0, 24).padEnd(24, " ");
|
||||
const source = (e.source?.kind || "").padEnd(6, " ");
|
||||
const tag = (e.git_tag || "").slice(0, 16).padEnd(16, " ");
|
||||
const targets = (e.bench?.targets || Object.keys(e.servers || {})).join(",");
|
||||
console.log(` ${ts} ${runId} ${machineId} ${source} ${tag} ${targets}`);
|
||||
}
|
||||
NODE
|
||||
exit 0
|
||||
fi
|
||||
|
||||
WORK_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK_DIR"' EXIT
|
||||
|
||||
JSON_OUT="$WORK_DIR/bench_summary.json"
|
||||
RAW_OUTPUT="$WORK_DIR/bench_output.txt"
|
||||
echo "Generating chart (machine=$MACHINE_ID${RUN_ID:+, run_id=$RUN_ID})"
|
||||
|
||||
# --- Phase 1: Run benchmarks -------------------------------------------------
|
||||
|
||||
echo "Running ${RUNS}-run benchmark suite..."
|
||||
|
||||
PARRHESIA_BENCH_RUNS="$RUNS" \
|
||||
BENCH_JSON_OUT="$JSON_OUT" \
|
||||
./scripts/run_bench_compare.sh 2>&1 | tee "$RAW_OUTPUT"
|
||||
|
||||
if [[ ! -f "$JSON_OUT" ]]; then
|
||||
echo "Benchmark JSON output not found at $JSON_OUT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Phase 2: Append to history ----------------------------------------------
|
||||
|
||||
echo "Appending to history..."
|
||||
|
||||
node - "$JSON_OUT" "$TIMESTAMP" "$MACHINE_ID" "$GIT_TAG" "$GIT_COMMIT" "$RUNS" "$HISTORY_FILE" <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
|
||||
const [, , jsonOut, timestamp, machineId, gitTag, gitCommit, runsStr, historyFile] = process.argv;
|
||||
|
||||
const servers = JSON.parse(fs.readFileSync(jsonOut, "utf8"));
|
||||
|
||||
const entry = {
|
||||
timestamp,
|
||||
machine_id: machineId,
|
||||
git_tag: gitTag,
|
||||
git_commit: gitCommit,
|
||||
runs: Number(runsStr),
|
||||
servers,
|
||||
};
|
||||
|
||||
fs.appendFileSync(historyFile, JSON.stringify(entry) + "\n", "utf8");
|
||||
console.log(" entry: " + gitTag + " (" + gitCommit + ") on " + machineId);
|
||||
NODE
|
||||
|
||||
# --- Phase 3: Generate chart --------------------------------------------------
|
||||
|
||||
echo "Generating chart..."
|
||||
|
||||
node - "$HISTORY_FILE" "$MACHINE_ID" "$WORK_DIR" <<'NODE'
|
||||
if ! node - "$HISTORY_FILE" "$MACHINE_ID" "$RUN_ID" "$WORK_DIR" <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const [, , historyFile, machineId, workDir] = process.argv;
|
||||
const [, , historyFile, machineId, runId, workDir] = process.argv;
|
||||
|
||||
if (!fs.existsSync(historyFile)) {
|
||||
console.log(" no history file, skipping chart generation");
|
||||
process.exit(0);
|
||||
function parseSemverTag(tag) {
|
||||
const match = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(tag || "");
|
||||
return match ? match.slice(1).map(Number) : null;
|
||||
}
|
||||
|
||||
const lines = fs.readFileSync(historyFile, "utf8")
|
||||
const all = fs.readFileSync(historyFile, "utf8")
|
||||
.split("\n")
|
||||
.filter(l => l.trim().length > 0)
|
||||
.map(l => JSON.parse(l));
|
||||
.filter((l) => l.trim().length > 0)
|
||||
.map((l) => JSON.parse(l));
|
||||
|
||||
// Filter to current machine
|
||||
const entries = lines.filter(e => e.machine_id === machineId);
|
||||
|
||||
if (entries.length === 0) {
|
||||
console.log(" no history entries for machine '" + machineId + "', skipping chart");
|
||||
process.exit(0);
|
||||
let selected = all;
|
||||
if (runId && runId.length > 0) {
|
||||
selected = all.filter((e) => e.run_id === runId);
|
||||
console.log(` filtered by run_id: ${runId}`);
|
||||
} else if (machineId !== "all") {
|
||||
selected = all.filter((e) => e.machine_id === machineId);
|
||||
console.log(` filtered to machine: ${machineId}`);
|
||||
} else {
|
||||
console.log(" using all machines");
|
||||
}
|
||||
|
||||
// Sort chronologically, deduplicate by tag (latest wins)
|
||||
entries.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
||||
if (selected.length === 0) {
|
||||
console.error(" no matching history entries");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
selected.sort((a, b) => (a.timestamp || "").localeCompare(b.timestamp || ""));
|
||||
|
||||
const byTag = new Map();
|
||||
for (const e of entries) {
|
||||
byTag.set(e.git_tag, e);
|
||||
for (const e of selected) {
|
||||
byTag.set(e.git_tag || "untagged", e);
|
||||
}
|
||||
const deduped = [...byTag.values()];
|
||||
|
||||
// Determine which non-parrhesia servers are present
|
||||
const baselineServerNames = ["strfry", "nostr-rs-relay"];
|
||||
const presentBaselines = baselineServerNames.filter(srv =>
|
||||
deduped.some(e => e.servers[srv])
|
||||
);
|
||||
deduped.sort((a, b) => {
|
||||
const aTag = parseSemverTag(a.git_tag);
|
||||
const bTag = parseSemverTag(b.git_tag);
|
||||
|
||||
// Compute averages for baseline servers (constant horizontal lines)
|
||||
const baselineAvg = {};
|
||||
for (const srv of presentBaselines) {
|
||||
const vals = deduped.filter(e => e.servers[srv]).map(e => e.servers[srv]);
|
||||
baselineAvg[srv] = {};
|
||||
for (const metric of Object.keys(vals[0])) {
|
||||
const valid = vals.map(v => v[metric]).filter(Number.isFinite);
|
||||
baselineAvg[srv][metric] = valid.length > 0
|
||||
? valid.reduce((a, b) => a + b, 0) / valid.length
|
||||
: NaN;
|
||||
if (aTag && bTag) {
|
||||
return aTag[0] - bTag[0] || aTag[1] - bTag[1] || aTag[2] - bTag[2];
|
||||
}
|
||||
|
||||
return (a.git_tag || "").localeCompare(b.git_tag || "", undefined, { numeric: true });
|
||||
});
|
||||
|
||||
const primaryServerNames = new Set(["parrhesia-pg", "parrhesia-memory"]);
|
||||
const preferredBaselineOrder = ["strfry", "nostr-rs-relay", "nostream", "haven"];
|
||||
|
||||
const discoveredBaselines = new Set();
|
||||
for (const e of deduped) {
|
||||
for (const serverName of Object.keys(e.servers || {})) {
|
||||
if (!primaryServerNames.has(serverName)) {
|
||||
discoveredBaselines.add(serverName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Metrics to chart
|
||||
const chartMetrics = [
|
||||
{ key: "event_tps", label: "Event Throughput (TPS) — higher is better", file: "event_tps.tsv", ylabel: "TPS" },
|
||||
{ key: "req_tps", label: "Req Throughput (TPS) — higher is better", file: "req_tps.tsv", ylabel: "TPS" },
|
||||
{ key: "echo_tps", label: "Echo Throughput (TPS) — higher is better", file: "echo_tps.tsv", ylabel: "TPS" },
|
||||
{ key: "connect_avg_ms", label: "Connect Avg Latency (ms) — lower is better", file: "connect_avg_ms.tsv", ylabel: "ms" },
|
||||
const presentBaselines = [
|
||||
...preferredBaselineOrder.filter((srv) => discoveredBaselines.has(srv)),
|
||||
...[...discoveredBaselines].filter((srv) => !preferredBaselineOrder.includes(srv)).sort((a, b) => a.localeCompare(b)),
|
||||
];
|
||||
|
||||
// Write per-metric TSV files
|
||||
for (const cm of chartMetrics) {
|
||||
const header = ["tag", "parrhesia-pg", "parrhesia-memory"];
|
||||
for (const srv of presentBaselines) header.push(srv);
|
||||
// --- Colour palette per server: [cold, warm, hot] ---
|
||||
const serverColours = {
|
||||
"parrhesia-pg": ["#93c5fd", "#3b82f6", "#1e40af"],
|
||||
"parrhesia-memory": ["#86efac", "#22c55e", "#166534"],
|
||||
"strfry": ["#fdba74", "#f97316", "#9a3412"],
|
||||
"nostr-rs-relay": ["#fca5a5", "#ef4444", "#991b1b"],
|
||||
"nostream": ["#d8b4fe", "#a855f7", "#6b21a8"],
|
||||
"haven": ["#fde68a", "#eab308", "#854d0e"],
|
||||
};
|
||||
|
||||
const levelStyles = [
|
||||
/* cold */ { dt: 3, pt: 6, ps: 0.7, lw: 1.5 },
|
||||
/* warm */ { dt: 2, pt: 8, ps: 0.8, lw: 1.5 },
|
||||
/* hot */ { dt: 1, pt: 7, ps: 1.0, lw: 2 },
|
||||
];
|
||||
|
||||
const levels = ["cold", "warm", "hot"];
|
||||
|
||||
const shortLabel = {
|
||||
"parrhesia-pg": "pg", "parrhesia-memory": "mem",
|
||||
"strfry": "strfry", "nostr-rs-relay": "nostr-rs",
|
||||
"nostream": "nostream", "haven": "haven",
|
||||
};
|
||||
|
||||
const allServers = ["parrhesia-pg", "parrhesia-memory", ...presentBaselines];
|
||||
|
||||
function phasedKey(base, level) {
|
||||
const idx = base.lastIndexOf("_");
|
||||
return `${base.slice(0, idx)}_${level}_${base.slice(idx + 1)}`;
|
||||
}
|
||||
|
||||
function phasedValue(d, base, level) {
|
||||
const direct = d?.[phasedKey(base, level)];
|
||||
if (direct !== undefined) return direct;
|
||||
|
||||
if (level === "cold") {
|
||||
// Backward compatibility for historical entries written with `empty` phase names.
|
||||
const legacy = d?.[phasedKey(base, "empty")];
|
||||
if (legacy !== undefined) return legacy;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isPhased(e) {
|
||||
for (const srv of Object.values(e.servers || {})) {
|
||||
if (phasedValue(srv, "event_tps", "cold") !== undefined) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- Emit linetype definitions (server × level) ---
|
||||
const plotLines = [];
|
||||
for (let si = 0; si < allServers.length; si++) {
|
||||
const colours = serverColours[allServers[si]] || ["#888888", "#555555", "#222222"];
|
||||
for (let li = 0; li < 3; li++) {
|
||||
const s = levelStyles[li];
|
||||
plotLines.push(
|
||||
`set linetype ${si * 3 + li + 1} lc rgb "${colours[li]}" lw ${s.lw} pt ${s.pt} ps ${s.ps} dt ${s.dt}`
|
||||
);
|
||||
}
|
||||
}
|
||||
plotLines.push("");
|
||||
|
||||
// Panel definitions — order matches 4x2 grid (left-to-right, top-to-bottom)
|
||||
const panels = [
|
||||
{ kind: "simple", key: "echo_tps", label: "Echo Throughput (TPS) — higher is better", file: "echo_tps.tsv", ylabel: "TPS" },
|
||||
{ kind: "simple", key: "echo_mibs", label: "Echo Throughput (MiB/s) — higher is better", file: "echo_mibs.tsv", ylabel: "MiB/s" },
|
||||
{ kind: "fill", base: "event_tps", label: "Event Throughput (TPS) — higher is better", file: "event_tps.tsv", ylabel: "TPS" },
|
||||
{ kind: "fill", base: "event_mibs", label: "Event Throughput (MiB/s) — higher is better", file: "event_mibs.tsv", ylabel: "MiB/s" },
|
||||
{ kind: "fill", base: "req_tps", label: "Req Throughput (TPS) — higher is better", file: "req_tps.tsv", ylabel: "TPS" },
|
||||
{ kind: "fill", base: "req_mibs", label: "Req Throughput (MiB/s) — higher is better", file: "req_mibs.tsv", ylabel: "MiB/s" },
|
||||
{ kind: "simple", key: "connect_avg_ms", label: "Connect Avg Latency (ms) — lower is better", file: "connect_avg_ms.tsv", ylabel: "ms" },
|
||||
];
|
||||
|
||||
for (const panel of panels) {
|
||||
if (panel.kind === "simple") {
|
||||
// One column per server
|
||||
const header = ["tag", ...allServers.map((s) => shortLabel[s] || s)];
|
||||
const rows = [header.join("\t")];
|
||||
for (const e of deduped) {
|
||||
const row = [
|
||||
e.git_tag,
|
||||
e.servers["parrhesia-pg"]?.[cm.key] ?? "NaN",
|
||||
e.servers["parrhesia-memory"]?.[cm.key] ?? "NaN",
|
||||
];
|
||||
for (const srv of presentBaselines) {
|
||||
row.push(baselineAvg[srv]?.[cm.key] ?? "NaN");
|
||||
const row = [e.git_tag || "untagged"];
|
||||
for (const srv of allServers) {
|
||||
row.push(e.servers?.[srv]?.[panel.key] ?? "NaN");
|
||||
}
|
||||
rows.push(row.join("\t"));
|
||||
}
|
||||
fs.writeFileSync(path.join(workDir, panel.file), rows.join("\n") + "\n", "utf8");
|
||||
|
||||
fs.writeFileSync(path.join(workDir, cm.file), rows.join("\n") + "\n", "utf8");
|
||||
}
|
||||
|
||||
// Generate gnuplot plot commands (handles variable column counts)
|
||||
const serverLabels = ["parrhesia-pg", "parrhesia-memory"];
|
||||
for (const srv of presentBaselines) serverLabels.push(srv + " (avg)");
|
||||
|
||||
const plotLines = [];
|
||||
for (const cm of chartMetrics) {
|
||||
const dataFile = `data_dir."/${cm.file}"`;
|
||||
plotLines.push(`set title "${cm.label}"`);
|
||||
plotLines.push(`set ylabel "${cm.ylabel}"`);
|
||||
|
||||
const plotParts = [];
|
||||
// Column 2 = parrhesia-pg, 3 = parrhesia-memory, 4+ = baselines
|
||||
plotParts.push(`${dataFile} using 0:2:xtic(1) lt 1 title "${serverLabels[0]}"`);
|
||||
plotParts.push(`'' using 0:3 lt 2 title "${serverLabels[1]}"`);
|
||||
for (let i = 0; i < presentBaselines.length; i++) {
|
||||
plotParts.push(`'' using 0:${4 + i} lt ${3 + i} title "${serverLabels[2 + i]}"`);
|
||||
}
|
||||
|
||||
plotLines.push("plot " + plotParts.join(", \\\n "));
|
||||
// Plot: one series per server, using its "hot" linetype
|
||||
const dataFile = `data_dir."/${panel.file}"`;
|
||||
plotLines.push(`set title "${panel.label}"`);
|
||||
plotLines.push(`set ylabel "${panel.ylabel}"`);
|
||||
const parts = allServers.map((srv, si) => {
|
||||
const src = si === 0 ? dataFile : "''";
|
||||
const xtic = si === 0 ? ":xtic(1)" : "";
|
||||
return `${src} using 0:${si + 2}${xtic} lt ${si * 3 + 3} title "${shortLabel[srv] || srv}"`;
|
||||
});
|
||||
plotLines.push("plot " + parts.join(", \\\n "));
|
||||
plotLines.push("");
|
||||
|
||||
} else {
|
||||
// Three columns per server (cold, warm, hot)
|
||||
const header = ["tag"];
|
||||
for (const srv of allServers) {
|
||||
const sl = shortLabel[srv] || srv;
|
||||
for (const lvl of levels) header.push(`${sl}-${lvl}`);
|
||||
}
|
||||
const rows = [header.join("\t")];
|
||||
for (const e of deduped) {
|
||||
const row = [e.git_tag || "untagged"];
|
||||
const phased = isPhased(e);
|
||||
for (const srv of allServers) {
|
||||
const d = e.servers?.[srv];
|
||||
if (!d) { row.push("NaN", "NaN", "NaN"); continue; }
|
||||
if (phased) {
|
||||
for (const lvl of levels) row.push(phasedValue(d, panel.base, lvl) ?? "NaN");
|
||||
} else {
|
||||
row.push("NaN", d[panel.base] ?? "NaN", "NaN"); // flat → warm only
|
||||
}
|
||||
}
|
||||
rows.push(row.join("\t"));
|
||||
}
|
||||
fs.writeFileSync(path.join(workDir, panel.file), rows.join("\n") + "\n", "utf8");
|
||||
|
||||
// Plot: three series per server (cold/warm/hot)
|
||||
const dataFile = `data_dir."/${panel.file}"`;
|
||||
plotLines.push(`set title "${panel.label}"`);
|
||||
plotLines.push(`set ylabel "${panel.ylabel}"`);
|
||||
const parts = [];
|
||||
let first = true;
|
||||
for (let si = 0; si < allServers.length; si++) {
|
||||
const label = shortLabel[allServers[si]] || allServers[si];
|
||||
for (let li = 0; li < 3; li++) {
|
||||
const src = first ? dataFile : "''";
|
||||
const xtic = first ? ":xtic(1)" : "";
|
||||
const col = 2 + si * 3 + li;
|
||||
parts.push(`${src} using 0:${col}${xtic} lt ${si * 3 + li + 1} title "${label} (${levels[li]})"`);
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
plotLines.push("plot " + parts.join(", \\\n "));
|
||||
plotLines.push("");
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(workDir, "plot_commands.gnuplot"),
|
||||
plotLines.join("\n") + "\n",
|
||||
"utf8"
|
||||
);
|
||||
fs.writeFileSync(path.join(workDir, "plot_commands.gnuplot"), plotLines.join("\n") + "\n", "utf8");
|
||||
|
||||
console.log(" " + deduped.length + " tag(s), " + presentBaselines.length + " baseline server(s)");
|
||||
const latestForReadme = [...selected]
|
||||
.sort((a, b) => (b.timestamp || "").localeCompare(a.timestamp || ""))
|
||||
.find((e) => e.servers?.["parrhesia-pg"] && e.servers?.["parrhesia-memory"]);
|
||||
|
||||
if (latestForReadme) {
|
||||
fs.writeFileSync(path.join(workDir, "latest_entry.json"), JSON.stringify(latestForReadme), "utf8");
|
||||
}
|
||||
|
||||
console.log(` selected=${selected.length}, series_tags=${deduped.length}, baselines=${presentBaselines.length}`);
|
||||
NODE
|
||||
then
|
||||
echo "No matching data for chart/update" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -f "$WORK_DIR/plot_commands.gnuplot" ]]; then
|
||||
gnuplot \
|
||||
@@ -207,25 +377,46 @@ if [[ -f "$WORK_DIR/plot_commands.gnuplot" ]]; then
|
||||
"$GNUPLOT_TEMPLATE"
|
||||
echo " chart written to $CHART_FILE"
|
||||
else
|
||||
echo " chart generation skipped (no data for this machine)"
|
||||
echo " chart generation skipped"
|
||||
fi
|
||||
|
||||
# --- Phase 4: Update README.md -----------------------------------------------
|
||||
echo "Updating README.md with latest benchmark..."
|
||||
|
||||
echo "Updating README.md..."
|
||||
if [[ ! -f "$WORK_DIR/latest_entry.json" ]]; then
|
||||
echo "Warning: no selected entry contains both parrhesia-pg and parrhesia-memory; skipping README table update" >&2
|
||||
echo
|
||||
echo "Benchmark rendering complete. Files updated:"
|
||||
echo " $CHART_FILE"
|
||||
echo
|
||||
exit 0
|
||||
fi
|
||||
|
||||
node - "$JSON_OUT" "$ROOT_DIR/README.md" <<'NODE'
|
||||
node - "$WORK_DIR/latest_entry.json" "$README_FILE" <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
|
||||
const [, , jsonOut, readmePath] = process.argv;
|
||||
|
||||
const servers = JSON.parse(fs.readFileSync(jsonOut, "utf8"));
|
||||
const readme = fs.readFileSync(readmePath, "utf8");
|
||||
const [, , entryPath, readmePath] = process.argv;
|
||||
const entry = JSON.parse(fs.readFileSync(entryPath, "utf8"));
|
||||
const servers = entry.servers || {};
|
||||
|
||||
const pg = servers["parrhesia-pg"];
|
||||
const mem = servers["parrhesia-memory"];
|
||||
const strfry = servers["strfry"];
|
||||
const nostrRs = servers["nostr-rs-relay"];
|
||||
|
||||
if (!pg || !mem) {
|
||||
console.error("Selected entry is missing parrhesia-pg or parrhesia-memory");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Detect phased entries — use hot fill level as headline metric
|
||||
const phased = pg.event_cold_tps !== undefined || pg.event_empty_tps !== undefined;
|
||||
|
||||
// For phased entries, resolve "event_tps" → "event_hot_tps" etc.
|
||||
function resolveKey(key) {
|
||||
if (!phased) return key;
|
||||
const fillKeys = ["event_tps", "event_mibs", "req_tps", "req_mibs"];
|
||||
if (!fillKeys.includes(key)) return key;
|
||||
const idx = key.lastIndexOf("_");
|
||||
return `${key.slice(0, idx)}_hot_${key.slice(idx + 1)}`;
|
||||
}
|
||||
|
||||
function toFixed(v, d = 2) {
|
||||
return Number.isFinite(v) ? v.toFixed(d) : "n/a";
|
||||
@@ -238,44 +429,56 @@ function ratio(base, other) {
|
||||
|
||||
function boldIf(ratioStr, lowerIsBetter) {
|
||||
if (ratioStr === "n/a") return ratioStr;
|
||||
const num = parseFloat(ratioStr);
|
||||
const num = Number.parseFloat(ratioStr);
|
||||
if (!Number.isFinite(num)) return ratioStr;
|
||||
const better = lowerIsBetter ? num < 1 : num > 1;
|
||||
return better ? "**" + ratioStr + "**" : ratioStr;
|
||||
return better ? `**${ratioStr}**` : ratioStr;
|
||||
}
|
||||
|
||||
const fillNote = phased ? " (hot fill level)" : "";
|
||||
|
||||
const metricRows = [
|
||||
["connect avg latency (ms) \u2193", "connect_avg_ms", true],
|
||||
["connect max latency (ms) \u2193", "connect_max_ms", true],
|
||||
["echo throughput (TPS) \u2191", "echo_tps", false],
|
||||
["echo throughput (MiB/s) \u2191", "echo_mibs", false],
|
||||
["event throughput (TPS) \u2191", "event_tps", false],
|
||||
["event throughput (MiB/s) \u2191", "event_mibs", false],
|
||||
["req throughput (TPS) \u2191", "req_tps", false],
|
||||
["req throughput (MiB/s) \u2191", "req_mibs", false],
|
||||
["connect avg latency (ms) ↓", "connect_avg_ms", true],
|
||||
["connect max latency (ms) ↓", "connect_max_ms", true],
|
||||
["echo throughput (TPS) ↑", "echo_tps", false],
|
||||
["echo throughput (MiB/s) ↑", "echo_mibs", false],
|
||||
[`event throughput (TPS)${fillNote} ↑`, "event_tps", false],
|
||||
[`event throughput (MiB/s)${fillNote} ↑`, "event_mibs", false],
|
||||
[`req throughput (TPS)${fillNote} ↑`, "req_tps", false],
|
||||
[`req throughput (MiB/s)${fillNote} ↑`, "req_mibs", false],
|
||||
];
|
||||
|
||||
const hasStrfry = !!strfry;
|
||||
const hasNostrRs = !!nostrRs;
|
||||
const preferredComparisonOrder = ["strfry", "nostr-rs-relay", "nostream", "haven"];
|
||||
const discoveredComparisons = Object.keys(servers).filter(
|
||||
(name) => name !== "parrhesia-pg" && name !== "parrhesia-memory",
|
||||
);
|
||||
|
||||
// Build header
|
||||
const header = ["metric", "parrhesia-pg", "parrhesia-mem"];
|
||||
if (hasStrfry) header.push("strfry");
|
||||
if (hasNostrRs) header.push("nostr-rs-relay");
|
||||
header.push("mem/pg");
|
||||
if (hasStrfry) header.push("strfry/pg");
|
||||
if (hasNostrRs) header.push("nostr-rs/pg");
|
||||
const comparisonServers = [
|
||||
...preferredComparisonOrder.filter((name) => discoveredComparisons.includes(name)),
|
||||
...discoveredComparisons.filter((name) => !preferredComparisonOrder.includes(name)).sort((a, b) => a.localeCompare(b)),
|
||||
];
|
||||
|
||||
const header = ["metric", "parrhesia-pg", "parrhesia-mem", ...comparisonServers, "mem/pg"];
|
||||
for (const serverName of comparisonServers) {
|
||||
header.push(`${serverName}/pg`);
|
||||
}
|
||||
|
||||
const alignRow = ["---"];
|
||||
for (let i = 1; i < header.length; i++) alignRow.push("---:");
|
||||
for (let i = 1; i < header.length; i += 1) alignRow.push("---:");
|
||||
|
||||
const rows = metricRows.map(([label, key, lowerIsBetter]) => {
|
||||
const row = [label, toFixed(pg[key]), toFixed(mem[key])];
|
||||
if (hasStrfry) row.push(toFixed(strfry[key]));
|
||||
if (hasNostrRs) row.push(toFixed(nostrRs[key]));
|
||||
const rk = resolveKey(key);
|
||||
const row = [label, toFixed(pg[rk]), toFixed(mem[rk])];
|
||||
|
||||
row.push(boldIf(ratio(pg[key], mem[key]), lowerIsBetter));
|
||||
if (hasStrfry) row.push(boldIf(ratio(pg[key], strfry[key]), lowerIsBetter));
|
||||
if (hasNostrRs) row.push(boldIf(ratio(pg[key], nostrRs[key]), lowerIsBetter));
|
||||
for (const serverName of comparisonServers) {
|
||||
row.push(toFixed(servers?.[serverName]?.[rk]));
|
||||
}
|
||||
|
||||
row.push(boldIf(ratio(pg[rk], mem[rk]), lowerIsBetter));
|
||||
|
||||
for (const serverName of comparisonServers) {
|
||||
row.push(boldIf(ratio(pg[rk], servers?.[serverName]?.[rk]), lowerIsBetter));
|
||||
}
|
||||
|
||||
return row;
|
||||
});
|
||||
@@ -283,12 +486,12 @@ const rows = metricRows.map(([label, key, lowerIsBetter]) => {
|
||||
const tableLines = [
|
||||
"| " + header.join(" | ") + " |",
|
||||
"| " + alignRow.join(" | ") + " |",
|
||||
...rows.map(r => "| " + r.join(" | ") + " |"),
|
||||
...rows.map((r) => "| " + r.join(" | ") + " |"),
|
||||
];
|
||||
|
||||
// Replace the first markdown table in the ## Benchmark section
|
||||
const readmeLines = readme.split("\n");
|
||||
const benchIdx = readmeLines.findIndex(l => /^## Benchmark/.test(l));
|
||||
const readme = fs.readFileSync(readmePath, "utf8");
|
||||
const lines = readme.split("\n");
|
||||
const benchIdx = lines.findIndex((l) => /^## Benchmark/.test(l));
|
||||
if (benchIdx === -1) {
|
||||
console.error("Could not find '## Benchmark' section in README.md");
|
||||
process.exit(1);
|
||||
@@ -296,8 +499,8 @@ if (benchIdx === -1) {
|
||||
|
||||
let tableStart = -1;
|
||||
let tableEnd = -1;
|
||||
for (let i = benchIdx + 1; i < readmeLines.length; i++) {
|
||||
if (readmeLines[i].startsWith("|")) {
|
||||
for (let i = benchIdx + 1; i < lines.length; i += 1) {
|
||||
if (lines[i].startsWith("|")) {
|
||||
if (tableStart === -1) tableStart = i;
|
||||
tableEnd = i;
|
||||
} else if (tableStart !== -1) {
|
||||
@@ -310,20 +513,19 @@ if (tableStart === -1) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const before = readmeLines.slice(0, tableStart);
|
||||
const after = readmeLines.slice(tableEnd + 1);
|
||||
const updated = [...before, ...tableLines, ...after].join("\n");
|
||||
const updated = [
|
||||
...lines.slice(0, tableStart),
|
||||
...tableLines,
|
||||
...lines.slice(tableEnd + 1),
|
||||
].join("\n");
|
||||
|
||||
fs.writeFileSync(readmePath, updated, "utf8");
|
||||
console.log(" table updated (" + tableLines.length + " rows)");
|
||||
console.log(` table updated (${tableLines.length} rows)`);
|
||||
NODE
|
||||
|
||||
# --- Done ---------------------------------------------------------------------
|
||||
|
||||
echo
|
||||
echo "Benchmark update complete. Files changed:"
|
||||
echo " $HISTORY_FILE"
|
||||
echo "Benchmark rendering complete. Files updated:"
|
||||
echo " $CHART_FILE"
|
||||
echo " $ROOT_DIR/README.md"
|
||||
echo " $README_FILE"
|
||||
echo
|
||||
echo "Review with: git diff"
|
||||
|
||||
@@ -108,4 +108,8 @@ docker compose -f "$COMPOSE_FILE" up -d parrhesia-b
|
||||
wait_for_health "$NODE_B_HTTP_URL" "Node B"
|
||||
run_runner verify-resume
|
||||
|
||||
run_runner filter-selectivity
|
||||
run_runner sync-stop-restart
|
||||
run_runner bidirectional-sync
|
||||
|
||||
printf 'node-sync-e2e docker run completed\nstate: %s\n' "$STATE_FILE"
|
||||
|
||||
@@ -115,7 +115,7 @@ wait_for_health() {
|
||||
return
|
||||
fi
|
||||
|
||||
sleep 0.1
|
||||
sleep 0.2
|
||||
done
|
||||
|
||||
echo "${label} did not become healthy on port ${port}" >&2
|
||||
@@ -224,4 +224,8 @@ start_node \
|
||||
wait_for_health "$NODE_B_PORT" "Node B"
|
||||
run_runner verify-resume
|
||||
|
||||
run_runner filter-selectivity
|
||||
run_runner sync-stop-restart
|
||||
run_runner bidirectional-sync
|
||||
|
||||
printf 'node-sync-e2e local run completed\nlogs: %s\n' "$LOG_DIR"
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
MarmotClient,
|
||||
KeyPackageStore,
|
||||
KeyValueGroupStateBackend,
|
||||
Proposals,
|
||||
createKeyPackageRelayListEvent,
|
||||
deserializeApplicationData,
|
||||
extractMarmotGroupData,
|
||||
@@ -142,6 +143,129 @@ function createClient(account, network) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstraps a group with one admin and `count` invited members.
|
||||
*/
|
||||
async function createGroupWithMembers(network, count) {
|
||||
const adminAccount = PrivateKeyAccount.generateNew();
|
||||
const adminPubkey = await adminAccount.signer.getPublicKey();
|
||||
const adminClient = createClient(adminAccount, network);
|
||||
|
||||
const adminRelayList = await adminAccount.signer.signEvent(
|
||||
createKeyPackageRelayListEvent({
|
||||
pubkey: adminPubkey,
|
||||
relays: [relayWsUrl],
|
||||
client: "parrhesia-marmot-e2e",
|
||||
}),
|
||||
);
|
||||
await network.publish([relayWsUrl], adminRelayList);
|
||||
|
||||
const memberInfos = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const account = PrivateKeyAccount.generateNew();
|
||||
const pubkey = await account.signer.getPublicKey();
|
||||
const client = createClient(account, network);
|
||||
|
||||
const relayList = await account.signer.signEvent(
|
||||
createKeyPackageRelayListEvent({
|
||||
pubkey,
|
||||
relays: [relayWsUrl],
|
||||
client: "parrhesia-marmot-e2e",
|
||||
}),
|
||||
);
|
||||
await network.publish([relayWsUrl], relayList);
|
||||
await client.keyPackages.create({
|
||||
relays: [relayWsUrl],
|
||||
client: "parrhesia-marmot-e2e",
|
||||
});
|
||||
|
||||
memberInfos.push({ account, pubkey, client });
|
||||
}
|
||||
|
||||
const adminGroup = await adminClient.createGroup(
|
||||
`E2E Group ${randomUUID()}`,
|
||||
{ relays: [relayWsUrl], adminPubkeys: [adminPubkey] },
|
||||
);
|
||||
|
||||
const members = [];
|
||||
for (const mi of memberInfos) {
|
||||
const keyPackageEvents = await network.request([relayWsUrl], [
|
||||
{ kinds: [KEY_PACKAGE_KIND], authors: [mi.pubkey], limit: 1 },
|
||||
]);
|
||||
assert.equal(keyPackageEvents.length, 1, `key package missing for ${mi.pubkey}`);
|
||||
await adminGroup.inviteByKeyPackageEvent(keyPackageEvents[0]);
|
||||
|
||||
const giftWraps = await requestGiftWrapsWithAuth({
|
||||
relayUrl: relayWsUrl,
|
||||
relayHttpUrl,
|
||||
signer: mi.account.signer,
|
||||
recipientPubkey: mi.pubkey,
|
||||
});
|
||||
assert.ok(giftWraps.length >= 1, `gift wrap missing for ${mi.pubkey}`);
|
||||
|
||||
const inviteReader = new InviteReader({
|
||||
signer: mi.account.signer,
|
||||
store: {
|
||||
received: new MemoryBackend(),
|
||||
unread: new MemoryBackend(),
|
||||
seen: new MemoryBackend(),
|
||||
},
|
||||
});
|
||||
await inviteReader.ingestEvents(giftWraps);
|
||||
const invites = await inviteReader.decryptGiftWraps();
|
||||
assert.equal(invites.length, 1);
|
||||
|
||||
const { group } = await mi.client.joinGroupFromWelcome({
|
||||
welcomeRumor: invites[0],
|
||||
});
|
||||
|
||||
members.push({ account: mi.account, pubkey: mi.pubkey, client: mi.client, group });
|
||||
}
|
||||
|
||||
return {
|
||||
admin: { account: adminAccount, pubkey: adminPubkey, client: adminClient, group: adminGroup },
|
||||
members,
|
||||
};
|
||||
}
|
||||
|
||||
function getNostrGroupId(group) {
|
||||
const data = extractMarmotGroupData(group.state);
|
||||
assert.ok(data, "MarmotGroupData should exist on group");
|
||||
return bytesToHex(data.nostrGroupId);
|
||||
}
|
||||
|
||||
async function fetchGroupEvents(network, group) {
|
||||
const nostrGroupId = getNostrGroupId(group);
|
||||
return network.request([relayWsUrl], [
|
||||
{ kinds: [GROUP_EVENT_KIND], "#h": [nostrGroupId], limit: 100 },
|
||||
]);
|
||||
}
|
||||
|
||||
async function countEvents(relayUrl, filters) {
|
||||
const relay = await openRelay(relayUrl, 5_000);
|
||||
const subId = randomSubId("count");
|
||||
const filtersArray = Array.isArray(filters) ? filters : [filters];
|
||||
|
||||
try {
|
||||
relay.send(["COUNT", subId, ...filtersArray]);
|
||||
|
||||
while (true) {
|
||||
const frame = await relay.nextFrame(5_000);
|
||||
if (!Array.isArray(frame)) continue;
|
||||
|
||||
if (frame[0] === "COUNT" && frame[1] === subId) {
|
||||
return frame[2];
|
||||
}
|
||||
|
||||
if (frame[0] === "CLOSED" && frame[1] === subId) {
|
||||
throw new Error(`COUNT closed: ${frame[2]}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await relay.close();
|
||||
}
|
||||
}
|
||||
|
||||
function computeEventId(event) {
|
||||
const payload = [
|
||||
0,
|
||||
@@ -603,3 +727,230 @@ test("admin invites user, user joins, and message round-trip decrypts", async ()
|
||||
"admin should decrypt invitee application message",
|
||||
);
|
||||
});
|
||||
|
||||
test("sendChatMessage convenience API works", async () => {
|
||||
const network = new RelayNetwork(relayWsUrl, relayHttpUrl);
|
||||
const { admin, members } = await createGroupWithMembers(network, 1);
|
||||
const invitee = members[0];
|
||||
|
||||
const content = `chat-msg-${randomUUID()}`;
|
||||
await invitee.group.sendChatMessage(content);
|
||||
|
||||
const groupEvents = await fetchGroupEvents(network, admin.group);
|
||||
|
||||
const decrypted = [];
|
||||
for await (const result of admin.group.ingest(groupEvents)) {
|
||||
if (result.kind === "processed" && result.result.kind === "applicationMessage") {
|
||||
decrypted.push(deserializeApplicationData(result.result.message));
|
||||
}
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
decrypted.some((r) => r.content === content && r.kind === 9),
|
||||
"admin should decrypt kind 9 chat message from invitee",
|
||||
);
|
||||
});
|
||||
|
||||
test("admin removes member from group", async () => {
|
||||
const network = new RelayNetwork(relayWsUrl, relayHttpUrl);
|
||||
const { admin, members } = await createGroupWithMembers(network, 2);
|
||||
const [user1, user2] = members;
|
||||
|
||||
// proposeRemoveUser returns ProposalRemove[] — use propose() which handles arrays
|
||||
await admin.group.propose(Proposals.proposeRemoveUser(user2.pubkey));
|
||||
await admin.group.commit();
|
||||
|
||||
// user1 ingests the removal commit
|
||||
const groupEvents = await fetchGroupEvents(network, admin.group);
|
||||
|
||||
let user1Processed = false;
|
||||
for await (const result of user1.group.ingest(groupEvents)) {
|
||||
if (result.kind === "processed" && result.result.kind === "newState") {
|
||||
user1Processed = true;
|
||||
}
|
||||
}
|
||||
assert.ok(user1Processed, "user1 should process the removal commit");
|
||||
|
||||
// user1 can still send a message that admin decrypts
|
||||
const msg = `post-removal-${randomUUID()}`;
|
||||
await user1.group.sendChatMessage(msg);
|
||||
|
||||
const updatedEvents = await fetchGroupEvents(network, admin.group);
|
||||
|
||||
const decrypted = [];
|
||||
for await (const result of admin.group.ingest(updatedEvents)) {
|
||||
if (result.kind === "processed" && result.result.kind === "applicationMessage") {
|
||||
decrypted.push(deserializeApplicationData(result.result.message));
|
||||
}
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
decrypted.some((r) => r.content === msg),
|
||||
"admin should decrypt message from user1 after user2 removal",
|
||||
);
|
||||
});
|
||||
|
||||
test("group metadata update via proposal", async () => {
|
||||
const network = new RelayNetwork(relayWsUrl, relayHttpUrl);
|
||||
const { admin, members } = await createGroupWithMembers(network, 1);
|
||||
const invitee = members[0];
|
||||
|
||||
const updatedName = `Updated-${randomUUID()}`;
|
||||
await admin.group.commit({
|
||||
extraProposals: [Proposals.proposeUpdateMetadata({ name: updatedName })],
|
||||
});
|
||||
|
||||
// Admin sees the updated name locally
|
||||
const adminGroupData = extractMarmotGroupData(admin.group.state);
|
||||
assert.equal(adminGroupData.name, updatedName);
|
||||
|
||||
// Invitee ingests the commit and sees updated metadata
|
||||
const groupEvents = await fetchGroupEvents(network, admin.group);
|
||||
|
||||
for await (const _result of invitee.group.ingest(groupEvents)) {
|
||||
// drain ingest
|
||||
}
|
||||
|
||||
const inviteeGroupData = extractMarmotGroupData(invitee.group.state);
|
||||
assert.equal(inviteeGroupData.name, updatedName, "invitee should see updated group name");
|
||||
});
|
||||
|
||||
test("member self-update rotates leaf keys (forward secrecy)", async () => {
|
||||
const network = new RelayNetwork(relayWsUrl, relayHttpUrl);
|
||||
const { admin, members } = await createGroupWithMembers(network, 1);
|
||||
const invitee = members[0];
|
||||
|
||||
const epochBefore = admin.group.state.groupContext.epoch;
|
||||
|
||||
await invitee.group.selfUpdate();
|
||||
|
||||
// Admin ingests the self-update commit
|
||||
const groupEvents = await fetchGroupEvents(network, admin.group);
|
||||
|
||||
for await (const result of admin.group.ingest(groupEvents)) {
|
||||
// drain
|
||||
}
|
||||
|
||||
const epochAfter = admin.group.state.groupContext.epoch;
|
||||
assert.ok(epochAfter > epochBefore, "admin epoch should advance after self-update commit");
|
||||
|
||||
// Both parties can still exchange messages after key rotation
|
||||
const msg = `after-selfupdate-${randomUUID()}`;
|
||||
await invitee.group.sendChatMessage(msg);
|
||||
|
||||
const updatedEvents = await fetchGroupEvents(network, admin.group);
|
||||
|
||||
const decrypted = [];
|
||||
for await (const result of admin.group.ingest(updatedEvents)) {
|
||||
if (result.kind === "processed" && result.result.kind === "applicationMessage") {
|
||||
decrypted.push(deserializeApplicationData(result.result.message));
|
||||
}
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
decrypted.some((r) => r.content === msg),
|
||||
"admin should decrypt message sent after self-update",
|
||||
);
|
||||
});
|
||||
|
||||
test("member leaves group voluntarily", async () => {
|
||||
const network = new RelayNetwork(relayWsUrl, relayHttpUrl);
|
||||
const { admin, members } = await createGroupWithMembers(network, 2);
|
||||
const [stayer, leaver] = members;
|
||||
|
||||
// Leaver publishes self-remove proposals and destroys local state
|
||||
await leaver.group.leave();
|
||||
|
||||
// Admin ingests the leave proposals (they become unapplied)
|
||||
const groupEventsForAdmin = await fetchGroupEvents(network, admin.group);
|
||||
for await (const _result of admin.group.ingest(groupEventsForAdmin)) {
|
||||
// drain
|
||||
}
|
||||
|
||||
// Admin commits all unapplied proposals (the leave removals)
|
||||
await admin.group.commit();
|
||||
|
||||
// Stayer ingests everything and the group continues
|
||||
const allEvents = await fetchGroupEvents(network, admin.group);
|
||||
for await (const _result of stayer.group.ingest(allEvents)) {
|
||||
// drain
|
||||
}
|
||||
|
||||
const msgAfterLeave = `after-leave-${randomUUID()}`;
|
||||
await stayer.group.sendChatMessage(msgAfterLeave);
|
||||
|
||||
const latestEvents = await fetchGroupEvents(network, admin.group);
|
||||
|
||||
const decrypted = [];
|
||||
for await (const result of admin.group.ingest(latestEvents)) {
|
||||
if (result.kind === "processed" && result.result.kind === "applicationMessage") {
|
||||
decrypted.push(deserializeApplicationData(result.result.message));
|
||||
}
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
decrypted.some((r) => r.content === msgAfterLeave),
|
||||
"group should continue functioning after member departure",
|
||||
);
|
||||
});
|
||||
|
||||
test("NIP-45 COUNT returns accurate event count", async () => {
|
||||
const account = PrivateKeyAccount.generateNew();
|
||||
const pubkey = await account.signer.getPublicKey();
|
||||
const tag = `count-test-${randomUUID()}`;
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const event = {
|
||||
kind: 1,
|
||||
pubkey,
|
||||
created_at: unixNow() + i,
|
||||
tags: [["t", tag]],
|
||||
content: `count-event-${i}`,
|
||||
};
|
||||
event.id = computeEventId(event);
|
||||
const signed = await account.signer.signEvent(event);
|
||||
const frame = await publishEvent(relayWsUrl, signed);
|
||||
assert.equal(frame[2], true, `publish of event ${i} failed: ${frame[3]}`);
|
||||
}
|
||||
|
||||
const result = await countEvents(relayWsUrl, { kinds: [1], "#t": [tag] });
|
||||
assert.equal(result.count, 3, "COUNT should report 3 events");
|
||||
});
|
||||
|
||||
test("NIP-9 event deletion removes event from relay", async () => {
|
||||
const account = PrivateKeyAccount.generateNew();
|
||||
const pubkey = await account.signer.getPublicKey();
|
||||
|
||||
const original = {
|
||||
kind: 1,
|
||||
pubkey,
|
||||
created_at: unixNow(),
|
||||
tags: [],
|
||||
content: `to-be-deleted-${randomUUID()}`,
|
||||
};
|
||||
original.id = computeEventId(original);
|
||||
const signedOriginal = await account.signer.signEvent(original);
|
||||
const publishFrame = await publishEvent(relayWsUrl, signedOriginal);
|
||||
assert.equal(publishFrame[2], true, `original publish failed: ${publishFrame[3]}`);
|
||||
|
||||
// Verify it exists
|
||||
const beforeDelete = await requestEvents(relayWsUrl, [{ ids: [signedOriginal.id] }]);
|
||||
assert.equal(beforeDelete.length, 1, "event should exist before deletion");
|
||||
|
||||
// Publish kind 5 deletion referencing the original
|
||||
const deletion = {
|
||||
kind: 5,
|
||||
pubkey,
|
||||
created_at: unixNow(),
|
||||
tags: [["e", signedOriginal.id]],
|
||||
content: "",
|
||||
};
|
||||
deletion.id = computeEventId(deletion);
|
||||
const signedDeletion = await account.signer.signEvent(deletion);
|
||||
const deleteFrame = await publishEvent(relayWsUrl, signedDeletion);
|
||||
assert.equal(deleteFrame[2], true, `deletion publish failed: ${deleteFrame[3]}`);
|
||||
|
||||
// Query again -- the original should be gone
|
||||
const afterDelete = await requestEvents(relayWsUrl, [{ ids: [signedOriginal.id] }]);
|
||||
assert.equal(afterDelete.length, 0, "event should be deleted after kind 5 request");
|
||||
});
|
||||
|
||||
@@ -41,11 +41,14 @@ defmodule Parrhesia.API.ACLTest do
|
||||
authenticated_pubkey = String.duplicate("b", 64)
|
||||
|
||||
assert {:error, :auth_required} =
|
||||
ACL.check(:sync_read, filter, context: %RequestContext{})
|
||||
ACL.check(:sync_read, filter, context: %RequestContext{caller: :websocket})
|
||||
|
||||
assert {:error, :sync_read_not_allowed} =
|
||||
ACL.check(:sync_read, filter,
|
||||
context: %RequestContext{authenticated_pubkeys: MapSet.new([authenticated_pubkey])}
|
||||
context: %RequestContext{
|
||||
caller: :websocket,
|
||||
authenticated_pubkeys: MapSet.new([authenticated_pubkey])
|
||||
}
|
||||
)
|
||||
|
||||
assert :ok =
|
||||
@@ -58,7 +61,10 @@ defmodule Parrhesia.API.ACLTest do
|
||||
|
||||
assert :ok =
|
||||
ACL.check(:sync_read, filter,
|
||||
context: %RequestContext{authenticated_pubkeys: MapSet.new([authenticated_pubkey])}
|
||||
context: %RequestContext{
|
||||
caller: :websocket,
|
||||
authenticated_pubkeys: MapSet.new([authenticated_pubkey])
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
@@ -75,7 +81,38 @@ defmodule Parrhesia.API.ACLTest do
|
||||
|
||||
assert {:error, :sync_read_not_allowed} =
|
||||
ACL.check(:sync_read, %{"kinds" => [5000]},
|
||||
context: %RequestContext{authenticated_pubkeys: MapSet.new([principal])}
|
||||
context: %RequestContext{
|
||||
caller: :websocket,
|
||||
authenticated_pubkeys: MapSet.new([principal])
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
test "check/3 bypasses protected sync ACL for local callers" do
|
||||
protected_filter = %{"kinds" => [5000], "#r" => ["tribes.accounts.user"]}
|
||||
|
||||
assert :ok =
|
||||
ACL.check(:sync_read, %{"ids" => [String.duplicate("d", 64)]},
|
||||
context: %RequestContext{caller: :local}
|
||||
)
|
||||
|
||||
assert :ok =
|
||||
ACL.check(
|
||||
:sync_write,
|
||||
%{
|
||||
"id" => String.duplicate("e", 64),
|
||||
"kind" => 5000,
|
||||
"tags" => [["r", "tribes.accounts.user"]]
|
||||
},
|
||||
context: %RequestContext{caller: :local}
|
||||
)
|
||||
|
||||
assert {:error, :sync_read_not_allowed} =
|
||||
ACL.check(:sync_read, protected_filter,
|
||||
context: %RequestContext{
|
||||
caller: :websocket,
|
||||
authenticated_pubkeys: MapSet.new([String.duplicate("f", 64)])
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -30,6 +30,45 @@ defmodule Parrhesia.API.EventsTest do
|
||||
assert second_result.message == "duplicate: event already stored"
|
||||
end
|
||||
|
||||
test "publish fanout includes sync-originated events when relay guard is disabled" do
|
||||
with_sync_relay_guard(false)
|
||||
join_multi_node_group!()
|
||||
|
||||
event = valid_event()
|
||||
event_id = event["id"]
|
||||
|
||||
assert {:ok, %{accepted: true}} =
|
||||
Events.publish(event, context: %RequestContext{caller: :sync})
|
||||
|
||||
assert_receive {:remote_fanout_event, %{"id" => ^event_id}}, 200
|
||||
end
|
||||
|
||||
test "publish fanout skips sync-originated events when relay guard is enabled" do
|
||||
with_sync_relay_guard(true)
|
||||
join_multi_node_group!()
|
||||
|
||||
event = valid_event()
|
||||
event_id = event["id"]
|
||||
|
||||
assert {:ok, %{accepted: true}} =
|
||||
Events.publish(event, context: %RequestContext{caller: :sync})
|
||||
|
||||
refute_receive {:remote_fanout_event, %{"id" => ^event_id}}, 200
|
||||
end
|
||||
|
||||
test "publish fanout still includes local-originated events when relay guard is enabled" do
|
||||
with_sync_relay_guard(true)
|
||||
join_multi_node_group!()
|
||||
|
||||
event = valid_event()
|
||||
event_id = event["id"]
|
||||
|
||||
assert {:ok, %{accepted: true}} =
|
||||
Events.publish(event, context: %RequestContext{caller: :local})
|
||||
|
||||
assert_receive {:remote_fanout_event, %{"id" => ^event_id}}, 200
|
||||
end
|
||||
|
||||
test "query and count preserve read semantics through the shared API" do
|
||||
now = System.system_time(:second)
|
||||
first = valid_event(%{"content" => "first", "created_at" => now})
|
||||
@@ -53,6 +92,67 @@ defmodule Parrhesia.API.EventsTest do
|
||||
)
|
||||
end
|
||||
|
||||
test "local query can read protected sync events without ACL grants or kind scoping" do
|
||||
previous_acl = Application.get_env(:parrhesia, :acl, [])
|
||||
|
||||
Application.put_env(
|
||||
:parrhesia,
|
||||
:acl,
|
||||
protected_filters: [%{"kinds" => [5000], "#r" => ["tribes.accounts.user"]}]
|
||||
)
|
||||
|
||||
on_exit(fn ->
|
||||
Application.put_env(:parrhesia, :acl, previous_acl)
|
||||
end)
|
||||
|
||||
protected_event =
|
||||
valid_event(%{
|
||||
"kind" => 5000,
|
||||
"tags" => [["r", "tribes.accounts.user"]],
|
||||
"content" => "protected"
|
||||
})
|
||||
|
||||
assert {:ok, %{accepted: true}} =
|
||||
Events.publish(protected_event, context: %RequestContext{caller: :local})
|
||||
|
||||
assert {:ok, [stored_event]} =
|
||||
Events.query([%{"ids" => [protected_event["id"]]}],
|
||||
context: %RequestContext{caller: :local}
|
||||
)
|
||||
|
||||
assert stored_event["id"] == protected_event["id"]
|
||||
end
|
||||
|
||||
defp with_sync_relay_guard(enabled?) when is_boolean(enabled?) do
|
||||
[{:config, previous}] = :ets.lookup(Parrhesia.Config, :config)
|
||||
|
||||
sync =
|
||||
previous
|
||||
|> Map.get(:sync, [])
|
||||
|> Keyword.put(:relay_guard, enabled?)
|
||||
|
||||
:ets.insert(Parrhesia.Config, {:config, Map.put(previous, :sync, sync)})
|
||||
|
||||
on_exit(fn ->
|
||||
:ets.insert(Parrhesia.Config, {:config, previous})
|
||||
end)
|
||||
end
|
||||
|
||||
defp join_multi_node_group! do
|
||||
case Process.whereis(:pg) do
|
||||
nil ->
|
||||
case :pg.start_link() do
|
||||
{:ok, _pid} -> :ok
|
||||
{:error, {:already_started, _pid}} -> :ok
|
||||
end
|
||||
|
||||
_pid ->
|
||||
:ok
|
||||
end
|
||||
|
||||
:ok = :pg.join(Parrhesia.Fanout.MultiNode, self())
|
||||
end
|
||||
|
||||
defp valid_event(overrides \\ %{}) do
|
||||
base_event = %{
|
||||
"pubkey" => String.duplicate("1", 64),
|
||||
|
||||
@@ -13,6 +13,7 @@ defmodule Parrhesia.Auth.ChallengesTest do
|
||||
assert Challenges.valid?(server, self(), challenge)
|
||||
|
||||
refute Challenges.valid?(server, self(), "wrong")
|
||||
refute Challenges.valid?(server, self(), challenge <> "x")
|
||||
|
||||
assert :ok = Challenges.clear(server, self())
|
||||
assert Challenges.current(server, self()) == nil
|
||||
|
||||
@@ -22,6 +22,7 @@ defmodule Parrhesia.ConfigTest do
|
||||
assert Parrhesia.Config.get([:limits, :max_filter_limit]) == 500
|
||||
assert Parrhesia.Config.get([:database, :separate_read_pool?]) == false
|
||||
assert Parrhesia.Config.get([:relay_url]) == "ws://localhost:4413/relay"
|
||||
assert Parrhesia.Config.get([:sync, :relay_guard]) == false
|
||||
assert Parrhesia.Config.get([:policies, :auth_required_for_writes]) == false
|
||||
assert Parrhesia.Config.get([:policies, :marmot_media_max_imeta_tags_per_event]) == 8
|
||||
assert Parrhesia.Config.get([:policies, :marmot_media_reject_mip04_v1]) == true
|
||||
|
||||
43
test/parrhesia/plug_test.exs
Normal file
43
test/parrhesia/plug_test.exs
Normal file
@@ -0,0 +1,43 @@
|
||||
defmodule Parrhesia.PlugTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
import Plug.Test
|
||||
|
||||
alias Parrhesia.Plug
|
||||
|
||||
test "init resolves configured listener id" do
|
||||
opts = Plug.init(listener: :public)
|
||||
|
||||
assert is_list(opts)
|
||||
assert Keyword.fetch!(opts, :listener).id == :public
|
||||
end
|
||||
|
||||
test "init accepts inline listener map" do
|
||||
opts =
|
||||
Plug.init(
|
||||
listener: %{
|
||||
id: :host_mount,
|
||||
features: %{nostr: %{enabled: true}}
|
||||
}
|
||||
)
|
||||
|
||||
assert Keyword.fetch!(opts, :listener).id == :host_mount
|
||||
end
|
||||
|
||||
test "init raises for unknown listener id" do
|
||||
assert_raise ArgumentError, ~r/listener :does_not_exist not found/, fn ->
|
||||
Plug.init(listener: :does_not_exist)
|
||||
end
|
||||
end
|
||||
|
||||
test "call serves health route" do
|
||||
opts = Plug.init(listener: :public)
|
||||
|
||||
response =
|
||||
conn(:get, "/health")
|
||||
|> Plug.call(opts)
|
||||
|
||||
assert response.status == 200
|
||||
assert response.resp_body == "ok"
|
||||
end
|
||||
end
|
||||
77
test/parrhesia/protocol/event_validator_property_test.exs
Normal file
77
test/parrhesia/protocol/event_validator_property_test.exs
Normal file
@@ -0,0 +1,77 @@
|
||||
defmodule Parrhesia.Protocol.EventValidatorPropertyTest do
|
||||
use ExUnit.Case, async: true
|
||||
use ExUnitProperties
|
||||
|
||||
alias Parrhesia.Protocol.EventValidator
|
||||
|
||||
property "compute_id always returns lowercase 64-char hex" do
|
||||
check all(event <- event_payload()) do
|
||||
id = EventValidator.compute_id(event)
|
||||
|
||||
assert byte_size(id) == 64
|
||||
assert {:ok, _decoded} = Base.decode16(id, case: :lower)
|
||||
end
|
||||
end
|
||||
|
||||
property "compute_id depends only on the canonical NIP-01 tuple fields" do
|
||||
check all(
|
||||
event <- event_payload(),
|
||||
replacement_id <- hex64(),
|
||||
replacement_sig <- hex128(),
|
||||
extra_value <- StreamData.string(:alphanumeric)
|
||||
) do
|
||||
original = EventValidator.compute_id(event)
|
||||
|
||||
mutated_event =
|
||||
event
|
||||
|> Map.put("id", replacement_id)
|
||||
|> Map.put("sig", replacement_sig)
|
||||
|> Map.put("extra_field", extra_value)
|
||||
|
||||
assert EventValidator.compute_id(mutated_event) == original
|
||||
end
|
||||
end
|
||||
|
||||
defp event_payload do
|
||||
gen all(
|
||||
pubkey <- hex64(),
|
||||
created_at <- StreamData.non_negative_integer(),
|
||||
kind <- StreamData.integer(0..65_535),
|
||||
tags <- tags(),
|
||||
content <- StreamData.string(:printable, max_length: 256),
|
||||
sig <- hex128(),
|
||||
id <- hex64()
|
||||
) do
|
||||
%{
|
||||
"id" => id,
|
||||
"pubkey" => pubkey,
|
||||
"created_at" => created_at,
|
||||
"kind" => kind,
|
||||
"tags" => tags,
|
||||
"content" => content,
|
||||
"sig" => sig
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
defp tags do
|
||||
StreamData.list_of(tag(), max_length: 8)
|
||||
end
|
||||
|
||||
defp tag do
|
||||
StreamData.list_of(StreamData.string(:printable, min_length: 1, max_length: 32),
|
||||
min_length: 1,
|
||||
max_length: 4
|
||||
)
|
||||
end
|
||||
|
||||
defp hex64 do
|
||||
StreamData.binary(length: 32)
|
||||
|> StreamData.map(&Base.encode16(&1, case: :lower))
|
||||
end
|
||||
|
||||
defp hex128 do
|
||||
StreamData.binary(length: 64)
|
||||
|> StreamData.map(&Base.encode16(&1, case: :lower))
|
||||
end
|
||||
end
|
||||
@@ -10,22 +10,83 @@ defmodule Parrhesia.Protocol.FilterPropertyTest do
|
||||
candidate_authors <- list_of(hex64(), min_length: 1, max_length: 5),
|
||||
created_at <- StreamData.non_negative_integer()
|
||||
) do
|
||||
event = %{
|
||||
"pubkey" => author,
|
||||
"kind" => 1,
|
||||
"created_at" => created_at,
|
||||
"tags" => [],
|
||||
"content" => ""
|
||||
}
|
||||
|
||||
event = base_event(author, created_at)
|
||||
filter = %{"authors" => candidate_authors}
|
||||
|
||||
assert Filter.matches_filter?(event, filter) == author in candidate_authors
|
||||
end
|
||||
end
|
||||
|
||||
property "since and until filters follow timestamp boundaries" do
|
||||
check all(
|
||||
author <- hex64(),
|
||||
created_at <- StreamData.non_negative_integer(),
|
||||
since <- StreamData.non_negative_integer(),
|
||||
until <- StreamData.non_negative_integer()
|
||||
) do
|
||||
event = base_event(author, created_at)
|
||||
|
||||
assert Filter.matches_filter?(event, %{"since" => since}) == created_at >= since
|
||||
assert Filter.matches_filter?(event, %{"until" => until}) == created_at <= until
|
||||
end
|
||||
end
|
||||
|
||||
property "tag filters match when any configured value is present on the event" do
|
||||
check all(
|
||||
author <- hex64(),
|
||||
created_at <- StreamData.non_negative_integer(),
|
||||
tag_value <- short_string(),
|
||||
extra_values <- list_of(short_string(), min_length: 1, max_length: 5)
|
||||
) do
|
||||
event =
|
||||
base_event(author, created_at)
|
||||
|> Map.put("tags", [["e", tag_value]])
|
||||
|
||||
matching_filter = %{"#e" => Enum.uniq([tag_value | extra_values])}
|
||||
non_matching_filter = %{"#e" => Enum.map(extra_values, &("nomatch:" <> &1))}
|
||||
|
||||
assert Filter.matches_filter?(event, matching_filter)
|
||||
refute Filter.matches_filter?(event, non_matching_filter)
|
||||
end
|
||||
end
|
||||
|
||||
property "invalid tag filters are rejected during matching" do
|
||||
check all(
|
||||
author <- hex64(),
|
||||
created_at <- StreamData.non_negative_integer(),
|
||||
invalid_value <- invalid_tag_filter_value()
|
||||
) do
|
||||
event = base_event(author, created_at)
|
||||
|
||||
refute Filter.matches_filter?(event, %{"#e" => [invalid_value]})
|
||||
end
|
||||
end
|
||||
|
||||
defp base_event(author, created_at) do
|
||||
%{
|
||||
"pubkey" => author,
|
||||
"kind" => 1,
|
||||
"created_at" => created_at,
|
||||
"tags" => [],
|
||||
"content" => ""
|
||||
}
|
||||
end
|
||||
|
||||
defp hex64 do
|
||||
StreamData.binary(length: 32)
|
||||
|> StreamData.map(&Base.encode16(&1, case: :lower))
|
||||
end
|
||||
|
||||
defp short_string do
|
||||
StreamData.string(:alphanumeric, min_length: 1, max_length: 16)
|
||||
end
|
||||
|
||||
defp invalid_tag_filter_value do
|
||||
StreamData.one_of([
|
||||
StreamData.integer(),
|
||||
StreamData.boolean(),
|
||||
StreamData.map_of(StreamData.string(:alphanumeric), StreamData.integer(), max_length: 2),
|
||||
StreamData.list_of(StreamData.integer(), max_length: 2)
|
||||
])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,6 +6,12 @@ defmodule Parrhesia.Storage.Adapters.Memory.AdapterTest do
|
||||
alias Parrhesia.Storage.Adapters.Memory.Events
|
||||
alias Parrhesia.Storage.Adapters.Memory.Groups
|
||||
alias Parrhesia.Storage.Adapters.Memory.Moderation
|
||||
alias Parrhesia.Storage.Adapters.Memory.Store
|
||||
|
||||
setup do
|
||||
start_supervised!(Store)
|
||||
:ok
|
||||
end
|
||||
|
||||
test "memory adapter supports basic behavior contract operations" do
|
||||
event_id = String.duplicate("a", 64)
|
||||
|
||||
@@ -978,6 +978,41 @@ defmodule Parrhesia.Web.ConnectionTest do
|
||||
assert JSON.decode!(notice_payload) == ["NOTICE", message]
|
||||
end
|
||||
|
||||
test "websocket keepalive ping waits for matching pong" do
|
||||
state =
|
||||
connection_state(websocket_ping_interval_seconds: 30, websocket_pong_timeout_seconds: 5)
|
||||
|
||||
assert {:push, {:ping, payload}, ping_state} =
|
||||
Connection.handle_info(:websocket_keepalive_ping, state)
|
||||
|
||||
assert is_binary(payload)
|
||||
assert ping_state.websocket_awaiting_pong_payload == payload
|
||||
assert is_reference(ping_state.websocket_keepalive_timeout_timer_ref)
|
||||
|
||||
assert {:ok, acknowledged_state} =
|
||||
Connection.handle_control({payload, [opcode: :pong]}, ping_state)
|
||||
|
||||
assert acknowledged_state.websocket_awaiting_pong_payload == nil
|
||||
assert acknowledged_state.websocket_keepalive_timeout_timer_ref == nil
|
||||
end
|
||||
|
||||
test "websocket keepalive timeout closes the connection" do
|
||||
state =
|
||||
connection_state(websocket_ping_interval_seconds: 30, websocket_pong_timeout_seconds: 5)
|
||||
|
||||
assert {:push, {:ping, payload}, ping_state} =
|
||||
Connection.handle_info(:websocket_keepalive_ping, state)
|
||||
|
||||
assert {:stop, :normal, {1001, "keepalive timeout"}, _timeout_state} =
|
||||
Connection.handle_info({:websocket_keepalive_timeout, payload}, ping_state)
|
||||
end
|
||||
|
||||
test "websocket keepalive can be disabled" do
|
||||
state = connection_state(websocket_ping_interval_seconds: 0)
|
||||
|
||||
assert {:ok, ^state} = Connection.handle_info(:websocket_keepalive_ping, state)
|
||||
end
|
||||
|
||||
defp subscribed_connection_state(opts) do
|
||||
state = connection_state(opts)
|
||||
req_payload = JSON.encode!(["REQ", "sub-1", %{"kinds" => [1]}])
|
||||
@@ -1004,6 +1039,8 @@ defmodule Parrhesia.Web.ConnectionTest do
|
||||
|> Keyword.put_new(:subscription_index, nil)
|
||||
|> Keyword.put_new(:trap_exit?, false)
|
||||
|> Keyword.put_new(:track_population?, false)
|
||||
|> Keyword.put_new(:websocket_ping_interval_seconds, 0)
|
||||
|> Keyword.put_new(:websocket_pong_timeout_seconds, 10)
|
||||
|
||||
{:ok, state} = Connection.init(opts)
|
||||
state
|
||||
|
||||
Reference in New Issue
Block a user