namespace eval ::ns::stats {}
::nsf::object::alloc ::nx::Class ::ns::stats::SQLiteHistory {}
::ns::stats::SQLiteHistory protected method allocatorPoint record {
set memory {}
foreach key {current_allocated_bytes heap_size total_physical_bytes} {
if {[dict exists $record process allocatorBytes $key]} {
dict set memory $key [dict get $record process allocatorBytes $key]
}
}
return [dict create timestamp [dict get $record timestamp] instance [dict get $record instance] sampling [dict create intervalSeconds [dict get $record sampling intervalSeconds]] memory $memory]
}
::ns::stats::SQLiteHistory public method {history allocator} {-from:integer,required -to:integer,required {-limit:integer 10000} {-cursor:integer 0}} {
set started [clock microseconds]
if {$from < 0 || $to <= $from || $to-$from > 86400 || $to > [clock seconds]
|| $limit < 1 || $limit > 10000 || $cursor < 0} {
error "expected an epoch window up to 24 hours, limit 1..10000 and nonnegative cursor"
}
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 checkedOut [clock microseconds]
set transaction false
try {
:sql $db {BEGIN}
set transaction true
set columns [lmap row [:sql $db {SELECT name FROM pragma_table_info('ns_stats_samples')}] {dict get $row name}]
if {"allocator" in $columns} {
set projection {coalesce(allocator,'') AS allocator,CASE WHEN allocator IS NULL THEN payload ELSE '' END AS legacy}
} else {
set projection {'' AS allocator,payload AS legacy}
}
set low [expr {$from*1000000}]; set high [expr {$to*1000000}]
set beforeRead [clock microseconds]
set rows [:sql $db "SELECT at,instance,$projection FROM ns_stats_samples WHERE at >= $low AND at < $high AND at > $cursor ORDER BY at LIMIT [expr {$limit+1}]"]
set afterRead [clock microseconds]
set more [expr {[llength $rows]>$limit}]
set records {}; set legacy 0
foreach row [lrange $rows 0 [expr {$limit-1}]] {
if {[dict get $row allocator] eq ""} {
set point [:allocatorPoint [:unpack [dict get $row legacy]]]
incr legacy
} else {
set point [dict get $row allocator]
}
lappend records $point
}
set decoded [clock microseconds]
:sql $db {COMMIT}
set transaction false
return [dict create schemaVersion 1 source sqlite projection allocator from $from to $to records $records more $more nextCursor [expr {$more ? [dict get [lindex $rows $limit-1] at] : ""}] legacyDecoded $legacy coverage [dict create complete false windowExamined false incompleteReasons [expr {$more ? {allocator-projection-not-certified more-pages} : {allocator-projection-not-certified}}]] timings [dict create checkoutMs [expr {($checkedOut-$started)/1000.0}] readMs [expr {($afterRead-$beforeRead)/1000.0}] decodeMs [expr {($decoded-$afterRead)/1000.0}] totalMs [expr {([clock microseconds]-$started)/1000.0}]]]
} finally {
if {$transaction} {catch {:sql $db {ROLLBACK}}}
ns_db releasehandle $db
}
}
::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 {
:sql $db {BEGIN}
set transaction true
set low [expr {$from * 1000000}]
set high [expr {$to * 1000000}]
set count [expr {$limit + 1}]
set rows [: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 [:scalar $db "SELECT payload FROM ns_stats_samples WHERE at $operator $boundary ORDER BY at DESC LIMIT 1"]
if {$prior ne ""} {
set previous [:unpack $prior]
}
foreach row $rows {
set record [: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 [:unpack [:scalar $db "SELECT payload FROM ns_stats_topology WHERE id=[ns_dbquotevalue $id]"]] $server false]
}
lappend records [:selectServer $record $server false]
}
if {!$more && $previous ne ""} {
set following [:scalar $db "SELECT payload FROM ns_stats_samples WHERE at >= $high ORDER BY at LIMIT 1"]
if {$following ne ""} {
set nextRecord [: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 [:sql $db {SELECT coalesce(min(at),0) AS first,coalesce(max(at),0) AS last FROM ns_stats_samples}] 0]
set events [: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 [: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]
}
if {!$more} {
set window [:historyWindowCoverage $db $low $high]
set gaps [dict get $window gaps]
set restarts [dict get $window restartBoundaries]
set resets [dict get $window resetSamples]
}
set complete [expr {$first>0 && $first<=$low && $last>=$high && !$more
&& !$overlap && $gaps eq "" && $restarts eq "" && $resets==0}]
set reasons {}
if {$more} {lappend reasons more-pages}
if {$first==0 || $first>$low} {lappend reasons before-retained-history}
if {$last<$high} {lappend reasons after-latest-sample}
if {$overlap} {lappend reasons eviction-or-write-failure}
if {$gaps ne ""} {lappend reasons gaps}
if {$restarts ne ""} {lappend reasons restarts}
if {$resets>0} {lappend reasons baseline-or-reset}
set result [dict create schemaVersion 1 from $from to $to records $records topologies $topology nextCursor $next more $more storage [:historyStorage $db $config] coverage [dict create complete $complete windowExamined [expr {!$more}] scope [expr {$more ? "page" : "window"}] incompleteReasons $reasons 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]]]
:sql $db {COMMIT}
set transaction false
return $result
} finally {
if {$transaction} {
catch {: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 [:pack $record]
set packedTopology [:pack $topology]
set allocator [:allocatorPoint $record]
set size [expr {[string length $packed]+[string length $packedTopology]+[string length $allocator]}]
set db [:historyConnect $config]
:historySchema $db
:sql $db {BEGIN IMMEDIATE}
set transaction true
set cutoff [expr {([clock seconds]-[dict get $config retention])*1000000}]
:historyDrop $db "at < $cutoff" age
set page [:scalar $db {PRAGMA page_size}]
set max [:scalar $db {PRAGMA max_page_count}]
while {1} {
set used [expr {([:scalar $db {PRAGMA page_count}] - [:scalar $db {PRAGMA freelist_count}]) * $page}]
if {$used+3*$size < $max*$page*0.8} {
break
}
if {![: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 [:scalar $db "SELECT payload FROM ns_stats_topology WHERE id=[ns_dbquotevalue $topologyId]"]
if {$stored ne "" && [:unpack $stored] ne $topology} {
error "history topology fingerprint collision"
}
:sql $db "INSERT OR IGNORE INTO ns_stats_topology VALUES([ns_dbquotevalue $topologyId],[ns_dbquotevalue $packedTopology])"
:sql $db "INSERT OR IGNORE INTO ns_stats_samples(at,instance,topology,payload,allocator) VALUES($stamp,[ns_dbquotevalue [dict get $record instance]],[ns_dbquotevalue $topologyId],[ns_dbquotevalue $packed],[ns_dbquotevalue $allocator])"
foreach gap [dict get $config gaps] {
lassign $gap first last n
:sql $db "INSERT INTO ns_stats_events(at,first,last,n,reason) VALUES($stamp,$first,$last,$n,'write-failure')"
}
:sql $db {DELETE FROM ns_stats_events WHERE id NOT IN (SELECT id FROM ns_stats_events ORDER BY id DESC LIMIT 64)}
:sql $db {DELETE FROM ns_stats_topology WHERE id NOT IN (SELECT DISTINCT topology FROM ns_stats_samples)}
:sql $db {COMMIT}
set transaction false
set ok true
} on error {message options} {
set errorMessage $message
if {$transaction} {
catch {: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 public method {history storage} {} {
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]
try {
:sql $db {BEGIN}
try {
set result [:historyStorage $db $config]
} finally {
:sql $db {ROLLBACK}
}
return $result
} finally {
ns_db releasehandle $db
}
}
::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 {[: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}
} {
:sql $db $query
}
set page [:scalar $db {PRAGMA page_size}]
set maximum [expr {int([dict get $config maxBytes]*0.45)/$page}]
if {[:scalar $db {PRAGMA page_count}]>$maximum} {
error "existing history database exceeds byte budget"
}
: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 [: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]
:sql $db "DELETE FROM ns_stats_samples WHERE $where"
:sql $db {DELETE FROM ns_stats_topology WHERE id NOT IN (SELECT DISTINCT topology FROM ns_stats_samples)}
:sql $db "INSERT INTO ns_stats_events(at,first,last,n,reason) VALUES($now,$first,$last,$n,[ns_dbquotevalue $reason])"
set prior [:scalar $db {SELECT value FROM ns_stats_meta WHERE key='droppedThrough'}]
set through [expr {$prior eq "" ? $last : max($last,$prior)}]
: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)}
} {
:sql $db $query
}
set columns [lmap row [:sql $db {SELECT name FROM pragma_table_info('ns_stats_samples')}] {dict get $row name}]
if {"allocator" ni $columns} {
:sql $db {ALTER TABLE ns_stats_samples ADD COLUMN allocator TEXT}
}
set version [: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_dbquotevalue [list [ns_info hostname] [ns_info home]]]
set stored [:scalar $db {SELECT value FROM ns_stats_meta WHERE key='source'}]
if {$stored ne "" && [ns_dbquotevalue $stored] ne $source} {
error "history database belongs to another process installation"
}
:sql $db {INSERT OR IGNORE INTO ns_stats_meta VALUES('schemaVersion','1')}
:sql $db {INSERT OR IGNORE INTO ns_stats_meta VALUES('encoding','tcl-zlib-hex-v1')}
:sql $db "INSERT OR IGNORE INTO ns_stats_meta VALUES('source',$source)"
}
::ns::stats::SQLiteHistory protected method historyStorage {db config} {
set bounds [lindex [:sql $db {SELECT count(*) AS n,coalesce(min(at),0) AS first,coalesce(max(at),0) AS last FROM ns_stats_samples}] 0]
set first [dict get $bounds first]
set last [dict get $bounds last]
set pageSize [:scalar $db {PRAGMA page_size}]
set pageCount [:scalar $db {PRAGMA page_count}]
set freePages [:scalar $db {PRAGMA freelist_count}]
set used [expr {($pageCount-$freePages)*$pageSize}]
set span [expr {($last-$first)/1000000.0}]
return [dict create retainedFrom [expr {$first>0 ? $first/1000000.0 : ""}] retainedTo [expr {$last>0 ? $last/1000000.0 : ""}] retainedSeconds $span sampleCount [dict get $bounds n] pageSize $pageSize allocatedBytes [expr {$pageCount*$pageSize}] usedBytes $used freeBytes [expr {$freePages*$pageSize}] databaseLimitBytes [expr {[:scalar $db {PRAGMA max_page_count}]*$pageSize}] maxBytes [dict get $config maxBytes] bytesPerSample [expr {[dict get $bounds n]>0 ? double($used)/[dict get $bounds n] : ""}] bytesPerSecond [expr {$span>0 ? $used/$span : ""}]]
}
::ns::stats::SQLiteHistory protected method historyWindowCoverage {db low high} {
set previous {}
set prior [:scalar $db "SELECT payload FROM ns_stats_samples WHERE at < $low ORDER BY at DESC LIMIT 1"]
if {$prior ne ""} {
set previous [:unpack $prior]
}
set gaps {}
set restarts {}
set resets 0
set cursor [expr {$low-1}]
set finished false
while {!$finished} {
set rows [:sql $db "SELECT at,payload FROM ns_stats_samples WHERE at > $cursor ORDER BY at LIMIT 128"]
if {$rows eq ""} {
break
}
foreach row $rows {
set cursor [dict get $row at]
set record [:unpack [dict get $row payload]]
if {$cursor<$high && ![dict get $record sampling ready]} {
incr resets
}
if {$previous ne ""} {
if {$cursor<$high && [dict get $previous instance] ne [dict get $record instance]} {
lappend restarts [dict get $record sampling to]
}
set delta [expr {($cursor-[dict get $previous timestamp])/1000000.0}]
if {$delta>1.5*max([dict get $previous sampling intervalSeconds],
[dict get $record sampling intervalSeconds])
|| ($cursor>=$high && (![dict get $record sampling ready]
|| [dict get $previous instance] ne [dict get $record instance]))} {
lappend gaps [dict create from [dict get $previous sampling to] to [dict get $record sampling to]]
}
}
if {$cursor >= $high} {
set finished true
break
}
set previous $record
}
}
return [dict create gaps $gaps restartBoundaries $restarts resetSamples $resets]
}
::ns::stats::SQLiteHistory protected method pack value {
return [binary encode hex [zlib compress [encoding convertto utf-8 $value] 1]]
}
::ns::stats::SQLiteHistory protected method scalar {db query} {
return [lindex [dict values [lindex [:sql $db $query] 0]] 0]
}
::ns::stats::SQLiteHistory protected method sql {db query} {
if {[ns_db exec $db $query] ne "NS_ROWS"} {
return {}
}
set row [ns_db bindrow $db]
set rows {}
try {
while {[ns_db getrow $db $row]} {
set result {}
for {set i 0} {$i<[ns_set size $row]} {incr i} {
dict set result [ns_set key $row $i] [ns_set value $row $i]
}
lappend rows $result
}
} finally {
ns_db flush $db
ns_set free $row
}
return $rows
}
::ns::stats::SQLiteHistory protected method unpack value {
return [encoding convertfrom utf-8 [zlib decompress [binary decode hex $value]]]
}
::nx::slotObj -container slot ::ns::stats::SQLiteHistory