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,100 @@
|
|
|
1
|
+
"""The constants of budget/1.0 and the metering primitives: what one message
|
|
2
|
+
costs and which side owes it. Separate because it holds no node state - every
|
|
3
|
+
other module in the package charges through these and the tests read them
|
|
4
|
+
directly.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any, Mapping
|
|
9
|
+
|
|
10
|
+
from fleetmesh_core.accounting import accounting, window
|
|
11
|
+
from fleetmesh_core.storage import negentropy, sync
|
|
12
|
+
|
|
13
|
+
# Lists that grew for as long as the node ran. Findings feed the reporter and
|
|
14
|
+
# the tests read them, so they stay lists, trimmed on the tick.
|
|
15
|
+
KEEP_FINDINGS: int = 1000
|
|
16
|
+
|
|
17
|
+
PIURI: str = 'https://fleetmesh.org/budget/1.0'
|
|
18
|
+
AMEND: str = 'https://fleetmesh.org/peering/1.0/amend'
|
|
19
|
+
SYNC: str = sync.PIURI
|
|
20
|
+
ZERO: str = accounting.ZERO
|
|
21
|
+
C: Mapping[str, Any] = window.C['grant']
|
|
22
|
+
TOLERANCE: float = C['divergence_tolerance']
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Meter:
|
|
26
|
+
def __init__(self) -> None:
|
|
27
|
+
self.c: dict[str, int] = dict.fromkeys(sync.COUNTERS, 0)
|
|
28
|
+
|
|
29
|
+
def add(self, name: str, n: int = 1) -> None:
|
|
30
|
+
self.c[name] = self.c.get(name, 0) + n
|
|
31
|
+
|
|
32
|
+
def snapshot(self) -> dict[str, int]:
|
|
33
|
+
return {k: v for k, v in self.c.items() if v}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _ranges(frame_hex: str) -> int:
|
|
37
|
+
try:
|
|
38
|
+
return len(negentropy.decode_message(bytes.fromhex(frame_hex)))
|
|
39
|
+
except Exception:
|
|
40
|
+
return 0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
METERED: tuple[str, ...] = ('id', 'type', 'thid', 'body', 'attachments')
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def metered_bytes(message: Mapping[str, Any]) -> int:
|
|
47
|
+
"""What both sides charge for one message: the parts the protocol wrote.
|
|
48
|
+
Addressing and created_time are added by the transport on the way out and
|
|
49
|
+
would make the sender's count differ from the receiver's."""
|
|
50
|
+
return sync.wire_bytes({k: v for k, v in message.items() if k in METERED})
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _observe(session_meter: Meter, deliverer_meter: Meter, message: Mapping[str, Any],
|
|
54
|
+
from_initiator: bool) -> None:
|
|
55
|
+
"""Charge one sync/1.0 message: the session to its initiator, the events
|
|
56
|
+
to whoever delivered them.
|
|
57
|
+
|
|
58
|
+
Two different things cross in a sync session and they are owed to
|
|
59
|
+
different sides. Opening it, asking, reconciling, and the bytes both ways
|
|
60
|
+
are the SESSION, and a session is paid for by whoever opened it, under
|
|
61
|
+
the grant the other side issued - sync_wire's docstring says so and the
|
|
62
|
+
reference Session binds each side to exactly that grant. Events are
|
|
63
|
+
different: an event is pushed into the receiver by whoever sent it,
|
|
64
|
+
whichever side opened the session, and `events_in` is the receiver's
|
|
65
|
+
limit on exactly that. The receiver prices duplicates against the
|
|
66
|
+
deliverer for the same reason, and `observe_session` already does.
|
|
67
|
+
|
|
68
|
+
This used to charge whoever SENT each message and then mirror the bytes
|
|
69
|
+
onto the other side's meter. On a symmetric peering the phantom that
|
|
70
|
+
produced appeared identically in the issuer's measurement and the
|
|
71
|
+
subject's claim - both sides run this code - so the two errors cancelled
|
|
72
|
+
and nobody saw it for a week. The first asymmetric peering exposed it:
|
|
73
|
+
New York pulled from a newcomer that never pulled back, booked the bytes
|
|
74
|
+
it RECEIVED as the newcomer's consumption of New York's grant, and when
|
|
75
|
+
the newcomer sent no receipt - it had delivered nothing into a converged
|
|
76
|
+
shard and had nothing to claim - priced that as active silence. Fifty
|
|
77
|
+
windows of S2 against a stranger's node on its first contact with the
|
|
78
|
+
mesh, for answering.
|
|
79
|
+
"""
|
|
80
|
+
wb = metered_bytes(message)
|
|
81
|
+
kind = message.get('type', '').rsplit('/', 1)[1]
|
|
82
|
+
body = message.get('body', {})
|
|
83
|
+
# The session, to its initiator. Direction is relative to the responder,
|
|
84
|
+
# because that is what the grant's limits name.
|
|
85
|
+
session_meter.add('bytes_in' if from_initiator else 'bytes_out', wb)
|
|
86
|
+
if from_initiator:
|
|
87
|
+
if kind == 'open':
|
|
88
|
+
session_meter.add('conn_open')
|
|
89
|
+
session_meter.add('req')
|
|
90
|
+
session_meter.add('sync_ranges', _ranges(body.get('initial_frame', '')))
|
|
91
|
+
elif kind == 'need':
|
|
92
|
+
session_meter.add('req')
|
|
93
|
+
elif kind == 'range':
|
|
94
|
+
session_meter.add('sync_ranges', _ranges(body.get('frame', '')))
|
|
95
|
+
elif kind == 'range':
|
|
96
|
+
# A reconciliation's frames go both ways and are one cost.
|
|
97
|
+
session_meter.add('sync_ranges', _ranges(body.get('frame', '')))
|
|
98
|
+
# The events, to whoever pushed them.
|
|
99
|
+
if kind == 'deliver':
|
|
100
|
+
deliverer_meter.add('events_in', sync.delivered(dict(message)))
|
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
"""The receipt chain, both ends: this node as subject writing receipts and
|
|
2
|
+
the farewell, and as issuer reading receipts, closes and acks off the wire.
|
|
3
|
+
Separate because it is the whole of what budget/1.0 puts on the wire; the
|
|
4
|
+
pricing of a window is windows.py.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import secrets
|
|
9
|
+
from typing import Any, Mapping, Optional
|
|
10
|
+
|
|
11
|
+
from fleetmesh_core.accounting import accounting, window
|
|
12
|
+
from fleetmesh_core.wire.interfaces import ReceiptBody
|
|
13
|
+
from fleetmesh_core.wire.wire_types import BudgetBody, BudgetCloseBody, BudgetReceiptAckBody, Message, ReceiptAckStatus
|
|
14
|
+
from fleetmesh_core.wire import validate
|
|
15
|
+
from fleetmesh_core.identity.did import did_nostr
|
|
16
|
+
from node.storage import accounts as accounts_mod, grants as grants_mod
|
|
17
|
+
from .chain import OutChain
|
|
18
|
+
from .meter import C, PIURI, ZERO
|
|
19
|
+
from .shape import BudgetShape
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ReceiptsMixin:
|
|
23
|
+
# ---- subject: receipts out -------------------------------------------------
|
|
24
|
+
def _msg(self: BudgetShape, kind: str, body: BudgetBody, peer: str, piuri: str = PIURI) -> Message[BudgetBody]:
|
|
25
|
+
msg: Message[BudgetBody] = {
|
|
26
|
+
'id': 'budget-%s-%s' % (kind, secrets.token_hex(4)),
|
|
27
|
+
'type': '%s/%s' % (piuri, kind),
|
|
28
|
+
'from': self.ks.did,
|
|
29
|
+
'to': [did_nostr(peer)],
|
|
30
|
+
'body': body,
|
|
31
|
+
}
|
|
32
|
+
return msg
|
|
33
|
+
|
|
34
|
+
def receipt_due(self: BudgetShape, peer: str, now: int) -> bool:
|
|
35
|
+
row = self.grants.get(peer, grants_mod.RECEIVED)
|
|
36
|
+
if row is None or row['status'] != 'live':
|
|
37
|
+
return False
|
|
38
|
+
cadence = (row['payload'].get('receipt') or [600])[0]
|
|
39
|
+
epoch = self.epoch(peer, grants_mod.RECEIVED, now)
|
|
40
|
+
state = self.chain_out.get(peer, {}).get(epoch)
|
|
41
|
+
claim = self._meter(self.usage_of_me, peer, epoch).snapshot()
|
|
42
|
+
if not claim:
|
|
43
|
+
return False
|
|
44
|
+
if state is None:
|
|
45
|
+
return True
|
|
46
|
+
return state.sent_at is not None and (now - state.sent_at >= cadence) and (claim != state.last)
|
|
47
|
+
|
|
48
|
+
def send_receipt(
|
|
49
|
+
self: BudgetShape,
|
|
50
|
+
peer: str,
|
|
51
|
+
now: Optional[int] = None,
|
|
52
|
+
final: bool = False,
|
|
53
|
+
epoch: Optional[int] = None,
|
|
54
|
+
) -> tuple[ReceiptBody, str]:
|
|
55
|
+
now_val = now if now is not None else self.clock()
|
|
56
|
+
row = self.grants.get(peer, grants_mod.RECEIVED)
|
|
57
|
+
assert row is not None
|
|
58
|
+
epoch_val = self.epoch(peer, grants_mod.RECEIVED, now_val) if epoch is None else epoch
|
|
59
|
+
# The grant a receipt names is the one that governed its window. The
|
|
60
|
+
# chain is per window and is never restarted because the grant changed:
|
|
61
|
+
# that restart, met by an issuer expecting seq N, was an S2 misreport
|
|
62
|
+
# filed against an honest node every time a window was amended.
|
|
63
|
+
governing = self.governing(peer, grants_mod.RECEIVED, epoch_val) or row
|
|
64
|
+
with self._lock:
|
|
65
|
+
state = self.chain_out.setdefault(peer, {}).setdefault(epoch_val, OutChain(grant=governing['envelope_id']))
|
|
66
|
+
state.grant = governing['envelope_id']
|
|
67
|
+
counters = self._meter(self.usage_of_me, peer, epoch_val).snapshot()
|
|
68
|
+
body = accounting.sign_receipt(
|
|
69
|
+
ReceiptBody(grant=governing['envelope_id'], window=epoch_val, seq=state.seq,
|
|
70
|
+
prev=state.prev, counters=counters, at=now_val), self.ks.node_sk)
|
|
71
|
+
head = accounting.link(body)
|
|
72
|
+
# The heads this node produced, in sequence order. An ack carries
|
|
73
|
+
# the head the receiver holds, and without our own list that number
|
|
74
|
+
# is unreadable - it has been arriving on every ack since the
|
|
75
|
+
# protocol was written and being thrown away. With the list it says
|
|
76
|
+
# exactly how many receipts behind the receiver is.
|
|
77
|
+
state.seq, state.prev, state.sent_at, state.last = state.seq + 1, head, now_val, counters
|
|
78
|
+
state.heads = state.heads + [head]
|
|
79
|
+
if self.accounts is not None:
|
|
80
|
+
# Before the message leaves, not after: a receipt this node has
|
|
81
|
+
# sent and does not remember sending is the whole bug.
|
|
82
|
+
self.accounts.put_chain(peer, epoch_val, state.to_dict())
|
|
83
|
+
self.accounts.put_meter(peer, epoch_val, accounts_mod.OF_ME, counters, now_val)
|
|
84
|
+
# A receipt is a live wrap, and the attempt to make it store-and-forward
|
|
85
|
+
# is written down here because the reason it failed is not in the code.
|
|
86
|
+
#
|
|
87
|
+
# The case for storing it stands: a receipt lost while a subscription is
|
|
88
|
+
# down is priced as active silence, S2, unpublishable, and on
|
|
89
|
+
# 2026-09-05 that took a peering apart in both directions for eleven
|
|
90
|
+
# windows. So it was moved to kind 1059 on 2026-09-06, like the close
|
|
91
|
+
# that follows it. Seventeen hours later every peering in the mesh was
|
|
92
|
+
# pricing every other S2 every window: `relay.pocketstr.com` ACCEPTS a
|
|
93
|
+
# kind 1059 - the publish returns OK and no drop is recorded - and does
|
|
94
|
+
# not deliver it to a live subscription. A probe put both kinds through
|
|
95
|
+
# the same relay to the same subscription; the 21059 arrived and the
|
|
96
|
+
# 1059 did not.
|
|
97
|
+
#
|
|
98
|
+
# So the sender cannot tell accepted from delivered, and the whole
|
|
99
|
+
# store-and-forward binding is only as real as the relays under it.
|
|
100
|
+
# Anything this protocol sends stored - a grant amendment, a complaint
|
|
101
|
+
# notice, a window close, a withdrawal - has the same hole on this
|
|
102
|
+
# deployment, and had it before this change: moving receipts onto that
|
|
103
|
+
# path is what made it visible. Fixing it properly is choosing relays
|
|
104
|
+
# that serve 1059, or re-sending on an ack that never comes, and it is
|
|
105
|
+
# not something to decide while the mesh cannot account.
|
|
106
|
+
if final:
|
|
107
|
+
close = BudgetCloseBody(grant=governing['envelope_id'], window=epoch_val, final_receipt_head=head, counters=counters)
|
|
108
|
+
sent = self.wire_client.send(peer, self._msg('receipt', body, peer))
|
|
109
|
+
self.wire_client.send(peer, self._msg('close', close, peer), stored=True)
|
|
110
|
+
else:
|
|
111
|
+
sent = self.wire_client.send(peer, self._msg('receipt', body, peer))
|
|
112
|
+
# The publish result was thrown away. It does not prove delivery - a
|
|
113
|
+
# relay that accepts a message and never serves it returns exactly this
|
|
114
|
+
# - but a receipt NO relay would take has certainly not arrived, and
|
|
115
|
+
# that was invisible too. Cheap, and it is the half of the question the
|
|
116
|
+
# sender can answer on its own.
|
|
117
|
+
if sent and not any(ok for ok, _note in sent.values()):
|
|
118
|
+
self.unpublished.append((peer, epoch_val, body['seq'], now_val))
|
|
119
|
+
self.refusals.refuse('receipt-out', 'no relay accepted the receipt', subject=peer, at=now_val,
|
|
120
|
+
detail={'window': epoch_val, 'seq': body['seq'],
|
|
121
|
+
'relays': {u: n for u, (_ok, n) in sent.items()}})
|
|
122
|
+
return body, head
|
|
123
|
+
|
|
124
|
+
def _ack_lag(self: BudgetShape, peer: str, body: Mapping[str, Any]) -> int:
|
|
125
|
+
"""How many receipts behind the receiver is, from the ack it just sent.
|
|
126
|
+
|
|
127
|
+
Every ack carries the head of the chain the receiver holds. This node
|
|
128
|
+
holds the head of every receipt it produced, in order, so the two
|
|
129
|
+
together say exactly where the receiver has got to - and that number
|
|
130
|
+
has been on the wire since the protocol was written, unread. A relay
|
|
131
|
+
that accepts a receipt and never serves it looks identical to a
|
|
132
|
+
delivered one from the publish result; it does not look identical
|
|
133
|
+
here.
|
|
134
|
+
|
|
135
|
+
Zero when the receiver is level, or when the head is one this node
|
|
136
|
+
cannot place - a fresh window, a peer that restarted, an ack for a
|
|
137
|
+
window already forgotten. Not knowing is not the same as being behind,
|
|
138
|
+
and reporting a lag this node cannot substantiate is the mistake every
|
|
139
|
+
other diagnosis here has had to be corrected for. Caller holds the lock.
|
|
140
|
+
"""
|
|
141
|
+
head = body.get('head')
|
|
142
|
+
if not isinstance(head, str):
|
|
143
|
+
return 0
|
|
144
|
+
for epoch in sorted(self.chain_out.get(peer, {}), reverse=True):
|
|
145
|
+
heads = self.chain_out[peer][epoch].heads
|
|
146
|
+
if not heads:
|
|
147
|
+
continue
|
|
148
|
+
if head not in heads:
|
|
149
|
+
continue
|
|
150
|
+
idx = heads.index(head)
|
|
151
|
+
furthest = self.ack_head.get(peer)
|
|
152
|
+
if furthest is not None and furthest[0] == epoch and furthest[1] > idx:
|
|
153
|
+
# An ack for an older head arriving after one for a newer:
|
|
154
|
+
# two receipts sent in the same second come back over
|
|
155
|
+
# different relays in either order. The peer has not gone
|
|
156
|
+
# backwards; this is the later-sent ack arriving late.
|
|
157
|
+
return 0
|
|
158
|
+
self.ack_head[peer] = (epoch, idx)
|
|
159
|
+
behind = len(heads) - 1 - idx
|
|
160
|
+
if behind:
|
|
161
|
+
self.ack_lag[peer] = (epoch, behind, self.clock())
|
|
162
|
+
else:
|
|
163
|
+
self.ack_lag.pop(peer, None)
|
|
164
|
+
return behind
|
|
165
|
+
return 0
|
|
166
|
+
|
|
167
|
+
def farewell(self: BudgetShape, now: Optional[int] = None) -> list[tuple[Any, ...]]:
|
|
168
|
+
"""Say what this node used, to every peer, before it stops.
|
|
169
|
+
|
|
170
|
+
A node that vanished mid-window left its issuer with traffic measured
|
|
171
|
+
and no receipt, which is active silence: an S2 floor, and S2 drops the
|
|
172
|
+
tier to probation. A tier is only ever raised by a person, so the
|
|
173
|
+
upgrade this specification REQUIRES for a digest change - roll every
|
|
174
|
+
node in one pass or they decline to peer - cost every peering its seeded
|
|
175
|
+
tier, every time. Measured on the test mesh: one fleet restart, an S2 at
|
|
176
|
+
every peering, every streak back to zero, and an operator-seeded trusted
|
|
177
|
+
grant pending demotion.
|
|
178
|
+
|
|
179
|
+
It is the third layer at which an operator restart was being read as
|
|
180
|
+
dishonesty, after the session abort and the standing streak. The answer
|
|
181
|
+
is the same as it was there: say something rather than vanish. A receipt
|
|
182
|
+
for the open window is a claim, and an honest node's window then prices
|
|
183
|
+
on its own numbers.
|
|
184
|
+
|
|
185
|
+
Nothing here waits on an answer and nothing here may raise: a node that
|
|
186
|
+
could not say goodbye must still stop.
|
|
187
|
+
"""
|
|
188
|
+
now_val = now if now is not None else self.clock()
|
|
189
|
+
did: list[tuple[Any, ...]] = []
|
|
190
|
+
for peer in self._peers():
|
|
191
|
+
try:
|
|
192
|
+
row = self.grants.get(peer, grants_mod.RECEIVED)
|
|
193
|
+
if row is None or row['status'] != 'live':
|
|
194
|
+
continue
|
|
195
|
+
epoch = self.epoch(peer, grants_mod.RECEIVED, now_val)
|
|
196
|
+
if not self._meter(self.usage_of_me, peer, epoch).snapshot():
|
|
197
|
+
continue # nothing used this window; silence is honest
|
|
198
|
+
body, _head = self.send_receipt(peer, now_val)
|
|
199
|
+
did.append(('farewell receipt', peer, epoch, body['seq']))
|
|
200
|
+
except Exception as e:
|
|
201
|
+
did.append(('farewell failed', peer, '%s: %s' % (type(e).__name__, e)))
|
|
202
|
+
return did
|
|
203
|
+
|
|
204
|
+
def final_due(self: BudgetShape, peer: str, now: int) -> Optional[int]:
|
|
205
|
+
"""The previous window closed and my last word on it is stale: say the
|
|
206
|
+
final word while the grace period is open. Traffic after the last
|
|
207
|
+
receipt is priced by divergence, so a node that syncs near the end of
|
|
208
|
+
a window and then only ticks in the next one would look like a liar."""
|
|
209
|
+
row = self.grants.get(peer, grants_mod.RECEIVED)
|
|
210
|
+
if row is None or row['status'] != 'live':
|
|
211
|
+
return None
|
|
212
|
+
w = row['payload'].get('window') or C['window_seconds_default']
|
|
213
|
+
prev = accounting.epoch(now, w) - 1
|
|
214
|
+
if now > accounting.window_bounds(prev, w)[1] + accounting.window_grace(w):
|
|
215
|
+
return None
|
|
216
|
+
meter = self.usage_of_me.get(peer, {}).get(prev)
|
|
217
|
+
if meter is None or not meter.snapshot():
|
|
218
|
+
return None
|
|
219
|
+
state = self.chain_out.get(peer, {}).get(prev)
|
|
220
|
+
if state is not None and (state.final or state.last == meter.snapshot()):
|
|
221
|
+
return None
|
|
222
|
+
return prev
|
|
223
|
+
|
|
224
|
+
# ---- issuer: receipts in ---------------------------------------------------
|
|
225
|
+
def on_receipt(self: BudgetShape, peer: str, body: Any, now: Optional[int] = None) -> Message[BudgetReceiptAckBody]:
|
|
226
|
+
now_val = now if now is not None else self.clock()
|
|
227
|
+
why = validate.check_body('budget', 'receipt', body)
|
|
228
|
+
if why is not None:
|
|
229
|
+
# Not a claim, not an offence: a receipt this node cannot read is
|
|
230
|
+
# answered so the subject learns at once, and counted, and that is all.
|
|
231
|
+
#
|
|
232
|
+
# The seq is echoed like every other refusal branch echoes it. This
|
|
233
|
+
# one answered 0 whatever it was sent, so a subject at seq 5 heard
|
|
234
|
+
# that seq 0 was divergent and could not tell which receipt the
|
|
235
|
+
# answer was about - an answer that identifies nothing, in the one
|
|
236
|
+
# branch whose whole purpose is to tell the subject at once. The
|
|
237
|
+
# ack body is {seq, head, status} and says nothing about why, so
|
|
238
|
+
# the reason lives in the refusal channel, where `meshnode
|
|
239
|
+
# refusals` reads it. A body that is not a mapping has no seq to
|
|
240
|
+
# echo and that is the one case that still answers 0.
|
|
241
|
+
self.refusals.refuse('receipt', why, subject=peer, at=now_val)
|
|
242
|
+
seq = body.get('seq') if isinstance(body, dict) else None
|
|
243
|
+
return self._msg('receipt-ack', BudgetReceiptAckBody(
|
|
244
|
+
seq=seq if isinstance(seq, int) and not isinstance(seq, bool) else 0,
|
|
245
|
+
head=ZERO, status='divergent'), peer)
|
|
246
|
+
row = self.grants.get(peer, grants_mod.ISSUED)
|
|
247
|
+
epoch = body['window']
|
|
248
|
+
# A receipt is a claim about a window this grant could be about. One
|
|
249
|
+
# naming a window that will never close is never evaluated and never
|
|
250
|
+
# forgotten - forget_before only drops what is older than the horizon -
|
|
251
|
+
# so a peer naming a fresh future window per message takes as much of
|
|
252
|
+
# this node's memory and disk as it cares to. One naming a window long
|
|
253
|
+
# past is answerable by nothing either. Neither is an offence: it is a
|
|
254
|
+
# claim nobody can be held to, so it is refused the way an unreadable
|
|
255
|
+
# receipt is, counted and not stored.
|
|
256
|
+
#
|
|
257
|
+
# The upper bound is the CURRENT window, not the next one. A receipt
|
|
258
|
+
# about a window that has not opened yet claims usage that has not
|
|
259
|
+
# happened, and the one extra epoch that used to be accepted was a
|
|
260
|
+
# whole window of storage a peer could fill ahead of time; clock skew
|
|
261
|
+
# is already absorbed by the fact that a receipt about the window that
|
|
262
|
+
# just closed remains acceptable for KEEP_WINDOWS more.
|
|
263
|
+
current = self.epoch(peer, grants_mod.ISSUED, now_val)
|
|
264
|
+
oldest = current - self.KEEP_WINDOWS
|
|
265
|
+
if not isinstance(epoch, int) or isinstance(epoch, bool) or not (oldest <= epoch <= current):
|
|
266
|
+
self.refusals.refuse('receipt', 'names a window this grant cannot be about',
|
|
267
|
+
subject=peer, at=now_val,
|
|
268
|
+
detail={'window': epoch, 'acceptable': [oldest, current]})
|
|
269
|
+
return self._msg('receipt-ack', BudgetReceiptAckBody(seq=body.get('seq', 0), head=ZERO, status='divergent'), peer)
|
|
270
|
+
# The one divergent branch that recorded no reason, and the one that
|
|
271
|
+
# fired. Every receipt on a four-node mesh was refused here for an hour
|
|
272
|
+
# while budget_rejected stayed empty, so an operator saw an S2 a window
|
|
273
|
+
# with no claim earns and nothing at all about why the claim was
|
|
274
|
+
# refused. A refusal that is not counted is indistinguishable from
|
|
275
|
+
# silence, which is the thing this protocol is least able to afford.
|
|
276
|
+
if row is None:
|
|
277
|
+
self.refusals.refuse('receipt', 'no grant issued to this peer', subject=peer, at=now_val)
|
|
278
|
+
return self._msg('receipt-ack', BudgetReceiptAckBody(seq=body['seq'], head=ZERO, status='divergent'), peer)
|
|
279
|
+
acceptable = self._acceptable_grants(peer, epoch)
|
|
280
|
+
if body['grant'] not in acceptable:
|
|
281
|
+
self.refusals.refuse('receipt', 'names a grant that did not govern that window',
|
|
282
|
+
subject=peer, at=now_val,
|
|
283
|
+
detail={'window': epoch, 'named': body['grant'][:16],
|
|
284
|
+
'acceptable': [g[:16] for g in sorted(acceptable)]})
|
|
285
|
+
return self._msg('receipt-ack', BudgetReceiptAckBody(seq=body['seq'], head=ZERO, status='divergent'), peer)
|
|
286
|
+
limits = (self.governing(peer, grants_mod.ISSUED, epoch) or row)['payload']['limits']
|
|
287
|
+
with self._lock:
|
|
288
|
+
chain = self.receipts_in.setdefault(peer, {}).setdefault(epoch, [])
|
|
289
|
+
# The chain expects the next sequence number, not the length it
|
|
290
|
+
# happens to have: once a gap is tolerated those stop being the
|
|
291
|
+
# same number, and reading the expectation off the length would
|
|
292
|
+
# refuse every receipt after the first gap for the rest of the
|
|
293
|
+
# window - the failure this is here to end.
|
|
294
|
+
expected_seq = chain[-1]['seq'] + 1 if chain else 0
|
|
295
|
+
expected_prev = accounting.link(chain[-1]) if chain else ZERO
|
|
296
|
+
problems: list[str] = []
|
|
297
|
+
# A receipt's `at` is the subject's clock. constants.json states a
|
|
298
|
+
# tolerance of max_clock_skew_seconds and nothing in this project
|
|
299
|
+
# read it, while the specification says drift beyond it produces
|
|
300
|
+
# *unearned* divergence - a wrong answer from honest nodes, whose
|
|
301
|
+
# remedy is NTP rather than the ladder. So this is a diagnosis and
|
|
302
|
+
# not a finding: pricing a bad clock as dishonesty is the mistake
|
|
303
|
+
# two other things here have already had to be corrected for. It is
|
|
304
|
+
# recorded so an operator staring at an unexplained divergence has
|
|
305
|
+
# somewhere to look.
|
|
306
|
+
at = int(body.get('at', now_val))
|
|
307
|
+
drift = abs(at - now_val)
|
|
308
|
+
# A receipt dated before this node started is one a relay held
|
|
309
|
+
# while it was down - the peers' farewells, replayed on the
|
|
310
|
+
# resubscription - and is late, not drifted; nothing here can tell
|
|
311
|
+
# the two apart, so it is not written down as either. Dated after
|
|
312
|
+
# this clock, or late by more than this node has been up, it is
|
|
313
|
+
# a clock and nothing else.
|
|
314
|
+
if drift > C['max_clock_skew_seconds'] and (at > now_val or at >= self.started):
|
|
315
|
+
self.clock_drift.append((peer, epoch, drift, now_val))
|
|
316
|
+
if self.strain is not None:
|
|
317
|
+
self.strain.record('drift', peer=peer)
|
|
318
|
+
if not accounting.verify_receipt(body, peer):
|
|
319
|
+
# An unsigned receipt is not a claim anyone can be held to, so it
|
|
320
|
+
# is not a claim. Saying so at once is the only way the subject
|
|
321
|
+
# learns before the window closes on it.
|
|
322
|
+
problems.append('no valid subject signature over the chain link')
|
|
323
|
+
# A gap is the wire, and a rewrite is the subject.
|
|
324
|
+
#
|
|
325
|
+
# This required seq == len(chain) and refused anything else, so one
|
|
326
|
+
# receipt lost in transit cost the whole window: the subject went
|
|
327
|
+
# on incrementing, every later receipt was refused as a chain
|
|
328
|
+
# break, and the window closed on a claim missing everything after
|
|
329
|
+
# the hole. It only ever cleared at the boundary, because the chain
|
|
330
|
+
# is keyed per epoch. Seventeen hours of a four-node mesh were
|
|
331
|
+
# priced that way, against peers that had sent every receipt.
|
|
332
|
+
#
|
|
333
|
+
# What the strict form bought was the ability to say a subject had
|
|
334
|
+
# OMITTED a receipt on purpose, as distinct from the wire dropping
|
|
335
|
+
# one. That distinction was never really available - the transport
|
|
336
|
+
# demonstrably drops them - and it was being resolved against the
|
|
337
|
+
# subject every time. What it must not give up is the guarantee
|
|
338
|
+
# that a subject cannot understate, and that survives whole:
|
|
339
|
+
# counters are cumulative and monotonic, a counter that goes
|
|
340
|
+
# backwards is still refused, and the most recent receipt alone
|
|
341
|
+
# bounds the claim. A skipped middle receipt therefore buys the
|
|
342
|
+
# subject nothing, and a missing tail is already priced as the
|
|
343
|
+
# under-claim it is.
|
|
344
|
+
#
|
|
345
|
+
# Behind the expectation is a different thing and stays a fault: a
|
|
346
|
+
# receipt at a sequence already held is a rewrite of what the
|
|
347
|
+
# subject signed, which is exactly what the chain exists to refuse.
|
|
348
|
+
seq = body.get('seq')
|
|
349
|
+
gap = 0
|
|
350
|
+
if not isinstance(seq, int) or isinstance(seq, bool) or seq < expected_seq:
|
|
351
|
+
problems.append('seq %s, expected %d or later' % (seq, expected_seq))
|
|
352
|
+
elif seq > expected_seq:
|
|
353
|
+
gap = seq - expected_seq
|
|
354
|
+
if not gap and body.get('prev') != expected_prev:
|
|
355
|
+
# Only checkable with no gap: the links the wire lost are the
|
|
356
|
+
# ones that would have joined this receipt to the last one held.
|
|
357
|
+
problems.append('prev does not chain')
|
|
358
|
+
if chain:
|
|
359
|
+
for k, v in body.get('counters', {}).items():
|
|
360
|
+
if v < chain[-1]['counters'].get(k, 0):
|
|
361
|
+
problems.append('counter %s went backwards' % k)
|
|
362
|
+
if problems:
|
|
363
|
+
self.findings.append((peer, epoch, 'S2', 'receipt chain: %s' % '; '.join(problems)))
|
|
364
|
+
head = expected_prev
|
|
365
|
+
status: ReceiptAckStatus = 'divergent'
|
|
366
|
+
else:
|
|
367
|
+
if gap:
|
|
368
|
+
# A diagnosis and not a finding, recorded the way clock
|
|
369
|
+
# drift is: an operator reading an unexplained under-claim
|
|
370
|
+
# has somewhere to look, and nothing crosses the wire.
|
|
371
|
+
self.receipt_gaps.append((peer, epoch, expected_seq, seq, now_val))
|
|
372
|
+
chain.append(body)
|
|
373
|
+
head = accounting.link(body)
|
|
374
|
+
self.heads_in.setdefault(peer, {})[epoch] = head
|
|
375
|
+
if self.accounts is not None:
|
|
376
|
+
self.accounts.put_receipt(peer, epoch, body)
|
|
377
|
+
# A receipt for a window this node has already priced. The
|
|
378
|
+
# window is not reopened here - that writes grants and fires
|
|
379
|
+
# the reporter, and this runs inside an inbound handler under
|
|
380
|
+
# the lock - it is queued for the tick, where every other
|
|
381
|
+
# evaluation happens.
|
|
382
|
+
if (peer, epoch) in self.evaluated:
|
|
383
|
+
self.reopen.add((peer, epoch))
|
|
384
|
+
measured = self._meter(self.usage_by, peer, epoch).snapshot()
|
|
385
|
+
ev = window.evaluate_window(limits, measured, body['counters'])
|
|
386
|
+
# evaluate_window already classifies each limit; this ack is
|
|
387
|
+
# about MISREPORT only, so a limit counts as divergent when
|
|
388
|
+
# its class is worse than the overrun alone would have earned.
|
|
389
|
+
# The hand-rolled `divergence > TOLERANCE` that stood here was
|
|
390
|
+
# a second copy of classify()'s threshold, and ignored the
|
|
391
|
+
# sample floor evaluate_window applies, so a receipt could be
|
|
392
|
+
# acked divergent for a window the tick then scored S0.
|
|
393
|
+
divergent = any(d['severity'] != window.classify(d['overrun'], 0.0)
|
|
394
|
+
for d in ev['per_limit'].values())
|
|
395
|
+
status = 'divergent' if divergent else 'accepted'
|
|
396
|
+
return self._msg('receipt-ack', BudgetReceiptAckBody(seq=body.get('seq', 0), head=head, status=status), peer)
|
|
397
|
+
|
|
398
|
+
def on_close(self: BudgetShape, peer: str, body: Any, now: Optional[int] = None) -> Message[BudgetReceiptAckBody]:
|
|
399
|
+
now_val = now if now is not None else self.clock()
|
|
400
|
+
why = validate.check_body('budget', 'close', body)
|
|
401
|
+
if why is not None:
|
|
402
|
+
self.refusals.refuse('close', why, subject=peer, at=now_val)
|
|
403
|
+
return self._msg('receipt-ack', BudgetReceiptAckBody(seq=0, head=ZERO, status='divergent'), peer)
|
|
404
|
+
epoch = body['window']
|
|
405
|
+
w = self._window_of(peer, grants_mod.ISSUED)
|
|
406
|
+
head = self.heads_in.get(peer, {}).get(epoch, ZERO)
|
|
407
|
+
final = body.get('final_receipt_head') == head and accounting.close_is_final(
|
|
408
|
+
now_val, accounting.window_bounds(epoch, w)[1], w)
|
|
409
|
+
return self._msg('receipt-ack', BudgetReceiptAckBody(
|
|
410
|
+
seq=len(self.receipts_in.get(peer, {}).get(epoch, [])),
|
|
411
|
+
head=head, status='final' if final else 'accepted'), peer)
|
|
412
|
+
|
|
413
|
+
def handle_receipt(self: BudgetShape, inbound: Any, sender: str) -> tuple[Any, ...]:
|
|
414
|
+
msg = getattr(inbound, 'message', inbound)
|
|
415
|
+
body = msg.get('body')
|
|
416
|
+
reply = self.on_receipt(sender, body)
|
|
417
|
+
self.wire_client.reply(inbound, reply, sender=sender)
|
|
418
|
+
return ('acked-out', sender, reply['body']['status'])
|
|
419
|
+
|
|
420
|
+
def handle_close(self: BudgetShape, inbound: Any, sender: str) -> tuple[Any, ...]:
|
|
421
|
+
msg = getattr(inbound, 'message', inbound)
|
|
422
|
+
body = msg.get('body')
|
|
423
|
+
reply = self.on_close(sender, body)
|
|
424
|
+
self.wire_client.reply(inbound, reply, sender=sender)
|
|
425
|
+
return ('acked-out', sender, reply['body']['status'])
|
|
426
|
+
|
|
427
|
+
def handle_receipt_ack(self: BudgetShape, inbound: Any, sender: str) -> tuple[Any, ...]:
|
|
428
|
+
msg = getattr(inbound, 'message', inbound)
|
|
429
|
+
body = msg.get('body')
|
|
430
|
+
why = validate.check_body('budget', 'receipt-ack', body)
|
|
431
|
+
if why is not None:
|
|
432
|
+
self.refusals.refuse('receipt-ack', why, subject=sender)
|
|
433
|
+
return ('ignored', sender, 'malformed receipt-ack: ' + why)
|
|
434
|
+
with self._lock:
|
|
435
|
+
self.acks[sender] = body
|
|
436
|
+
behind = self._ack_lag(sender, body)
|
|
437
|
+
if behind:
|
|
438
|
+
return ('acked', sender, '%s, %d receipt(s) behind' % (body['status'], behind))
|
|
439
|
+
return ('acked', sender, body['status'])
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""The shape of a Budget as its mixins see it.
|
|
2
|
+
|
|
3
|
+
The one class is split across receipts.py, windows.py, tick.py and core.py by
|
|
4
|
+
concern, but every method reads instance state set in Budget.__init__ and
|
|
5
|
+
calls methods that live on another of those modules. Each mixin method
|
|
6
|
+
annotates `self` as this Protocol so the checker knows what is on it without
|
|
7
|
+
the mixins inheriting from one another, stubbing each other's methods, or
|
|
8
|
+
falling back to Any. It is a declaration only; Budget satisfies it
|
|
9
|
+
structurally and nothing here runs.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import threading
|
|
14
|
+
from typing import Any, Callable, Mapping, Optional, Protocol
|
|
15
|
+
|
|
16
|
+
from fleetmesh_core.accounting import standing
|
|
17
|
+
from fleetmesh_core.wire.interfaces import ReceiptBody
|
|
18
|
+
from fleetmesh_core.wire.wire_types import BudgetBody, BudgetReceiptAckBody, GoverningRow, Message
|
|
19
|
+
from node.compartment import Refusals, stores
|
|
20
|
+
from node.storage import grants as grants_mod
|
|
21
|
+
from .chain import OutChain
|
|
22
|
+
from .evaluation import Evaluation, Repricing
|
|
23
|
+
from .meter import Meter
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class BudgetShape(Protocol):
|
|
27
|
+
# class attributes on Budget and Compartment
|
|
28
|
+
KEEP_WINDOWS: int
|
|
29
|
+
TROUBLE_WINDOW: int
|
|
30
|
+
# set in Compartment.__init__
|
|
31
|
+
refusals: Refusals
|
|
32
|
+
# set in WireProtocol.__init__
|
|
33
|
+
wire_client: Any
|
|
34
|
+
# set in Budget.__init__, in that order
|
|
35
|
+
strain: Optional[Any]
|
|
36
|
+
accounts: Optional[Any]
|
|
37
|
+
ks: Any
|
|
38
|
+
transport: Any
|
|
39
|
+
grants: grants_mod.GrantStore
|
|
40
|
+
peering: Any
|
|
41
|
+
clock: Callable[[], int]
|
|
42
|
+
ledger: standing.Ledger
|
|
43
|
+
_lock: threading.RLock
|
|
44
|
+
usage_by: dict[str, dict[int, Meter]]
|
|
45
|
+
usage_of_me: dict[str, dict[int, Meter]]
|
|
46
|
+
receipts_in: dict[str, dict[int, list[ReceiptBody]]]
|
|
47
|
+
heads_in: dict[str, dict[int, str]]
|
|
48
|
+
chain_out: dict[str, dict[int, OutChain]]
|
|
49
|
+
acks: dict[str, Mapping[str, Any]]
|
|
50
|
+
evaluated: set[tuple[str, int]]
|
|
51
|
+
evaluations: stores.Ring[Evaluation]
|
|
52
|
+
findings: stores.Ring[tuple[Any, ...]]
|
|
53
|
+
zero_until: dict[str, int]
|
|
54
|
+
idle: dict[str, int]
|
|
55
|
+
clock_drift: stores.Ring[tuple[Any, ...]]
|
|
56
|
+
started: int
|
|
57
|
+
receipt_gaps: stores.Ring[tuple[Any, ...]]
|
|
58
|
+
ack_lag: dict[str, tuple[int, int, int]]
|
|
59
|
+
ack_head: dict[str, tuple[int, int]]
|
|
60
|
+
unpublished: stores.Ring[tuple[Any, ...]]
|
|
61
|
+
on_evaluated: list[Callable[[Evaluation], Any]]
|
|
62
|
+
reopen: set[tuple[str, int]]
|
|
63
|
+
repriced: list[Repricing]
|
|
64
|
+
on_repriced: list[Callable[[str, int, str, str], Any]]
|
|
65
|
+
# set by the tick, the first time it forgets
|
|
66
|
+
_forgotten_to: int
|
|
67
|
+
|
|
68
|
+
# core.py
|
|
69
|
+
def _save_meters(self, peer: Optional[str] = None, epoch: Optional[int] = None) -> None: ...
|
|
70
|
+
def _window_of(self, peer: str, direction: grants_mod.GrantDirection) -> int: ...
|
|
71
|
+
def _peers(self) -> list[str]: ...
|
|
72
|
+
def governing(self, peer: str, direction: grants_mod.GrantDirection, epoch: int) -> Optional[GoverningRow]: ...
|
|
73
|
+
def _acceptable_grants(self, peer: str, epoch: int) -> set[str]: ...
|
|
74
|
+
def epoch(self, peer: str, direction: grants_mod.GrantDirection, at: Optional[int] = None) -> int: ...
|
|
75
|
+
def _meter(self, table: dict[str, dict[int, Meter]], peer: str, epoch: int) -> Meter: ...
|
|
76
|
+
|
|
77
|
+
# receipts.py
|
|
78
|
+
def _msg(self, kind: str, body: BudgetBody, peer: str, piuri: str = ...) -> Message[BudgetBody]: ...
|
|
79
|
+
def receipt_due(self, peer: str, now: int) -> bool: ...
|
|
80
|
+
def send_receipt(self, peer: str, now: Optional[int] = None, final: bool = False,
|
|
81
|
+
epoch: Optional[int] = None) -> tuple[ReceiptBody, str]: ...
|
|
82
|
+
def _ack_lag(self, peer: str, body: Mapping[str, Any]) -> int: ...
|
|
83
|
+
def final_due(self, peer: str, now: int) -> Optional[int]: ...
|
|
84
|
+
def on_receipt(self, peer: str, body: Any, now: Optional[int] = None) -> Message[BudgetReceiptAckBody]: ...
|
|
85
|
+
def on_close(self, peer: str, body: Any, now: Optional[int] = None) -> Message[BudgetReceiptAckBody]: ...
|
|
86
|
+
def handle_receipt(self, inbound: Any, sender: str) -> tuple[Any, ...]: ...
|
|
87
|
+
def handle_close(self, inbound: Any, sender: str) -> tuple[Any, ...]: ...
|
|
88
|
+
def handle_receipt_ack(self, inbound: Any, sender: str) -> tuple[Any, ...]: ...
|
|
89
|
+
|
|
90
|
+
# windows.py
|
|
91
|
+
def evaluate(self, peer: str, epoch: int, now: Optional[int] = None) -> Optional[Evaluation]: ...
|
|
92
|
+
def publish_pending(self, peer: str, now: Optional[int] = None) -> Optional[Mapping[str, Any]]: ...
|
|
93
|
+
def _over(self, limits: Mapping[str, Any], counters: Mapping[str, Any], opening: bool = False) -> Optional[str]: ...
|
|
94
|
+
def closeable(self, peer: str, now: int) -> list[int]: ...
|
|
95
|
+
def reopen_due(self, peer: str) -> list[int]: ...
|
|
96
|
+
def reprice(self, peer: str, epoch: int, now: Optional[int] = None) -> Optional[Repricing]: ...
|
|
97
|
+
|
|
98
|
+
# tick.py
|
|
99
|
+
def _tick_peer(self, peer: str, now_val: int) -> list[tuple[Any, ...]]: ...
|
|
100
|
+
def _forget_old(self, now: int) -> None: ...
|
|
101
|
+
def _forget_in_memory(self, horizon: int) -> None: ...
|