Skip to content

feat(quantum): RelayPool — multi-relay fanout + failover for the artifact store#26

Merged
febuz merged 1 commit into
mainfrom
feat/relay-pool
Jul 6, 2026
Merged

feat(quantum): RelayPool — multi-relay fanout + failover for the artifact store#26
febuz merged 1 commit into
mainfrom
feat/relay-pool

Conversation

@febuz

@febuz febuz commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Removes the single-relay SPOF: KNITWEB_RELAY can now be a comma-separated list. Pushes fan out to all relays (best-effort, warns on partial outage); pulls fail over in order. Backward compatible; injectable transport, 11 offline tests. Relates to #275.

🤖 Generated with Claude Code

Summary by Sourcery

Introduce a RelayPool abstraction for multi-relay fanout and failover in the quantum artifact store and integrate it into the Store API.

New Features:

  • Add RelayPool to support configuring multiple Knitweb relay URLs with push fanout and pull failover semantics.
  • Expose RelayPool from the quantum package for external use.
  • Allow Store to be configured with multi-relay setups while preserving a backward-compatible primary relay accessor.

Tests:

  • Add offline tests for RelayPool normalization, push fanout, pull failover behavior, and Store integration with multi-relay configurations.

…fact store

A single relay was a single point of failure for P2P artifact sharing. RelayPool
lets KNITWEB_RELAY be a comma-separated list (or iterable): pushes fan out to
every relay best-effort (success if >=1 accepts, warns on partial outage), and
pulls fail over to each relay in order until one returns the artifact.

- relaypool.py — RelayPool{push,pull,primary} with an injectable HTTP transport
  (put_fn/get_fn), fully testable offline; normalizes str/list/None, strips
  trailing slashes, dedups.
- store.py — Store holds a RelayPool (from relay arg or KNITWEB_RELAY);
  `store.relay` kept as backward-compatible primary accessor.

Backward compatible: a single relay URL behaves exactly as before. Relates to #275.

Tests: tests/quantum/test_relaypool.py (11) — normalization, fanout, partial-outage
best-effort, pull failover, KeyError-when-none, Store integration incl. primary-down
failover. Full quantum suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Jul 4, 2026

Copy link
Copy Markdown

Reviewer's Guide

Introduce a RelayPool abstraction that supports multi-relay fanout for pushes and ordered failover for pulls, and integrate it into the Store while keeping the public API backward compatible and fully testable offline.

Sequence diagram for Store.get failover via RelayPool.pull

sequenceDiagram
    actor User
    participant Store
    participant RelayPool
    participant Relay1
    participant Relay2

    User->>Store: get(cid)
    Store->>Store: _path(cid).exists()
    alt [local missing]
        Store->>RelayPool: pull(collection, cid)
        alt [Relay1 has artifact]
            RelayPool->>Relay1: _get(url)
            Relay1-->>RelayPool: bytes
            RelayPool-->>Store: bytes
            Store->>Store: path.write_bytes(bytes)
        else [Relay1 fails, Relay2 has artifact]
            RelayPool->>Relay1: _get(url)
            Relay1-->>RelayPool: Exception
            RelayPool->>Relay2: _get(url)
            Relay2-->>RelayPool: bytes
            RelayPool-->>Store: bytes
            Store->>Store: path.write_bytes(bytes)
        else [no relay has artifact]
            RelayPool-->>Store: KeyError
            Store-->>User: KeyError
        end
    else [local exists]
        Store->>Store: load artifact
        Store-->>User: artifact
    end
Loading

File-Level Changes

Change Details Files
Introduce RelayPool abstraction to handle multi-relay push fanout and pull failover with injectable HTTP transport.
  • Add relaypool module implementing RelayPool with normalized relay configuration from strings or iterables
  • Implement best-effort push that fans out to all relays, counts successes, and warns on partial/full failures
  • Implement ordered pull that fails over across relays and raises KeyError if none have the artifact
  • Support injectable put/get functions so RelayPool can be tested without network access
  • Expose RelayPool in the quantum package public API
src/knitweb_lens/quantum/relaypool.py
src/knitweb_lens/quantum/__init__.py
Integrate RelayPool into Store to replace single-relay HTTP logic while preserving backward-compatible access to the primary relay.
  • Change Store constructor to build a RelayPool from the relay argument or KNITWEB_RELAY environment variable
  • Add a relay property that exposes the primary relay URL from the RelayPool for backward compatibility
  • Update put/get to use RelayPool.push and RelayPool.pull instead of inline urllib logic
  • Remove Store’s private _push_to_relay and _pull_from_relay helper methods
  • Adjust Store.repr to show the RelayPool’s relay list instead of a single relay
