Skip to content

Halve the event loop's CPU cost per check - #547

Open
nook24 wants to merge 15 commits into
naemon:masterfrom
nook24:perf-event-loop
Open

nook24 wants to merge 15 commits into
naemon:masterfrom
nook24:perf-event-loop

Conversation

@nook24

@nook24 nook24 commented Sep 28, 2026

Copy link
Copy Markdown
Member

As you know i did some AI shenanigans to reduce the CPU time of Naemons Event Loop. This PR has all the findings in it. We can pick singe commits or merge it all together. How ever, we should do some serious code review :)


Naemon's event loop is a single thread, so what it spends per check is a hard ceiling on how large an installation can grow. This series, driven by profiling rather than inspection, takes 48 % off main-thread CPU per check (50.1 → 26.2 µs at 100 000 services, no broker module) and fixes two bugs found along the way. With a broker module loaded — the common case — the gain is about 18 %, because the module then dominates the loop.

Full write-up with methodology and measurements: https://claude.ai/artifact/1RAwMLoi4539y95SE19zjc

Commits

# Commit Effect
1 Reject unix socket paths that do not fit in sun_path crash fix: a query_socket path of 108+ characters overran the stack at startup
2 Do not sift past the end when removing the last event heap node pre-existing use-after-free in the scheduler, reproduced on master
3 Only flush the descriptor iobroker_write_packet() wrote to no longer walks every worker socket per dispatched check
4 Keep event heap sort keys in the heap array −40 % of the heap's cost
5 Cache the current local day for timeperiod lookups 3.4 % → 0.4 % of the loop
6 Skip the NEB result set when nothing is subscribed two allocations per call saved without a broker module
7 Write status.dat without a fprintf() call per field writer 2.04 s → 0.83 s per dump (−59 %)
8 Write retention.dat through nm_writebuf as well ~1.0 s → ~0.4 s per dump
9 Carry the checked object in check_result no lookup by name per result; NEB API 8 → 9
10 Give schedule-only status events their own NEBTYPE closes #162
11 Add the event loop benchmark tooling contrib/perfbench/

Commits 1 and 2 stand alone and can be taken without the rest. Every commit builds on its own. The reasoning for each change is in its commit message.

Breaking change: NEB modules need a recompile

check_result gained an object_ptr member, so CURRENT_NEB_API_VERSION goes from 8 to 9. A module built against v8 is refused at load time instead of misreading the struct. A recompile is all that is needed — no module source change is required. Newly available: modules may set cr->object_ptr to the host/service a result belongs to (or leave it NULL for the old lookup), and can recognise NEBTYPE_HOSTSTATUS_SCHEDULE / NEBTYPE_SERVICESTATUS_SCHEDULE.

Behaviour

  • status.dat, retention.dat and objects.cache are byte-identical to master's output, verified against a stock build driven to the same state with a fixed set of passive results.
  • The status event sent when a check is only rescheduled is now NEBTYPE_*STATUS_SCHEDULE instead of NEBTYPE_*STATUS_UPDATE. Modules that do not look at the type see no difference. The contract, including when a schedule event carries a fresh result, is documented at the type definitions in broker.h.

Testing

  • The CI matrix from citest.yml (-O2, -g -O0, AddressSanitizer): make check, make distcheck, flake8, behave — all green, no ASan or LeakSanitizer reports.
  • New tests, each confirmed to fail without the change it covers: removing heap entries with equal event times (commit 2), extreme event times in one heap (the saturating key in commit 4), nm_writebuf output against snprintf() up to DBL_MAX (commit 7).
  • A stress test for the timeperiod day cache compares it against the uncached computation over 1990–2036 in 16 time zones, including DST transitions at midnight and 30-minute shifts. Cases whose zone file is not installed are skipped rather than silently run as UTC — Ubuntu 26.04 ships no right/ zones.

Not included

  • In retention.dat, a host's retry_check_interval is written from check_interval. Fixing it changes what an existing file means, so it is marked FIXME and left for its own patch.
  • objects.cache stays on fprintf(): the fcache_* functions are declared in installed headers with a FILE * signature, and the file is written only once at startup.

nook24 and others added 6 commits September 29, 2026 11:07
nm_bufferqueue_unshift_to_delim() takes a size_t *, but the test passed
an unsigned long * and printed size_t values with %ld. Both only happen
to match on 64 bit Linux; on i386 the test fails to compile with -Werror.

