Clarify shared logging state and owning handle types

This commit is contained in:
jdb committed 2026-09-22 07:25:38 -05:00
1 parent 761bd66793
commit 851caef706
2 files changed
+94 -95

No files matched your search

+6 -6
View File
@@ -266,7 +266,7 @@ usability in multithreaded environments. The design centers on two key types:
#### LogService (Value Type) #### LogService (Value Type)
```nim ```nim
type LogService* = object type LogService* = object
global: GlobalLogService shared: ptr SharedLogServiceState
``` ```
The *LogService* object is intended to support uses cases such as: The *LogService* object is intended to support uses cases such as:
@@ -288,7 +288,7 @@ The *LogService* object is intended to support uses cases such as:
#### ThreadLocalLogService (Reference Type) #### ThreadLocalLogService (Reference Type)
```nim ```nim
type ThreadLocalLogService* = ref object type ThreadLocalLogService* = ref object
shared: LogService serviceHandle: LogService
configVersion: int configVersion: int
appenders: seq[LogAppender] appenders: seq[LogAppender]
thresholds: TableRef[string, Level] thresholds: TableRef[string, Level]
@@ -321,7 +321,7 @@ during regular operation and support both the configuration functions of
> *ThreadLocalLogService* objects should **never** be shared outside the > *ThreadLocalLogService* objects should **never** be shared outside the
> context of the thread in which they were initialized. > context of the thread in which they were initialized.
#### GlobalLogService (Internal) #### SharedLogServiceState (Internal)
Under the hood *LogService* holds a pointer to a manually allocated shared Under the hood *LogService* holds a pointer to a manually allocated shared
control block that serves as the single source of truth for logging control block that serves as the single source of truth for logging
@@ -376,12 +376,12 @@ changes:
```nim ```nim
proc ensureFreshness*(ls: ThreadLocalLogService) = proc ensureFreshness*(ls: ThreadLocalLogService) =
# Cheap atomic check first # Cheap atomic check first
if ls.configVersion == ls.global.configVersion.load(): if ls.configVersion == ls.shared.configVersion.load():
return # No changes, return immediately return # No changes, return immediately
# Only acquire lock and copy if versions differ # Only acquire lock and copy if versions differ
withLock ls.global.lock: withLock ls.shared.lock:
ls.configVersion = ls.global.configVersion.load ls.configVersion = ls.shared.configVersion.load
# Sync state... # Sync state...
``` ```
+88 -89
View File
@@ -7,7 +7,7 @@ from std/logging import Level
export Level export Level
type type
GlobalLogServiceObj = object SharedLogServiceState = object
owners: Atomic[int] # Lifetime of independently owned service handles owners: Atomic[int] # Lifetime of independently owned service handles
configVersion: Atomic[int] # To signal the need for threads to update configVersion: Atomic[int] # To signal the need for threads to update
lock: Lock # To protect reads/writes across threads lock: Lock # To protect reads/writes across threads
@@ -19,8 +19,6 @@ type
errorHandler: ErrorHandlerFunc errorHandler: ErrorHandlerFunc
errorHandlerLock: Lock errorHandlerLock: Lock
GlobalLogService = ptr GlobalLogServiceObj
ErrorHandlerFunc* = ErrorHandlerFunc* =
proc (err: ref Exception, msg: string) {.gcsafe,nimcall.} proc (err: ref Exception, msg: string) {.gcsafe,nimcall.}
@@ -30,13 +28,13 @@ type
## Copyable shared configuration handle with an atomic lifetime. ## Copyable shared configuration handle with an atomic lifetime.
## Each thread must own its own handle; do not concurrently assign to or ## Each thread must own its own handle; do not concurrently assign to or
## destroy the same handle variable while another thread reads it. ## destroy the same handle variable while another thread reads it.
global: GlobalLogService shared: ptr SharedLogServiceState
ThreadLocalLogService* = ref object ThreadLocalLogService* = ref object
## Cache confined to the thread that creates it. Appender clones must ## Cache confined to the thread that creates it. Appender clones must
## themselves respect thread ownership of custom state and formatters. ## themselves respect thread ownership of custom state and formatters.
shared: LogService serviceHandle: LogService
configVersion: int configVersion: int
appenders: seq[LogAppender] appenders: seq[LogAppender]
thresholds: TableRef[string, Level] thresholds: TableRef[string, Level]
@@ -133,52 +131,52 @@ type
const UninitializedConfigVersion = low(int) const UninitializedConfigVersion = low(int)
proc retainGlobal(gls: GlobalLogService) {.inline.} = proc retainSharedState(state: ptr SharedLogServiceState) {.inline.} =
if gls != nil: if state != nil:
discard gls.owners.fetchAdd(1, moRelaxed) discard state.owners.fetchAdd(1, moRelaxed)
proc releaseGlobal(gls: GlobalLogService) = proc releaseSharedState(state: ptr SharedLogServiceState) =
if gls != nil and gls.owners.fetchSub(1, moAcquireRelease) == 1: if state != nil and state.owners.fetchSub(1, moAcquireRelease) == 1:
# The last owner has exclusive access to the control block. Its managed # The last owner has exclusive access to the control block. Its managed
# fields must be destroyed explicitly because it is manually allocated. # fields must be destroyed explicitly because it is manually allocated.
`=destroy`(gls.appenders) `=destroy`(state.appenders)
`=destroy`(gls.thresholds) `=destroy`(state.thresholds)
deinitLock(gls.errorHandlerLock) deinitLock(state.errorHandlerLock)
deinitLock(gls.lock) deinitLock(state.lock)
deallocShared(gls) deallocShared(state)
proc `=destroy`*(ls: LogService) = proc `=destroy`*(ls: LogService) =
releaseGlobal(ls.global) releaseSharedState(ls.shared)
proc `=copy`*(dest: var LogService, source: LogService) = proc `=copy`*(dest: var LogService, source: LogService) =
if dest.global == source.global: return if dest.shared == source.shared: return
retainGlobal(source.global) retainSharedState(source.shared)
releaseGlobal(dest.global) releaseSharedState(dest.shared)
dest.global = source.global dest.shared = source.shared
proc `=dup`*(source: LogService): LogService = proc `=dup`*(source: LogService): LogService =
retainGlobal(source.global) retainSharedState(source.shared)
result.global = source.global result.shared = source.shared
proc `=wasMoved`*(ls: var LogService) = proc `=wasMoved`*(ls: var LogService) =
ls.global = nil ls.shared = nil
# The compiler-generated sink operation transfers the pointer after releasing # The compiler-generated sink operation transfers the pointer after releasing
# the destination. It does not retain the transferred owner. # the destination. It does not retain the transferred owner.
template global(ls: ThreadLocalLogService): untyped = template shared(ls: ThreadLocalLogService): untyped =
ls.shared.global ls.serviceHandle.shared
proc service*(ls: ThreadLocalLogService): LogService = proc service*(ls: ThreadLocalLogService): LogService =
## Obtain an independently owned shared handle, e.g. to pass to a worker. ## Obtain an independently owned shared handle, e.g. to pass to a worker.
## This replaces copying ls[] when ThreadLocalLogService was ref LogService. ## This replaces copying ls[] when ThreadLocalLogService was ref LogService.
ls.shared ls.serviceHandle
var consoleLogging {.global.}: LogWriterThreadState[ConsoleMessage] var consoleLogging {.global.}: LogWriterThreadState[ConsoleMessage]
@@ -274,7 +272,7 @@ proc shutdownThreadedFileLogging() =
proc ensureFreshness*(ls: var LogService) = proc ensureFreshness*(ls: var LogService) =
## A shared handle has no cached configuration. Retained for callers that ## A shared handle has no cached configuration. Retained for callers that
## previously refreshed LogService values; only local services need refresh. ## previously refreshed LogService values; only local services need refresh.
if ls.global == nil: if ls.shared == nil:
raise newException(ValueError, raise newException(ValueError,
"LogService not properly initialized. Use initLogService") "LogService not properly initialized. Use initLogService")
@@ -284,7 +282,7 @@ proc ensureFreshness*(ls: ThreadLocalLogService) =
## against the global log service definition. This uses an atomic version ## against the global log service definition. This uses an atomic version
## number to allow the check to be inexpensive, only incurring the more ## number to allow the check to be inexpensive, only incurring the more
## expensive copy operations if there are actual changes. ## expensive copy operations if there are actual changes.
if ls.isNil or ls.global.isNil: if ls.isNil or ls.shared.isNil:
raise newException(ValueError, raise newException(ValueError,
"LogService not properly initialized. Use initLogService to obtain a" & "LogService not properly initialized. Use initLogService to obtain a" &
"properly initialized LogService object") "properly initialized LogService object")
@@ -293,23 +291,24 @@ proc ensureFreshness*(ls: ThreadLocalLogService) =
# particular, do not iterate a seq through a function-returned expression: # particular, do not iterate a seq through a function-returned expression:
# that can create an owning temporary and register its shared appender refs # that can create an owning temporary and register its shared appender refs
# with this thread's ORC cycle collector even while the lock is held. # with this thread's ORC cycle collector even while the lock is held.
let gls = ls.shared.global let state = ls.serviceHandle.shared
if ls.configVersion == gls.configVersion.load(): return if ls.configVersion == state.configVersion.load(): return
withLock gls.lock: withLock state.lock:
ls.configVersion = gls.configVersion.load ls.configVersion = state.configVersion.load
ls.appenders = @[] ls.appenders = @[]
for a in gls.appenders: ls.appenders.add(clone(a)) for a in state.appenders: ls.appenders.add(clone(a))
ls.thresholds = newTable[string, Level]() ls.thresholds = newTable[string, Level]()
for ns, t in pairs(gls.thresholds): ls.thresholds[ns] = t for ns, t in pairs(state.thresholds): ls.thresholds[ns] = t
proc initGlobalLogService( proc initSharedLogServiceState(
rootLevel = lvlAll, rootLevel = lvlAll,
errorHandler = defaultErrorHandlerFunc): GlobalLogService = errorHandler = defaultErrorHandlerFunc): ptr SharedLogServiceState =
result = cast[GlobalLogService](allocShared0(sizeof(GlobalLogServiceObj))) result = cast[ptr SharedLogServiceState](
allocShared0(sizeof(SharedLogServiceState)))
result.owners.store(1) result.owners.store(1)
result.configVersion.store(0) result.configVersion.store(0)
initLock(result.lock) initLock(result.lock)
@@ -335,26 +334,26 @@ proc initLogService*(
## Multiple calls to *initLogService* will result in completely independent ## Multiple calls to *initLogService* will result in completely independent
## *LogService* objects with their own synchronized configuration and global ## *LogService* objects with their own synchronized configuration and global
## state. ## state.
result.global = initGlobalLogService(rootLevel, errorHandler) result.shared = initSharedLogServiceState(rootLevel, errorHandler)
proc threadLocalRef*(ls: LogService): ThreadLocalLogService = proc threadLocalRef*(ls: LogService): ThreadLocalLogService =
## Build a fresh cache in the calling thread, retaining only the shared ## Build a fresh cache in the calling thread, retaining only the shared
## handle. Never copy another thread's cached appender or threshold refs. ## handle. Never copy another thread's cached appender or threshold refs.
result = ThreadLocalLogService(shared: ls, result = ThreadLocalLogService(serviceHandle: ls,
configVersion: UninitializedConfigVersion) configVersion: UninitializedConfigVersion)
ensureFreshness(result) ensureFreshness(result)
proc reportLoggingError( proc reportLoggingError(
gls: GlobalLogService, state: ptr SharedLogServiceState,
err: ref Exception, err: ref Exception,
msg: string) = msg: string) =
var handler: ErrorHandlerFunc var handler: ErrorHandlerFunc
withLock gls.errorHandlerLock: withLock state.errorHandlerLock:
handler = gls.errorHandler handler = state.errorHandler
if not handler.isNil: if not handler.isNil:
try: handler(err, msg) try: handler(err, msg)
@@ -398,23 +397,23 @@ proc setErrorHandler*(
if errHandler.isNil: return if errHandler.isNil: return
withLock ls.global.errorHandlerLock: withLock ls.shared.errorHandlerLock:
ls.global.errorHandler = errHandler ls.shared.errorHandler = errHandler
proc setErrorHandler*( proc setErrorHandler*(
ls: ThreadLocalLogService, ls: ThreadLocalLogService,
errHandler = defaultErrorHandlerFunc) {.gcsafe.} = errHandler = defaultErrorHandlerFunc) {.gcsafe.} =
setErrorHandler(ls.shared, errHandler) setErrorHandler(ls.serviceHandle, errHandler)
proc setRootThreshold*(ls: var LogService, lvl: Level) {.gcsafe.} = proc setRootThreshold*(ls: var LogService, lvl: Level) {.gcsafe.} =
ls.global.rootLevel.store(lvl) ls.shared.rootLevel.store(lvl)
ls.global.configVersion.atomicInc ls.shared.configVersion.atomicInc
proc setRootThreshold*(ls: ThreadLocalLogService, lvl: Level) {.gcsafe.} = proc setRootThreshold*(ls: ThreadLocalLogService, lvl: Level) {.gcsafe.} =
setRootThreshold(ls.shared, lvl) setRootThreshold(ls.serviceHandle, lvl)
func formatSimpleTextLog*(msg: LogMessage): string {.gcsafe.} = msg.msg func formatSimpleTextLog*(msg: LogMessage): string {.gcsafe.} = msg.msg
@@ -424,24 +423,24 @@ func formatJsonStructuredLog*(msg: LogMessage): string {.gcsafe.} = $(%msg)
proc setThreshold*(ls: var LogService, scope: string, lvl: Level) {.gcsafe.} = proc setThreshold*(ls: var LogService, scope: string, lvl: Level) {.gcsafe.} =
withLock ls.global.lock: withLock ls.shared.lock:
ls.global.thresholds[scope] = lvl ls.shared.thresholds[scope] = lvl
ls.global.configVersion.atomicInc ls.shared.configVersion.atomicInc
proc setThreshold*(ls: ThreadLocalLogService, scope: string, lvl: Level) {.gcsafe.} = proc setThreshold*(ls: ThreadLocalLogService, scope: string, lvl: Level) {.gcsafe.} =
setThreshold(ls.shared, scope, lvl) setThreshold(ls.serviceHandle, scope, lvl)
ensureFreshness(ls) ensureFreshness(ls)
proc setThresholds*(ls: var LogService, thresholds: TableRef[string, Level]) {.gcsafe.} = proc setThresholds*(ls: var LogService, thresholds: TableRef[string, Level]) {.gcsafe.} =
withLock ls.global.lock: withLock ls.shared.lock:
for k,v in thresholds: ls.global.thresholds[k] = v for k,v in thresholds: ls.shared.thresholds[k] = v
ls.global.configVersion.atomicInc ls.shared.configVersion.atomicInc
proc setThresholds*(ls: ThreadLocalLogService, thresholds: TableRef[string, Level]) {.gcsafe.} = proc setThresholds*(ls: ThreadLocalLogService, thresholds: TableRef[string, Level]) {.gcsafe.} =
setThresholds(ls.shared, thresholds) setThresholds(ls.serviceHandle, thresholds)
ensureFreshness(ls) ensureFreshness(ls)
proc getLogger*( proc getLogger*(
@@ -479,8 +478,8 @@ proc getLogger*(
proc appenders*(ls: var LogService): seq[LogAppender] {.gcsafe.} = proc appenders*(ls: var LogService): seq[LogAppender] {.gcsafe.} =
withLock ls.global.lock: withLock ls.shared.lock:
for a in ls.global.appenders: result.add(clone(a)) for a in ls.shared.appenders: result.add(clone(a))
proc appenders*(ls: ThreadLocalLogService): seq[LogAppender] {.gcsafe.} = proc appenders*(ls: ThreadLocalLogService): seq[LogAppender] {.gcsafe.} =
for a in ls.appenders: result.add(clone(a)) for a in ls.appenders: result.add(clone(a))
@@ -488,34 +487,34 @@ proc appenders*(ls: ThreadLocalLogService): seq[LogAppender] {.gcsafe.} =
proc addAppender*(ls: var LogService, appender: LogAppender) {.gcsafe.} = proc addAppender*(ls: var LogService, appender: LogAppender) {.gcsafe.} =
## Add a log appender to the shared configuration. Local services refresh ## Add a log appender to the shared configuration. Local services refresh
## their cached state on their next freshness check. ## their cached state on their next freshness check.
withLock ls.global.lock: withLock ls.shared.lock:
var copiedAppender = clone(appender) var copiedAppender = clone(appender)
ls.global.appenders.add(copiedAppender) ls.shared.appenders.add(copiedAppender)
ls.global.configVersion.atomicInc ls.shared.configVersion.atomicInc
proc addAppender*(ls: ThreadLocalLogService, appender: LogAppender) {.gcsafe.} = proc addAppender*(ls: ThreadLocalLogService, appender: LogAppender) {.gcsafe.} =
addAppender(ls.shared, appender) addAppender(ls.serviceHandle, appender)
ensureFreshness(ls) ensureFreshness(ls)
proc clearAppenders*(ls: var LogService) {.gcsafe.} = proc clearAppenders*(ls: var LogService) {.gcsafe.} =
## Remove all shared appenders. Local services refresh their cached state ## Remove all shared appenders. Local services refresh their cached state
## on their next freshness check. ## on their next freshness check.
withLock ls.global.lock: withLock ls.shared.lock:
ls.global.appenders = @[] ls.shared.appenders = @[]
ls.global.configVersion.atomicInc ls.shared.configVersion.atomicInc
proc clearAppenders*(ls: ThreadLocalLogService) {.gcsafe.} = proc clearAppenders*(ls: ThreadLocalLogService) {.gcsafe.} =
clearAppenders(ls.shared) clearAppenders(ls.serviceHandle)
func getEffectiveThreshold(logger: Logger): Level {.gcsafe.} = 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.global.rootLevel.load 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):
@@ -738,7 +737,7 @@ method appendLogMessage[T](
raise newException(ValueError, "CustomLogAppender.appendLogMessage: doLogMessage is nil") raise newException(ValueError, "CustomLogAppender.appendLogMessage: doLogMessage is nil")
else: cla.doLogMessage(cla.state, msg) else: cla.doLogMessage(cla.state, msg)
except Exception: except Exception:
ls.global.reportLoggingError( ls.shared.reportLoggingError(
getCurrentException(), getCurrentException(),
"unable to append to CustomLogAppender") "unable to append to CustomLogAppender")
@@ -822,7 +821,7 @@ method appendLogMessage(
else: else:
cla.appendLogMessageSingleThreaded(ls, msg) cla.appendLogMessageSingleThreaded(ls, msg)
except Exception: except Exception:
ls.global.reportLoggingError( ls.shared.reportLoggingError(
getCurrentException(), getCurrentException(),
"unable to append to ConsoleLogAppender") "unable to append to ConsoleLogAppender")
@@ -892,7 +891,7 @@ method appendLogMessage(
else: else:
fla.appendLogMessageSingleThreaded(ls, msg) fla.appendLogMessageSingleThreaded(ls, msg)
except Exception: except Exception:
ls.global.reportLoggingError( ls.shared.reportLoggingError(
getCurrentException(), getCurrentException(),
"unable to append to FileLogAppender") "unable to append to FileLogAppender")
@@ -958,22 +957,22 @@ when isMainModule:
import std/[files, tempfiles, unittest] import std/[files, tempfiles, unittest]
import ./namespaced_logging/testutil import ./namespaced_logging/testutil
suite "GlobalLogService Initialization": suite "LogService Initialization":
test "initLogService creates valid service": test "initLogService creates valid service":
let ls = initLogService() let ls = initLogService()
let local = ls.threadLocalRef() let local = ls.threadLocalRef()
check: check:
ls.global != nil ls.shared != nil
ls.global.owners.load() == 2 ls.shared.owners.load() == 2
ls.global.configVersion.load() == 0 ls.shared.configVersion.load() == 0
ls.global.rootLevel.load() == lvlAll ls.shared.rootLevel.load() == lvlAll
local.configVersion >= 0 local.configVersion >= 0
local.configVersion == ls.global.configVersion.load local.configVersion == ls.shared.configVersion.load
test "initLogService with custom root level": test "initLogService with custom root level":
let ls = initLogService(lvlInfo) let ls = initLogService(lvlInfo)
check ls.global.rootLevel.load() == lvlInfo check ls.shared.rootLevel.load() == lvlInfo
test "threadLocalRef builds independent caches for an unchanged service": test "threadLocalRef builds independent caches for an unchanged service":
var service = initLogService() var service = initLogService()
@@ -982,7 +981,7 @@ when isMainModule:
let first = service.threadLocalRef() let first = service.threadLocalRef()
let second = service.threadLocalRef() let second = service.threadLocalRef()
check: check:
first.global == second.global first.shared == second.shared
first.configVersion == second.configVersion first.configVersion == second.configVersion
cast[pointer](first.thresholds) != cast[pointer](second.thresholds) cast[pointer](first.thresholds) != cast[pointer](second.thresholds)
first.thresholds["worker"] == second.thresholds["worker"] first.thresholds["worker"] == second.thresholds["worker"]
@@ -996,7 +995,7 @@ when isMainModule:
test "setRootThreshold updates level": test "setRootThreshold updates level":
ls.setRootThreshold(lvlError) ls.setRootThreshold(lvlError)
check ls.global.rootLevel.load() == lvlError check ls.shared.rootLevel.load() == lvlError
test "setThreshold adds namespace threshold": test "setThreshold adds namespace threshold":
ls.setThreshold("api", lvlWarn) ls.setThreshold("api", lvlWarn)
@@ -1004,9 +1003,9 @@ when isMainModule:
test "ensureFreshness syncs configuration": test "ensureFreshness syncs configuration":
# Modify global state directly # Modify global state directly
withLock ls.global.lock: withLock ls.shared.lock:
ls.global.thresholds["test"] = lvlError ls.shared.thresholds["test"] = lvlError
ls.global.configVersion.atomicInc() ls.shared.configVersion.atomicInc()
# Local state should be out of sync # Local state should be out of sync
check not ls.thresholds.hasKey("test") check not ls.thresholds.hasKey("test")
@@ -1021,18 +1020,18 @@ when isMainModule:
ls.setThreshold("api", lvlWarn) ls.setThreshold("api", lvlWarn)
check: check:
ls.configVersion == ls.global.configVersion.load ls.configVersion == ls.shared.configVersion.load
ls.configVersion != ls2.configVersion ls.configVersion != ls2.configVersion
ls2.configVersion != ls.global.configVersion.load ls2.configVersion != ls.shared.configVersion.load
ls.thresholds["api"] == lvlWarn ls.thresholds["api"] == lvlWarn
ls.global.thresholds["api"] == lvlWarn ls.shared.thresholds["api"] == lvlWarn
not ls2.thresholds.contains("api") not ls2.thresholds.contains("api")
ls2.ensureFreshness() ls2.ensureFreshness()
check: check:
ls.configVersion == ls2.configVersion ls.configVersion == ls2.configVersion
ls2.configVersion == ls.global.configVersion.load ls2.configVersion == ls.shared.configVersion.load
ls2.thresholds["api"] == lvlWarn ls2.thresholds["api"] == lvlWarn
test "ensureFreshness syncs configuration across threads": test "ensureFreshness syncs configuration across threads":
@@ -1083,13 +1082,13 @@ when isMainModule:
joinThreads(tA, tB) joinThreads(tA, tB)
test "configuration version incrementing": test "configuration version incrementing":
let initialVersion = ls.global.configVersion.load() let initialVersion = ls.shared.configVersion.load()
ls.setRootThreshold(lvlWarn) ls.setRootThreshold(lvlWarn)
check ls.global.configVersion.load() > initialVersion check ls.shared.configVersion.load() > initialVersion
ls.setThreshold("test", lvlError) ls.setThreshold("test", lvlError)
check ls.global.configVersion.load() > initialVersion + 1 check ls.shared.configVersion.load() > initialVersion + 1
suite "Logger Creation and Usage": suite "Logger Creation and Usage":