From 912d3c52eac3bb75fdd7420d3a0d3f41f8906f2c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 11:53:43 +0000 Subject: [PATCH 01/11] Fix visualizer settings being clobbered by a minimal app override ColdBox merges an app's moduleSettings over a module's defaults with a shallow append, so the documented minimal config `visualizer = { enabled = true }` replaced the whole default visualizer struct and dropped metricsStore/datasourceName. RuleEventBus.onDIComplete then threw on the missing key (its catch block re-read the same key, so the error escaped), the bus was never built, and every RuleBook run failed with a null publish() call. - ModuleConfig: single visualizerDefaults() source; onLoad() deep-merges the defaults under whatever the app supplied. - RuleEventBus, Visualizer handler, SQLiteMetricsStore: tolerate missing visualizer keys (defence in depth); resolveMetricsStore() no longer reads settings inside its own catch block. - Add VisualizerSettingsSpec. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Yc5MDcm3tyR2yqPWs9RpgX --- ModuleConfig.bx | 43 +++-- handlers/Visualizer.bx | 11 +- models/metrics/RuleEventBus.bx | 19 ++- models/metrics/SQLiteMetricsStore.bx | 2 +- .../tests/specs/VisualizerSettingsSpec.bx | 151 ++++++++++++++++++ 5 files changed, 204 insertions(+), 22 deletions(-) create mode 100644 test-harness/tests/specs/VisualizerSettingsSpec.bx diff --git a/ModuleConfig.bx b/ModuleConfig.bx index 65e549a..1b82438 100644 --- a/ModuleConfig.bx +++ b/ModuleConfig.bx @@ -36,20 +36,7 @@ class { // The Rule Visualizer: a dashboard/dry-run/metrics/live-tracker admin UI, off by default. // Secure it yourself (e.g. with cbSecurity) once enabled - RuleBox doesn't gate access on its own. // See the "Rule Visualizer" guide for the full settings shape. - visualizer = { - // Master switch. While false, the visualizer's routes 404 and no rule events are - // recorded or broadcast at all - flipping this on is the only thing that turns on - // the (small) per-rule-evaluation bookkeeping cost. - enabled = false, - // WireBox mapping ID for the metrics persistence store. Defaults to the in-memory - // store (no setup, nothing survives a restart). For persistence across restarts, - // point this at "SQLiteMetricsStore@rulebox" - see the "Rule Visualizer" guide for - // what that needs (the bx-sqlite module plus a matching datasource), or implement - // IMetricsStore@rulebox yourself and point this at its mapping. - metricsStore = "InMemoryMetricsStore@rulebox", - // Datasource name SQLiteMetricsStore reads/writes, if you opt into it above. - datasourceName = "rulebox_visualizer" - } + visualizer = visualizerDefaults() } } @@ -57,10 +44,38 @@ class { * Fired when the module is registered and activated. */ function onLoad(){ + // ColdBox merges an app's moduleSettings over ours with a SHALLOW append, so an app that only + // writes `visualizer = { enabled = true }` replaces our whole `visualizer` struct. Put the + // defaults back underneath whatever the app supplied (the live settings struct is shared by + // reference with the `coldbox:moduleSettings:rulebox` injection DSL). + var supplied = variables.settings.keyExists( "visualizer" ) && isStruct( variables.settings.visualizer ) ? variables.settings.visualizer : {} + variables.settings.visualizer = visualizerDefaults().append( supplied, true ) + // Custom injection DSL: inject="rulebook" (the registry) / inject="rulebook:{name}" (a provider) wirebox.registerDSL( namespace = "rulebook", path = "rulebox.models.RuleBookDSL" ) } + /** + * The default `visualizer` settings: the single source of truth for both configure() and the + * onLoad() back-fill of keys an app's (shallow-merged) override left out. + */ + private struct function visualizerDefaults(){ + return { + // Master switch. While false, the visualizer's routes 404 and no rule events are + // recorded or broadcast at all - flipping this on is the only thing that turns on + // the (small) per-rule-evaluation bookkeeping cost. + enabled = false, + // WireBox mapping ID for the metrics persistence store. Defaults to the in-memory + // store (no setup, nothing survives a restart). For persistence across restarts, + // point this at "SQLiteMetricsStore@rulebox" - see the "Rule Visualizer" guide for + // what that needs (the bx-sqlite module plus a matching datasource), or implement + // IMetricsStore@rulebox yourself and point this at its mapping. + metricsStore = "InMemoryMetricsStore@rulebox", + // Datasource name SQLiteMetricsStore reads/writes, if you opt into it above. + datasourceName = "rulebox_visualizer" + } + } + /** * Fired when the module is unregistered and unloaded */ diff --git a/handlers/Visualizer.bx b/handlers/Visualizer.bx index cf57727..d1411b4 100644 --- a/handlers/Visualizer.bx +++ b/handlers/Visualizer.bx @@ -27,7 +27,7 @@ class{ * seeds prc with the settings the shared layout needs (sidebar datasource badge). */ function preHandler( event, rc, prc, action, eventArguments ){ - if( !variables.settings.visualizer.enabled ){ + if( !( variables.settings.visualizer.enabled ?: false ) ){ event.renderData( type = "json", data = { error: "The Rule Visualizer is disabled. Enable it via moduleSettings.rulebox.visualizer.enabled." }, @@ -36,7 +36,12 @@ class{ event.noExecution() return } - prc.visualizerSettings = variables.settings.visualizer + // Complete with defaults so the layout never reads a key an app's override left out + prc.visualizerSettings = { + enabled : false, + metricsStore : "InMemoryMetricsStore@rulebox", + datasourceName : "rulebox_visualizer" + }.append( variables.settings.visualizer ?: {}, true ) // event.buildLink( "visualizer.x" ) drops the module's entry point (-> /visualizer/x, a 500), // so every layout/view link goes through this instead. @@ -187,7 +192,7 @@ class{ * keeps the handler correct even if the setting changes between requests. */ private any function getMetricsStore(){ - return wirebox.getInstance( variables.settings.visualizer.metricsStore ) + return wirebox.getInstance( variables.settings.visualizer.metricsStore ?: "InMemoryMetricsStore@rulebox" ) } /** diff --git a/models/metrics/RuleEventBus.bx b/models/metrics/RuleEventBus.bx index f632bcd..09bb309 100644 --- a/models/metrics/RuleEventBus.bx +++ b/models/metrics/RuleEventBus.bx @@ -33,7 +33,7 @@ class{ } function onDIComplete(){ - if( variables.settings.visualizer.enabled ){ + if( visualizerSetting( "enabled", false ) ){ resolveMetricsStore() } return this @@ -73,7 +73,7 @@ class{ * @event { rulebookName, ruleName, state, durationMs, timestamp } */ void function publish( required struct event ){ - if( !variables.settings.visualizer.enabled ){ + if( !visualizerSetting( "enabled", false ) ){ return } @@ -109,14 +109,25 @@ class{ * when bx-sqlite isn't installed. */ private void function resolveMetricsStore(){ + // Read once, outside the try: the catch block below must never be able to throw on its own. + var mapping = visualizerSetting( "metricsStore", "InMemoryMetricsStore@rulebox" ) try{ - variables.metricsStore = wirebox.getInstance( variables.settings.visualizer.metricsStore ) + variables.metricsStore = wirebox.getInstance( mapping ) } catch( any e ){ variables.metricsStoreFailed = true logger.error( - "RuleBox visualizer could not resolve its configured metricsStore '#variables.settings.visualizer.metricsStore#'. Events will still broadcast live, but nothing will be persisted. #e.message# #e.detail#" + "RuleBox visualizer could not resolve its configured metricsStore '#mapping#'. Events will still broadcast live, but nothing will be persisted. #e.message# #e.detail#" ) } } + /** + * Read one key of the visualizer settings, tolerating a missing struct or key (e.g. an app that + * overrode moduleSettings.rulebox.visualizer wholesale) by falling back to the module default. + */ + private any function visualizerSetting( required string key, required any defaultValue ){ + var viz = variables.settings.keyExists( "visualizer" ) ? variables.settings.visualizer : {} + return isStruct( viz ) && viz.keyExists( arguments.key ) ? viz[ arguments.key ] : arguments.defaultValue + } + } diff --git a/models/metrics/SQLiteMetricsStore.bx b/models/metrics/SQLiteMetricsStore.bx index 11df895..0916c86 100644 --- a/models/metrics/SQLiteMetricsStore.bx +++ b/models/metrics/SQLiteMetricsStore.bx @@ -20,7 +20,7 @@ class implements="IMetricsStore"{ property name="datasourceName" type="string"; function onDIComplete(){ - variables.datasourceName = variables.settings.visualizer.datasourceName + variables.datasourceName = variables.settings.visualizer.datasourceName ?: "rulebox_visualizer" ensureSchema() return this } diff --git a/test-harness/tests/specs/VisualizerSettingsSpec.bx b/test-harness/tests/specs/VisualizerSettingsSpec.bx new file mode 100644 index 0000000..19985b2 --- /dev/null +++ b/test-harness/tests/specs/VisualizerSettingsSpec.bx @@ -0,0 +1,151 @@ +/** + * Regression tests for the Rule Visualizer's settings merge. ColdBox merges an app's + * moduleSettings over a module's defaults with a SHALLOW append, so the documented minimal config + * `moduleSettings.rulebox.visualizer = { enabled = true }` replaces the whole default + * `visualizer` struct and drops `metricsStore`/`datasourceName`. The harness's own + * config/Coldbox.bx spells those keys out, so these specs recreate the minimal shape on the live + * settings struct (the same struct ModuleConfig.onLoad() fills and every reader is injected with). + */ +class extends="tests.resources.BaseSpec"{ + + function run( testResults, testBox ){ + describe( "Visualizer settings with a minimal app override", function(){ + + /** + * The live module settings struct, shared by reference with RuleEventBus, the Visualizer + * handler and SQLiteMetricsStore via the coldbox:moduleSettings:rulebox DSL. + */ + function liveSettings(){ + return getWireBox().getInstance( dsl="coldbox:moduleSettings:rulebox" ); + } + + /** + * Replace the live visualizer struct with what a shallow append of + * `{ visualizer = { enabled = true } }` leaves behind. Returns the original to restore. + */ + function applyMinimalOverride(){ + var settings = liveSettings(); + var original = settings.visualizer; + settings.visualizer = { enabled: true }; + return original; + } + + function newBus( required struct settings ){ + var bus = new rulebox.models.metrics.RuleEventBus(); + bus.setWirebox( getWireBox() ); + bus.setLogger( getController().getLogBox().getRootLogger() ); + bus.setSettings( arguments.settings ); + return bus; + } + + it( "ModuleConfig.onLoad() deep-merges the defaults under the app's visualizer settings", function(){ + var original = applyMinimalOverride(); + try{ + var moduleConfig = getController().getModuleService().getModuleConfigCache()[ "rulebox" ]; + moduleConfig.onLoad(); + + var viz = liveSettings().visualizer; + expect( viz.enabled ).toBeTrue(); + expect( viz.metricsStore ).toBe( "InMemoryMetricsStore@rulebox" ); + expect( viz.datasourceName ).toBe( "rulebox_visualizer" ); + } finally { + liveSettings().visualizer = original; + } + } ); + + it( "ModuleConfig.onLoad() keeps every value the app did supply", function(){ + var settings = liveSettings(); + var original = settings.visualizer; + settings.visualizer = { enabled: true, metricsStore: "SQLiteMetricsStore@rulebox" }; + try{ + getController().getModuleService().getModuleConfigCache()[ "rulebox" ].onLoad(); + + expect( settings.visualizer.metricsStore ).toBe( "SQLiteMetricsStore@rulebox" ); + expect( settings.visualizer.datasourceName ).toBe( "rulebox_visualizer" ); + } finally { + settings.visualizer = original; + } + } ); + + it( "RuleEventBus constructs and resolves the in-memory store once the settings are completed", function(){ + var original = applyMinimalOverride(); + try{ + getController().getModuleService().getModuleConfigCache()[ "rulebox" ].onLoad(); + + var bus = newBus( liveSettings() ); + bus.onDIComplete(); + + var received = []; + bus.subscribe( ( evt ) => received.append( evt ) ); + bus.publish( { rulebookName: "minimalSettingsBus", ruleName: "r1", state: "EXECUTED", durationMs: 1, timestamp: "2026-01-01T00:00:00.000" } ); + + expect( received ).toHaveLength( 1 ); + expect( + getInstance( "InMemoryMetricsStore@rulebox" ).queryEvents( filters={ rulebookName: "minimalSettingsBus" } ) + ).toHaveLength( 1 ); + } finally { + liveSettings().visualizer = original; + } + } ); + + it( "RuleEventBus tolerates missing keys even if the settings were never completed", function(){ + var store = getInstance( "InMemoryMetricsStore@rulebox" ); + var bus = newBus( { visualizer: { enabled: true } } ); + + // Used to throw "The key [metricsStore] was not found in the struct" + bus.onDIComplete(); + + bus.publish( { rulebookName: "tolerantBus", ruleName: "r1", state: "EXECUTED", durationMs: 1, timestamp: "2026-01-01T00:00:00.000" } ); + expect( store.queryEvents( filters={ rulebookName: "tolerantBus" } ) ).toHaveLength( 1 ); + } ); + + it( "RuleEventBus treats a missing visualizer struct as disabled", function(){ + var received = []; + var bus = newBus( {} ); + bus.subscribe( ( evt ) => received.append( evt ) ); + + bus.onDIComplete(); + bus.publish( { rulebookName: "rb", ruleName: "r1", state: "EXECUTED", durationMs: 1, timestamp: "2026-01-01T00:00:00.000" } ); + + expect( received ).toBeEmpty(); + } ); + + it( "RuleEventBus survives an unresolvable metrics store without throwing from its own catch block", function(){ + var received = []; + var bus = newBus( { visualizer: { enabled: true, metricsStore: "DoesNotExist@rulebox" } } ); + bus.subscribe( ( evt ) => received.append( evt ) ); + + bus.onDIComplete(); + bus.publish( { rulebookName: "rb", ruleName: "r1", state: "EXECUTED", durationMs: 1, timestamp: "2026-01-01T00:00:00.000" } ); + + expect( received ).toHaveLength( 1 ); + } ); + + it( "a RuleBook run still succeeds and publishes an event under the minimal settings", function(){ + var original = applyMinimalOverride(); + var bus = getInstance( "RuleEventBus@rulebox" ); + var received = []; + var token = bus.subscribe( ( evt ) => received.append( evt ) ); + try{ + var executed = []; + var ruleBook = getInstance( name="RuleBook@rulebox", initArguments={ name: "minimalSettingsBook" } ); + ruleBook.addRule( + ruleBook.newRule( "minimalSettingsRule" ).then( ( facts ) => { executed.append( "ran" ); } ) + ); + + ruleBook.run(); + + expect( executed ).toHaveLength( 1 ); + expect( received ).toHaveLength( 1 ); + expect( received[ 1 ].rulebookName ).toBe( "minimalSettingsBook" ); + expect( received[ 1 ].ruleName ).toBe( "minimalSettingsRule" ); + } finally { + bus.unsubscribe( token ); + liveSettings().visualizer = original; + } + } ); + + } ); + } + +} From 50d1fae701383db8293babdb29c75b01d21b52a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 11:59:27 +0000 Subject: [PATCH 02/11] Harden SQLiteMetricsStore: circuit breaker, index, retention - Count consecutive failures in the store; after visualizer.circuitBreakerThreshold (5) open a circuit for circuitBreakerCooldownSeconds (60). recordEvent is a cheap no-op while open; one error is logged on open and one info on recovery. Insert failures no longer escape to RuleEventBus (which logged an error per evaluation). - ensureSchema remembers a failed state and recordEvent re-attempts it before inserting. - Add CREATE INDEX IF NOT EXISTS on rulebox_events (rulebookName, ruleName, id). - Add retention: visualizer.retentionDays (30) and maxStoredEvents (100000), pruned with a DELETE every 500 inserts (0 disables each). - Make the SQL runner and clock injectable so breaker/retention logic is unit-tested without a database; add DB-backed specs behind the existing skip guard. - Fix stale class headers that called SQLite the default store. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Yc5MDcm3tyR2yqPWs9RpgX --- ModuleConfig.bx | 10 +- docs/guides/visualizer.md | 27 ++ models/metrics/IMetricsStore.bx | 2 +- models/metrics/SQLiteMetricsStore.bx | 312 +++++++++++++-- .../tests/specs/SQLiteMetricsStoreSpec.bx | 358 ++++++++++++++++++ 5 files changed, 675 insertions(+), 34 deletions(-) diff --git a/ModuleConfig.bx b/ModuleConfig.bx index 65e549a..030c627 100644 --- a/ModuleConfig.bx +++ b/ModuleConfig.bx @@ -48,7 +48,15 @@ class { // IMetricsStore@rulebox yourself and point this at its mapping. metricsStore = "InMemoryMetricsStore@rulebox", // Datasource name SQLiteMetricsStore reads/writes, if you opt into it above. - datasourceName = "rulebox_visualizer" + datasourceName = "rulebox_visualizer", + // SQLiteMetricsStore retention (0 disables either): prune events older than this many days... + retentionDays = 30, + // ...and keep at most this many rows. Checked every ~500 inserts, not on every insert. + maxStoredEvents = 100000, + // SQLiteMetricsStore circuit breaker: after this many consecutive failures stop + // writing for the cool-down (seconds), logging one error on open and one info on recovery. + circuitBreakerThreshold = 5, + circuitBreakerCooldownSeconds = 60 } } } diff --git a/docs/guides/visualizer.md b/docs/guides/visualizer.md index 9ec899f..eed294b 100644 --- a/docs/guides/visualizer.md +++ b/docs/guides/visualizer.md @@ -176,6 +176,33 @@ you opt into this store, you set these up yourself. If `bx-sqlite` or the datasource isn't available, RuleBox logs it and keeps going: live broadcast still works, nothing gets persisted. +#### Retention, indexing and the circuit breaker + +The SQLite store is hardened for long-running apps. All of these settings live +under `visualizer` and are optional: + +| Setting | Default | Meaning | +| --- | --- | --- | +| `retentionDays` | `30` | Delete events older than this many days. `0` disables. | +| `maxStoredEvents` | `100000` | Keep at most this many rows (the newest). `0` disables. | +| `circuitBreakerThreshold` | `5` | Consecutive failed writes before the store stops trying. | +| `circuitBreakerCooldownSeconds` | `60` | How long it stays idle before a single trial write. | + +- Retention is enforced by a cheap `DELETE` once every 500 inserts, never on + every insert, so the table can briefly exceed the limits between prunes. +- The `rulebox_events` table has an index on `(rulebookName, ruleName, id)` for + the per-rule metrics queries (created automatically; existing tables get it + on the next startup). +- If writes keep failing (a missing datasource, a locked or full database), the + store logs **one** error when the breaker opens, skips persistence for the + cool-down (live broadcast keeps working), then retries once and logs **one** + info line when it recovers. A failed schema create is retried on the next + event rather than being swallowed for good. +- Each recorded event is still a synchronous `INSERT` on the thread that + evaluated the rule. That is a deliberate trade-off for simplicity; if it is + too slow for your traffic, use the in-memory store or implement + `IMetricsStore@rulebox` with a queue. + ### Swapping it out Implement `IMetricsStore@rulebox` (`recordEvent`, `queryEvents`, diff --git a/models/metrics/IMetricsStore.bx b/models/metrics/IMetricsStore.bx index 204b1d0..78b6a0a 100644 --- a/models/metrics/IMetricsStore.bx +++ b/models/metrics/IMetricsStore.bx @@ -1,7 +1,7 @@ /** * Contract for a Rule Visualizer metrics persistence store. Implement this and point * moduleSettings.rulebox.visualizer.metricsStore at your WireBox mapping to swap out the - * default SQLiteMetricsStore@rulebox. + * default InMemoryMetricsStore@rulebox (or the opt-in SQLiteMetricsStore@rulebox). */ interface { diff --git a/models/metrics/SQLiteMetricsStore.bx b/models/metrics/SQLiteMetricsStore.bx index 11df895..f53ff5e 100644 --- a/models/metrics/SQLiteMetricsStore.bx +++ b/models/metrics/SQLiteMetricsStore.bx @@ -1,11 +1,22 @@ /** - * Default IMetricsStore for the Rule Visualizer: persists rule-evaluation events to a SQLite - * table via the bx-sqlite module, so metrics/stats survive a restart. + * Opt-in persistent IMetricsStore for the Rule Visualizer: persists rule-evaluation events to a + * SQLite table via the bx-sqlite module, so metrics/stats survive a restart. (The default store is + * InMemoryMetricsStore@rulebox; point visualizer.metricsStore at "SQLiteMetricsStore@rulebox" to opt in.) * * Requires the bx-sqlite BoxLang module and a datasource registered under * moduleSettings.rulebox.visualizer.datasourceName (default "rulebox_visualizer"). Neither is * installed/declared by RuleBox itself - see the "Rule Visualizer" guide for setup. * + * Hardening: + * - Circuit breaker: after visualizer.circuitBreakerThreshold (5) consecutive failures the store + * stops touching the database for visualizer.circuitBreakerCooldownSeconds (60), logging ONE error + * when it opens and one info line when it recovers. While open, recordEvent() is a cheap no-op. + * - Schema: a failed schema create is remembered and re-attempted by the next recordEvent(). + * An index on (rulebookName, ruleName, id) backs the per-rule metrics queries. + * - Retention: visualizer.retentionDays (30) and visualizer.maxStoredEvents (100000) are enforced + * by a cheap DELETE every pruneEvery (500) inserts - never on every insert. 0 disables either. + * - recordEvent() still performs a synchronous INSERT on the calling thread (a documented trade-off). + * * Swap this out entirely via moduleSettings.rulebox.visualizer.metricsStore. */ @singleton @@ -19,47 +30,284 @@ class implements="IMetricsStore"{ property name="datasourceName" type="string"; + // Resolved from settings (defensively, with defaults) in onDIComplete() + variables.breakerThreshold = 5 + variables.breakerCooldownMs = 60000 + variables.retentionDays = 30 + variables.maxStoredEvents = 100000 + // Run the retention DELETE once per this many successful inserts + variables.pruneEvery = 500 + + // Breaker / schema state + variables.schemaReady = false + variables.consecutiveFailures = 0 + variables.circuitOpen = false + variables.circuitOpenedAt = 0 + variables.insertsSincePrune = 0 + variables.lastError = "" + function onDIComplete(){ - variables.datasourceName = variables.settings.visualizer.datasourceName - ensureSchema() + var viz = ( isStruct( variables.settings ) && variables.settings.keyExists( "visualizer" ) && isStruct( variables.settings.visualizer ) ) + ? variables.settings.visualizer + : {} + variables.datasourceName = viz.keyExists( "datasourceName" ) ? viz.datasourceName : "rulebox_visualizer" + variables.breakerThreshold = numericSetting( viz, "circuitBreakerThreshold", 5, 1 ) + variables.breakerCooldownMs = numericSetting( viz, "circuitBreakerCooldownSeconds", 60, 0 ) * 1000 + variables.retentionDays = numericSetting( viz, "retentionDays", 30, 0 ) + variables.maxStoredEvents = numericSetting( viz, "maxStoredEvents", 100000, 0 ) + ensureSchema( true ) return this } - private void function ensureSchema(){ - try{ - queryExecute( - " - CREATE TABLE IF NOT EXISTS rulebox_events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - rulebookName TEXT NOT NULL, - ruleName TEXT NOT NULL, - state TEXT NOT NULL, - durationMs REAL NOT NULL, - timestamp TEXT NOT NULL + /** + * Inject the function that executes SQL: ( sql, params, options ) => result. Defaults to + * queryExecute() against the configured datasource. Exists so the breaker/retention logic is unit-testable. + */ + function setSqlRunner( required function runner ){ + variables.sqlRunner = arguments.runner + return this + } + + /** + * Inject the clock used by the circuit breaker: () => epoch milliseconds. Defaults to getTickCount(). + */ + function setClock( required function clock ){ + variables.clock = arguments.clock + return this + } + + function setPruneEvery( required numeric every ){ + variables.pruneEvery = arguments.every + return this + } + + /** + * Is the circuit currently open (tripped and not yet recovered)? Stays true through the cool-down + * and until a trial insert succeeds. + */ + boolean function isCircuitOpen(){ + return variables.circuitOpen + } + + boolean function isSchemaReady(){ + return variables.schemaReady + } + + /** + * May we touch the database right now? Always true while closed. While open it is false until the + * cool-down elapses, then true for exactly one caller (which becomes the trial) per cool-down. + */ + boolean function canAttempt(){ + if( !variables.circuitOpen ){ + return true + } + var allowed = false + lock name="rulebox_sqlite_store_state" type="exclusive" timeout="5"{ + var now = nowMillis() + if( variables.circuitOpen && ( now - variables.circuitOpenedAt ) >= variables.breakerCooldownMs ){ + // Claim the trial; others stay shut out until the next cool-down or until we close it + variables.circuitOpenedAt = now + allowed = true + } else { + allowed = !variables.circuitOpen + } + } + return allowed + } + + /** + * Count one failure; opens the circuit (logging ONE error) at the threshold. + */ + void function recordFailure( required string message ){ + lock name="rulebox_sqlite_store_state" type="exclusive" timeout="5"{ + variables.consecutiveFailures++ + variables.lastError = arguments.message + if( variables.circuitOpen ){ + // A failed trial: stay open for another cool-down, quietly + variables.circuitOpenedAt = nowMillis() + } else if( variables.consecutiveFailures >= variables.breakerThreshold ){ + variables.circuitOpen = true + variables.circuitOpenedAt = nowMillis() + logger.error( + "RuleBox SQLiteMetricsStore: #variables.consecutiveFailures# consecutive failures on datasource '#variables.datasourceName#'; pausing metric persistence for #variables.breakerCooldownMs / 1000# seconds (live broadcast is unaffected). Last error: #arguments.message#" ) - ", - {}, - { datasource: variables.datasourceName } + } else { + logger.debug( "RuleBox SQLiteMetricsStore failure #variables.consecutiveFailures#/#variables.breakerThreshold#: #arguments.message#" ) + } + } + } + + /** + * Reset the failure count; closes the circuit (logging one info line) if it was open. + */ + void function recordSuccess(){ + if( variables.consecutiveFailures == 0 && !variables.circuitOpen ){ + return + } + lock name="rulebox_sqlite_store_state" type="exclusive" timeout="5"{ + var wasOpen = variables.circuitOpen + variables.consecutiveFailures = 0 + variables.circuitOpen = false + variables.lastError = "" + if( wasOpen ){ + logger.info( "RuleBox SQLiteMetricsStore recovered on datasource '#variables.datasourceName#'; resuming metric persistence." ) + } + } + } + + /** + * Count one successful insert and report whether the retention prune is due (every pruneEvery + * inserts). Always false when both retention limits are disabled. + */ + boolean function shouldPrune(){ + if( variables.retentionDays <= 0 && variables.maxStoredEvents <= 0 ){ + return false + } + var due = false + lock name="rulebox_sqlite_store_state" type="exclusive" timeout="5"{ + variables.insertsSincePrune++ + if( variables.insertsSincePrune >= variables.pruneEvery ){ + variables.insertsSincePrune = 0 + due = true + } + } + return due + } + + /** + * The DDL ensureSchema() runs: the table, then the index backing queryRuleMetrics(). + */ + array function getSchemaStatements(){ + return [ + " + CREATE TABLE IF NOT EXISTS rulebox_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rulebookName TEXT NOT NULL, + ruleName TEXT NOT NULL, + state TEXT NOT NULL, + durationMs REAL NOT NULL, + timestamp TEXT NOT NULL ) + ", + "CREATE INDEX IF NOT EXISTS idx_rulebox_events_rule ON rulebox_events ( rulebookName, ruleName, id )" + ] + } + + /** + * The retention DELETEs for the configured limits, as [ { sql, params } ]. + * + * @asOf The reference "now" for retentionDays (injectable for tests) + */ + array function buildPruneStatements( date asOf=now() ){ + var statements = [] + if( variables.maxStoredEvents > 0 ){ + // ids only grow, so "everything older than the newest N ids" is a cheap primary-key range delete + statements.append( { + sql : "DELETE FROM rulebox_events WHERE id <= ( SELECT MAX( id ) FROM rulebox_events ) - :maxEvents", + params : { maxEvents: { value: variables.maxStoredEvents, cfsqltype: "integer" } } + } ) + } + if( variables.retentionDays > 0 ){ + var cutoff = dateTimeFormat( dateAdd( "d", -variables.retentionDays, arguments.asOf ), "yyyy-MM-dd'T'HH:mm:ss" ) + statements.append( { + sql : "DELETE FROM rulebox_events WHERE timestamp < :cutoff", + params : { cutoff: { value: cutoff, cfsqltype: "varchar" } } + } ) + } + return statements + } + + /** + * Run the retention DELETEs. A prune failure is logged but never trips the breaker. + */ + void function prune(){ + try{ + var statements = buildPruneStatements() + for( var stmt in statements ){ + runSql( stmt.sql, stmt.params ) + } } catch( any e ){ - logger.error( - "RuleBox visualizer could not create/verify its SQLite schema on datasource '#variables.datasourceName#'. Is the bx-sqlite module installed and is that datasource registered? #e.message# #e.detail#" - ) + logger.warn( "RuleBox SQLiteMetricsStore could not prune old events: #e.message# #e.detail#" ) + } + } + + /** + * Create the table/index if needed. A failure is remembered (schemaReady=false) and counts toward + * the breaker; recordEvent() re-attempts it before inserting. + * + * @initial True from onDIComplete(): log a warning (later retries stay quiet until the breaker opens) + */ + private boolean function ensureSchema( boolean initial=false ){ + try{ + var statements = getSchemaStatements() + for( var sql in statements ){ + runSql( sql ) + } + variables.schemaReady = true + return true + } catch( any e ){ + variables.schemaReady = false + var msg = "#e.message# #e.detail#" + if( arguments.initial ){ + logger.warn( + "RuleBox visualizer could not create/verify its SQLite schema on datasource '#variables.datasourceName#'; will retry when the next event is recorded. Is the bx-sqlite module installed and is that datasource registered? #msg#" + ) + } + recordFailure( msg ) + return false } } void function recordEvent( required struct event ){ - queryExecute( - "INSERT INTO rulebox_events ( rulebookName, ruleName, state, durationMs, timestamp ) VALUES ( :rulebookName, :ruleName, :state, :durationMs, :timestamp )", - { - rulebookName : { value: arguments.event.rulebookName, cfsqltype: "varchar" }, - ruleName : { value: arguments.event.ruleName, cfsqltype: "varchar" }, - state : { value: arguments.event.state, cfsqltype: "varchar" }, - durationMs : { value: arguments.event.durationMs, cfsqltype: "double" }, - timestamp : { value: arguments.event.timestamp, cfsqltype: "varchar" } - }, - { datasource: variables.datasourceName } - ) + if( !canAttempt() ){ + return + } + + if( !variables.schemaReady && !ensureSchema() ){ + return + } + + try{ + runSql( + "INSERT INTO rulebox_events ( rulebookName, ruleName, state, durationMs, timestamp ) VALUES ( :rulebookName, :ruleName, :state, :durationMs, :timestamp )", + { + rulebookName : { value: arguments.event.rulebookName, cfsqltype: "varchar" }, + ruleName : { value: arguments.event.ruleName, cfsqltype: "varchar" }, + state : { value: arguments.event.state, cfsqltype: "varchar" }, + durationMs : { value: arguments.event.durationMs, cfsqltype: "double" }, + timestamp : { value: arguments.event.timestamp, cfsqltype: "varchar" } + } + ) + } catch( any e ){ + recordFailure( "#e.message# #e.detail#" ) + return + } + + recordSuccess() + if( shouldPrune() ){ + prune() + } + } + + private numeric function nowMillis(){ + return structKeyExists( variables, "clock" ) ? variables.clock() : getTickCount() + } + + private any function runSql( required string sql, struct params={}, struct options={} ){ + if( structKeyExists( variables, "sqlRunner" ) ){ + return variables.sqlRunner( arguments.sql, arguments.params, arguments.options ) + } + return queryExecute( arguments.sql, arguments.params, { datasource: variables.datasourceName } ) + } + + /** + * Read a numeric visualizer setting, falling back to a default if it is absent or not numeric. + */ + private numeric function numericSetting( required struct viz, required string key, required numeric defaultValue, required numeric min ){ + if( arguments.viz.keyExists( arguments.key ) && isNumeric( arguments.viz[ arguments.key ] ) && arguments.viz[ arguments.key ] >= arguments.min ){ + return arguments.viz[ arguments.key ] + } + return arguments.defaultValue } array function queryEvents( struct filters={}, numeric limit=50 ){ diff --git a/test-harness/tests/specs/SQLiteMetricsStoreSpec.bx b/test-harness/tests/specs/SQLiteMetricsStoreSpec.bx index b595a0c..3c31f51 100644 --- a/test-harness/tests/specs/SQLiteMetricsStoreSpec.bx +++ b/test-harness/tests/specs/SQLiteMetricsStoreSpec.bx @@ -100,6 +100,364 @@ class extends="tests.resources.BaseSpec"{ } ); } ); + + /** + * Pure-logic specs: the SQL runner and the clock are injected, so none of this needs a database + * (and therefore always runs, even where the bx-sqlite driver isn't registered). + */ + describe( "SQLiteMetricsStore hardening (no database)", function(){ + + // Build a store wired to a recording fake runner, a controllable clock and a recording logger + function newUnit( struct visualizer={}, boolean failAtStartup=false ){ + var ctx = { fail: failAtStartup, failDeletes: false, now: 1000000, sql: [], logs: { error: [], warn: [], info: [], debug: [] } }; + var fakeLogger = { + error : ( m ) => ctx.logs.error.append( m ), + warn : ( m ) => ctx.logs.warn.append( m ), + info : ( m ) => ctx.logs.info.append( m ), + debug : ( m ) => ctx.logs.debug.append( m ) + }; + var settings = { visualizer: { datasourceName: "unit_ds" } }; + settings.visualizer.append( visualizer ); + + var store = new rulebox.models.metrics.SQLiteMetricsStore(); + store.setLogger( fakeLogger ); + store.setSettings( settings ); + store.setClock( () => ctx.now ); + store.setSqlRunner( ( sql, params, options ) => { + ctx.sql.append( { sql: sql, params: params } ); + if( ctx.fail || ( ctx.failDeletes && left( trim( sql ), 6 ) == "DELETE" ) ){ + throw( type="UnitTest", message="simulated database failure" ); + } + } ); + store.onDIComplete(); + return { store: store, ctx: ctx }; + } + + function countSql( ctx, verb ){ + return ctx.sql.filter( ( c ) => left( trim( c.sql ), len( verb ) ) == verb ).len(); + } + + function anUnitEvent( ruleName="r1" ){ + return { rulebookName: "unit", ruleName: ruleName, state: "EXECUTED", durationMs: 1, timestamp: "2026-01-01T00:00:00" }; + } + + it( "creates the table and the (rulebookName, ruleName, id) index on startup", function(){ + var u = newUnit(); + + expect( u.store.isSchemaReady() ).toBeTrue(); + expect( u.ctx.sql ).toHaveLength( 2 ); + expect( u.ctx.sql[ 1 ].sql ).toInclude( "CREATE TABLE IF NOT EXISTS rulebox_events" ); + expect( u.ctx.sql[ 2 ].sql ).toInclude( "CREATE INDEX IF NOT EXISTS" ); + expect( u.ctx.sql[ 2 ].sql ).toInclude( "rulebox_events ( rulebookName, ruleName, id )" ); + } ); + + it( "remembers a failed schema create and re-attempts it before the first insert", function(){ + var u = newUnit( failAtStartup=true ); + expect( u.store.isSchemaReady() ).toBeFalse(); + expect( u.ctx.logs.warn ).toHaveLength( 1 ); + + u.ctx.fail = false; + u.ctx.sql = []; + u.store.recordEvent( anUnitEvent() ); + + expect( u.store.isSchemaReady() ).toBeTrue(); + expect( u.ctx.sql[ 1 ].sql ).toInclude( "CREATE TABLE IF NOT EXISTS" ); + expect( u.ctx.sql[ u.ctx.sql.len() ].sql ).toInclude( "INSERT INTO rulebox_events" ); + expect( countSql( u.ctx, "INSERT" ) ).toBe( 1 ); + } ); + + it( "never lets a database failure escape recordEvent", function(){ + var u = newUnit(); + u.ctx.fail = true; + + for( var i = 1; i <= 20; i++ ){ + u.store.recordEvent( anUnitEvent() ); + } + + expect( true ).toBeTrue(); + } ); + + it( "opens the circuit after N consecutive failures and logs exactly one error", function(){ + var u = newUnit( { circuitBreakerThreshold: 3 } ); + u.ctx.fail = true; + + u.store.recordEvent( anUnitEvent() ); + u.store.recordEvent( anUnitEvent() ); + expect( u.store.isCircuitOpen() ).toBeFalse(); + expect( u.ctx.logs.error ).toBeEmpty(); + + u.store.recordEvent( anUnitEvent() ); + expect( u.store.isCircuitOpen() ).toBeTrue(); + expect( u.ctx.logs.error ).toHaveLength( 1 ); + expect( u.ctx.logs.error[ 1 ] ).toInclude( "simulated database failure" ); + } ); + + it( "is a no-op against the database while the circuit is open", function(){ + var u = newUnit( { circuitBreakerThreshold: 2 } ); + u.ctx.fail = true; + u.store.recordEvent( anUnitEvent() ); + u.store.recordEvent( anUnitEvent() ); + expect( u.store.isCircuitOpen() ).toBeTrue(); + var attempts = u.ctx.sql.len(); + + for( var i = 1; i <= 50; i++ ){ + u.store.recordEvent( anUnitEvent() ); + } + + expect( u.ctx.sql ).toHaveLength( attempts ); + expect( u.ctx.logs.error ).toHaveLength( 1 ); + } ); + + it( "stays shut until the cool-down elapses, then recovers on a successful trial with one info log", function(){ + var u = newUnit( { circuitBreakerThreshold: 2, circuitBreakerCooldownSeconds: 60 } ); + u.ctx.fail = true; + u.store.recordEvent( anUnitEvent() ); + u.store.recordEvent( anUnitEvent() ); + expect( u.store.isCircuitOpen() ).toBeTrue(); + + u.ctx.fail = false; + u.ctx.now += 59999; + var before = u.ctx.sql.len(); + u.store.recordEvent( anUnitEvent() ); + expect( u.ctx.sql ).toHaveLength( before ); + expect( u.store.isCircuitOpen() ).toBeTrue(); + + u.ctx.now += 1; + u.store.recordEvent( anUnitEvent() ); + expect( countSql( u.ctx, "INSERT" ) ).toBe( 3 ); + expect( u.store.isCircuitOpen() ).toBeFalse(); + expect( u.ctx.logs.info ).toHaveLength( 1 ); + + u.store.recordEvent( anUnitEvent() ); + expect( countSql( u.ctx, "INSERT" ) ).toBe( 4 ); + expect( u.ctx.logs.info ).toHaveLength( 1 ); + } ); + + it( "lets only one trial through per cool-down and stays open (quietly) when the trial fails", function(){ + var u = newUnit( { circuitBreakerThreshold: 2, circuitBreakerCooldownSeconds: 60 } ); + u.ctx.fail = true; + u.store.recordEvent( anUnitEvent() ); + u.store.recordEvent( anUnitEvent() ); + var attempts = u.ctx.sql.len(); + + u.ctx.now += 60000; + u.store.recordEvent( anUnitEvent() ); + u.store.recordEvent( anUnitEvent() ); + u.store.recordEvent( anUnitEvent() ); + + expect( u.ctx.sql ).toHaveLength( attempts + 1 ); + expect( u.store.isCircuitOpen() ).toBeTrue(); + expect( u.ctx.logs.error ).toHaveLength( 1 ); + + u.ctx.now += 60000; + u.store.recordEvent( anUnitEvent() ); + expect( u.ctx.sql ).toHaveLength( attempts + 2 ); + } ); + + it( "only counts consecutive failures: a success resets the count", function(){ + var u = newUnit( { circuitBreakerThreshold: 3 } ); + + u.ctx.fail = true; + u.store.recordEvent( anUnitEvent() ); + u.store.recordEvent( anUnitEvent() ); + u.ctx.fail = false; + u.store.recordEvent( anUnitEvent() ); + u.ctx.fail = true; + u.store.recordEvent( anUnitEvent() ); + u.store.recordEvent( anUnitEvent() ); + + expect( u.store.isCircuitOpen() ).toBeFalse(); + expect( u.ctx.logs.error ).toBeEmpty(); + } ); + + it( "defaults to a threshold of 5 when the setting is absent or invalid", function(){ + var u = newUnit( { circuitBreakerThreshold: "lots" } ); + u.ctx.fail = true; + + for( var i = 1; i <= 4; i++ ){ + u.store.recordEvent( anUnitEvent() ); + } + expect( u.store.isCircuitOpen() ).toBeFalse(); + + u.store.recordEvent( anUnitEvent() ); + expect( u.store.isCircuitOpen() ).toBeTrue(); + } ); + + it( "tolerates a settings struct with no visualizer key at all", function(){ + var store = new rulebox.models.metrics.SQLiteMetricsStore(); + store.setLogger( { error: ( m ) => {}, warn: ( m ) => {}, info: ( m ) => {}, debug: ( m ) => {} } ); + store.setSettings( {} ); + store.setSqlRunner( ( sql, params, options ) => {} ); + store.onDIComplete(); + + expect( store.isSchemaReady() ).toBeTrue(); + expect( store.buildPruneStatements() ).toHaveLength( 2 ); + } ); + + it( "does not throw or log per event when the datasource does not exist (real queryExecute path)", function(){ + var logs = { error: [] }; + var store = new rulebox.models.metrics.SQLiteMetricsStore(); + store.setLogger( { + error : ( m ) => logs.error.append( m ), + warn : ( m ) => {}, + info : ( m ) => {}, + debug : ( m ) => {} + } ); + store.setSettings( { visualizer: { datasourceName: "rulebox_no_such_datasource" } } ); + store.onDIComplete(); + + for( var i = 1; i <= 25; i++ ){ + store.recordEvent( anUnitEvent() ); + } + + expect( store.isCircuitOpen() ).toBeTrue(); + expect( logs.error ).toHaveLength( 1 ); + } ); + + it( "prunes once per pruneEvery inserts, not on every insert", function(){ + var u = newUnit( { retentionDays: 0, maxStoredEvents: 10 } ); + u.store.setPruneEvery( 5 ); + + for( var i = 1; i <= 4; i++ ){ + u.store.recordEvent( anUnitEvent() ); + } + expect( countSql( u.ctx, "DELETE" ) ).toBe( 0 ); + + u.store.recordEvent( anUnitEvent() ); + expect( countSql( u.ctx, "DELETE" ) ).toBe( 1 ); + + for( i = 1; i <= 5; i++ ){ + u.store.recordEvent( anUnitEvent() ); + } + expect( countSql( u.ctx, "DELETE" ) ).toBe( 2 ); + expect( countSql( u.ctx, "INSERT" ) ).toBe( 10 ); + } ); + + it( "never prunes when both retentionDays and maxStoredEvents are 0", function(){ + var u = newUnit( { retentionDays: 0, maxStoredEvents: 0 } ); + u.store.setPruneEvery( 2 ); + + for( var i = 1; i <= 20; i++ ){ + u.store.recordEvent( anUnitEvent() ); + } + + expect( countSql( u.ctx, "DELETE" ) ).toBe( 0 ); + } ); + + it( "builds a retention DELETE per enabled limit", function(){ + var u = newUnit( { retentionDays: 30, maxStoredEvents: 500 } ); + + var statements = u.store.buildPruneStatements( createDateTime( 2026, 2, 1, 12, 0, 0 ) ); + + expect( statements ).toHaveLength( 2 ); + expect( statements[ 1 ].sql ).toInclude( "DELETE FROM rulebox_events WHERE id <=" ); + expect( statements[ 1 ].params.maxEvents.value ).toBe( 500 ); + expect( statements[ 2 ].sql ).toInclude( "DELETE FROM rulebox_events WHERE timestamp <" ); + expect( statements[ 2 ].params.cutoff.value ).toBe( "2026-01-02T12:00:00" ); + + var onlyDays = newUnit( { retentionDays: 7, maxStoredEvents: 0 } ).store.buildPruneStatements(); + expect( onlyDays ).toHaveLength( 1 ); + expect( onlyDays[ 1 ].sql ).toInclude( "timestamp <" ); + } ); + + it( "treats a failing prune as non-fatal and does not trip the breaker", function(){ + var u = newUnit( { retentionDays: 0, maxStoredEvents: 10, circuitBreakerThreshold: 2 } ); + u.store.setPruneEvery( 1 ); + u.ctx.failDeletes = true; + + for( var i = 1; i <= 10; i++ ){ + u.store.recordEvent( anUnitEvent() ); + } + + expect( u.store.isCircuitOpen() ).toBeFalse(); + expect( countSql( u.ctx, "INSERT" ) ).toBe( 10 ); + expect( u.ctx.logs.error ).toBeEmpty(); + expect( u.ctx.logs.warn ).toHaveLength( 10 ); + } ); + + } ); + + /** + * Real bx-sqlite round trips for the new behavior; skipped, like the specs above, where the + * driver isn't registered. + */ + describe( "SQLiteMetricsStore hardening (SQLite)", function(){ + + function newTunedStore( struct visualizer ){ + var settings = { visualizer: { datasourceName: "rulebox_visualizer" } }; + settings.visualizer.append( arguments.visualizer ); + var store = new rulebox.models.metrics.SQLiteMetricsStore(); + store.setLogger( getController().getLogBox().getRootLogger() ); + store.setSettings( settings ); + store.onDIComplete(); + store.reset(); + return store; + } + + beforeEach( function(){ + variables.storeUnavailable = ""; + try{ + newTunedStore( {} ); + } catch( any e ){ + variables.storeUnavailable = "bx-sqlite driver is not registered with BoxLang's DatasourceService in this environment: #e.message#" + } + } ); + + it( "creates the (rulebookName, ruleName, id) index", function(){ + if( len( variables.storeUnavailable ) ){ + skip( variables.storeUnavailable ); + return; + } + newTunedStore( {} ); + + var indexes = queryExecute( + "SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'rulebox_events'", + {}, + { datasource: "rulebox_visualizer", returnType: "array" } + ).map( ( row ) => row.name ); + + expect( indexes ).toInclude( "idx_rulebox_events_rule" ); + } ); + + it( "keeps only the newest maxStoredEvents rows once the prune cadence is reached", function(){ + if( len( variables.storeUnavailable ) ){ + skip( variables.storeUnavailable ); + return; + } + var store = newTunedStore( { retentionDays: 0, maxStoredEvents: 3 } ); + store.setPruneEvery( 5 ); + + for( var i = 1; i <= 5; i++ ){ + store.recordEvent( { rulebookName: "sqliteTest", ruleName: "r#i#", state: "EXECUTED", durationMs: 1, timestamp: "2026-01-01T00:00:0#i#" } ); + } + + var events = store.queryEvents( filters={ rulebookName: "sqliteTest" } ); + expect( events ).toHaveLength( 3 ); + expect( events.map( ( e ) => e.ruleName ).toList() ).toBe( "r5,r4,r3" ); + } ); + + it( "deletes events older than retentionDays on the prune cadence", function(){ + if( len( variables.storeUnavailable ) ){ + skip( variables.storeUnavailable ); + return; + } + var store = newTunedStore( { retentionDays: 30, maxStoredEvents: 0 } ); + store.setPruneEvery( 5 ); + var recent = dateTimeFormat( now(), "yyyy-MM-dd'T'HH:mm:ss" ); + + for( var i = 1; i <= 3; i++ ){ + store.recordEvent( { rulebookName: "sqliteTest", ruleName: "old#i#", state: "EXECUTED", durationMs: 1, timestamp: "2000-01-01T00:00:00" } ); + } + for( i = 1; i <= 2; i++ ){ + store.recordEvent( { rulebookName: "sqliteTest", ruleName: "new#i#", state: "EXECUTED", durationMs: 1, timestamp: recent } ); + } + + var events = store.queryEvents( filters={ rulebookName: "sqliteTest" } ); + expect( events ).toHaveLength( 2 ); + expect( events.map( ( e ) => e.ruleName ).toList() ).toBe( "new2,new1" ); + } ); + + } ); } } From a7e6397667812bf01851a89e8e320244c74adc66 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 14:45:30 +0000 Subject: [PATCH 03/11] Complete and validate visualizer settings only in ModuleConfig.onLoad() ModuleConfig is now the single place that sets up and checks the visualizer settings: onLoad() deep-merges the app's values over visualizerDefaults(), then throws RuleBox.InvalidSettingException for a value of the wrong shape. The handler, RuleEventBus and SQLiteMetricsStore go back to reading the settings directly, with no scattered fallbacks. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Yc5MDcm3tyR2yqPWs9RpgX --- ModuleConfig.bx | 35 +++++++++++++-- handlers/Visualizer.bx | 11 ++--- models/metrics/RuleEventBus.bx | 19 ++------ models/metrics/SQLiteMetricsStore.bx | 2 +- .../tests/specs/VisualizerSettingsSpec.bx | 45 ++++++++++--------- 5 files changed, 63 insertions(+), 49 deletions(-) diff --git a/ModuleConfig.bx b/ModuleConfig.bx index 1b82438..6bc203d 100644 --- a/ModuleConfig.bx +++ b/ModuleConfig.bx @@ -46,15 +46,42 @@ class { function onLoad(){ // ColdBox merges an app's moduleSettings over ours with a SHALLOW append, so an app that only // writes `visualizer = { enabled = true }` replaces our whole `visualizer` struct. Put the - // defaults back underneath whatever the app supplied (the live settings struct is shared by - // reference with the `coldbox:moduleSettings:rulebox` injection DSL). - var supplied = variables.settings.keyExists( "visualizer" ) && isStruct( variables.settings.visualizer ) ? variables.settings.visualizer : {} - variables.settings.visualizer = visualizerDefaults().append( supplied, true ) + // defaults back underneath whatever the app supplied, then validate. This is the one place + // the visualizer settings are completed and checked: every reader trusts them as-is. + if( !isStruct( variables.settings.visualizer ?: "" ) ){ + invalidSetting( "visualizer", variables.settings.visualizer ?: "null", "a struct" ) + } + variables.settings.visualizer = validateVisualizerSettings( + visualizerDefaults().append( variables.settings.visualizer, true ) + ) // Custom injection DSL: inject="rulebook" (the registry) / inject="rulebook:{name}" (a provider) wirebox.registerDSL( namespace = "rulebook", path = "rulebox.models.RuleBookDSL" ) } + /** + * Fail fast on app start for a visualizer setting of the wrong shape. + */ + private struct function validateVisualizerSettings( required struct visualizer ){ + if( isNull( arguments.visualizer.enabled ) || !isBoolean( arguments.visualizer.enabled ) ){ + invalidSetting( "visualizer.enabled", arguments.visualizer.enabled ?: "null", "true or false" ) + } + for( var key in [ "metricsStore", "datasourceName" ] ){ + var value = arguments.visualizer[ key ] ?: "" + if( !isSimpleValue( value ) || !len( trim( value ) ) ){ + invalidSetting( "visualizer.#key#", value, "a non-blank WireBox mapping or name" ) + } + } + return arguments.visualizer + } + + private void function invalidSetting( required string key, any value, required string expected ){ + throw( + type = "RuleBox.InvalidSettingException", + message = "Invalid moduleSettings.rulebox.#arguments.key#: expected #arguments.expected#, got #( isSimpleValue( arguments.value ?: "" ) ? "'#arguments.value ?: ""#'" : "a complex value" )#." + ) + } + /** * The default `visualizer` settings: the single source of truth for both configure() and the * onLoad() back-fill of keys an app's (shallow-merged) override left out. diff --git a/handlers/Visualizer.bx b/handlers/Visualizer.bx index d1411b4..cf57727 100644 --- a/handlers/Visualizer.bx +++ b/handlers/Visualizer.bx @@ -27,7 +27,7 @@ class{ * seeds prc with the settings the shared layout needs (sidebar datasource badge). */ function preHandler( event, rc, prc, action, eventArguments ){ - if( !( variables.settings.visualizer.enabled ?: false ) ){ + if( !variables.settings.visualizer.enabled ){ event.renderData( type = "json", data = { error: "The Rule Visualizer is disabled. Enable it via moduleSettings.rulebox.visualizer.enabled." }, @@ -36,12 +36,7 @@ class{ event.noExecution() return } - // Complete with defaults so the layout never reads a key an app's override left out - prc.visualizerSettings = { - enabled : false, - metricsStore : "InMemoryMetricsStore@rulebox", - datasourceName : "rulebox_visualizer" - }.append( variables.settings.visualizer ?: {}, true ) + prc.visualizerSettings = variables.settings.visualizer // event.buildLink( "visualizer.x" ) drops the module's entry point (-> /visualizer/x, a 500), // so every layout/view link goes through this instead. @@ -192,7 +187,7 @@ class{ * keeps the handler correct even if the setting changes between requests. */ private any function getMetricsStore(){ - return wirebox.getInstance( variables.settings.visualizer.metricsStore ?: "InMemoryMetricsStore@rulebox" ) + return wirebox.getInstance( variables.settings.visualizer.metricsStore ) } /** diff --git a/models/metrics/RuleEventBus.bx b/models/metrics/RuleEventBus.bx index 09bb309..f632bcd 100644 --- a/models/metrics/RuleEventBus.bx +++ b/models/metrics/RuleEventBus.bx @@ -33,7 +33,7 @@ class{ } function onDIComplete(){ - if( visualizerSetting( "enabled", false ) ){ + if( variables.settings.visualizer.enabled ){ resolveMetricsStore() } return this @@ -73,7 +73,7 @@ class{ * @event { rulebookName, ruleName, state, durationMs, timestamp } */ void function publish( required struct event ){ - if( !visualizerSetting( "enabled", false ) ){ + if( !variables.settings.visualizer.enabled ){ return } @@ -109,25 +109,14 @@ class{ * when bx-sqlite isn't installed. */ private void function resolveMetricsStore(){ - // Read once, outside the try: the catch block below must never be able to throw on its own. - var mapping = visualizerSetting( "metricsStore", "InMemoryMetricsStore@rulebox" ) try{ - variables.metricsStore = wirebox.getInstance( mapping ) + variables.metricsStore = wirebox.getInstance( variables.settings.visualizer.metricsStore ) } catch( any e ){ variables.metricsStoreFailed = true logger.error( - "RuleBox visualizer could not resolve its configured metricsStore '#mapping#'. Events will still broadcast live, but nothing will be persisted. #e.message# #e.detail#" + "RuleBox visualizer could not resolve its configured metricsStore '#variables.settings.visualizer.metricsStore#'. Events will still broadcast live, but nothing will be persisted. #e.message# #e.detail#" ) } } - /** - * Read one key of the visualizer settings, tolerating a missing struct or key (e.g. an app that - * overrode moduleSettings.rulebox.visualizer wholesale) by falling back to the module default. - */ - private any function visualizerSetting( required string key, required any defaultValue ){ - var viz = variables.settings.keyExists( "visualizer" ) ? variables.settings.visualizer : {} - return isStruct( viz ) && viz.keyExists( arguments.key ) ? viz[ arguments.key ] : arguments.defaultValue - } - } diff --git a/models/metrics/SQLiteMetricsStore.bx b/models/metrics/SQLiteMetricsStore.bx index 0916c86..11df895 100644 --- a/models/metrics/SQLiteMetricsStore.bx +++ b/models/metrics/SQLiteMetricsStore.bx @@ -20,7 +20,7 @@ class implements="IMetricsStore"{ property name="datasourceName" type="string"; function onDIComplete(){ - variables.datasourceName = variables.settings.visualizer.datasourceName ?: "rulebox_visualizer" + variables.datasourceName = variables.settings.visualizer.datasourceName ensureSchema() return this } diff --git a/test-harness/tests/specs/VisualizerSettingsSpec.bx b/test-harness/tests/specs/VisualizerSettingsSpec.bx index 19985b2..4666649 100644 --- a/test-harness/tests/specs/VisualizerSettingsSpec.bx +++ b/test-harness/tests/specs/VisualizerSettingsSpec.bx @@ -88,31 +88,33 @@ class extends="tests.resources.BaseSpec"{ } } ); - it( "RuleEventBus tolerates missing keys even if the settings were never completed", function(){ - var store = getInstance( "InMemoryMetricsStore@rulebox" ); - var bus = newBus( { visualizer: { enabled: true } } ); - - // Used to throw "The key [metricsStore] was not found in the struct" - bus.onDIComplete(); - - bus.publish( { rulebookName: "tolerantBus", ruleName: "r1", state: "EXECUTED", durationMs: 1, timestamp: "2026-01-01T00:00:00.000" } ); - expect( store.queryEvents( filters={ rulebookName: "tolerantBus" } ) ).toHaveLength( 1 ); - } ); - - it( "RuleEventBus treats a missing visualizer struct as disabled", function(){ - var received = []; - var bus = newBus( {} ); - bus.subscribe( ( evt ) => received.append( evt ) ); - - bus.onDIComplete(); - bus.publish( { rulebookName: "rb", ruleName: "r1", state: "EXECUTED", durationMs: 1, timestamp: "2026-01-01T00:00:00.000" } ); - - expect( received ).toBeEmpty(); + it( "ModuleConfig.onLoad() throws RuleBox.InvalidSettingException naming the bad key", function(){ + var settings = liveSettings(); + var original = settings.visualizer; + var badShapes = [ + { setting: { enabled: "maybe" }, key: "visualizer.enabled" }, + { setting: { enabled: true, metricsStore: " " }, key: "visualizer.metricsStore" }, + { setting: { enabled: true, datasourceName: {} }, key: "visualizer.datasourceName" } + ]; + try{ + for( var bad in badShapes ){ + settings.visualizer = bad.setting; + expect( function(){ + getController().getModuleService().getModuleConfigCache()[ "rulebox" ].onLoad(); + } ).toThrow( type="RuleBox.InvalidSettingException", regex=bad.key ); + } + settings.visualizer = true; + expect( function(){ + getController().getModuleService().getModuleConfigCache()[ "rulebox" ].onLoad(); + } ).toThrow( type="RuleBox.InvalidSettingException", regex="visualizer" ); + } finally { + settings.visualizer = original; + } } ); it( "RuleEventBus survives an unresolvable metrics store without throwing from its own catch block", function(){ var received = []; - var bus = newBus( { visualizer: { enabled: true, metricsStore: "DoesNotExist@rulebox" } } ); + var bus = newBus( { visualizer: { enabled: true, metricsStore: "DoesNotExist@rulebox", datasourceName: "rulebox_visualizer" } } ); bus.subscribe( ( evt ) => received.append( evt ) ); bus.onDIComplete(); @@ -123,6 +125,7 @@ class extends="tests.resources.BaseSpec"{ it( "a RuleBook run still succeeds and publishes an event under the minimal settings", function(){ var original = applyMinimalOverride(); + getController().getModuleService().getModuleConfigCache()[ "rulebox" ].onLoad(); var bus = getInstance( "RuleEventBus@rulebox" ); var received = []; var token = bus.subscribe( ( evt ) => received.append( evt ) ); From 58c75834a1d6d53151f93f8f6f61caad2dfc758e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 15:00:32 +0000 Subject: [PATCH 04/11] Make visualizerDefaults() static The defaults do not depend on instance state. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Yc5MDcm3tyR2yqPWs9RpgX --- ModuleConfig.bx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ModuleConfig.bx b/ModuleConfig.bx index 6bc203d..f41bb82 100644 --- a/ModuleConfig.bx +++ b/ModuleConfig.bx @@ -86,7 +86,7 @@ class { * The default `visualizer` settings: the single source of truth for both configure() and the * onLoad() back-fill of keys an app's (shallow-merged) override left out. */ - private struct function visualizerDefaults(){ + private static struct function visualizerDefaults(){ return { // Master switch. While false, the visualizer's routes 404 and no rule events are // recorded or broadcast at all - flipping this on is the only thing that turns on From aa1ccb52e64559315a6fdb13f144e349f6a8e789 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 15:00:34 +0000 Subject: [PATCH 05/11] Make visualizerDefaults() static The defaults do not depend on instance state. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Yc5MDcm3tyR2yqPWs9RpgX --- ModuleConfig.bx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ModuleConfig.bx b/ModuleConfig.bx index d719fbc..61409d5 100644 --- a/ModuleConfig.bx +++ b/ModuleConfig.bx @@ -99,7 +99,7 @@ class { * The default `visualizer` settings: the single source of truth for both configure() and the * onLoad() back-fill of keys an app's (shallow-merged) override left out. */ - private struct function visualizerDefaults(){ + private static struct function visualizerDefaults(){ return { // Master switch. While false, the visualizer's routes 404 and no rule events are // recorded or broadcast at all - flipping this on is the only thing that turns on From e447c4a02b8e8879e11d302f46e29aece65879b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 15:01:16 +0000 Subject: [PATCH 06/11] Document every ModuleConfig settings method Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Yc5MDcm3tyR2yqPWs9RpgX --- ModuleConfig.bx | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/ModuleConfig.bx b/ModuleConfig.bx index f41bb82..40be1f8 100644 --- a/ModuleConfig.bx +++ b/ModuleConfig.bx @@ -60,7 +60,13 @@ class { } /** - * Fail fast on app start for a visualizer setting of the wrong shape. + * Validate the completed visualizer settings so a bad config fails on app start, not on first use. + * + * @visualizer The visualizer settings, already merged over visualizerDefaults() + * + * @return The same, now validated, visualizer settings + * + * @throws RuleBox.InvalidSettingException When a setting has the wrong type or an out-of-range value */ private struct function validateVisualizerSettings( required struct visualizer ){ if( isNull( arguments.visualizer.enabled ) || !isBoolean( arguments.visualizer.enabled ) ){ @@ -75,6 +81,15 @@ class { return arguments.visualizer } + /** + * Throw the exception every visualizer setting check uses, naming the key and the bad value. + * + * @key The setting path under moduleSettings.rulebox, such as "visualizer.enabled" + * @value The value the app supplied + * @expected A short description of what the setting accepts + * + * @throws RuleBox.InvalidSettingException Always + */ private void function invalidSetting( required string key, any value, required string expected ){ throw( type = "RuleBox.InvalidSettingException", @@ -85,6 +100,8 @@ class { /** * The default `visualizer` settings: the single source of truth for both configure() and the * onLoad() back-fill of keys an app's (shallow-merged) override left out. + * + * @return A new struct of defaults on every call, so callers can merge into it safely */ private static struct function visualizerDefaults(){ return { From 0de9b477e9fd5f5c931b5989b6641d170ec51897 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 15:01:19 +0000 Subject: [PATCH 07/11] Document every ModuleConfig settings method Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Yc5MDcm3tyR2yqPWs9RpgX --- ModuleConfig.bx | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/ModuleConfig.bx b/ModuleConfig.bx index 61409d5..7933a78 100644 --- a/ModuleConfig.bx +++ b/ModuleConfig.bx @@ -60,7 +60,13 @@ class { } /** - * Fail fast on app start for a visualizer setting of the wrong shape. + * Validate the completed visualizer settings so a bad config fails on app start, not on first use. + * + * @visualizer The visualizer settings, already merged over visualizerDefaults() + * + * @return The same, now validated, visualizer settings + * + * @throws RuleBox.InvalidSettingException When a setting has the wrong type or an out-of-range value */ private struct function validateVisualizerSettings( required struct visualizer ){ if( isNull( arguments.visualizer.enabled ) || !isBoolean( arguments.visualizer.enabled ) ){ @@ -88,6 +94,15 @@ class { return arguments.visualizer } + /** + * Throw the exception every visualizer setting check uses, naming the key and the bad value. + * + * @key The setting path under moduleSettings.rulebox, such as "visualizer.enabled" + * @value The value the app supplied + * @expected A short description of what the setting accepts + * + * @throws RuleBox.InvalidSettingException Always + */ private void function invalidSetting( required string key, any value, required string expected ){ throw( type = "RuleBox.InvalidSettingException", @@ -98,6 +113,8 @@ class { /** * The default `visualizer` settings: the single source of truth for both configure() and the * onLoad() back-fill of keys an app's (shallow-merged) override left out. + * + * @return A new struct of defaults on every call, so callers can merge into it safely */ private static struct function visualizerDefaults(){ return { From 52785bdea598539970892fac0e917c27b4545966 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 15:02:17 +0000 Subject: [PATCH 08/11] Document every new method Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Yc5MDcm3tyR2yqPWs9RpgX --- models/metrics/SQLiteMetricsStore.bx | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/models/metrics/SQLiteMetricsStore.bx b/models/metrics/SQLiteMetricsStore.bx index bac5895..82499c8 100644 --- a/models/metrics/SQLiteMetricsStore.bx +++ b/models/metrics/SQLiteMetricsStore.bx @@ -74,6 +74,14 @@ class implements="IMetricsStore"{ return this } + /** + * Override how many successful inserts happen between retention prunes (default 500). + * Exists so the retention cadence is unit-testable. + * + * @every Number of inserts between prunes + * + * @return This store, to allow chaining + */ function setPruneEvery( required numeric every ){ variables.pruneEvery = arguments.every return this @@ -87,6 +95,9 @@ class implements="IMetricsStore"{ return variables.circuitOpen } + /** + * Has the events table and its index been created? A failed create is retried by the next recordEvent(). + */ boolean function isSchemaReady(){ return variables.schemaReady } @@ -287,10 +298,22 @@ class implements="IMetricsStore"{ } } + /** + * The current time in milliseconds, from the injected clock when there is one. + */ private numeric function nowMillis(){ return structKeyExists( variables, "clock" ) ? variables.clock() : getTickCount() } + /** + * Execute SQL through the injected runner when there is one, else queryExecute() on the configured datasource. + * + * @sql The SQL statement + * @params Query parameters + * @options Extra options for the injected runner (queryExecute always uses the configured datasource) + * + * @return The query result, or whatever the injected runner returns + */ private any function runSql( required string sql, struct params={}, struct options={} ){ if( structKeyExists( variables, "sqlRunner" ) ){ return variables.sqlRunner( arguments.sql, arguments.params, arguments.options ) From db66c6d7b44997a61c6c345937ded17faf37d346 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 16:36:05 +0000 Subject: [PATCH 09/11] Keep visualizer defaults in a static VISUALIZER_DEFAULTS struct A module is loaded once, so the defaults are a static struct instead of a function. configure() copies it, and onLoad() fills the keys an app left out with append( static.VISUALIZER_DEFAULTS, false ), so the static is never written to. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Yc5MDcm3tyR2yqPWs9RpgX --- ModuleConfig.bx | 59 ++++++++++++++++++++++--------------------------- 1 file changed, 27 insertions(+), 32 deletions(-) diff --git a/ModuleConfig.bx b/ModuleConfig.bx index 40be1f8..9e5b457 100644 --- a/ModuleConfig.bx +++ b/ModuleConfig.bx @@ -22,6 +22,28 @@ class { // The visualizer itself stays inert (routes 404, no events recorded) unless settings.visualizer.enabled = true. this.entryPoint = "rulebox-visualizer" + /** + * The default `visualizer` settings. A module is loaded once, so they live in a static struct + * that configure() copies and onLoad() uses to fill the keys an app's (shallow-merged) + * override left out. Nothing writes to it. + */ + static { + VISUALIZER_DEFAULTS = { + // Master switch. While false, the visualizer's routes 404 and no rule events are + // recorded or broadcast at all - flipping this on is the only thing that turns on + // the (small) per-rule-evaluation bookkeeping cost. + enabled = false, + // WireBox mapping ID for the metrics persistence store. Defaults to the in-memory + // store (no setup, nothing survives a restart). For persistence across restarts, + // point this at "SQLiteMetricsStore@rulebox" - see the "Rule Visualizer" guide for + // what that needs (the bx-sqlite module plus a matching datasource), or implement + // IMetricsStore@rulebox yourself and point this at its mapping. + metricsStore = "InMemoryMetricsStore@rulebox", + // Datasource name SQLiteMetricsStore reads/writes, if you opt into it above. + datasourceName = "rulebox_visualizer" + } + } + /** * Configure Module */ @@ -36,7 +58,7 @@ class { // The Rule Visualizer: a dashboard/dry-run/metrics/live-tracker admin UI, off by default. // Secure it yourself (e.g. with cbSecurity) once enabled - RuleBox doesn't gate access on its own. // See the "Rule Visualizer" guide for the full settings shape. - visualizer = visualizerDefaults() + visualizer = duplicate( static.VISUALIZER_DEFAULTS ) } } @@ -51,9 +73,8 @@ class { if( !isStruct( variables.settings.visualizer ?: "" ) ){ invalidSetting( "visualizer", variables.settings.visualizer ?: "null", "a struct" ) } - variables.settings.visualizer = validateVisualizerSettings( - visualizerDefaults().append( variables.settings.visualizer, true ) - ) + variables.settings.visualizer.append( static.VISUALIZER_DEFAULTS, false ) + validateVisualizerSettings( variables.settings.visualizer ) // Custom injection DSL: inject="rulebook" (the registry) / inject="rulebook:{name}" (a provider) wirebox.registerDSL( namespace = "rulebook", path = "rulebox.models.RuleBookDSL" ) @@ -62,13 +83,11 @@ class { /** * Validate the completed visualizer settings so a bad config fails on app start, not on first use. * - * @visualizer The visualizer settings, already merged over visualizerDefaults() - * - * @return The same, now validated, visualizer settings + * @visualizer The visualizer settings, already filled in from VISUALIZER_DEFAULTS * * @throws RuleBox.InvalidSettingException When a setting has the wrong type or an out-of-range value */ - private struct function validateVisualizerSettings( required struct visualizer ){ + private void function validateVisualizerSettings( required struct visualizer ){ if( isNull( arguments.visualizer.enabled ) || !isBoolean( arguments.visualizer.enabled ) ){ invalidSetting( "visualizer.enabled", arguments.visualizer.enabled ?: "null", "true or false" ) } @@ -78,7 +97,6 @@ class { invalidSetting( "visualizer.#key#", value, "a non-blank WireBox mapping or name" ) } } - return arguments.visualizer } /** @@ -97,29 +115,6 @@ class { ) } - /** - * The default `visualizer` settings: the single source of truth for both configure() and the - * onLoad() back-fill of keys an app's (shallow-merged) override left out. - * - * @return A new struct of defaults on every call, so callers can merge into it safely - */ - private static struct function visualizerDefaults(){ - return { - // Master switch. While false, the visualizer's routes 404 and no rule events are - // recorded or broadcast at all - flipping this on is the only thing that turns on - // the (small) per-rule-evaluation bookkeeping cost. - enabled = false, - // WireBox mapping ID for the metrics persistence store. Defaults to the in-memory - // store (no setup, nothing survives a restart). For persistence across restarts, - // point this at "SQLiteMetricsStore@rulebox" - see the "Rule Visualizer" guide for - // what that needs (the bx-sqlite module plus a matching datasource), or implement - // IMetricsStore@rulebox yourself and point this at its mapping. - metricsStore = "InMemoryMetricsStore@rulebox", - // Datasource name SQLiteMetricsStore reads/writes, if you opt into it above. - datasourceName = "rulebox_visualizer" - } - } - /** * Fired when the module is unregistered and unloaded */ From ff938faa80eb61c1b9ec3f35757565d0aa3b8c54 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 15:15:11 +0000 Subject: [PATCH 10/11] Use a destructuring for-in loop for the struct iteration Take the value straight from for( key, value in struct ) instead of looking it up by key in the loop body. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Yc5MDcm3tyR2yqPWs9RpgX --- ModuleConfig.bx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ModuleConfig.bx b/ModuleConfig.bx index ca64166..4bc6f14 100644 --- a/ModuleConfig.bx +++ b/ModuleConfig.bx @@ -112,10 +112,10 @@ class { circuitBreakerThreshold : 1, circuitBreakerCooldownSeconds : 0 } - for( var key in minimums ){ + for( var key, minimum in minimums ){ var value = arguments.visualizer[ key ] ?: "" - if( !isNumeric( value ) || value < minimums[ key ] || value != int( value ) ){ - invalidSetting( "visualizer.#key#", value, "a whole number of #minimums[ key ]# or more" ) + if( !isNumeric( value ) || value < minimum || value != int( value ) ){ + invalidSetting( "visualizer.#key#", value, "a whole number of #minimum# or more" ) } } } From b386495de4b2a6f8c2c79d67821a7ff3e70c621f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 16:14:24 +0000 Subject: [PATCH 11/11] SQLiteMetricsStore: properties as fields, no plain accessors, @threadSafe - @threadSafe: a singleton with property injection and onDIComplete() - Every field (settings, breaker and retention state, sqlRunner, clock) is a declared property with a docblock; simple starting values move to property defaults - setSqlRunner(), setClock(), setPruneEvery(), isCircuitOpen() and isSchemaReady() removed: BoxLang generates setSqlRunner(), setClock(), setPruneEvery(), getCircuitOpen() and getSchemaReady() (the spec now uses the getters) - sqlRunner/clock are checked with isNull(): a declared property always exists in variables - Docblocks on onDIComplete() and the IMetricsStore methods Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Yc5MDcm3tyR2yqPWs9RpgX --- models/metrics/SQLiteMetricsStore.bx | 164 ++++++++++++------ .../tests/specs/SQLiteMetricsStoreSpec.bx | 30 ++-- 2 files changed, 123 insertions(+), 71 deletions(-) diff --git a/models/metrics/SQLiteMetricsStore.bx b/models/metrics/SQLiteMetricsStore.bx index 82499c8..565cbae 100644 --- a/models/metrics/SQLiteMetricsStore.bx +++ b/models/metrics/SQLiteMetricsStore.bx @@ -20,6 +20,7 @@ * Swap this out entirely via moduleSettings.rulebox.visualizer.metricsStore. */ @singleton +@threadsafe class implements="IMetricsStore"{ @inject( "coldbox:moduleSettings:rulebox" ) @@ -28,78 +29,93 @@ class implements="IMetricsStore"{ @inject( "logbox:logger:{this}" ) property name="logger"; + /** + * The datasource events are written to, read from visualizer.datasourceName in onDIComplete() + */ property name="datasourceName" type="string"; - // Resolved from settings in onDIComplete(); ModuleConfig fills and validates them - variables.breakerThreshold = 5 - variables.breakerCooldownMs = 60000 - variables.retentionDays = 30 - variables.maxStoredEvents = 100000 - // Run the retention DELETE once per this many successful inserts - variables.pruneEvery = 500 - - // Breaker / schema state - variables.schemaReady = false - variables.consecutiveFailures = 0 - variables.circuitOpen = false - variables.circuitOpenedAt = 0 - variables.insertsSincePrune = 0 - variables.lastError = "" - - function onDIComplete(){ - var viz = variables.settings.visualizer - variables.datasourceName = viz.datasourceName - variables.breakerThreshold = viz.circuitBreakerThreshold - variables.breakerCooldownMs = viz.circuitBreakerCooldownSeconds * 1000 - variables.retentionDays = viz.retentionDays - variables.maxStoredEvents = viz.maxStoredEvents - ensureSchema( true ) - return this - } + /** + * Consecutive failures that open the circuit, from visualizer.circuitBreakerThreshold + */ + property name="breakerThreshold" type="numeric" default="5"; /** - * Inject the function that executes SQL: ( sql, params, options ) => result. Defaults to - * queryExecute() against the configured datasource. Exists so the breaker/retention logic is unit-testable. + * How long the circuit stays open before a trial insert, in milliseconds, from + * visualizer.circuitBreakerCooldownSeconds */ - function setSqlRunner( required function runner ){ - variables.sqlRunner = arguments.runner - return this - } + property name="breakerCooldownMs" type="numeric" default="60000"; /** - * Inject the clock used by the circuit breaker: () => epoch milliseconds. Defaults to getTickCount(). + * Prune events older than this many days, from visualizer.retentionDays. 0 disables it. */ - function setClock( required function clock ){ - variables.clock = arguments.clock - return this - } + property name="retentionDays" type="numeric" default="30"; /** - * Override how many successful inserts happen between retention prunes (default 500). - * Exists so the retention cadence is unit-testable. - * - * @every Number of inserts between prunes - * - * @return This store, to allow chaining + * Keep at most this many rows, from visualizer.maxStoredEvents. 0 disables it. */ - function setPruneEvery( required numeric every ){ - variables.pruneEvery = arguments.every - return this - } + property name="maxStoredEvents" type="numeric" default="100000"; /** - * Is the circuit currently open (tripped and not yet recovered)? Stays true through the cool-down - * and until a trial insert succeeds. + * Run the retention DELETE once per this many successful inserts */ - boolean function isCircuitOpen(){ - return variables.circuitOpen - } + property name="pruneEvery" type="numeric" default="500"; /** * Has the events table and its index been created? A failed create is retried by the next recordEvent(). */ - boolean function isSchemaReady(){ - return variables.schemaReady + property name="schemaReady" type="boolean" default="false"; + + /** + * Failures in a row since the last successful database call + */ + property name="consecutiveFailures" type="numeric" default="0"; + + /** + * Is the circuit open (tripped and not yet recovered)? + */ + property name="circuitOpen" type="boolean" default="false"; + + /** + * When the circuit opened, or when the last trial was claimed, in milliseconds + */ + property name="circuitOpenedAt" type="numeric" default="0"; + + /** + * Successful inserts since the last retention prune + */ + property name="insertsSincePrune" type="numeric" default="0"; + + /** + * The message of the most recent database failure + */ + property name="lastError" type="string" default=""; + + /** + * Executes SQL: ( sql, params, options ) => result. Null means queryExecute() against the configured + * datasource. Set it with setSqlRunner() to unit-test the breaker and retention logic. + */ + property name="sqlRunner" type="function"; + + /** + * The circuit breaker's clock: () => epoch milliseconds. Null means getTickCount(). Set it with + * setClock() to unit-test the cool-down. + */ + property name="clock" type="function"; + + /** + * Read the visualizer settings (already filled and validated by ModuleConfig) and create the schema + * + * @return This store + */ + function onDIComplete(){ + var viz = variables.settings.visualizer + variables.datasourceName = viz.datasourceName + variables.breakerThreshold = viz.circuitBreakerThreshold + variables.breakerCooldownMs = viz.circuitBreakerCooldownSeconds * 1000 + variables.retentionDays = viz.retentionDays + variables.maxStoredEvents = viz.maxStoredEvents + ensureSchema( true ) + return this } /** @@ -267,6 +283,11 @@ class implements="IMetricsStore"{ } } + /** + * Persist one rule-evaluation event. A no-op while the circuit is open. + * + * @event { rulebookName, ruleName, state, durationMs, timestamp } + */ void function recordEvent( required struct event ){ if( !canAttempt() ){ return @@ -302,7 +323,7 @@ class implements="IMetricsStore"{ * The current time in milliseconds, from the injected clock when there is one. */ private numeric function nowMillis(){ - return structKeyExists( variables, "clock" ) ? variables.clock() : getTickCount() + return isNull( variables.clock ) ? getTickCount() : variables.clock() } /** @@ -315,12 +336,20 @@ class implements="IMetricsStore"{ * @return The query result, or whatever the injected runner returns */ private any function runSql( required string sql, struct params={}, struct options={} ){ - if( structKeyExists( variables, "sqlRunner" ) ){ + if( !isNull( variables.sqlRunner ) ){ return variables.sqlRunner( arguments.sql, arguments.params, arguments.options ) } return queryExecute( arguments.sql, arguments.params, { datasource: variables.datasourceName } ) } + /** + * Return recorded events, newest first. + * + * @filters Optional filters: { rulebookName } + * @limit Max rows to return + * + * @return An array of { rulebookName, ruleName, state, durationMs, timestamp } structs + */ array function queryEvents( struct filters={}, numeric limit=50 ){ var whereClause = arguments.filters.keyExists( "rulebookName" ) ? "WHERE rulebookName = :rulebookName" : "" var params = { limit: { value: arguments.limit, cfsqltype: "integer" } } @@ -337,6 +366,13 @@ class implements="IMetricsStore"{ return q } + /** + * Aggregated stats for one rulebook across all its recorded rules. + * + * @rulebookName The rulebook to summarize + * + * @return { rulebookName, totalEvaluations, countsByState, avgDurationMs, totalDurationMs } + */ struct function queryRuleBookSummary( required string rulebookName ){ var q = queryExecute( " @@ -352,6 +388,14 @@ class implements="IMetricsStore"{ return aggregate( q, { rulebookName: arguments.rulebookName } ) } + /** + * Aggregated stats for a single rule within a rulebook. + * + * @rulebookName The rule's owning rulebook + * @ruleName The rule to summarize + * + * @return { rulebookName, ruleName, totalEvaluations, countsByState, avgDurationMs, minDurationMs, maxDurationMs, lastRunAt } + */ struct function queryRuleMetrics( required string rulebookName, required string ruleName ){ var q = queryExecute( " @@ -384,6 +428,11 @@ class implements="IMetricsStore"{ return summary } + /** + * Every distinct rulebook name that has at least one recorded event. + * + * @return An array of rulebook names + */ array function queryRuleBookNames(){ var q = queryExecute( "SELECT DISTINCT rulebookName FROM rulebox_events ORDER BY rulebookName", @@ -393,6 +442,9 @@ class implements="IMetricsStore"{ return q.map( ( row ) => row.rulebookName ) } + /** + * Clear every recorded event. + */ void function reset(){ queryExecute( "DELETE FROM rulebox_events", {}, { datasource: variables.datasourceName } ) } diff --git a/test-harness/tests/specs/SQLiteMetricsStoreSpec.bx b/test-harness/tests/specs/SQLiteMetricsStoreSpec.bx index eef9a3f..3720f8c 100644 --- a/test-harness/tests/specs/SQLiteMetricsStoreSpec.bx +++ b/test-harness/tests/specs/SQLiteMetricsStoreSpec.bx @@ -153,7 +153,7 @@ class extends="tests.resources.BaseSpec"{ it( "creates the table and the (rulebookName, ruleName, id) index on startup", function(){ var u = newUnit(); - expect( u.store.isSchemaReady() ).toBeTrue(); + expect( u.store.getSchemaReady() ).toBeTrue(); expect( u.ctx.sql ).toHaveLength( 2 ); expect( u.ctx.sql[ 1 ].sql ).toInclude( "CREATE TABLE IF NOT EXISTS rulebox_events" ); expect( u.ctx.sql[ 2 ].sql ).toInclude( "CREATE INDEX IF NOT EXISTS" ); @@ -162,14 +162,14 @@ class extends="tests.resources.BaseSpec"{ it( "remembers a failed schema create and re-attempts it before the first insert", function(){ var u = newUnit( failAtStartup=true ); - expect( u.store.isSchemaReady() ).toBeFalse(); + expect( u.store.getSchemaReady() ).toBeFalse(); expect( u.ctx.logs.warn ).toHaveLength( 1 ); u.ctx.fail = false; u.ctx.sql = []; u.store.recordEvent( anUnitEvent() ); - expect( u.store.isSchemaReady() ).toBeTrue(); + expect( u.store.getSchemaReady() ).toBeTrue(); expect( u.ctx.sql[ 1 ].sql ).toInclude( "CREATE TABLE IF NOT EXISTS" ); expect( u.ctx.sql[ u.ctx.sql.len() ].sql ).toInclude( "INSERT INTO rulebox_events" ); expect( countSql( u.ctx, "INSERT" ) ).toBe( 1 ); @@ -192,11 +192,11 @@ class extends="tests.resources.BaseSpec"{ u.store.recordEvent( anUnitEvent() ); u.store.recordEvent( anUnitEvent() ); - expect( u.store.isCircuitOpen() ).toBeFalse(); + expect( u.store.getCircuitOpen() ).toBeFalse(); expect( u.ctx.logs.error ).toBeEmpty(); u.store.recordEvent( anUnitEvent() ); - expect( u.store.isCircuitOpen() ).toBeTrue(); + expect( u.store.getCircuitOpen() ).toBeTrue(); expect( u.ctx.logs.error ).toHaveLength( 1 ); expect( u.ctx.logs.error[ 1 ] ).toInclude( "simulated database failure" ); } ); @@ -206,7 +206,7 @@ class extends="tests.resources.BaseSpec"{ u.ctx.fail = true; u.store.recordEvent( anUnitEvent() ); u.store.recordEvent( anUnitEvent() ); - expect( u.store.isCircuitOpen() ).toBeTrue(); + expect( u.store.getCircuitOpen() ).toBeTrue(); var attempts = u.ctx.sql.len(); for( var i = 1; i <= 50; i++ ){ @@ -222,19 +222,19 @@ class extends="tests.resources.BaseSpec"{ u.ctx.fail = true; u.store.recordEvent( anUnitEvent() ); u.store.recordEvent( anUnitEvent() ); - expect( u.store.isCircuitOpen() ).toBeTrue(); + expect( u.store.getCircuitOpen() ).toBeTrue(); u.ctx.fail = false; u.ctx.now += 59999; var before = u.ctx.sql.len(); u.store.recordEvent( anUnitEvent() ); expect( u.ctx.sql ).toHaveLength( before ); - expect( u.store.isCircuitOpen() ).toBeTrue(); + expect( u.store.getCircuitOpen() ).toBeTrue(); u.ctx.now += 1; u.store.recordEvent( anUnitEvent() ); expect( countSql( u.ctx, "INSERT" ) ).toBe( 3 ); - expect( u.store.isCircuitOpen() ).toBeFalse(); + expect( u.store.getCircuitOpen() ).toBeFalse(); expect( u.ctx.logs.info ).toHaveLength( 1 ); u.store.recordEvent( anUnitEvent() ); @@ -255,7 +255,7 @@ class extends="tests.resources.BaseSpec"{ u.store.recordEvent( anUnitEvent() ); expect( u.ctx.sql ).toHaveLength( attempts + 1 ); - expect( u.store.isCircuitOpen() ).toBeTrue(); + expect( u.store.getCircuitOpen() ).toBeTrue(); expect( u.ctx.logs.error ).toHaveLength( 1 ); u.ctx.now += 60000; @@ -275,7 +275,7 @@ class extends="tests.resources.BaseSpec"{ u.store.recordEvent( anUnitEvent() ); u.store.recordEvent( anUnitEvent() ); - expect( u.store.isCircuitOpen() ).toBeFalse(); + expect( u.store.getCircuitOpen() ).toBeFalse(); expect( u.ctx.logs.error ).toBeEmpty(); } ); @@ -286,10 +286,10 @@ class extends="tests.resources.BaseSpec"{ for( var i = 1; i <= 4; i++ ){ u.store.recordEvent( anUnitEvent() ); } - expect( u.store.isCircuitOpen() ).toBeFalse(); + expect( u.store.getCircuitOpen() ).toBeFalse(); u.store.recordEvent( anUnitEvent() ); - expect( u.store.isCircuitOpen() ).toBeTrue(); + expect( u.store.getCircuitOpen() ).toBeTrue(); } ); it( "does not throw or log per event when the datasource does not exist (real queryExecute path)", function(){ @@ -308,7 +308,7 @@ class extends="tests.resources.BaseSpec"{ store.recordEvent( anUnitEvent() ); } - expect( store.isCircuitOpen() ).toBeTrue(); + expect( store.getCircuitOpen() ).toBeTrue(); expect( logs.error ).toHaveLength( 1 ); } ); @@ -367,7 +367,7 @@ class extends="tests.resources.BaseSpec"{ u.store.recordEvent( anUnitEvent() ); } - expect( u.store.isCircuitOpen() ).toBeFalse(); + expect( u.store.getCircuitOpen() ).toBeFalse(); expect( countSql( u.ctx, "INSERT" ) ).toBe( 10 ); expect( u.ctx.logs.error ).toBeEmpty(); expect( u.ctx.logs.warn ).toHaveLength( 10 );