Signed-off-by: nook24 <info@nook24.eu>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WY8mbGLNkt5eQfc57cTnZ5
runnable_delays held values that do not fit a 32 bit time_t, which
fails the -Werror build; those are now skipped. event_timespec_msdiff
expected 2^31 seconds to come back exactly, but in milliseconds that
saturates a 32 bit long, as timespec_msdiff() is meant to.

Signed-off-by: nook24 <info@nook24.eu>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WY8mbGLNkt5eQfc57cTnZ5
nsock_unix() copied strlen(path) bytes into struct sockaddr_un.sun_path
without checking the length. sun_path is a fixed 108 byte array on Linux,
so any socket path of 108 characters or more overran the caller's stack
frame. With a query_socket configured below a sufficiently deep directory
naemon died during startup with "*** buffer overflow detected ***" inside
qh_init().

Return NSOCK_EINVAL instead. Every caller already handles a negative
return value, so qh_init() now reports a normal configuration error and
bails out cleanly rather than smashing the stack.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WY8mbGLNkt5eQfc57cTnZ5
Signed-off-by: nook24 <info@nook24.eu>
evheap_remove() moves the last node into the removed node's slot and then
sifts it into place. When the removed node was itself the last one there is
no hole to fill, but the guard was "ev->pos <= q->count" -- after the
decrement ev->pos equals the count, so this sifted from one past the end.

That compared the removed entry with its parent, and evheap_cond_swap()
swaps on equal keys. So removing the last node while its parent had the
same event time put the removed event back in as the head and moved a live
event out of the heap. The caller then frees the removed event
(execute_and_destroy_event() and destroy_event() both do), leaving the head
of the scheduling queue pointing at freed memory, and the event that fell
out never runs.

Equal event times are not exotic: events are scheduled at clock_gettime()
plus whole-second delays, so any two scheduled within the same clock tick
with the same delay tie -- routinely so on a coarse clocksource at startup,
when every check is scheduled in one pass.

The comment above the condition already said "if it wasn't the last node";
make the condition say it too. The existing tests never had two events with
equal times, so they could not reach this. Add one that removes the last of
two equal events, and one that drains 1000 events sharing three distinct
times -- alternately taking the last slot and a random event -- and checks
after every removal that each remaining event still holds its own slot.
Both fail without the fix; the second reports the live event that was
pushed out to the slot one past the end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WY8mbGLNkt5eQfc57cTnZ5
Signed-off-by: nook24 <info@nook24.eu>
iobroker_write_packet() queued its data for one descriptor and then
called iobroker_push(), which walks the entire descriptor set and flushes
whatever it finds. The source already asked "horrible idea?" about this.
Since a check is dispatched through this path, every dispatched check
walked every registered worker socket.

Flush only the descriptor that was just written to. A backlog on any
other descriptor is still picked up by the iobroker_push() call the event
loop performs on each iteration, which is what that call is there for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WY8mbGLNkt5eQfc57cTnZ5
Signed-off-by: nook24 <info@nook24.eu>
The scheduling queue was an array of timed_event pointers, so every
comparison while sifting an event up or down dereferenced two events to
read their event_time. With 100k scheduled events that is 17 levels of
essentially random memory access per heap operation, and there are two
heap operations per executed check.

Store the event time inline next to the pointer as a nanosecond stamp so
comparisons stay within the heap array, and compare it as a single
integer instead of a two field timespec. That takes about 40% off the
heap's share of the event loop.

The old comparison looked at tv_sec and tv_nsec separately and could not
overflow. The combined key can: tv_sec is the monotonic clock plus a delay,
and for acknowledgement and downtime expiry that delay comes from a
user-supplied end time. An end time past ~2318 makes tv_sec * 1e9 exceed
INT64_MAX; the product wraps, can come out negative, and would put that
event at the head of the heap -- where event_poll_full() sees a far-future
event_time, sleeps, and never reaches the checks queued behind it. The key
therefore saturates. Times further out than ~292 years still sort after
every realistic event and only tie among themselves.

test-event-heap.c reaches into the queue directly, so it is updated to
match the new entry layout. verify_queue_heap() now also checks that every
inline key matches the event_time it sorts. The existing polling tests
already schedule delays like -(1 << 62) and LONG_MIN / 10, but each puts a
single event in the queue, where the key decides nothing; under UBSan they
trip six signed overflows in the unsaturated key without failing.
event_heap_extreme_times_in_one_heap puts such times into one heap next to
ordinary ones and checks the exact order; it fails without the saturation.

