Class ::ns::stats::SQLiteHistory (public)

 ::nx::Class ::ns::stats::SQLiteHistory[i]

Defined in

Testcases:
No testcase defined.
Source code:
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}} {
        #
        # Read compact process-wide allocator history from the configured owner
        # server using one bounded sample SELECT. New samples provide stored
        # projections; old rows without a projection are decoded once in this read.
        # No topology loading or full-window payload rescan is performed.
        #
        # Preserve missing gauges as absent dictionary keys. Timestamps are epoch
        # microseconds; instance and intervalSeconds allow callers to preserve
        # restart boundaries and gaps. Reading does not collect or write samples.
        # Coverage complete and windowExamined remain false: this projection does
        # not certify window boundaries or absence of eviction/write failures.
        # Use history query when full-window coverage assessment is required.
        # Timings distinguish checkout, sample SQL read, decoding and total time.
        #
        # @param from Inclusive lower bound in epoch seconds; must be nonnegative.
        # @param to Exclusive upper bound in epoch seconds; after from, at most 24 hours later
        #         and no later than now.
        # @param limit Maximum compact records per page, 1..10000; default 10000.
        # @param cursor Nonnegative timestamp cursor in microseconds, default 0; pass nextCursor
        #        unchanged for continuation.
        # @return Dictionary containing compact records (timestamp, instance,
        #        sampling.intervalSeconds and memory), more, nextCursor, legacyDecoded, timings in
        #        milliseconds and conservative coverage metadata.
        #
        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}} {
        #
        # Validate the database pool and retention limits, initialize the
        # schema and start a separate persistence callback. Repeated
        # configuration with identical settings is harmless; changing
        # active settings requires disabling persistence first.
        # Return the resulting history status. Does not start sampling.
        # Normalize retention to fractional seconds and maxBytes to bytes
        # with ns_baseunit before validating the bounds.
        #
        # @param pool Required dedicated persistent nsdbsqlite pool, accessible to the
        #     configuring virtual server.
        # @param retention Retention duration accepting ns_baseunit time syntax; 60..604800
        #     seconds, default 86400 seconds.
        # @param maxBytes Storage budget accepting ns_baseunit size syntax; at least 4 MiB,
        #     default 67108864 bytes. Includes database journal headroom.
        # @return History status dictionary after schema initialization and persistence
        #     scheduling.
        #
        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} {} {
        #
        # Stop future persistence callbacks without deleting stored history
        # or stopping sampling. An outstanding write may finish.
        # Return the resulting history status.
        #
        # @return Resulting history status dictionary; retained database records are not deleted.
        #
        :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}} {
        #
        # Return stored samples, associated topology, paging information and
        # coverage for an epoch interval. Queries run in the configuring
        # virtual server and use an operation-local database handle.
        # Historical server selection may include servers no longer active.
        #
        # @param from Inclusive lower bound in epoch seconds; must be nonnegative.
        # @param to Exclusive upper bound in epoch seconds; after from, at most 24 hours later
        #     and no later than now.
        # @param server Optional historical virtual-server name, including one no longer
        #     configured; empty returns all stored server data.
        # @param limit Maximum records per page, 1..1000; default 1000.
        # @param cursor Nonnegative timestamp cursor in microseconds, default 0; pass nextCursor
        #     unchanged for continuation.
        # @return Dictionary containing records, referenced topologies, storage statistics,
        #     coverage, more and nextCursor. Final-page coverage examines the full requested
        #     window.
        #
        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]
            }
            # Intermediate coverage describes this page. The final response
            # examines all retained intervals in the requested window again,
            # within this read transaction, independently of the paging cursor.
            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} {} {
        #
        # Persist the latest published sample if history is enabled, no write
        # is in progress and its timestamp has not already been saved.
        # Claim the write under the shared coordination lock, then perform
        # database operations outside that lock.
        #
        # Store the compressed sample and associated topology in one
        # transaction, deduplicating samples by timestamp. Apply age and
        # storage-budget eviction, retain coverage events and remove topology
        # no longer referenced by retained samples.
        #
        # On failure, roll back the transaction, retain the pending sample
        # for retry and update failure status. Repeated identical warnings
        # are throttled. A subsequent successful write records the failed
        # interval for coverage reporting.
        #
        # Release the database handle and clear the write claim on completion.
        # Does not collect observations or advance the sampling baseline;
        # callers inspect history status for the persistence outcome.
        #
        #
        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 {
            # Collection and SQLite work happen outside the shared control mutex.
            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
            }
            # Stable Tcl serialization is enough for a private cache key; store
            # the full topology in the table to detect the unlikely CRC collision.
            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} {} {
        #
        # Return shared history configuration and persistence status without
        # opening the database. Internal coordination and cached topology
        # fields are omitted.
        #
        # @return Persistence status dictionary including owner, pool, retention, maxBytes,
        #     scheduler and write failures; enabled is false when never configured.
        #
        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} {} {
        #
        # Return retained bounds/count and SQLite space usage for sizing the
        # dedicated history database. Run in the history owner server. Does
        # not collect samples, write history or change its configuration.
        #
        # @return Dictionary of retained epoch-second bounds, sample count, SQLite page usage,
        #     effective byte cap and estimated bytes per sample/second; unavailable estimates are
        #     empty.
        #
        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
            # A dedicated database avoids altering another application's budget.
            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
        }
        # Additive migration: existing compressed snapshots remain readable.
        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} {
        # Metadata only: no sample decompression. Allocated bytes include free
        # SQLite pages; used bytes exclude those pages. The configured budget
        # also reserves journal headroom, so report the effective database cap.
        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} {
        # Inspect the whole window on the final page, including its boundary
        # neighbors. Read compressed payloads in bounded batches so coverage
        # does not depend on client-carried state or retain all snapshots.
        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 {
        # Keep the stored tcl-zlib-hex-v1 representation unchanged.
        return [binary encode hex [zlib compress [encoding convertto utf-8 $value] 1]]
    }
::ns::stats::SQLiteHistory protected method scalar {db query} {
        # Return the first column of the first row, or empty for no rows.
        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 {
        # Decode stored text without losing Unicode or embedded NULs.
        return [encoding convertfrom utf-8 [zlib decompress [binary decode hex $value]]]
    }


::nx::slotObj -container slot ::ns::stats::SQLiteHistory
XQL Not present:
Generic, PostgreSQL, Oracle
[ hide source ] | [ make this the default ]
Show another procedure: