From 912d3c52eac3bb75fdd7420d3a0d3f41f8906f2c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 11:53:43 +0000 Subject: [PATCH 01/13] 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 c5941303f84c48720f6b1a5e0f7b1cd61c76c503 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 11:59:53 +0000 Subject: [PATCH 02/13] fix(visualizer): cap live SSE streams, bound per-stream queues, never leak a subscription stream() opened an SSE connection per tab with no limit (each pins two server threads, so a loop of GET requests could exhaust the worker pool), buffered events in an unbounded queue, and subscribed to the bus before SSE() so a failure opening the stream leaked the subscription. - New LiveStreams@rulebox model: atomic stream-slot counter, bounded per-stream queue (1000 events, drops the oldest when full, never blocks the bus), and serve() which subscribes, runs the opener, and always unsubscribes and releases the slot in a finally. - New setting visualizer.maxStreams (default 25, read defensively in the handler); at the cap stream() answers HTTP 503 {"error":"Too many live tracker connections"}. - Specs for the cap, slot release on failed open, and the bounded queue; docs. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Yc5MDcm3tyR2yqPWs9RpgX --- ModuleConfig.bx | 4 +- docs/guides/visualizer.md | 25 +++ handlers/Visualizer.bx | 58 ++++-- models/metrics/LiveStreams.bx | 126 +++++++++++++ test-harness/tests/specs/RuleEventBusSpec.bx | 165 ++++++++++++++++++ .../tests/specs/VisualizerHandlerSpec.bx | 67 +++++++ 6 files changed, 425 insertions(+), 20 deletions(-) create mode 100644 models/metrics/LiveStreams.bx diff --git a/ModuleConfig.bx b/ModuleConfig.bx index 65e549a..f07517e 100644 --- a/ModuleConfig.bx +++ b/ModuleConfig.bx @@ -48,7 +48,9 @@ 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", + // Max live-tracker (SSE) connections open at once; each pins two server threads. Over the cap, stream() answers 503. + maxStreams = 25 } } } diff --git a/docs/guides/visualizer.md b/docs/guides/visualizer.md index 9ec899f..0be00c2 100644 --- a/docs/guides/visualizer.md +++ b/docs/guides/visualizer.md @@ -123,6 +123,31 @@ Each row shows the time, rulebook, rule, outcome state, and duration. Use > `whitespaceCompressionEnabled` to `false` in `boxlang.json`, keeping in mind > that it applies to all of your app's output. +#### Limiting live connections + +Each open Live Tracker tab holds a stream open, and that pins two server +threads for as long as it stays connected. RuleBox therefore caps how many +streams can be open at once with `visualizer.maxStreams` (default `25`): + +```cfc +moduleSettings = { + rulebox = { + visualizer = { + enabled = true, + maxStreams = 10 + } + } +} +``` + +Once the cap is reached, further requests to `stream` get an HTTP `503` with +`{ "error": "Too many live tracker connections" }` instead of a new stream, and +a slot frees up as soon as a tab closes or its connection drops. Keep the cap +comfortably below your servlet container's worker thread count so the tracker +can never starve the rest of your application. Each stream also buffers at +most 1000 events; if a browser stalls and falls behind, its oldest unsent +events are dropped rather than letting memory grow. + ## Metrics persistence Every rule evaluation is recorded through `RuleEventBus@rulebox`, which fans diff --git a/handlers/Visualizer.bx b/handlers/Visualizer.bx index cf57727..0606ec0 100644 --- a/handlers/Visualizer.bx +++ b/handlers/Visualizer.bx @@ -20,6 +20,9 @@ class{ @inject( "RuleEventBus@rulebox" ) property name="eventBus"; + @inject( "LiveStreams@rulebox" ) + property name="liveStreams"; + this.layout = "Visualizer" /** @@ -159,27 +162,44 @@ class{ * SSE stream of every rule-evaluation event, as it happens, across all rulebooks. */ function stream( event, rc, prc ){ - var queue = createObject( "java", "java.util.concurrent.LinkedBlockingQueue" ).init() - var bus = variables.eventBus - var token = bus.subscribe( ( evt ) => queue.offer( evt ) ) - - SSE( - callback: ( emitter ) => { - try{ - while( !emitter.isClosed() ){ - var evt = queue.poll( 1000, createObject( "java", "java.util.concurrent.TimeUnit" ).MILLISECONDS ) - if( !isNull( evt ) ){ - emitter.send( evt, "rule" ) + // Slot accounting, the bounded per-stream queue and the subscribe/unsubscribe pairing live + // in LiveStreams; serve() releases the slot and unsubscribes even if SSE() itself throws. + var served = variables.liveStreams.serve( + bus : variables.eventBus, + maxStreams : getMaxStreams(), + opener : ( queue ) => { + SSE( + callback: ( emitter ) => { + while( !emitter.isClosed() ){ + var evt = queue.poll( 1000, createObject( "java", "java.util.concurrent.TimeUnit" ).MILLISECONDS ) + if( !isNull( evt ) ){ + emitter.send( evt, "rule" ) + } } - } - } finally { - bus.unsubscribe( token ) - } - }, - async: true, - keepAliveInterval: 15000, - timeout: 0 + }, + async: true, + keepAliveInterval: 15000, + timeout: 0 + ) + } ) + + if( !served ){ + event.renderData( + type = "json", + data = { "error": "Too many live tracker connections" }, + statusCode = 503 + ) + } + } + + /** + * The configured live-stream cap. Read defensively so an app whose settings predate + * visualizer.maxStreams (or set it to something unusable) still gets the default of 25. + */ + private numeric function getMaxStreams(){ + var configured = variables.settings.visualizer.keyExists( "maxStreams" ) ? variables.settings.visualizer.maxStreams : 25 + return ( isNumeric( configured ) && configured >= 1 ) ? int( configured ) : 25 } /** diff --git a/models/metrics/LiveStreams.bx b/models/metrics/LiveStreams.bx new file mode 100644 index 0000000..0de5edb --- /dev/null +++ b/models/metrics/LiveStreams.bx @@ -0,0 +1,126 @@ +/** + * Bookkeeping for the Rule Visualizer's live SSE streams (handlers/Visualizer.bx stream()): + * + * - caps how many streams may be open at once (each open stream pins two server threads), + * - hands out a bounded per-stream event queue that never blocks the event bus and never grows, + * - guarantees a stream's slot is released and its bus subscription removed however it ends. + * + * Kept out of the handler so the limit logic is unit-testable without opening a real stream. + */ +@singleton +@threadsafe +class{ + + function init(){ + // Events held per stream before the oldest start being dropped + variables.active = createObject( "java", "java.util.concurrent.atomic.AtomicInteger" ).init( 0 ) + variables.queueCapacity = 1000 + return this + } + + /** + * How many streams currently hold a slot. + */ + numeric function activeCount(){ + return variables.active.get() + } + + /** + * Atomically take a stream slot unless maxStreams are already in use. + * + * @maxStreams The cap on concurrent streams + * + * @return true if a slot was taken (the caller must release() it), false if at the cap + */ + boolean function tryAcquire( required numeric maxStreams ){ + while( true ){ + var current = variables.active.get() + if( current >= arguments.maxStreams ){ + return false + } + if( variables.active.compareAndSet( current, current + 1 ) ){ + return true + } + } + } + + /** + * Give back a slot taken by tryAcquire(). Never drops the count below zero. + */ + void function release(){ + while( true ){ + var current = variables.active.get() + if( current <= 0 || variables.active.compareAndSet( current, current - 1 ) ){ + return + } + } + } + + /** + * A fresh bounded queue for one stream's events. + * + * @capacity Max events held; defaults to the model's queueCapacity + */ + any function newQueue( numeric capacity=variables.queueCapacity ){ + return createObject( "java", "java.util.concurrent.LinkedBlockingQueue" ).init( javaCast( "int", arguments.capacity ) ) + } + + /** + * Add an event to a bounded queue without ever blocking the publisher. When the queue is full + * (a slow or stalled client) the OLDEST queued event is dropped to make room: a live tracker + * is most useful showing what just happened. + * + * @queue A queue from newQueue() + * @evt The event to add + * + * @return true if nothing had to be dropped + */ + boolean function offer( required queue, required evt ){ + if( arguments.queue.offer( arguments.evt ) ){ + return true + } + // Full: evict the head and retry. Another producer can win the freed slot, so retry a few + // times; if we still lose, dropping this (newest) event is the same bounded behavior. + for( var attempt = 1; attempt <= 3; attempt++ ){ + arguments.queue.poll() + if( arguments.queue.offer( arguments.evt ) ){ + return false + } + } + return false + } + + /** + * Open one live stream under the cap. Takes a slot, subscribes a bounded queue to the bus, runs + * the opener, and in every outcome (normal end, client gone, opener throws) unsubscribes and + * releases the slot. The opener normally blocks until the stream closes. + * + * @bus The RuleEventBus to subscribe to + * @maxStreams The cap on concurrent streams + * @opener A ( queue ) => void closure that opens the stream and drains the queue + * + * @return false (and runs nothing) if the cap is reached, true once a stream ran + */ + boolean function serve( required bus, required numeric maxStreams, required opener ){ + if( !tryAcquire( arguments.maxStreams ) ){ + return false + } + + var token = 0 + var subscribed = false + try{ + var queue = newQueue() + var self = this + token = arguments.bus.subscribe( ( evt ) => self.offer( queue, evt ) ) + subscribed = true + arguments.opener( queue ) + } finally { + if( subscribed ){ + arguments.bus.unsubscribe( token ) + } + release() + } + return true + } + +} diff --git a/test-harness/tests/specs/RuleEventBusSpec.bx b/test-harness/tests/specs/RuleEventBusSpec.bx index 6a2cbe4..2fa9ba4 100644 --- a/test-harness/tests/specs/RuleEventBusSpec.bx +++ b/test-harness/tests/specs/RuleEventBusSpec.bx @@ -88,6 +88,171 @@ class extends="tests.resources.BaseSpec"{ } ); } ); + + describe( "LiveStreams (the Live Tracker's stream cap and bounded queues)", function(){ + + function newStreams(){ + return new rulebox.models.metrics.LiveStreams(); + } + + function newBus(){ + var bus = new rulebox.models.metrics.RuleEventBus(); + bus.setWirebox( getWireBox() ); + bus.setLogger( getController().getLogBox().getRootLogger() ); + bus.setSettings( { visualizer: { enabled: true, metricsStore: "InMemoryMetricsStore@rulebox", datasourceName: "" } } ); + return bus; + } + + // Drain a queue into an array of its events' ruleName, oldest first + function drainRuleNames( required queue ){ + var names = []; + while( !arguments.queue.isEmpty() ){ + names.append( arguments.queue.poll().ruleName ); + } + return names; + } + + it( "rejects the N+1th stream and admits one again once a slot is released", function(){ + var streams = newStreams(); + + expect( streams.tryAcquire( 2 ) ).toBeTrue(); + expect( streams.tryAcquire( 2 ) ).toBeTrue(); + expect( streams.tryAcquire( 2 ) ).toBeFalse(); + expect( streams.activeCount() ).toBe( 2 ); + + streams.release(); + + expect( streams.activeCount() ).toBe( 1 ); + expect( streams.tryAcquire( 2 ) ).toBeTrue(); + } ); + + it( "never lets release() drive the count below zero", function(){ + var streams = newStreams(); + + streams.release(); + + expect( streams.activeCount() ).toBe( 0 ); + } ); + + it( "never admits more than the cap under concurrent attempts", function(){ + var streams = newStreams(); + var ids = []; + for( var i = 1; i <= 100; i++ ){ + ids.append( i ); + } + + var admitted = ids.map( ( id ) => streams.tryAcquire( 10 ), true ); + + expect( admitted.filter( ( ok ) => ok ) ).toHaveLength( 10 ); + expect( streams.activeCount() ).toBe( 10 ); + } ); + + it( "serve() refuses to open a stream at the cap, without subscribing or running the opener", function(){ + var streams = newStreams(); + var bus = newBus(); + var opened = false; + streams.tryAcquire( 1 ); + + var served = streams.serve( bus, 1, ( queue ) => { opened = true } ); + + expect( served ).toBeFalse(); + expect( opened ).toBeFalse(); + expect( bus.getSubscribers() ).toBeEmpty(); + expect( streams.activeCount() ).toBe( 1 ); + } ); + + it( "serve() runs the opener against a queue the bus feeds, then unsubscribes and frees the slot", function(){ + var streams = newStreams(); + var bus = newBus(); + var seen = []; + var during = -1; + + var served = streams.serve( bus, 1, ( queue ) => { + bus.publish( { rulebookName: "rb", ruleName: "first", state: "EXECUTED", durationMs: 1, timestamp: "" } ); + seen = drainRuleNames( queue ); + during = streams.activeCount(); + } ); + + expect( served ).toBeTrue(); + expect( during ).toBe( 1 ); + expect( seen.toList() ).toBe( "first" ); + expect( bus.getSubscribers() ).toBeEmpty(); + expect( streams.activeCount() ).toBe( 0 ); + } ); + + it( "serve() releases the slot and unsubscribes when opening the stream throws", function(){ + var streams = newStreams(); + var bus = newBus(); + var threw = false; + + try{ + streams.serve( bus, 1, ( queue ) => { throw( type="Test.OpenFailed", message="SSE() blew up" ) } ); + } catch( any e ){ + threw = ( e.type == "Test.OpenFailed" ); + } + + expect( threw ).toBeTrue(); + expect( bus.getSubscribers() ).toBeEmpty(); + expect( streams.activeCount() ).toBe( 0 ); + // ...and the freed slot is genuinely usable + expect( streams.tryAcquire( 1 ) ).toBeTrue(); + } ); + + it( "serve() releases the slot when subscribing itself throws", function(){ + var streams = newStreams(); + var failingBus = { + subscribe : ( listener ) => { throw( type="Test.SubscribeFailed", message="no" ) }, + unsubscribe: ( token ) => { throw( type="Test.ShouldNotUnsubscribe", message="nothing was subscribed" ) } + }; + var threw = false; + + try{ + streams.serve( failingBus, 1, ( queue ) => {} ); + } catch( any e ){ + threw = ( e.type == "Test.SubscribeFailed" ); + } + + expect( threw ).toBeTrue(); + expect( streams.activeCount() ).toBe( 0 ); + } ); + + it( "a full queue drops the OLDEST event instead of growing or blocking", function(){ + var streams = newStreams(); + var queue = streams.newQueue( 3 ); + + for( var n = 1; n <= 5; n++ ){ + streams.offer( queue, { ruleName: "e#n#" } ); + } + + expect( queue.size() ).toBe( 3 ); + expect( drainRuleNames( queue ).toList() ).toBe( "e3,e4,e5" ); + } ); + + it( "bounds each stream's queue at 1000 events by default", function(){ + expect( newStreams().newQueue().remainingCapacity() ).toBe( 1000 ); + } ); + + it( "keeps a stalled subscriber bounded while the bus keeps publishing", function(){ + var streams = newStreams(); + var bus = newBus(); + var queue = streams.newQueue( 10 ); + var token = bus.subscribe( ( evt ) => streams.offer( queue, evt ) ); + getInstance( "InMemoryMetricsStore@rulebox" ).reset(); + + // Nothing ever drains the queue: a stalled client + for( var n = 1; n <= 50; n++ ){ + bus.publish( { rulebookName: "rb", ruleName: "e#n#", state: "EXECUTED", durationMs: 1, timestamp: "" } ); + } + bus.unsubscribe( token ); + getInstance( "InMemoryMetricsStore@rulebox" ).reset(); + + var kept = drainRuleNames( queue ); + expect( kept ).toHaveLength( 10 ); + expect( kept[ 1 ] ).toBe( "e41" ); + expect( kept[ 10 ] ).toBe( "e50" ); + } ); + + } ); } } diff --git a/test-harness/tests/specs/VisualizerHandlerSpec.bx b/test-harness/tests/specs/VisualizerHandlerSpec.bx index 4c47b51..c175aca 100644 --- a/test-harness/tests/specs/VisualizerHandlerSpec.bx +++ b/test-harness/tests/specs/VisualizerHandlerSpec.bx @@ -171,6 +171,73 @@ class extends="tests.resources.BaseSpec"{ expect( summary ).toHaveKey( "countsByState" ); } ); + describe( "stream() connection cap", function(){ + + // The exact settings struct and LiveStreams singleton the handler reads, so holding + // slots here is seen by the real stream() action. + function hold( required numeric count ){ + var streams = getWireBox().getInstance( "LiveStreams@rulebox" ); + for( var i = 1; i <= arguments.count; i++ ){ + streams.tryAcquire( 1000 ); + } + } + + function releaseAll(){ + var streams = getWireBox().getInstance( "LiveStreams@rulebox" ); + while( streams.activeCount() > 0 ){ + streams.release(); + } + } + + it( "answers 503 JSON instead of opening a stream once visualizer.maxStreams are open", function(){ + var settings = getWireBox().getInstance( dsl="coldbox:moduleSettings:rulebox" ); + settings.visualizer.maxStreams = 2; + hold( 2 ); + + try{ + var event = execute( event="rulebox:visualizer.stream", renderResults=true ); + + expect( event.getRenderData().statusCode ).toBe( 503 ); + expect( jsonDeserialize( event.getRenderedContent() ).error ).toBe( "Too many live tracker connections" ); + // A rejected request must not hold a slot of its own + expect( getWireBox().getInstance( "LiveStreams@rulebox" ).activeCount() ).toBe( 2 ); + } finally { + releaseAll(); + settings.visualizer.delete( "maxStreams" ); + } + } ); + + it( "falls back to a cap of 25 when visualizer.maxStreams is not set", function(){ + var settings = getWireBox().getInstance( dsl="coldbox:moduleSettings:rulebox" ); + settings.visualizer.delete( "maxStreams" ); + hold( 25 ); + + try{ + var event = execute( event="rulebox:visualizer.stream", renderResults=true ); + + expect( event.getRenderData().statusCode ).toBe( 503 ); + } finally { + releaseAll(); + } + } ); + + it( "ignores an unusable visualizer.maxStreams and uses the default cap", function(){ + var settings = getWireBox().getInstance( dsl="coldbox:moduleSettings:rulebox" ); + settings.visualizer.maxStreams = "lots"; + hold( 25 ); + + try{ + var event = execute( event="rulebox:visualizer.stream", renderResults=true ); + + expect( event.getRenderData().statusCode ).toBe( 503 ); + } finally { + releaseAll(); + settings.visualizer.delete( "maxStreams" ); + } + } ); + + } ); + it( "404s every action while the visualizer is disabled", function(){ // Same DSL the handler/RuleEventBus are injected with, so this is guaranteed to be // the exact same live settings struct they read from. From 84851654c268457fd3d13adc0a3aa47ac888e280 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 12:07:24 +0000 Subject: [PATCH 03/13] test: fix the new LiveStreams specs so the TestBox bundles load RuleEventBusSpec declared a second newBus() helper in the same run() scope as the existing one, which BoxLang rejects at compile time ("Cannot define multiple functions with the same name: newBus"); the whole bundle failed to load and the runner produced no report. The new helpers are renamed (newStreamBus, newLiveStreams). The stream() cap specs in VisualizerHandlerSpec now call setup() first so execute() gets a fresh request context instead of inheriting the previous spec's 404 renderData. Verified with the real TestBox runner against the test-harness: the full suite is 144 passed, 0 failed. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Yc5MDcm3tyR2yqPWs9RpgX --- test-harness/tests/specs/RuleEventBusSpec.bx | 32 +++++++++---------- .../tests/specs/VisualizerHandlerSpec.bx | 6 ++++ 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/test-harness/tests/specs/RuleEventBusSpec.bx b/test-harness/tests/specs/RuleEventBusSpec.bx index 2fa9ba4..b9bc94a 100644 --- a/test-harness/tests/specs/RuleEventBusSpec.bx +++ b/test-harness/tests/specs/RuleEventBusSpec.bx @@ -91,11 +91,11 @@ class extends="tests.resources.BaseSpec"{ describe( "LiveStreams (the Live Tracker's stream cap and bounded queues)", function(){ - function newStreams(){ + function newLiveStreams(){ return new rulebox.models.metrics.LiveStreams(); } - function newBus(){ + function newStreamBus(){ var bus = new rulebox.models.metrics.RuleEventBus(); bus.setWirebox( getWireBox() ); bus.setLogger( getController().getLogBox().getRootLogger() ); @@ -113,7 +113,7 @@ class extends="tests.resources.BaseSpec"{ } it( "rejects the N+1th stream and admits one again once a slot is released", function(){ - var streams = newStreams(); + var streams = newLiveStreams(); expect( streams.tryAcquire( 2 ) ).toBeTrue(); expect( streams.tryAcquire( 2 ) ).toBeTrue(); @@ -127,7 +127,7 @@ class extends="tests.resources.BaseSpec"{ } ); it( "never lets release() drive the count below zero", function(){ - var streams = newStreams(); + var streams = newLiveStreams(); streams.release(); @@ -135,7 +135,7 @@ class extends="tests.resources.BaseSpec"{ } ); it( "never admits more than the cap under concurrent attempts", function(){ - var streams = newStreams(); + var streams = newLiveStreams(); var ids = []; for( var i = 1; i <= 100; i++ ){ ids.append( i ); @@ -148,8 +148,8 @@ class extends="tests.resources.BaseSpec"{ } ); it( "serve() refuses to open a stream at the cap, without subscribing or running the opener", function(){ - var streams = newStreams(); - var bus = newBus(); + var streams = newLiveStreams(); + var bus = newStreamBus(); var opened = false; streams.tryAcquire( 1 ); @@ -162,8 +162,8 @@ class extends="tests.resources.BaseSpec"{ } ); it( "serve() runs the opener against a queue the bus feeds, then unsubscribes and frees the slot", function(){ - var streams = newStreams(); - var bus = newBus(); + var streams = newLiveStreams(); + var bus = newStreamBus(); var seen = []; var during = -1; @@ -181,8 +181,8 @@ class extends="tests.resources.BaseSpec"{ } ); it( "serve() releases the slot and unsubscribes when opening the stream throws", function(){ - var streams = newStreams(); - var bus = newBus(); + var streams = newLiveStreams(); + var bus = newStreamBus(); var threw = false; try{ @@ -199,7 +199,7 @@ class extends="tests.resources.BaseSpec"{ } ); it( "serve() releases the slot when subscribing itself throws", function(){ - var streams = newStreams(); + var streams = newLiveStreams(); var failingBus = { subscribe : ( listener ) => { throw( type="Test.SubscribeFailed", message="no" ) }, unsubscribe: ( token ) => { throw( type="Test.ShouldNotUnsubscribe", message="nothing was subscribed" ) } @@ -217,7 +217,7 @@ class extends="tests.resources.BaseSpec"{ } ); it( "a full queue drops the OLDEST event instead of growing or blocking", function(){ - var streams = newStreams(); + var streams = newLiveStreams(); var queue = streams.newQueue( 3 ); for( var n = 1; n <= 5; n++ ){ @@ -229,12 +229,12 @@ class extends="tests.resources.BaseSpec"{ } ); it( "bounds each stream's queue at 1000 events by default", function(){ - expect( newStreams().newQueue().remainingCapacity() ).toBe( 1000 ); + expect( newLiveStreams().newQueue().remainingCapacity() ).toBe( 1000 ); } ); it( "keeps a stalled subscriber bounded while the bus keeps publishing", function(){ - var streams = newStreams(); - var bus = newBus(); + var streams = newLiveStreams(); + var bus = newStreamBus(); var queue = streams.newQueue( 10 ); var token = bus.subscribe( ( evt ) => streams.offer( queue, evt ) ); getInstance( "InMemoryMetricsStore@rulebox" ).reset(); diff --git a/test-harness/tests/specs/VisualizerHandlerSpec.bx b/test-harness/tests/specs/VisualizerHandlerSpec.bx index c175aca..361096d 100644 --- a/test-harness/tests/specs/VisualizerHandlerSpec.bx +++ b/test-harness/tests/specs/VisualizerHandlerSpec.bx @@ -173,6 +173,12 @@ class extends="tests.resources.BaseSpec"{ describe( "stream() connection cap", function(){ + // Fresh request context per spec: execute() otherwise reuses the previous spec's + // (e.g. the disabled-visualizer spec's 404 renderData). + beforeEach( function(){ + setup(); + } ); + // The exact settings struct and LiveStreams singleton the handler reads, so holding // slots here is seen by the real stream() action. function hold( required numeric count ){ From a7e6397667812bf01851a89e8e320244c74adc66 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 14:45:30 +0000 Subject: [PATCH 04/13] 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 ab77940406763b11f4c4c25722ac32dbe182bc66 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 14:48:43 +0000 Subject: [PATCH 05/13] Validate whole-number visualizer settings from a key-to-minimum map Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Yc5MDcm3tyR2yqPWs9RpgX --- ModuleConfig.bx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/ModuleConfig.bx b/ModuleConfig.bx index 86835e6..f58f19e 100644 --- a/ModuleConfig.bx +++ b/ModuleConfig.bx @@ -72,10 +72,14 @@ class { invalidSetting( "visualizer.#key#", value, "a non-blank WireBox mapping or name" ) } } - for( var key in [ "maxStreams" ] ){ + // Whole-number settings and the smallest value each accepts + var minimums = { + maxStreams : 1 + } + for( var key in minimums ){ var value = arguments.visualizer[ key ] ?: "" - if( !isNumeric( value ) || value < 1 || value != int( value ) ){ - invalidSetting( "visualizer.#key#", value, "a whole number of 1 or more" ) + if( !isNumeric( value ) || value < minimums[ key ] || value != int( value ) ){ + invalidSetting( "visualizer.#key#", value, "a whole number of #minimums[ key ]# or more" ) } } return arguments.visualizer From 58c75834a1d6d53151f93f8f6f61caad2dfc758e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 15:00:32 +0000 Subject: [PATCH 06/13] 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 1ebd1cd3c0afcc33a54facf0aa7d03b62be5dc84 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 15:00:37 +0000 Subject: [PATCH 07/13] 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 f58f19e..b69f1d3 100644 --- a/ModuleConfig.bx +++ b/ModuleConfig.bx @@ -96,7 +96,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 08/13] 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 ae0488da1fd17d342c862988895311990d038a0c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 15:01:22 +0000 Subject: [PATCH 09/13] 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 b69f1d3..660388f 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 ) ){ @@ -85,6 +91,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", @@ -95,6 +110,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 54f0a940dc58bf923b4422e5070bac2f34076276 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 15:02:20 +0000 Subject: [PATCH 10/13] Document every new method Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Yc5MDcm3tyR2yqPWs9RpgX --- models/metrics/LiveStreams.bx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/models/metrics/LiveStreams.bx b/models/metrics/LiveStreams.bx index 0de5edb..cfc3874 100644 --- a/models/metrics/LiveStreams.bx +++ b/models/metrics/LiveStreams.bx @@ -11,6 +11,11 @@ @threadsafe class{ + /** + * Constructor: no streams open, and each stream's queue holds at most 1000 events. + * + * @return This LiveStreams instance + */ function init(){ // Events held per stream before the oldest start being dropped variables.active = createObject( "java", "java.util.concurrent.atomic.AtomicInteger" ).init( 0 ) From db66c6d7b44997a61c6c345937ded17faf37d346 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 16:36:05 +0000 Subject: [PATCH 11/13] 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 5b32bb985b16cd638fef41d9d4997dd86b4328e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 08:11:05 +0000 Subject: [PATCH 12/13] LiveStreams: make the per-stream queue capacity static A fixed limit, the same for every instance, so it lives in a static block (QUEUE_CAPACITY) like the other constants, not in variables. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Yc5MDcm3tyR2yqPWs9RpgX --- models/metrics/LiveStreams.bx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/models/metrics/LiveStreams.bx b/models/metrics/LiveStreams.bx index cfc3874..7e81760 100644 --- a/models/metrics/LiveStreams.bx +++ b/models/metrics/LiveStreams.bx @@ -12,14 +12,20 @@ class{ /** - * Constructor: no streams open, and each stream's queue holds at most 1000 events. + * Fixed limits, the same for every instance. Static so they are not copied per instance. + */ + static { + // Events held per stream before the oldest start being dropped + QUEUE_CAPACITY = 1000 + } + + /** + * Constructor: no streams open. * * @return This LiveStreams instance */ function init(){ - // Events held per stream before the oldest start being dropped - variables.active = createObject( "java", "java.util.concurrent.atomic.AtomicInteger" ).init( 0 ) - variables.queueCapacity = 1000 + variables.active = createObject( "java", "java.util.concurrent.atomic.AtomicInteger" ).init( 0 ) return this } @@ -64,9 +70,9 @@ class{ /** * A fresh bounded queue for one stream's events. * - * @capacity Max events held; defaults to the model's queueCapacity + * @capacity Max events held; defaults to QUEUE_CAPACITY */ - any function newQueue( numeric capacity=variables.queueCapacity ){ + any function newQueue( numeric capacity=static.QUEUE_CAPACITY ){ return createObject( "java", "java.util.concurrent.LinkedBlockingQueue" ).init( javaCast( "int", arguments.capacity ) ) } From 92738f8a0b1446eb5ece2ca04c160ef619715b97 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 15:15:13 +0000 Subject: [PATCH 13/13] 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 a3da546..e8f1a4f 100644 --- a/ModuleConfig.bx +++ b/ModuleConfig.bx @@ -103,10 +103,10 @@ class { var minimums = { maxStreams : 1 } - 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" ) } } }