Preserve cached configuration when refresh fails
Build replacement appenders and thresholds before installing them and advancing the local version. Release partial replacements and old caches outside the configuration lock. In order to make the `ensureFreshness` version check as cheap as posible and only incur overhead when we actually need to update the local cache, isolate the refresh logic in a separate scope to prevent initialization and cleanup of temporary variables when the version has not changed Add regression coverage for repeated clone failures, cache preservation, cleanup, and retry without another configuration change. Update documentation to reflect the remaining freshness issue.
This commit is contained in:
1 parent
d5e3b9c301
commit
14e71c72ca
4 files changed
+99
-32
No files matched your search
@@ -314,9 +314,10 @@ loggers or configuring each worker separately.
|
|||||||
|
|
||||||
> [!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. Runtime configuration
|
> formatters must follow the ownership rules above. Standalone `isEnabled()`
|
||||||
> refresh and concurrent local-time timestamp construction still have known
|
> checks do not yet notice changed namespace thresholds, and concurrent
|
||||||
> failures. Nim 2.2.10 also produces sanitizer reports in its default allocator
|
> local-time timestamp construction still has a known failure.
|
||||||
|
> 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.
|
||||||
|
|
||||||
@@ -375,9 +376,10 @@ let handle = localLogSvc.service
|
|||||||
```
|
```
|
||||||
|
|
||||||
Configuration methods remain available on both types. Shared handles contain no
|
Configuration methods remain available on both types. Shared handles contain no
|
||||||
cache; `ensureFreshness(handle)` only validates initialization, and
|
cache; `handle.appenders()` clones the current shared configuration.
|
||||||
`handle.appenders()` clones the current shared configuration. Local services
|
`ensureFreshness` applies only to *ThreadLocalLogService*, which maintains a
|
||||||
retain their per-thread caches and refresh them through `ensureFreshness(local)`.
|
per-thread cache. Calls to `ensureFreshness(handle)` must be removed or changed
|
||||||
|
to refresh the relevant local service when migrating to version 3.0.0.
|
||||||
|
|
||||||
*ThreadLocalLogService* is the object that users are expected to interact with
|
*ThreadLocalLogService* is the object that users are expected to interact with
|
||||||
during regular operation and support both the configuration functions of
|
during regular operation and support both the configuration functions of
|
||||||
@@ -450,8 +452,10 @@ proc ensureFreshness*(ls: ThreadLocalLogService) =
|
|||||||
|
|
||||||
# Only acquire lock and copy if versions differ
|
# Only acquire lock and copy if versions differ
|
||||||
withLock ls.shared.lock:
|
withLock ls.shared.lock:
|
||||||
ls.configVersion = ls.shared.configVersion.load
|
let version = ls.shared.configVersion.load()
|
||||||
# Sync state...
|
# Build the complete replacement cache, then install it after success.
|
||||||
|
# If building raises, preserve the previous cache and version for retry.
|
||||||
|
ls.configVersion = version
|
||||||
```
|
```
|
||||||
|
|
||||||
Goals/Motivation:
|
Goals/Motivation:
|
||||||
|
|||||||
+29
-19
@@ -285,15 +285,6 @@ proc shutdownThreadedConsoleLogging() =
|
|||||||
proc shutdownThreadedFileLogging() =
|
proc shutdownThreadedFileLogging() =
|
||||||
shutdownWriter(fileLogging)
|
shutdownWriter(fileLogging)
|
||||||
|
|
||||||
|
|
||||||
proc ensureFreshness*(ls: var LogService) =
|
|
||||||
## A shared handle has no cached configuration. Retained for callers that
|
|
||||||
## previously refreshed LogService values; only local services need refresh.
|
|
||||||
if ls.shared == nil:
|
|
||||||
raise newException(ValueError,
|
|
||||||
"LogService not properly initialized. Use initLogService")
|
|
||||||
|
|
||||||
|
|
||||||
proc ensureFreshness*(ls: ThreadLocalLogService) =
|
proc ensureFreshness*(ls: ThreadLocalLogService) =
|
||||||
## Check our thread-local version of the logging service configuration
|
## Check our thread-local version of the logging service configuration
|
||||||
## against the global log service definition. This uses an atomic version
|
## against the global log service definition. This uses an atomic version
|
||||||
@@ -304,22 +295,41 @@ proc ensureFreshness*(ls: ThreadLocalLogService) =
|
|||||||
"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")
|
||||||
|
|
||||||
# Keep a stable borrowed pointer while inspecting configuration. In
|
# ORC's cycle collector maintains some thread-local bookkeeping state.
|
||||||
# particular, do not iterate a seq through a function-returned expression:
|
# Because of this, we have to be careful here while making a copy of the
|
||||||
# that can create an owning temporary and register its shared appender refs
|
# shared logging configuration state to avoid behavior that would result in
|
||||||
# with this thread's ORC cycle collector even while the lock is held.
|
# temporary variables with ownership semantics (specifically the shared
|
||||||
|
# appender references in the seq).
|
||||||
|
#
|
||||||
|
# We will keep a stable borrowed pointer while inspecting configuration and
|
||||||
|
# will create temporary variables to own the data being copied that can be
|
||||||
|
# cleaned up in this proc's scope.
|
||||||
let state = ls.serviceHandle.shared
|
let state = ls.serviceHandle.shared
|
||||||
if ls.configVersion == state.configVersion.load(): return
|
if ls.configVersion == state.configVersion.load(): return
|
||||||
|
|
||||||
withLock state.lock:
|
block:
|
||||||
ls.configVersion = state.configVersion.load
|
# This block is a performance optimization. By putting these variables and
|
||||||
|
# operations in their own scope they won't get initialized or cleaned up
|
||||||
|
# unless ls.configVersion != state.configVersion.load().
|
||||||
|
|
||||||
ls.appenders = @[]
|
# We want the following vars to be in the outer scope (not in the lock) so
|
||||||
for a in state.appenders: ls.appenders.add(clone(a))
|
# that their cleanup happens after the lock is released.
|
||||||
|
var newAppenders: seq[LogAppender]
|
||||||
|
var newThresholds: TableRef[string, Level]
|
||||||
|
|
||||||
ls.thresholds = newTable[string, Level]()
|
withLock state.lock:
|
||||||
for ns, t in pairs(state.thresholds): ls.thresholds[ns] = t
|
let version = state.configVersion.load()
|
||||||
|
|
||||||
|
for a in state.appenders: newAppenders.add(clone(a))
|
||||||
|
newThresholds = newTable[string, Level]()
|
||||||
|
for ns, t in pairs(state.thresholds): newThresholds[ns] = t
|
||||||
|
|
||||||
|
# Record the version update only after every copy succeeds; a failure
|
||||||
|
# leaves the old version in place so the next freshness check retries the
|
||||||
|
# refresh.
|
||||||
|
swap(ls.appenders, newAppenders)
|
||||||
|
swap(ls.thresholds, newThresholds)
|
||||||
|
ls.configVersion = version
|
||||||
|
|
||||||
proc initSharedLogServiceState(
|
proc initSharedLogServiceState(
|
||||||
rootLevel = lvlAll,
|
rootLevel = lvlAll,
|
||||||
|
|||||||
+7
-3
@@ -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. Shutdown tests launch
|
handle from a local service. Refresh tests verify that repeated clone failures
|
||||||
|
preserve the old appenders and thresholds, release partial replacements, and
|
||||||
|
allow a later retry without another configuration change. A successful refresh
|
||||||
|
releases the replaced cache and installs both appenders and thresholds.
|
||||||
|
Shutdown tests launch
|
||||||
a fresh process for each scenario and check all 10,000 numbered messages after
|
a fresh process for each scenario and check all 10,000 numbered messages after
|
||||||
normal exit, using isolated temporary paths and a 15-second deadline. Scenarios
|
normal exit, using isolated temporary paths and a 15-second deadline. Scenarios
|
||||||
cover stdout, stderr, files, empty formatted messages, multiple file destinations,
|
cover stdout, stderr, files, empty formatted messages, multiple file destinations,
|
||||||
@@ -109,8 +113,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, failed-refresh
|
exercise the new ownership contracts. `isEnabled` freshness,
|
||||||
recovery, local-time timestamp construction, and allocator reports remain for
|
local-time timestamp construction, and 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
|
||||||
|
|||||||
+51
-2
@@ -14,6 +14,18 @@ method clone(app: FallibleAppender): LogAppender {.gcsafe.} =
|
|||||||
raise newException(ValueError, "injected clone failure")
|
raise newException(ValueError, "injected clone failure")
|
||||||
FallibleAppender(control: app.control)
|
FallibleAppender(control: app.control)
|
||||||
|
|
||||||
|
type
|
||||||
|
RefreshProbeObj = object of LogAppender
|
||||||
|
destroyed: ptr int
|
||||||
|
RefreshProbe = ref RefreshProbeObj
|
||||||
|
|
||||||
|
proc `=destroy`(app: RefreshProbeObj) =
|
||||||
|
if app.destroyed != nil: inc app.destroyed[]
|
||||||
|
`=destroy`(app.namespace)
|
||||||
|
|
||||||
|
method clone(app: RefreshProbe): LogAppender {.gcsafe.} =
|
||||||
|
RefreshProbe(namespace: app.namespace, destroyed: app.destroyed)
|
||||||
|
|
||||||
type
|
type
|
||||||
LifetimeProbeObj = object
|
LifetimeProbeObj = object
|
||||||
destroyed: ptr int
|
destroyed: ptr int
|
||||||
@@ -26,10 +38,11 @@ proc `=destroy`(probe: LifetimeProbeObj) =
|
|||||||
proc ignoreMessage(state: var LifetimeProbe, msg: LogMessage) {.gcsafe, noconv.} =
|
proc ignoreMessage(state: var LifetimeProbe, msg: LogMessage) {.gcsafe, noconv.} =
|
||||||
discard
|
discard
|
||||||
|
|
||||||
proc watchedService(destroyed: ptr int): LogService =
|
proc watchedService(destroyed: ptr int, namespace = ""): LogService =
|
||||||
result = initLogService()
|
result = initLogService()
|
||||||
result.addAppender(initCustomLogAppender(
|
result.addAppender(initCustomLogAppender(
|
||||||
initSharedLogState(LifetimeProbeObj(destroyed: destroyed)), ignoreMessage))
|
initSharedLogState(LifetimeProbeObj(destroyed: destroyed)), ignoreMessage,
|
||||||
|
namespace = namespace))
|
||||||
|
|
||||||
proc createAndReleaseService(destroyed: ptr int) =
|
proc createAndReleaseService(destroyed: ptr int) =
|
||||||
var service = watchedService(destroyed)
|
var service = watchedService(destroyed)
|
||||||
@@ -128,6 +141,42 @@ suite "ORC ownership and configuration regressions":
|
|||||||
local.ensureFreshness()
|
local.ensureFreshness()
|
||||||
check local.appenders().len == 1
|
check local.appenders().len == 1
|
||||||
|
|
||||||
|
test "failed refresh preserves the old cache and releases partial replacements":
|
||||||
|
var oldStateDestroyed, discardedClones: int
|
||||||
|
var control: CloneControl
|
||||||
|
var service = watchedService(addr oldStateDestroyed, "old")
|
||||||
|
service.setThreshold("worker", lvlError)
|
||||||
|
let local = service.threadLocalRef()
|
||||||
|
let logger = local.getLogger("worker")
|
||||||
|
|
||||||
|
# Only the local cache retains the old appender's state after this change.
|
||||||
|
service.clearAppenders()
|
||||||
|
service.addAppender(RefreshProbe(namespace: "new", destroyed: addr discardedClones))
|
||||||
|
service.addAppender(FallibleAppender(control: addr control))
|
||||||
|
service.setThreshold("worker", lvlInfo)
|
||||||
|
let initiallyDestroyed = discardedClones
|
||||||
|
|
||||||
|
for attempt in 1 .. 2:
|
||||||
|
control.failNext = true
|
||||||
|
expect ValueError:
|
||||||
|
local.ensureFreshness()
|
||||||
|
check oldStateDestroyed == 0
|
||||||
|
check discardedClones == initiallyDestroyed + attempt
|
||||||
|
block:
|
||||||
|
let cached = local.appenders()
|
||||||
|
require cached.len == 1
|
||||||
|
check cached[0].namespace == "old"
|
||||||
|
check not logger.isEnabled(lvlInfo)
|
||||||
|
|
||||||
|
# No further configuration update is needed to retry and install both
|
||||||
|
# the new appenders and the new namespace thresholds.
|
||||||
|
local.ensureFreshness()
|
||||||
|
check oldStateDestroyed == 1
|
||||||
|
let cached = local.appenders()
|
||||||
|
require cached.len == 2
|
||||||
|
check cached[0].namespace == "new"
|
||||||
|
check logger.isEnabled(lvlInfo)
|
||||||
|
|
||||||
test "releasing the service releases state owned by its appenders":
|
test "releasing the service releases state owned by its appenders":
|
||||||
var destroyed = 0
|
var destroyed = 0
|
||||||
createAndReleaseService(addr destroyed)
|
createAndReleaseService(addr destroyed)
|
||||||
|
|||||||
Reference in new issue
Block a user