Activity Subsystem — Phase-0 Architecture Note
Synced read-only from
/home/synnet/mirrors/synvirt-product/docs/architecture/activity-subsystem.md. Edit at the source, not here.
Activity Subsystem — Phase-0 Architecture Note
Section titled “Activity Subsystem — Phase-0 Architecture Note”Scope: SynVirt activity subsystem = the docked Recent Tasks + Alarms panel (web-ux-v2) plus the backend that feeds it (observability tasks, the alarm/issue engine, the live stream, and the Cloud per-host fan-out).
Status: Phase-0 (findings + locked decision + slice plan). No code yet.
Governing constraint (from spec): Do not invent parallel infrastructure — extend what exists.
The repo already ships an observability tasks table + TaskRegistry, the new alarm_rules /
alarm_instances / issue_occurrences tables, a 40-detector registry, an SSE/WebSocket event stream,
a Monitor UI, and a Cloud HTTP host-proxy fan-out. This note locks extend, not replace.
1. Phase-0 findings against the spec’s 5 questions
Section titled “1. Phase-0 findings against the spec’s 5 questions”Q1 — Where do “tasks” (async jobs) live today, and is the model rich enough?
Section titled “Q1 — Where do “tasks” (async jobs) live today, and is the model rich enough?”A real async-task subsystem already exists in crates/synvirt-observability:
- Model:
TaskRow(crates/synvirt-observability/src/db/tasks.rs:74-110) — 16 fields:id(UUID),kind,target_type,target_id,target_name,state,progress_pct(0-100),started_at,ended_at,initiated_by_user,initiated_by_source,parent_task_id,result_json,error_code,error_msg,correlation_id. - Enums:
TaskState(Queued/Running/Success/Error/Cancelled,tasks.rs:15-42);TaskSource(Ui/Api/Scheduler/System/Cli,tasks.rs:44-72). - Lifecycle facade:
TaskRegistry(src/tasks/mod.rs:18-89) withstart/get/list;TaskHandle(src/tasks/mod.rs:93-170) withprogress(pct),succeed(result_json),fail(code, msg), and an auto-cancel-on-drop guard. - Daemon entry points:
crates/daemon/src/api/task_helper.rs:32-92—start(),start_with_actor(),settle(task, result). - Schema:
taskstable (migrations/0001_initial.sql:54-70) +task_stepstable (migrations/0008_task_steps_alarms_issues.sql:12-26, FKtask_idCASCADE). - Live producers: ~30 task kinds across 5 domains already emit tasks — VM power/lifecycle
(
vm_power.rs,vms.rs), snapshots (snapshots.rs), VM edit (vm_edit/*.rs), network (network.rs), storage (storage_proxy.rs), update (update_v2.rs), templates (templates.rs).
Verdict: rich enough for basic tracking (id/kind/target/state/progress/timestamps/initiator/error/ correlation/parent). It is missing the cluster + typed-target dimensions (see Q3) and a few cancel/history affordances (see §6).
Q2 — Where do “alarms” live, and is there a real engine or just a table?
Section titled “Q2 — Where do “alarms” live, and is there a real engine or just a table?”There is a real, deduplicating alarm path plus a newer, not-yet-wired rule engine schema:
- Active path (wired):
DetectorRegistry.tick_once()(src/issues/mod.rs:78-216) runs each detector and callsAlarmRegistry.upsert()(src/alarms/mod.rs:30-68), which wrapsDatabase::upsert_alarm()(src/db/alarms.rs:150-214). Dedup is pessimistic — the partial unique indexalarms_active_uniqueon(issue_code, target_type, target_id) WHERE state IN ('triggered','acknowledged')guarantees exactly one active row per condition; a second insert finds the existing row and bumpslast_triggered_at+ count.auto_resolve_stale()(alarms.rs:309-322) resolves alarms idle pastauto_resolve_secs(default 172800s = 2 days). - Rule-engine schema (NOT wired):
migrations/0008_task_steps_alarms_issues.sqladdsalarm_rules,alarm_instances,issue_occurrences— present in the schema but the detector tick still upserts to the legacyalarmstable. No threshold/compound/state-change rule evaluator exists yet. - Catalog: every detector self-registers a
CatalogEntry(src/db/issues.rs) on boot.
Verdict: the engine is real but split — a working legacy dedup path and an unwired issue_occurrences
rule schema. Phase-0 decision: build the lifecycle engine onto the existing
alarms_active_unique semantics, migrating toward issue_occurrences as the occurrence ledger
(see §6, backend slice B2). Do not invent a third path.
Q3 — What’s missing to make tasks/alarms cluster-aware and clickable?
Section titled “Q3 — What’s missing to make tasks/alarms cluster-aware and clickable?”- No origin-node attribution.
TaskRowhas noorigin_host/origin_node; alarms carry a target but nohost_id. FED host-scoped views work via/api/v1/hosts/{id}/observability/*proxying, but the rows themselves cannot say which cluster member produced them. - No typed clickable target.
target_type/target_idare bare strings; there is noTarget{type,id}to safe-generate an href. - No result schema in list.
result_jsonround-trips in export but the list response does not deserialize it back to callers.
Q4 — Is there a live stream, and does it already multiplex tasks + alarms + events?
Section titled “Q4 — Is there a live stream, and does it already multiplex tasks + alarms + events?”Yes. /api/v1/observability/stream (canonical alias of /api/v1/events/stream,
observability/mod.rs:19, events_ws.rs) is a single multiplexed WebSocket: one
tokio::sync::broadcast channel (EventBus, src/events/mod.rs:19-35) carries tasks + alarms +
events, classified at wire time into {type: event|task_update|alarm|lag_warning|pong, payload}
(events_ws.rs:184-188). Clients send a SubscribeFilter (severity[], target_id,
categories[], events_ws.rs:50-54) applied client-side. SQLite is the source of truth; the
broadcast is lossy (slow subscribers drop, get a lag_warning).
Verdict: the single-stream requirement is already satisfied in-process. Multi-host is the gap.
Q5 — How does the Cloud GUI reach a peer’s tasks/alarms today, and what’s missing?
Section titled “Q5 — How does the Cloud GUI reach a peer’s tasks/alarms today, and what’s missing?”Today it reaches peer observability only over the HTTP host-proxy: ANY /api/v1/hosts/{fingerprint}/proxy/* (api/hosts.rs:453-590) forwards REST/SSE to the peer’s
:15443 with the Authorization header, 120s read-timeout, no whole-request timeout. Host-scoped
reads land on /api/v1/hosts/{id}/observability/{events|tasks|issues}
(observability/host_scoped.rs:34-68). GET /api/v1/cloud/health (cloud_health.rs:142-299) already
rolls up per-host/per-cluster state by walking the inventory tree (build_inventory_document) over
the same federation GetInventory RPC.
Missing: there is no /api/v1/hosts/{fp}/observability/stream — the Cloud GUI can only
poll peer REST or relay SSE over the proxy; there is no single-WS-per-peer push, and no
server-side “follow peer X’s observability” primitive. Observability is in-process per daemon and is
deliberately not replicated through Raft (spec forbids routing task/alarm data through Raft — the
current architecture already honors this).
2. Locked decision — EXTEND the observability + Cloud-proxy infra (no parallel stack)
Section titled “2. Locked decision — EXTEND the observability + Cloud-proxy infra (no parallel stack)”We extend the existing observability task/alarm/stream infrastructure and the Cloud host-proxy fan-out. We do not stand up a new job store, a new alarm bus, a new socket, or a new UI shell.
Concretely, the activity subsystem is built by extending these exact pieces:
| Concern | Reuse / extend (exact symbol) |
|---|---|
| Task model + lifecycle | synvirt-observability::db::tasks::TaskRow (tasks.rs:74-110), TaskRegistry (tasks/mod.rs:18-89), TaskHandle (tasks/mod.rs:93-170) |
| Task creation from daemon | daemon::api::task_helper::start_with_actor() (task_helper.rs:32-92) |
| Alarm dedup + lifecycle | Database::upsert_alarm() (alarms.rs:150-214), AlarmRegistry::upsert() (alarms/mod.rs:30-68), spawn_auto_resolve() (alarms.rs:309-322), partial index alarms_active_unique |
| Alarm rule ledger (to wire) | migrations/0008 tables alarm_rules, alarm_instances, issue_occurrences |
| Detector signal sourcing | DetectorRegistry::tick_once() (issues/mod.rs:78-216), 8 suppliers (observability/suppliers.rs:1-685), Detector trait + 8 callback types (issues/callbacks.rs) |
| Live multiplexed stream | EventBus (events/mod.rs:19-35), /api/v1/observability/stream (events_ws.rs), SubscribeFilter (events_ws.rs:50-54) |
| REST surface | GET /api/v1/{tasks,alarms,issues,events} + canonical /api/v1/observability/* + host-scoped + vm-scoped (observability/mod.rs:51-141) |
| Cluster/Cloud fan-out | HTTP host-proxy ANY /api/v1/hosts/{fp}/proxy/* (hosts.rs:453-590), /api/v1/cloud/health rollup (cloud_health.rs:142-299), PeerRegistry::members() (mesh/peers.rs:28-85) |
| Frontend store + stream | useObservabilityStore() (stores/observability.ts:389-773), useObservabilityStream() (composables/useObservabilityStream.ts:80-182) |
| Frontend UI primitives | AppShell.vue:236-338, Card.vue, Tag.vue, SummaryCards.vue, SeverityBadge.vue, table pattern TasksView.vue:284-353, filters IssuesView.vue:56-89, drawer EventsView.vue:275-310 |
3. Per-node ownership + on-demand request/response fan-out (NO Raft)
Section titled “3. Per-node ownership + on-demand request/response fan-out (NO Raft)”Ownership model. Each node owns and persists its own tasks/alarms/events in its local
/var/lib/synvirt/observability.db (SQLite WAL, FKs on). The EventBus broadcast is in-process and
local. Tasks and alarms are never routed through Raft — Raft stays reserved for cluster control
plane state. This matches the spec constraint and the current architecture (observability is local +
HTTP proxy only; nothing observability-shaped is Raft-replicated).
Fan-out model. Aggregation is on-demand request/response, not replication:
- The Cloud/aggregating node enumerates reachable owners via
PeerRegistry::members()(mesh/peers.rs:28-85, cluster voters) and, where loose federation applies, the advisoryDiscoveredHostsStore(mDNS,discovery/mod.rs:75-150). - For each owner, the aggregator pulls observability over the existing HTTP host-proxy
(
/api/v1/hosts/{fp}/proxy/api/v1/observability/...) — REST snapshot now, and a new/api/v1/hosts/{fp}/observability/streamWS relay later (backend slice B3). Rollup counts piggyback on/api/v1/cloud/health(cloud_health.rs) extended withtask.queued/running/error+ alarm-by-severity tallies. - Reads use the “fresh” tight-timeout, non-blocking pattern from
cluster_control_client.rs:21-114(snapshot cache for sync reads; fresh async reads with a short timeout; noblock_in_place). A slow/dead peer degrades to its last snapshot, never stalls the aggregator.
Standalone = single-localhost-peer parity. A standalone host is modeled as a one-member peer set
whose only owner is localhost. The same panel, the same store, the same stream, the same fan-out code
path run — the peer list is [self]. No standalone-specific branch. This keeps standalone and cluster
on one code path and makes the panel work identically with zero cluster wiring.
4. Enumerated real alarm sources (found, not fabricated)
Section titled “4. Enumerated real alarm sources (found, not fabricated)”The detector registry wires 40 detectors (daemon/src/main.rs:2049-2346): 7 real-signal,
30 supplier-wired, 3 deferred stubs. Backed by 8 real suppliers (observability/suppliers.rs).
The conditions that actually exist in code:
Host — OS / kernel (real-signal, read /proc or subprocess):
- Host CPU utilization threshold breached —
host_cpu.rs(/proc/stat) - Host memory utilization threshold breached —
host_mem.rs(/proc/meminfo) - Host load average threshold breached —
host_load.rs(/proc/loadavg) - Swap space used threshold breached —
host_swap.rs(/proc/swaps+ sysctl) - NTP clock drift out of range —
host_ntp_drift.rs(chrony stats) - DNS query to default gateway fails —
host_dns.rs(DNS socket test) - Daemon error-log rate exceeds threshold —
host_log_rate.rs(placeholder counter; tracing wiring pending) - Kernel panic detected in recent log —
host_kernel.rs(journalctl -k)
Host — storage (supplier: storaged capacity-samples / drill-down):
9. Storage pool usage warn — host_pool_usage.rs
10. Storage pool usage critical — host_pool_usage.rs
11. Storage pool degraded — host_storage.rs (parses zpool status)
12. Storage pool scrub overdue — host_storage.rs (last_scrub timestamp)
13. Storage pool resilver active — host_storage.rs (scrub_state=‘resilvering’)
Host — network (supplier: in-process NetworkController bridge_status):
14. OVS bridge missing from live config — host_network.rs
15. OVS uplink degraded (bond members down) — host_network.rs
16. OVS uplink lost (no members UP) — host_network.rs
17. VLAN trunk mode mismatch — host_network.rs
18. MTU mismatch between uplink and bridge — host_network.rs
Host — TLS cert (supplier: synvirt-certs parser on cert.pem):
19. TLS certificate expiry warning — host_cert.rs
20. TLS certificate expiry critical — host_cert.rs
Host — hardware / BMC (supplier: ipmitool sdr/sel, cached):
21. BMC fan critical — host_hardware.rs
22. BMC temperature critical — host_hardware.rs
23. BMC power supply failed — host_hardware.rs
24. BMC RAID battery degraded — host_hardware.rs
25. IPMI SEL capacity full — host_hardware.rs
VM — liveness / agent (supplier: guest-tools supervisor):
26. VM heartbeat stale (no guest agent seen recently) — vm_heartbeat.rs
27. VM guest agent not running — vm_guest_agent.rs
VM — resources (supplier: vm_telemetry / guest-tools; note many telemetry fields currently 0):
28. VM CPU utilization high — vm_resources.rs (cpu_pct)
29. VM CPU ready percentage high — vm_resources.rs
30. VM memory utilization high — vm_resources.rs (mem_unused_pct)
31. VM disk read latency high — vm_resources.rs (disk_read_await_ms)
32. VM disk write latency high — vm_resources.rs (disk_write_await_ms)
33. VM disk usage high — vm_resources.rs (max_fs_used_pct)
34. VM drivers/virtio-win outdated — vm_resources.rs (virtio_win_age_days)
35. VM boot failure detected — vm_resources.rs (last_boot_failure_at)
36. VM migration aborted — vm_resources.rs (last_migration_failure_at)
37. VM snapshot delta size large — vm_resources.rs (largest_snapshot_delta_bytes)
VM — deferred stubs (return empty Vec, awaiting storaged endpoints):
38. VM consolidation needed (snapshot chain depth) — vm_consolidation.rs (STUB)
39. VM snapshot age exceeds threshold — vm_snapshot_old.rs (STUB)
40. VM orphan disk dataset detected — vm_orphan.rs (STUB)
These 40 are the whole truth of today’s alarm conditions. No additional conditions are invented in this note. (Known not-yet-built detectors — cluster/Raft peer loss, VIP/keepalived failover, backup job failure, update rollback, cgroup pressure, inode exhaustion, uplink latency/packet-loss — are listed under §6 gaps, NOT counted as existing sources.)
5. Guest-safety boundary (out-of-band, daemon-restart-safe)
Section titled “5. Guest-safety boundary (out-of-band, daemon-restart-safe)”The activity subsystem must never destabilize a running guest. The existing design already enforces this and the extension preserves it:
- Out-of-band signal collection. Detectors read host facts (
/proc, subprocesses) and out-of-band guest telemetry (vsock / guest-tools supervisorlast_seen,agent_connected). Activity tracking never touches the guest’s CPU/devices in-band; the worst case is a stale or neutral telemetry value (vm_telemetryfields default to 0/neutral), never a guest stall. - Fail-soft suppliers. Every supplier catches errors and returns an empty/neutral snapshot
(unreachable storaged, missing ipmitool, absent BMC, timeout) — no false alarms, no cascade onto a
guest (
suppliers.rs, fail-soft principle). - Daemon-restart-safe persistence. SQLite WAL is the source of truth; the
EventBusbroadcast is lossy and reconstructible from the DB. A daemon restart loses no task/alarm history. This pairs with the established guest-drain invariant — daemon restart must not bounce guests (drain is owned bysynvirt-guest-drain.service; daemon stop is HTTP-only; start adopts). The activity subsystem holds no guest-affecting state across restarts and adds nothing that could. - TaskHandle drop guard. A daemon panic/shutdown mid-task auto-cancels the task row (no perpetual “running” zombie) without sending any signal to the guest.
- Tier-2 fan-out, not Raft. Cross-host observability rides peer-authenticated HTTP proxy / mesh RPC, never Raft replication — a busy activity feed cannot back-pressure the control plane or a guest scheduler.
6. Prioritized GAP → implementation plan
Section titled “6. Prioritized GAP → implementation plan”Each slice is independently buildable and unit-testable. Fleet deploy (.11/.12/.13 lockstep)
and the cluster Playwright E2E are GATED on explicit owner go — not part of any slice’s “done.”
Backend slices
Section titled “Backend slices”B1 — Task-model generalization (typed target / origin-node / progress / cancel). P1.
- Add
origin_node(cluster member attribution) and a typedtarget_href/Target{type,id}hint toTaskRow+ serialization (tasks.rs:74-110); threadorigin_nodethroughtask_helper::start_with_actor(). Add acreated_by_systemdistinction so scheduler/background tasks separate from operator intervention. - Expose
POST /api/v1/tasks/{id}/cancel(cancellation exists at DB layercancel_task()+ onTaskHandledrop, but has no REST surface). - Surface
result_json(typedResult{success|error{code,msg,context}}) in the list response, not just export. - Add missing task kinds (already-found gaps):
vm.snapshot.restore,vm.snapshot.clone(snapshots.rs),vm.nic.edit(vm_nic_edit.rs),storage.pool.snapshot_policy(storage_proxy.rs). - Tests: TaskRow round-trip incl. new fields; cancel state transition; kind-emission per handler.
B2 — Alarm dedup + lifecycle ENGINE on issue_occurrences. P1.
- Wire the unwired migration-0008 ledger: detector tick records an occurrence in
issue_occurrencesand maintains one activealarm_instancesrow, preserving today’salarms_active_unique(issue_code, target_type, target_id)dedup semantics (alarms.rs:150-214). Keepauto_resolve_stale()behavior. - Add
origin_node/host_idto alarm rows for cluster context. - Tests: N ticks of the same condition → 1 active instance + N occurrences + bumped count; idle past
auto_resolve_secs→ resolved; distinct targets → distinct instances.
B3 — Cluster fan-out (request/response, no Raft). P2.
- Add
GET /api/v1/hosts/{fp}/observability/streamas a WS relay over the existing host-proxy (hosts.rs:453-590) so the Cloud GUI gets one WS per peer instead of polling. Aggregator enumerates viaPeerRegistry::members()(+ advisoryDiscoveredHostsStore), reads with thecluster_control_client.rsfresh-timeout pattern. - Extend
/api/v1/cloud/health(cloud_health.rs:142-299) withtask.queued/running/error_pctand alarm-by-severity rollup over the sameGetInventoryRPC. - Standalone parity: peer set =
[self](localhost), identical path. - Tests: rollup math over 2 fake peers; dead-peer degrades to snapshot without stalling.
B4 — History pagination + DB-layer filters. P2.
- Push
kind_prefix(vm.*),since,until,parent_task_idinto the DB query (today they are post-filters in memory overMAX_LIMIT*2). Add composite indices(target_type, started_at DESC)and(kind, started_at DESC). Return parent/group structure in the list response (parent_task_idexists but list() doesn’t return group structure). - Tests: paginated cursor stability; kind-prefix at DB layer; group structure shape.
B5 — Ack / clear / silence API. P3.
- Beyond existing
POST /api/v1/issues/{id}/{acknowledge,resolve,reset}: add bulkPOST /api/v1/alarms/batch-acknowledge, bulk-resolve-by-target/code/severity, and silence-until (silence_reason,silenced_until) — none exist today. - Tests: bulk ack idempotency; silence window suppresses re-trigger then expires.
Frontend slices
Section titled “Frontend slices”F1 — Bottom-docked two-tab panel shell. P1.
- Add a fourth dock region (bottom slot) to
AppShell.vue:236-338(today only topbar/sidebar/main). Build a collapsible/resizable panel with two tabs (Recent Tasks, Recent Alarms), height toggle, tab + expand/collapse state persisted to localStorage (NotificationBell pattern,NotificationBell.vue:35-39). ReuseCard.vue(flush),Tag.vue,SeverityBadge.vue,SummaryCards.vue. - Default filters: tasks = running+queued; alarms = triggered.
- Tests (vitest): panel mount/collapse/persist; default-filter selection.
F2 — Store on the single stream (push handlers). P1.
- Extend
useObservabilityStore()(stores/observability.ts:389-773) withpushTask()andpushAlarm()(onlypushEventexists) souseObservabilityStream()(useObservabilityStream.ts:80-182, already mounted at shell) folds livetask_update/alarmframes into the visible tables. Add “Recent N” loaders (limit rows for compact display). ReuseTaskRow/AlarmRowtypes as-is (observability.ts:53-90). - Tests (vitest): WS frame → table mutation; recent-N truncation; de-dup of live vs loaded rows.
F3 — History / sort / filter / acknowledge in the dock. P2.
- Reuse the
TasksView.vue:284-353table column rendering (Kind/Target/State/Progress/Started),IssuesView.vue:56-89filters, andEventsView.vue:275-310detail drawer adapted for task/alarm detail. Individual-row acknowledge/resolve/reset via existingstore.acknowledgeAlarm/resolveAlarm/resetAlarm(IssuesView.vue:192-220). Expand-to-full link →/monitor/tasksor/monitor/issues. No bulk multi-select in the dock (too crowded). - Optional Server column when cluster scope present (depends on B1/B2
origin_node). - Tests (vitest): sort/filter reducers; ack action dispatch; drawer open with row payload.
Gated on owner (NOT slice-complete criteria)
Section titled “Gated on owner (NOT slice-complete criteria)”- Fleet deploy to
.11/.12/.13in lockstep (await explicit “dale”). - Cluster Playwright E2E (multi-host fan-out, live panel) — run after owner go.
Appendix — reuse vs gap tally
Section titled “Appendix — reuse vs gap tally”- Reusable pieces (extend, don’t build): task model/registry/handle,
task_helper, alarm upsert/registry/auto-resolve +alarms_active_uniqueindex, detector registry + 8 suppliers + 8 callback types,EventBussingle-stream +SubscribeFilter, full REST surface (global/host/vm-scoped) + events WS, HTTP host-proxy +/cloud/healthrollup +PeerRegistry, and the web-ux-v2 store/stream/UI primitives — ~30 named symbols across backend + frontend. - Net-new (gaps to close):
origin_node+ typed target on tasks/alarms; cancel REST; result in list; 4 missing task kinds; wireissue_occurrencesrule ledger; per-host observability WS relay; DB-layer history filters + composite indices; bulk/silence ack API; AppShell bottom dock +pushTask/pushAlarmstore handlers — 9 backend + 3 frontend themes, mapped to slices B1-B5 / F1-F3. - Existing alarm sources enumerated: 40 detectors (37 active across host CPU/mem/load/swap/ ntp/dns/log-rate/kernel + storage×5 + network×5 + cert×2 + BMC×5 + VM liveness×2 + VM resources×10; 3 deferred stubs: consolidation, snapshot-age, orphan-disk).