With a 32 bit time_t, as on Debian 11 and 12 for i386, the key cannot
overflow and both saturation checks are always false. GCC's -Wtype-limits
flags that and the -Werror build fails -- also through a plain cast, since
the warning looks past a widening conversion -- so tv_sec is copied into an
int64_t first. The new test skips the values that do not fit the platform's
time_t and checks the 32 bit limits in their place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WY8mbGLNkt5eQfc57cTnZ5
Signed-off-by: nook24 <info@nook24.eu>
Comment thread src/naemon/broker.h Outdated
#define NEBTYPE_SERVICESTATUS_UPDATE 1202
#define NEBTYPE_CONTACTSTATUS_UPDATE 1203

/*

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i like the idea of having some docs about certain callbacks. Might be better to have them in the docs in the developer section? What you think?

Comment thread src/naemon/nm_writebuf.c Outdated
return error;
}

#define NM_WB_DBL_CACHE_SIZE 512

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure if that's worth the effort. Either doubles are pretty unique, so there will be almost no cache hits. Or it is a round number, then it would be way faster to print is as integer.

nook24 and others added 8 commits October 2, 2026 19:39
check_time_against_period() is called for every check that gets
dispatched. It computed midnight via localtime_r()/mktime(), and then
_get_matching_timerange() computed the very same midnight again, so each
call cost two of those round trips. Since every caller asks about "now",
the same day was being recomputed thousands of times per second, which
came to 3.4% of the event loop's CPU. Cost drops to 0.4%.

Two properties keep the cached answer identical to what the original code
returned:

  - Days containing a DST transition are never cached. There the result
    depends on the tm_isdst localtime_r() reported for the timestamp being
    tested, so one cached answer for the whole day would change behaviour.
    The day length is probed with tm_isdst = -1 on both ends, because the
    midnight computed for a given timestamp is itself shifted on such a
    day and measuring from it would make the day look 86400 seconds long.

  - read_main_config_file() resets the cache after applying use_timezone.
    Behind that, the cache records the timezone it was computed in
    (timezone, daylight and both tzname entries, which tzset() maintains)
    and discards itself when that changes. That alone is not enough:
    zones with different DST rules can share all four, Europe/Amsterdam
    and Africa/Tunis for one.

That exclusion of irregular days is the whole safety argument, so the
comment block above the cache writes down why a local day is not simply
86400 seconds, why the probe needs tm_isdst = -1 on both ends, and why a
system clock jump needs no special handling.

tests/test-timeperiod-daycache.c reimplements the uncached computation and
compares the cache against it over 1990-2036 in sixteen timezones, visited
sequentially, randomly, and backwards (what a system clock stepped back
looks like), plus a dense sweep across every DST transition and all of
February and March. It also asserts which days must and must not be cached,
so a future change cannot quietly start caching a transition day, and that
a tzset() invalidates the cache, and that a use_timezone switch from
Amsterdam to Tunis does too. Confirmed to fail when the tm_isdst probe
bug is reintroduced, or the reset in read_main_config_file() removed.

The cache is private to objects_timeperiod.c, and the test cannot include
that file: it would define timeperiod_list and timeperiod_ary a second time
next to libnaemon's, which AddressSanitizer reports as an ODR violation --
the reason t-tap/test_timeperiods.c stopped including it. Two small hooks,
_get_day_cache_entry() and _reset_day_cache(), are exported for testing
alongside the ones that change added, and the test uses those instead.

glibc treats an unknown TZ as UTC without complaint, so a zone missing from
the system turns a case into a UTC test. That is harmless where cache and
reference are compared -- both then compute UTC -- but the leap second day
is cacheable in UTC and its classification fails. Ubuntu 26.04 does not
install the right/ zones or legacy aliases such as Iran by default, so cases
whose zone file is missing are skipped rather than run against UTC.

The cache is shared, unlocked state, so check_time_against_period() is only
safe from the main loop. Livestatus already keeps to that: it calls it from
a timed event, not from its query threads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WY8mbGLNkt5eQfc57cTnZ5
Signed-off-by: nook24 <info@nook24.eu>
neb_make_callbacks() allocated a neb_cb_resultset and the GLib pointer
array inside it, iterated over an empty callback list, and tore both down
again. On an installation without event broker modules that is two
allocations and two frees per call, and broker_service_check() is called
twice per executed check - plus it strdup()s and tokenizes the command
line to fill in event data nobody reads.

Return early when neb_callback_list has no callback registered for the
requested type. The return code is unchanged: an empty result set yielded
0 before as well.

broker_service_check() gets the same early out via the new
neb_callbacks_registered() so it can skip assembling the event data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WY8mbGLNkt5eQfc57cTnZ5
Signed-off-by: nook24 <info@nook24.eu>
xsddefault_save_status_data() emitted roughly 57 separate fprintf() calls
per host and per service. On an installation with 100k services and the
default status_update_interval of 10 seconds that is about 570k format
string parses per second, and profiling the event loop under that load
showed 51% of its CPU time inside this function, 29% of the total in
__vfprintf_internal alone. On top of that fdopen() hands the stream a 4k
buffer, so a ~50MB dump also cost about 12000 write() syscalls.

Add nm_writebuf, a 1MB append buffer with typed per-field helpers, and
emit status.dat through it. Integers and strings, which are the
overwhelming majority of the fields, are formatted directly; the
appenders are static inline so the compiler pastes them into the writer
loops rather than emitting a call per field.

The handful of doubles per object still go through snprintf(), because
reimplementing printf's rounding of a binary double would risk silently
changing the file.

tests/test-nm-writebuf.c pins the invariant the design rests on: what
the appenders write must be byte for byte what printf() would have
produced, including %f of values up to DBL_MAX.

The output is byte identical to what fprintf() produced, verified over a
full status.dat including custom variables.

Event loop CPU at 100k services and ~1760 checks/s drops from 5.28s to
3.9s per 60s window; the writer itself goes from 2.04s to 0.83s. Those
numbers include a cache for formatted doubles that was dropped again:
with realistically spread check intervals it saved only 2.5% of the
writer's time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WY8mbGLNkt5eQfc57cTnZ5
Signed-off-by: nook24 <info@nook24.eu>
xrddefault_save_state_information() had the same shape as the status.dat
writer before it: 217 fprintf() calls, one per field, against a stdio
stream with the default 4k buffer. It runs far less often than the status
file - once per retention_update_interval and on shutdown - but at 100k
services the file is around 117MB, and the event loop is blocked for the
whole dump, so it is a scheduling stall rather than a background cost.

Emit it through nm_writebuf like status.dat. Besides being faster this
keeps the two state file writers on the same technique, which is worth
something on its own given how similar they are.

The state_history list and the custom variable lines are written out
directly instead of through the printf fallback, since they occur once
per object.

Output is byte identical, verified over a full retention.dat including
custom variables, comments, downtimes and state history, and the result
still reads back cleanly.

One dump at 100k services drops from about 1.0s of CPU to about 0.4s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WY8mbGLNkt5eQfc57cTnZ5
Signed-off-by: nook24 <info@nook24.eu>
fcache_objects() wrote objects.cache with one fprintf() per field, like
status.dat and retention.dat did. It is only written at startup and on
reload, but on a large installation that is the same number of objects
and the same per-field format parsing.

The writers now append to an nm_writebuf: nm_fcache_*() in the new,
uninstalled objects_fcache.h. The public fcache_*(FILE *) functions keep
their signatures and wrap them, so modules calling them still work.
timerange2str(), which returned a static buffer filled by sprintf(), is
replaced by a writer of its own.

The output is byte identical, including "(null)" for a NULL string,
which fprintf() printed and naemon -p reads back.

With 100k services, naemon -vp (parse plus writing the 80MB precache)
goes from about 530ms to 425ms.

Signed-off-by: nook24 <info@nook24.eu>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WY8mbGLNkt5eQfc57cTnZ5
process_check_result() resolved the object a result belongs to with
find_service(host_name, service_description) or find_host(host_name) -- a
hash lookup plus a string compare -- although the code that dispatched the
check had the pointer in hand the whole time. That is 5.4% of the event
loop, and handle_worker_host_check() paid for it twice, because it looked
the host up itself as well.

Add an object_ptr member to check_result and fill it in when the check is
dispatched. process_check_result() uses it when set and falls back to the
lookup when it is NULL, so every existing caller keeps working.

check_result is public and modules populate it field by field, so a new
member arrives holding whatever was on the caller's stack in results a
module submits -- not NULL, which means a NULL check is no defence on its
own. What makes the field safe is nebmods.c:197: naemon refuses to load
any module whose __neb_api_version is not exactly CURRENT_NEB_API_VERSION.
Bumping that from 8 to 9 means a module compiled against the old struct
cannot load at all, so it can never submit an uninitialised object_ptr.
Verified by building a module against v8 and confirming naemon rejects it
with "is using an incompatible version (v8) of the event broker API
(current version: v9). Module will be unloaded."

init_check_result() now memsets the struct rather than assigning 18 fields
individually. It had been quietly missing output_file, timeout and rusage,
which stayed as stack garbage even after the struct had been "initialised";
that is a pre-existing bug and the new field makes it load-bearing.
Removing the memset again makes three result-processing tests crash
outright, which is the failure mode this guards against.

The three check_results in tests/test-scheduled-downtimes are now
initialised properly. They were leaving those same fields uninitialised.

Measured without a broker over three interleaved pairs on identical check
counts: 26.63 to 26.07 microseconds of loop CPU per check, -2.1%, negative
in all three pairs. Small, and smaller than the run-to-run spread, but the
sign is consistent and the mechanism is identifiable.

The larger point is what the field enables: a broker module handing back a
result for an object it already tracks -- mod_gearman is the obvious case
-- can now skip the lookup too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WY8mbGLNkt5eQfc57cTnZ5
Signed-off-by: nook24 <info@nook24.eu>
Host and service status events fire twice for every check: once from
schedule_next_*_check() when the next run is queued, and once when the
result of the current one is processed. Until now both carried
NEBTYPE_*STATUS_UPDATE, so a module had no way to tell the duplicate apart
from a genuine update -- the only defence was to checksum every event and
drop the repeats.

Add NEBTYPE_HOSTSTATUS_SCHEDULE and NEBTYPE_SERVICESTATUS_SCHEDULE, and
send those from schedule_next_host_check() and
schedule_next_service_check(). update_host_status() and
update_service_status() are untouched, as are all their other callers in
commands.c, downtime.c, flapping.c and notifications.c: everything that
represents a real change to the object still arrives as
NEBTYPE_*STATUS_UPDATE, exactly as before. A module that does not look at
the type sees both events and behaves as it always did.

The split has to go on the schedule event rather than the result event.
Tagging the result event instead would leave the schedule event as 1202,
where it is indistinguishable from a downtime, an acknowledgement or an
external command, and a module would still have to receive and dedupe it.

A schedule event has usually changed only next_check, check_options and
last_update, but that is not guaranteed. schedule_next_*_check() also
runs from inside handle_async_*_check_result(): the reschedule at
dispatch is skipped while a check is still executing, and the "make sure
there is a next check event is queued" branch then does the rescheduling
-- at a point where the object already holds the new state. The share is
therefore a property of the installation: it measures how often a check
is still outstanding when its next one falls due.

None of that makes the event unsafe to drop. A full update always follows
the result, and every event carries a complete snapshot rather than a
delta.

The NEB documentation tells module authors two things: the result event
is the one to store, since it carries both the fresh state and a
next_check equal to or newer than the schedule event's; and a check that
is scheduled but then never run -- host down, check period closed,
dependencies failed, checks disabled, cache horizon, or
max_parallel_*_checks reached -- produces only the schedule event, so
dropping it outright freezes next_check for exactly the objects that are
not being checked.

Closes naemon#162

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WY8mbGLNkt5eQfc57cTnZ5
Signed-off-by: nook24 <info@nook24.eu>
Signed-off-by: nook24 <info@nook24.eu>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WY8mbGLNkt5eQfc57cTnZ5
@nook24

nook24 commented Oct 2, 2026

Copy link
Copy Markdown
Member Author

What changed

  • contrib/perfbench removed. The tooling commit is gone, and no commit message refers to it anymore.

  • The NEBTYPE_*STATUS_SCHEDULE explanation moved out of broker.h moved into the Naemon Docs (PR soon)

  • The double cache (NM_WB_DBL_CACHE_SIZE) is removed. You were right to be suspicious. I re-measured with realistically spread check intervals (1 min to 1 day, mixed retry intervals, 10 % of services changing state), 100k services, two interleaved rounds each:

    status.dat dump (median) cache hit rate
    with cache 180 ms 43 %
    without cache 185 ms –

    2.5 % of the writer's time is not worth a global cache. nm_wb_dbl() is now a plain snprintf() via nm_wb_printf().

  • objects.cache now goes through nm_writebuf as well. The public fcache_*(FILE *) functions keep their signatures and become thin wrappers. The writers proper are nm_fcache_*() in a new, uninstalled objects_fcache.h. The output is byte-identical (including (null) for NULL strings), checked on 7 configs: the 100k one (80 MB), the test configs, and one that covers every object type, every daterange type, escalations, dependencies, coordinates and custom variables. naemon -vp at 100k services: 530 → 425 ms.

  • Day cache: timezone switch. As pointed out in the review: zones with different DST rules can share timezone/daylight/tzname, Europe/Amsterdam and Africa/Tunis for one. A use_timezone switch between them left a midnight that was an hour off for the rest of the cached day. read_main_config_file() now resets the cache after tzset(). A new test switches between exactly those two zones through read_main_config_file(); it fails without the fix and passes with it.

Commits

# Commit Change since the last push
1 d7b581ea Build test-bufferqueue on 32 bit platforms unchanged shared with #548, same SHA
2 ed7e363a Build and pass test-event-heap on 32 bit platforms unchanged shared with #548, same SHA
3 38a97f2d Reject unix socket paths that do not fit in sun_path unchanged
4 c1f041a4 Do not sift past the end when removing the last event heap node unchanged
5 4be0b058 Only flush the descriptor iobroker_write_packet() wrote to unchanged
6 9b42ca5f Keep event heap sort keys in the heap array unchanged
7 5d427cc7 Cache the current local day for timeperiod lookups changed cache reset on use_timezone, new test
8 058ddb19 Skip the NEB result set when nothing is subscribed rebased
9 df7bab34 Write status.dat without a fprintf() call per field changed double cache removed, tests and message trimmed
10 830b7908 Write retention.dat through nm_writebuf as well rebased
11 d351b4ba Write objects.cache through nm_writebuf as well new
12 a479e0d0 Carry the checked object in check_result rebased
13 e77e14f1 Give schedule-only status events their own NEBTYPE changed comment block in broker.h removed, see docs PR
14 2c24f24d List the event loop changes in NEWS new NEWS entry from the old tooling commit, plus objects.cache
– 9e5f84a6 Add the event loop benchmark tooling removed

"Rebased" means the content is unchanged and only the SHA moved because an earlier commit changed.

Verification

  • Every commit builds with -Werror and passes make check on its own.
  • CI matrix as in citest.yml (Ubuntu 26.04): -O2, -g -O0 and AddressSanitizer, each with build, make check, make distcheck, flake8 and behave. 15/15 green, no sanitizer reports.
  • objects.cache is byte-identical to the previous build, see above.
  • On Debian 11/12 i386 the library builds. make check only shows the three failures as I did not pulled Fix the build and make check on 32bit platforms #548.

Den Platzhalter #548 gibt es mehrmals, am einfachsten mit Suchen und Ersetzen.

Comment thread lib/nsock.c
else
return NSOCK_EINVAL;

/*

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is its own fix from commit 38a97f2 , only modifies this file.

Comment thread src/naemon/objects.c

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

changes on this file are related to nm_writebuffer + fcache usage.

after writing to the buffer it calls nm_writebuf_done to write it into file

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

changes on this file are related to nm_writebuffer + fcache usage.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

changes on this file are related to nm_writebuffer + fcache usage.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

changes on this file are related to nm_writebuffer + fcache usage.

return &day_cache;
}

static inline time_t get_midnight(time_t when)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

check_time_against_period uses this function, which this PR replaced with its version using cache

Comment thread src/naemon/xrddefault.c

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

changes on this file are related to nm_writebuffer + fcache usage.

Comment thread src/naemon/xsddefault.c

/* write all status data to file */
/* status.dat indents every field inside a block with a tab */
#define sd_kv_str(wb, key, val) nm_wb_kv_str((wb), "\t" key, (val))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

