stack-guardian 0.1.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.
@@ -0,0 +1,152 @@
1
+ # Multi-node snapshot pull: every node at once, one overall deadline.
2
+ """Collect health data from several machines at once, without letting one hung
3
+ node stall the whole check.
4
+
5
+ Every node is handed to ``collect_one(name, cfg)`` in its own thread at the same
6
+ instant, and a single overall deadline is applied to all of them. Whatever has
7
+ not answered by the deadline is reported as down instead of being waited on, so
8
+ a run always returns within ``timeout_s`` plus a little slack.
9
+ """
10
+
11
+ import concurrent.futures
12
+ import json
13
+ import sys
14
+ import time
15
+ from datetime import datetime, timezone
16
+
17
+
18
+ def _down(error):
19
+ return {"reachable": False, "error": error, "ollama": []}
20
+
21
+
22
+ def pull(nodes, collect_one, timeout_s=5.0, now=None):
23
+ """Pull ``nodes`` (name -> cfg, cfg may be None) concurrently.
24
+
25
+ Returns ``{"schema": 1, "taken_at": ..., "nodes": {...}}`` with the node
26
+ results in the same order as the input dict. A node that raised, returned a
27
+ non-dict or did not answer before the deadline is reported as down.
28
+ """
29
+ items = list((nodes or {}).items())
30
+ result = {}
31
+ if items:
32
+ executor = concurrent.futures.ThreadPoolExecutor(max_workers=len(items))
33
+ try:
34
+ futures = [
35
+ (name, executor.submit(collect_one, name, cfg if cfg is not None else {}))
36
+ for name, cfg in items
37
+ ]
38
+ concurrent.futures.wait([f for _, f in futures], timeout=timeout_s)
39
+ for name, future in futures:
40
+ if not future.done():
41
+ result[name] = _down("timeout after %ss" % timeout_s)
42
+ continue
43
+ try:
44
+ value = future.result()
45
+ except Exception as exc: # noqa: BLE001
46
+ result[name] = _down("%s: %s" % (type(exc).__name__, exc))
47
+ continue
48
+ if isinstance(value, dict):
49
+ result[name] = value
50
+ else:
51
+ result[name] = _down("collector returned %s" % type(value).__name__)
52
+ finally:
53
+ executor.shutdown(wait=False, cancel_futures=True)
54
+ return {
55
+ "schema": 1,
56
+ "taken_at": now or datetime.now(timezone.utc).isoformat(timespec="seconds"),
57
+ "nodes": result,
58
+ }
59
+
60
+
61
+ def elapsed_ok(started, finished, timeout_s, slack_s=1.0):
62
+ """True when a run finished within ``timeout_s`` plus ``slack_s``."""
63
+ return (finished - started) <= timeout_s + slack_s
64
+
65
+
66
+ def main(argv=None):
67
+ argv = sys.argv[1:] if argv is None else argv
68
+ if "--selftest" in argv:
69
+ return _selftest()
70
+ print(json.dumps({"usage": "multinode.py --selftest (library: pull(nodes, collect_one, timeout_s))"}))
71
+ return 2
72
+
73
+
74
+ def _selftest():
75
+ import threading
76
+ release = threading.Event()
77
+
78
+ def fake(name, cfg):
79
+ kind = cfg.get("kind")
80
+ if kind == "hang":
81
+ release.wait(30)
82
+ return {"reachable": True}
83
+ if kind == "slow":
84
+ time.sleep(0.3)
85
+ return {"reachable": True, "who": name}
86
+ if kind == "boom":
87
+ raise ConnectionError("refused")
88
+ if kind == "odd":
89
+ return ["not", "a", "dict"]
90
+ return {"reachable": True, "who": name, "cfg": cfg}
91
+
92
+ def c_order_and_shape():
93
+ r = pull({"b": {}, "a": {}}, fake, timeout_s=2, now="T")
94
+ return r["schema"] == 1 and r["taken_at"] == "T" and list(r["nodes"]) == ["b", "a"] and r["nodes"]["a"]["who"] == "a"
95
+
96
+ def c_cfg_passed_and_none_cfg():
97
+ r = pull({"a": {"x": 1}, "b": None}, fake, timeout_s=2, now="T")
98
+ return r["nodes"]["a"]["cfg"] == {"x": 1} and r["nodes"]["b"]["cfg"] == {}
99
+
100
+ def c_raise_becomes_down():
101
+ r = pull({"d": {"kind": "boom"}}, fake, timeout_s=2, now="T")
102
+ return r["nodes"]["d"] == {"reachable": False, "error": "ConnectionError: refused", "ollama": []}
103
+
104
+ def c_non_dict_becomes_down():
105
+ return pull({"o": {"kind": "odd"}}, fake, timeout_s=2, now="T")["nodes"]["o"]["error"] == "collector returned list"
106
+
107
+ def c_hang_times_out_fast():
108
+ t0 = time.monotonic()
109
+ r = pull({"h": {"kind": "hang"}, "ok": {}}, fake, timeout_s=0.5, now="T")
110
+ took = time.monotonic() - t0
111
+ release.set()
112
+ return (r["nodes"]["h"] == {"reachable": False, "error": "timeout after 0.5s", "ollama": []}
113
+ and r["nodes"]["ok"]["reachable"] is True and took < 1.5)
114
+
115
+ def c_concurrent_not_serial():
116
+ t0 = time.monotonic()
117
+ r = pull({"s1": {"kind": "slow"}, "s2": {"kind": "slow"}, "s3": {"kind": "slow"}}, fake, timeout_s=3, now="T")
118
+ took = time.monotonic() - t0
119
+ return all(n["reachable"] for n in r["nodes"].values()) and took < 0.8
120
+
121
+ def c_empty():
122
+ r = pull({}, fake, now="T")
123
+ return r == {"schema": 1, "taken_at": "T", "nodes": {}} and pull(None, fake, now="T")["nodes"] == {}
124
+
125
+ def c_default_now_is_iso():
126
+ t = pull({}, fake)["taken_at"]
127
+ return isinstance(t, str) and t[4] == "-" and "T" in t
128
+
129
+ def c_elapsed_ok():
130
+ return elapsed_ok(0.0, 5.9, 5) is True and elapsed_ok(0.0, 6.1, 5) is False and elapsed_ok(0.0, 5.4, 5, slack_s=0.2) is False
131
+
132
+ checks = [("order_and_shape", c_order_and_shape), ("cfg_passed_and_none_cfg", c_cfg_passed_and_none_cfg),
133
+ ("raise_becomes_down", c_raise_becomes_down), ("non_dict_becomes_down", c_non_dict_becomes_down),
134
+ ("hang_times_out_fast", c_hang_times_out_fast), ("concurrent_not_serial", c_concurrent_not_serial),
135
+ ("empty", c_empty), ("default_now_is_iso", c_default_now_is_iso), ("elapsed_ok", c_elapsed_ok)]
136
+ passed = failed = 0
137
+ for name, fn in checks:
138
+ try:
139
+ ok = bool(fn())
140
+ except Exception as exc: # noqa: BLE001
141
+ ok = False
142
+ print("FAIL %s -- raised %s: %s" % (name, type(exc).__name__, exc))
143
+ else:
144
+ print(("ok %s" if ok else "FAIL %s") % name)
145
+ passed += ok
146
+ failed += not ok
147
+ print("multinode selftest: %d checks, %d passed, %d failed" % (len(checks), passed, failed))
148
+ return 0 if failed == 0 and checks else 1
149
+
150
+
151
+ if __name__ == "__main__":
152
+ sys.exit(main())