Sync config from arch
- hypr/apps.lua - hypr/autostart.lua - hypr/envs.lua - hypr/hyprland.lua - hypr/hyprsunset.conf - hypr/input.lua - hypr/looknfeel.lua - hypr/omasettings.lua - hypr/xdph.conf - omarchy/branding/about.txt - omarchy/branding/screensaver.txt - omarchy/extensions/omarchy-menu.jsonc - omarchy/hooks/battery-low.d/play-warning-sound.sample - omarchy/hooks/font-set.d/show-font-notification.sample - omarchy/hooks/post-boot.d/weather.sample - omarchy/hooks/post-update.d/install-voxtype.hook - omarchy/hooks/post-update.d/setup-agent.hook - omarchy/hooks/post-update.d/setup-fingerprint.hook - omarchy/hooks/post-update.d/show-update-notification.sample - omarchy/hooks/pre-refresh-pacman.d/add-custom-repo.sample - omarchy/hooks/theme-set.d/show-theme-notification.sample - omarchy/shell.json - omarchy/shell.toml - omarchy/theme.name - omarchy/themes/azure-glow/README.md - omarchy/themes/azure-glow/alacritty.toml - omarchy/themes/azure-glow/btop.theme - omarchy/themes/azure-glow/hyprland.conf - omarchy/themes/azure-glow/hyprlock.conf - omarchy/themes/azure-glow/icons.theme - … 269 more
This commit is contained in:
@@ -0,0 +1,388 @@
|
||||
"""Tests for store.py — rows, rollups, events, and the one connection three threads share.
|
||||
|
||||
Run: python3 -m unittest discover -s test
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from nexthopd.store import Store # noqa: E402
|
||||
|
||||
|
||||
class StoreRoundtrip(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.dir = tempfile.TemporaryDirectory()
|
||||
self.store = Store(Path(self.dir.name) / "t.db")
|
||||
|
||||
def tearDown(self):
|
||||
self.store.close()
|
||||
self.dir.cleanup()
|
||||
|
||||
def test_minute_hour_resolution_switch(self):
|
||||
now = int(time.time())
|
||||
for i in range(300):
|
||||
self.store.put_minute(now - (300 - i) * 60, {"lag": 20.0})
|
||||
rows, table = self.store.series(3600, now=now)
|
||||
self.assertEqual(table, "minute")
|
||||
self.assertEqual(len(rows), 60)
|
||||
self.store.rollup_hours(now)
|
||||
rows, table = self.store.series(7 * 86400, now=now)
|
||||
self.assertEqual(table, "hour")
|
||||
self.assertGreaterEqual(len(rows), 4)
|
||||
|
||||
def test_outage_accounting(self):
|
||||
now = time.time()
|
||||
eid = self.store.open_event(int(now - 600), "outage", "critical",
|
||||
"wan", "test")
|
||||
self.store.close_event(eid, int(now - 540))
|
||||
frac, disruptions, disrupt_frac = self.store.outage_stats(3600, now=now)
|
||||
self.assertAlmostEqual(frac, 60 / 3600, places=3)
|
||||
self.assertEqual(disruptions, 0)
|
||||
self.assertEqual(disrupt_frac, 0.0)
|
||||
|
||||
def test_ongoing_outage_counts_to_now(self):
|
||||
now = time.time()
|
||||
self.store.open_event(int(now - 120), "outage", "critical", "wan", "t")
|
||||
frac, _, _ = self.store.outage_stats(3600, now=now)
|
||||
self.assertAlmostEqual(frac, 120 / 3600, places=3)
|
||||
|
||||
def test_disruptions_report_duration_not_just_count(self):
|
||||
now = time.time()
|
||||
for start, length in ((900, 20), (600, 40)):
|
||||
eid = self.store.open_event(int(now - start), "disruption",
|
||||
"warning", "wan", "t")
|
||||
self.store.close_event(eid, int(now - start + length))
|
||||
frac, disruptions, disrupt_frac = self.store.outage_stats(3600, now=now)
|
||||
self.assertEqual(frac, 0.0)
|
||||
self.assertEqual(disruptions, 2)
|
||||
self.assertAlmostEqual(disrupt_frac, 60 / 3600, places=3)
|
||||
|
||||
def test_baseline_is_p90_per_network(self):
|
||||
now = int(time.time())
|
||||
for i, v in enumerate([100, 200, 210, 220, 230, 240, 900]):
|
||||
self.store.put_test(now - i * 3600, "content", "cloudflare",
|
||||
down_mbps=v, ok=True, network="Office")
|
||||
# p90 shrugs off the one lucky 900 run.
|
||||
self.assertLess(self.store.baseline_speed(network="Office", now=now), 900)
|
||||
self.assertGreaterEqual(self.store.baseline_speed(network="Office", now=now), 240)
|
||||
# Too few samples on an unknown network falls back to all networks.
|
||||
self.assertIsNotNone(self.store.baseline_speed(network="Home", now=now))
|
||||
|
||||
def test_baseline_no_fallback_without_local_samples(self):
|
||||
now = int(time.time())
|
||||
for i in range(6):
|
||||
self.store.put_test(now - i * 3600, "content", "x",
|
||||
down_mbps=300, ok=True, network="OfficeA")
|
||||
# Another network's history must not become this network's normal.
|
||||
self.assertIsNone(self.store.baseline_speed(network="OfficeB",
|
||||
now=now, fallback=False))
|
||||
self.assertIsNotNone(self.store.baseline_speed(network="OfficeB",
|
||||
now=now))
|
||||
|
||||
def test_baseline_needs_enough_samples(self):
|
||||
now = int(time.time())
|
||||
for i in range(3):
|
||||
self.store.put_test(now - i * 3600, "content", "x",
|
||||
down_mbps=100, ok=True)
|
||||
self.assertIsNone(self.store.baseline_speed(now=now))
|
||||
|
||||
|
||||
class MinuteProvenance(unittest.TestCase):
|
||||
def test_minute_rows_carry_basis_and_seats(self):
|
||||
d = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(d.cleanup)
|
||||
store = Store(Path(d.name) / "t.db")
|
||||
self.addCleanup(store.close)
|
||||
ts = int(time.time() // 60) * 60
|
||||
store.put_minute(ts, {"lag": 30.0, "lag_icmp": 24.0},
|
||||
iface="wlo1", network="x", probes="icmp-anchor+tcp-cf")
|
||||
rows, _ = store.series(3600)
|
||||
row = rows[-1]
|
||||
self.assertEqual(row["lag_icmp"], 24.0)
|
||||
self.assertEqual(row["probes"], "icmp-anchor+tcp-cf")
|
||||
|
||||
def test_old_databases_gain_the_columns(self):
|
||||
d = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(d.cleanup)
|
||||
path = Path(d.name) / "t.db"
|
||||
import sqlite3 as sq
|
||||
from nexthopd.store import SAMPLE_COLUMNS
|
||||
old_cols = ", ".join(f"{c} REAL" for c in SAMPLE_COLUMNS
|
||||
if c != "lag_icmp")
|
||||
db = sq.connect(path)
|
||||
db.execute(f"CREATE TABLE minute (ts INTEGER PRIMARY KEY, {old_cols}, "
|
||||
"iface TEXT, network TEXT)")
|
||||
db.commit(); db.close()
|
||||
store = Store(path)
|
||||
self.addCleanup(store.close)
|
||||
store.put_minute(60, {"lag": 1.0}, probes="a+b") # must not raise
|
||||
|
||||
|
||||
class TailStatisticsAreRecorded(unittest.TestCase):
|
||||
"""p75 and max per leg, written but never scored.
|
||||
|
||||
Comparing our headline against Orb's and LibreQoS's could only be done
|
||||
as an upper bound because neither statistic was ever stored; p50 and
|
||||
p95 cannot reconstruct them. Recorded now so the choice can be argued
|
||||
from real days rather than bounded — the rule 0.1.11 held loaded
|
||||
latency to.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.dir = tempfile.TemporaryDirectory()
|
||||
self.path = Path(self.dir.name) / "history.db"
|
||||
|
||||
def tearDown(self):
|
||||
self.dir.cleanup()
|
||||
|
||||
def test_the_columns_exist_and_round_trip(self):
|
||||
st = Store(self.path)
|
||||
st.put_minute(60, {"local_p50": 1.0, "local_p75": 2.0,
|
||||
"local_max": 9.0, "wan_p50": 3.0,
|
||||
"wan_p75": 4.0, "wan_max": 77.7})
|
||||
row = st.db.execute("SELECT * FROM minute WHERE ts = 60").fetchone()
|
||||
self.assertEqual(row["local_p75"], 2.0)
|
||||
self.assertEqual(row["local_max"], 9.0)
|
||||
self.assertEqual(row["wan_p75"], 4.0)
|
||||
self.assertEqual(row["wan_max"], 77.7)
|
||||
st.close()
|
||||
|
||||
def test_an_older_database_is_migrated_in_place(self):
|
||||
# The invariant across nine schema-touching releases: additive
|
||||
# ALTER TABLE, never a rewrite. An existing row must survive it.
|
||||
import sqlite3 as sq
|
||||
st = Store(self.path)
|
||||
st.put_minute(60, {"local_p50": 1.0})
|
||||
st.close()
|
||||
db = sq.connect(self.path)
|
||||
for col in ("local_p75", "local_max", "wan_p75", "wan_max"):
|
||||
db.execute("ALTER TABLE minute DROP COLUMN %s" % col)
|
||||
db.execute("ALTER TABLE hour DROP COLUMN %s" % col)
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
st = Store(self.path) # reopening must migrate
|
||||
cols = [r[1] for r in st.db.execute("PRAGMA table_info(minute)")]
|
||||
for col in ("local_p75", "local_max", "wan_p75", "wan_max"):
|
||||
self.assertIn(col, cols)
|
||||
row = st.db.execute("SELECT * FROM minute WHERE ts = 60").fetchone()
|
||||
self.assertEqual(row["local_p50"], 1.0) # the old row survived
|
||||
self.assertIsNone(row["local_max"]) # and is honestly blank
|
||||
st.close()
|
||||
|
||||
def test_they_are_recorded_but_not_scored(self):
|
||||
# Nothing in the scoring path may read them yet. If that changes it
|
||||
# should be a deliberate release, not a drift.
|
||||
src = Path(__file__).resolve().parent.parent / "nexthopd" / "score.py"
|
||||
text = src.read_text()
|
||||
for col in ("local_p75", "local_max", "wan_p75", "wan_max"):
|
||||
self.assertNotIn(col, text)
|
||||
|
||||
|
||||
class StoreConcurrency(unittest.TestCase):
|
||||
"""One connection shared by the loop and two test workers. Every call
|
||||
must serialise: sqlite3 raises on an overlapping use of one connection
|
||||
and the row it was writing is lost."""
|
||||
|
||||
def test_overlapping_writers_and_readers_lose_nothing(self):
|
||||
import threading
|
||||
d = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(d.cleanup)
|
||||
store = Store(Path(d.name) / "t.db")
|
||||
self.addCleanup(store.close)
|
||||
errors, n = [], 150
|
||||
|
||||
def guard(fn):
|
||||
def run():
|
||||
try:
|
||||
fn()
|
||||
except Exception as e: # noqa: BLE001 — recorded, asserted
|
||||
errors.append(repr(e))
|
||||
return run
|
||||
|
||||
def tests(kind, base):
|
||||
for i in range(n):
|
||||
store.put_test(base + i, kind, "x", down_mbps=1.0, ok=True,
|
||||
network="n")
|
||||
|
||||
def loop():
|
||||
for i in range(n):
|
||||
store.put_minute(1_000_000 + i * 60, {"lag": 1.0})
|
||||
store.outage_stats(3600, now=2_000_000)
|
||||
|
||||
def events():
|
||||
for i in range(n):
|
||||
eid = store.open_event(3_000_000 + i, "outage", "critical",
|
||||
"wan", "t")
|
||||
store.close_event(eid, 3_000_000 + i + 1)
|
||||
store.events(86400, now=3_000_000 + n)
|
||||
|
||||
threads = [threading.Thread(target=guard(lambda: tests("content", 10_000))),
|
||||
threading.Thread(target=guard(lambda: tests("peak", 20_000))),
|
||||
threading.Thread(target=guard(loop)),
|
||||
threading.Thread(target=guard(events))]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join(60)
|
||||
self.assertEqual(errors, [])
|
||||
self.assertEqual(len(store.tests(limit=1000, kind="content")), n)
|
||||
self.assertEqual(len(store.tests(limit=1000, kind="peak")), n)
|
||||
rows, _ = store.series(10 ** 9, now=1_000_000 + n * 60 + 1,
|
||||
resolution="minute")
|
||||
self.assertEqual(len(rows), n)
|
||||
|
||||
|
||||
class EventWindowSemantics(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.dir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.dir.cleanup)
|
||||
self.store = Store(Path(self.dir.name) / "t.db")
|
||||
self.addCleanup(self.store.close)
|
||||
self.now = 1_000_000
|
||||
|
||||
def details(self, seconds):
|
||||
return [e["detail"] for e in self.store.events(seconds, now=self.now)]
|
||||
|
||||
def test_an_event_that_ended_inside_the_window_is_listed(self):
|
||||
# Began 25 h ago, ended an hour ago: it overlaps the last 24 h and
|
||||
# is exactly the row the user opens the list to find.
|
||||
eid = self.store.open_event(self.now - 90_000, "outage", "critical",
|
||||
"wan", "straddles")
|
||||
self.store.close_event(eid, self.now - 3_600)
|
||||
# Began and ended before the window: not listed.
|
||||
eid = self.store.open_event(self.now - 90_000, "outage", "critical",
|
||||
"wan", "old")
|
||||
self.store.close_event(eid, self.now - 89_000)
|
||||
listed = self.details(86_400)
|
||||
self.assertIn("straddles", listed)
|
||||
self.assertNotIn("old", listed)
|
||||
|
||||
def test_orphans_are_closed_at_the_shortest_span(self):
|
||||
self.store.open_event(self.now - 90_000, "outage", "critical", "wan",
|
||||
"orphan")
|
||||
# Left NULL, it charges every window forever.
|
||||
frac, _, _ = self.store.outage_stats(3600, now=self.now)
|
||||
self.assertAlmostEqual(frac, 1.0, places=3)
|
||||
self.assertEqual(self.store.close_orphans(self.now), 1)
|
||||
frac, _, _ = self.store.outage_stats(3600, now=self.now)
|
||||
self.assertEqual(frac, 0.0)
|
||||
row = self.store.events(10 ** 6, now=self.now)[0]
|
||||
self.assertEqual(row["ended_ts"], row["ts"] + 1)
|
||||
# Idempotent, and it never touches a properly closed row.
|
||||
self.assertEqual(self.store.close_orphans(self.now), 0)
|
||||
|
||||
def test_prune_removes_events_past_the_hourly_horizon(self):
|
||||
eid = self.store.open_event(self.now - 500 * 86_400, "info", "info",
|
||||
"local", "ancient")
|
||||
self.store.close_event(eid, self.now - 500 * 86_400 + 1)
|
||||
eid = self.store.open_event(self.now - 10, "info", "info", "local",
|
||||
"recent")
|
||||
self.store.close_event(eid, self.now - 9)
|
||||
self.store.prune(now=self.now)
|
||||
listed = self.details(10 ** 9)
|
||||
self.assertNotIn("ancient", listed)
|
||||
self.assertIn("recent", listed)
|
||||
|
||||
|
||||
class RollupNetworkLabel(unittest.TestCase):
|
||||
def test_an_hour_spanning_two_networks_is_labelled_neither(self):
|
||||
d = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(d.cleanup)
|
||||
store = Store(Path(d.name) / "t.db")
|
||||
self.addCleanup(store.close)
|
||||
hour = 3_600_000
|
||||
store.put_minute(hour + 60, {"lag": 1.0}, iface="wlo1", network="Home")
|
||||
store.put_minute(hour + 120, {"lag": 1.0}, iface="wlo1", network="Office")
|
||||
store.put_minute(hour + 3660, {"lag": 1.0}, iface="wlo1", network="Office")
|
||||
store.rollup_hours(now=hour + 3 * 3600)
|
||||
rows, _ = store.series(10 ** 6, now=hour + 3 * 3600, resolution="hour")
|
||||
by_ts = {r["ts"]: r for r in rows}
|
||||
self.assertEqual(by_ts[hour]["network"], "")
|
||||
self.assertEqual(by_ts[hour]["iface"], "wlo1")
|
||||
self.assertEqual(by_ts[hour + 3600]["network"], "Office")
|
||||
|
||||
|
||||
class DrainIsStored(unittest.TestCase):
|
||||
"""The drain was published and never stored, so it could not be audited.
|
||||
|
||||
That is why the distribution proving it is quantised by probe cadence had
|
||||
to come from the other implementation: this one had no history to look at.
|
||||
"""
|
||||
|
||||
def store(self):
|
||||
d = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(d.cleanup)
|
||||
s = Store(Path(d.name) / "t.db")
|
||||
self.addCleanup(s.close)
|
||||
return s
|
||||
|
||||
def test_a_minute_carries_the_drain_and_what_produced_it(self):
|
||||
s = self.store()
|
||||
s.put_minute(60, {"lag": 12.0, "drain_ms": 2000.0,
|
||||
"drain_min_ms": 1000.0, "drain_settled": 1.0,
|
||||
"drain_src": "tcp-anchor"},
|
||||
iface="wlan0", network="home", probes="icmp+tcp")
|
||||
rows, _ = s.series(seconds=3600, now=120, resolution="minute")
|
||||
row = rows[0]
|
||||
self.assertEqual(row["drain_ms"], 2000.0)
|
||||
self.assertEqual(row["drain_min_ms"], 1000.0)
|
||||
self.assertEqual(row["drain_settled"], 1.0)
|
||||
self.assertEqual(row["drain_src"], "tcp-anchor")
|
||||
|
||||
def test_never_measured_and_did_not_settle_stay_different(self):
|
||||
# None is "no drain in this minute"; 0.0 is "measured, never came
|
||||
# back inside the window". Collapsing them would turn a censored
|
||||
# observation into a real one.
|
||||
s = self.store()
|
||||
s.put_minute(60, {"drain_ms": 30000.0, "drain_settled": 0.0}, network="home")
|
||||
s.put_minute(120, {"lag": 9.0}, network="home")
|
||||
got, _ = s.series(seconds=3600, now=180, resolution="minute")
|
||||
rows = {r["ts"]: r for r in got}
|
||||
self.assertEqual(rows[60]["drain_settled"], 0.0)
|
||||
self.assertIsNone(rows[120]["drain_settled"])
|
||||
self.assertIsNone(rows[120]["drain_ms"])
|
||||
|
||||
def test_the_drain_does_not_roll_up_into_an_hour(self):
|
||||
"""A mean of drains destroys the only thing it is stored for.
|
||||
|
||||
The value is quantised by probe cadence, so what has to survive is
|
||||
the distribution. Sixty of them averaged has none of that in it —
|
||||
the same objection the rollup already carries for percentiles, but
|
||||
binding harder, because here the spread IS the finding.
|
||||
"""
|
||||
from nexthopd.store import SAMPLE_COLUMNS
|
||||
for c in ("drain_ms", "drain_min_ms", "drain_settled", "drain_src"):
|
||||
self.assertNotIn(c, SAMPLE_COLUMNS)
|
||||
s = self.store()
|
||||
cols = [r[1] for r in s.db.execute("PRAGMA table_info(hour)")]
|
||||
for c in ("drain_ms", "drain_min_ms", "drain_settled", "drain_src"):
|
||||
self.assertNotIn(c, cols)
|
||||
|
||||
def test_an_older_database_gains_the_columns(self):
|
||||
# Additive migration only, as every schema change here has been.
|
||||
d = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(d.cleanup)
|
||||
path = Path(d.name) / "old.db"
|
||||
old = sqlite3.connect(path)
|
||||
old.execute("CREATE TABLE minute (ts INTEGER PRIMARY KEY, lag REAL)")
|
||||
old.execute("CREATE TABLE hour (ts INTEGER PRIMARY KEY, lag REAL)")
|
||||
old.execute("CREATE TABLE tests (ts INTEGER, kind TEXT)")
|
||||
old.execute("CREATE TABLE events (ts INTEGER, kind TEXT)")
|
||||
old.commit()
|
||||
old.close()
|
||||
s = Store(path)
|
||||
self.addCleanup(s.close)
|
||||
cols = [r[1] for r in s.db.execute("PRAGMA table_info(minute)")]
|
||||
for c in ("drain_ms", "drain_min_ms", "drain_settled", "drain_src"):
|
||||
self.assertIn(c, cols)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user