scan-pipeline
GitHub解析扫描流水线中设备存在性生命周期,涵盖CurrentScan、Events、Sessions等核心表与视图关系,说明会话闭合机制及字段写入权限,辅助修改session_events.py等设备处理逻辑。
Trigger Scenarios
Install
npx skills add netalertx/NetAlertX --skill scan-pipeline -g -y
SKILL.md
Frontmatter
{
"name": "scan-pipeline",
"description": "Reference for how the scan pipeline actually works — process_scan()'s call order, the CurrentScan\/Events\/Sessions\/DevicesView relationships, how a session actually \"closes\" (there is no close function), and the FIELD_SPECS field-write authority mechanism. Load this before modifying server\/scan\/session_events.py or server\/scan\/device_handling.py, or when reasoning about device presence, connect\/disconnect events, or session\/timeline behavior."
}
Scan Pipeline & Device Presence Lifecycle
Scope
Covers what happens after a plugin's rows land in CurrentScan: presence computation, event generation, session/timeline derivation. Not plugin authoring (manifest, data contract, settings) — see plugin-development and docs/PLUGINS_DEV*.md. Not the general *Source attribution system or SQLite audit triggers — see database-patterns; the field-authority section below is the scan-pipeline-local half of that system.
Core tables and views
CurrentScan— ephemeral scratch table.process_plugin_events()populates it for any plugin whoseconfig.jsondeclaresmapped_to_table;process_scan()deletes all rows at the end of every cycle. A value written to aCurrentScanrow is not readable in a later cycle — the row is gone by then. See Gotcha 2.Devices— persistent identity + state table.Events— persistent, append-only log of state-transition events (New Device,Connected,Down Reconnected,Device Down,Disconnected,IP Changed). This is the audit trail;Sessionsis derived from it, not the reverse.Sessions— fully wiped and rebuilt every cycle fromConvert_Events_to_Sessions(below), not incrementally updated. Treat it as a materialized query result, not a live connection state machine.Online_History— one row per scan cycle, feeds the dashboard's online/offline graph. A rollup ofdevPresentLastScan/devAlertDown/devIsSleepingcounts onDevicesView— no state of its own.
Key views
LatestDeviceScan(server/db/db_upgrade.py) —DevicesLEFT JOIN'd to the most recentCurrentScanrow per(scanMac, scanSourcePlugin)pair, viaROW_NUMBER() OVER (PARTITION BY scanMac, scanSourcePlugin ...).update_devices_data_from_scan()loops overDISTINCT scanSourcePluginand re-queries this view once per plugin: when two plugins report the same device in one cycle, each contribution is evaluated separately, per field, through the authority mechanism below — they are not merged into one row first.LatestEventsPerMAC— most recent Event per MAC, joined toDevicesandCurrentScan. The "New Connections" query ininsert_events()uses it to decide whether a device was previously down (→Down Reconnected) or new (→Connected).Convert_Events_to_Sessions— defines "is this device's session still open." There is noclose_session()function anywhere in this codebase. A session closes as an emergent property:pair_sessions_events()setsevePairEventRowidon aNew Device/Connected/Down ReconnectedEvent to point at the nextDisconnected/Device DownEvent for that MAC; this view setssesStillConnected = 1exactly when that pairing isNULL. To close a session, insert the rightEventsrow — never mutateSessionsdirectly (the one exception iscreate_new_devices()'s reconnect-insert, in the call order below).DevicesView— adds computeddevIsSleeping/devFlapping/devStatuson top ofDevices. The UI andinsertOnlineHistory()read presence from this, not the rawDevicestable.
process_scan() call order (server/scan/session_events.py) — order is load-bearing
save_own_device(),exclude_ignored_devices()insert_events(db)— runs before presence updates for this cycle. The Down/Disconnected/Connected queries need the previous cycle'sdevPresentLastScanto detect a transition. If this ran after the presence update, every query would see the new value and the edge-triggered design would break — firing never, or every cycle.create_new_devices(db)— runs before presence updates so a brand-new device gets aNew Deviceevent, not aConnectedevent (it has noDevicesrow yet for step 2's queries to match). Also has a rawINSERT INTO Sessions ... sesStillConnected = 1for existing devices with no open session — the one place outside theEvents-derived path that writesSessionsdirectly.update_devices_data_from_scan(db)— field-level updates for existing devices; see the authority mechanism below.update_sync_hub_node,update_devLastConnection_from_CurrentScanupdate_presence_from_CurrentScan(db)— setsdevPresentLastScanfromCurrentScanfor this cycle (step 2 reads this as "previous" on the next cycle).update_devPresentLastScan_based_on_nics(db)— NIC/parent-child presence aggregation; can override step 6 for parent devices.update_devPresentLastScan_based_on_force_status(db)— the user's manualdevForceStatusoverride; runs last, wins over everything above.update_vendors_from_mac,update_ipv4_ipv6,update_icons_and_types—update_ipv4_ipv6()does not go throughLatestDeviceScan/FIELD_SPECS; it readsCurrentScandirectly with its ownPARTITION BY scanMac, address_familyranking, so a device reporting both an IPv4 and an IPv6 row in the same cycle gets bothdevPrimaryIPv4/devPrimaryIPv6set from that cycle, not just whichever family happened to windevLastIP's single-value reduction.pair_sessions_events(db)— pairsEventsrows as described above.create_sessions_snapshot(db)—DELETE FROM Sessions; INSERT INTO Sessions SELECT * FROM Convert_Events_to_Sessions.Sessionsreflects step 10's pairing from here.insertOnlineHistory(db)— dashboard graph rollup.skip_repeated_notifications(db)DELETE FROM CurrentScan— the table's entire lifetime is one call toprocess_scan().
Field-write authority for scan-derived updates
update_devices_data_from_scan() (server/scan/device_handling.py) does not overwrite fields from whichever plugin ran most recently. Each trackable field is declared once in FIELD_SPECS (scan_col, source_col, a priority list of plugin prefixes, optional allow_override_if_changed). can_overwrite_field() uses that plus get_plugin_authoritative_settings() (a plugin's own authority-override setting, if any) to decide, per field per row, whether this plugin's value may replace what's there. The paired <field>Source column (devNameSource, devLastIPSource, etc.) records who currently owns the field. devMac is never a target of these updates — it's the join key, not a tracked field — so no scan-derived update can alter a device's identity, only its attributes.
This is the scan-pipeline-local half of a bigger attribution system — see database-patterns for FIELD_SOURCE_MAP/server/db/authoritative_handler.py, the full *Source model, and the SQLite triggers that consume it for audit logging. Read both before touching anything that writes a *Source column.
Gotchas
- A "presence" check exists in more than one place. A per-row signal meaning "don't count this as a live sighting" (e.g.
scanPresence) has to reach every query that independently re-derives "is this MAC currently present" fromCurrentScan.current_scan_presence_condition()(server/scan/presence.py) centralizes that check for five sites:update_presence_from_CurrentScan()(both statements),update_devLastConnection_from_CurrentScan(), and three ofinsert_events()'s four queries (bothDevice Downvariants,Disconnected). Two sites can't use it: the "New Connections" query and the rawSessionsinsert increate_new_devices()need the actualscanLastIP/scanVendorvalue off the presence-asserting row viaMIN()/GROUP BY, not just a boolean. Check any new presence-adjacent query against both patterns — a bare helper call isn't always enough. CurrentScanis deleted at the end of every cycle — a per-row flag on it can't express a decision that needs to survive to a cycle where the row is gone. Anything that fires because a row is missing (Device Down,Disconnected) can't read a flag that lived on that row. A per-row plugin signal that needs to affect behavior beyond its own cycle has to persist onto theDevicesrow at creation time (e.g. seedingdevAlertDown/devAlertEventsfrom the row's flag instead of the globalNEWDEV_*defaults), not ride on the ephemeral table.CurrentScanis not small, and it's indexed onscanMac. Real production users run 10,000+ devices; with one row per contributing plugin (seeLatestDeviceScanabove), a single cycle'sCurrentScanis routinely 20,000-50,000+ rows.idx_currentscan_scanmac(server/db/db_upgrade.py:ensure_CurrentScan(), mirrored inserver/db/schema/app.sql) covers everyscanMac-keyed lookup in this file.ensure_CurrentScan()'sDROP TABLE/CREATE TABLEruns once, at app startup (DB.initDB(),server/__main__.py) — don't confuse this with the per-cycleDELETE FROM CurrentScanin point 1, which clears rows but leaves the table and its index in place.server/plugins/sync/sync.pybypasses this pipeline on purpose, twice — a permanent exception, not a bug. It fires its own directINSERT OR IGNORE INTO Events (... 'New Device' ...)for newly-seen synced devices (hardcodedevePendingAlertEmail = 1, noscanNotificationModeawareness), and incarbon-copymode its own rawDevicesUPSERT viaON CONFLICT(devMac) DO UPDATE— both skipcreate_new_devices()/update_devices_data_from_scan()/can_overwrite_field()(sync.py's own comments: "Node is fully authoritative in this mode"). It's a normalmapped_to_table: CurrentScanplugin for its presence contribution, soIMPORT_ON/scanPresenceapply to it like any other plugin — but its two direct-write paths ignorescanNotificationMode = 'quiet'orscanCreatesDevice = 0. Don't assume everyEvents/Deviceswrite goes through the generic pipeline —sync.pydoesn't.- A blank/null-equivalent
scanMaccan create a phantomDevicesrow.create_new_devices()'s two creation-path queries filterscanMac NOT IN (NULL_EQUIVALENTS_SQL)(server/scan/device_handling.py,const.NULL_EQUIVALENTS_SQL) as a backstop, becausescanCreatesDevicedefaults to1— any plugin reporting a row with no real MAC, without settingscanCreatesDevice = 0itself, would otherwise create adevMac = ''device, and every other blank-MAC row from every other plugin would then silently write onto it. The filter doesn't replacescanCreatesDevice = 0as the correct thing for a plugin to set on such rows; it keeps a MAC-less row inert when some other plugin forgets to. Check any new creation-adjacent query against blankscanMactoo. app.sqlis not dead code.install/production-filesystem/entrypoint.d/25-first-run-db.shpipes it intosqlite3to bootstrap a brand-new database on first install;scripts/db_cleanup/regenerate-database.shuses it too.CurrentScan,Parameters, andSettingsare safe from drift: each has a dedicatedensure_X()function (server/db/db_upgrade.py) that drops and recreates the table on every startup, superseding whateverapp.sqlbootstrapped.Plugins_Language_Stringsgets the same treatment inside the sharedensure_plugins_tables().AppEventsgets its own drop/recreate viaAppEvent_obj.__init__()(server/workflows/app_events.py), independent ofdb_upgrade.py.Deviceshas no drop/recreate, butserver/database.pyhas 18 explicitensure_column()calls that backfill any column missing from an olderapp.sqlsnapshot on every startup.Events,Sessions, andNotificationsget the same backfill viaensure_table_columns()(server/db/db_upgrade.py), driven by one Python column-list constant per table (server/db/schema_columns.py) that's diffed againstapp.sqlin CI (test/db/test_schema_drift_guard.py).AppEvents/Notificationseach also have a second schema-definition surface — their own inlineCREATE TABLE IF NOT EXISTSinserver/workflows/app_events.py/server/models/notification_instance.py— kept in sync by the same drift-check test. Check any new query here withEXPLAIN QUERY PLANat a realistic row count rather than assuming it's fine because it resembles an existing one — a correlated subquery re-evaluated per row (an accidental self-join) is the pattern most likely to look reasonable while actually being quadratic at this scale.
When to read this vs. other docs/skills
- Writing or reviewing a plugin's
config.json/data contract →plugin-development,docs/PLUGINS_DEV*.md. This skill covers what happens after a plugin's rows land inCurrentScan, not the authoring contract. - Devices-table write paths,
*Sourceattribution, audit/history logging, SQLite triggers →database-patterns. - Implementing a change here → read the actual function in
server/scan/session_events.py/server/scan/device_handling.pyfirst; this skill's line numbers are a map, not a guarantee, and drift as the code moves.
Version History
- f6010e0 Current 2026-09-27 22:53
- cd1d0ed 2026-09-22 11:07


