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,460 @@
|
|
|
1
|
+
"""How a compartment lives: built, wired, running, degraded, quarantined, retired.
|
|
2
|
+
|
|
3
|
+
The fifth thing a compartment declares, beside PARAMS (what it can be told),
|
|
4
|
+
CHANNELS (what it says), METRICS (what it measures) and COMMANDS (what it can
|
|
5
|
+
be asked to do): LIFECYCLE, how it behaves when it fails.
|
|
6
|
+
|
|
7
|
+
Before this a compartment had no state. It was built or it was not, and once
|
|
8
|
+
built it was ticked forever. A tick that raised was recorded as a refusal and
|
|
9
|
+
then raised again on the next tick, and the one after, for as long as the node
|
|
10
|
+
ran - a broken compartment cost a traceback per cycle, filled its refusal
|
|
11
|
+
ring with one reason, and could not tell an operator anything they did not
|
|
12
|
+
already know after the first one. Nothing distinguished "failed once and
|
|
13
|
+
recovered" from "has been failing for a day".
|
|
14
|
+
|
|
15
|
+
So the Registry now carries one `Tracker` per compartment and moves it through
|
|
16
|
+
|
|
17
|
+
CONFIGURED -> WIRED -> RUNNING <-> DEGRADED -> QUARANTINED -> RUNNING
|
|
18
|
+
| ^ (breaker) (trial / resume)
|
|
19
|
+
| |
|
|
20
|
+
v | restart (only when declared)
|
|
21
|
+
DRAINING
|
|
22
|
+
|
|
|
23
|
+
v
|
|
24
|
+
RETIRED start() raised: -> FAILED, or
|
|
25
|
+
-> QUARANTINED when restartable
|
|
26
|
+
|
|
27
|
+
and each move is a declared channel, so the journal, the control socket and
|
|
28
|
+
the metrics all see the same transition.
|
|
29
|
+
|
|
30
|
+
CONFIGURED built from its config block; not yet reaching its neighbours
|
|
31
|
+
WIRED back-edges reached (`wire()` ran)
|
|
32
|
+
RUNNING started and ticking cleanly
|
|
33
|
+
DEGRADED the last tick raised; still ticked, and back to RUNNING after
|
|
34
|
+
one clean tick. A note, not trouble: one failure is weather.
|
|
35
|
+
QUARANTINED `Breaker.failures` ticks raised within `Breaker.window`
|
|
36
|
+
seconds. Not ticked until the backoff passes, then given one
|
|
37
|
+
trial tick: clean returns it to RUNNING, a raise sends it back
|
|
38
|
+
with the backoff doubled (to `Breaker.backoff[1]` at most).
|
|
39
|
+
Trouble, because something is wrong now. `resume` lets an
|
|
40
|
+
operator end it early.
|
|
41
|
+
FAILED an optional compartment's start() raised and it does not
|
|
42
|
+
declare `restart`, so nothing here can safely try again. Not
|
|
43
|
+
ticked, not stopped. Trouble. (A required compartment whose
|
|
44
|
+
start() raises still stops the node - see Registry.start.)
|
|
45
|
+
DRAINING being stopped - for good, or for a restart
|
|
46
|
+
RETIRED stopped; never ticked again
|
|
47
|
+
|
|
48
|
+
`Lifecycle(restart=True)` is a promise the compartment makes: its stop() and
|
|
49
|
+
then start() on the SAME instance leave it as good as new. Only a compartment
|
|
50
|
+
that makes it is restarted - by the `restart` control command, by a config
|
|
51
|
+
reload that changes its own block, by a quarantine's trial (which restarts it
|
|
52
|
+
before the trial tick), and after a start() that raised (which quarantines it
|
|
53
|
+
rather than failing it). The default is False because most compartments were
|
|
54
|
+
written to start once: a restart nobody designed for would read as resilience
|
|
55
|
+
the node does not have.
|
|
56
|
+
|
|
57
|
+
`Lifecycle(snapshot=N)` is the other promise, and it is about the PROCESS
|
|
58
|
+
restarting rather than one compartment: a core upgrade, a digest change, a
|
|
59
|
+
`systemctl restart`. The compartment implements `snapshot()` - a JSON-able
|
|
60
|
+
mapping of the state worth carrying - and `restore(state)`, and N is the
|
|
61
|
+
version of that mapping's shape. On a clean stop the node writes every
|
|
62
|
+
started compartment's snapshot to SNAPSHOT_FILE; the next start hands each one
|
|
63
|
+
back BEFORE start() runs, but only when the version still matches, the file
|
|
64
|
+
is recent (SNAPSHOT_MAX_AGE), and restore() returns. Anything else is a
|
|
65
|
+
refusal recorded in the compartment's lifecycle (`restored`), and it starts
|
|
66
|
+
fresh - which is what it did before snapshots existed, so a refused restore
|
|
67
|
+
costs continuity and nothing else. The file is removed once a start
|
|
68
|
+
succeeds: a snapshot is handed over once.
|
|
69
|
+
|
|
70
|
+
restore() must be all-or-nothing: validate first, then assign. A raise is
|
|
71
|
+
caught, but a half-applied restore is not something the node can see.
|
|
72
|
+
|
|
73
|
+
A package rather than a root module for the reason node/channels is one:
|
|
74
|
+
node/*.py is held to a monotone baseline (fleetmesh.org/scripts/check-root.py).
|
|
75
|
+
Standard library only, beside the two surfaces it emits through.
|
|
76
|
+
"""
|
|
77
|
+
from __future__ import annotations
|
|
78
|
+
|
|
79
|
+
import collections
|
|
80
|
+
import dataclasses
|
|
81
|
+
import json
|
|
82
|
+
import os
|
|
83
|
+
from typing import Any, Callable, Deque, Mapping, NamedTuple, Optional, TypedDict
|
|
84
|
+
|
|
85
|
+
from ..channels import NOTE, TROUBLE, Channel
|
|
86
|
+
from ..metrics import COUNTER, Metric
|
|
87
|
+
|
|
88
|
+
# ---- the states ---------------------------------------------------------------
|
|
89
|
+
CONFIGURED = 'configured'
|
|
90
|
+
WIRED = 'wired'
|
|
91
|
+
RUNNING = 'running'
|
|
92
|
+
DEGRADED = 'degraded'
|
|
93
|
+
QUARANTINED = 'quarantined'
|
|
94
|
+
FAILED = 'failed'
|
|
95
|
+
DRAINING = 'draining'
|
|
96
|
+
RETIRED = 'retired'
|
|
97
|
+
STATES: tuple[str, ...] = (CONFIGURED, WIRED, RUNNING, DEGRADED, QUARANTINED, FAILED, DRAINING, RETIRED)
|
|
98
|
+
|
|
99
|
+
# The states in which a compartment is ticked on the ordinary cadence. A
|
|
100
|
+
# quarantined one is ticked only for its trial; a retired one never.
|
|
101
|
+
TICKED: frozenset[str] = frozenset({CONFIGURED, WIRED, RUNNING, DEGRADED})
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclasses.dataclass(frozen=True)
|
|
105
|
+
class Breaker:
|
|
106
|
+
"""When repeated failure stops being weather and becomes a fault.
|
|
107
|
+
|
|
108
|
+
`failures` ticks that raise within `window` seconds trip it. `backoff` is
|
|
109
|
+
(first, ceiling) in seconds: the first quarantine lasts `first`, each
|
|
110
|
+
failed trial doubles it, and it never exceeds `ceiling`. `failures=0`
|
|
111
|
+
turns the breaker off for a compartment that must be ticked whatever
|
|
112
|
+
happens - the old behaviour, chosen rather than defaulted into.
|
|
113
|
+
"""
|
|
114
|
+
failures: int = 5
|
|
115
|
+
window: int = 300
|
|
116
|
+
backoff: tuple[int, int] = (30, 600)
|
|
117
|
+
|
|
118
|
+
def delay(self, trips: int) -> int:
|
|
119
|
+
"""Seconds a compartment stays quarantined on its `trips`-th trip."""
|
|
120
|
+
first, ceiling = self.backoff
|
|
121
|
+
return int(min(first * (2 ** max(trips - 1, 0)), ceiling))
|
|
122
|
+
|
|
123
|
+
def problems(self) -> list[str]:
|
|
124
|
+
out: list[str] = []
|
|
125
|
+
if self.failures < 0:
|
|
126
|
+
out.append('breaker failures must be 0 (off) or more')
|
|
127
|
+
if self.window <= 0:
|
|
128
|
+
out.append('breaker window must be positive')
|
|
129
|
+
first, ceiling = self.backoff
|
|
130
|
+
if first <= 0 or ceiling < first:
|
|
131
|
+
out.append('breaker backoff is (first, ceiling) with 0 < first <= ceiling')
|
|
132
|
+
return out
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclasses.dataclass(frozen=True)
|
|
136
|
+
class Lifecycle:
|
|
137
|
+
"""How a compartment behaves when it fails. The default suits nearly all.
|
|
138
|
+
|
|
139
|
+
`restart`: stop() then start() on the same instance is safe and leaves it
|
|
140
|
+
as good as new. `snapshot`: the version of what snapshot() returns and
|
|
141
|
+
restore() reads; 0 carries nothing across a process restart. See the
|
|
142
|
+
module docstring for everything that acts on either.
|
|
143
|
+
"""
|
|
144
|
+
breaker: Breaker = Breaker()
|
|
145
|
+
restart: bool = False
|
|
146
|
+
snapshot: int = 0
|
|
147
|
+
|
|
148
|
+
def problems(self) -> list[str]:
|
|
149
|
+
out = self.breaker.problems()
|
|
150
|
+
if self.snapshot < 0:
|
|
151
|
+
out.append('snapshot is a version: 0 (none) or more')
|
|
152
|
+
return out
|
|
153
|
+
|
|
154
|
+
def describe(self) -> 'LifecycleDecl':
|
|
155
|
+
return {'breaker': {'failures': self.breaker.failures, 'window': self.breaker.window,
|
|
156
|
+
'backoff': list(self.breaker.backoff)},
|
|
157
|
+
'restart': self.restart, 'snapshot': self.snapshot}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
DEFAULT: Lifecycle = Lifecycle()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class BreakerDecl(TypedDict):
|
|
164
|
+
failures: int
|
|
165
|
+
window: int
|
|
166
|
+
backoff: list[int]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class LifecycleDecl(TypedDict):
|
|
170
|
+
breaker: BreakerDecl
|
|
171
|
+
restart: bool
|
|
172
|
+
snapshot: int
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class LifecycleRecord(TypedDict):
|
|
176
|
+
"""One compartment's lifecycle as `meshnode lifecycle` and health read it."""
|
|
177
|
+
state: str
|
|
178
|
+
since: int
|
|
179
|
+
failures: int # ticks that raised inside the breaker's window
|
|
180
|
+
trips: int # times the breaker has tripped since the last clean run
|
|
181
|
+
until: int # quarantined until (0 when not quarantined)
|
|
182
|
+
last_error: str
|
|
183
|
+
started: Optional[bool] # None: start() not run yet; False: it raised; True: it returned
|
|
184
|
+
restarts: int
|
|
185
|
+
restored: str # '' nothing offered; 'restored (vN, from <at>)'; or why it was refused
|
|
186
|
+
transitions: dict[str, int]
|
|
187
|
+
declared: LifecycleDecl
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class Transition(NamedTuple):
|
|
191
|
+
compartment: str
|
|
192
|
+
was: str
|
|
193
|
+
now: str
|
|
194
|
+
at: int
|
|
195
|
+
why: str
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class Tracker:
|
|
199
|
+
"""One compartment's place in the lifecycle. Owned by the Registry.
|
|
200
|
+
|
|
201
|
+
Not thread-safe on its own: the Registry ticks from one thread and the
|
|
202
|
+
control socket's `resume` goes through the same lock the tick holds.
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
def __init__(self, name: str, lifecycle: Lifecycle, at: int = 0) -> None:
|
|
206
|
+
self.name: str = name
|
|
207
|
+
self.lifecycle: Lifecycle = lifecycle
|
|
208
|
+
self.state: str = CONFIGURED
|
|
209
|
+
self.since: int = at
|
|
210
|
+
self.fails: Deque[int] = collections.deque()
|
|
211
|
+
self.trips: int = 0
|
|
212
|
+
self.until: int = 0
|
|
213
|
+
self.last_error: str = ''
|
|
214
|
+
# Whether start() has returned. Registry.stop() skips a compartment
|
|
215
|
+
# whose start() raised: a stop() for something that never began is how
|
|
216
|
+
# a node that never served sent its peers a farewell (audit L1).
|
|
217
|
+
self.started: Optional[bool] = None
|
|
218
|
+
self.restarts: int = 0
|
|
219
|
+
# What happened to the snapshot the last process left for it.
|
|
220
|
+
self.restored: str = ''
|
|
221
|
+
self.transitions: collections.Counter[str] = collections.Counter()
|
|
222
|
+
|
|
223
|
+
def move(self, to: str, at: int, why: str = '') -> Optional[Transition]:
|
|
224
|
+
"""Go to `to`. Returns the transition, or None when already there."""
|
|
225
|
+
if to == self.state:
|
|
226
|
+
return None
|
|
227
|
+
t = Transition(self.name, self.state, to, at, why)
|
|
228
|
+
self.state, self.since = to, at
|
|
229
|
+
self.transitions[to] += 1
|
|
230
|
+
return t
|
|
231
|
+
|
|
232
|
+
def due(self, now: int) -> bool:
|
|
233
|
+
"""Whether the Registry should tick this compartment now."""
|
|
234
|
+
if self.state in TICKED:
|
|
235
|
+
return True
|
|
236
|
+
return self.state == QUARANTINED and now >= self.until
|
|
237
|
+
|
|
238
|
+
def succeeded(self, now: int) -> Optional[Transition]:
|
|
239
|
+
"""A clean tick. Clears the failure record and recovers if it was down."""
|
|
240
|
+
self.fails.clear()
|
|
241
|
+
if self.state in (DEGRADED, QUARANTINED):
|
|
242
|
+
self.trips, self.until = 0, 0
|
|
243
|
+
return self.move(RUNNING, now, 'a clean tick')
|
|
244
|
+
return None
|
|
245
|
+
|
|
246
|
+
def failed(self, now: int, error: str) -> Optional[Transition]:
|
|
247
|
+
"""A tick that raised. Degrades, or trips the breaker."""
|
|
248
|
+
b = self.lifecycle.breaker
|
|
249
|
+
self.last_error = error[:200]
|
|
250
|
+
trial = self.state == QUARANTINED
|
|
251
|
+
self.fails.append(now)
|
|
252
|
+
while self.fails and self.fails[0] <= now - b.window:
|
|
253
|
+
self.fails.popleft()
|
|
254
|
+
if b.failures and (trial or len(self.fails) >= b.failures):
|
|
255
|
+
self.trips += 1
|
|
256
|
+
self.until = now + b.delay(self.trips)
|
|
257
|
+
why = ('the trial tick raised: %s' % self.last_error if trial else
|
|
258
|
+
'%d ticks raised within %ds: %s' % (len(self.fails), b.window, self.last_error))
|
|
259
|
+
if trial:
|
|
260
|
+
# Still quarantined; a new transition record keeps the
|
|
261
|
+
# operator's count honest about how many trials failed.
|
|
262
|
+
self.transitions[QUARANTINED] += 1
|
|
263
|
+
return Transition(self.name, QUARANTINED, QUARANTINED, now, why)
|
|
264
|
+
return self.move(QUARANTINED, now, why)
|
|
265
|
+
return self.move(DEGRADED, now, self.last_error)
|
|
266
|
+
|
|
267
|
+
def resume(self, now: int, why: str = 'resumed by the operator') -> Optional[Transition]:
|
|
268
|
+
"""End a quarantine now. The next tick is an ordinary one."""
|
|
269
|
+
if self.state != QUARANTINED:
|
|
270
|
+
return None
|
|
271
|
+
self.fails.clear()
|
|
272
|
+
self.trips, self.until = 0, 0
|
|
273
|
+
return self.move(RUNNING, now, why)
|
|
274
|
+
|
|
275
|
+
def start_failed(self, now: int, error: str) -> Optional[Transition]:
|
|
276
|
+
"""start() raised. Quarantined when a restart can try again, else FAILED.
|
|
277
|
+
|
|
278
|
+
From QUARANTINED (a trial restart that raised) this is a failed trial:
|
|
279
|
+
the breaker's `failed` already doubles the backoff and says so.
|
|
280
|
+
"""
|
|
281
|
+
self.started = False
|
|
282
|
+
self.last_error = error[:200]
|
|
283
|
+
if self.state == QUARANTINED:
|
|
284
|
+
return self.failed(now, error)
|
|
285
|
+
if not self.lifecycle.restart:
|
|
286
|
+
return self.move(FAILED, now, 'start raised: %s' % self.last_error)
|
|
287
|
+
self.trips += 1
|
|
288
|
+
self.until = now + self.lifecycle.breaker.delay(self.trips)
|
|
289
|
+
return self.move(QUARANTINED, now, 'start raised: %s' % self.last_error)
|
|
290
|
+
|
|
291
|
+
def restarted(self, now: int, why: str) -> Optional[Transition]:
|
|
292
|
+
"""stop() and start() both returned: as good as new, so a clean record."""
|
|
293
|
+
self.started = True
|
|
294
|
+
self.restarts += 1
|
|
295
|
+
self.fails.clear()
|
|
296
|
+
self.trips, self.until, self.last_error = 0, 0, ''
|
|
297
|
+
return self.move(RUNNING, now, 'restarted: %s' % why)
|
|
298
|
+
|
|
299
|
+
def describe(self) -> LifecycleRecord:
|
|
300
|
+
return {'state': self.state, 'since': self.since, 'failures': len(self.fails),
|
|
301
|
+
'trips': self.trips, 'until': self.until, 'last_error': self.last_error,
|
|
302
|
+
'started': self.started, 'restarts': self.restarts, 'restored': self.restored,
|
|
303
|
+
'transitions': dict(self.transitions),
|
|
304
|
+
'declared': self.lifecycle.describe()}
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# ---- carrying state across a process restart ------------------------------------
|
|
308
|
+
SNAPSHOT_FILE = 'snapshots.json'
|
|
309
|
+
SNAPSHOT_FORMAT = 1
|
|
310
|
+
# A snapshot carries a compartment across a restart - an upgrade, a reload -
|
|
311
|
+
# which takes seconds. One an hour old is from a node that was down, and the
|
|
312
|
+
# state in it describes a world that has moved on (peers learned, links held)
|
|
313
|
+
# while nobody was counting. Starting fresh is the honest answer then.
|
|
314
|
+
SNAPSHOT_MAX_AGE = 3600
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
class SnapshotEntry(TypedDict):
|
|
318
|
+
version: int
|
|
319
|
+
state: Mapping[str, Any]
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class SnapshotFile(TypedDict):
|
|
323
|
+
format: int
|
|
324
|
+
at: int
|
|
325
|
+
compartments: dict[str, SnapshotEntry]
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def write_snapshots(directory: str, at: int, compartments: Mapping[str, SnapshotEntry]) -> str:
|
|
329
|
+
"""Write the snapshot file atomically. Returns its path."""
|
|
330
|
+
path = os.path.join(directory, SNAPSHOT_FILE)
|
|
331
|
+
body: SnapshotFile = {'format': SNAPSHOT_FORMAT, 'at': at, 'compartments': dict(compartments)}
|
|
332
|
+
tmp = path + '.tmp'
|
|
333
|
+
with open(tmp, 'w', encoding='utf-8') as f:
|
|
334
|
+
json.dump(body, f, indent=1, sort_keys=True)
|
|
335
|
+
os.replace(tmp, path)
|
|
336
|
+
return path
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def read_snapshots(directory: str, now: int) -> tuple[dict[str, SnapshotEntry], int, str]:
|
|
340
|
+
"""(compartments, written at, why none) from the snapshot file.
|
|
341
|
+
|
|
342
|
+
An empty mapping with a reason when there is a file and it cannot be
|
|
343
|
+
used - unreadable, another format, or older than SNAPSHOT_MAX_AGE - and
|
|
344
|
+
with '' when there is simply no file. Never raises: a node must start
|
|
345
|
+
whatever the last one left behind.
|
|
346
|
+
"""
|
|
347
|
+
path = os.path.join(directory, SNAPSHOT_FILE)
|
|
348
|
+
if not os.path.exists(path):
|
|
349
|
+
return {}, 0, ''
|
|
350
|
+
try:
|
|
351
|
+
with open(path, encoding='utf-8') as f:
|
|
352
|
+
body = json.load(f)
|
|
353
|
+
except (OSError, ValueError) as e:
|
|
354
|
+
return {}, 0, 'the snapshot file is unreadable (%s)' % type(e).__name__
|
|
355
|
+
if not isinstance(body, dict) or body.get('format') != SNAPSHOT_FORMAT:
|
|
356
|
+
return {}, 0, 'the snapshot file is not format %d' % SNAPSHOT_FORMAT
|
|
357
|
+
at = body.get('at')
|
|
358
|
+
comps = body.get('compartments')
|
|
359
|
+
if not isinstance(at, int) or not isinstance(comps, dict):
|
|
360
|
+
return {}, 0, 'the snapshot file is malformed'
|
|
361
|
+
if now - at > SNAPSHOT_MAX_AGE:
|
|
362
|
+
return {}, at, 'the snapshot is %ds old (more than %ds): starting fresh' % (now - at, SNAPSHOT_MAX_AGE)
|
|
363
|
+
out: dict[str, SnapshotEntry] = {}
|
|
364
|
+
for name, entry in comps.items():
|
|
365
|
+
if isinstance(entry, dict) and isinstance(entry.get('version'), int) and isinstance(entry.get('state'), dict):
|
|
366
|
+
out[str(name)] = {'version': entry['version'], 'state': entry['state']}
|
|
367
|
+
return out, at, ''
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def discard_snapshots(directory: str) -> None:
|
|
371
|
+
"""A snapshot is handed over once: gone after the start that used it."""
|
|
372
|
+
try:
|
|
373
|
+
os.unlink(os.path.join(directory, SNAPSHOT_FILE))
|
|
374
|
+
except FileNotFoundError:
|
|
375
|
+
pass
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# ---- what the lifecycle says, and counts ---------------------------------------
|
|
379
|
+
# Declared here, beside the states, rather than on each compartment: every
|
|
380
|
+
# compartment moves through the same machine, so its transitions are the
|
|
381
|
+
# Registry's to say. The payload names the compartment, so one channel per
|
|
382
|
+
# kind of move is enough and a reader filters by field.
|
|
383
|
+
def _payload(t: Transition) -> Mapping[str, Any]:
|
|
384
|
+
return {'compartment': t.compartment, 'was': t.was, 'now': t.now, 'why': t.why}
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
CHANGED = 'lifecycle.changed'
|
|
388
|
+
DEGRADED_CH = 'lifecycle.degraded'
|
|
389
|
+
QUARANTINED_CH = 'lifecycle.quarantined'
|
|
390
|
+
RECOVERED_CH = 'lifecycle.recovered'
|
|
391
|
+
RESUMED_CH = 'lifecycle.resumed'
|
|
392
|
+
RESTARTED_CH = 'lifecycle.restarted'
|
|
393
|
+
FAILED_CH = 'lifecycle.failed'
|
|
394
|
+
|
|
395
|
+
CHANNELS: tuple[Channel, ...] = (
|
|
396
|
+
Channel(name=CHANGED, description='A compartment moved through its lifecycle '
|
|
397
|
+
'(configured, wired, running, draining, retired).',
|
|
398
|
+
wire=_payload, say='{compartment} {was} -> {now}', tags=('lifecycle',)),
|
|
399
|
+
Channel(name=DEGRADED_CH, description='A compartment tick raised; it is still ticked and '
|
|
400
|
+
'recovers after one clean tick.',
|
|
401
|
+
level=NOTE, wire=_payload, say='{compartment} degraded: {why}', tags=('lifecycle',)),
|
|
402
|
+
Channel(name=QUARANTINED_CH, description='A compartment tripped its breaker and is not ticked '
|
|
403
|
+
'until its backoff passes.',
|
|
404
|
+
level=TROUBLE, wire=_payload, say='{compartment} quarantined: {why}', tags=('lifecycle',)),
|
|
405
|
+
Channel(name=RECOVERED_CH, description='A degraded or quarantined compartment ticked cleanly again.',
|
|
406
|
+
wire=_payload, say='{compartment} recovered ({was} -> {now})', tags=('lifecycle',)),
|
|
407
|
+
Channel(name=RESUMED_CH, description='An operator ended a quarantine early.',
|
|
408
|
+
wire=_payload, say='{compartment} resumed: {why}', tags=('lifecycle',)),
|
|
409
|
+
Channel(name=RESTARTED_CH, description='A compartment that declares Lifecycle(restart=True) was '
|
|
410
|
+
'stopped and started again in place.',
|
|
411
|
+
wire=_payload, say='{compartment} {why}', tags=('lifecycle',)),
|
|
412
|
+
Channel(name=FAILED_CH, description='An optional compartment\'s start() raised and it cannot be '
|
|
413
|
+
'restarted in place; it is not ticked until the node restarts.',
|
|
414
|
+
level=TROUBLE, wire=_payload, say='{compartment} failed: {why}', tags=('lifecycle',)),
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
METRICS: tuple[Metric, ...] = (
|
|
418
|
+
Metric(name='lifecycle_degraded', description='Times a compartment went from running to degraded. '
|
|
419
|
+
'Every raise is in its refusal ring as tick: raised.',
|
|
420
|
+
kind=COUNTER, counts=DEGRADED_CH, tags=('lifecycle',)),
|
|
421
|
+
Metric(name='lifecycle_quarantined', description='Times a compartment breaker tripped, '
|
|
422
|
+
'including failed trial ticks.',
|
|
423
|
+
kind=COUNTER, counts=QUARANTINED_CH, tags=('lifecycle',)),
|
|
424
|
+
Metric(name='lifecycle_recovered', description='Times a compartment recovered to running.',
|
|
425
|
+
kind=COUNTER, counts=RECOVERED_CH, tags=('lifecycle',)),
|
|
426
|
+
Metric(name='lifecycle_restarted', description='Times a compartment was restarted in place.',
|
|
427
|
+
kind=COUNTER, counts=RESTARTED_CH, tags=('lifecycle',)),
|
|
428
|
+
Metric(name='lifecycle_failed', description='Times an optional compartment failed to start.',
|
|
429
|
+
kind=COUNTER, counts=FAILED_CH, tags=('lifecycle',)),
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def channel_for(t: Transition) -> str:
|
|
434
|
+
"""Which declared channel a transition is said on."""
|
|
435
|
+
if t.now == QUARANTINED:
|
|
436
|
+
return QUARANTINED_CH
|
|
437
|
+
if t.now == DEGRADED:
|
|
438
|
+
return DEGRADED_CH
|
|
439
|
+
if t.now == FAILED:
|
|
440
|
+
return FAILED_CH
|
|
441
|
+
if t.now == RUNNING and t.why.startswith('restarted'):
|
|
442
|
+
return RESTARTED_CH
|
|
443
|
+
if t.now == RUNNING and t.was in (DEGRADED, QUARANTINED):
|
|
444
|
+
return RESUMED_CH if t.why.startswith('resumed') else RECOVERED_CH
|
|
445
|
+
return CHANGED
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def gauge(count: Callable[[], int], name: str, description: str) -> Metric:
|
|
449
|
+
"""A sampled gauge over the Registry's trackers, built where they live."""
|
|
450
|
+
return Metric(name=name, description=description, sample=count, tags=('lifecycle',))
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
__all__ = [
|
|
454
|
+
'BreakerDecl', 'Breaker', 'CHANGED', 'CHANNELS', 'CONFIGURED', 'DEFAULT', 'DEGRADED', 'DEGRADED_CH',
|
|
455
|
+
'DRAINING', 'FAILED', 'FAILED_CH', 'Lifecycle', 'LifecycleDecl', 'LifecycleRecord', 'METRICS',
|
|
456
|
+
'QUARANTINED', 'QUARANTINED_CH', 'RECOVERED_CH', 'RESTARTED_CH', 'RESUMED_CH', 'RETIRED', 'RUNNING',
|
|
457
|
+
'SNAPSHOT_FILE', 'SNAPSHOT_FORMAT', 'SNAPSHOT_MAX_AGE', 'STATES', 'SnapshotEntry', 'SnapshotFile',
|
|
458
|
+
'TICKED', 'Tracker', 'Transition', 'WIRED', 'channel_for', 'discard_snapshots', 'gauge',
|
|
459
|
+
'read_snapshots', 'write_snapshots',
|
|
460
|
+
]
|