defining them as macros instead of functions

this is to prevent performance overhead i believe

Comment thread src/naemon/xsddefault.c

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

changes on this file are related to nm_writebuffer + fcache usage.

at the end the changes are written into the file using nm_writebuf__done

Comment thread tests/test-nm-writebuf.c
ck_assert(memcmp(wb.buf, expected, (size_t)n) == 0);
}

START_TEST(dbl_matches_snprintf_over_many_values)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the START_TEST and END_TEST macros come from libcheck

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is defined in with_tests option in configure.ac

Comment thread src/naemon/events.c

/*
* Heap entries carry the sort key inline. Sifting an event through a heap of
* ~100k entries otherwise dereferences two timed_event structs per level, all

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when it says it dereferences two timed_event structs it means the older evheap_compare function:

static inline int evheap_compare(struct timed_event *eva, struct timed_event *evb)
{
	if (eva->event_time.tv_sec < evb->event_time.tv_sec)
		return -1;
	if (eva->event_time.tv_sec > evb->event_time.tv_sec)
		return 1;
	if (eva->event_time.tv_nsec < evb->event_time.tv_nsec)
		return -1;
	if (eva->event_time.tv_nsec > evb->event_time.tv_nsec)
		return 1;
	return 0;
}

evA and evB are the two events in heap, they each point to timed_event.

timed_event has a field event_time of type "struct timespec"

which has separate tv_sec and tv_nsec , so it might do 4 comparsions in total.

the PR combines tv_sec and tv_nsec into one int64_t key , stores it in evheap_entry and uses it as the heap key

Comment thread src/naemon/events.c
size_t size;
};

static inline int64_t evheap_key(const struct timespec *ts)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

once this function generates the key to the timestamp i.e event time in nanoseconds, it can be compared in a single comparison

Comment thread src/naemon/events.c
* ~100k entries otherwise dereferences two timed_event structs per level, all
* of them in different cache lines, which made heap maintenance one of the
* more expensive parts of the event loop.
*/

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the heap used to directly store timed_events