src/knitweb_lens/quantum/store.py
Add offline tests for RelayPool behavior and its integration with Store using a fake cloud backend.
  • Introduce FakeCloud helper to simulate multiple relays, outages, and stored artifacts
  • Test relay normalization, truthiness, length, and primary relay selection
  • Verify push fanout to all relays and best-effort behavior on partial outages with warnings
  • Verify pull failover across relays and KeyError on total miss
  • Test Store interaction with RelayPool for multi-relay config, backward-compatible relay property, push/write, pull/read, and failover when the primary relay is down
tests/quantum/test_relaypool.py

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • Consider restoring and expanding the type hint on Store.__init__(relay=...) to make it clear it accepts a string, comma-separated string, or iterable of URLs (e.g. str | Iterable[str] | None), which will help static analysis and readability.
  • In RelayPool.push and RelayPool.pull, you currently catch Exception broadly; if practical, narrowing this to the specific network/transport errors you expect would make failure modes clearer and avoid masking unexpected exceptions.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- Consider restoring and expanding the type hint on `Store.__init__(relay=...)` to make it clear it accepts a string, comma-separated string, or iterable of URLs (e.g. `str | Iterable[str] | None`), which will help static analysis and readability.
- In `RelayPool.push` and `RelayPool.pull`, you currently catch `Exception` broadly; if practical, narrowing this to the specific network/transport errors you expect would make failure modes clearer and avoid masking unexpected exceptions.

## Individual Comments

### Comment 1
<location path="src/knitweb_lens/quantum/store.py" line_range="81" />
<code_context>
     """

-    def __init__(self, root: str | Path | None = None, relay: str | None = None):
+    def __init__(self, root: str | Path | None = None, relay=None):
         self.root = Path(root) if root else _DEFAULT_DIR
         self.root.mkdir(parents=True, exist_ok=True)
</code_context>
<issue_to_address>
**suggestion:** Keep type information for `relay` consistent with supported inputs.

`relay` is now untyped, but RelayPool supports strings, comma‑separated strings, and iterables. Please restore a precise annotation (e.g. `relay: str | Iterable[str] | None = None` or a typed alias) so valid input shapes are clear and static analysis can catch mismatches.

Suggested implementation:

```python
from .relaypool import RelayPool
from collections.abc import Iterable

```

```python
    def __init__(self, root: str | Path | None = None, relay: str | Iterable[str] | None = None):

```
</issue_to_address>

### Comment 2
<location path="src/knitweb_lens/quantum/relaypool.py" line_range="109-110" />
<code_context>
+                return self._get(url)
+            except Exception as e:  # noqa: BLE001 - try the next relay
+                errors.append(f"{base}: {e}")
+        raise KeyError(f"{cid} not found on any relay ({len(self.relays)} tried): "
+                       + "; ".join(errors) if errors else f"{cid}: no relays configured")
+
+    def __repr__(self) -> str:
</code_context>
<issue_to_address>
**suggestion:** Clarify the `KeyError` message construction to avoid precedence surprises.

The combined use of `+` with a conditional expression makes it unclear which parts of the string are conditionally included, and is easy to misinterpret or break if modified. Please build the error message in a separate variable using an explicit `if errors: ... else: ...` before raising, so the logic is clearer and safer to maintain.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

"""

def __init__(self, root: str | Path | None = None, relay: str | None = None):
def __init__(self, root: str | Path | None = None, relay=None):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Keep type information for relay consistent with supported inputs.

relay is now untyped, but RelayPool supports strings, comma‑separated strings, and iterables. Please restore a precise annotation (e.g. relay: str | Iterable[str] | None = None or a typed alias) so valid input shapes are clear and static analysis can catch mismatches.

Suggested implementation:

from .relaypool import RelayPool
from collections.abc import Iterable
    def __init__(self, root: str | Path | None = None, relay: str | Iterable[str] | None = None):

Comment on lines +109 to +110
raise KeyError(f"{cid} not found on any relay ({len(self.relays)} tried): "
+ "; ".join(errors) if errors else f"{cid}: no relays configured")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Clarify the KeyError message construction to avoid precedence surprises.

The combined use of + with a conditional expression makes it unclear which parts of the string are conditionally included, and is easy to misinterpret or break if modified. Please build the error message in a separate variable using an explicit if errors: ... else: ... before raising, so the logic is clearer and safer to maintain.

@febuz febuz merged commit f30303d into main Jul 6, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant