Define cached threshold behavior for isEnabled

Document and test explicit refresh boundaries for isEnabled guards, and
only read the shared root threshold when no namespace threshold applies.
Include the portable Nimble configuration loader.
This commit is contained in:
jdb committed 2026-09-29 08:43:31 -05:00
1 parent 14e71c72ca
commit 011780e6ab
5 files changed
+88 -10

No files matched your search

+31 -4
View File
@@ -76,6 +76,33 @@ proc configureLogging(localLogSvc: ThreadLocalLogService, verbose: bool) =
# Changes automatically propagate to all threads # Changes automatically propagate to all threads
``` ```
### Avoiding Expensive Log Preparation
Use `isEnabled` to guard expensive work performed before a logging call:
```nim
if logger.isEnabled(lvlDebug):
let details = buildExpensiveDetails()
logger.debug(details)
```
`isEnabled` checks the logger's effective threshold without refreshing its
configuration. It uses cached namespace thresholds and the shared root fallback
level. Namespace changes can remain unseen until the local service refreshes.
Normal logging calls and `getLogger` refresh the cache; callers can also use
`ensureFreshness(localLogSvc)` explicitly.
If all logging is guarded by `isEnabled`, a cached disabled level can keep
skipping those logging calls indefinitely. To pick up runtime namespace changes
in that situation, refresh the local service at suitable application boundaries,
such as the start of a request or work batch.
A true result does not guarantee delivery: the logging call refreshes
configuration and appenders apply their own filters. For a single expression,
`logger.debug(buildExpensiveDetails())` already delays evaluation until the
logger's threshold permits the message. A separate guard is useful when
preparation requires multiple statements.
## Loggers and Appenders ## Loggers and Appenders
The logging system is composed of two main components: loggers and appenders. The logging system is composed of two main components: loggers and appenders.
@@ -310,13 +337,13 @@ No explicit flush or delay is needed after joining the threads that log.
You can change logging configuration through a service copy or a local service You can change logging configuration through a service copy or a local service
in any thread. Those changes are intended to take effect automatically on in any thread. Those changes are intended to take effect automatically on
subsequent logging calls in every thread using that service, without recreating subsequent logging calls in every thread using that service, without recreating
loggers or configuring each worker separately. loggers or configuring each worker separately. `isEnabled` intentionally does
not refresh configuration; see [Avoiding Expensive Log Preparation](#avoiding-expensive-log-preparation).
> [!NOTE] > [!NOTE]
> Thread-safety work is still in progress. Custom state and captured > Thread-safety work is still in progress. Custom state and captured
> formatters must follow the ownership rules above. Standalone `isEnabled()` > formatters must follow the ownership rules above. Concurrent local-time
> checks do not yet notice changed namespace thresholds, and concurrent > timestamp construction still has a known failure.
> local-time timestamp construction still has a known failure.
> Nim 2.2.10 also produces sanitizer reports in its default allocator > Nim 2.2.10 also produces sanitizer reports in its default allocator
> during some cross-thread allocation patterns. See > during some cross-thread allocation patterns. See
> [the regression tests](tests/README.md) for the remaining work. > [the regression tests](tests/README.md) for the remaining work.
+4
View File
@@ -0,0 +1,4 @@
# begin Nimble config (version 2)
when withDir(thisDir(), system.fileExists("nimble.paths")):
include "nimble.paths"
# end Nimble config
+18 -2
View File
@@ -541,7 +541,6 @@ func getEffectiveThreshold(logger: Logger): Level {.gcsafe.} =
## Get the effective logging level threshold for a logger. This is the most ## Get the effective logging level threshold for a logger. This is the most
## specific level that is set for the logger or any of its parents. The root ## specific level that is set for the logger or any of its parents. The root
## logger is used as the default if no other level is set. ## logger is used as the default if no other level is set.
result = logger.threadSvc.shared.rootLevel.load
# Check for exact match first # Check for exact match first
if logger.threadSvc.thresholds.hasKey(logger.scope): if logger.threadSvc.thresholds.hasKey(logger.scope):
@@ -555,10 +554,27 @@ func getEffectiveThreshold(logger: Logger): Level {.gcsafe.} =
# If we have matches, return the threshold configured for the most specific. # If we have matches, return the threshold configured for the most specific.
if namespaces.len > 0: if namespaces.len > 0:
result = logger.threadSvc.thresholds[namespaces[0]] return logger.threadSvc.thresholds[namespaces[0]]
return logger.threadSvc.shared.rootLevel.load
proc isEnabled*(l: Logger, lvl: Level): bool {.inline,gcsafe.} = proc isEnabled*(l: Logger, lvl: Level): bool {.inline,gcsafe.} =
## Check the logger's effective threshold without refreshing configuration.
## Use this to guard expensive log preparation performed outside a logging
## call. Namespace thresholds come from the thread-local cache; the root
## fallback level is read from the shared service.
##
## Repeated checks do not refresh that cache. Namespace changes can remain
## unseen until a logging call, getLogger, or an explicit ensureFreshness on
## the local service refreshes it. Applications that only log behind these
## guards should refresh their local service at suitable execution boundaries
## if they need to observe configuration changes.
##
## A true result does not guarantee delivery: the subsequent logging call
## refreshes configuration and appenders apply their own filters. Expressions
## passed directly to the level/message logging templates are already
## evaluated only when the logger's threshold permits the message.
lvl >= l.getEffectiveThreshold lvl >= l.getEffectiveThreshold
+7 -3
View File
@@ -84,7 +84,11 @@ the unsafe forms are tested as compilation failures.
The in-process regression tests are deterministic. The lifetime suite checks The in-process regression tests are deterministic. The lifetime suite checks
copying, self-assignment, replacement, moving, container copies, and exporting a copying, self-assignment, replacement, moving, container copies, and exporting a
handle from a local service. Refresh tests verify that repeated clone failures handle from a local service. `isEnabled` tests verify that namespace thresholds
remain cached until an explicit refresh, a logging call, or `getLogger`, while
the root fallback is read from shared state. This behavior is intentional so
guards can avoid configuration refresh work.
Refresh tests verify that repeated clone failures
preserve the old appenders and thresholds, release partial replacements, and preserve the old appenders and thresholds, release partial replacements, and
allow a later retry without another configuration change. A successful refresh allow a later retry without another configuration change. A successful refresh
releases the replaced cache and installs both appenders and thresholds. releases the replaced cache and installs both appenders and thresholds.
@@ -113,8 +117,8 @@ The suite provides targeted regressions; it does not exhaust thread scheduling
or ORC cycle-collector interleavings. or ORC cycle-collector interleavings.
The ownership scenarios, lifetime suite, and appender-state cleanup regression The ownership scenarios, lifetime suite, and appender-state cleanup regression
exercise the new ownership contracts. `isEnabled` freshness, exercise the new ownership contracts. Local-time timestamp construction and
local-time timestamp construction, and allocator reports remain for allocator reports remain for
subsequent work. subsequent work.
The standalone `ownership.nim` regression suite passed AddressSanitizer and The standalone `ownership.nim` regression suite passed AddressSanitizer and
+28 -1
View File
@@ -112,7 +112,7 @@ suite "ORC service handle lifetime":
check destroyed == 1 check destroyed == 1
suite "ORC ownership and configuration regressions": suite "ORC ownership and configuration regressions":
test "isEnabled observes a namespace change before any log call": test "isEnabled uses cached namespace thresholds until a refresh":
var service = initLogService() var service = initLogService()
service.setThreshold("worker", lvlError) service.setThreshold("worker", lvlError)
let local = service.threadLocalRef() let local = service.threadLocalRef()
@@ -122,8 +122,35 @@ suite "ORC ownership and configuration regressions":
# Simulate a change from another service handle without introducing a # Simulate a change from another service handle without introducing a
# reference-count race into this deterministic configuration test. # reference-count race into this deterministic configuration test.
service.setThreshold("worker", lvlInfo) service.setThreshold("worker", lvlInfo)
check not logger.isEnabled(lvlInfo)
local.ensureFreshness()
check logger.isEnabled(lvlInfo) check logger.isEnabled(lvlInfo)
# A stale true result can become false when the logging call refreshes.
service.setThreshold("worker", lvlError)
check logger.isEnabled(lvlInfo)
logger.info("filtered after refreshing configuration")
check not logger.isEnabled(lvlInfo)
# Creating another logger also refreshes their shared local service.
service.setThreshold("worker", lvlInfo)
check not logger.isEnabled(lvlInfo)
discard local.getLogger("another")
check logger.isEnabled(lvlInfo)
test "isEnabled reads the shared root fallback without refreshing namespaces":
var service = initLogService(rootLevel = lvlError)
let local = service.threadLocalRef()
let logger = local.getLogger("worker")
check not logger.isEnabled(lvlInfo)
service.setRootThreshold(lvlInfo)
service.setThreshold("worker", lvlError)
check logger.isEnabled(lvlInfo)
local.ensureFreshness()
check not logger.isEnabled(lvlInfo)
test "a failed refresh is retried without another configuration change": test "a failed refresh is retried without another configuration change":
var control: CloneControl var control: CloneControl
var service = initLogService() var service = initLogService()