struct timed_event **queue;

array of pointers to timed_event

the array was kept consistent into heap principles, meaning the first one is the smallest.

now instead of storing *timed_event in the array, it stores *evheap_entry

an evheap_entry then points to the same timed_event it was made from

Comment thread src/naemon/events.c
if (size != q->size) {
q->size = size;
q->queue = nm_realloc(q->queue, q->size * sizeof(struct timed_event *));
q->queue = nm_realloc(q->queue, q->size * sizeof(struct evheap_entry));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

new heap is made using evheap_entry , which has the easier to compare nanosecond based key

@nook24

nook24 commented Oct 6, 2026

Copy link
Copy Markdown
Member Author

@inqrphl is there some actual call to action for me in the comments?

@inqrphl

inqrphl commented Oct 6, 2026

Copy link
Copy Markdown

@inqrphl is there some actual call to action for me in the comments?

No, not at all. I am just looking at it and understanding the changes. Possibly help @sni later on maybe as well.

nm_wb_lit(wb, "\t}\n\n");
}

void fcache_command(FILE *fp, const command *temp_command)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not used anymore then. Could it be removed?

*/
static int day_cache_tz_matches(void)
{
return day_cache.tz_offset == timezone &&

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i don't understand why the tz_name must be compared? It should not change during runtime but only on reload where this cache is cleared anyway.
I mean, it would be nice to have timeperiods or hosts support timezones, but that is a different story.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By default use_timezone is not present in the naemon.cfg (only as comment). If use_timezone is set in the naemon.cfg Naemon set the TZ environment variable on startup. In this case, the timezone name in the cache is not relevant.

	/* adjust timezone values */
	if (use_timezone != NULL)
		set_environment_var("TZ", use_timezone, 1);
	tzset();
	/* zones with different DST rules can look alike to the day cache */
	_reset_day_cache();

But. As mentioned: By default this value is not set. In this case, glibc will use the value of /etc/localtime to determine the timezone. As soon as /etc/localtime change, the next call to mktime() will immediately use the new timezone, no Naemon reload required.

To be able to detect a timezone change, the cache has to hold the timezone fingerprint.

Current Naemon versions are applying timezone changes immediately. If we do not clear the cache on a timezone change, Naemon would calculate wrong values until next midnight.

The current detection is also not 100% bullet proof. A timezone change with the same timezone shortcut but different daylight saving times is not detected. (Europe/Amsterdam and Africa/Tunis for example). For this reason, the reload is resetting the cache anyways.

As id did not find glibc on GitHub, I ask AI to get a little call stack overview, that's the result:

  • mktime() calls __tzset() every time (time/mktime.c#l537), as POSIX requires: "Local timezone information shall be set as though mktime() called tzset()." (mktime)
  • With TZ unset there is no early return; glibc falls back to TZDEFAULT and calls __tzfile_read() (time/tzset.c#l388)
  • which re-reads the file when it changed (time/tzfile.c#l158)

day_cache.tm = *t;
day_cache.midnight = mktime(&day_cache.tm);

if (probe_midnight != (time_t) -1 && next_midnight != (time_t) -1 &&

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't it be possible to cache midnight, even if there is a daylight change on that day? Midnight does not move, only the day is not exactly 86400 seconds long.

I assume this might raise questions on why naemon is "slower" twice a year :-)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will be a longer one :)

The current Naemon versions check on every check, if the time is within the check_period. src/naemon/objects_timeperiod.c

static inline time_t get_midnight(time_t when)
{
      struct tm *t, tm_s;

      t = localtime_r((time_t *)&when, &tm_s);   /* timestamp -> date/time, also sets tm_isdst */
      t->tm_sec = 0;
      t->tm_min = 0;
      t->tm_hour = 0;                            /* time to 00:00:00, tm_isdst is kept */
      return mktime(t);                          /* date/time -> timestamp */
}

int check_time_against_period(time_t test_time, const timeperiod *tperiod)
{
      ...
      midnight = get_midnight(test_time);
      for (temp_timerange = _get_matching_timerange(test_time, tperiod); ...) {
              if (timerange_includes_time(temp_timerange, test_time - midnight))
                      return OK;
      }
      return ERROR;
}

_get_matching_timerange() looks up the time-ranges for the day, and calculates the same midnight again. 2*localtime_r() and 2*mktime() called. This is where the 3.4% CPU time was consumed in the event loop.

The cache holds the result for the entire day so a lookup within the same day do not have to do anything.

What midnight means in Naemon
Time-ranges from the configuration are stored as seconds since the start of the day: 09:00-17:00 becomes range_start = 32400, range_end = 61200. They are checked like this:

static inline int timerange_includes_time(struct timerange *range, time_t when)
{
      return (when >= (time_t)range->range_start && when < (time_t)range->range_end);
}
/* called with: test_time - midnight */

So midnight is the origin where Naemon starts counting seconds of the day.
On a normal day that is the real midnight. On a transition day it is not: localtime_r() sets tm_isdst for the tested timestamp, get_midnight() only zeroes hour, minute and second, and mktime() then interprets 00:00 with that inherited tm_isdst. So the origin depends on whether the tested time is before or after the switch.

Europe/Berlin, 2025-03-30:

tested time "midnight" 09:00 becomes
01:00 CET (before the switch) 03-30 00:00 CET 10:00 CEST
15:00 CEST (after the switch) 03-29 23:00 CET 09:00 CEST
real midnight (tm_isdst=-1) 03-30 00:00 CET 10:00 CEST

Before the switch only times up to 02:00 are tested, and for those both origins agree. After the switch, the shifted origin is what makes 09:00 mean 09:00.

The cache holds one origin per day, but a transition day needs two. Caching one of them would put the time ranges an hour off for part of that day. Avoiding that would mean changing how get_midnight() works, which changes timeperiod behaviour on transition days.

How does the cache detect a transition day? It measures the day length with tm_isdst = -1

      probe = *t;                     /* today, 00:00 */
      probe.tm_isdst = -1;
      probe_midnight = mktime(&probe);

      next = *t;                      /* tomorrow, 00:00 */
      next.tm_mday += 1;
      next.tm_isdst = -1;
      next_midnight = mktime(&next);

      day_cache.tm = *t;              /* the origin as on master, with inherited tm_isdst */
      day_cache.midnight = mktime(&day_cache.tm);

      if (next_midnight - probe_midnight == 86400 &&     /* a normal 24 h day */
          day_cache.midnight == probe_midnight)          /* with a single origin */
              day_cache.valid_until = next_midnight;         /* -> cache it */
      else
              day_cache.valid_until = 0;                     /* -> do not cache */

While going through all of this, I have to admit you where right with the "slower twice a year" part.
On a transition day now 2*localtime_r() and 6*mktime() class are made. (407ns on master, 752ns on this code) I guess we should cache if a day is not cacheable :)
I will address this.

@sni sni left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks good, i have some questiond but no blockers. The FIXMEs should be adressed in a follow up PR.

On a DST transition day every lookup repeated the day length probe, so a
timeperiod check cost two localtime_r() and six mktime() calls instead of
two of each before the cache. The cache now remembers the last day it
found not cacheable and computes it there as before, without the probe.

Also say in the cache comment why the timezone fingerprint is kept:
without use_timezone, glibc re-reads /etc/localtime in mktime() once it
changed, so the zone can change at runtime without a reload.

Signed-off-by: nook24 <info@nook24.eu>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WY8mbGLNkt5eQfc57cTnZ5
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

NEBCALLBACK_SERVICE_STATUS_DATA and NEBCALLBACK_HOST_STATUS_DATA are send twice to broker modules

3 participants