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,537 @@
|
|
|
1
|
+
"""The Budget subsystem itself: construction and restore, the meters, the
|
|
2
|
+
window arithmetic, what the sync layer reports, and the Compartment contract.
|
|
3
|
+
Separate from the mixins because this is where the state they all share is
|
|
4
|
+
created; the mixins are the concerns that act on it.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import collections
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any, Callable, Counter, Mapping, Optional
|
|
12
|
+
|
|
13
|
+
from node.interfaces import ReceiptRefusedRow
|
|
14
|
+
|
|
15
|
+
from fleetmesh_core.accounting import accounting, standing
|
|
16
|
+
from fleetmesh_core.wire.interfaces import ReceiptBody
|
|
17
|
+
from fleetmesh_core.wire.wire_types import GoverningRow
|
|
18
|
+
from node.compartment import stores as st
|
|
19
|
+
from node.storage import accounts as accounts_mod, grants as grants_mod
|
|
20
|
+
from node.compartment import Command, Handler, Need, Outbound, WireProtocol
|
|
21
|
+
import dataclasses
|
|
22
|
+
from node.config.parameter import Parameter, ParamBlock
|
|
23
|
+
from node.channels import Channel, NOTE, TROUBLE, bus as eventbus_mod
|
|
24
|
+
from node.metrics import COUNTER, Metric
|
|
25
|
+
from .chain import OutChain
|
|
26
|
+
from .evaluation import Evaluation, Repricing
|
|
27
|
+
from .meter import C, KEEP_FINDINGS, PIURI, SYNC, TOLERANCE, Meter, _observe
|
|
28
|
+
from .receipts import ReceiptsMixin
|
|
29
|
+
from .tick import TickMixin
|
|
30
|
+
from .windows import WindowsMixin
|
|
31
|
+
from .types import BudgetStateReport
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclasses.dataclass(frozen=True)
|
|
35
|
+
class BudgetConfig(ParamBlock):
|
|
36
|
+
key = 'budget'
|
|
37
|
+
name = 'budget'
|
|
38
|
+
DERIVED: bool = False
|
|
39
|
+
PARAMS = (
|
|
40
|
+
Parameter(
|
|
41
|
+
key='tolerance', name='Divergence Tolerance',
|
|
42
|
+
description='Ratio by which measured usage may exceed limit before divergence',
|
|
43
|
+
type=float, default=TOLERANCE, minimum=0.0, maximum=1.0,
|
|
44
|
+
tags=('budget', 'tolerance'),
|
|
45
|
+
),
|
|
46
|
+
Parameter(
|
|
47
|
+
key='keep_windows', name='Keep Windows',
|
|
48
|
+
description='Number of closed windows retained before pruning',
|
|
49
|
+
type=int, default=24 * 7, minimum=1,
|
|
50
|
+
tags=('budget', 'history'),
|
|
51
|
+
),
|
|
52
|
+
Parameter(
|
|
53
|
+
key='keep_findings', name='Keep Findings',
|
|
54
|
+
description='Maximum adverse findings kept in memory',
|
|
55
|
+
type=int, default=KEEP_FINDINGS, minimum=10,
|
|
56
|
+
tags=('budget', 'findings'),
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
tolerance: float = TOLERANCE
|
|
60
|
+
keep_windows: int = 24 * 7
|
|
61
|
+
keep_findings: int = KEEP_FINDINGS
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Budget(ReceiptsMixin, WindowsMixin, TickMixin, WireProtocol):
|
|
65
|
+
requires: tuple[str, ...] = ('peering', 'strain')
|
|
66
|
+
"""The accounting, as a compartment the operator can watch.
|
|
67
|
+
|
|
68
|
+
Its refusals are the ones that mattered: a receipt this node would not read,
|
|
69
|
+
one naming a window this grant cannot be about, and one naming a grant that
|
|
70
|
+
did not govern the window. That last was refused silently for a night on a
|
|
71
|
+
four-node mesh while every window closed as active silence, and nothing
|
|
72
|
+
anywhere said the two ends were naming different grants. It goes through the
|
|
73
|
+
channel every compartment has now.
|
|
74
|
+
|
|
75
|
+
Its `stop` is the farewell: a node that vanishes mid-window leaves its issuer
|
|
76
|
+
with traffic measured and no receipt, which is an S2 nobody earned.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
name: str = 'budget'
|
|
80
|
+
piuris: tuple[str, ...] = (PIURI,)
|
|
81
|
+
config_cls = BudgetConfig
|
|
82
|
+
CHANNELS = (
|
|
83
|
+
Channel('window_evaluated', 'A closed window was evaluated against peer limits',
|
|
84
|
+
level=NOTE, wire=eventbus_mod._wire_evaluated,
|
|
85
|
+
say='evaluated window {epoch} for peer {peer} severity {severity}',
|
|
86
|
+
tags=('budget', 'evaluation')),
|
|
87
|
+
Channel('window_repriced', 'A peer limit was repriced under AIMD congestion control',
|
|
88
|
+
level=NOTE, wire=eventbus_mod._wire_repriced,
|
|
89
|
+
say='repriced peer {peer} for window {epoch}: {was} -> {now}',
|
|
90
|
+
tags=('budget', 'repricing')),
|
|
91
|
+
Channel('receipt_refused', 'A receipt from a peer was refused',
|
|
92
|
+
level=TROUBLE, say='receipt refused from peer {peer}: {reason}',
|
|
93
|
+
tags=('budget', 'receipt')),
|
|
94
|
+
)
|
|
95
|
+
METRICS = (
|
|
96
|
+
Metric(name='budget_windows_evaluated', description='Windows evaluated.',
|
|
97
|
+
kind=COUNTER, counts='window_evaluated', tags=('budget', 'evaluation')),
|
|
98
|
+
Metric(name='budget_windows_repriced', description='Windows repriced under AIMD.',
|
|
99
|
+
kind=COUNTER, counts='window_repriced', tags=('budget', 'repricing')),
|
|
100
|
+
Metric(name='budget_receipts_refused', description='Receipts refused from peers.',
|
|
101
|
+
kind=COUNTER, counts='receipt_refused', tags=('budget', 'receipt')),
|
|
102
|
+
)
|
|
103
|
+
OUTBOUND = (
|
|
104
|
+
Outbound('receipt', reply='receipt-ack', doc='deliver window accounting receipt'),
|
|
105
|
+
Outbound('close', doc='signal cooperative window closure'),
|
|
106
|
+
Outbound('receipt-ack', doc='acknowledge received receipt and head status'),
|
|
107
|
+
)
|
|
108
|
+
HANDLERS = (
|
|
109
|
+
Handler('receipt', run='handle_receipt', doc='answer inbound budget/1.0 receipt'),
|
|
110
|
+
Handler('close', run='handle_close', doc='answer inbound budget/1.0 window close'),
|
|
111
|
+
Handler('receipt-ack', run='handle_receipt_ack', doc='take inbound budget/1.0 receipt-ack'),
|
|
112
|
+
)
|
|
113
|
+
STORES = (
|
|
114
|
+
st.Store(key='evaluations', description='completed window evaluations',
|
|
115
|
+
kind=st.RING, shape=Evaluation, bound=KEEP_FINDINGS),
|
|
116
|
+
st.Store(key='findings', description='adverse window findings',
|
|
117
|
+
kind=st.RING, shape=tuple, bound=KEEP_FINDINGS),
|
|
118
|
+
st.Store(key='clock_drift', description='receipt clock drift events',
|
|
119
|
+
kind=st.RING, shape=tuple, bound=KEEP_FINDINGS),
|
|
120
|
+
st.Store(key='receipt_gaps', description='receipt sequence gaps',
|
|
121
|
+
kind=st.RING, shape=tuple, bound=KEEP_FINDINGS),
|
|
122
|
+
st.Store(key='unpublished', description='receipts unpublishable to relays',
|
|
123
|
+
kind=st.RING, shape=tuple, bound=KEEP_FINDINGS),
|
|
124
|
+
)
|
|
125
|
+
COMMANDS = (
|
|
126
|
+
Command(
|
|
127
|
+
key='show',
|
|
128
|
+
name='Budget',
|
|
129
|
+
description='what this node is accounting: peers metered, windows evaluated, findings'
|
|
130
|
+
' held',
|
|
131
|
+
tags=('budget', 'status'),
|
|
132
|
+
permission='budget/show',
|
|
133
|
+
run='state',
|
|
134
|
+
reply='{peers, windows_evaluated, findings, grants_waiting_for_a_boundary, '
|
|
135
|
+
'clock_drift, receipt_chains_open, idle_streaks, rejected, receipts_refused}',
|
|
136
|
+
),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# Windows kept after they close. A window is history once it has been
|
|
140
|
+
# evaluated and the complaint deadline on it has passed, but a report may
|
|
141
|
+
# still cite the receipt that backs it, so the horizon is generous. Without
|
|
142
|
+
# one the accounting store grows for as long as the node runs. It is also
|
|
143
|
+
# how far back a receipt may reach; see on_receipt.
|
|
144
|
+
KEEP_WINDOWS: int = 24 * 7
|
|
145
|
+
|
|
146
|
+
evaluations: st.Ring[Evaluation]
|
|
147
|
+
findings: st.Ring[tuple[Any, ...]]
|
|
148
|
+
clock_drift: st.Ring[tuple[Any, ...]]
|
|
149
|
+
receipt_gaps: st.Ring[tuple[Any, ...]]
|
|
150
|
+
unpublished: st.Ring[tuple[Any, ...]]
|
|
151
|
+
|
|
152
|
+
NEEDS = (
|
|
153
|
+
Need('keystore', 'ks'),
|
|
154
|
+
Need('transport'),
|
|
155
|
+
Need('grant_store', 'stores.grants'),
|
|
156
|
+
Need('peering'),
|
|
157
|
+
Need('ledger'),
|
|
158
|
+
Need('accounts', 'stores.accounts'),
|
|
159
|
+
Need('strain'),
|
|
160
|
+
Need('events'),
|
|
161
|
+
Need('reporter', late=True),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
def start(self) -> None:
|
|
165
|
+
if self.reporter is not None:
|
|
166
|
+
if self.reporter.on_evaluated not in self.on_evaluated:
|
|
167
|
+
self.on_evaluated.append(self.reporter.on_evaluated)
|
|
168
|
+
if self.reporter.on_repriced not in self.on_repriced:
|
|
169
|
+
self.on_repriced.append(self.reporter.on_repriced)
|
|
170
|
+
if self.events is not None:
|
|
171
|
+
events = self.events
|
|
172
|
+
self.on_evaluated.append(lambda rec: events.emit(eventbus_mod.TOPIC_WINDOW_EVALUATED, rec.to_dict()))
|
|
173
|
+
self.on_repriced.append(lambda *a: events.emit(eventbus_mod.TOPIC_WINDOW_REPRICED, *a))
|
|
174
|
+
|
|
175
|
+
def __init__(
|
|
176
|
+
self,
|
|
177
|
+
keystore: Any,
|
|
178
|
+
transport: Any,
|
|
179
|
+
grant_store: grants_mod.GrantStore,
|
|
180
|
+
peering: Any,
|
|
181
|
+
clock: Optional[Callable[[], int]] = None,
|
|
182
|
+
ledger: Optional[standing.Ledger] = None,
|
|
183
|
+
accounts: Optional[Any] = None,
|
|
184
|
+
strain: Optional[Any] = None,
|
|
185
|
+
node: Optional[Any] = None,
|
|
186
|
+
events: Optional[Any] = None,
|
|
187
|
+
reporter: Optional[Any] = None,
|
|
188
|
+
) -> None:
|
|
189
|
+
super().__init__(node, clock)
|
|
190
|
+
self.strain: Optional[Any] = strain # what this node has survived, or None
|
|
191
|
+
self.accounts: Optional[Any] = accounts # AccountStore, or None for a node that forgets
|
|
192
|
+
self.events: Optional[Any] = events
|
|
193
|
+
self.reporter: Optional[Any] = reporter
|
|
194
|
+
self.ks: Any = keystore
|
|
195
|
+
self.transport: Any = transport
|
|
196
|
+
self.grants: grants_mod.GrantStore = grant_store
|
|
197
|
+
self.peering: Any = peering # for the WireNode that writes envelopes
|
|
198
|
+
self.ledger: standing.Ledger = ledger or standing.Ledger()
|
|
199
|
+
self._lock: threading.RLock = threading.RLock()
|
|
200
|
+
# (peer, direction) -> (window seconds, store change counter, monotonic at)
|
|
201
|
+
self._window_memo: dict[tuple[str, str], tuple[int, int, float]] = {} # not a store: memoized window calculations
|
|
202
|
+
self.usage_by: dict[str, dict[int, Meter]] = {} # not a store: restored from accounts, for persist=DURABLE
|
|
203
|
+
self.usage_of_me: dict[str, dict[int, Meter]] = {} # not a store: restored from accounts, for persist=DURABLE
|
|
204
|
+
self.receipts_in: dict[str, dict[int, list[ReceiptBody]]] = {} # not a store: restored from accounts, for persist=DURABLE
|
|
205
|
+
self.heads_in: dict[str, dict[int, str]] = {} # not a store: derived from restored receipts, for persist=DURABLE
|
|
206
|
+
self.chain_out: dict[str, dict[int, OutChain]] = {} # not a store: restored from accounts, for persist=DURABLE
|
|
207
|
+
self.acks: dict[str, Mapping[str, Any]] = {} # not a store: last ack per live peer
|
|
208
|
+
self.evaluated: set[tuple[str, int]] = set() # not a store: restored from accounts, for persist=DURABLE
|
|
209
|
+
self.zero_until: dict[str, int] = {} # not a store: time penalty per zeroed peer
|
|
210
|
+
self.idle: dict[str, int] = {} # not a store: restored from accounts evaluations, consecutive closed idle windows
|
|
211
|
+
# Each of these carries the time it happened, and `trouble` reads only
|
|
212
|
+
# the ones inside TROUBLE_WINDOW. A cumulative counter with no time
|
|
213
|
+
# bound warns forever about a fault that has stopped: every node here
|
|
214
|
+
# spent a day reporting 151 clock-drift events that were hours dead
|
|
215
|
+
# against NTP-synced boxes, and a peering warned for four hours about
|
|
216
|
+
# one message lost in a restart while completing thirty-four sessions.
|
|
217
|
+
# The compartment contract already says what to do - short enough that a
|
|
218
|
+
# fault which has stopped stops being reported - and the six
|
|
219
|
+
# compartments that report through `Refusals.within` already did it.
|
|
220
|
+
self.started: int = self.clock() # a receipt dated before this cannot be told from a relay replay
|
|
221
|
+
self.ack_lag: dict[str, tuple[int, int, int]] = {} # not a store: ack lag per live peer
|
|
222
|
+
self.ack_head: dict[str, tuple[int, int]] = {} # not a store: ack head per live peer
|
|
223
|
+
# Refusals go through the compartment channel. `rejected` remains as a
|
|
224
|
+
# read-only view of the same counts, because the health block, the CLI
|
|
225
|
+
# and three suites already read it by that name.
|
|
226
|
+
self.on_evaluated: list[Callable[[Evaluation], Any]] = [] # not a store: callbacks after every evaluated window
|
|
227
|
+
self.reopen: set[tuple[str, int]] = set() # not a store: priced windows with late receipts pending evaluation
|
|
228
|
+
self.repriced: list[Repricing] = [] # not a store: late receipt repricing records
|
|
229
|
+
self.on_repriced: list[Callable[[str, int, str, str], Any]] = [] # not a store: callbacks after a correction
|
|
230
|
+
if self.accounts is not None:
|
|
231
|
+
self._restore()
|
|
232
|
+
|
|
233
|
+
def _restore(self) -> None:
|
|
234
|
+
"""Pick the accounting back up where the last process left it.
|
|
235
|
+
|
|
236
|
+
Without this a restart begins a fresh receipt chain at seq 0 while the
|
|
237
|
+
issuer is still expecting seq N, and reports counters lower than it
|
|
238
|
+
already reported: a broken chain and a counter going backwards, which
|
|
239
|
+
is an S2 misreport filed against a node that did nothing wrong.
|
|
240
|
+
"""
|
|
241
|
+
assert self.accounts is not None
|
|
242
|
+
m = self.accounts.meters()
|
|
243
|
+
for side, table in ((accounts_mod.BY, self.usage_by), (accounts_mod.OF_ME, self.usage_of_me)):
|
|
244
|
+
for peer, by_epoch in m.get(side, {}).items():
|
|
245
|
+
for epoch, counters in by_epoch.items():
|
|
246
|
+
meter = table.setdefault(peer, {}).setdefault(epoch, Meter())
|
|
247
|
+
meter.c.update(counters)
|
|
248
|
+
self.receipts_in = self.accounts.receipts()
|
|
249
|
+
for peer, by_epoch in self.receipts_in.items():
|
|
250
|
+
for epoch, chain in by_epoch.items():
|
|
251
|
+
if chain:
|
|
252
|
+
self.heads_in.setdefault(peer, {})[epoch] = accounting.link(chain[-1])
|
|
253
|
+
self.chain_out = {peer: {epoch: OutChain.from_dict(s) for epoch, s in by_epoch.items()} # not a store: restored from accounts, for persist=DURABLE
|
|
254
|
+
for peer, by_epoch in self.accounts.chains().items()}
|
|
255
|
+
self.evaluated = self.accounts.evaluated()
|
|
256
|
+
# The ledger is what standing/1.0 tells a stranger and what weights a
|
|
257
|
+
# reporter. It was memory only, so a restart set every streak to zero
|
|
258
|
+
# and a reporter this node had watched for a week weighed nothing.
|
|
259
|
+
for peer, epoch, severity, tier in self.accounts.evaluations():
|
|
260
|
+
if severity is None:
|
|
261
|
+
continue
|
|
262
|
+
if tier is None: # recorded before the tier was kept: the grant that governed it
|
|
263
|
+
g = self.grants.governing(peer, grants_mod.ISSUED, epoch)
|
|
264
|
+
tier = (g['payload'].get('tier') if g else None) or 'probation'
|
|
265
|
+
self.ledger.record(peer, epoch, severity, tier)
|
|
266
|
+
# The idle streak decides when a dormant grant decays. Counted only in
|
|
267
|
+
# memory it would begin again at zero on every restart, and a node that
|
|
268
|
+
# is redeployed weekly would never reach the threshold at all - the same
|
|
269
|
+
# shape as the standing streak above.
|
|
270
|
+
rows = self.accounts.evaluations()
|
|
271
|
+
for peer in {r[0] for r in rows}:
|
|
272
|
+
run = 0
|
|
273
|
+
for _p, _e, sev, _t in [r for r in rows if r[0] == peer]:
|
|
274
|
+
run = run + 1 if sev is None else 0
|
|
275
|
+
self.idle[peer] = run
|
|
276
|
+
|
|
277
|
+
def _save_meters(self, peer: Optional[str] = None, epoch: Optional[int] = None) -> None:
|
|
278
|
+
if self.accounts is None:
|
|
279
|
+
return
|
|
280
|
+
# The sync thread adds a peer's first meter the moment its first
|
|
281
|
+
# session opens; the tick walks these tables. Snapshot under the lock.
|
|
282
|
+
with self._lock:
|
|
283
|
+
rows = [(side, pk, e, meter)
|
|
284
|
+
for side, table in ((accounts_mod.BY, self.usage_by), (accounts_mod.OF_ME, self.usage_of_me))
|
|
285
|
+
for pk, by_epoch in table.items() if peer is None or pk == peer
|
|
286
|
+
for e, meter in by_epoch.items() if epoch is None or e == epoch]
|
|
287
|
+
for side, pk, e, meter in rows:
|
|
288
|
+
self.accounts.put_meter(pk, e, side, meter.snapshot(), self.clock())
|
|
289
|
+
|
|
290
|
+
# ---- window arithmetic ----------------------------------------------------
|
|
291
|
+
# How long a remembered window size may be trusted without re-reading the
|
|
292
|
+
# grant. A window is a grant payload field that changes only when a grant
|
|
293
|
+
# is re-issued; any write through this node's own store connection drops
|
|
294
|
+
# the memo at once (sqlite's total_changes moves), and the TTL covers a
|
|
295
|
+
# write from another process on the same file.
|
|
296
|
+
WINDOW_MEMO_SECONDS: float = 2.0
|
|
297
|
+
|
|
298
|
+
def _window_of(self, peer: str, direction: grants_mod.GrantDirection) -> int:
|
|
299
|
+
"""The window size of the grant in force, in seconds.
|
|
300
|
+
|
|
301
|
+
Every observed sync message asked this three times, and each ask was a
|
|
302
|
+
sqlite read plus a json.loads of the whole payload: measured at ~70%
|
|
303
|
+
of the cost of metering a message. The memo below is keyed on the
|
|
304
|
+
connection's change counter so a grant written through this store is
|
|
305
|
+
seen immediately.
|
|
306
|
+
"""
|
|
307
|
+
key = (peer, direction)
|
|
308
|
+
changes = self.grants.db.total_changes
|
|
309
|
+
now_mono = time.monotonic()
|
|
310
|
+
memo = self._window_memo.get(key)
|
|
311
|
+
if memo is not None and memo[1] == changes and now_mono - memo[2] < self.WINDOW_MEMO_SECONDS:
|
|
312
|
+
return memo[0]
|
|
313
|
+
row = self.grants.get(peer, direction)
|
|
314
|
+
w = int((row['payload'].get('window') if row else None) or C['window_seconds_default'])
|
|
315
|
+
self._window_memo[key] = (w, changes, now_mono)
|
|
316
|
+
return w
|
|
317
|
+
|
|
318
|
+
def _peers(self) -> list[str]:
|
|
319
|
+
"""The peers holding a live grant, as a plain list of pubkeys.
|
|
320
|
+
|
|
321
|
+
GrantStore.list() shadows the builtin inside the class body, so the
|
|
322
|
+
checker cannot read GrantStore.peers()' own annotation; this is the one
|
|
323
|
+
place the node re-states it (the fix belongs in node/grants.py).
|
|
324
|
+
"""
|
|
325
|
+
# Read through Any: the store's return annotation is unusable to the
|
|
326
|
+
# checker, so its shape is re-stated on this side and validated by the
|
|
327
|
+
# call sites that index/sort it.
|
|
328
|
+
raw: Any = self.grants.peers()
|
|
329
|
+
peers: list[str] = [str(p) for p in raw]
|
|
330
|
+
return peers
|
|
331
|
+
|
|
332
|
+
def governing(self, peer: str, direction: grants_mod.GrantDirection, epoch: int) -> Optional[GoverningRow]:
|
|
333
|
+
"""The grant that governed one window, which is what the window is
|
|
334
|
+
priced under and what a receipt for it names."""
|
|
335
|
+
return self.grants.governing(peer, direction, epoch)
|
|
336
|
+
|
|
337
|
+
def _acceptable_grants(self, peer: str, epoch: int) -> set[str]:
|
|
338
|
+
"""Which grant ids a receipt for this window may name: the one that
|
|
339
|
+
governed it, the one before (an amend published at the boundary may
|
|
340
|
+
not have reached the subject yet) and the one current now (a seed
|
|
341
|
+
applied mid-window)."""
|
|
342
|
+
ids: set[str] = set()
|
|
343
|
+
for e in (epoch - 1, epoch):
|
|
344
|
+
g = self.grants.governing(peer, grants_mod.ISSUED, e)
|
|
345
|
+
if g:
|
|
346
|
+
ids.add(g['envelope_id'])
|
|
347
|
+
row = self.grants.get(peer, grants_mod.ISSUED)
|
|
348
|
+
if row:
|
|
349
|
+
ids.add(row['envelope_id'])
|
|
350
|
+
return ids
|
|
351
|
+
|
|
352
|
+
def epoch(self, peer: str, direction: grants_mod.GrantDirection, at: Optional[int] = None) -> int:
|
|
353
|
+
return accounting.epoch(at if at is not None else self.clock(), self._window_of(peer, direction))
|
|
354
|
+
|
|
355
|
+
def aggregate(self, at: Optional[int] = None) -> dict[str, int]:
|
|
356
|
+
"""What every peer together is doing to this node, in the open window.
|
|
357
|
+
|
|
358
|
+
The issuer frame - `usage_by` is what a peer did to me - because that is
|
|
359
|
+
the frame a ceiling is about. A grant bounds one peer and nothing bounded
|
|
360
|
+
the node, so this is the number `capacity` measures pressure against.
|
|
361
|
+
|
|
362
|
+
Windows are per peering, so each peer contributes its own open epoch.
|
|
363
|
+
With the default window that is one hour for all of them; with mixed
|
|
364
|
+
windows it is still each peer's current promise, which is the honest
|
|
365
|
+
reading of "what is happening to me now".
|
|
366
|
+
"""
|
|
367
|
+
now = at if at is not None else self.clock()
|
|
368
|
+
total: Counter[str] = collections.Counter()
|
|
369
|
+
with self._lock:
|
|
370
|
+
peers = list(self.usage_by)
|
|
371
|
+
for peer in peers:
|
|
372
|
+
e = self.epoch(peer, grants_mod.ISSUED, now)
|
|
373
|
+
with self._lock:
|
|
374
|
+
m = self.usage_by.get(peer, {}).get(e)
|
|
375
|
+
snap = m.snapshot() if m is not None else None
|
|
376
|
+
if snap:
|
|
377
|
+
total.update(snap)
|
|
378
|
+
return dict(total)
|
|
379
|
+
|
|
380
|
+
def _meter(self, table: dict[str, dict[int, Meter]], peer: str, epoch: int) -> Meter:
|
|
381
|
+
with self._lock:
|
|
382
|
+
return table.setdefault(peer, {}).setdefault(epoch, Meter())
|
|
383
|
+
|
|
384
|
+
# ---- what the sync layer reports ----------------------------------------
|
|
385
|
+
def observe(self, peer: str, message: Mapping[str, Any], i_initiated: bool, outbound: bool) -> None:
|
|
386
|
+
"""One sync message, charged to the session's initiator.
|
|
387
|
+
|
|
388
|
+
If I opened the session it is my usage under the peer's grant,
|
|
389
|
+
whichever way this message went; if the peer opened it, it is the
|
|
390
|
+
peer's usage under mine. The wire knows who opened it by the thid.
|
|
391
|
+
|
|
392
|
+
An `open` is flushed before this returns, because the counter it moves
|
|
393
|
+
is the one this node's own guard reads to decide whether it may open
|
|
394
|
+
the next session. `observe_session` flushes at session END, which
|
|
395
|
+
bounds the loss to one session in flight - and one session in flight
|
|
396
|
+
is exactly what a restart loses. The node then reloaded a meter one
|
|
397
|
+
short, `throttled()` found room that was not there, and the node
|
|
398
|
+
opened a session the grant did not cover while believing it did. The
|
|
399
|
+
peer measured the overrun and priced it: S1 on conn_rate at 5 over a
|
|
400
|
+
grant of 4, which cost a peer 51 clean windows for restarting. See
|
|
401
|
+
I15 in docs/audit-2026-10-07-4-issues.md, where the arithmetic is
|
|
402
|
+
reproduced. Nothing else here needs to be durable this early - the
|
|
403
|
+
volume counters are reconciled by the receipt and their divergence
|
|
404
|
+
floor already absorbs a lost session by construction - so this is the
|
|
405
|
+
one message kind that writes through.
|
|
406
|
+
"""
|
|
407
|
+
if not message.get('type', '').startswith(SYNC + '/'):
|
|
408
|
+
return
|
|
409
|
+
mine = self._meter(self.usage_of_me, peer, self.epoch(peer, grants_mod.RECEIVED))
|
|
410
|
+
theirs = self._meter(self.usage_by, peer, self.epoch(peer, grants_mod.ISSUED))
|
|
411
|
+
session_meter = mine if i_initiated else theirs
|
|
412
|
+
deliverer_meter = mine if outbound else theirs
|
|
413
|
+
_observe(session_meter, deliverer_meter, message, from_initiator=(i_initiated == outbound))
|
|
414
|
+
if message.get('type', '').rsplit('/', 1)[-1] == 'open' and self.accounts is not None:
|
|
415
|
+
# Exactly the one row the open moved, not every epoch on both
|
|
416
|
+
# sides: this is on the sync path, and `_save_meters(peer)` would
|
|
417
|
+
# rewrite each remembered window of each side per session.
|
|
418
|
+
side = accounts_mod.OF_ME if i_initiated else accounts_mod.BY
|
|
419
|
+
at = self.epoch(peer, grants_mod.RECEIVED if i_initiated else grants_mod.ISSUED)
|
|
420
|
+
self.accounts.put_meter(peer, at, side, session_meter.snapshot(), self.clock())
|
|
421
|
+
|
|
422
|
+
def observe_in(self, peer: str, message: Mapping[str, Any], i_initiated: bool = False) -> None:
|
|
423
|
+
"""A sync message the peer sent me."""
|
|
424
|
+
self.observe(peer, message, i_initiated, outbound=False)
|
|
425
|
+
|
|
426
|
+
def observe_out(self, peer: str, message: Mapping[str, Any], i_initiated: bool = True) -> None:
|
|
427
|
+
"""A sync message I sent the peer."""
|
|
428
|
+
self.observe(peer, message, i_initiated, outbound=True)
|
|
429
|
+
|
|
430
|
+
def observe_session(self, peer: str, role: str, result: Mapping[str, Any]) -> None:
|
|
431
|
+
"""After a session: duplicates the peer delivered to me count against
|
|
432
|
+
them, and both meters for this peer reach the store.
|
|
433
|
+
|
|
434
|
+
`usage_of_me` was written down at every receipt - `send_receipt` puts
|
|
435
|
+
the meter before the message leaves - and `usage_by` was written only
|
|
436
|
+
by the tick. So a node that stopped between ticks came back having
|
|
437
|
+
forgotten some of what a peer had done to it while remembering all of
|
|
438
|
+
what it had done to the peer, and an issuer that under-measures reads
|
|
439
|
+
an honest claim as too high: divergence, and an S2 misreport against a
|
|
440
|
+
peer that did nothing but keep working across somebody else's crash.
|
|
441
|
+
The asymmetry was the bug. A session is the natural boundary, the same
|
|
442
|
+
one the other side already uses, and it bounds the loss to one session
|
|
443
|
+
in flight rather than a whole tick interval.
|
|
444
|
+
"""
|
|
445
|
+
dups = result.get('counters', {}).get('dups_sent', 0)
|
|
446
|
+
if role == 'initiator' and dups:
|
|
447
|
+
self._meter(self.usage_by, peer, self.epoch(peer, grants_mod.ISSUED)).add('dups_sent', dups)
|
|
448
|
+
self._save_meters(peer)
|
|
449
|
+
|
|
450
|
+
# ---- the compartment contract -------------------------------------------------
|
|
451
|
+
@property
|
|
452
|
+
def rejected(self) -> Counter[str]:
|
|
453
|
+
"""The refusal counts, under the name the health block and the suites
|
|
454
|
+
already use. One channel, two readers."""
|
|
455
|
+
return self.refusals.counts
|
|
456
|
+
|
|
457
|
+
def state(self) -> BudgetStateReport:
|
|
458
|
+
pending = sum(1 for p in self._peers() if self.grants.pending(p))
|
|
459
|
+
return {
|
|
460
|
+
'peers': len(self._peers()),
|
|
461
|
+
'windows_evaluated': len(self.evaluated),
|
|
462
|
+
'findings': len(self.findings),
|
|
463
|
+
'grants_waiting_for_a_boundary': pending,
|
|
464
|
+
'clock_drift': len(self.clock_drift),
|
|
465
|
+
'receipt_gaps': len(self.receipt_gaps),
|
|
466
|
+
'receipts_behind': {pk: n for pk, (_e, n, _at) in self.ack_lag.items() if n},
|
|
467
|
+
'receipts_unpublished': len(self.unpublished),
|
|
468
|
+
'receipt_chains_open': sum(len(v) for v in self.chain_out.values()),
|
|
469
|
+
'idle_streaks': {p[:16]: n for p, n in self.idle.items() if n},
|
|
470
|
+
# Declared rather than reached for: the health block read both
|
|
471
|
+
# of these off this object by name, which is why `rejected` had
|
|
472
|
+
# to survive as a property after the refusal channel replaced it.
|
|
473
|
+
'rejected': dict(self.refusals.counts),
|
|
474
|
+
'receipts_refused': self.receipts_refused(),
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
def receipts_refused(self) -> list[ReceiptRefusedRow]:
|
|
478
|
+
"""The windows this node would not take a receipt for, because the two
|
|
479
|
+
ends name different grants. Five at most: it is a fault to fix, not a
|
|
480
|
+
history to read, and the counts keep the history."""
|
|
481
|
+
return [ReceiptRefusedRow(peer=r.subject and r.subject[:16], window=r.detail.get('window'),
|
|
482
|
+
named=r.detail.get('named'), acceptable=r.detail.get('acceptable') or [],
|
|
483
|
+
at=r.at)
|
|
484
|
+
for r in self.refusals.within(self.TROUBLE_WINDOW, self.clock(),
|
|
485
|
+
why='names a grant that did not govern that window')][-5:]
|
|
486
|
+
|
|
487
|
+
def trouble(self) -> list[str]:
|
|
488
|
+
out: list[str] = []
|
|
489
|
+
disagree = self.refusals.within(self.TROUBLE_WINDOW, self.clock(),
|
|
490
|
+
why='names a grant that did not govern that window')
|
|
491
|
+
if disagree:
|
|
492
|
+
peers = sorted({(r.subject or '')[:16] for r in disagree})
|
|
493
|
+
out.append('receipts refused over a grant disagreement with %s - every window '
|
|
494
|
+
'those peerings close will be active silence until it is resolved'
|
|
495
|
+
% ', '.join(peers))
|
|
496
|
+
unaddressable = self.refusals.within(self.TROUBLE_WINDOW, self.clock(),
|
|
497
|
+
kind='accounting', why='the peer raised')
|
|
498
|
+
if unaddressable:
|
|
499
|
+
peers = sorted({(r.subject or '')[:16] for r in unaddressable})
|
|
500
|
+
out.append('accounting raised against %s and that window\'s work was skipped for '
|
|
501
|
+
'them: most often a descriptor this node will not accept, which is what a '
|
|
502
|
+
'peer left behind a protocol digest looks like from here' % ', '.join(peers))
|
|
503
|
+
# Only what is still happening. The record stays whole in `state`.
|
|
504
|
+
now = self.clock()
|
|
505
|
+
recent = lambda rows: [r for r in rows if now - r[-1] <= self.TROUBLE_WINDOW]
|
|
506
|
+
drifting, unsent_now, gaps_now = recent(self.clock_drift), recent(self.unpublished), recent(self.receipt_gaps)
|
|
507
|
+
if drifting:
|
|
508
|
+
out.append('%d receipts have been timed outside the tolerance: check NTP at both ends '
|
|
509
|
+
'before reading any divergence' % len(drifting))
|
|
510
|
+
lagging = {pk: n for pk, (_e, n, at) in self.ack_lag.items() if n and now - at <= self.TROUBLE_WINDOW}
|
|
511
|
+
if lagging:
|
|
512
|
+
out.append('%d peer(s) are acking a chain head behind the one this node sent, by up to %d '
|
|
513
|
+
'receipt(s): the claims exist and are not arriving'
|
|
514
|
+
% (len(lagging), max(lagging.values())))
|
|
515
|
+
if unsent_now:
|
|
516
|
+
out.append('%d receipt(s) no relay would take at all' % len(unsent_now))
|
|
517
|
+
if gaps_now:
|
|
518
|
+
out.append('%d receipt(s) never arrived and the chain carried on past them: the wire is '
|
|
519
|
+
'dropping claims, and a window missing its tail still prices as an under-claim'
|
|
520
|
+
% len(gaps_now))
|
|
521
|
+
return out
|
|
522
|
+
|
|
523
|
+
def stop(self) -> list[tuple[Any, ...]]:
|
|
524
|
+
"""Say what this node used before it goes. Never raises."""
|
|
525
|
+
try:
|
|
526
|
+
return self.farewell()
|
|
527
|
+
except Exception as e:
|
|
528
|
+
self.refusals.refuse('farewell', 'could not be sent', detail={'error': str(e)})
|
|
529
|
+
return []
|
|
530
|
+
|
|
531
|
+
def run(self, stop: threading.Event, interval: float = 5.0) -> None:
|
|
532
|
+
while not stop.is_set():
|
|
533
|
+
try:
|
|
534
|
+
self.tick()
|
|
535
|
+
except Exception as e: # the loop must outlive one bad tick
|
|
536
|
+
self.findings.append(('self', None, None, 'tick failed: %s' % e))
|
|
537
|
+
stop.wait(interval)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""The record one closed window leaves behind.
|
|
2
|
+
|
|
3
|
+
`Budget.evaluate` builds one per (peer, epoch) and appends it to
|
|
4
|
+
`budget.evaluations`; the reporter reads it to decide whether a finding
|
|
5
|
+
opens, the event bus wires a six-key slice of it, `status()` shows the tail,
|
|
6
|
+
and tests reach in for severity and detail. Nothing is stored whole: the
|
|
7
|
+
AccountStore keeps (peer, epoch, severity, tier) and the rest is rebuilt on
|
|
8
|
+
the next window.
|
|
9
|
+
|
|
10
|
+
A window that neither moved nor claimed anything is `not-evaluated`: it
|
|
11
|
+
has no severity, no detail, and no remedy. Those fields are Optional for
|
|
12
|
+
that reason only - an evaluated window has all of them.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import asdict, dataclass, field
|
|
18
|
+
from typing import Any, Mapping, Optional, cast
|
|
19
|
+
from fleetmesh_core.wire.interfaces import ( # noqa: E402
|
|
20
|
+
ReceiptBody,
|
|
21
|
+
SilenceEvaluation,
|
|
22
|
+
WindowEvaluationDetail,
|
|
23
|
+
)
|
|
24
|
+
from .types import EvaluationDict, RepricingDict
|
|
25
|
+
|
|
26
|
+
__all__ = ['Evaluation', 'Repricing', 'evaluation_like']
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(slots=True)
|
|
30
|
+
class Evaluation:
|
|
31
|
+
peer: str
|
|
32
|
+
epoch: int
|
|
33
|
+
state: str # fleetmesh_core.accounting.window_claim: claim / active-silence / ...
|
|
34
|
+
measured: dict[str, int] # what this node counted, by metric
|
|
35
|
+
claimed: Optional[dict[str, int]] # what the subject's receipt said, if one came
|
|
36
|
+
tier_before: str
|
|
37
|
+
grant: str # envelope id of the grant that governed the window
|
|
38
|
+
limits: Mapping[str, int | float] # the ceilings it was priced against (fleetmesh_core.accounting.window's limit set)
|
|
39
|
+
severity: Optional[str] = None # None only when state == 'not-evaluated'
|
|
40
|
+
note: str = '' # fleetmesh_core.accounting.window.adjust's one-line reason
|
|
41
|
+
detail: dict[str, WindowEvaluationDetail | SilenceEvaluation] = field(default_factory=dict) # by metric, plus 'silence'
|
|
42
|
+
tier_after: Optional[str] = None
|
|
43
|
+
limits_after: Optional[Mapping[str, int | float]] = None
|
|
44
|
+
effective_from: Optional[int] = None
|
|
45
|
+
receipt_heads: list[str] = field(default_factory=list)
|
|
46
|
+
receipts: list[ReceiptBody] = field(default_factory=list) # the bodies as received, which are the evidence
|
|
47
|
+
|
|
48
|
+
def to_dict(self) -> EvaluationDict:
|
|
49
|
+
return cast(EvaluationDict, asdict(self))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(slots=True)
|
|
53
|
+
class Repricing:
|
|
54
|
+
"""A window this node had priced, read again after its receipt arrived late."""
|
|
55
|
+
peer: str
|
|
56
|
+
epoch: int
|
|
57
|
+
was: str
|
|
58
|
+
severity: str
|
|
59
|
+
state: str
|
|
60
|
+
measured: dict[str, int]
|
|
61
|
+
claimed: Optional[dict[str, int]]
|
|
62
|
+
receipts: int
|
|
63
|
+
remedy_cancelled: bool
|
|
64
|
+
at: int
|
|
65
|
+
|
|
66
|
+
def to_dict(self) -> RepricingDict:
|
|
67
|
+
return cast(RepricingDict, asdict(self))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def evaluation_like(rec: Evaluation | Mapping[str, Any]) -> Evaluation:
|
|
71
|
+
"""The reporter is handed Evaluations by the budget and plain mappings by
|
|
72
|
+
tests and plugins. Both are one shape."""
|
|
73
|
+
if isinstance(rec, Evaluation):
|
|
74
|
+
return rec
|
|
75
|
+
known = {f for f in Evaluation.__dataclass_fields__}
|
|
76
|
+
body = {k: v for k, v in rec.items() if k in known}
|
|
77
|
+
for k, default in (('state', ''), ('measured', {}), ('claimed', None), ('tier_before', ''),
|
|
78
|
+
('grant', ''), ('limits', {})):
|
|
79
|
+
body.setdefault(k, default)
|
|
80
|
+
return Evaluation(**body)
|