fleetmesh-node 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fleetmesh_node-0.2.0.dist-info/METADATA +57 -0
- fleetmesh_node-0.2.0.dist-info/RECORD +338 -0
- fleetmesh_node-0.2.0.dist-info/WHEEL +5 -0
- fleetmesh_node-0.2.0.dist-info/entry_points.txt +2 -0
- fleetmesh_node-0.2.0.dist-info/licenses/LICENSE +121 -0
- fleetmesh_node-0.2.0.dist-info/top_level.txt +2 -0
- node/RELEASE +1 -0
- node/__init__.py +35 -0
- node/app/__init__.py +49 -0
- node/app/commands.py +265 -0
- node/app/config.py +131 -0
- node/app/control.py +670 -0
- node/app/core.py +867 -0
- node/app/plugins.py +76 -0
- node/app/router.py +83 -0
- node/app/socket.py +431 -0
- node/app/systemd.py +36 -0
- node/app/views.py +204 -0
- node/channels/__init__.py +378 -0
- node/channels/bus.py +331 -0
- node/channels/journal.py +46 -0
- node/commands/__init__.py +140 -0
- node/commands/adapter.py +210 -0
- node/commands/base.py +278 -0
- node/commands/cluster_cmds.py +183 -0
- node/commands/compute_cmds.py +401 -0
- node/commands/core.py +474 -0
- node/commands/gossip_cmds.py +106 -0
- node/commands/nsite_cmds.py +141 -0
- node/commands/peering.py +402 -0
- node/commands/plugin_cmds.py +135 -0
- node/commands/sidecar_cmds.py +120 -0
- node/commands/sync.py +388 -0
- node/commands/transport_cmds.py +451 -0
- node/commands/video_cmds.py +99 -0
- node/compartment/__init__.py +124 -0
- node/compartment/base.py +868 -0
- node/compartment/command.py +458 -0
- node/compartment/context.py +163 -0
- node/compartment/every.py +108 -0
- node/compartment/handler.py +131 -0
- node/compartment/lifecycle.py +460 -0
- node/compartment/lint.py +388 -0
- node/compartment/need.py +158 -0
- node/compartment/problem.py +521 -0
- node/compartment/refusals.py +120 -0
- node/compartment/registry.py +790 -0
- node/compartment/schema.py +290 -0
- node/compartment/sidecar.py +499 -0
- node/compartment/stores.py +662 -0
- node/compartment/wire.py +225 -0
- node/compartment/worker.py +61 -0
- node/config/__init__.py +43 -0
- node/config/base.py +116 -0
- node/config/change.py +441 -0
- node/config/changestore.py +201 -0
- node/config/compartments.py +1144 -0
- node/config/node.py +318 -0
- node/config/parameter.py +563 -0
- node/config/protocols.py +274 -0
- node/config/records.py +171 -0
- node/examples/__init__.py +1 -0
- node/examples/demo.py +226 -0
- node/examples/follower.py +46 -0
- node/interfaces.py +235 -0
- node/keystore/__init__.py +27 -0
- node/keystore/core.py +103 -0
- node/keystore/enclave.py +688 -0
- node/meshnode.py +111 -0
- node/metrics/__init__.py +396 -0
- node/metrics/adapters.py +102 -0
- node/metrics/face.py +110 -0
- node/planes/__init__.py +21 -0
- node/planes/identity/__init__.py +20 -0
- node/planes/identity/announce/__init__.py +9 -0
- node/planes/identity/announce/compartment.py +447 -0
- node/planes/identity/announce/types.py +26 -0
- node/planes/identity/discovery/__init__.py +9 -0
- node/planes/identity/discovery/compartment.py +522 -0
- node/planes/identity/discovery/types.py +13 -0
- node/planes/identity/linked/__init__.py +1 -0
- node/planes/identity/linked/compartment.py +270 -0
- node/planes/identity/linked/types.py +25 -0
- node/planes/identity/presence/__init__.py +7 -0
- node/planes/identity/presence/compartment.py +409 -0
- node/planes/identity/presence/types.py +14 -0
- node/planes/operations/__init__.py +13 -0
- node/planes/operations/scheduler/__init__.py +9 -0
- node/planes/operations/scheduler/compartment.py +353 -0
- node/planes/operations/scheduler/types.py +21 -0
- node/planes/operations/updates/__init__.py +17 -0
- node/planes/operations/updates/core.py +310 -0
- node/planes/operations/updates/store.py +79 -0
- node/planes/operations/updates/types.py +20 -0
- node/planes/peering/__init__.py +28 -0
- node/planes/peering/budget/__init__.py +43 -0
- node/planes/peering/budget/chain.py +43 -0
- node/planes/peering/budget/core.py +537 -0
- node/planes/peering/budget/evaluation.py +80 -0
- node/planes/peering/budget/meter.py +100 -0
- node/planes/peering/budget/receipts.py +439 -0
- node/planes/peering/budget/shape.py +101 -0
- node/planes/peering/budget/tick.py +110 -0
- node/planes/peering/budget/types.py +67 -0
- node/planes/peering/budget/windows.py +346 -0
- node/planes/peering/capacity/__init__.py +7 -0
- node/planes/peering/capacity/compartment.py +342 -0
- node/planes/peering/capacity/types.py +24 -0
- node/planes/peering/peering/__init__.py +25 -0
- node/planes/peering/peering/core.py +464 -0
- node/planes/peering/peering/envelope.py +142 -0
- node/planes/peering/peering/handshake.py +198 -0
- node/planes/peering/peering/shape.py +80 -0
- node/planes/peering/peering/types.py +39 -0
- node/planes/peering/reporter/__init__.py +42 -0
- node/planes/peering/reporter/constants.py +29 -0
- node/planes/peering/reporter/core.py +299 -0
- node/planes/peering/reporter/finding.py +74 -0
- node/planes/peering/reporter/findings.py +171 -0
- node/planes/peering/reporter/publish.py +285 -0
- node/planes/peering/reporter/reading.py +193 -0
- node/planes/peering/reporter/shape.py +86 -0
- node/planes/peering/reporter/types.py +50 -0
- node/planes/peering/strain/__init__.py +9 -0
- node/planes/peering/strain/compartment.py +204 -0
- node/planes/peering/strain/types.py +21 -0
- node/planes/peering/trust/__init__.py +9 -0
- node/planes/peering/trust/compartment.py +239 -0
- node/planes/peering/trust/types.py +30 -0
- node/planes/services/__init__.py +33 -0
- node/planes/services/compute/__init__.py +160 -0
- node/planes/services/compute/common.py +134 -0
- node/planes/services/compute/compartment.py +681 -0
- node/planes/services/compute/executors/__init__.py +121 -0
- node/planes/services/compute/executors/agent.py +840 -0
- node/planes/services/compute/executors/bacalhau.py +605 -0
- node/planes/services/compute/executors/base.py +127 -0
- node/planes/services/compute/executors/harness.py +439 -0
- node/planes/services/compute/executors/mcp.py +454 -0
- node/planes/services/compute/executors/media.py +898 -0
- node/planes/services/compute/executors/nondet.py +283 -0
- node/planes/services/compute/executors/providers.py +499 -0
- node/planes/services/compute/executors/registry.py +140 -0
- node/planes/services/compute/executors/wasm.py +244 -0
- node/planes/services/compute/executors/zk.py +604 -0
- node/planes/services/compute/jobs.py +227 -0
- node/planes/services/compute/load.py +180 -0
- node/planes/services/compute/market.py +433 -0
- node/planes/services/compute/sandbox.py +282 -0
- node/planes/services/compute/types.py +84 -0
- node/planes/services/compute_wire/__init__.py +58 -0
- node/planes/services/compute_wire/compartment.py +256 -0
- node/planes/services/compute_wire/messages.py +320 -0
- node/planes/services/compute_wire/sealing.py +147 -0
- node/planes/services/compute_wire/session.py +572 -0
- node/planes/services/compute_wire/types.py +49 -0
- node/planes/services/compute_wire/verify.py +331 -0
- node/planes/services/dvm/__init__.py +24 -0
- node/planes/services/dvm/gateway.py +526 -0
- node/planes/services/dvm/protocol.py +35 -0
- node/planes/services/dvm/translate.py +15 -0
- node/planes/services/dvm/types.py +25 -0
- node/planes/services/gateway/__init__.py +40 -0
- node/planes/services/gateway/settle.py +279 -0
- node/planes/services/gateway/translate.py +644 -0
- node/planes/services/gossip/__init__.py +22 -0
- node/planes/services/gossip/core.py +929 -0
- node/planes/services/gossip/envelope.py +154 -0
- node/planes/services/gossip/mesh.py +127 -0
- node/planes/services/gossip/types.py +50 -0
- node/planes/services/nsite/__init__.py +7 -0
- node/planes/services/nsite/client.py +62 -0
- node/planes/services/nsite/compartment.py +752 -0
- node/planes/services/nsite/types.py +44 -0
- node/planes/services/paidfetch/__init__.py +7 -0
- node/planes/services/paidfetch/compartment.py +862 -0
- node/planes/services/paidfetch/types.py +99 -0
- node/planes/services/settle/__init__.py +27 -0
- node/planes/services/settle/compartment.py +384 -0
- node/planes/services/settle/engine.py +456 -0
- node/planes/services/settle/gateways.py +541 -0
- node/planes/services/settle/payment.py +512 -0
- node/planes/services/settle/store.py +74 -0
- node/planes/services/settle/types.py +58 -0
- node/planes/services/sidecars/__init__.py +8 -0
- node/planes/services/sidecars/compartment.py +461 -0
- node/planes/services/sidecars/manifest.py +304 -0
- node/planes/services/sidecars/manifests/10-grasp.json +37 -0
- node/planes/services/sidecars/manifests/20-ncontainer.json +31 -0
- node/planes/services/sidecars/manifests/30-magpie.json +28 -0
- node/planes/services/sidecars/types.py +43 -0
- node/planes/services/video/__init__.py +106 -0
- node/planes/services/video/barter.py +407 -0
- node/planes/services/video/cli.py +158 -0
- node/planes/services/video/compartment.py +932 -0
- node/planes/services/video/ingest.py +524 -0
- node/planes/services/video/ledger.py +705 -0
- node/planes/services/video/packager.py +847 -0
- node/planes/services/video/player.py +456 -0
- node/planes/services/video/timed_segments.py +124 -0
- node/planes/services/video/transit.py +478 -0
- node/planes/store/__init__.py +24 -0
- node/planes/store/blob_replication/__init__.py +7 -0
- node/planes/store/blob_replication/compartment.py +496 -0
- node/planes/store/blob_replication/types.py +35 -0
- node/planes/store/blob_wire/__init__.py +48 -0
- node/planes/store/blob_wire/client.py +57 -0
- node/planes/store/blob_wire/core.py +359 -0
- node/planes/store/blob_wire/intake.py +74 -0
- node/planes/store/blob_wire/messages.py +85 -0
- node/planes/store/projected/__init__.py +7 -0
- node/planes/store/projected/compartment.py +258 -0
- node/planes/store/projected/types.py +29 -0
- node/planes/store/retention/__init__.py +7 -0
- node/planes/store/retention/compartment.py +214 -0
- node/planes/store/retention/types.py +12 -0
- node/planes/store/sync/__init__.py +35 -0
- node/planes/store/sync/core.py +311 -0
- node/planes/store/sync/envelope.py +106 -0
- node/planes/store/sync/handover.py +206 -0
- node/planes/store/sync/initiator.py +128 -0
- node/planes/store/sync/responder.py +114 -0
- node/planes/store/sync/rules.py +179 -0
- node/planes/store/sync/shape.py +80 -0
- node/planes/store/sync/types.py +51 -0
- node/planes/transport/__init__.py +23 -0
- node/planes/transport/bridge/__init__.py +13 -0
- node/planes/transport/bridge/compartment.py +785 -0
- node/planes/transport/bridge/ratelimit.py +44 -0
- node/planes/transport/bridge/types.py +43 -0
- node/planes/transport/lan/__init__.py +9 -0
- node/planes/transport/lan/compartment.py +446 -0
- node/planes/transport/lan/types.py +25 -0
- node/planes/transport/link/__init__.py +7 -0
- node/planes/transport/link/compartment.py +257 -0
- node/planes/transport/link/types.py +27 -0
- node/planes/transport/mediation/__init__.py +45 -0
- node/planes/transport/mediation/compartment.py +238 -0
- node/planes/transport/mediation/leaf.py +107 -0
- node/planes/transport/mediation/messages.py +302 -0
- node/planes/transport/mediation/service.py +233 -0
- node/planes/transport/mediation/store.py +37 -0
- node/planes/transport/mediation/types.py +20 -0
- node/planes/transport/p2p/__init__.py +7 -0
- node/planes/transport/p2p/compartment.py +349 -0
- node/planes/transport/p2p/types.py +22 -0
- node/protocols/__init__.py +277 -0
- node/protocols/activitypub/__init__.py +56 -0
- node/protocols/activitypub/actor.py +230 -0
- node/protocols/activitypub/federation.py +490 -0
- node/protocols/activitypub/media.py +145 -0
- node/protocols/activitypub/models.py +286 -0
- node/protocols/activitypub/package.py +271 -0
- node/protocols/activitypub/store.py +564 -0
- node/protocols/arcade/__init__.py +26 -0
- node/protocols/atproto/__init__.py +13 -0
- node/protocols/atproto/firehose.py +126 -0
- node/protocols/atproto/metering.py +165 -0
- node/protocols/atproto/package.py +155 -0
- node/protocols/atproto/repo.py +250 -0
- node/protocols/atproto/xrpc.py +285 -0
- node/protocols/base.py +109 -0
- node/protocols/circles/__init__.py +29 -0
- node/protocols/dm/__init__.py +50 -0
- node/protocols/grasp/__init__.py +22 -0
- node/protocols/isotime.py +15 -0
- node/protocols/nostr/__init__.py +51 -0
- node/protocols/nostr/blobstore.py +383 -0
- node/protocols/nostr/blossom.py +627 -0
- node/protocols/nostr/client.py +377 -0
- node/protocols/nostr/descriptor.py +211 -0
- node/protocols/nostr/eventstore.py +304 -0
- node/protocols/nostr/package.py +182 -0
- node/protocols/nostr/peers.py +109 -0
- node/protocols/nostr/relayserver.py +656 -0
- node/protocols/p2panda/__init__.py +22 -0
- node/protocols/p2panda/identity.py +19 -0
- node/protocols/p2panda/package.py +236 -0
- node/protocols/p2panda/rung.py +152 -0
- node/protocols/p2panda/store.py +273 -0
- node/protocols/rhio/__init__.py +33 -0
- node/protocols/rhio/package.py +275 -0
- node/protocols/rhio/rung.py +136 -0
- node/protocols/rhio/store.py +1423 -0
- node/protocols/ssb/muxrpc.py +256 -0
- node/protocols/ssb/package.py +267 -0
- node/protocols/textile/__init__.py +111 -0
- node/protocols/textile/transducers.py +259 -0
- node/release.py +119 -0
- node/storage/__init__.py +88 -0
- node/storage/accounts.py +305 -0
- node/storage/adapter.py +161 -0
- node/storage/base.py +385 -0
- node/storage/dialect.py +209 -0
- node/storage/grants.py +317 -0
- node/storage/locator.py +150 -0
- node/storage/postgres.py +294 -0
- node/storage/projected.py +953 -0
- node/storage/sharding.py +448 -0
- node/storage/standing.py +568 -0
- node/testnet/__init__.py +9 -0
- node/testnet/cluster.py +731 -0
- node/testnet/fips_daemon.py +230 -0
- node/transport/__init__.py +94 -0
- node/transport/const.py +110 -0
- node/transport/didcomm_inbound.py +251 -0
- node/transport/dnstxt.py +169 -0
- node/transport/fips_rung/__init__.py +67 -0
- node/transport/fips_rung/core.py +666 -0
- node/transport/fips_rung/listener.py +96 -0
- node/transport/fips_rung/types.py +12 -0
- node/transport/host.py +334 -0
- node/transport/iroh_rung/__init__.py +29 -0
- node/transport/iroh_rung/core.py +477 -0
- node/transport/iroh_rung/quic.py +942 -0
- node/transport/iroh_rung/types.py +22 -0
- node/transport/ladder.py +145 -0
- node/transport/lineproto.py +49 -0
- node/transport/mdns.py +303 -0
- node/transport/netfetch.py +283 -0
- node/transport/relay_rung/__init__.py +23 -0
- node/transport/relay_rung/cooldown.py +87 -0
- node/transport/relay_rung/core.py +138 -0
- node/transport/relay_rung/inbound.py +161 -0
- node/transport/relay_rung/ledger.py +104 -0
- node/transport/relay_rung/listen.py +121 -0
- node/transport/relay_rung/outbound.py +181 -0
- node/transport/relay_rung/shape.py +71 -0
- node/transport/relay_rung/types.py +58 -0
- node/transport/relay_rung/wire.py +63 -0
- node/transport/router.py +761 -0
- node/transport/rung.py +276 -0
- node/transport/ssb_rung.py +166 -0
- node/transport/ws.py +502 -0
- sidecar/fleetmesh_announce.py +338 -0
- sidecar/fleetmesh_p2p.py +373 -0
- sidecar/fleetmesh_rhio.py +352 -0
- sidecar/fleetmesh_ssb.py +603 -0
|
@@ -0,0 +1,929 @@
|
|
|
1
|
+
"""GossipMesh: the gossip/1.0 compartment.
|
|
2
|
+
|
|
3
|
+
A true epidemic fanout for group topics, independent of any single relay.
|
|
4
|
+
Membership in a topic is explicit: an admin-signed kind 30804 (addressable,
|
|
5
|
+
republish-supersedes) naming every member pubkey, not ambient trust. A node
|
|
6
|
+
only grafts into, or accepts a graft for, a topic it holds a current
|
|
7
|
+
membership event naming its own pubkey for - and only toward a peer it
|
|
8
|
+
already shares a peering grant with, so this invents no new authorization
|
|
9
|
+
and rides the budget/grant model everyone else already accounts against.
|
|
10
|
+
|
|
11
|
+
The payload gossiped is the NIP-59 seal+wrap this node would otherwise have
|
|
12
|
+
sent over the 1:1 transport-ladder path - one sealed envelope per recipient,
|
|
13
|
+
exactly as DMs ship today - so gossip never sees plaintext: it floods
|
|
14
|
+
already-encrypted bytes keyed by the wrap's own event id. 1:1 DMs are
|
|
15
|
+
untouched; this is purely an additional fanout substrate for group topics of
|
|
16
|
+
3+ members, which is where a single relay going down costs everyone their
|
|
17
|
+
only path.
|
|
18
|
+
|
|
19
|
+
Loop prevention is strict: a seen-id store (sqlite, TTL + size bounded) keyed
|
|
20
|
+
(topic, event_id) is checked before flooding, and an id is never re-sent back
|
|
21
|
+
toward the peer it arrived from. graft/prune build and prune a bounded mesh
|
|
22
|
+
per topic, degree DEFAULT_MESH_DEGREE (gossipsub's D), maintained by tick().
|
|
23
|
+
|
|
24
|
+
ihave/iwant is the lazy half (gossipsub's "gossip" proper): every tick, each
|
|
25
|
+
topic's recent event ids are offered to a few members *outside* this node's
|
|
26
|
+
mesh, and a member that is missing one asks for it by id and is sent the
|
|
27
|
+
sealed envelope directly. That is what carries a publish across a mesh
|
|
28
|
+
that is partitioned - a member whose grafts all failed, a topic larger than
|
|
29
|
+
D where two cliques formed, a node that was down for the flood - without
|
|
30
|
+
any relay in the path. Envelopes to answer an iwant with come from a short
|
|
31
|
+
in-memory message cache; the durable seen store stays the only thing loop
|
|
32
|
+
prevention relies on.
|
|
33
|
+
"""
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import threading
|
|
37
|
+
import time
|
|
38
|
+
from dataclasses import dataclass, field
|
|
39
|
+
from typing import Any, Callable, Mapping, Optional, Union
|
|
40
|
+
|
|
41
|
+
from node import channels as ch_mod
|
|
42
|
+
from node.storage import grants as grants_mod
|
|
43
|
+
from node import metrics as mx_mod
|
|
44
|
+
from node.protocols.nostr import client as relay_mod
|
|
45
|
+
from fleetmesh_core.crypto import giftwrap
|
|
46
|
+
from fleetmesh_core.wire.interfaces import NostrEvent
|
|
47
|
+
from node.config.compartments import GossipConfig
|
|
48
|
+
from node.config.parameter import Parameter
|
|
49
|
+
from node.compartment import Command, Every, Handler, Need, Outbound, WireProtocol, Worker
|
|
50
|
+
from node.compartment import stores as st
|
|
51
|
+
from .envelope import (DEFAULT_MESH_DEGREE, GossipError, MEMBERSHIP_KIND, PIURI,
|
|
52
|
+
make_graft, make_ihave, make_iwant, make_prune, make_publish)
|
|
53
|
+
from .mesh import RosterStore, SeenStore
|
|
54
|
+
|
|
55
|
+
REGRAFT_INTERVAL_SECONDS: int = 300
|
|
56
|
+
SEEN_SWEEP_INTERVAL_SECONDS: int = 3600
|
|
57
|
+
# Lazy gossip (gossipsub's mcache / D_lazy / history window). Envelopes are
|
|
58
|
+
# kept just long enough to answer an iwant for an id offered in an ihave; ids
|
|
59
|
+
# are offered to a few non-mesh members per tick, never to the mesh itself,
|
|
60
|
+
# which already had the full envelope flooded to it.
|
|
61
|
+
MCACHE_SECONDS: int = 120
|
|
62
|
+
MCACHE_MAX_PER_TOPIC: int = 512
|
|
63
|
+
GOSSIP_LAZY_DEGREE: int = 6
|
|
64
|
+
IHAVE_MAX_IDS: int = 256
|
|
65
|
+
# The message cache across every topic. MCACHE_MAX_PER_TOPIC still caps one
|
|
66
|
+
# topic; this caps the sum, so a node carrying many busy topics holds a
|
|
67
|
+
# bounded number of envelopes. It is a cache for lazy gossip and nothing
|
|
68
|
+
# depends on a hit: an id that fell out is heard from another member.
|
|
69
|
+
MCACHE_MAX: int = MCACHE_MAX_PER_TOPIC * 16
|
|
70
|
+
# How many of each kind of gossip event the compartment keeps to look at. The
|
|
71
|
+
# totals are counted separately (GossipTotals), so this bounds the memory and
|
|
72
|
+
# not the numbers an operator reads.
|
|
73
|
+
RECENT: int = 256
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(slots=True)
|
|
78
|
+
class Roster:
|
|
79
|
+
"""A kind 30804 membership event this node holds for a topic, and the
|
|
80
|
+
member set read from its tags. The event is kept as it arrived - it is
|
|
81
|
+
what graft names, what `topics` reports and what RosterStore persists."""
|
|
82
|
+
event: Mapping[str, Any]
|
|
83
|
+
members: set[str] = field(default_factory=set)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
from .types import (
|
|
87
|
+
GossipTotals,
|
|
88
|
+
TopicView,
|
|
89
|
+
TopicCreationResult,
|
|
90
|
+
OriginateResult,
|
|
91
|
+
GossipStateReport,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _recent(key: str, what: str) -> st.Store:
|
|
96
|
+
return st.Store(key=key, description='%s, newest last' % what, kind=st.RING, shape=tuple, bound=RECENT)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class GossipMesh(WireProtocol):
|
|
100
|
+
"""The group-topic gossip/1.0 compartment: graft/prune mesh + flood publish."""
|
|
101
|
+
|
|
102
|
+
name: str = 'gossip'
|
|
103
|
+
piuris: tuple[str, ...] = (PIURI,)
|
|
104
|
+
optional: bool = True
|
|
105
|
+
enabled_by: Optional[str] = 'gossip'
|
|
106
|
+
# Gossip reads the grant store and the relay list, both context fields;
|
|
107
|
+
# it never reaches the peering or budget compartments.
|
|
108
|
+
requires: tuple[str, ...] = ()
|
|
109
|
+
reads: tuple[str, ...] = ('gossip',)
|
|
110
|
+
config_cls: Any = GossipConfig
|
|
111
|
+
HANDLERS = (
|
|
112
|
+
Handler('graft', run='wire_graft', doc='receive gossip mesh graft'),
|
|
113
|
+
Handler('prune', run='wire_prune', doc='receive gossip mesh prune'),
|
|
114
|
+
Handler('publish', run='wire_publish', doc='receive epidemic flood publish'),
|
|
115
|
+
Handler('ihave', run='wire_ihave', doc='receive lazy gossip ihave offer'),
|
|
116
|
+
Handler('iwant', run='wire_iwant', doc='receive lazy gossip iwant request'),
|
|
117
|
+
)
|
|
118
|
+
OUTBOUND = (
|
|
119
|
+
Outbound('graft', reply=('graft', 'prune'), doc='request to join a topic mesh'),
|
|
120
|
+
Outbound('prune', doc='leave a topic mesh or prune a peer'),
|
|
121
|
+
Outbound('publish', doc='epidemic flood of sealed message to topic mesh'),
|
|
122
|
+
Outbound('ihave', reply='iwant', doc='lazy gossip offer of recently seen message ids'),
|
|
123
|
+
Outbound('iwant', reply='publish', doc='request message payloads for offered ids'),
|
|
124
|
+
)
|
|
125
|
+
COMMANDS = (
|
|
126
|
+
Command(
|
|
127
|
+
key='show',
|
|
128
|
+
name='Gossip Status',
|
|
129
|
+
description='topics this node gossips, mesh size per topic, and seen-store size',
|
|
130
|
+
tags=('gossip', 'status'),
|
|
131
|
+
permission='gossip/show',
|
|
132
|
+
run='state',
|
|
133
|
+
reply='{topics, mesh, seen}',
|
|
134
|
+
),
|
|
135
|
+
Command(
|
|
136
|
+
key='topics',
|
|
137
|
+
name='Gossip Topics',
|
|
138
|
+
description='every roster held: admin, members, mesh peers, expiry',
|
|
139
|
+
tags=('gossip', 'topics'),
|
|
140
|
+
permission='gossip/topics',
|
|
141
|
+
run='topics_cmd',
|
|
142
|
+
reply='[{topic, admin, members, mesh, expires, roster_id}]',
|
|
143
|
+
),
|
|
144
|
+
Command(
|
|
145
|
+
key='create',
|
|
146
|
+
name='Create Topic',
|
|
147
|
+
description='sign a kind 30804 roster for <node_pk>:<name>, admit it, publish it to the relays',
|
|
148
|
+
tags=('gossip', 'create'),
|
|
149
|
+
parameters=(
|
|
150
|
+
Parameter(key='name', name='name', description='topic name', type=str, default='', required=True),
|
|
151
|
+
Parameter(key='members', name='members', description='initial member pubkeys', type=list, default=list, required=False),
|
|
152
|
+
Parameter(key='ttl_seconds', name='ttl_seconds', description='roster time-to-live in seconds', type=int, default=30 * 86400, required=False),
|
|
153
|
+
Parameter(key='no_relay', name='no_relay', description='do not publish to relays', type=bool, default=False, required=False),
|
|
154
|
+
),
|
|
155
|
+
permission='gossip/create',
|
|
156
|
+
run='create_cmd',
|
|
157
|
+
reply='{topic, event, relays}',
|
|
158
|
+
),
|
|
159
|
+
Command(
|
|
160
|
+
key='admit',
|
|
161
|
+
name='Admit Topic',
|
|
162
|
+
description='admit a kind 30804 roster another admin signed that names this node',
|
|
163
|
+
tags=('gossip', 'admit'),
|
|
164
|
+
parameters=(
|
|
165
|
+
Parameter(key='event', name='event', description='kind 30804 roster event', type=dict, default=dict, required=True),
|
|
166
|
+
),
|
|
167
|
+
permission='gossip/admit',
|
|
168
|
+
run='admit_cmd',
|
|
169
|
+
reply='{topic}',
|
|
170
|
+
),
|
|
171
|
+
Command(
|
|
172
|
+
key='leave',
|
|
173
|
+
name='Leave Topic',
|
|
174
|
+
description='drop a roster and prune every mesh peer on it',
|
|
175
|
+
tags=('gossip', 'leave'),
|
|
176
|
+
parameters=(
|
|
177
|
+
Parameter(key='topic', name='topic', description='topic to leave', type=str, default='', required=True),
|
|
178
|
+
),
|
|
179
|
+
permission='gossip/leave',
|
|
180
|
+
run='leave_cmd',
|
|
181
|
+
reply='{topic, pruned}',
|
|
182
|
+
),
|
|
183
|
+
Command(
|
|
184
|
+
key='publish',
|
|
185
|
+
name='Publish Message',
|
|
186
|
+
description='flood a sealed NIP-59 wrap into a topic mesh',
|
|
187
|
+
tags=('gossip', 'publish'),
|
|
188
|
+
parameters=(
|
|
189
|
+
Parameter(key='topic', name='topic', description='target topic id', type=str, default='', required=True),
|
|
190
|
+
Parameter(key='envelope', name='envelope', description='sealed NIP-59 wrap event', type=dict, default=dict, required=True),
|
|
191
|
+
),
|
|
192
|
+
permission='gossip/publish',
|
|
193
|
+
run='publish_cmd',
|
|
194
|
+
reply='{topic, event_id, sent_to, mesh}',
|
|
195
|
+
),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
# What it keeps. Each of the six history lists below used to be a plain
|
|
199
|
+
# list appended once per gossip message and never trimmed - memory that
|
|
200
|
+
# grew with whatever peers sent - while nothing in the node read more
|
|
201
|
+
# than its length. Now: a ring of the recent ones, and the length kept
|
|
202
|
+
# as a counter in `totals`, so `state` reports the same numbers.
|
|
203
|
+
STORES = (
|
|
204
|
+
# REFUSE: dropping a roster would silently leave a topic. create() and
|
|
205
|
+
# admit() refuse at the configured max_topics first, with a reason.
|
|
206
|
+
st.Store(key='members', description='the roster held for each topic', kind=st.TABLE,
|
|
207
|
+
shape=Roster, bound=GossipConfig.TOPIC_CEILING, evict=st.REFUSE, keyed_by='topic'),
|
|
208
|
+
# Derived from members and rebuilt by tick's regraft, so OLDEST is a
|
|
209
|
+
# safe second line: a topic dropped here is regrafted.
|
|
210
|
+
st.Store(key='peers', description="the pubkeys in this node's mesh, per topic", kind=st.TABLE,
|
|
211
|
+
shape=set, bound=GossipConfig.TOPIC_CEILING, keyed_by='topic'),
|
|
212
|
+
st.Store(key='grafted_at', description='when each mesh peer was grafted, per topic', kind=st.TABLE,
|
|
213
|
+
shape=int, bound=GossipConfig.TOPIC_CEILING * GossipConfig.MESH_DEGREE_CEILING,
|
|
214
|
+
keyed_by='topic and peer'),
|
|
215
|
+
st.Store(key='mcache', description='sealed envelopes kept to answer an iwant, with when they were cached',
|
|
216
|
+
kind=st.TABLE, shape=tuple, bound=MCACHE_MAX, keyed_by='topic and event'),
|
|
217
|
+
st.Store(key='totals', description='gossip events this node took part in, since it started',
|
|
218
|
+
kind=st.COUNTERS, shape=GossipTotals),
|
|
219
|
+
_recent('published', 'events this node originated, as (topic, event id)'),
|
|
220
|
+
_recent('delivered', 'events delivered from a mesh peer, as (topic, event id, from)'),
|
|
221
|
+
_recent('offered', 'ihave offers sent, as (topic, peer, ids)'),
|
|
222
|
+
_recent('wanted', 'iwant requests sent, as (topic, peer, ids)'),
|
|
223
|
+
_recent('served', 'iwant answers sent, as (topic, event id, to)'),
|
|
224
|
+
_recent('auto_admitted', 'rosters admitted from a relay, as (topic, roster id)'),
|
|
225
|
+
)
|
|
226
|
+
CHANNELS = (
|
|
227
|
+
ch_mod.Channel('event_published', 'A gossip event was originated and flooded to the topic mesh',
|
|
228
|
+
level=ch_mod.INFO, say='published gossip event {event_id} to topic {topic}',
|
|
229
|
+
tags=('gossip', 'publish')),
|
|
230
|
+
ch_mod.Channel('event_delivered', 'A gossip event was delivered from a mesh peer',
|
|
231
|
+
level=ch_mod.INFO, say='delivered gossip event {event_id} on topic {topic} from {peer}',
|
|
232
|
+
tags=('gossip', 'deliver')),
|
|
233
|
+
ch_mod.Channel('graft_established', "A peer was grafted into this node's topic mesh",
|
|
234
|
+
level=ch_mod.INFO, say='grafted peer {peer} on topic {topic}',
|
|
235
|
+
tags=('gossip', 'mesh')),
|
|
236
|
+
ch_mod.Channel('prune_executed', "A peer was pruned from this node's topic mesh",
|
|
237
|
+
level=ch_mod.NOTE, say='pruned peer {peer} on topic {topic} (reason: {reason})',
|
|
238
|
+
tags=('gossip', 'mesh')),
|
|
239
|
+
)
|
|
240
|
+
METRICS = (
|
|
241
|
+
mx_mod.Metric(name='gossip_events_published', description='Events originated and flooded to topic mesh.',
|
|
242
|
+
kind=mx_mod.COUNTER, counts='event_published', tags=('gossip', 'publish')),
|
|
243
|
+
mx_mod.Metric(name='gossip_events_delivered', description='Events received from mesh peers and delivered.',
|
|
244
|
+
kind=mx_mod.COUNTER, counts='event_delivered', tags=('gossip', 'deliver')),
|
|
245
|
+
mx_mod.Metric(name='gossip_peers_grafted', description='Peers added to local gossip mesh.',
|
|
246
|
+
kind=mx_mod.COUNTER, counts='graft_established', tags=('gossip', 'mesh')),
|
|
247
|
+
mx_mod.Metric(name='gossip_peers_pruned', description='Peers removed from local gossip mesh.',
|
|
248
|
+
kind=mx_mod.COUNTER, counts='prune_executed', tags=('gossip', 'mesh')),
|
|
249
|
+
)
|
|
250
|
+
NEEDS = (
|
|
251
|
+
Need('keystore', 'ks'),
|
|
252
|
+
Need('transport', 'transport'),
|
|
253
|
+
Need('grant_store', 'stores.grants'),
|
|
254
|
+
Need('cfg', block=GossipConfig),
|
|
255
|
+
Need('seen_path', path='gossip_seen.sqlite'),
|
|
256
|
+
Need('roster_path', path='gossip_rosters.sqlite'),
|
|
257
|
+
Need('relays', 'relays'),
|
|
258
|
+
)
|
|
259
|
+
EVERY = (Every(seconds=SEEN_SWEEP_INTERVAL_SECONDS, run='expire_seen'),)
|
|
260
|
+
WORKERS = (
|
|
261
|
+
Worker(run='_listen', name='gossip-rosters', when='relays'),
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
def __init__(
|
|
265
|
+
self,
|
|
266
|
+
keystore: Any,
|
|
267
|
+
transport: Any,
|
|
268
|
+
grant_store: grants_mod.GrantStore,
|
|
269
|
+
clock: Optional[Callable[[], int]] = None,
|
|
270
|
+
node: Optional[Any] = None,
|
|
271
|
+
mesh_degree: int = DEFAULT_MESH_DEGREE,
|
|
272
|
+
seen_ttl_seconds: int = 24 * 3600,
|
|
273
|
+
max_topics: int = 64,
|
|
274
|
+
seen_path: str = ':memory:',
|
|
275
|
+
roster_path: Optional[str] = None,
|
|
276
|
+
relays: Optional[list[str]] = None,
|
|
277
|
+
cfg: Optional[Union[GossipConfig, Mapping[str, Any]]] = None,
|
|
278
|
+
) -> None:
|
|
279
|
+
super().__init__(node, clock)
|
|
280
|
+
self.ks: Any = keystore
|
|
281
|
+
self.transport: Any = transport
|
|
282
|
+
self.grants: grants_mod.GrantStore = grant_store
|
|
283
|
+
if cfg is not None:
|
|
284
|
+
if isinstance(cfg, GossipConfig):
|
|
285
|
+
mesh_degree = cfg.mesh_degree
|
|
286
|
+
seen_ttl_seconds = cfg.seen_ttl_seconds
|
|
287
|
+
max_topics = cfg.max_topics
|
|
288
|
+
elif isinstance(cfg, Mapping):
|
|
289
|
+
mesh_degree = int(cfg.get('mesh_degree', mesh_degree))
|
|
290
|
+
seen_ttl_seconds = int(cfg.get('seen_ttl_seconds', seen_ttl_seconds))
|
|
291
|
+
max_topics = int(cfg.get('max_topics', max_topics))
|
|
292
|
+
self.mesh_degree: int = mesh_degree
|
|
293
|
+
self.seen_ttl_seconds: int = seen_ttl_seconds
|
|
294
|
+
self.max_topics: int = max_topics
|
|
295
|
+
self.seen: SeenStore = SeenStore(seen_path, ttl_seconds=seen_ttl_seconds)
|
|
296
|
+
self.rosters: RosterStore = RosterStore(roster_path or seen_path)
|
|
297
|
+
self.relays: list[str] = list(relays or []) # not a store: the configured relays
|
|
298
|
+
self._lock: threading.RLock = threading.RLock()
|
|
299
|
+
self._last_seen_sweep: int = 0
|
|
300
|
+
self._closed: bool = False
|
|
301
|
+
self._seen_count: int = 0
|
|
302
|
+
self._stop: threading.Event = getattr(self, '_stop_event', None) or threading.Event()
|
|
303
|
+
self._wake: threading.Event = threading.Event() # set by tick when the granted set moves
|
|
304
|
+
self._watching: set[str] = set() # not a store: a copy of grants.peers(), replaced whole
|
|
305
|
+
# The stores were built and bound by the base (see STORES).
|
|
306
|
+
self.members: st.Table[str, Roster]
|
|
307
|
+
self.peers: st.Table[str, set[str]]
|
|
308
|
+
self.grafted_at: st.Table[tuple[str, str], int]
|
|
309
|
+
self.mcache: st.Table[tuple[str, str], tuple[Mapping[str, Any], int]]
|
|
310
|
+
self.totals: st.Counters
|
|
311
|
+
self.published: st.Ring[tuple[str, str]]
|
|
312
|
+
self.delivered: st.Ring[tuple[str, str, str]]
|
|
313
|
+
self.offered: st.Ring[tuple[str, str, int]]
|
|
314
|
+
self.wanted: st.Ring[tuple[str, str, int]]
|
|
315
|
+
self.served: st.Ring[tuple[str, str, str]]
|
|
316
|
+
self.auto_admitted: st.Ring[tuple[str, str]]
|
|
317
|
+
self._reload_rosters()
|
|
318
|
+
|
|
319
|
+
def _record(self, ring: 'st.Ring[Any]', entry: tuple[Any, ...]) -> None:
|
|
320
|
+
"""One gossip event: kept in its ring, and counted in `totals`."""
|
|
321
|
+
ring.append(entry)
|
|
322
|
+
self.totals.incr(ring.store.key)
|
|
323
|
+
if ring.store.key == 'published' and len(entry) >= 2:
|
|
324
|
+
self.say('event_published', topic=entry[0], event_id=entry[1])
|
|
325
|
+
elif ring.store.key == 'delivered' and len(entry) >= 3:
|
|
326
|
+
self.say('event_delivered', topic=entry[0], event_id=entry[1], peer=entry[2])
|
|
327
|
+
|
|
328
|
+
# ---- learning rosters from the relays -------------------------------------
|
|
329
|
+
def _listen(self) -> None:
|
|
330
|
+
while not self._stop.is_set():
|
|
331
|
+
admins = sorted(self.grants.peers())
|
|
332
|
+
self._watching = set(admins) # not a store: a copy of grants.peers(), replaced whole
|
|
333
|
+
self._wake.clear()
|
|
334
|
+
if not admins:
|
|
335
|
+
self._wait_either(30)
|
|
336
|
+
continue
|
|
337
|
+
# A short socket timeout is how the loop notices _wake between frames.
|
|
338
|
+
r = relay_mod.Relay(self.relays[0], timeout=1.0)
|
|
339
|
+
try:
|
|
340
|
+
# NIP-01 indexes single-letter tags only, so `member` cannot be
|
|
341
|
+
# filtered at the relay; admit() drops rosters not naming us.
|
|
342
|
+
sid = r.connect().subscribe([{'kinds': [MEMBERSHIP_KIND], 'authors': admins}])
|
|
343
|
+
for ev in r.events(sid, until=None):
|
|
344
|
+
if self._stop.is_set() or self._wake.is_set():
|
|
345
|
+
break
|
|
346
|
+
self.on_roster(ev)
|
|
347
|
+
if self._wake.is_set():
|
|
348
|
+
continue # re-subscribe now with the new author set
|
|
349
|
+
except Exception as e:
|
|
350
|
+
self.refusals.refuse('subscription', 'could not be held open',
|
|
351
|
+
subject=self.relays[0], detail={'error': str(e)})
|
|
352
|
+
finally:
|
|
353
|
+
r.close()
|
|
354
|
+
self._wait_either(5)
|
|
355
|
+
|
|
356
|
+
def _wait_either(self, seconds: float) -> None:
|
|
357
|
+
"""Sleep until `seconds` pass, the node stops, or the granted set moves.
|
|
358
|
+
|
|
359
|
+
Waits on `_wake`, which stop() sets too, in slices of a second so a
|
|
360
|
+
stop that reaches only `_stop` is still seen within one. It polled
|
|
361
|
+
every 0.2 s on the wall clock (audit 2026-10-15, E8).
|
|
362
|
+
"""
|
|
363
|
+
deadline = time.monotonic() + seconds
|
|
364
|
+
while not self._stop.is_set() and not self._wake.is_set():
|
|
365
|
+
left = deadline - time.monotonic()
|
|
366
|
+
if left <= 0:
|
|
367
|
+
return
|
|
368
|
+
self._wake.wait(min(1.0, left))
|
|
369
|
+
|
|
370
|
+
def on_roster(self, event: Mapping[str, Any]) -> Optional[str]:
|
|
371
|
+
"""A roster read from a relay: admit it if it names this node and is
|
|
372
|
+
from a granted peer; the topic admitted, or None (refusal recorded)."""
|
|
373
|
+
if not isinstance(event, Mapping) or event.get('kind') != MEMBERSHIP_KIND:
|
|
374
|
+
return None
|
|
375
|
+
event = dict(event)
|
|
376
|
+
admin = event.get('pubkey', '')
|
|
377
|
+
if not self._has_grant(admin):
|
|
378
|
+
self.refusals.refuse('membership', 'roster from a peer with no grant', subject=admin)
|
|
379
|
+
return None
|
|
380
|
+
try:
|
|
381
|
+
topic = self.admit(event)
|
|
382
|
+
except GossipError:
|
|
383
|
+
return None # admit() recorded why, or it was not for us
|
|
384
|
+
self._record(self.auto_admitted, (topic, event.get('id', '')))
|
|
385
|
+
return topic
|
|
386
|
+
|
|
387
|
+
def _reload_rosters(self) -> None:
|
|
388
|
+
"""Rosters held before the last stop. One that has expired meanwhile is
|
|
389
|
+
dropped here rather than refused on every tick."""
|
|
390
|
+
for topic, event in self.rosters.all().items():
|
|
391
|
+
members = [t[1] for t in event.get('tags', []) if len(t) > 1 and t[0] == 'member']
|
|
392
|
+
if self.check_membership(topic, event, members) is None:
|
|
393
|
+
if not self.members.put(topic, Roster(event, set(members))):
|
|
394
|
+
# More rosters on disk than the topic ceiling - written
|
|
395
|
+
# by a build with a higher one. Kept on disk, not held.
|
|
396
|
+
self.refusals.refuse('membership', 'over the topic ceiling; not reloaded', subject=topic)
|
|
397
|
+
else:
|
|
398
|
+
self.rosters.delete(topic)
|
|
399
|
+
|
|
400
|
+
# ---- membership -----------------------------------------------------------
|
|
401
|
+
def admit_membership(self, topic: str, event: Mapping[str, Any], member_pubkeys: list[str]) -> None:
|
|
402
|
+
"""Record a current admin-signed kind 30804 membership event for a topic.
|
|
403
|
+
|
|
404
|
+
A republish of the same `d` (topic) supersedes the one held, matching
|
|
405
|
+
the addressable-event convention every other control event here uses.
|
|
406
|
+
The roster is read from the event's own `member` tags; the caller's
|
|
407
|
+
`member_pubkeys` is accepted only when it matches them, so a caller
|
|
408
|
+
cannot admit a roster the admin did not sign.
|
|
409
|
+
"""
|
|
410
|
+
why = self.check_membership(topic, event, member_pubkeys)
|
|
411
|
+
if why is not None:
|
|
412
|
+
self.refusals.refuse('membership', why, subject=topic)
|
|
413
|
+
raise GossipError(why)
|
|
414
|
+
with self._lock:
|
|
415
|
+
held = self.members.get(topic)
|
|
416
|
+
if held and int(held.event.get('created_at', 0)) > int(event.get('created_at', 0)):
|
|
417
|
+
self.refusals.refuse('membership', 'older than the roster held', subject=topic)
|
|
418
|
+
raise GossipError('older than the roster held')
|
|
419
|
+
self.members[topic] = Roster(event, set(member_pubkeys))
|
|
420
|
+
self.rosters.put(topic, event)
|
|
421
|
+
|
|
422
|
+
# ---- operator verbs: the control socket's side of the compartment ----------
|
|
423
|
+
def topic_id(self, name: str) -> str:
|
|
424
|
+
"""The coordinate of a topic this node administers: `<node_pk>:<name>`."""
|
|
425
|
+
if not name or ':' in name or len(name) > 64:
|
|
426
|
+
raise GossipError('a topic name is 1-64 characters with no colon')
|
|
427
|
+
return self.ks.node_pk + ':' + name
|
|
428
|
+
|
|
429
|
+
def make_roster(self, name: str, members: list[str], ttl_seconds: int = 30 * 86400) -> NostrEvent:
|
|
430
|
+
"""Sign a kind 30804 roster for a topic this node administers.
|
|
431
|
+
|
|
432
|
+
This node is always on its own roster: an admin that is not a member
|
|
433
|
+
would hold a topic it can never graft into. Signing is the slow pure-
|
|
434
|
+
Python curve, so this is an operator action, not something on a tick.
|
|
435
|
+
"""
|
|
436
|
+
from fleetmesh_core.storage import store
|
|
437
|
+
topic = self.topic_id(name)
|
|
438
|
+
roster = sorted({self.ks.node_pk, *members})
|
|
439
|
+
if len(roster) < 2:
|
|
440
|
+
raise GossipError('a roster names at least one member besides this node')
|
|
441
|
+
if ttl_seconds <= 0:
|
|
442
|
+
raise GossipError('ttl_seconds is positive')
|
|
443
|
+
now = self.clock()
|
|
444
|
+
tags = [['d', topic]] + [['member', m] for m in roster] + [['expiration', str(now + int(ttl_seconds))]]
|
|
445
|
+
return store.make_signed_event(self.ks.node_sk, now, MEMBERSHIP_KIND, tags, '')
|
|
446
|
+
|
|
447
|
+
def create_topic(self, name: str, members: list[str], ttl_seconds: int = 30 * 86400,
|
|
448
|
+
publish_to_relays: bool = True) -> TopicCreationResult:
|
|
449
|
+
"""Sign, admit, and (optionally) publish a roster. Returns {topic, event, relays}.
|
|
450
|
+
|
|
451
|
+
Publishing to the configured relays is how the other members learn the
|
|
452
|
+
roster before any of them has a gossip mesh to learn it over - the one
|
|
453
|
+
place a relay is in the path, and only as a bulletin board.
|
|
454
|
+
"""
|
|
455
|
+
event = self.make_roster(name, members, ttl_seconds)
|
|
456
|
+
topic = self.topic_id(name)
|
|
457
|
+
if topic not in self.members and len(self.members) >= self.max_topics:
|
|
458
|
+
raise GossipError('this node holds its maximum of %d topics' % self.max_topics)
|
|
459
|
+
self.admit_membership(topic, event, sorted(self.members_of(event)))
|
|
460
|
+
out: TopicCreationResult = {'topic': topic, 'event': event, 'relays': {}}
|
|
461
|
+
if publish_to_relays and self.relays:
|
|
462
|
+
out['relays'] = {u: list(r) for u, r in relay_mod.publish_all(self.relays, event, 15.0).items()}
|
|
463
|
+
return out
|
|
464
|
+
|
|
465
|
+
@staticmethod
|
|
466
|
+
def members_of(event: Mapping[str, Any]) -> set[str]:
|
|
467
|
+
return {t[1] for t in event.get('tags', []) if len(t) > 1 and t[0] == 'member'}
|
|
468
|
+
|
|
469
|
+
def admit(self, event: Mapping[str, Any]) -> str:
|
|
470
|
+
"""Admit a roster another admin signed (fetched from a relay, pasted by
|
|
471
|
+
the operator). The topic is the event's own `d`; returns it."""
|
|
472
|
+
d = [t[1] for t in event.get('tags', []) if len(t) > 1 and t[0] == 'd']
|
|
473
|
+
if len(d) != 1:
|
|
474
|
+
raise GossipError('roster has no single d tag')
|
|
475
|
+
if self.ks.node_pk not in self.members_of(event):
|
|
476
|
+
raise GossipError('this node is not named on that roster')
|
|
477
|
+
if d[0] not in self.members and len(self.members) >= self.max_topics:
|
|
478
|
+
raise GossipError('this node holds its maximum of %d topics' % self.max_topics)
|
|
479
|
+
self.admit_membership(d[0], event, sorted(self.members_of(event)))
|
|
480
|
+
return d[0]
|
|
481
|
+
|
|
482
|
+
def leave(self, topic: str) -> int:
|
|
483
|
+
"""Drop a roster and prune every mesh peer on it. Returns peers pruned."""
|
|
484
|
+
with self._lock:
|
|
485
|
+
if topic not in self.members:
|
|
486
|
+
raise GossipError('not a member of %s' % topic)
|
|
487
|
+
peers = list(self.peers.get(topic, set()))
|
|
488
|
+
for p in peers:
|
|
489
|
+
self.prune(topic, p, reason='left')
|
|
490
|
+
with self._lock:
|
|
491
|
+
self.members.pop(topic, None)
|
|
492
|
+
self.peers.pop(topic, None)
|
|
493
|
+
self.rosters.delete(topic)
|
|
494
|
+
return len(peers)
|
|
495
|
+
|
|
496
|
+
def originate(self, topic: str, envelope: Mapping[str, Any]) -> OriginateResult:
|
|
497
|
+
"""Flood an already-sealed NIP-59 wrap (kind 1059/13/21059) into a topic.
|
|
498
|
+
|
|
499
|
+
The compartment carries ciphertext only; a plaintext kind here is a
|
|
500
|
+
refusal, not a convenience, because the mesh is every member's peers.
|
|
501
|
+
"""
|
|
502
|
+
if not self._is_member(topic, self.ks.node_pk):
|
|
503
|
+
raise GossipError('not a member of %s' % topic)
|
|
504
|
+
if envelope.get('kind') not in (13, 1059, 21059):
|
|
505
|
+
raise GossipError('only sealed NIP-59 envelopes (kind 13, 1059, 21059) travel on gossip')
|
|
506
|
+
event_id = envelope.get('id')
|
|
507
|
+
if not isinstance(event_id, str) or len(event_id) != 64:
|
|
508
|
+
raise GossipError('envelope has no 64-hex id')
|
|
509
|
+
if not giftwrap.verify_event(envelope):
|
|
510
|
+
raise GossipError('envelope signature does not verify')
|
|
511
|
+
sent = self.publish(topic, event_id, envelope)
|
|
512
|
+
return {'topic': topic, 'event_id': event_id, 'sent_to': sent,
|
|
513
|
+
'mesh': len(self.peers.get(topic, set()))}
|
|
514
|
+
|
|
515
|
+
def check_membership(self, topic: str, event: Mapping[str, Any], member_pubkeys: list[str]) -> Optional[str]:
|
|
516
|
+
"""Why this event is not a current kind 30804 roster for `topic`, or None.
|
|
517
|
+
|
|
518
|
+
Kind, signature, `d` coordinate scoped to the signing admin, a roster
|
|
519
|
+
that is what the tags say, and a NIP-40 expiration still ahead - in
|
|
520
|
+
that order, cheapest refusal first.
|
|
521
|
+
"""
|
|
522
|
+
if event.get('kind') != MEMBERSHIP_KIND:
|
|
523
|
+
return 'not a kind %d' % MEMBERSHIP_KIND
|
|
524
|
+
tags = event.get('tags') or []
|
|
525
|
+
d = [t[1] for t in tags if len(t) > 1 and t[0] == 'd']
|
|
526
|
+
if d != [topic]:
|
|
527
|
+
return 'd tag does not name this topic'
|
|
528
|
+
admin = event.get('pubkey', '')
|
|
529
|
+
if not topic.startswith(admin + ':'):
|
|
530
|
+
return 'topic coordinate is not scoped to the signing admin'
|
|
531
|
+
members = {t[1] for t in tags if len(t) > 1 and t[0] == 'member'}
|
|
532
|
+
if not members or members != set(member_pubkeys):
|
|
533
|
+
return 'member tags do not match the roster offered'
|
|
534
|
+
exp = [t[1] for t in tags if len(t) > 1 and t[0] == 'expiration']
|
|
535
|
+
if len(exp) != 1 or not exp[0].isdigit():
|
|
536
|
+
return 'no single expiration tag'
|
|
537
|
+
if int(exp[0]) <= self.clock():
|
|
538
|
+
return 'roster has expired'
|
|
539
|
+
if not giftwrap.verify_event(event):
|
|
540
|
+
return 'signature does not verify'
|
|
541
|
+
return None
|
|
542
|
+
|
|
543
|
+
def _is_member(self, topic: str, pubkey: str) -> bool:
|
|
544
|
+
with self._lock:
|
|
545
|
+
row = self.members.get(topic)
|
|
546
|
+
return bool(row and pubkey in row.members)
|
|
547
|
+
|
|
548
|
+
def i_am_member(self, topic: str) -> bool:
|
|
549
|
+
return self._is_member(topic, self.ks.node_pk)
|
|
550
|
+
|
|
551
|
+
# ---- grant integration (no new authorization invented) ---------------------
|
|
552
|
+
def _has_grant(self, peer: str) -> bool:
|
|
553
|
+
"""Only graft a peer this node already shares a peering grant with.
|
|
554
|
+
|
|
555
|
+
Reuses the existing accounting relationship rather than a second
|
|
556
|
+
authorization system: a peer with no grant in either direction has no
|
|
557
|
+
accounted channel for this node to meter gossip traffic against.
|
|
558
|
+
"""
|
|
559
|
+
issued = self.grants.get(peer, grants_mod.ISSUED)
|
|
560
|
+
received = self.grants.get(peer, grants_mod.RECEIVED)
|
|
561
|
+
live = lambda r: bool(r) and r.get('status', 'live') == 'live'
|
|
562
|
+
return live(issued) or live(received)
|
|
563
|
+
|
|
564
|
+
# ---- graft / prune: mesh maintenance --------------------------------------
|
|
565
|
+
def handle_graft(self, peer: str, body: Mapping[str, Any]) -> tuple[Any, ...]:
|
|
566
|
+
topic = body.get('topic')
|
|
567
|
+
if not isinstance(topic, str) or not topic:
|
|
568
|
+
self.refusals.refuse('graft', 'no topic named', subject=peer)
|
|
569
|
+
return ('graft refused', peer, 'no topic named')
|
|
570
|
+
if not self._is_member(topic, self.ks.node_pk):
|
|
571
|
+
self.refusals.refuse('graft', 'no membership event for this topic', subject=peer, detail={'topic': topic})
|
|
572
|
+
return ('graft refused', peer, 'no membership event')
|
|
573
|
+
if not self._is_member(topic, peer):
|
|
574
|
+
self.refusals.refuse('graft', 'peer is not a named member', subject=peer, detail={'topic': topic})
|
|
575
|
+
return ('graft refused', peer, 'peer not a member')
|
|
576
|
+
if not self._has_grant(peer):
|
|
577
|
+
self.refusals.refuse('graft', 'no peering grant with this peer', subject=peer, detail={'topic': topic})
|
|
578
|
+
return ('graft refused', peer, 'no grant')
|
|
579
|
+
with self._lock:
|
|
580
|
+
current = self.peers.setdefault(topic, set())
|
|
581
|
+
if peer in current:
|
|
582
|
+
return ('graft already present', peer, topic)
|
|
583
|
+
if len(current) >= self.mesh_degree:
|
|
584
|
+
self.refusals.refuse('graft', 'mesh is already at target degree', subject=peer, detail={'topic': topic})
|
|
585
|
+
return ('graft refused', peer, 'mesh full')
|
|
586
|
+
current.add(peer)
|
|
587
|
+
self.grafted_at[(topic, peer)] = self.clock()
|
|
588
|
+
self.say('graft_established', topic=topic, peer=peer)
|
|
589
|
+
return ('grafted', peer, topic)
|
|
590
|
+
|
|
591
|
+
def handle_prune(self, peer: str, body: Mapping[str, Any]) -> tuple[Any, ...]:
|
|
592
|
+
topic = body.get('topic')
|
|
593
|
+
if not isinstance(topic, str):
|
|
594
|
+
return ('prune ignored', peer, 'no topic named')
|
|
595
|
+
with self._lock:
|
|
596
|
+
self.peers.get(topic, set()).discard(peer)
|
|
597
|
+
self.grafted_at.pop((topic, peer), None)
|
|
598
|
+
self.say('prune_executed', topic=topic, peer=peer, reason='remote_prune')
|
|
599
|
+
return ('pruned', peer, topic)
|
|
600
|
+
|
|
601
|
+
def graft(self, topic: str, peer: str) -> bool:
|
|
602
|
+
"""This node asks `peer` to add it to their mesh for `topic`."""
|
|
603
|
+
row = self.members.get(topic)
|
|
604
|
+
if row is None:
|
|
605
|
+
self.refusals.refuse('graft', 'no membership event for this topic', subject=peer, detail={'topic': topic})
|
|
606
|
+
return False
|
|
607
|
+
msg = make_graft(self.ks.did if hasattr(self.ks, 'did') else self.ks.node_pk, peer, topic,
|
|
608
|
+
row.event.get('id', ''))
|
|
609
|
+
ok = self._send(peer, msg)
|
|
610
|
+
if ok:
|
|
611
|
+
with self._lock:
|
|
612
|
+
self.peers.setdefault(topic, set()).add(peer)
|
|
613
|
+
self.grafted_at[(topic, peer)] = self.clock()
|
|
614
|
+
self.say('graft_established', topic=topic, peer=peer)
|
|
615
|
+
return ok
|
|
616
|
+
|
|
617
|
+
def prune(self, topic: str, peer: str, reason: str = 'degree') -> bool:
|
|
618
|
+
msg = make_prune(self.ks.did if hasattr(self.ks, 'did') else self.ks.node_pk, peer, topic, reason=reason)
|
|
619
|
+
with self._lock:
|
|
620
|
+
self.peers.get(topic, set()).discard(peer)
|
|
621
|
+
self.grafted_at.pop((topic, peer), None)
|
|
622
|
+
ok = self._send(peer, msg)
|
|
623
|
+
self.say('prune_executed', topic=topic, peer=peer, reason=reason)
|
|
624
|
+
return ok
|
|
625
|
+
|
|
626
|
+
# ---- publish: the flood ----------------------------------------------------
|
|
627
|
+
def handle_publish(self, peer: str, body: Mapping[str, Any]) -> tuple[Any, ...]:
|
|
628
|
+
topic = body.get('topic')
|
|
629
|
+
event_id = body.get('event_id')
|
|
630
|
+
envelope = body.get('envelope')
|
|
631
|
+
if not isinstance(topic, str) or not isinstance(event_id, str) or not isinstance(envelope, dict):
|
|
632
|
+
self.refusals.refuse('publish', 'malformed body', subject=peer)
|
|
633
|
+
return ('publish refused', peer, 'malformed body')
|
|
634
|
+
if not self._is_member(topic, self.ks.node_pk):
|
|
635
|
+
self.refusals.refuse('publish', 'no membership event for this topic', subject=peer, detail={'topic': topic})
|
|
636
|
+
return ('publish refused', peer, 'not a member')
|
|
637
|
+
if not self.seen.mark(topic, event_id, self.clock()):
|
|
638
|
+
return ('publish dropped', peer, 'already seen')
|
|
639
|
+
self._record(self.delivered, (topic, event_id, peer))
|
|
640
|
+
self._cache(topic, event_id, envelope)
|
|
641
|
+
self._flood(topic, event_id, envelope, exclude={peer})
|
|
642
|
+
return ('publish delivered', peer, event_id)
|
|
643
|
+
|
|
644
|
+
def publish(self, topic: str, event_id: str, envelope: Mapping[str, Any]) -> list[str]:
|
|
645
|
+
"""Originate a publish into this topic's mesh. Returns peers flooded to."""
|
|
646
|
+
if not self.seen.mark(topic, event_id, self.clock()):
|
|
647
|
+
return []
|
|
648
|
+
self._record(self.published, (topic, event_id))
|
|
649
|
+
self._cache(topic, event_id, envelope)
|
|
650
|
+
return self._flood(topic, event_id, envelope, exclude=set())
|
|
651
|
+
|
|
652
|
+
# ---- ihave / iwant: lazy gossip to members outside the mesh ----------------
|
|
653
|
+
def _cache(self, topic: str, event_id: str, envelope: Mapping[str, Any]) -> None:
|
|
654
|
+
with self._lock:
|
|
655
|
+
self.mcache[(topic, event_id)] = (envelope, self.clock())
|
|
656
|
+
mine = [k for k in self.mcache if k[0] == topic]
|
|
657
|
+
if len(mine) > MCACHE_MAX_PER_TOPIC:
|
|
658
|
+
mine.sort(key=lambda k: self.mcache[k][1])
|
|
659
|
+
for k in mine[:len(mine) - MCACHE_MAX_PER_TOPIC]:
|
|
660
|
+
del self.mcache[k]
|
|
661
|
+
|
|
662
|
+
def _sweep_mcache(self, now: int) -> int:
|
|
663
|
+
with self._lock:
|
|
664
|
+
old = [k for k, (_, at) in self.mcache.items() if now - at > MCACHE_SECONDS]
|
|
665
|
+
for k in old:
|
|
666
|
+
del self.mcache[k]
|
|
667
|
+
return len(old)
|
|
668
|
+
|
|
669
|
+
def _recent_ids(self, topic: str) -> list[str]:
|
|
670
|
+
with self._lock:
|
|
671
|
+
ids = [(at, k[1]) for k, (_, at) in self.mcache.items() if k[0] == topic]
|
|
672
|
+
ids.sort(reverse=True)
|
|
673
|
+
return [i for _, i in ids[:IHAVE_MAX_IDS]]
|
|
674
|
+
|
|
675
|
+
def _me(self) -> str:
|
|
676
|
+
return self.ks.did if hasattr(self.ks, 'did') else self.ks.node_pk
|
|
677
|
+
|
|
678
|
+
def emit_gossip(self, topic: str) -> list[tuple[Any, ...]]:
|
|
679
|
+
"""Offer this topic's recent ids to up to GOSSIP_LAZY_DEGREE members
|
|
680
|
+
that are *not* in this node's mesh (the mesh already got the flood).
|
|
681
|
+
Only members with a grant: an ihave is a promise to answer iwant."""
|
|
682
|
+
ids = self._recent_ids(topic)
|
|
683
|
+
if not ids:
|
|
684
|
+
return []
|
|
685
|
+
with self._lock:
|
|
686
|
+
mesh = self.peers.get(topic, set())
|
|
687
|
+
row = self.members.get(topic)
|
|
688
|
+
if row is None:
|
|
689
|
+
return []
|
|
690
|
+
outside = [p for p in row.members if p != self.ks.node_pk and p not in mesh]
|
|
691
|
+
did: list[tuple[Any, ...]] = []
|
|
692
|
+
for peer in outside:
|
|
693
|
+
if len(did) >= GOSSIP_LAZY_DEGREE:
|
|
694
|
+
break
|
|
695
|
+
if not self._has_grant(peer):
|
|
696
|
+
continue
|
|
697
|
+
if self._send(peer, make_ihave(self._me(), peer, topic, ids)):
|
|
698
|
+
self._record(self.offered, (topic, peer, len(ids)))
|
|
699
|
+
did.append(('ihave', topic, peer))
|
|
700
|
+
return did
|
|
701
|
+
|
|
702
|
+
def handle_ihave(self, peer: str, body: Mapping[str, Any]) -> tuple[Any, ...]:
|
|
703
|
+
topic = body.get('topic')
|
|
704
|
+
ids = body.get('ids')
|
|
705
|
+
if not isinstance(topic, str) or not isinstance(ids, list):
|
|
706
|
+
self.refusals.refuse('ihave', 'malformed body', subject=peer)
|
|
707
|
+
return ('ihave refused', peer, 'malformed body')
|
|
708
|
+
if not self._is_member(topic, self.ks.node_pk) or not self._is_member(topic, peer):
|
|
709
|
+
self.refusals.refuse('ihave', 'not both members of this topic', subject=peer, detail={'topic': topic})
|
|
710
|
+
return ('ihave refused', peer, 'not a member')
|
|
711
|
+
missing = [i for i in ids[:IHAVE_MAX_IDS] if isinstance(i, str) and not self.seen.seen(topic, i)]
|
|
712
|
+
if not missing:
|
|
713
|
+
return ('ihave nothing missing', peer, topic)
|
|
714
|
+
if self._send(peer, make_iwant(self._me(), peer, topic, missing)):
|
|
715
|
+
self._record(self.wanted, (topic, peer, len(missing)))
|
|
716
|
+
return ('iwant sent', peer, len(missing))
|
|
717
|
+
return ('iwant unsent', peer, len(missing))
|
|
718
|
+
|
|
719
|
+
def handle_iwant(self, peer: str, body: Mapping[str, Any]) -> tuple[Any, ...]:
|
|
720
|
+
topic = body.get('topic')
|
|
721
|
+
ids = body.get('ids')
|
|
722
|
+
if not isinstance(topic, str) or not isinstance(ids, list):
|
|
723
|
+
self.refusals.refuse('iwant', 'malformed body', subject=peer)
|
|
724
|
+
return ('iwant refused', peer, 'malformed body')
|
|
725
|
+
if not self._is_member(topic, self.ks.node_pk) or not self._is_member(topic, peer):
|
|
726
|
+
self.refusals.refuse('iwant', 'not both members of this topic', subject=peer, detail={'topic': topic})
|
|
727
|
+
return ('iwant refused', peer, 'not a member')
|
|
728
|
+
sent = 0
|
|
729
|
+
for event_id in ids[:IHAVE_MAX_IDS]:
|
|
730
|
+
if not isinstance(event_id, str):
|
|
731
|
+
continue
|
|
732
|
+
with self._lock:
|
|
733
|
+
hit = self.mcache.get((topic, event_id))
|
|
734
|
+
if hit is None:
|
|
735
|
+
continue # fell out of the cache: the asker will hear it from someone else
|
|
736
|
+
if self._send(peer, make_publish(self._me(), peer, topic, event_id, hit[0])):
|
|
737
|
+
self._record(self.served, (topic, event_id, peer))
|
|
738
|
+
sent += 1
|
|
739
|
+
return ('iwant served', peer, sent)
|
|
740
|
+
|
|
741
|
+
def _flood(self, topic: str, event_id: str, envelope: Mapping[str, Any], exclude: set[str]) -> list[str]:
|
|
742
|
+
"""Send to every mesh peer for `topic` except `exclude` - which always
|
|
743
|
+
contains the peer this event arrived from, so it is never re-flooded
|
|
744
|
+
back toward its source. This is the strict loop-prevention rule."""
|
|
745
|
+
with self._lock:
|
|
746
|
+
targets = list(self.peers.get(topic, set()) - exclude)
|
|
747
|
+
sent: list[str] = []
|
|
748
|
+
for peer in targets:
|
|
749
|
+
msg = make_publish(self.ks.did if hasattr(self.ks, 'did') else self.ks.node_pk, peer,
|
|
750
|
+
topic, event_id, envelope)
|
|
751
|
+
if self._send(peer, msg):
|
|
752
|
+
sent.append(peer)
|
|
753
|
+
return sent
|
|
754
|
+
|
|
755
|
+
# ---- transport (rides the existing transport ladder) -----------------------
|
|
756
|
+
def _send(self, peer: str, message: Mapping[str, Any]) -> bool:
|
|
757
|
+
# TransportRouter.send(message, to_pubkey) -> {rung: (ok, why)}; the
|
|
758
|
+
# ladder picks the rung. Sent means at least one rung took it.
|
|
759
|
+
try:
|
|
760
|
+
res = self.wire_client.send(peer, message)
|
|
761
|
+
if isinstance(res, dict) and res and not any(ok for ok, _ in res.values()):
|
|
762
|
+
self.refusals.refuse('transport', 'no rung accepted', subject=peer,
|
|
763
|
+
detail={'why': [w for _, w in res.values()][:2]})
|
|
764
|
+
return False
|
|
765
|
+
return True
|
|
766
|
+
except Exception as e:
|
|
767
|
+
self.refusals.refuse('transport', 'send failed', subject=peer, detail={'error': str(e)})
|
|
768
|
+
return False
|
|
769
|
+
|
|
770
|
+
# ---- dispatch ---------------------------------------------------------------
|
|
771
|
+
def wire_graft(self, inbound: Any, sender: str) -> tuple[Any, ...]:
|
|
772
|
+
msg = getattr(inbound, 'message', inbound)
|
|
773
|
+
return self.handle_graft(sender, (msg.get('body') or {}) if isinstance(msg, dict) else {})
|
|
774
|
+
|
|
775
|
+
def wire_prune(self, inbound: Any, sender: str) -> tuple[Any, ...]:
|
|
776
|
+
msg = getattr(inbound, 'message', inbound)
|
|
777
|
+
return self.handle_prune(sender, (msg.get('body') or {}) if isinstance(msg, dict) else {})
|
|
778
|
+
|
|
779
|
+
def wire_publish(self, inbound: Any, sender: str) -> tuple[Any, ...]:
|
|
780
|
+
msg = getattr(inbound, 'message', inbound)
|
|
781
|
+
return self.handle_publish(sender, (msg.get('body') or {}) if isinstance(msg, dict) else {})
|
|
782
|
+
|
|
783
|
+
def wire_ihave(self, inbound: Any, sender: str) -> tuple[Any, ...]:
|
|
784
|
+
msg = getattr(inbound, 'message', inbound)
|
|
785
|
+
return self.handle_ihave(sender, (msg.get('body') or {}) if isinstance(msg, dict) else {})
|
|
786
|
+
|
|
787
|
+
def wire_iwant(self, inbound: Any, sender: str) -> tuple[Any, ...]:
|
|
788
|
+
msg = getattr(inbound, 'message', inbound)
|
|
789
|
+
return self.handle_iwant(sender, (msg.get('body') or {}) if isinstance(msg, dict) else {})
|
|
790
|
+
|
|
791
|
+
# ---- the compartment contract -------------------------------------------------
|
|
792
|
+
def tick(self, now: int) -> list[tuple[Any, ...]]:
|
|
793
|
+
did: list[tuple[Any, ...]] = []
|
|
794
|
+
if self.relays and set(self.grants.peers()) != self._watching:
|
|
795
|
+
self._wake.set() # a new grant: the roster subscription needs its author
|
|
796
|
+
with self._lock:
|
|
797
|
+
topics = list(self.members.keys())
|
|
798
|
+
for topic in topics:
|
|
799
|
+
if not self.i_am_member(topic):
|
|
800
|
+
continue
|
|
801
|
+
with self._lock:
|
|
802
|
+
current = self.peers.get(topic, set())
|
|
803
|
+
degree = len(current)
|
|
804
|
+
candidates = [p for p in self.members[topic].members
|
|
805
|
+
if p != self.ks.node_pk and p not in current]
|
|
806
|
+
if degree < self.mesh_degree:
|
|
807
|
+
for peer in candidates:
|
|
808
|
+
if len(current) >= self.mesh_degree:
|
|
809
|
+
break
|
|
810
|
+
if not self._has_grant(peer):
|
|
811
|
+
continue
|
|
812
|
+
if self.graft(topic, peer):
|
|
813
|
+
did.append(('regrafted', topic, peer))
|
|
814
|
+
current = self.peers.get(topic, set())
|
|
815
|
+
# Prune a peer no longer named in a superseding membership event.
|
|
816
|
+
with self._lock:
|
|
817
|
+
stale = [p for p in self.peers.get(topic, set()) if not self._is_member(topic, p)]
|
|
818
|
+
for peer in stale:
|
|
819
|
+
self.prune(topic, peer, reason='no longer a member')
|
|
820
|
+
did.append(('pruned stale', topic, peer))
|
|
821
|
+
did.extend(self.emit_gossip(topic))
|
|
822
|
+
self._sweep_mcache(now)
|
|
823
|
+
did += self.run_every(now)
|
|
824
|
+
return did
|
|
825
|
+
|
|
826
|
+
_tick_runs_every = True
|
|
827
|
+
|
|
828
|
+
def expire_seen(self, now: Optional[int] = None) -> list[tuple[Any, ...]]:
|
|
829
|
+
now = now if now is not None else self.clock()
|
|
830
|
+
removed = self.seen.expire(now)
|
|
831
|
+
self._last_seen_sweep = now
|
|
832
|
+
if removed:
|
|
833
|
+
return [('seen store swept', removed, None)]
|
|
834
|
+
return []
|
|
835
|
+
|
|
836
|
+
def state(self) -> GossipStateReport:
|
|
837
|
+
with self._lock:
|
|
838
|
+
mesh = {t: len(p) for t, p in self.peers.items()}
|
|
839
|
+
topics = sorted(self.members.keys())
|
|
840
|
+
totals = self.totals.as_dict()
|
|
841
|
+
return {
|
|
842
|
+
'topics': topics,
|
|
843
|
+
'mesh': mesh,
|
|
844
|
+
# The store is closed after stop(); health() still reads state.
|
|
845
|
+
'seen': self._seen_count if getattr(self, '_closed', False) else self._count_seen(),
|
|
846
|
+
# Counted, not measured: each was len() of a list that grew
|
|
847
|
+
# forever. The rings beside them keep only the recent ones.
|
|
848
|
+
'published': totals['published'],
|
|
849
|
+
'auto_admitted': totals['auto_admitted'],
|
|
850
|
+
'delivered': totals['delivered'],
|
|
851
|
+
'offered': totals['offered'],
|
|
852
|
+
'wanted': totals['wanted'],
|
|
853
|
+
'served': totals['served'],
|
|
854
|
+
'mcache': len(self.mcache),
|
|
855
|
+
'refused': self.refusals.total(),
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
def trouble(self) -> list[str]:
|
|
859
|
+
out: list[str] = []
|
|
860
|
+
refused = self.refusals.within(self.TROUBLE_WINDOW, self.clock(), kind='graft')
|
|
861
|
+
if refused:
|
|
862
|
+
peers = sorted({(r.subject or '')[:16] for r in refused})
|
|
863
|
+
out.append('%d graft(s) refused in the last window from %s: no membership event, '
|
|
864
|
+
'not a named member, no grant, or the mesh is already at target degree'
|
|
865
|
+
% (len(refused), ', '.join(peers)))
|
|
866
|
+
with self._lock:
|
|
867
|
+
thin = [t for t, p in self.peers.items() if len(p) < max(1, self.mesh_degree // 2)]
|
|
868
|
+
if thin:
|
|
869
|
+
out.append('%d topic(s) below half target mesh degree: %s'
|
|
870
|
+
% (len(thin), ', '.join(sorted(thin)[:5])))
|
|
871
|
+
return out
|
|
872
|
+
|
|
873
|
+
def _count_seen(self) -> int:
|
|
874
|
+
self._seen_count = self.seen.count()
|
|
875
|
+
return self._seen_count
|
|
876
|
+
|
|
877
|
+
def topics_cmd(self) -> list[TopicView]:
|
|
878
|
+
with self._lock:
|
|
879
|
+
return [{'topic': t, 'admin': row.event.get('pubkey'),
|
|
880
|
+
'members': sorted(row.members),
|
|
881
|
+
'mesh': sorted(self.peers.get(t, set())),
|
|
882
|
+
'expires': int(next((x[1] for x in row.event.get('tags', [])
|
|
883
|
+
if x[0] == 'expiration'), 0)),
|
|
884
|
+
'roster_id': row.event.get('id')}
|
|
885
|
+
for t, row in sorted(self.members.items())]
|
|
886
|
+
|
|
887
|
+
def create_cmd(self, args: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
888
|
+
try:
|
|
889
|
+
return self.create_topic(str(args['name']), list(args.get('members') or []),
|
|
890
|
+
int(args.get('ttl_seconds') or 30 * 86400),
|
|
891
|
+
publish_to_relays=not args.get('no_relay'))
|
|
892
|
+
except GossipError as e:
|
|
893
|
+
from node.app import socket as control_mod
|
|
894
|
+
raise control_mod.ControlError(str(e))
|
|
895
|
+
|
|
896
|
+
def admit_cmd(self, args: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
897
|
+
try:
|
|
898
|
+
return {'topic': self.admit(dict(args['event']))}
|
|
899
|
+
except GossipError as e:
|
|
900
|
+
from node.app import socket as control_mod
|
|
901
|
+
raise control_mod.ControlError(str(e))
|
|
902
|
+
|
|
903
|
+
def leave_cmd(self, args: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
904
|
+
try:
|
|
905
|
+
return {'topic': args['topic'], 'pruned': self.leave(str(args['topic']))}
|
|
906
|
+
except GossipError as e:
|
|
907
|
+
from node.app import socket as control_mod
|
|
908
|
+
raise control_mod.ControlError(str(e))
|
|
909
|
+
|
|
910
|
+
def publish_cmd(self, args: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
911
|
+
try:
|
|
912
|
+
return self.originate(str(args['topic']), dict(args['envelope']))
|
|
913
|
+
except GossipError as e:
|
|
914
|
+
from node.app import socket as control_mod
|
|
915
|
+
raise control_mod.ControlError(str(e))
|
|
916
|
+
|
|
917
|
+
def stop(self) -> Any:
|
|
918
|
+
self._closed = True
|
|
919
|
+
self._stop.set()
|
|
920
|
+
self._wake.set() # ends _wait_either now, not at its next slice
|
|
921
|
+
for s in (self.seen, self.rosters):
|
|
922
|
+
try:
|
|
923
|
+
s.close()
|
|
924
|
+
except Exception:
|
|
925
|
+
pass
|
|
926
|
+
return super().stop()
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
__all__ = ['GossipMesh', 'PIURI', 'MEMBERSHIP_KIND', 'MCACHE_SECONDS', 'GOSSIP_LAZY_DEGREE']
|