Repository navigation
Conversation
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>
0588352 to
9e5f84a
Compare
| #define NEBTYPE_SERVICESTATUS_UPDATE 1202 | ||
| #define NEBTYPE_CONTACTSTATUS_UPDATE 1203 | ||
|
|
||
| /* |
There was a problem hiding this comment.
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?
| return error; | ||
| } | ||
|
|
||
| #define NM_WB_DBL_CACHE_SIZE 512 |
There was a problem hiding this comment.
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.
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
9e5f84a to
2c24f24
Compare
What changed
Commits
"Rebased" means the content is unchanged and only the SHA moved because an earlier commit changed. Verification
Den Platzhalter #548 gibt es mehrmals, am einfachsten mit Suchen und Ersetzen. |
| else | ||
| return NSOCK_EINVAL; | ||
|
|
||
| /* |
There was a problem hiding this comment.
this is its own fix from commit 38a97f2 , only modifies this file.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
changes on this file are related to nm_writebuffer + fcache usage.
There was a problem hiding this comment.
changes on this file are related to nm_writebuffer + fcache usage.
There was a problem hiding this comment.
changes on this file are related to nm_writebuffer + fcache usage.
| return &day_cache; | ||
| } | ||
|
|
||
| static inline time_t get_midnight(time_t when) |
There was a problem hiding this comment.
check_time_against_period uses this function, which this PR replaced with its version using cache
There was a problem hiding this comment.
changes on this file are related to nm_writebuffer + fcache usage.
|
|
||
| /* 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)) |
There was a problem hiding this comment.
defining them as macros instead of functions
this is to prevent performance overhead i believe
There was a problem hiding this comment.
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
| ck_assert(memcmp(wb.buf, expected, (size_t)n) == 0); | ||
| } | ||
|
|
||
| START_TEST(dbl_matches_snprintf_over_many_values) |
There was a problem hiding this comment.
the START_TEST and END_TEST macros come from libcheck
There was a problem hiding this comment.
this is defined in with_tests option in configure.ac
|
|
||
| /* | ||
| * 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 |
There was a problem hiding this comment.
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
| size_t size; | ||
| }; | ||
|
|
||
| static inline int64_t evheap_key(const struct timespec *ts) |
There was a problem hiding this comment.
once this function generates the key to the timestamp i.e event time in nanoseconds, it can be compared in a single comparison
| * ~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. | ||
| */ |
There was a problem hiding this comment.
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
| 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)); |
There was a problem hiding this comment.
new heap is made using evheap_entry , which has the easier to compare nanosecond based key
|
@inqrphl is there some actual call to action for me in the comments? |
| nm_wb_lit(wb, "\t}\n\n"); | ||
| } | ||
|
|
||
| void fcache_command(FILE *fp, const command *temp_command) |
There was a problem hiding this comment.
This is not used anymore then. Could it be removed?
| */ | ||
| static int day_cache_tz_matches(void) | ||
| { | ||
| return day_cache.tz_offset == timezone && |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 && |
There was a problem hiding this comment.
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 :-)
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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
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
query_socketpath of 108+ characters overran the stack at startupcontrib/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_resultgained anobject_ptrmember, soCURRENT_NEB_API_VERSIONgoes 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 setcr->object_ptrto the host/service a result belongs to (or leave it NULL for the old lookup), and can recogniseNEBTYPE_HOSTSTATUS_SCHEDULE/NEBTYPE_SERVICESTATUS_SCHEDULE.Behaviour
status.dat,retention.datandobjects.cacheare byte-identical to master's output, verified against a stock build driven to the same state with a fixed set of passive results.NEBTYPE_*STATUS_SCHEDULEinstead ofNEBTYPE_*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 inbroker.h.Testing
citest.yml(-O2,-g -O0, AddressSanitizer):make check,make distcheck,flake8,behave— all green, no ASan or LeakSanitizer reports.nm_writebufoutput againstsnprintf()up toDBL_MAX(commit 7).right/zones.Not included
retention.dat, a host'sretry_check_intervalis written fromcheck_interval. Fixing it changes what an existing file means, so it is markedFIXMEand left for its own patch.objects.cachestays onfprintf(): thefcache_*functions are declared in installed headers with aFILE *signature, and the file is written only once at startup.