namespace eval ::ns::stats {}
::nsf::object::alloc ::nx::Class ::ns::stats::SQLiteHistory {}
::ns::stats::SQLiteHistory public method {history configure} {-pool:required {-retention 86400} {-maxBytes 67108864}} {
set retention [ns_baseunit -time $retention]
set maxBytes [ns_baseunit -size $maxBytes]
if {$retention < 60 || $retention > 604800 || $maxBytes < 4194304} {
error "retention must be 60..604800 seconds and maxBytes at least 4 MiB"
}
if {[info commands ns_db] eq ""} {
error "history requires nsdb/nsdbsqlite"
}
set config [dict create enabled true pool $pool retention $retention maxBytes $maxBytes owner [ns_info server] busy false scheduler {} generation [clock microseconds] lastSaved {} failures 0 lastFailure {} lastLoggedAt 0 gaps {} mixins [:info object mixins] topology {} topologyAt 0]
set old [nsv_get ns_stats history]
if {$old ne "" && [dict get $old enabled]} {
foreach k {pool retention maxBytes owner} {
if {[dict get $old $k] ne [dict get $config $k]} {
error "history already configured differently; disable before reconfiguring"
}
}
return [:history status]
}
set db [:historyConnect $config]
try {
:historySchema $db
} finally {
ns_db releasehandle $db
}
:locked {
set old [nsv_get ns_stats history]
if {$old ne "" && [dict get $old enabled]} {
error "history was concurrently configured; retry"
}
dict set config scheduler [ns_schedule_proc -thread 1 [self] history persist]
nsv_set ns_stats history $config
}
return [:history status]
}
::ns::stats::SQLiteHistory public method {history disable} {} {
:locked {
set config [nsv_get ns_stats history]
if {$config ne ""} {
if {[dict get $config enabled] && [dict get $config scheduler] ne ""} {
ns_unschedule_proc [dict get $config scheduler]
}
dict set config enabled false
dict set config scheduler {}
nsv_set ns_stats history $config
}
}
return [:history status]
}
::ns::stats::SQLiteHistory public method {history query} {-from:integer,required -to:integer,required {-server {}} {-limit:integer 1000} {-cursor:integer 0}} {
if {$from < 0
|| $to <= $from
|| $to - $from > 86400
|| $to > [clock seconds]
|| $limit < 1
|| $limit > 1000
|| $cursor < 0
} {
error "expected an epoch window up to 24 hours, limit 1..1000 and nonnegative cursor"
}
if {[string length $server]>512 || [string first \x00 $server] >= 0} {
error "invalid server name"
}
set config [nsv_get ns_stats history]
if {$config eq ""} {
error "history is not configured"
}
if {[dict get $config owner] ne [ns_info server]} {
error "query history from its configured owner server"
}
set db [:historyConnect $config]
set transaction false
try {
::ns::stats::sql $db {BEGIN}
set transaction true
set low [expr {$from * 1000000}]
set high [expr {$to * 1000000}]
set count [expr {$limit + 1}]
set rows [::ns::stats::sql $db "SELECT at,topology,payload FROM ns_stats_samples WHERE at >= $low AND at < $high AND at > $cursor ORDER BY at LIMIT $count"]
set more [expr {[llength $rows]>$limit}]
set rows [lrange $rows 0 [expr {$limit - 1}]]
set records {}
set gaps {}
set restarts {}
set previous {}
set topology {}
set resets 0
set boundary [expr {max($low,$cursor)}]
set operator [expr {$cursor >= $low ? "<=" : "<"}]
set prior [::ns::stats::scalar $db "SELECT payload FROM ns_stats_samples WHERE at $operator $boundary ORDER BY at DESC LIMIT 1"]
if {$prior ne ""} {
set previous [::ns::stats::unpack $prior]
}
foreach row $rows {
set record [::ns::stats::unpack [dict get $row payload]]
if {![dict get $record sampling ready]} {
incr resets
}
if {$previous ne ""} {
if {[dict get $previous instance] ne [dict get $record instance]} {
lappend restarts [dict get $record sampling to]
}
set delta [expr {([dict get $record timestamp]-[dict get $previous timestamp])/1000000.0}]
if {$delta > 1.5 * max([dict get $previous sampling intervalSeconds],[dict get $record sampling intervalSeconds])} {
lappend gaps [dict create from [dict get $previous sampling to] to [dict get $record sampling to]]
}
}
set previous $record
set id [dict get $row topology]
dict set record topologyId $id
if {![dict exists $topology $id]} {
dict set topology $id [:selectServer [::ns::stats::unpack [::ns::stats::scalar $db "SELECT payload FROM ns_stats_topology WHERE id=[::ns::stats::quote $id]"]] $server false]
}
lappend records [:selectServer $record $server false]
}
if {!$more && $previous ne ""} {
set following [::ns::stats::scalar $db "SELECT payload FROM ns_stats_samples WHERE at >= $high ORDER BY at LIMIT 1"]
if {$following ne ""} {
set nextRecord [::ns::stats::unpack $following]
set delta [expr {([dict get $nextRecord timestamp]-[dict get $previous timestamp])/1000000.0}]
if {$delta > 1.5 * max([dict get $previous sampling intervalSeconds],[dict get $nextRecord sampling intervalSeconds])} {
lappend gaps [dict create from [dict get $previous sampling to] to [dict get $nextRecord sampling to]]
}
}
}
set bounds [lindex [::ns::stats::sql $db {SELECT coalesce(min(at),0) AS first,coalesce(max(at),0) AS last FROM ns_stats_samples}] 0]
set events [::ns::stats::sql $db {SELECT at,first,last,n,reason FROM ns_stats_events ORDER BY id DESC}]
set first [dict get $bounds first]
set last [dict get $bounds last]
set overlap false
foreach event $events {
if {[dict get $event first] < $high && [dict get $event last] >= $low} {
set overlap true
}
}
set through [::ns::stats::scalar $db {SELECT value FROM ns_stats_meta WHERE key='droppedThrough'}]
if {$through ne "" && $through >= $low} {
set overlap true
}
set next {}
if {$more} {
set next [dict get [lindex $rows end] at]
}
set complete [expr {$first > 0 && $first <= $low && $last >= $high && !$more && $cursor == 0
&& !$overlap && $gaps eq "" && $restarts eq "" && $resets == 0}]
set result [dict create schemaVersion 1 from $from to $to records $records topologies $topology nextCursor $next more $more coverage [dict create complete $complete windowExamined [expr {!$more && $cursor == 0}] retainedFrom [expr {$first / 1000000.0}] retainedTo [expr {$last / 1000000.0}] gaps $gaps restartBoundaries $restarts resetSamples $resets events $events evictionOrFailureOverlap $overlap droppedThrough $through writeFailuresSinceStart [dict get $config failures]]]
::ns::stats::sql $db {COMMIT}
set transaction false
return $result
} finally {
if {$transaction} {
catch {::ns::stats::sql $db {ROLLBACK}}
}
ns_db releasehandle $db
}
}
::ns::stats::SQLiteHistory public method {history save} {} {
set config {}
set generation {}
set stamp {}
:locked {
set candidate [nsv_get ns_stats history]
set sample [nsv_get ns_stats pair]
if {$candidate ne "" && [dict get $candidate enabled] && ![dict get $candidate busy] && $sample ne ""} {
set stamp [dict get $sample timestamp]
if {$stamp ne [dict get $candidate lastSaved]} {
set config $candidate
set generation [dict get $config generation]
dict set candidate busy true
nsv_set ns_stats history $candidate
}
}
}
if {$config eq ""} {
return
}
set db {}
set transaction false
set ok false
set errorMessage {}
try {
set now [clock seconds]
set topology [dict get $config topology]
if {$topology eq "" || $now-[dict get $config topologyAt]>=60} {
set topology [:describe]
foreach k {pid bootTime uptimeSeconds} {dict unset topology process $k}
dict set config topology $topology
dict set config topologyAt $now
}
set topologyId [format %08x [zlib crc32 [encoding convertto utf-8 $topology]]]
set record $sample
dict unset record raw
dict unset record cpu
dict set record topologyId $topologyId
set payload [encoding convertto utf-8 $record]
set rawSize [string length $payload]
if {$rawSize > 1048576} {
error "history sample exceeds 1 MiB"
}
set packed [::ns::stats::pack $record]
set packedTopology [::ns::stats::pack $topology]
set size [expr {[string length $packed]+[string length $packedTopology]}]
set db [:historyConnect $config]
:historySchema $db
::ns::stats::sql $db {BEGIN IMMEDIATE}
set transaction true
set cutoff [expr {([clock seconds]-[dict get $config retention])*1000000}]
:historyDrop $db "at < $cutoff" age
set page [::ns::stats::scalar $db {PRAGMA page_size}]
set max [::ns::stats::scalar $db {PRAGMA max_page_count}]
while {1} {
set used [expr {([::ns::stats::scalar $db {PRAGMA page_count}] - [::ns::stats::scalar $db {PRAGMA freelist_count}]) * $page}]
if {$used+3*$size < $max*$page*0.8} {
break
}
if {![::ns::stats::scalar $db {SELECT count(*) FROM ns_stats_samples}]} {
error "history metadata or sample exceeds storage budget"
}
:historyDrop $db {at IN (SELECT at FROM ns_stats_samples ORDER BY at LIMIT 100)} size
}
set stored [::ns::stats::scalar $db "SELECT payload FROM ns_stats_topology WHERE id=[::ns::stats::quote $topologyId]"]
if {$stored ne "" && [::ns::stats::unpack $stored] ne $topology} {
error "history topology fingerprint collision"
}
::ns::stats::sql $db "INSERT OR IGNORE INTO ns_stats_topology VALUES([::ns::stats::quote $topologyId],[::ns::stats::quote $packedTopology])"
::ns::stats::sql $db "INSERT OR IGNORE INTO ns_stats_samples VALUES($stamp,[::ns::stats::quote [dict get $record instance]],[::ns::stats::quote $topologyId],[::ns::stats::quote $packed])"
foreach gap [dict get $config gaps] {
lassign $gap first last n
::ns::stats::sql $db "INSERT INTO ns_stats_events(at,first,last,n,reason) VALUES($stamp,$first,$last,$n,'write-failure')"
}
::ns::stats::sql $db {DELETE FROM ns_stats_events WHERE id NOT IN (SELECT id FROM ns_stats_events ORDER BY id DESC LIMIT 64)}
::ns::stats::sql $db {DELETE FROM ns_stats_topology WHERE id NOT IN (SELECT DISTINCT topology FROM ns_stats_samples)}
::ns::stats::sql $db {COMMIT}
set transaction false
set ok true
} on error {message options} {
set errorMessage $message
if {$transaction} {
catch {::ns::stats::sql $db {ROLLBACK}}
}
if {$message ne [dict get $config lastFailure] || [clock seconds]-[dict get $config lastLoggedAt] >= 60} {
ns_log warning "ns_stats: history write failed: $message"
dict set config lastLoggedAt [clock seconds]
}
} finally {
if {$db ne ""} {
ns_db releasehandle $db
}
:locked {
set current [nsv_get ns_stats history]
if {$generation eq [dict get $current generation]} {
dict set current busy false
dict set current lastLoggedAt [dict get $config lastLoggedAt]
if {$ok} {
dict set current lastSaved $stamp
dict set current lastFailure {}
dict set current gaps {}
dict set current topology [dict get $config topology]
dict set current topologyAt [dict get $config topologyAt]
} else {
dict incr current failures
dict set current lastFailure $errorMessage
set gaps [dict get $current gaps]
if {$gaps eq ""} {
set gaps [list [list $stamp $stamp 1]]
} else {
lassign [lindex $gaps 0] first last n
set gaps [list [list $first $stamp [expr {$n+1}]]]
}
dict set current gaps $gaps
}
nsv_set ns_stats history $current
}
}
}
}
::ns::stats::SQLiteHistory public method {history status} {} {
set config [nsv_get ns_stats history]
if {$config eq ""} {
return {enabled false}
}
dict unset config busy
dict unset config mixins
dict unset config topology
dict unset config gaps
return $config
}
::ns::stats::SQLiteHistory protected method historyConnect config {
set db [ns_db gethandle -timeout 1 [dict get $config pool]]
if {$db eq ""} {
error "history pool checkout timed out"
}
try {
if {[ns_db dbtype $db] ne "sqlite"} {
error "history requires an nsdbsqlite pool"
}
set path [ns_db datasource $db]
if {$path eq "" || $path eq ":memory:" || [string match file:* $path]} {
error "history requires a persistent file datasource"
}
file attributes $path -permissions 0600
if {[::ns::stats::scalar $db {
SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT GLOB 'ns_stats_*' AND name NOT GLOB 'sqlite_*'}]
} {
error "history requires a dedicated database"
}
foreach query {
{PRAGMA busy_timeout=1000}
{PRAGMA journal_mode=DELETE}
{PRAGMA synchronous=FULL}
{PRAGMA temp_store=MEMORY}
} {
::ns::stats::sql $db $query
}
set page [::ns::stats::scalar $db {PRAGMA page_size}]
set maximum [expr {int([dict get $config maxBytes]*0.45)/$page}]
if {[::ns::stats::scalar $db {PRAGMA page_count}]>$maximum} {
error "existing history database exceeds byte budget"
}
::ns::stats::sql $db "PRAGMA max_page_count=$maximum"
return $db
} on error {message options} {
ns_db releasehandle $db
return -options $options $message
}
}
::ns::stats::SQLiteHistory protected method historyDrop {db where reason} {
set stats [lindex [::ns::stats::sql $db "SELECT count(*) AS n,coalesce(min(at),0) AS first,coalesce(max(at),0) AS last FROM ns_stats_samples WHERE $where"] 0]
set n [dict get $stats n]
if {!$n} {
return
}
set first [dict get $stats first]
set last [dict get $stats last]
set now [clock microseconds]
::ns::stats::sql $db "DELETE FROM ns_stats_samples WHERE $where"
::ns::stats::sql $db {DELETE FROM ns_stats_topology WHERE id NOT IN (SELECT DISTINCT topology FROM ns_stats_samples)}
::ns::stats::sql $db "INSERT INTO ns_stats_events(at,first,last,n,reason) VALUES($now,$first,$last,$n,[::ns::stats::quote $reason])"
set prior [::ns::stats::scalar $db {SELECT value FROM ns_stats_meta WHERE key='droppedThrough'}]
set through [expr {$prior eq "" ? $last : max($last,$prior)}]
::ns::stats::sql $db "INSERT OR REPLACE INTO ns_stats_meta VALUES('droppedThrough','$through')"
}
::ns::stats::SQLiteHistory protected method historySchema db {
foreach query {
{CREATE TABLE IF NOT EXISTS ns_stats_meta (key TEXT PRIMARY KEY,value TEXT NOT NULL)}
{CREATE TABLE IF NOT EXISTS ns_stats_topology (id TEXT PRIMARY KEY,payload TEXT NOT NULL)}
{CREATE TABLE IF NOT EXISTS ns_stats_samples (at INTEGER PRIMARY KEY,instance TEXT NOT NULL,topology TEXT NOT NULL,payload TEXT NOT NULL)}
{CREATE TABLE IF NOT EXISTS ns_stats_events (id INTEGER PRIMARY KEY,at INTEGER NOT NULL,first INTEGER NOT NULL,last INTEGER NOT NULL,n INTEGER NOT NULL,reason TEXT NOT NULL)}
} {
::ns::stats::sql $db $query
}
set version [::ns::stats::scalar $db {SELECT value FROM ns_stats_meta WHERE key='schemaVersion'}]
if {$version ne "" && $version ne "1"} {
error "unsupported diagnostics history schema"
}
set source [::ns::stats::quote [list [ns_info hostname] [ns_info home]]]
set stored [::ns::stats::scalar $db {SELECT value FROM ns_stats_meta WHERE key='source'}]
if {$stored ne "" && [::ns::stats::quote $stored] ne $source} {
error "history database belongs to another process installation"
}
::ns::stats::sql $db {INSERT OR IGNORE INTO ns_stats_meta VALUES('schemaVersion','1')}
::ns::stats::sql $db {INSERT OR IGNORE INTO ns_stats_meta VALUES('encoding','tcl-zlib-hex-v1')}
::ns::stats::sql $db "INSERT OR IGNORE INTO ns_stats_meta VALUES('source',$source)"
}
::nx::slotObj -container slot ::ns::stats::SQLiteHistory