diff --git a/0007-Issue-7562-Error-NssSsl.add_cert-got-an-unexpected-k.patch b/0007-Issue-7562-Error-NssSsl.add_cert-got-an-unexpected-k.patch new file mode 100644 index 0000000..3bd18dc --- /dev/null +++ b/0007-Issue-7562-Error-NssSsl.add_cert-got-an-unexpected-k.patch @@ -0,0 +1,51 @@ +From e92fa4b13066cc0107134346ea0af15981021c42 Mon Sep 17 00:00:00 2001 +From: Viktor Ashirov +Date: Fri, 5 Jun 2026 18:59:25 +0200 +Subject: [PATCH] Issue 7562 - Error: NssSsl.add_cert() got an unexpected + keyword argument 'input_file' (#7563) + +Bug Description: +In #7281 `input_file` parameter was renamed to `cert_file`, but not all +callers were updated. This causes a TypeError at runtime. + +Fix Description: +Update dscontainer and dsctl to use the new `cert_file` parameter name. + +Relates: https://github.com/389ds/389-ds-base/issues/7281 +Fixes: https://github.com/389ds/389-ds-base/issues/7562 + +Reviewed by: @ mreynolds389 (Thanks!) +--- + src/lib389/cli/dscontainer | 2 +- + src/lib389/lib389/cli_ctl/tls.py | 2 +- + 2 files changed, 2 insertions(+), 2 deletions(-) + +diff --git a/src/lib389/cli/dscontainer b/src/lib389/cli/dscontainer +index e206effaa..970cf1d14 100755 +--- a/src/lib389/cli/dscontainer ++++ b/src/lib389/cli/dscontainer +@@ -149,7 +149,7 @@ def _begin_setup_pem_tls(): + # Import the ca's + for ca_path in [os.path.join(CONTAINER_TLS_SERVER_CADIR, ca) for ca in cas]: + log.info("Enrolling -> %s" % ca_path) +- tls.add_cert(nickname=ca_path, input_file=ca_path, ca=True) ++ tls.add_cert(nickname=ca_path, cert_file=ca_path, ca=True) + tls.edit_cert_trust(ca_path, "C,,") + # Import the new server-cert + tls.add_server_key_and_cert(CONTAINER_TLS_SERVER_KEY, CONTAINER_TLS_SERVER_CERT) +diff --git a/src/lib389/lib389/cli_ctl/tls.py b/src/lib389/lib389/cli_ctl/tls.py +index f1caa3d86..0d917c62c 100644 +--- a/src/lib389/lib389/cli_ctl/tls.py ++++ b/src/lib389/lib389/cli_ctl/tls.py +@@ -44,7 +44,7 @@ def import_client_ca(inst, log, args): + if nickname.lower() == CERT_NAME.lower() or nickname.lower() == CA_NAME.lower(): + log.error("You may not import a CA with the nickname %s or %s" % (CERT_NAME, CA_NAME)) + return +- tls.add_cert(nickname=nickname, input_file=cert_path) ++ tls.add_cert(nickname=nickname, cert_file=cert_path) + tls.edit_cert_trust(nickname, "T,,") + + +-- +2.54.0 + diff --git a/0008-Issue-7500-Prevent-unsigned-integer-underflow-during.patch b/0008-Issue-7500-Prevent-unsigned-integer-underflow-during.patch new file mode 100644 index 0000000..ce87534 --- /dev/null +++ b/0008-Issue-7500-Prevent-unsigned-integer-underflow-during.patch @@ -0,0 +1,89 @@ +From 55377637f238346626e87c2633b79d1fd366f556 Mon Sep 17 00:00:00 2001 +From: Mark Reynolds +Date: Mon, 18 May 2026 14:27:04 -0400 +Subject: [PATCH] Issue 7500 - Prevent unsigned integer underflow during + stalled import + +Description: + +During a stalled import the foreman's first ID could be greater than the +history progress size which leads to underflowing the rate. Check if the +foreman's first ID is greater than the progress size and just set it to +zero. + +Relates: https://github.com/389ds/389-ds-base/issues/7500 + +Reviewed by: progier(Thanks!) +--- + .../slapd/back-ldbm/db-bdb/bdb_import.c | 22 ++++++++++++++----- + .../slapd/back-ldbm/db-mdb/mdb_import.c | 4 ++-- + 2 files changed, 19 insertions(+), 7 deletions(-) + +diff --git a/ldap/servers/slapd/back-ldbm/db-bdb/bdb_import.c b/ldap/servers/slapd/back-ldbm/db-bdb/bdb_import.c +index b72d9d10e..a965529ea 100644 +--- a/ldap/servers/slapd/back-ldbm/db-bdb/bdb_import.c ++++ b/ldap/servers/slapd/back-ldbm/db-bdb/bdb_import.c +@@ -1765,16 +1765,28 @@ bdb_import_monitor_threads(ImportJob *job, int *status) + /* Now calculate our rate of progress overall for this chunk */ + if (time_now != job->start_time) { + /* log a cute chart of the worker progress */ ++ uint32_t history_size = 0; ++ double rate = 0.0; ++ + bdb_import_log_status_start(job); + bdb_import_log_status_add_line(job, +- "Index status for import of %s:", job->inst->inst_name); ++ "Index status for import of %s:", ++ job->inst->inst_name); + bdb_import_log_status_add_line(job, +- "-------Index Task-------State---Entry----Rate-"); ++ "-------Index Task-------State---Entry----Rate-"); + + bdb_import_push_progress_history(job, foreman->last_ID_processed, +- time_now); +- job->average_progress_rate = +- (double)(HISTORY(IMPORT_JOB_PROG_HISTORY_SIZE - 1) + 1 - foreman->first_ID) / ++ time_now); ++ ++ history_size = HISTORY(IMPORT_JOB_PROG_HISTORY_SIZE - 1) + 1; ++ if (foreman->first_ID > history_size) { ++ /* Import is stalled and subtracting first_ID will ++ * underflow the rate - so set it to 0.0 */ ++ rate = 0.0; ++ } else { ++ rate = (double)(history_size - foreman->first_ID); ++ } ++ job->average_progress_rate = rate / + (double)(TIMES(IMPORT_JOB_PROG_HISTORY_SIZE - 1) - job->start_time); + job->recent_progress_rate = + PROGRESS(0, IMPORT_JOB_PROG_HISTORY_SIZE - 1); +diff --git a/ldap/servers/slapd/back-ldbm/db-mdb/mdb_import.c b/ldap/servers/slapd/back-ldbm/db-mdb/mdb_import.c +index d79715fea..9a92e8ffc 100644 +--- a/ldap/servers/slapd/back-ldbm/db-mdb/mdb_import.c ++++ b/ldap/servers/slapd/back-ldbm/db-mdb/mdb_import.c +@@ -586,7 +586,6 @@ dbmdb_import_monitor_threads(ImportJob *job, int *status) + int count = 1; /* 1 to prevent premature status report */ + const int display_interval = 200; + time_t time_now = 0; +- int i = 0; + + for (current_worker = job->worker_list; current_worker != NULL; + current_worker = current_worker->next) +@@ -615,12 +614,13 @@ dbmdb_import_monitor_threads(ImportJob *job, int *status) + dbmdb_import_clear_progress_history(job); + + while (!finished) { ++ size_t max_slots = ctx->workerq.max_slots; + DS_Sleep(tenthsecond); + finished = 1; + + /* Compute the number of entries processed by the workers */ + entry_processed = 0; +- for (i=0; iworkerq.max_slots; i++) { ++ for (size_t i = 0; i < max_slots; i++) { + entry_processed += slots[i].count; + } + +-- +2.54.0 + diff --git a/0009-Issue-7558-During-online-import-the-IDL-should-be-cr.patch b/0009-Issue-7558-During-online-import-the-IDL-should-be-cr.patch new file mode 100644 index 0000000..9bcffb0 --- /dev/null +++ b/0009-Issue-7558-During-online-import-the-IDL-should-be-cr.patch @@ -0,0 +1,598 @@ +From f5fa6f7dc0adcdb48f0f17eb6d27b8fd2f575f20 Mon Sep 17 00:00:00 2001 +From: tbordaz +Date: Wed, 10 Jun 2026 15:14:08 +0200 +Subject: [PATCH] Issue 7558 - During online import, the IDL should be created + with in-depth first approach (#7559) + +Bug description: + The online initialization requires that the supplier builds a sorted IDL. + It is sorted in the way that the parent entry appears before the children in the IDL. + The current implementation goes through the parentid index from the first entry + until the end (next). To make sure parent entry is already in IDL before adding a child + it uses a list of ID ranges. + This list works well if the ID are mostly consecutive and with limited number of holes, + else the list grows (lot of singleton) and checking the list becomes costly as well. + +Fix description: + Instead of walking the parentid index from the first entry to the end it walks + the parentid in depth first. So there is no need to check that the parent ID is already + present in IDL + +fixes: #7558 + +Reviewed by: Pierre Rogier (Thanks !!) +--- + ldap/servers/slapd/back-ldbm/idl_new.c | 459 +++++++++++++++---------- + 1 file changed, 285 insertions(+), 174 deletions(-) + +diff --git a/ldap/servers/slapd/back-ldbm/idl_new.c b/ldap/servers/slapd/back-ldbm/idl_new.c +index 613d53815..29457500e 100644 +--- a/ldap/servers/slapd/back-ldbm/idl_new.c ++++ b/ldap/servers/slapd/back-ldbm/idl_new.c +@@ -45,13 +45,6 @@ struct idl_private + int dummy; + }; + +-/* Used to store leftover parentid and entry ids */ +-typedef struct _range_id_pair +-{ +- ID key; +- ID id; +-} idl_range_id_pair; +- + /* lmdb iterator callback context */ + typedef struct { + backend *be; +@@ -62,18 +55,31 @@ typedef struct { + struct timespec *expire_time; + int lookthrough_limit; + int operator; +- idl_range_id_pair *leftover; +- size_t leftoverlen; +- size_t leftovercnt; + IDList *idl; +- IdRange_t *idrange_list; + int flag_err; + ID lastid; +- ID suffix; + uint64_t count; + char *index_id; + } idl_range_ctx_t; + ++/* Context for depth-first parentid index walk (bulk import) */ ++typedef struct { ++ backend *be; ++ dbi_db_t *db; ++ dbi_txn_t *txn; ++ struct attrinfo *ai; ++ IDList *idl; ++ int *flag_err; ++ int allidslimit; ++ int sizelimit; ++ struct timespec *expire_time; ++ int lookthrough_limit; ++ uint64_t count; ++ char *index_id; ++ char keybuf[32]; ++ dbi_val_t key; ++} idl_parentid_walk_ctx_t; ++ + + static int idl_tune = DEFAULT_IDL_TUNE; /* tuning parameters for IDL code */ + /* Currently none for new IDL code */ +@@ -383,22 +389,263 @@ keycmp(dbi_val_t *L, dbi_val_t *R, value_compare_fn_type cmp_fn) + return cmp_fn(&Lv, &Rv); + } + ++static void ++idl_parentid_sorted_suffix_init(backend *be, ID *suffix, const char *logfn) ++{ ++ struct _back_info_index_key bck_info; ++ int rc; ++ ++ bck_info.index = SLAPI_ATTR_PARENTID; ++ bck_info.key = "0"; ++ *suffix = 0; ++ ++ rc = slapi_back_get_info(be, BACK_INFO_INDEX_KEY, (void **)&bck_info); ++ if (rc) { ++ slapi_log_err(SLAPI_LOG_WARNING, logfn, ++ "Total update: fail to retrieve suffix entryID, continue assuming it is the first entry\n"); ++ } ++ if (bck_info.key_found) { ++ *suffix = bck_info.id; ++ } ++} ++ ++static void ++idl_parentid_set_index_key(idl_parentid_walk_ctx_t *ctx, ID parent_id) ++{ ++ ctx->key.ulen = sizeof(ctx->keybuf); ++ ctx->key.size = PR_snprintf(ctx->keybuf, ctx->key.ulen, "%c%lu", ++ EQ_PREFIX, (u_long)parent_id); ++ ctx->key.size++; /* include the null terminator */ ++ dblayer_value_set_buffer(ctx->be, &ctx->key, ctx->keybuf, ctx->key.size); ++} ++ ++static int ++idl_parentid_walk_check_limits(idl_parentid_walk_ctx_t *ctx) ++{ ++ if (ctx->idl) { ++ if ((ctx->lookthrough_limit != -1) && ++ (ctx->idl->b_nids > (ID)ctx->lookthrough_limit)) { ++ idl_free(&ctx->idl); ++ ctx->idl = idl_allids(ctx->be); ++ slapi_log_err(SLAPI_LOG_TRACE, "idl_new_parentid_sorted_range_fetch", ++ "lookthrough_limit exceeded\n"); ++ *(ctx->flag_err) = LDAP_ADMINLIMIT_EXCEEDED; ++ return -1; ++ } ++ if ((ctx->sizelimit > 0) && (ctx->idl->b_nids > (ID)ctx->sizelimit)) { ++ slapi_log_err(SLAPI_LOG_TRACE, "idl_new_parentid_sorted_range_fetch", ++ "sizelimit exceeded\n"); ++ *(ctx->flag_err) = LDAP_SIZELIMIT_EXCEEDED; ++ return -1; ++ } ++ } ++ if (slapi_timespec_expire_check(ctx->expire_time) == TIMER_EXPIRED) { ++ slapi_log_err(SLAPI_LOG_TRACE, "idl_new_parentid_sorted_range_fetch", ++ "timelimit exceeded\n"); ++ *(ctx->flag_err) = LDAP_TIMELIMIT_EXCEEDED; ++ return -1; ++ } ++ return 0; ++} ++ ++static int ++idl_parentid_walk_push(ID **stack, size_t *stack_size, size_t *stack_top, ID id) ++{ ++ if (*stack_top >= *stack_size) { ++ size_t new_size = (*stack_size == 0) ? 256 : (*stack_size * 2); ++ ID *new_stack = (ID *)slapi_ch_realloc((char *)*stack, new_size * sizeof(ID)); ++ ++ if (new_stack == NULL) { ++ return -1; ++ } ++ *stack = new_stack; ++ *stack_size = new_size; ++ } ++ (*stack)[(*stack_top)++] = id; ++ return 0; ++} ++ + /* +- * Perform the range search in the idl layer instead of the index layer +- * to improve the performance. ++ * Walk the parentid index depth-first starting at root_id. ++ * For each parent, look up index key "=parent_id" (direct get, not cursor next), ++ * append the parent to the IDList, then visit each child the same way. + */ ++static int ++idl_parentid_walk_tree(idl_parentid_walk_ctx_t *ctx, ID root_id, ID **stack, ++ size_t *stack_size, size_t *stack_top) ++{ ++ IDList *children = NULL; ++ int fetch_err = NEW_IDL_NO_ALLID; ++ size_t i; ++ ++ if (idl_parentid_walk_push(stack, stack_size, stack_top, root_id) != 0) { ++ return -1; ++ } ++ ++ while (*stack_top > 0) { ++ ID parent_id = (*stack)[--(*stack_top)]; ++ ++ if (idl_parentid_walk_check_limits(ctx) != 0) { ++ return -1; ++ } ++ ++ if (idl_append_extend(&ctx->idl, parent_id) != 0) { ++ slapi_log_err(SLAPI_LOG_ERR, "idl_new_parentid_sorted_range_fetch", ++ "Unable to extend id list for attribute (%s)\n", ctx->index_id); ++ idl_free(&ctx->idl); ++ return -1; ++ } ++ ctx->count++; ++ ++#if defined(DB_ALLIDS_ON_READ) ++ if ((NEW_IDL_NO_ALLID != *(ctx->flag_err)) && ctx->ai && (ctx->idl != NULL) && ++ idl_new_exceeds_allidslimit(ctx->count, ctx->ai, ctx->allidslimit)) { ++ ctx->idl->b_nids = 1; ++ ctx->idl->b_ids[0] = ALLID; ++ return 0; ++ } ++#endif ++ idl_parentid_set_index_key(ctx, parent_id); ++ children = idl_fetch_ext(ctx->be, ctx->db, &ctx->key, ctx->txn, ctx->ai, ++ &fetch_err, ctx->allidslimit); ++ if (fetch_err != 0 && fetch_err != DBI_RC_NOTFOUND) { ++ slapi_log_err(SLAPI_LOG_ERR, "idl_new_parentid_sorted_range_fetch", ++ "Failed to read parentid index key %s (err=%d)\n", ++ (char *)ctx->key.data, fetch_err); ++ *(ctx->flag_err) = fetch_err; ++ return -1; ++ } ++ if (children == NULL) { ++ continue; ++ } ++ if (ALLIDS(children)) { ++ idl_free(&ctx->idl); ++ ctx->idl = idl_allids(ctx->be); ++ idl_free(&children); ++ return 0; ++ } ++ for (i = children->b_nids; i > 0; i--) { ++ if (idl_parentid_walk_push(stack, stack_size, stack_top, ++ children->b_ids[i - 1]) != 0) { ++ idl_free(&children); ++ return -1; ++ } ++ } ++ idl_free(&children); ++ } ++ return 0; ++} ++ + /* +- * NOTE: +- * In the total update (bulk import), an entry requires its ancestors already added. +- * To guarantee it, the range search with parentid is used with setting the flag +- * SLAPI_OP_RANGE_NO_IDL_SORT in operator. +- * In bulk import the range search is parentid>=1 to retrieve all the entries +- * But we need to order the IDL with the parents first => retrieve the suffix entry ID +- * to store the children ++ * Build an IDList from the parentid index with ancestors before descendants. ++ * Uses direct key lookups (idl_fetch) and depth-first traversal from the ++ * suffix entry, rather than a full index scan with membership checks. + * +- * If the flag is set, +- * 1. the IDList is not sorted by the ID. +- * 2. holding to add an ID to the IDList unless the key is found in the IDList. ++ * Used during bulk import (SLAPI_OP_RANGE_NO_IDL_SORT). ++ */ ++static IDList * ++idl_new_parentid_sorted_range_fetch( ++ backend *be, ++ dbi_db_t *db, ++ dbi_val_t *lowerkey, ++ dbi_val_t *upperkey, ++ dbi_txn_t *txn, ++ struct attrinfo *ai, ++ int *flag_err, ++ int allidslimit, ++ int sizelimit, ++ struct timespec *expire_time, ++ int lookthrough_limit, ++ int operator) ++{ ++ int ret = 0; ++ ID suffix = 0; ++ ID *stack = NULL; ++ size_t stack_size = 0; ++ size_t stack_top = 0; ++ char *index_id = get_index_name(be, db, ai); ++ idl_parentid_walk_ctx_t walk = {0}; ++ IDList *suffix_idl = NULL; ++ int suffix_err = 0; ++ ++ walk.be = be; ++ walk.db = db; ++ walk.txn = txn; ++ walk.ai = ai; ++ walk.flag_err = flag_err; ++ walk.allidslimit = allidslimit; ++ walk.sizelimit = sizelimit; ++ walk.expire_time = expire_time; ++ walk.lookthrough_limit = lookthrough_limit; ++ walk.index_id = index_id; ++ walk.idl = idl_alloc(IDLIST_MIN_BLOCK_SIZE); ++ if (walk.idl == NULL) { ++ *flag_err = ENOMEM; ++ return NULL; ++ } ++ ++ idl_parentid_sorted_suffix_init(be, &suffix, "idl_new_parentid_sorted_range_fetch"); ++ if (suffix == 0) { ++ idl_parentid_set_index_key(&walk, 0); ++ suffix_idl = idl_fetch_ext(be, db, &walk.key, txn, ai, &suffix_err, allidslimit); ++ if (suffix_idl && suffix_idl->b_nids > 0 && !ALLIDS(suffix_idl)) { ++ suffix = suffix_idl->b_ids[0]; ++ } ++ idl_free(&suffix_idl); ++ } ++ if (suffix == 0) { ++ slapi_log_err(SLAPI_LOG_ERR, "idl_new_parentid_sorted_range_fetch", ++ "Unable to determine suffix entry ID for parentid tree walk\n"); ++ idl_free(&walk.idl); ++ *flag_err = LDAP_UNWILLING_TO_PERFORM; ++ return NULL; ++ } ++ ++ if (slapi_is_loglevel_set(SLAPI_LOG_FILTER)) { ++ char *included = ((operator & SLAPI_OP_RANGE) == SLAPI_OP_LESS) ? "not " : ""; ++ slapi_log_err(SLAPI_LOG_FILTER, ++ "idl_new_parentid_sorted_range_fetch", ++ "Walking parentid index from suffix ID %u, keys %s to %s\n", ++ suffix, (char *)lowerkey->data, ++ upperkey && upperkey->data ? (char *)upperkey->data : "(none)"); ++ slapi_log_err(SLAPI_LOG_FILTER, "idl_new_parentid_sorted_range_fetch", ++ "Candidate list is not sorted. lower key is %sincluded.\n", included); ++ } ++ ++ if (idl_parentid_walk_tree(&walk, suffix, &stack, &stack_size, &stack_top) != 0) { ++ if (*flag_err == 0) { ++ *flag_err = LDAP_UNWILLING_TO_PERFORM; ++ } ++ ret = *flag_err; ++ } ++ ++ slapi_ch_free((void **)&stack); ++ ++ if (walk.idl && (walk.idl->b_nids == 1) && (walk.idl->b_ids[0] == ALLID)) { ++ idl_free(&walk.idl); ++ walk.idl = idl_allids(be); ++ slapi_log_err(SLAPI_LOG_TRACE, "idl_new_parentid_sorted_range_fetch", ++ "%s returns allids\n", index_id); ++ } else { ++ slapi_log_err(SLAPI_LOG_TRACE, "idl_new_parentid_sorted_range_fetch", ++ "%s returns nids=%lu\n", index_id, (u_long)IDL_NIDS(walk.idl)); ++ } ++ ++ if (ret) { ++ slapi_log_err(SLAPI_LOG_ERR, "idl_new_parentid_sorted_range_fetch", ++ "Failed to build parentid candidate list on %s index. Error is %d\n", ++ index_id, ret); ++ } ++ *flag_err = ret; ++ slapi_log_err(SLAPI_LOG_FILTER, "idl_new_parentid_sorted_range_fetch", ++ "Found %d candidates; error code is: %d\n", ++ walk.idl ? walk.idl->b_nids : 0, *flag_err); ++ return walk.idl; ++} ++ ++/* ++ * Perform the range search in the idl layer instead of the index layer ++ * to improve the performance. + */ + IDList * + idl_new_range_fetch( +@@ -430,39 +677,19 @@ idl_new_range_fetch( + back_txn s_txn; + struct ldbminfo *li = (struct ldbminfo *)be->be_database->plg_private; + int coreop = operator&SLAPI_OP_RANGE; +- ID key = 0xff; /* random- to suppress compiler warning */ +- ID suffix = 0; /* random- to suppress compiler warning */ +- idl_range_id_pair *leftover = NULL; +- size_t leftoverlen = 32; +- size_t leftovercnt = 0; + char *index_id = get_index_name(be, db, ai); +- IdRange_t *idrange_list = NULL; +- + + if (NULL == flag_err) { + return NULL; + } +- if (operator & SLAPI_OP_RANGE_NO_IDL_SORT) { +- struct _back_info_index_key bck_info; +- int rc; +- /* We are doing a bulk import +- * try to retrieve the suffix entry id from the index +- */ +- +- bck_info.index = SLAPI_ATTR_PARENTID; +- bck_info.key = "0"; +- +- if ((rc = slapi_back_get_info(be, BACK_INFO_INDEX_KEY, (void **)&bck_info))) { +- slapi_log_err(SLAPI_LOG_WARNING, "idl_new_range_fetch", "Total update: fail to retrieve suffix entryID, continue assuming it is the first entry\n"); +- } +- if (bck_info.key_found) { +- suffix = bck_info.id; +- } +- } +- + if (NEW_IDL_NOOP == *flag_err) { + return NULL; + } ++ if (operator & SLAPI_OP_RANGE_NO_IDL_SORT) { ++ return idl_new_parentid_sorted_range_fetch(be, db, lowerkey, upperkey, txn, ai, ++ flag_err, allidslimit, sizelimit, ++ expire_time, lookthrough_limit, operator); ++ } + if (slapi_is_loglevel_set(SLAPI_LOG_FILTER)) { + char *included = ((operator & SLAPI_OP_RANGE) == SLAPI_OP_LESS) ? "not " : ""; + const char *sorted = (operator & SLAPI_OP_RANGE_NO_IDL_SORT) ? "not " : ""; +@@ -552,9 +779,6 @@ idl_new_range_fetch( + *flag_err = LDAP_TIMELIMIT_EXCEEDED; + goto error; + } +- if (operator & SLAPI_OP_RANGE_NO_IDL_SORT) { +- key = (ID)strtol((char *)cur_key.data + 1, (char **)NULL, 10); +- } + while (DBI_RC_SUCCESS == dblayer_bulk_nextdata(&bulkdata, &dataret)) { + if (dataret.size != sizeof(ID)) { + slapi_log_err(SLAPI_LOG_ERR, "idl_new_range_fetch", "Database index is corrupt; " +@@ -570,38 +794,7 @@ idl_new_range_fetch( + } + /* note the last id read to check for dups */ + lastid = id; +- /* we got another ID, add it to our IDL */ +- if (operator & SLAPI_OP_RANGE_NO_IDL_SORT) { +- if ((count == 0) && (suffix == 0)) { +- /* First time. Keep the suffix ID. +- * note that 'suffix==0' mean we did not retrieve the suffix entry id +- * from the parentid index (key '=0'), so let assume the first +- * found entry is the one from the suffix +- */ +- suffix = key; +- idl_append_extend(&idl, id); +- idrange_add_id(&idrange_list, id); +- } else if ((key == suffix) || idl_id_is_in_idlist_ranges(idl, idrange_list, key)) { +- /* the parent is the suffix or already in idl. */ +- idl_append_extend(&idl, id); +- idrange_add_id(&idrange_list, id); +- } else { +- /* Otherwise, keep the {key,id} in leftover array */ +- if (!leftover) { +- leftover = (idl_range_id_pair *)slapi_ch_calloc(leftoverlen, sizeof(idl_range_id_pair)); +- } else if (leftovercnt == leftoverlen) { +- leftover = (idl_range_id_pair *)slapi_ch_realloc((char *)leftover, 2 * leftoverlen * sizeof(idl_range_id_pair)); +- memset(leftover + leftovercnt, 0, leftoverlen); +- leftoverlen *= 2; +- } +- leftover[leftovercnt].key = key; +- leftover[leftovercnt].id = id; +- leftovercnt++; +- } +- } else { +- idl_append_extend(&idl, id); +- } +- ++ idl_append_extend(&idl, id); + count++; + } + +@@ -684,26 +877,9 @@ error: + *flag_err = ret; + + /* sort idl */ +- if (idl && !ALLIDS(idl) && !(operator&SLAPI_OP_RANGE_NO_IDL_SORT)) { ++ if (idl && !ALLIDS(idl)) { + qsort((void *)&idl->b_ids[0], idl->b_nids, (size_t)sizeof(ID), idl_sort_cmp); + } +- if (operator&SLAPI_OP_RANGE_NO_IDL_SORT) { +- size_t remaining = leftovercnt; +- +- while(remaining > 0) { +- for (size_t i = 0; i < leftovercnt; i++) { +- if (leftover[i].key > 0 && idl_id_is_in_idlist_ranges(idl, idrange_list, leftover[i].key) != 0) { +- /* if the leftover key has its parent in the idl */ +- idl_append_extend(&idl, leftover[i].id); +- idrange_add_id(&idrange_list, leftover[i].id); +- leftover[i].key = 0; +- remaining--; +- } +- } +- } +- } +- slapi_ch_free((void **)&leftover); +- idrange_free(&idrange_list); + slapi_log_err(SLAPI_LOG_FILTER, "idl_new_range_fetch", + "Found %d candidates; error code is: %d\n", + idl ? idl->b_nids : 0, *flag_err); +@@ -769,38 +945,7 @@ idl_range_add_id_cb(dbi_val_t *key, dbi_val_t *data, void *ctx) + "Detected duplicate id %d due to DB_MULTIPLE error - skipping\n", id); + return DBI_RC_SUCCESS; + } +- /* we got another ID, add it to our IDL */ +- if (rctx->operator & SLAPI_OP_RANGE_NO_IDL_SORT) { +- ID keyval = (ID)strtol((char *)key->data + 1, (char **)NULL, 10); +- if ((rctx->count == 0) && (rctx->suffix == 0)) { +- /* First time. Keep the suffix ID. +- * note that 'suffix==0' mean we did not retrieve the suffix entry id +- * from the parentid index (key '=0'), so let assume the first +- * found entry is the one from the suffix +- */ +- rctx->suffix = keyval; +- idl_append_extend(&rctx->idl, id); +- idrange_add_id(&rctx->idrange_list, id); +- } else if ((keyval == rctx->suffix) || idl_id_is_in_idlist_ranges(rctx->idl, rctx->idrange_list, keyval)) { +- /* the parent is the suffix or already in idl. */ +- idl_append_extend(&rctx->idl, id); +- idrange_add_id(&rctx->idrange_list, id); +- } else { +- /* Otherwise, keep the {keyval,id} in leftover array */ +- if (!rctx->leftover) { +- rctx->leftover = (idl_range_id_pair *)slapi_ch_calloc(rctx->leftoverlen, sizeof(idl_range_id_pair)); +- } else if (rctx->leftovercnt == rctx->leftoverlen) { +- rctx->leftover = (idl_range_id_pair *)slapi_ch_realloc((char *)rctx->leftover, 2 * rctx->leftoverlen * sizeof(idl_range_id_pair)); +- memset(rctx->leftover + rctx->leftovercnt, 0, rctx->leftoverlen * sizeof(idl_range_id_pair)); +- rctx->leftoverlen *= 2; +- } +- rctx->leftover[rctx->leftovercnt].key = keyval; +- rctx->leftover[rctx->leftovercnt].id = id; +- rctx->leftovercnt++; +- } +- } else { +- idl_append_extend(&rctx->idl, id); +- } ++ idl_append_extend(&rctx->idl, id); + #if defined(DB_ALLIDS_ON_READ) + /* enforce the allids read limit */ + if ((NEW_IDL_NO_ALLID != rctx->flag_err) && rctx->ai && (rctx->idl != NULL) && +@@ -845,6 +990,11 @@ idl_lmdb_range_fetch( + if ((NULL == flag_err) || (NEW_IDL_NOOP == *flag_err)) { + return NULL; + } ++ if (operator & SLAPI_OP_RANGE_NO_IDL_SORT) { ++ return idl_new_parentid_sorted_range_fetch(be, db, lowerkey, upperkey, txn, ai, ++ flag_err, allidslimit, sizelimit, ++ expire_time, lookthrough_limit, operator); ++ } + if (slapi_is_loglevel_set(SLAPI_LOG_FILTER)) { + char *included = ((operator & SLAPI_OP_RANGE) == SLAPI_OP_LESS) ? "not " : ""; + const char *sorted = (operator & SLAPI_OP_RANGE_NO_IDL_SORT) ? "not " : ""; +@@ -877,32 +1027,11 @@ idl_lmdb_range_fetch( + idl_range_ctx.expire_time = expire_time; + idl_range_ctx.lookthrough_limit = lookthrough_limit; + idl_range_ctx.operator = operator; +- idl_range_ctx.leftover = NULL; +- idl_range_ctx.leftoverlen = 32; +- idl_range_ctx.leftovercnt = 0; + idl_range_ctx.idl = idl_alloc(IDLIST_MIN_BLOCK_SIZE); + idl_range_ctx.flag_err = 0; + idl_range_ctx.lastid = 0; + idl_range_ctx.count = 0; + idl_range_ctx.index_id = index_id; +- idl_range_ctx.idrange_list = NULL; +- if (operator & SLAPI_OP_RANGE_NO_IDL_SORT) { +- struct _back_info_index_key bck_info; +- /* We are doing a bulk import +- * try to retrieve the suffix entry id from the index +- */ +- +- bck_info.index = SLAPI_ATTR_PARENTID; +- bck_info.key = "0"; +- +- if ((ret = slapi_back_get_info(be, BACK_INFO_INDEX_KEY, (void **)&bck_info))) { +- slapi_log_err(SLAPI_LOG_WARNING, "idl_lmdb_range_fetch", +- "Total update: fail to retrieve suffix entryID, continue assuming it is the first entry\n"); +- } +- if (bck_info.key_found) { +- idl_range_ctx.suffix = bck_info.id; +- } +- } + + /* + * Iterate +@@ -952,27 +1081,9 @@ error: + } + + /* sort idl */ +- if (!ALLIDS(idl_range_ctx.idl) && !(operator&SLAPI_OP_RANGE_NO_IDL_SORT)) { ++ if (!ALLIDS(idl_range_ctx.idl)) { + qsort((void *)&idl_range_ctx.idl->b_ids[0], idl_range_ctx.idl->b_nids, sizeof(ID), idl_sort_cmp); + } +- if (operator&SLAPI_OP_RANGE_NO_IDL_SORT) { +- size_t remaining = idl_range_ctx.leftovercnt; +- +- while(remaining > 0) { +- for (size_t i = 0; i < idl_range_ctx.leftovercnt; i++) { +- if (idl_range_ctx.leftover[i].key > 0 && +- idl_id_is_in_idlist_ranges(idl_range_ctx.idl, idl_range_ctx.idrange_list, idl_range_ctx.leftover[i].key) != 0) { +- /* if the leftover key has its parent in the idl */ +- idl_append_extend(&idl_range_ctx.idl, idl_range_ctx.leftover[i].id); +- idrange_add_id(&idl_range_ctx.idrange_list, idl_range_ctx.leftover[i].id); +- idl_range_ctx.leftover[i].key = 0; +- remaining--; +- } +- } +- } +- } +- slapi_ch_free((void **)&idl_range_ctx.leftover); +- idrange_free(&idl_range_ctx.idrange_list); + *flag_err = idl_range_ctx.flag_err; + slapi_log_err(SLAPI_LOG_FILTER, "idl_lmdb_range_fetch", + "Found %d candidates; error code is: %d\n", +-- +2.54.0 + diff --git a/0010-Issue-7593-Reject-invalid-SASL-packet-length-values-.patch b/0010-Issue-7593-Reject-invalid-SASL-packet-length-values-.patch new file mode 100644 index 0000000..df1ddcb --- /dev/null +++ b/0010-Issue-7593-Reject-invalid-SASL-packet-length-values-.patch @@ -0,0 +1,164 @@ +From c7eac4e19457fe80860ab3882e7aa63b454b241e Mon Sep 17 00:00:00 2001 +From: James Chapman +Date: Tue, 23 Jun 2026 10:07:00 +0100 +Subject: [PATCH] Issue 7593 - Reject invalid SASL packet length values in + sasl_io_start_packet (#7594) + +Description: +While processing SASL encrypted traffic, sasl_io_start_packet() reads a +4-byte length from the connection and adds sizeof(uint32_t) before resizing +the read buffer. Certain large length values can wrap in uint32_t, causing +incorrect buffer sizing when malformed SASL data is received on an +established connection. + +Fixes: https://github.com/389ds/389-ds-base/issues/7593 + +Reviewed by: @tbordaz, @progier389 (Thank you) +--- + .../suites/sasl/sasl_io_overflow_test.py | 106 ++++++++++++++++++ + ldap/servers/slapd/sasl_io.c | 9 ++ + 2 files changed, 115 insertions(+) + create mode 100644 dirsrvtests/tests/suites/sasl/sasl_io_overflow_test.py + +diff --git a/dirsrvtests/tests/suites/sasl/sasl_io_overflow_test.py b/dirsrvtests/tests/suites/sasl/sasl_io_overflow_test.py +new file mode 100644 +index 000000000..ac1f3760f +--- /dev/null ++++ b/dirsrvtests/tests/suites/sasl/sasl_io_overflow_test.py +@@ -0,0 +1,106 @@ ++# --- BEGIN COPYRIGHT BLOCK --- ++# Copyright (C) 2026 Red Hat, Inc. ++# All rights reserved. ++# ++# License: GPL (version 3 or any later version). ++# See LICENSE for details. ++# --- END COPYRIGHT BLOCK --- ++# ++ ++import socket ++import struct ++import time ++import ldap ++import pytest ++ ++from lib389._constants import DEFAULT_SUFFIX ++from lib389.idm.user import UserAccounts ++from lib389.saslmap import SaslMappings ++from lib389.utils import * ++from lib389.topologies import topology_st ++ ++pytestmark = pytest.mark.tier1 ++ ++log = logging.getLogger(__name__) ++ ++SASL_OVERFLOW_FAKE_LENGTH = 0xFFFFFFFC ++SASL_OVERFLOW_PAYLOAD_SIZE = 65536 ++ ++def test_sasl_io_packet_length_overflow(topology_st): ++ """Malformed SASL length prefix must not crash the server ++ :id: 318f871d-2f17-461b-98ed-04cdff6ab41a ++ :setup: Standalone instance ++ :steps: ++ 1. Set passwordStorageScheme to CLEAR and restart the instance ++ 2. Add SASL uid mapping and user sasltest for DIGEST-MD5 bind ++ 3. SASL DIGEST-MD5 bind as sasltest ++ 4. Send malformed SASL packeton the encrypted connection ++ 5. Verify server is still running ++ :expectedresults: ++ 1. CLEAR scheme and SASL map/user are configured successfully ++ 2. Test user added ++ 3. DIGEST-MD5 bind succeeds ++ 4. Malformed packet is accepted on the wire without crashing the server ++ 5. Server remains up ++ """ ++ inst = topology_st.standalone ++ inst.config.replace('passwordStorageScheme', 'CLEAR') ++ saslmappings = SaslMappings(inst) ++ ++ # Create SASL mapping ++ try: ++ saslmappings.create(properties={ ++ 'cn': 'uid map', ++ 'nsSaslMapRegexString': r'\(.*\)', ++ 'nsSaslMapBaseDNTemplate': DEFAULT_SUFFIX, ++ 'nsSaslMapFilterTemplate': '(uid=\\1)', ++ 'nsSaslMapPriority': '10', ++ }) ++ except ldap.ALREADY_EXISTS: ++ pass ++ ++ # Create test user ++ users = UserAccounts(inst, DEFAULT_SUFFIX) ++ try: ++ users.create(properties={ ++ 'uid': 'sasltest', ++ 'cn': 'SASL Test User', ++ 'sn': 'Test', ++ 'uidNumber': '10001', ++ 'gidNumber': '10001', ++ 'homeDirectory': '/home/sasltest', ++ 'userPassword': 'sasltest123', ++ }) ++ except ldap.ALREADY_EXISTS: ++ pass ++ inst.restart() ++ ++ try: ++ # Open connection to server and send bad payload ++ conn = ldap.initialize(inst.get_ldap_uri()) ++ conn.protocol_version = ldap.VERSION3 ++ conn.set_option(ldap.OPT_X_SASL_SSF_MIN, 1) ++ conn.set_option(ldap.OPT_X_SASL_SSF_MAX, 256) ++ conn.sasl_interactive_bind_s( ++ '', ++ ldap.sasl.digest_md5('sasltest', 'sasltest123'), ++ ) ++ fd = conn.fileno() ++ sock = socket.fromfd(fd, socket.AF_INET, socket.SOCK_STREAM) ++ payload = ( ++ struct.pack('!I', SASL_OVERFLOW_FAKE_LENGTH) ++ + b'A' * 3 ++ + b'B' * SASL_OVERFLOW_PAYLOAD_SIZE ++ ) ++ sock.send(payload) ++ sock.detach() ++ time.sleep(3) ++ ++ # Check if the server is still up ++ try: ++ inst.rootdse.get_attr_val_utf8('vendorVersion') ++ except ldap.SERVER_DOWN: ++ pytest.fail("Server is not responding after malformed SASL packet") ++ finally: ++ if not inst.status(): ++ inst.start() +diff --git a/ldap/servers/slapd/sasl_io.c b/ldap/servers/slapd/sasl_io.c +index 5c2093a5c..6c2cac084 100644 +--- a/ldap/servers/slapd/sasl_io.c ++++ b/ldap/servers/slapd/sasl_io.c +@@ -16,6 +16,7 @@ + #include "fe.h" + #include + #include ++#include + + /* + * I/O Shim Layer for SASL Encryption +@@ -371,6 +372,14 @@ sasl_io_start_packet(PRFileDesc *fd, PRIntn flags, PRIntervalTime timeout, PRInt + /* Decode the length */ + packet_length = ntohl(*(uint32_t *)sp->encrypted_buffer); + /* add length itself (for Cyrus SASL library) */ ++ if (packet_length > (UINT32_MAX - sizeof(uint32_t))) { ++ slapi_log_err(SLAPI_LOG_ERR, "sasl_io_start_packet", ++ "SASL packet length would overflow (%" PRIu32 ")\n", ++ packet_length); ++ PR_SetError(PR_BUFFER_OVERFLOW_ERROR, 0); ++ *err = PR_BUFFER_OVERFLOW_ERROR; ++ return -1; ++ } + packet_length += sizeof(uint32_t); + + slapi_log_err(SLAPI_LOG_CONNS, "sasl_io_start_packet", +-- +2.54.0 + diff --git a/0011-Issue-7593-Fix-testimony-docstring-for-SASL-overflow.patch b/0011-Issue-7593-Fix-testimony-docstring-for-SASL-overflow.patch new file mode 100644 index 0000000..fa60659 --- /dev/null +++ b/0011-Issue-7593-Fix-testimony-docstring-for-SASL-overflow.patch @@ -0,0 +1,32 @@ +From 61432231a97ff885f8987e61dd24eacdbff81da5 Mon Sep 17 00:00:00 2001 +From: James Chapman +Date: Thu, 25 Jun 2026 10:45:21 +0100 +Subject: [PATCH] Issue 7593 - Fix testimony docstring for SASL overflow test + (#7606) + +Description: +The test added in #7594 failed testimony validation because the docstring +summary was not separated from the metadata fields with a blank line. + +Relates: https://github.com/389ds/389-ds-base/issues/7593 + +Reviewed by: @progier389 (Thank you) +--- + dirsrvtests/tests/suites/sasl/sasl_io_overflow_test.py | 1 + + 1 file changed, 1 insertion(+) + +diff --git a/dirsrvtests/tests/suites/sasl/sasl_io_overflow_test.py b/dirsrvtests/tests/suites/sasl/sasl_io_overflow_test.py +index ac1f3760f..43fc344df 100644 +--- a/dirsrvtests/tests/suites/sasl/sasl_io_overflow_test.py ++++ b/dirsrvtests/tests/suites/sasl/sasl_io_overflow_test.py +@@ -28,6 +28,7 @@ SASL_OVERFLOW_PAYLOAD_SIZE = 65536 + + def test_sasl_io_packet_length_overflow(topology_st): + """Malformed SASL length prefix must not crash the server ++ + :id: 318f871d-2f17-461b-98ed-04cdff6ab41a + :setup: Standalone instance + :steps: +-- +2.54.0 + diff --git a/0012-Issue-7284-Automated-test-for-creating-local-passwor.patch b/0012-Issue-7284-Automated-test-for-creating-local-passwor.patch new file mode 100644 index 0000000..5080a11 --- /dev/null +++ b/0012-Issue-7284-Automated-test-for-creating-local-passwor.patch @@ -0,0 +1,91 @@ +From a4fc4059be9f93332ec15b1a1e0774a6d688d9f1 Mon Sep 17 00:00:00 2001 +From: Lenka Doudova +Date: Wed, 8 Jul 2026 14:39:38 +0200 +Subject: [PATCH] Issue 7284 - Automated test for creating local password + policy with incorrect passwordInHistory value (#7608) + +Description: +Adding automated test for creating local password policy with incorrect passwordInHistory value + +Relates: #7284 +Author: Lenka Doudova +Assisted by: Cursor +Reviewer: @progier389 +--- + .../suites/password/password_policy_test.py | 60 +++++++++++++++++++ + 1 file changed, 60 insertions(+) + +diff --git a/dirsrvtests/tests/suites/password/password_policy_test.py b/dirsrvtests/tests/suites/password/password_policy_test.py +index a618803d0..36f25eba1 100644 +--- a/dirsrvtests/tests/suites/password/password_policy_test.py ++++ b/dirsrvtests/tests/suites/password/password_policy_test.py +@@ -1563,6 +1563,66 @@ def test_additional_corner_cases(topo, policy_setup, _fixture_for_additional_cas + ]) + + ++@pytest.mark.parametrize('value,result', ++ [('0', ldap.SUCCESS), ++ ('24', ldap.SUCCESS), ++ pytest.param('-1', ldap.CONSTRAINT_VIOLATION, marks=pytest.mark.xfail(reason='https://github.com/389ds/389-ds-base/issues/7284')), ++ pytest.param('30', ldap.CONSTRAINT_VIOLATION, marks=pytest.mark.xfail(reason='https://github.com/389ds/389-ds-base/issues/7284')), ++ pytest.param('a', ldap.CONSTRAINT_VIOLATION, marks=pytest.mark.xfail(reason='https://github.com/389ds/389-ds-base/issues/7284'))]) ++def test_create_local_pwp_with_passwordInHistory(topo, value, result): ++ """Verify local password policy passwordInHistory accepts only values 0-24 ++ ++ :id: e7c4a1b2-3d5f-4a8e-9c1b-2f6e8d4a7b03 ++ :parametrized: yes ++ :setup: Standalone instance ++ :steps: ++ 1. Enable nsslapd-pwpolicy-local ++ 2. Create a dedicated OU under the default suffix ++ 3. Create a subtree local password policy with passwordInHistory set to ++ value using PwPolicyManager.create_subtree_policy ++ 4. For successful creation, verify passwordInHistory on the policy entry ++ 5. Delete the local password policy and OU ++ :expectedresults: ++ 1. Success ++ 2. Success ++ 3. Success for value 0 or 24; CONSTRAINT_VIOLATION for -1, 30, or a ++ (invalid cases marked xfail) ++ 4. passwordInHistory matches value when creation succeeds ++ 5. Success ++ """ ++ inst = topo.standalone ++ inst.config.replace('nsslapd-pwpolicy-local', 'on') ++ ++ ous = OrganizationalUnits(inst, DEFAULT_SUFFIX) ++ ou = ous.create(properties={'ou': f'pwpinhist{value}'}) ++ ++ pwp = PwPolicyManager(inst) ++ ++ try: ++ if result == ldap.SUCCESS: ++ policy_entry = pwp.create_subtree_policy(ou.dn, {'passwordInHistory': value}) ++ assert policy_entry.get_attr_val_utf8('passwordInHistory') == value ++ else: ++ with pytest.raises(result): ++ pwp.create_subtree_policy(ou.dn, {'passwordInHistory': value}) ++ except ldap.LDAPError: ++ raise ++ finally: ++ try: ++ pwp.delete_local_policy(ou.dn) ++ except ValueError: ++ container = nsContainer(inst, f'cn=nsPwPolicyContainer,{ou.dn}') ++ try: ++ if container.exists(): ++ container.delete() ++ except ldap.LDAPError: ++ pass ++ try: ++ ou.delete() ++ except ldap.LDAPError: ++ pass ++ ++ + def test_get_pwpolicy_cn_with_quotes(topology_m1, policy_qoutes_setup): + """Test that that we can get pwpolicy when + cn attr includes quotes +-- +2.54.0 + diff --git a/0013-Issue-7558-Total-init-sends-the-suffix-entry-twice-7.patch b/0013-Issue-7558-Total-init-sends-the-suffix-entry-twice-7.patch new file mode 100644 index 0000000..71946c7 --- /dev/null +++ b/0013-Issue-7558-Total-init-sends-the-suffix-entry-twice-7.patch @@ -0,0 +1,241 @@ +From e0645da85c62c8b05a3350c3b976f1a2df8e1a52 Mon Sep 17 00:00:00 2001 +From: Simon Pichugin +Date: Fri, 10 Jul 2026 18:17:40 -0700 +Subject: [PATCH] Issue 7558 - Total init sends the suffix entry twice (#7640) + +Description: Exclude the suffix entry from the depth-first parentid +walk because total init sends it separately. +Preserve NEW_IDL_NO_ALLID across all parentid fetches and honor it in +the LMDB fetch path to maintain parent-first ordering. +Update LMDB reindex handling so entries with an explicit parentid are +treated as regular entries when their RDN matches the suffix. + +Add a regression test with a wide moved subtree that verifies entry order +and ensures the suffix is sent only once. + +Fixes: https://github.com/389ds/389-ds-base/issues/7558 +Fixes: https://github.com/389ds/389-ds-base/issues/7604 + +Reviewed by: progier389 (Thanks!) +--- + .../suites/replication/regression_m2_test.py | 103 ++++++++++++++++++ + .../back-ldbm/db-mdb/mdb_import_threads.c | 10 ++ + .../slapd/back-ldbm/db-mdb/mdb_layer.c | 2 +- + ldap/servers/slapd/back-ldbm/idl_new.c | 25 +++-- + 4 files changed, 131 insertions(+), 9 deletions(-) + +diff --git a/dirsrvtests/tests/suites/replication/regression_m2_test.py b/dirsrvtests/tests/suites/replication/regression_m2_test.py +index 2f9a70686..665270754 100644 +--- a/dirsrvtests/tests/suites/replication/regression_m2_test.py ++++ b/dirsrvtests/tests/suites/replication/regression_m2_test.py +@@ -19,6 +19,7 @@ import time + import random + import string + from shutil import rmtree ++from lib389.backend import DatabaseConfig + from lib389.dbgen import dbgen_users + from lib389.idm.user import TEST_USER_PROPERTIES, UserAccount, UserAccounts + from lib389.pwpolicy import PwPolicyManager +@@ -1318,6 +1319,108 @@ def test_get_with_normalized_rid_dict(): + assert nrd.get('099') is None + + ++def test_online_init_no_duplicate_suffix(topo_m2, request): ++ """Total init must preserve tree order without sending the suffix twice ++ ++ :id: f0ccfb02-6c9d-48dd-9482-8a2c0763d485 ++ :setup: Two suppliers replication setup ++ :steps: ++ 1. Add a parent with more direct children than the ID list scan limit ++ 2. Move the subtree below a newer top-level ancestor ++ 3. Lower the ID list scan limit on supplier1 ++ 4. Perform online initialization from supplier1 to supplier2 ++ 5. Search supplier2 for entries with the suffix DN ++ 6. Compare the number of entries on both suppliers ++ :expectedresults: ++ 1. Success ++ 2. Success ++ 3. Success ++ 4. Success ++ 5. Exactly one entry has the suffix DN ++ 6. Both suppliers have the same number of entries ++ """ ++ m1 = topo_m2.ms["supplier1"] ++ m2 = topo_m2.ms["supplier2"] ++ ++ scan_limit = 100 ++ parent_rdn = 'ou=total-init-scanlimit' ++ ancestor_rdn = 'ou=total-init-ancestor' ++ original_parent_dn = f'{parent_rdn},{DEFAULT_SUFFIX}' ++ ancestor_dn = f'{ancestor_rdn},{DEFAULT_SUFFIX}' ++ moved_parent_dn = f'{parent_rdn},{ancestor_dn}' ++ db_config = DatabaseConfig(m1) ++ original_scan_limit = db_config.get_attr_vals_utf8('nsslapd-idlistscanlimit') ++ repl = ReplicationManager(DEFAULT_SUFFIX) ++ ++ def delete_test_subtrees(supplier): ++ deleted = False ++ for dn in (moved_parent_dn, original_parent_dn, ancestor_dn): ++ try: ++ supplier.delete_branch_s(dn, ldap.SCOPE_SUBTREE) ++ deleted = True ++ except ldap.NO_SUCH_OBJECT: ++ pass ++ return deleted ++ ++ def fin(): ++ for supplier in (m1, m2): ++ if not supplier.status(): ++ supplier.start() ++ db_config.set([('nsslapd-idlistscanlimit', original_scan_limit)]) ++ try: ++ if delete_test_subtrees(m1): ++ repl.wait_for_replication(m1, m2) ++ else: ++ delete_test_subtrees(m2) ++ except Exception as err: ++ log.warning("Replication cleanup failed, deleting supplier2 entries directly: %s", err) ++ delete_test_subtrees(m2) ++ ++ request.addfinalizer(fin) ++ ++ test_parent = OrganizationalUnits(m1, DEFAULT_SUFFIX).create( ++ properties={'ou': 'total-init-scanlimit'} ++ ) ++ # Force the parentid index lookup for this parent past the ALLIDS threshold. ++ test_children = OrganizationalUnits(m1, test_parent.dn) ++ for idx in range(scan_limit + 1): ++ last_child = test_children.create(properties={'ou': f'child{idx}'}) ++ ++ test_ancestor = OrganizationalUnits(m1, DEFAULT_SUFFIX).create( ++ properties={'ou': 'total-init-ancestor'} ++ ) ++ assert int(test_ancestor.get_attr_val_utf8('entryid')) > int( ++ last_child.get_attr_val_utf8('entryid') ++ ) ++ test_parent.rename(parent_rdn, newsuperior=test_ancestor.dn) ++ assert test_parent.dn.lower() == moved_parent_dn.lower() ++ assert repl.wait_for_replication(m1, m2) ++ ++ db_config.set([('nsslapd-idlistscanlimit', str(scan_limit))]) ++ assert db_config.get_attr_val_utf8('nsslapd-idlistscanlimit') == str(scan_limit) ++ ++ agmt = Agreements(m1).list()[0] ++ agmt.begin_reinit() ++ (done, error) = agmt.wait_reinit() ++ assert done is True ++ assert error is False ++ ++ # The consumer used to store the suffix entry twice: the supplier sent ++ # it explicitly and again as part of the bulk import candidate list ++ filter_all = '(|(objectclass=ldapsubentry)(objectclass=nstombstone)(nsuniqueid=*))' ++ m2entries = m2.search_s(DEFAULT_SUFFIX, ldap.SCOPE_SUBTREE, filter_all, ++ escapehatch='i am sure') ++ suffix_entries = [e for e in m2entries if e.dn.lower() == DEFAULT_SUFFIX.lower()] ++ log.info("%d entries with the suffix DN found on supplier2", len(suffix_entries)) ++ assert len(suffix_entries) == 1 ++ ++ m1entries = m1.search_s(DEFAULT_SUFFIX, ldap.SCOPE_SUBTREE, filter_all, ++ escapehatch='i am sure') ++ log.info("supplier1 has %d entries, supplier2 has %d entries", ++ len(m1entries), len(m2entries)) ++ assert len(m1entries) == len(m2entries) ++ ++ + def test_online_reinit_may_hang(topo_with_sigkill): + """Online reinitialization may hang when the first + entry of the DB is RUV entry instead of the suffix +diff --git a/ldap/servers/slapd/back-ldbm/db-mdb/mdb_import_threads.c b/ldap/servers/slapd/back-ldbm/db-mdb/mdb_import_threads.c +index 831f50b3d..16e6c8f35 100644 +--- a/ldap/servers/slapd/back-ldbm/db-mdb/mdb_import_threads.c ++++ b/ldap/servers/slapd/back-ldbm/db-mdb/mdb_import_threads.c +@@ -857,6 +857,16 @@ dbmdb_import_entry_info_by_param(EntryInfoParam_t *param, WorkerQueueData_t *wqe + } + + dnrc = get_entry_type(wqelmt, ¶m->sdn); ++ if (dnrc == DNRC_BAD_SUFFIX_ID && (param->flags & EIP_RDN)) { ++ /* In reindex mode the sdn contains only the RDN. A non-root record ++ * with an explicit parentid is therefore a regular entry even when ++ * its RDN equals a one-RDN suffix. */ ++ char *pidstr = NULL; ++ if (get_value_from_string(wqelmt->data, "parentid", &pidstr) == 0) { ++ slapi_ch_free_string(&pidstr); ++ dnrc = DNRC_OK; ++ } ++ } + if (dnrc == DNRC_SUFFIX) { + if ( param->eid != 1) { + dnrc = DNRC_BAD_SUFFIX_ID; +diff --git a/ldap/servers/slapd/back-ldbm/db-mdb/mdb_layer.c b/ldap/servers/slapd/back-ldbm/db-mdb/mdb_layer.c +index 1a273a2df..b364ec07a 100644 +--- a/ldap/servers/slapd/back-ldbm/db-mdb/mdb_layer.c ++++ b/ldap/servers/slapd/back-ldbm/db-mdb/mdb_layer.c +@@ -3005,7 +3005,7 @@ dbmdb_idl_new_fetch(backend *be, dbi_db_t *db, dbi_val_t *inkey, dbi_txn_t *txn, + } + } + +- if (allidslimit && count >= allidslimit) { ++ if ((NEW_IDL_NO_ALLID != *flag_err) && allidslimit && count >= allidslimit) { + idl = idl_allids(be); + slapi_log_err(SLAPI_LOG_TRACE, "dbmdb_idl_new_fetch", "%s returns allids (attribute: %s)\n", + (char *)key.mv_data, index_id); +diff --git a/ldap/servers/slapd/back-ldbm/idl_new.c b/ldap/servers/slapd/back-ldbm/idl_new.c +index 29457500e..0fa48ea5f 100644 +--- a/ldap/servers/slapd/back-ldbm/idl_new.c ++++ b/ldap/servers/slapd/back-ldbm/idl_new.c +@@ -468,14 +468,14 @@ idl_parentid_walk_push(ID **stack, size_t *stack_size, size_t *stack_top, ID id) + /* + * Walk the parentid index depth-first starting at root_id. + * For each parent, look up index key "=parent_id" (direct get, not cursor next), +- * append the parent to the IDList, then visit each child the same way. ++ * append each descendant (but not root_id) to the IDList, then visit its children. + */ + static int + idl_parentid_walk_tree(idl_parentid_walk_ctx_t *ctx, ID root_id, ID **stack, + size_t *stack_size, size_t *stack_top) + { + IDList *children = NULL; +- int fetch_err = NEW_IDL_NO_ALLID; ++ int fetch_err = 0; + size_t i; + + if (idl_parentid_walk_push(stack, stack_size, stack_top, root_id) != 0) { +@@ -489,13 +489,18 @@ idl_parentid_walk_tree(idl_parentid_walk_ctx_t *ctx, ID root_id, ID **stack, + return -1; + } + +- if (idl_append_extend(&ctx->idl, parent_id) != 0) { +- slapi_log_err(SLAPI_LOG_ERR, "idl_new_parentid_sorted_range_fetch", +- "Unable to extend id list for attribute (%s)\n", ctx->index_id); +- idl_free(&ctx->idl); +- return -1; ++ if (parent_id != root_id) { ++ /* The suffix entry itself is not a candidate: bulk import ++ * (total init) sends it separately before walking the tree, ++ * and the pre-walk implementation never returned it either. */ ++ if (idl_append_extend(&ctx->idl, parent_id) != 0) { ++ slapi_log_err(SLAPI_LOG_ERR, "idl_new_parentid_sorted_range_fetch", ++ "Unable to extend id list for attribute (%s)\n", ctx->index_id); ++ idl_free(&ctx->idl); ++ return -1; ++ } ++ ctx->count++; + } +- ctx->count++; + + #if defined(DB_ALLIDS_ON_READ) + if ((NEW_IDL_NO_ALLID != *(ctx->flag_err)) && ctx->ai && (ctx->idl != NULL) && +@@ -506,6 +511,10 @@ idl_parentid_walk_tree(idl_parentid_walk_ctx_t *ctx, ID root_id, ID **stack, + } + #endif + idl_parentid_set_index_key(ctx, parent_id); ++ /* idl_fetch_ext resets *err to 0 on success, so re-arm the ++ * no-allids hint for every fetch: collapsing one parent's ++ * children to ALLIDS would collapse the whole candidate list. */ ++ fetch_err = NEW_IDL_NO_ALLID; + children = idl_fetch_ext(ctx->be, ctx->db, &ctx->key, ctx->txn, ctx->ai, + &fetch_err, ctx->allidslimit); + if (fetch_err != 0 && fetch_err != DBI_RC_NOTFOUND) { +-- +2.54.0 + diff --git a/0014-Issue-7406-Fix-ldap-agent-SNMP-stats-file-loading-76.patch b/0014-Issue-7406-Fix-ldap-agent-SNMP-stats-file-loading-76.patch new file mode 100644 index 0000000..1160f62 --- /dev/null +++ b/0014-Issue-7406-Fix-ldap-agent-SNMP-stats-file-loading-76.patch @@ -0,0 +1,202 @@ +From edfd0352e31e5fd093aad8169ff251a9d6b5faff Mon Sep 17 00:00:00 2001 +From: Simon Pichugin +Date: Tue, 7 Jul 2026 17:26:30 -0700 +Subject: [PATCH] Issue 7406 - Fix ldap-agent SNMP stats file loading (#7630) + +Description: Fix ldap-agent stats file path construction so it opens the +instance .stats file instead of the truncated .stat path. +Move SNMP counter slot allocation after the configured worker thread count +is available so per-thread SNMP counter slots are created. + +Add SNMP test that checks bindSecurityErrors updates in cn=snmp,cn=monitor +and verifies ldap-agent loads the instance stats file used for SNMP counters. + +Fixes: https://github.com/389ds/389-ds-base/issues/7406 + +Reviewed by: @progier389 (Thanks!) + +(cherry picked from commit 5d575d37c6babcf2d051ecd70764aa98eb168bfc) +--- + .../tests/suites/snmp/regression_test.py | 137 ++++++++++++++++++ + ldap/servers/slapd/connection.c | 2 +- + ldap/servers/snmp/main.c | 2 +- + 3 files changed, 139 insertions(+), 2 deletions(-) + create mode 100644 dirsrvtests/tests/suites/snmp/regression_test.py + +diff --git a/dirsrvtests/tests/suites/snmp/regression_test.py b/dirsrvtests/tests/suites/snmp/regression_test.py +new file mode 100644 +index 000000000..5cc77b9d1 +--- /dev/null ++++ b/dirsrvtests/tests/suites/snmp/regression_test.py +@@ -0,0 +1,137 @@ ++# --- BEGIN COPYRIGHT BLOCK --- ++# Copyright (C) 2026 Red Hat, Inc. ++# All rights reserved. ++# ++# License: GPL (version 3 or any later version). ++# See LICENSE for details. ++# --- END COPYRIGHT BLOCK --- ++# ++ ++import logging ++import os ++from subprocess import check_output, PIPE, run ++import time ++ ++import ldap ++import pytest ++from lib389._constants import DN_DM, PW_DM ++from lib389.monitor import MonitorSNMP ++from test389.topologies import topology_st ++ ++ ++pytestmark = pytest.mark.tier1 ++ ++log = logging.getLogger(__name__) ++ ++ ++def get_bind_security_errors(inst): ++ return MonitorSNMP(inst).get_attr_val_int('bindsecurityerrors') ++ ++ ++def wait_for_bind_security_errors(inst, previous_value): ++ for _ in range(30): ++ current_value = get_bind_security_errors(inst) ++ if current_value > previous_value: ++ return current_value ++ time.sleep(1) ++ return get_bind_security_errors(inst) ++ ++ ++@pytest.fixture(scope="function") ++def ldapagent_config(topology_st, request): ++ """Creates an ldap-agent config for the standalone instance.""" ++ ++ var_dir = topology_st.standalone.get_local_state_dir() ++ config_file = os.path.join(topology_st.standalone.get_sysconf_dir(), 'dirsrv/config/agent.conf') ++ config = f"""agentx-supplier {var_dir}/agentx/supplier ++agent-logdir {var_dir}/log/dirsrv ++server slapd-{topology_st.standalone.serverid} ++""" ++ ++ with open(config_file, 'w') as agent_config_file: ++ agent_config_file.write(config) ++ ++ def fin(): ++ os.remove(config_file) ++ ++ request.addfinalizer(fin) ++ ++ return config_file ++ ++ ++def test_ldapagent_uses_instance_stats_file(topology_st, ldapagent_config): ++ """Tests that ldap-agent loads the instance stats file used for SNMP counters ++ ++ :id: 9bf83f16-922f-4505-8dc4-d22bd8e426ac ++ :setup: Standalone instance ++ :steps: ++ 1. Check the current bindSecurityErrors SNMP monitor counter. ++ 2. Perform an invalid Directory Manager bind. ++ 3. Check that bindSecurityErrors increased. ++ 4. Start ldap-agent with debug logging. ++ 5. Check that ldap-agent opens the instance .stats file. ++ 6. Check that ldap-agent does not open the truncated .stat file. ++ 7. Cleanup - Kill ldap-agent process. ++ :expectedresults: ++ 1. The initial bindSecurityErrors value should be readable. ++ 2. The invalid bind should fail. ++ 3. The bindSecurityErrors value should increase. ++ 4. ldap-agent should start. ++ 5. ldap-agent should open the full .stats file path used by ns-slapd. ++ 6. ldap-agent should not open or fail on the truncated .stat path. ++ 7. ldap-agent process should be successfully killed. ++ """ ++ ++ log.info('Running test_ldapagent_uses_instance_stats_file...') ++ ++ if not os.path.exists(os.path.join(topology_st.standalone.get_sbin_dir(), 'ldap-agent')): ++ pytest.skip("ldap-agent is not present") ++ ++ previous_bind_security_errors = get_bind_security_errors(topology_st.standalone) ++ with pytest.raises(ldap.INVALID_CREDENTIALS): ++ topology_st.standalone.simple_bind_s(DN_DM, 'badpassword') ++ topology_st.standalone.simple_bind_s(DN_DM, PW_DM) ++ assert wait_for_bind_security_errors(topology_st.standalone, previous_bind_security_errors) > previous_bind_security_errors ++ ++ run_dir = topology_st.standalone.get_run_dir() ++ pidpath = os.path.join(run_dir, 'ldap-agent.pid') ++ agent_log = os.path.join(topology_st.standalone.get_local_state_dir(), 'log', 'dirsrv', 'ldap-agent.log') ++ expected_stats_file = os.path.join(run_dir, f'slapd-{topology_st.standalone.serverid}.stats') ++ truncated_stats_file = expected_stats_file[:-1] ++ expected_log_line = f'Opening stats file ({expected_stats_file})' ++ pid = None ++ agent_log_content = '' ++ ++ if os.path.exists(agent_log): ++ os.remove(agent_log) ++ ++ try: ++ check_output([os.path.join(topology_st.standalone.get_sbin_dir(), 'ldap-agent'), '-D', ldapagent_config]) ++ ++ with open(pidpath, 'r') as pf: ++ pid = pf.readlines()[0].strip() ++ ++ for _ in range(30): ++ if os.path.exists(agent_log): ++ with open(agent_log, 'r') as lf: ++ agent_log_content = lf.read() ++ if expected_log_line in agent_log_content: ++ break ++ time.sleep(1) ++ ++ assert expected_log_line in agent_log_content ++ assert f'Opening stats file ({truncated_stats_file})' not in agent_log_content ++ assert f'Unable to open stats file ({truncated_stats_file})' not in agent_log_content ++ finally: ++ if pid: ++ log.debug('test_ldapagent_uses_instance_stats_file: Terminating agent %s', pid) ++ run(['kill', pid], stdout=PIPE, stderr=PIPE) ++ ++ log.info('test_ldapagent_uses_instance_stats_file: PASSED') ++ ++ ++if __name__ == '__main__': ++ # Run isolated ++ # -s for DEBUG mode ++ CURRENT_FILE = os.path.realpath(__file__) ++ pytest.main("-s %s" % CURRENT_FILE) +diff --git a/ldap/servers/slapd/connection.c b/ldap/servers/slapd/connection.c +index 6ddd21fcb..ff6d72372 100644 +--- a/ldap/servers/slapd/connection.c ++++ b/ldap/servers/slapd/connection.c +@@ -464,12 +464,12 @@ init_op_threads() + } + pthread_condattr_destroy(&condAttr); /* no longer needed */ + ++ max_threads = config_get_threadnumber(); + work_q_stack = PR_CreateStack("connection_work_q"); + op_stack = PR_CreateStack("connection_operation"); + alloc_per_thread_snmp_vars(max_threads); + init_thread_private_snmp_vars(); + +- max_threads = config_get_threadnumber(); + threads_indexes = (int32_t *) slapi_ch_calloc(max_threads, sizeof(int32_t)); + for (size_t i = 0; i < max_threads; i++) { + threads_indexes[i] = i + 1; /* idx 0 is reserved for global snmp_vars */ +diff --git a/ldap/servers/snmp/main.c b/ldap/servers/snmp/main.c +index 8cc803fd3..c8fd46868 100644 +--- a/ldap/servers/snmp/main.c ++++ b/ldap/servers/snmp/main.c +@@ -454,7 +454,7 @@ load_config(char *conf_path) + /* 8 = "/" + ".stats" + \0 */ + serv_p->stats_file = calloc(1, vlen + (instancename ? strlen(instancename) : 0) + 8); + if (serv_p->stats_file && instancename) { +- snprintf(serv_p->stats_file, vlen + strlen(instancename) + 7, ++ snprintf(serv_p->stats_file, vlen + strlen(instancename) + 8, + "%s/%s.stats", val, instancename); + } else { + printf("ldap-agent: malloc error processing config file\n"); +-- +2.54.0 + diff --git a/0015-Issue-7633-RFE-Add-offline-diagnostics-for-thread-po.patch b/0015-Issue-7633-RFE-Add-offline-diagnostics-for-thread-po.patch new file mode 100644 index 0000000..05c103d --- /dev/null +++ b/0015-Issue-7633-RFE-Add-offline-diagnostics-for-thread-po.patch @@ -0,0 +1,2595 @@ +From 8b28cff96f5b3f8b6a3b31a67036b3e0b23dcfca Mon Sep 17 00:00:00 2001 +From: Simon Pichugin +Date: Sun, 19 Jul 2026 22:15:12 -0700 +Subject: [PATCH] Issue 7633 - RFE - Add offline diagnostics for thread pool + saturation (#7634) + +Description: When the worker pool is fully saturated, cn=monitor cannot be used +for diagnostics because the monitor search itself needs a worker thread. + +Add offline thread-pool status reporting by publishing pool gauges and +per-worker activity into a hardened memory-mapped file under the instance run +directory. The new dsctl thread-pool-status command reads this file directly, +without an LDAP connection, so admins can inspect the pool even when worker +threads are exhausted. + +cn=monitor also exposes a sanitized threadpoolworker attribute backed by the +same data source. The feature is enabled by default and can be disabled with +the new nsslapd-thread-pool-stats cn=config attribute. Changing this setting +requires a restart. + +Fixes: https://github.com/389ds/389-ds-base/issues/7633 + +Reviewed by: @jchapma, @tbordaz, @mreynolds389 (Thanks!!!) + +(cherry picked from commit 05a17d0c6ca6bf22aab89b560b73d4c366ef2bbc) +--- + Makefile.am | 2 + + .../suites/monitor/threadpool_status_test.py | 800 ++++++++++++++++++ + ldap/schema/01core389.ldif | 1 + + ldap/servers/slapd/configdse.c | 1 + + ldap/servers/slapd/connection.c | 15 +- + ldap/servers/slapd/daemon.c | 9 +- + ldap/servers/slapd/fe.h | 2 +- + ldap/servers/slapd/libglobs.c | 36 + + ldap/servers/slapd/monitor.c | 2 + + ldap/servers/slapd/proto-slap.h | 2 + + ldap/servers/slapd/slap.h | 2 + + ldap/servers/slapd/threadpool_stats.c | 762 +++++++++++++++++ + ldap/servers/slapd/threadpool_stats.h | 94 ++ + src/lib389/cli/dsctl | 2 + + src/lib389/lib389/cli_ctl/threadpool.py | 468 ++++++++++ + src/lib389/lib389/monitor.py | 8 + + 16 files changed, 2202 insertions(+), 4 deletions(-) + create mode 100644 dirsrvtests/tests/suites/monitor/threadpool_status_test.py + create mode 100644 ldap/servers/slapd/threadpool_stats.c + create mode 100644 ldap/servers/slapd/threadpool_stats.h + create mode 100644 src/lib389/lib389/cli_ctl/threadpool.py + +diff --git a/Makefile.am b/Makefile.am +index ab4fad5b2..1986efc3b 100644 +--- a/Makefile.am ++++ b/Makefile.am +@@ -513,6 +513,7 @@ dist_noinst_HEADERS = \ + ldap/servers/slapd/snmp_collator.h \ + ldap/servers/slapd/sslerrstrs.h \ + ldap/servers/slapd/statechange.h \ ++ ldap/servers/slapd/threadpool_stats.h \ + ldap/servers/slapd/uuid.h \ + ldap/servers/slapd/vattr_spi.h \ + ldap/servers/slapd/views.h \ +@@ -1953,6 +1954,7 @@ ns_slapd_SOURCES = ldap/servers/slapd/abandon.c \ + ldap/servers/slapd/strdup.c \ + ldap/servers/slapd/stubs.c \ + ldap/servers/slapd/tempnam.c \ ++ ldap/servers/slapd/threadpool_stats.c \ + ldap/servers/slapd/unbind.c \ + ldap/servers/slapd/subentries.c + +diff --git a/dirsrvtests/tests/suites/monitor/threadpool_status_test.py b/dirsrvtests/tests/suites/monitor/threadpool_status_test.py +new file mode 100644 +index 000000000..d74a54d9c +--- /dev/null ++++ b/dirsrvtests/tests/suites/monitor/threadpool_status_test.py +@@ -0,0 +1,800 @@ ++# --- BEGIN COPYRIGHT BLOCK --- ++# Copyright (C) 2026 Red Hat, Inc. ++# All rights reserved. ++# ++# License: GPL (version 3 or any later version). ++# See LICENSE for details. ++# --- END COPYRIGHT BLOCK --- ++# ++ ++import json ++import logging ++import os ++import re ++import shutil ++import signal ++import stat ++import struct ++import subprocess ++import threading ++import time ++ ++import ldap ++import pytest ++ ++from lib389._constants import DEFAULT_SUFFIX, DN_CONFIG, DN_DM, PW_DM ++from lib389.cli_ctl.threadpool import (HEADER_FORMAT, TP_STATS_HEADER_SIZE, ++ TP_STATS_MAGIC, TP_STATS_WORKER_SLOT_SIZE) ++from lib389.dseldif import DSEldif ++from lib389.idm.account import Anonymous ++from lib389.idm.user import UserAccounts ++from lib389.monitor import Monitor ++from test389.topologies import topology_st as topo ++ ++ ++pytestmark = pytest.mark.tier1 ++ ++DEBUGGING = os.getenv("DEBUGGING", default=False) ++if DEBUGGING: ++ logging.getLogger(__name__).setLevel(logging.DEBUG) ++else: ++ logging.getLogger(__name__).setLevel(logging.INFO) ++log = logging.getLogger(__name__) ++ ++ ++def _threadpool_path(inst): ++ dse = DSEldif(inst) ++ rundir = dse.get(DN_CONFIG, "nsslapd-rundir", single=True, lower=True) ++ if rundir is None: ++ rundir = inst.ds_paths.run_dir ++ prefix = inst.serverid if inst.serverid.startswith("slapd-") else f"slapd-{inst.serverid}" ++ return os.path.join(rundir, f"{prefix}.monitor", "threadpool") ++ ++ ++def _monitor_dir(inst): ++ return os.path.dirname(_threadpool_path(inst)) ++ ++ ++def _ensure_monitor_dir(inst): ++ """Create the monitor dir owned like the run dir, for planting files while stopped""" ++ dirname = _monitor_dir(inst) ++ rundir_st = os.stat(os.path.dirname(dirname)) ++ os.makedirs(dirname, exist_ok=True) ++ os.chown(dirname, rundir_st.st_uid, rundir_st.st_gid) ++ return dirname ++ ++ ++def _wait_threadpool_file(inst, timeout=5): ++ path = _threadpool_path(inst) ++ deadline = time.time() + timeout ++ while time.time() < deadline: ++ if os.path.exists(path): ++ return path ++ time.sleep(0.1) ++ raise AssertionError(f"{path} was not created within {timeout}s") ++ ++ ++def _archive_paths(inst): ++ path = _threadpool_path(inst) ++ dirname, base = os.path.split(path) ++ pattern = re.compile(re.escape(base) + r"\.\d{8}-\d{6}$") ++ try: ++ names = sorted(name for name in os.listdir(dirname) if pattern.match(name)) ++ except OSError: ++ return [] ++ return [os.path.join(dirname, name) for name in names] ++ ++ ++def _purge_archives(inst): ++ for archive in _archive_paths(inst): ++ try: ++ os.unlink(archive) ++ except OSError: ++ pass ++ ++ ++def _run_dsctl_threadpool(inst, json_output=False, timeout=10, extra_args=None): ++ cmd = ["dsctl"] ++ if json_output: ++ cmd.append("-j") ++ cmd.extend([inst.serverid, "thread-pool", "status"]) ++ if extra_args: ++ cmd.extend(extra_args) ++ return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) ++ ++ ++def _cmd_output(result): ++ return f"{result.stdout}\n{result.stderr}" ++ ++ ++def _safe_unbind(conn): ++ try: ++ conn.unbind_s() ++ except ldap.LDAPError: ++ pass ++ ++ ++def _json_result(result): ++ assert result.returncode == 0, _cmd_output(result) ++ return json.loads(result.stdout) ++ ++ ++def _assert_monitor_values_sanitized(values): ++ assert values ++ pattern = re.compile(r"^worker=\d+ state=\w+ op=\w* duration_ns=\d+$") ++ for value in values: ++ assert pattern.match(value) ++ assert "conn=" not in value ++ assert "op_id=" not in value ++ ++ ++def test_file_created_mode(topo): ++ """The thread-pool mmap file is created with the expected mode and owner ++ ++ :id: 2bcb2478-b0e7-40e9-acdb-9a94f6039d00 ++ :setup: Standalone instance ++ :steps: ++ 1. Resolve the thread-pool mmap path from dse.ldif. ++ 2. Inspect the file and monitor directory metadata. ++ 3. Compare modes and owners with the runtime directory. ++ :expectedresults: ++ 1. The file exists. ++ 2. The file mode is 0640 and the monitor directory mode is 0750. ++ 3. The file and directory owners match the runtime directory owner. ++ """ ++ inst = topo.standalone ++ path = _wait_threadpool_file(inst) ++ ++ st = os.stat(path) ++ dir_st = os.stat(os.path.dirname(path)) ++ rundir_st = os.stat(os.path.dirname(os.path.dirname(path))) ++ assert stat.S_IMODE(st.st_mode) == 0o640 ++ assert stat.S_IMODE(dir_st.st_mode) == 0o750 ++ assert st.st_uid == rundir_st.st_uid ++ assert dir_st.st_uid == rundir_st.st_uid ++ ++ ++def test_file_unlinked_on_stop(topo): ++ """The thread-pool mmap file is removed during clean shutdown ++ ++ :id: 7ffa8107-2403-4e97-ac83-cf448cb01463 ++ :setup: Standalone instance ++ :steps: ++ 1. Resolve the thread-pool mmap path. ++ 2. Stop the instance. ++ 3. Run dsctl thread-pool status. ++ 4. Start the instance again. ++ :expectedresults: ++ 1. The file exists while running. ++ 2. The file is absent after stop. ++ 3. dsctl reports that the instance is not running. ++ 4. The instance is restored for later tests. ++ """ ++ inst = topo.standalone ++ path = _wait_threadpool_file(inst) ++ ++ inst.stop() ++ try: ++ assert not os.path.exists(path) ++ result = _run_dsctl_threadpool(inst) ++ assert result.returncode != 0 ++ assert "instance is not running" in _cmd_output(result).lower() ++ finally: ++ inst.start() ++ ++ ++def test_dsctl_basic_output(topo): ++ """dsctl thread-pool status reports text and JSON status ++ ++ :id: 3ba509a3-b347-4e81-a92c-04a1aa6ed17e ++ :setup: Standalone instance ++ :steps: ++ 1. Run dsctl thread-pool status. ++ 2. Run dsctl -j thread-pool status. ++ 3. Parse the JSON output. ++ :expectedresults: ++ 1. Text output includes pool gauges and a worker table. ++ 2. JSON output is valid. ++ 3. JSON output includes pool, worker, and warning fields. ++ """ ++ inst = topo.standalone ++ _wait_threadpool_file(inst) ++ ++ result = _run_dsctl_threadpool(inst) ++ assert result.returncode == 0, _cmd_output(result) ++ output = result.stdout ++ for expected in ("Instance:", "PID:", "Heartbeat age:", "Workers:", "Queue:", "Operations:", "IDX"): ++ assert expected in output ++ ++ data = _json_result(_run_dsctl_threadpool(inst, json_output=True)) ++ assert data["type"] == "result" ++ assert data["instance"] == inst.serverid ++ assert data["pool"]["max_workers"] >= 1 ++ assert isinstance(data["workers"], list) ++ assert isinstance(data["warnings"], list) ++ ++ ++def test_dsctl_under_saturation(topo): ++ """dsctl remains available while the worker pool is busy ++ ++ :id: 04f806c0-4a4f-4cce-ac58-a84297d5b04c ++ :setup: Standalone instance ++ :steps: ++ 1. Restart the instance with two worker threads. ++ 2. Add enough test users to make subtree searches non-trivial. ++ 3. Run concurrent searches: authenticated persistent-connection ++ loops plus fresh anonymous connections whose search is the ++ first operation (op 0) on each connection. ++ 4. Poll dsctl -j thread-pool status while searches are active. ++ 5. Restore the original thread count. ++ :expectedresults: ++ 1. The instance restarts. ++ 2. Test users are created. ++ 3. Searches run concurrently. ++ 4. dsctl reports a busy worker with operation detail, including ++ a worker running a first operation with op_id 0 and a duration. ++ 5. The instance is restored for later tests. ++ """ ++ inst = topo.standalone ++ original_threadnumber = inst.config.get_attr_val_utf8("nsslapd-threadnumber") ++ created = [] ++ stop_event = threading.Event() ++ search_threads = [] ++ search_errors = [] ++ ++ def _new_conn(): ++ conn = ldap.initialize(inst.ldapuri) ++ # Restart syscalls interrupted by stray signals (subprocess ++ # reaping, harness timers) instead of failing with SERVER_DOWN ++ conn.set_option(ldap.OPT_RESTART, ldap.OPT_ON) ++ return conn ++ ++ def search_worker(): ++ conn = None ++ try: ++ conn = _new_conn() ++ conn.simple_bind_s(DN_DM, PW_DM) ++ while not stop_event.is_set(): ++ conn.search_s(DEFAULT_SUFFIX, ldap.SCOPE_SUBTREE, "(objectclass=*)", ["1.1"]) ++ except ldap.LDAPError as e: ++ if not stop_event.is_set(): ++ search_errors.append(str(e)) ++ finally: ++ if conn is not None: ++ _safe_unbind(conn) ++ ++ def first_op_search_worker(): ++ # No bind: the search is the first operation (op 0) on each connection ++ try: ++ while not stop_event.is_set(): ++ conn = _new_conn() ++ try: ++ conn.search_s(DEFAULT_SUFFIX, ldap.SCOPE_SUBTREE, "(objectclass=*)", ["1.1"]) ++ finally: ++ _safe_unbind(conn) ++ except ldap.LDAPError as e: ++ if not stop_event.is_set(): ++ search_errors.append(str(e)) ++ ++ try: ++ inst.config.replace("nsslapd-threadnumber", "2") ++ inst.restart() ++ _wait_threadpool_file(inst) ++ ++ users = UserAccounts(inst, DEFAULT_SUFFIX) ++ base_uid = 700000 + (int(time.time()) % 100000) ++ for uid in range(base_uid, base_uid + 50): ++ created.append(users.create_test_user(uid=uid, gid=uid)) ++ ++ for target in [search_worker] * 4 + [first_op_search_worker] * 4: ++ thread = threading.Thread(target=target) ++ thread.daemon = True ++ thread.start() ++ search_threads.append(thread) ++ ++ busy_worker = None ++ op0_worker = None ++ last_data = None ++ # Generous deadline: each poll pays a full dsctl python startup, ++ # which can take seconds on slow or sanitizer builds. ++ deadline = time.time() + 30 ++ while time.time() < deadline and (busy_worker is None or op0_worker is None): ++ last_data = _json_result(_run_dsctl_threadpool(inst, json_output=True, timeout=15)) ++ for worker in last_data["workers"]: ++ if worker["state"] != "busy" or worker["op_id"] is None: ++ continue ++ if not worker["op"] or not worker["conn"]: ++ continue ++ if busy_worker is None: ++ busy_worker = worker ++ if op0_worker is None and worker["op_id"] == 0: ++ op0_worker = worker ++ time.sleep(0.25) ++ ++ assert not search_errors, search_errors ++ assert busy_worker is not None, last_data ++ assert busy_worker["duration_ns"] >= 0 ++ assert op0_worker is not None, last_data ++ assert op0_worker["op"] ++ assert op0_worker["duration_ns"] >= 0 ++ finally: ++ stop_event.set() ++ for thread in search_threads: ++ thread.join(timeout=2) ++ for user in created: ++ try: ++ user.delete() ++ except ldap.NO_SUCH_OBJECT: ++ pass ++ inst.config.replace("nsslapd-threadnumber", original_threadnumber) ++ inst.restart() ++ ++ ++def test_stale_file_after_kill(topo): ++ """dsctl reports a stale file after an unclean server exit ++ ++ :id: 4a865d53-3350-4f4a-8910-d30552f462b6 ++ :setup: Standalone instance ++ :steps: ++ 1. Read the server pid from dsctl JSON output. ++ 2. Kill the server process. ++ 3. Run dsctl thread-pool status against the leftover mmap file. ++ 4. Restart the instance. ++ :expectedresults: ++ 1. The pid is available. ++ 2. The server exits without clean mmap unlink. ++ 3. dsctl returns data with a stale-pid warning. ++ 4. The instance is restored for later tests. ++ """ ++ inst = topo.standalone ++ path = _wait_threadpool_file(inst) ++ data = _json_result(_run_dsctl_threadpool(inst, json_output=True)) ++ pid = data["pid"] ++ ++ os.kill(pid, signal.SIGKILL) ++ try: ++ deadline = time.time() + 10 ++ while time.time() < deadline and inst.status(): ++ time.sleep(0.2) ++ ++ assert os.path.exists(path) ++ result = _run_dsctl_threadpool(inst) ++ assert result.returncode == 0, _cmd_output(result) ++ assert "not running" in _cmd_output(result).lower() ++ finally: ++ if not inst.status(): ++ inst.start() ++ _purge_archives(inst) ++ ++ ++def test_symlink_rejected(topo): ++ """Symlinks at the mmap path are never followed by the writer or the reader ++ ++ :id: d833f584-7cbf-460d-8079-0687a00fd483 ++ :setup: Standalone instance ++ :steps: ++ 1. Stop the instance and place a symlink at the thread-pool mmap path. ++ 2. Start the instance. ++ 3. Check the outcome of the startup symlink handling. ++ 4. Stop the instance and place another symlink at the same path. ++ 5. Run dsctl thread-pool status. ++ 6. Clean up and restart the instance. ++ :expectedresults: ++ 1. The symlink is in place before startup. ++ 2. The instance starts. ++ 3. Either startup replaced the symlink with a regular mmap file, or ++ (with SELinux denying the unlink of a foreign-labeled symlink) ++ the feature failed safe: the symlink was not followed, a warning ++ was logged, and dsctl refuses the path. ++ 4. The symlink is in place for the reader. ++ 5. dsctl refuses the symlink. ++ 6. The instance is restored for later tests. ++ """ ++ inst = topo.standalone ++ path = _threadpool_path(inst) ++ decoy = os.path.join(_monitor_dir(inst), "threadpool-decoy") ++ ++ inst.stop() ++ try: ++ _ensure_monitor_dir(inst) ++ with open(decoy, "wb") as decoy_file: ++ decoy_file.truncate(4096) ++ ++ if os.path.lexists(path): ++ os.unlink(path) ++ os.symlink(decoy, path) ++ inst.start() ++ if os.path.islink(path): ++ # SELinux may deny ns-slapd unlinking a foreign-labeled symlink ++ # (tclass=lnk_file). The server must fail safe: never follow the ++ # symlink, disable the feature, and log a warning. ++ result = _run_dsctl_threadpool(inst) ++ assert result.returncode != 0 ++ assert "symlink" in _cmd_output(result).lower() ++ assert inst.ds_error_log.match(".*Could not remove stale thread-pool status.*") ++ else: ++ assert stat.S_ISREG(os.stat(path).st_mode) ++ ++ inst.stop() ++ if os.path.lexists(path): ++ os.unlink(path) ++ os.symlink(decoy, path) ++ result = _run_dsctl_threadpool(inst) ++ assert result.returncode != 0 ++ assert "symlink" in _cmd_output(result).lower() ++ finally: ++ if os.path.lexists(path): ++ os.unlink(path) ++ if os.path.exists(decoy): ++ os.unlink(decoy) ++ # Full restart either way: a failed-safe startup leaves the feature ++ # disabled and would leak into the following tests. ++ if inst.status(): ++ inst.restart() ++ else: ++ inst.start() ++ ++ ++def test_symlink_monitor_dir_rejected(topo): ++ """A symlink at the monitor directory path is refused at startup ++ ++ :id: 5b15d3e7-72e4-4d4c-ad79-3905fea1d0b7 ++ :setup: Standalone instance ++ :steps: ++ 1. Stop the instance and replace the monitor directory with a symlink ++ to a decoy directory. ++ 2. Start the instance. ++ 3. Check the errors log, the decoy directory, and dsctl output. ++ 4. Clean up and restart the instance. ++ :expectedresults: ++ 1. The symlink is in place before startup. ++ 2. The instance starts. ++ 3. The feature failed safe: the unsafe-directory warning is logged, ++ no status file was written into the decoy, and dsctl reports the ++ missing file. ++ 4. The instance is restored for later tests. ++ """ ++ inst = topo.standalone ++ monitor_dir = _monitor_dir(inst) ++ decoy_dir = os.path.join(os.path.dirname(monitor_dir), "monitor-decoy") ++ ++ inst.stop() ++ try: ++ os.makedirs(decoy_dir, exist_ok=True) ++ if os.path.islink(monitor_dir): ++ os.unlink(monitor_dir) ++ elif os.path.isdir(monitor_dir): ++ shutil.rmtree(monitor_dir) ++ os.symlink(decoy_dir, monitor_dir) ++ inst.start() ++ ++ assert os.path.islink(monitor_dir) ++ assert inst.ds_error_log.match(".*Refusing unsafe thread-pool monitor directory.*") ++ assert not os.path.exists(os.path.join(decoy_dir, "threadpool")) ++ result = _run_dsctl_threadpool(inst) ++ assert result.returncode != 0 ++ finally: ++ if os.path.islink(monitor_dir): ++ os.unlink(monitor_dir) ++ if os.path.isdir(decoy_dir): ++ shutil.rmtree(decoy_dir) ++ # A failed-safe startup leaves the feature disabled and would leak ++ # into the following tests. ++ if inst.status(): ++ inst.restart() ++ else: ++ inst.start() ++ ++ ++def test_monitor_attr_present(topo): ++ """cn=monitor exposes sanitized threadpoolworker values ++ ++ :id: 5090ac9b-e865-48ee-a185-2f8f047f82ca ++ :setup: Standalone instance ++ :steps: ++ 1. Read threadpoolworker through lib389 Monitor. ++ 2. Validate the key=value format. ++ :expectedresults: ++ 1. At least one value is returned. ++ 2. Values contain only worker, state, op, and duration_ns tokens. ++ """ ++ values = Monitor(topo.standalone).get_thread_pool_workers() ++ _assert_monitor_values_sanitized(values) ++ ++ ++def test_monitor_attr_sanitized(topo): ++ """Anonymous cn=monitor access exposes no connection or operation ids ++ ++ :id: a088e0b6-dcb5-43b0-ac1d-3b41bb393e95 ++ :setup: Standalone instance ++ :steps: ++ 1. Bind anonymously. ++ 2. Read threadpoolworker from cn=monitor. ++ 3. Validate the sanitized format. ++ :expectedresults: ++ 1. Anonymous bind succeeds. ++ 2. Values are returned. ++ 3. Values omit conn and op_id tokens. ++ """ ++ anon = Anonymous(topo.standalone).bind() ++ try: ++ values = Monitor(anon).get_thread_pool_workers() ++ _assert_monitor_values_sanitized(values) ++ finally: ++ anon.close() ++ ++ ++def test_feature_disabled_by_config(topo): ++ """nsslapd-thread-pool-stats: off disables the diagnostics after a restart ++ ++ :id: 867c1bae-6dd6-49d6-92dd-75804bc84510 ++ :setup: Standalone instance ++ :steps: ++ 1. Set nsslapd-thread-pool-stats to an invalid value. ++ 2. Set nsslapd-thread-pool-stats to off and run dsctl before restarting. ++ 3. Restart and check the mmap file, dsctl output, and cn=monitor. ++ 4. Set nsslapd-thread-pool-stats back to on and run dsctl before ++ restarting. ++ 5. Restart. ++ :expectedresults: ++ 1. The invalid value is rejected. ++ 2. dsctl still reports data with a restart-pending warning. ++ 3. The file is absent, dsctl explains why, and threadpoolworker is gone. ++ 4. dsctl fails with a message mentioning the missing restart. ++ 5. The feature is active again. ++ """ ++ inst = topo.standalone ++ path = _threadpool_path(inst) ++ ++ with pytest.raises(ldap.OPERATIONS_ERROR): ++ inst.config.replace("nsslapd-thread-pool-stats", "maybe") ++ ++ try: ++ inst.config.replace("nsslapd-thread-pool-stats", "off") ++ # The running server keeps publishing until it is restarted ++ data = _json_result(_run_dsctl_threadpool(inst, json_output=True)) ++ assert any("until it is restarted" in warning for warning in data["warnings"]) ++ inst.restart() ++ ++ assert not os.path.exists(path) ++ result = _run_dsctl_threadpool(inst) ++ assert result.returncode != 0 ++ assert "disabled by nsslapd-thread-pool-stats" in _cmd_output(result) ++ assert not Monitor(inst).get_thread_pool_workers() ++ ++ inst.config.replace("nsslapd-thread-pool-stats", "on") ++ # Enabled in cn=config, but the running server has no file yet ++ result = _run_dsctl_threadpool(inst) ++ assert result.returncode != 0 ++ assert "without a restart" in _cmd_output(result) ++ finally: ++ inst.config.replace("nsslapd-thread-pool-stats", "on") ++ inst.restart() ++ ++ _wait_threadpool_file(inst) ++ assert Monitor(inst).get_thread_pool_workers() ++ ++ ++def test_invalid_file_rejected(topo): ++ """dsctl refuses truncated and corrupted thread-pool status files ++ ++ :id: 421d1614-f57a-4775-afd8-45d3587cc923 ++ :setup: Standalone instance ++ :steps: ++ 1. Stop the instance. ++ 2. Place a truncated file at the thread-pool mmap path and run dsctl. ++ 3. Place a file with a corrupted magic and run dsctl. ++ 4. Clean up and start the instance. ++ :expectedresults: ++ 1. The instance is stopped. ++ 2. dsctl rejects the truncated file. ++ 3. dsctl rejects the corrupted magic. ++ 4. The instance is restored for later tests. ++ """ ++ inst = topo.standalone ++ path = _threadpool_path(inst) ++ ++ inst.stop() ++ try: ++ _ensure_monitor_dir(inst) ++ with open(path, "wb") as f: ++ f.write(b"\x00" * 100) ++ result = _run_dsctl_threadpool(inst) ++ assert result.returncode != 0 ++ assert "too short" in _cmd_output(result).lower() ++ ++ with open(path, "wb") as f: ++ f.write(b"\xff" * 8192) ++ result = _run_dsctl_threadpool(inst) ++ assert result.returncode != 0 ++ assert "magic" in _cmd_output(result).lower() ++ finally: ++ if os.path.exists(path): ++ os.unlink(path) ++ inst.start() ++ ++ ++def test_stale_heartbeat_warning(topo): ++ """dsctl warns when a live server stops updating the heartbeat ++ ++ :id: dcc1281d-c63a-4bbb-ab1d-ed5349e92858 ++ :setup: Standalone instance ++ :steps: ++ 1. Read the server pid from dsctl JSON output. ++ 2. Stop the process with SIGSTOP and wait past the staleness threshold. ++ 3. Run dsctl thread-pool status. ++ 4. Resume the process with SIGCONT. ++ :expectedresults: ++ 1. The pid is available. ++ 2. The heartbeat stops updating while the process stays alive. ++ 3. dsctl reports data with a stalled-server warning. ++ 4. The instance keeps running for later tests. ++ """ ++ inst = topo.standalone ++ _wait_threadpool_file(inst) ++ data = _json_result(_run_dsctl_threadpool(inst, json_output=True)) ++ pid = data["pid"] ++ ++ os.kill(pid, signal.SIGSTOP) ++ try: ++ time.sleep(31) ++ data = _json_result(_run_dsctl_threadpool(inst, json_output=True)) ++ assert any("may be stalled" in warning for warning in data["warnings"]) ++ assert data["heartbeat_age_sec"] > 30 ++ finally: ++ os.kill(pid, signal.SIGCONT) ++ ++ ++def test_crash_archive_created_after_kill(topo): ++ """A crash leftover is preserved as a timestamped archive on the next start ++ ++ :id: e7259a2b-6286-4e71-8c05-7bce3c8c9ab2 ++ :setup: Standalone instance ++ :steps: ++ 1. Remove existing archives and read the server pid from dsctl JSON output. ++ 2. Kill the server process and start the instance again. ++ 3. Check the live file, the archive count, and the errors log. ++ 4. Run dsctl thread-pool status against the running instance. ++ 5. Read the archive with dsctl thread-pool status --file. ++ :expectedresults: ++ 1. The pid is available. ++ 2. The instance starts. ++ 3. The live file is recreated, one archive exists, and the preserved ++ message is logged. ++ 4. The output warns that a crash archive is present. ++ 5. The archive reports the killed pid with a stale-file warning. ++ """ ++ inst = topo.standalone ++ _purge_archives(inst) ++ _wait_threadpool_file(inst) ++ data = _json_result(_run_dsctl_threadpool(inst, json_output=True)) ++ pid = data["pid"] ++ ++ try: ++ os.kill(pid, signal.SIGKILL) ++ deadline = time.time() + 10 ++ while time.time() < deadline and inst.status(): ++ time.sleep(0.2) ++ inst.start() ++ ++ _wait_threadpool_file(inst) ++ archives = _archive_paths(inst) ++ assert len(archives) == 1 ++ assert inst.ds_error_log.match(".*thread-pool status preserved as.*") ++ ++ data = _json_result(_run_dsctl_threadpool(inst, json_output=True)) ++ assert any("crash archive" in warning for warning in data["warnings"]) ++ ++ archive_data = _json_result( ++ _run_dsctl_threadpool(inst, json_output=True, extra_args=["--file", archives[0]]) ++ ) ++ assert archive_data["pid"] == pid ++ assert any("stale file" in warning for warning in archive_data["warnings"]) ++ finally: ++ if not inst.status(): ++ inst.start() ++ _purge_archives(inst) ++ ++ ++def test_no_archive_after_clean_restart(topo): ++ """A clean restart does not create a crash archive ++ ++ :id: c2bbb29f-1669-4cc6-9269-6a4e04559658 ++ :setup: Standalone instance ++ :steps: ++ 1. Remove existing archives. ++ 2. Restart the instance. ++ 3. Check for archives. ++ :expectedresults: ++ 1. No archives remain. ++ 2. The instance restarts. ++ 3. No archive was created. ++ """ ++ inst = topo.standalone ++ _purge_archives(inst) ++ inst.restart() ++ _wait_threadpool_file(inst) ++ assert _archive_paths(inst) == [] ++ ++ ++def test_archive_pruned_to_five(topo): ++ """Startup keeps at most five crash archives ++ ++ :id: d0a601d5-13ce-4114-8bb3-776bb20e65ff ++ :setup: Standalone instance ++ :steps: ++ 1. Stop the instance and remove existing archives. ++ 2. Plant seven dummy archives and a fabricated crash leftover at the ++ live path, owned by the server user. ++ 3. Start the instance. ++ 4. Count the archives. ++ :expectedresults: ++ 1. The instance is stopped. ++ 2. The files are in place. ++ 3. The instance starts and archives the leftover. ++ 4. Five archives remain: the four newest dummies plus the new one. ++ """ ++ inst = topo.standalone ++ path = _threadpool_path(inst) ++ dummies = [f"{path}.20250101-00000{i}" for i in range(7)] ++ ++ inst.stop() ++ _purge_archives(inst) ++ try: ++ _ensure_monitor_dir(inst) ++ dir_st = os.stat(os.path.dirname(path)) ++ for dummy in dummies: ++ with open(dummy, "wb") as f: ++ f.write(b"\x00") ++ ++ # A crash leftover the server will archive: valid magic, unclean shutdown ++ header = struct.pack(HEADER_FORMAT, TP_STATS_MAGIC, 1, 0, ++ TP_STATS_HEADER_SIZE, TP_STATS_WORKER_SLOT_SIZE, 1, ++ 0, 0, 0, 0, 0, ++ 0, 0, 0, 0, 0, 0, 0) ++ with open(path, "wb") as f: ++ f.write(header.ljust(TP_STATS_HEADER_SIZE + TP_STATS_WORKER_SLOT_SIZE, b"\x00")) ++ os.chown(path, dir_st.st_uid, dir_st.st_gid) ++ ++ inst.start() ++ ++ archives = [os.path.basename(archive) for archive in _archive_paths(inst)] ++ assert len(archives) == 5 ++ for dummy in dummies[:3]: ++ assert os.path.basename(dummy) not in archives ++ for dummy in dummies[3:]: ++ assert os.path.basename(dummy) in archives ++ finally: ++ if not inst.status(): ++ inst.start() ++ _purge_archives(inst) ++ ++ ++def test_dsctl_file_option(topo): ++ """dsctl thread-pool status --file reads an explicit status file path ++ ++ :id: c68f4cf1-8e82-487c-84cf-2ca2108ed898 ++ :setup: Standalone instance ++ :steps: ++ 1. Run dsctl thread-pool status --file with a nonexistent path. ++ 2. Run it with the live file path of the running instance. ++ :expectedresults: ++ 1. The command fails with a not-found error. ++ 2. The command succeeds and reports the pool. ++ """ ++ inst = topo.standalone ++ path = _wait_threadpool_file(inst) ++ ++ result = _run_dsctl_threadpool(inst, extra_args=["--file", "/nonexistent/threadpool"]) ++ assert result.returncode != 0 ++ assert "not found" in _cmd_output(result).lower() ++ ++ data = _json_result( ++ _run_dsctl_threadpool(inst, json_output=True, extra_args=["--file", path]) ++ ) ++ assert data["pool"]["max_workers"] >= 1 ++ ++ ++if __name__ == "__main__": ++ CURRENT_FILE = os.path.realpath(__file__) ++ pytest.main("-s %s" % CURRENT_FILE) +diff --git a/ldap/schema/01core389.ldif b/ldap/schema/01core389.ldif +index 7e2d1ac44..91dc25e3d 100644 +--- a/ldap/schema/01core389.ldif ++++ b/ldap/schema/01core389.ldif +@@ -334,6 +334,7 @@ attributeTypes: ( 2.16.840.1.113730.3.1.2393 NAME 'nsslapd-auditlog-display-attr + attributeTypes: ( 2.16.840.1.113730.3.1.2398 NAME 'nsslapd-haproxy-trusted-ip' DESC '389 Directory Server defined attribute type' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 X-ORIGIN '389 Directory Server' ) + attributeTypes: ( 2.16.840.1.113730.3.1.2400 NAME 'nsslapd-pwdPBKDF2NumIterations' DESC '389 Directory Server defined attribute type' SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE X-ORIGIN 'Directory Server' ) + attributeTypes: ( 2.16.840.1.113730.3.1.2402 NAME 'nsslapd-maxcontrolsperop' DESC '389 Directory Server defined attribute type' SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE X-ORIGIN '389 Directory Server' ) ++attributeTypes: ( 2.16.840.1.113730.3.1.2404 NAME 'nsslapd-thread-pool-stats' DESC '389 Directory Server defined attribute type' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE X-ORIGIN '389 Directory Server' ) + # + # objectclasses + # +diff --git a/ldap/servers/slapd/configdse.c b/ldap/servers/slapd/configdse.c +index eed046f2a..2be6036f2 100644 +--- a/ldap/servers/slapd/configdse.c ++++ b/ldap/servers/slapd/configdse.c +@@ -49,6 +49,7 @@ static const char *requires_restart[] = { + "cn=config:nsslapd-numlisteners", + "cn=config:" CONFIG_RETURN_EXACT_CASE_ATTRIBUTE, + "cn=config:" CONFIG_SCHEMA_IGNORE_TRAILING_SPACES, ++ "cn=config:" CONFIG_THREAD_POOL_STATS_ATTRIBUTE, + "cn=config,cn=ldbm:nsslapd-idlistscanlimit", + "cn=config,cn=ldbm:nsslapd-parentcheck", + "cn=config,cn=ldbm:nsslapd-dbcachesize", +diff --git a/ldap/servers/slapd/connection.c b/ldap/servers/slapd/connection.c +index ff6d72372..fd8826b66 100644 +--- a/ldap/servers/slapd/connection.c ++++ b/ldap/servers/slapd/connection.c +@@ -22,6 +22,7 @@ + #include "prcvar.h" + #include "prlog.h" /* for PR_ASSERT */ + #include "fe.h" ++#include "threadpool_stats.h" + #include + #include + #if defined(LINUX) +@@ -434,7 +435,7 @@ connection_reset(Connection *conn, int ns, PRNetAddr *from, int fromLen __attrib + + /* Create a pool of threads for handling the operations */ + void +-init_op_threads() ++init_op_threads(int32_t threadnumber) + { + pthread_condattr_t condAttr; + int32_t rc; +@@ -464,7 +465,7 @@ init_op_threads() + } + pthread_condattr_destroy(&condAttr); /* no longer needed */ + +- max_threads = config_get_threadnumber(); ++ max_threads = threadnumber; + work_q_stack = PR_CreateStack("connection_work_q"); + op_stack = PR_CreateStack("connection_operation"); + alloc_per_thread_snmp_vars(max_threads); +@@ -1710,6 +1711,7 @@ connection_threadmain(void *arg) + char tname[16]; + snprintf(tname, sizeof(tname), "worker-%d", *snmp_vars_idx); + slapi_set_thread_name(tname); ++ tp_stats_worker_idle((uint32_t)*snmp_vars_idx); + /* wait forever for new pb until one is available or shutdown */ + int32_t interval = 0; /* used be 10 seconds */ + Connection *conn = NULL; +@@ -1739,6 +1741,7 @@ connection_threadmain(void *arg) + if (is_busy) { + slapi_atomic_decr_32(¤t_busy_workers, __ATOMIC_ACQ_REL); + } ++ tp_stats_worker_exited((uint32_t)*snmp_vars_idx); + slapi_pblock_destroy(pb); + g_decr_active_threadcnt(); + return; +@@ -1752,6 +1755,7 @@ connection_threadmain(void *arg) + is_busy = false; + slapi_atomic_decr_32(¤t_busy_workers, __ATOMIC_ACQ_REL); + } ++ tp_stats_worker_idle((uint32_t)*snmp_vars_idx); + + /* If more data is left from the previous connection_read_operation, + we should finish the op now. Client might be thinking it's +@@ -1769,6 +1773,7 @@ connection_threadmain(void *arg) + if (is_busy) { + slapi_atomic_decr_32(¤t_busy_workers, __ATOMIC_ACQ_REL); + } ++ tp_stats_worker_exited((uint32_t)*snmp_vars_idx); + slapi_pblock_destroy(pb); + g_decr_active_threadcnt(); + return; +@@ -1784,6 +1789,7 @@ connection_threadmain(void *arg) + if (is_busy) { + slapi_atomic_decr_32(¤t_busy_workers, __ATOMIC_ACQ_REL); + } ++ tp_stats_worker_exited((uint32_t)*snmp_vars_idx); + slapi_pblock_destroy(pb); + g_decr_active_threadcnt(); + return; +@@ -1860,6 +1866,7 @@ connection_threadmain(void *arg) + while (val > slapi_atomic_load_32(&max_busy_workers, __ATOMIC_RELAXED)) { + slapi_atomic_store_32(&max_busy_workers, val, __ATOMIC_RELAXED); + } ++ tp_stats_worker_busy((uint32_t)*snmp_vars_idx); + } + slapi_pblock_get(pb, SLAPI_CONNECTION, &conn); + slapi_pblock_get(pb, SLAPI_OPERATION, &op); +@@ -1868,6 +1875,7 @@ connection_threadmain(void *arg) + if (is_busy) { + slapi_atomic_decr_32(¤t_busy_workers, __ATOMIC_ACQ_REL); + } ++ tp_stats_worker_exited((uint32_t)*snmp_vars_idx); + slapi_pblock_destroy(pb); + g_decr_active_threadcnt(); + return; +@@ -2067,6 +2075,7 @@ connection_threadmain(void *arg) + /* + * Call the do_ function to process this request. + */ ++ tp_stats_worker_operation_start((uint32_t)*snmp_vars_idx, conn->c_connid, (uint64_t)op->o_opid, (uint32_t)op->o_tag); + connection_dispatch_operation(conn, op, pb); + + done: +@@ -2074,6 +2083,7 @@ connection_threadmain(void *arg) + if (is_busy) { + slapi_atomic_decr_32(¤t_busy_workers, __ATOMIC_ACQ_REL); + } ++ tp_stats_worker_exited((uint32_t)*snmp_vars_idx); + pthread_mutex_lock(&(conn->c_mutex)); + connection_remove_operation_ext(pb, conn, op); + connection_make_readable_nolock(conn); +@@ -2097,6 +2107,7 @@ connection_threadmain(void *arg) + PR_AtomicIncrement(&conn->c_opscompleted); + /* total number of ops for the server */ + slapi_counter_increment(g_get_per_thread_snmp_vars()->server_tbl.dsOpCompleted); ++ tp_stats_worker_operation_done((uint32_t)*snmp_vars_idx); + /* If this op isn't a persistent search, remove it */ + if (op->o_flags & OP_FLAG_PS) { + /* Release the connection (i.e. decrease refcnt) at the condition +diff --git a/ldap/servers/slapd/daemon.c b/ldap/servers/slapd/daemon.c +index 42aea3df4..c99674896 100644 +--- a/ldap/servers/slapd/daemon.c ++++ b/ldap/servers/slapd/daemon.c +@@ -59,6 +59,7 @@ + #include "slap.h" + #include "slapi-plugin.h" + #include "snmp_collator.h" ++#include "threadpool_stats.h" + #include + #include + #include "fe.h" +@@ -1181,6 +1182,7 @@ slapd_daemon(daemon_ports_t *ports) + PRFileDesc **i_unix = NULL; + PRFileDesc **fdesp = NULL; + uint64_t threads; ++ int32_t threadnumber = config_get_threadnumber(); + int in_referral_mode = config_check_referral_mode(); + int connection_table_size = get_connection_table_size(); + the_connection_table = connection_table_new(connection_table_size); +@@ -1229,7 +1231,11 @@ slapd_daemon(daemon_ports_t *ports) + } + + init_ct_list_threads(); +- init_op_threads(); ++ tp_stats_init(threadnumber > 0 ? (uint32_t)threadnumber : 0); ++ init_op_threads(threadnumber); ++ /* Heartbeat must not start before init_op_threads: its callback reads ++ * per_thread_snmp_vars, which alloc_per_thread_snmp_vars reallocates. */ ++ tp_stats_start_heartbeat(); + + /* Start the SNMP collator if counters are enabled. */ + if (config_get_slapi_counters()) { +@@ -1479,6 +1485,7 @@ slapd_daemon(daemon_ports_t *ports) + pageresult_lock_cleanup(); + eq_stop(); /* deprecated */ + eq_stop_rel(); ++ tp_stats_close(); + if (!in_referral_mode) { + task_shutdown(); + uniqueIDGenCleanup(); +diff --git a/ldap/servers/slapd/fe.h b/ldap/servers/slapd/fe.h +index ad90dba2e..716a913bf 100644 +--- a/ldap/servers/slapd/fe.h ++++ b/ldap/servers/slapd/fe.h +@@ -58,7 +58,7 @@ void connection_post_shutdown_cleanup(void); + */ + void connection_abandon_operations(Connection *conn); + int connection_activity(Connection *conn, int maxthreads); +-void init_op_threads(void); ++void init_op_threads(int32_t threadnumber); + int connection_new_private(Connection *conn); + void connection_remove_operation(Connection *conn, Operation *op); + void connection_remove_operation_ext(Slapi_PBlock *pb, Connection *conn, Operation *op); +diff --git a/ldap/servers/slapd/libglobs.c b/ldap/servers/slapd/libglobs.c +index 3b031052f..1456ad21c 100644 +--- a/ldap/servers/slapd/libglobs.c ++++ b/ldap/servers/slapd/libglobs.c +@@ -245,6 +245,7 @@ slapi_onoff_t init_close_on_failed_bind; + slapi_onoff_t init_minssf_exclude_rootdse; + slapi_onoff_t init_force_sasl_external; + slapi_onoff_t init_slapi_counters; ++slapi_onoff_t init_thread_pool_stats; + slapi_onoff_t init_entryusn_global; + slapi_onoff_t init_disk_monitoring; + slapi_onoff_t init_disk_threshold_readonly; +@@ -956,6 +957,11 @@ static struct config_get_and_set + (void **)&global_slapdFrontendConfig.slapi_counters, + CONFIG_ON_OFF, (ConfigGetFunc)config_get_slapi_counters, + &init_slapi_counters, NULL}, ++ {CONFIG_THREAD_POOL_STATS_ATTRIBUTE, config_set_thread_pool_stats, ++ NULL, 0, ++ (void **)&global_slapdFrontendConfig.thread_pool_stats, ++ CONFIG_ON_OFF, (ConfigGetFunc)config_get_thread_pool_stats, ++ &init_thread_pool_stats, NULL}, + {CONFIG_ACCESSLOG_MINFREEDISKSPACE_ATTRIBUTE, NULL, + log_set_mindiskspace, SLAPD_ACCESS_LOG, + (void **)&global_slapdFrontendConfig.accesslog_minfreespace, +@@ -1867,6 +1873,7 @@ FrontendConfig_init(void) + init_close_on_failed_bind = cfg->close_on_failed_bind = LDAP_OFF; + cfg->allow_anon_access = SLAPD_DEFAULT_ALLOW_ANON_ACCESS; + init_slapi_counters = cfg->slapi_counters = LDAP_ON; ++ init_thread_pool_stats = cfg->thread_pool_stats = LDAP_ON; + cfg->threadnumber = util_get_hardware_threads(); + cfg->maxthreadsperconn = SLAPD_DEFAULT_MAX_THREADS_PER_CONN; + cfg->reservedescriptors = SLAPD_DEFAULT_RESERVE_FDS; +@@ -2239,6 +2246,11 @@ alloc_global_snmp_vars() + + /* Allocated the next slots of the arrays of counters + * with a slot per worker thread ++ * ++ * Must complete before any reader of per_thread_snmp_vars starts (worker ++ * threads, snmp collator, thread-pool stats heartbeat): the slot count and ++ * the array pointer are published without synchronization, and the realloc ++ * frees the old array under a concurrent reader. + */ + void + alloc_per_thread_snmp_vars(int32_t maxthread) +@@ -3448,6 +3460,23 @@ config_set_slapi_counters(const char *attrname, char *value, char *errorbuf, int + return retVal; + } + ++/* ++ * Enable/disable the thread-pool status diagnostics (mmap file, "dsctl ++ * thread-pool status", threadpoolworker on cn=monitor). Read once at ++ * startup; changing it requires a restart. ++ */ ++int32_t ++config_set_thread_pool_stats(const char *attrname, char *value, char *errorbuf, int apply) ++{ ++ int32_t retVal = LDAP_SUCCESS; ++ slapdFrontendConfig_t *slapdFrontendConfig = getFrontendConfig(); ++ ++ retVal = config_set_onoff(attrname, value, ++ &(slapdFrontendConfig->thread_pool_stats), errorbuf, apply); ++ ++ return retVal; ++} ++ + int + config_set_securelistenhost(const char *attrname __attribute__((unused)), char *value, char *errorbuf __attribute__((unused)), int apply) + { +@@ -6345,6 +6374,13 @@ config_get_slapi_counters() + + } + ++int32_t ++config_get_thread_pool_stats(void) ++{ ++ slapdFrontendConfig_t *slapdFrontendConfig = getFrontendConfig(); ++ return slapi_atomic_load_32(&(slapdFrontendConfig->thread_pool_stats), __ATOMIC_ACQUIRE); ++} ++ + char * + config_get_workingdir(void) + { +diff --git a/ldap/servers/slapd/monitor.c b/ldap/servers/slapd/monitor.c +index f9a85cfbb..2f024557e 100644 +--- a/ldap/servers/slapd/monitor.c ++++ b/ldap/servers/slapd/monitor.c +@@ -30,6 +30,7 @@ + #include + #include "slap.h" + #include "fe.h" ++#include "threadpool_stats.h" + + int32_t + monitor_info(Slapi_PBlock *pb __attribute__((unused)), +@@ -61,6 +62,7 @@ monitor_info(Slapi_PBlock *pb __attribute__((unused)), + attrlist_replace(&e->e_attrs, "threads", vals); + + connection_table_as_entry(the_connection_table, e); ++ tp_stats_as_entry(e); + + val.bv_len = snprintf(buf, sizeof(buf), "%" PRIu64, g_get_num_ops_initiated()); + val.bv_val = buf; +diff --git a/ldap/servers/slapd/proto-slap.h b/ldap/servers/slapd/proto-slap.h +index 9c82eabf8..0d0f98cc8 100644 +--- a/ldap/servers/slapd/proto-slap.h ++++ b/ldap/servers/slapd/proto-slap.h +@@ -261,6 +261,7 @@ int config_set_ldapi_auto_dn_suffix(const char *attrname, char *value, char *err + #endif + int config_set_anon_limits_dn(const char *attrname, char *value, char *errorbuf, int apply); + int config_set_slapi_counters(const char *attrname, char *value, char *errorbuf, int apply); ++int32_t config_set_thread_pool_stats(const char *attrname, char *value, char *errorbuf, int apply); + int config_set_srvtab(const char *attrname, char *value, char *errorbuf, int apply); + int config_set_sizelimit(const char *attrname, char *value, char *errorbuf, int apply); + int config_set_pagedsizelimit(const char *attrname, char *value, char *errorbuf, int apply); +@@ -453,6 +454,7 @@ char *config_get_ldapi_auto_dn_suffix(void); + #endif + char *config_get_anon_limits_dn(void); + int config_get_slapi_counters(void); ++int32_t config_get_thread_pool_stats(void); + char *config_get_srvtab(void); + int config_get_sizelimit(void); + int config_get_pagedsizelimit(void); +diff --git a/ldap/servers/slapd/slap.h b/ldap/servers/slapd/slap.h +index c968d1898..0a209921c 100644 +--- a/ldap/servers/slapd/slap.h ++++ b/ldap/servers/slapd/slap.h +@@ -2298,6 +2298,7 @@ typedef struct _slapdEntryPoints + #define CONFIG_LDAPI_AUTH_DN_ATTRIBUTE "nsslapd-authenticateAsDN" + #define CONFIG_ANON_LIMITS_DN_ATTRIBUTE "nsslapd-anonlimitsdn" + #define CONFIG_SLAPI_COUNTER_ATTRIBUTE "nsslapd-counters" ++#define CONFIG_THREAD_POOL_STATS_ATTRIBUTE "nsslapd-thread-pool-stats" + #define CONFIG_SECURITY_ATTRIBUTE "nsslapd-security" + #define CONFIG_SSL3CIPHERS_ATTRIBUTE "nsslapd-SSL3ciphers" + #define CONFIG_ACCESSLOG_ATTRIBUTE "nsslapd-accesslog" +@@ -2726,6 +2727,7 @@ typedef struct _slapdFrontendConfig + char *ldapi_auto_dn_suffix; /* suffix to be appended to auto gen DNs */ + char *ldapi_auto_mapping_base; /* suffix/subtree containing LDAPI mapping entries */ + slapi_onoff_t slapi_counters; /* switch to turn slapi_counters on/off */ ++ slapi_onoff_t thread_pool_stats; /* switch to turn thread-pool status diagnostics on/off */ + slapi_onoff_t allow_unauth_binds; /* switch to enable/disable unauthenticated binds */ + slapi_onoff_t require_secure_binds; /* switch to require simple binds to use a secure channel */ + slapi_onoff_t allow_anon_access; /* switch to enable/disable anonymous access */ +diff --git a/ldap/servers/slapd/threadpool_stats.c b/ldap/servers/slapd/threadpool_stats.c +new file mode 100644 +index 000000000..4e8aad1c5 +--- /dev/null ++++ b/ldap/servers/slapd/threadpool_stats.c +@@ -0,0 +1,762 @@ ++/** BEGIN COPYRIGHT BLOCK ++ * Copyright (C) 2026 Red Hat, Inc. ++ * All rights reserved. ++ * ++ * License: GPL (version 3 or any later version). ++ * See LICENSE for details. ++ * END COPYRIGHT BLOCK **/ ++ ++#ifdef HAVE_CONFIG_H ++#include ++#endif ++ ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++ ++#include "slap.h" ++#include "plstr.h" ++#include "threadpool_stats.h" ++ ++#define TP_STATS_COMPONENT "threadpool_stats" ++#define TP_STATS_DIR_SUFFIX ".monitor" ++#define TP_STATS_FILENAME "threadpool" ++#define TP_STATS_HEARTBEAT_INTERVAL_MS 1000 ++#define TP_STATS_ARCHIVE_KEEP 5 ++ ++/* ++ * Crash archives are named .YYYYMMDD-HHMMSS; the suffix is produced ++ * by strftime(TP_STATS_ARCHIVE_TIME_FMT). The lib389 reader matches the ++ * same pattern (\.\d{8}-\d{6}$). ++ */ ++#define TP_STATS_ARCHIVE_TIME_FMT "%Y%m%d-%H%M%S" ++#define TP_STATS_ARCHIVE_DATE_LEN (sizeof("YYYYMMDD") - 1) ++#define TP_STATS_ARCHIVE_STAMP_LEN (sizeof("YYYYMMDD-HHMMSS") - 1) ++ ++static tp_stats_header_t *tp_stats_header = NULL; ++static int tp_stats_fd = -1; ++static size_t tp_stats_len = 0; ++static uint32_t tp_stats_max_workers = 0; ++static char *tp_stats_path = NULL; ++static Slapi_Eq_Context tp_stats_eq_ctx = NULL; ++ ++static uint64_t ++tp_stats_mono_ns(void) ++{ ++ struct timespec ts = slapi_current_rel_time_hr(); ++ return ((uint64_t)ts.tv_sec * 1000000000ULL) + (uint64_t)ts.tv_nsec; ++} ++ ++static void ++tp_store_32(uint32_t *ptr, uint32_t val, int memorder) ++{ ++ slapi_atomic_store_32((int32_t *)ptr, (int32_t)val, memorder); ++} ++ ++static uint32_t ++tp_load_32(uint32_t *ptr, int memorder) ++{ ++ return (uint32_t)slapi_atomic_load_32((int32_t *)ptr, memorder); ++} ++ ++static tp_worker_slot_t * ++tp_stats_slot_at(tp_stats_header_t *header, size_t idx) ++{ ++ return (tp_worker_slot_t *)((uint8_t *)header + ++ TP_STATS_HEADER_SIZE + ++ (idx * TP_STATS_WORKER_SLOT_SIZE)); ++} ++ ++static tp_worker_slot_t * ++tp_stats_get_slot(uint32_t worker_idx) ++{ ++ if (tp_stats_header == NULL || worker_idx == 0 || worker_idx > tp_stats_max_workers) { ++ return NULL; ++ } ++ ++ return tp_stats_slot_at(tp_stats_header, worker_idx - 1); ++} ++ ++static const char * ++tp_stats_state_name(uint32_t state) ++{ ++ switch (state) { ++ case TP_WORKER_STATE_UNUSED: ++ return "unused"; ++ case TP_WORKER_STATE_IDLE: ++ return "idle"; ++ case TP_WORKER_STATE_BUSY: ++ return "busy"; ++ case TP_WORKER_STATE_EXITED: ++ return "exited"; ++ default: ++ return "unknown"; ++ } ++} ++ ++static const char * ++tp_stats_op_name(uint32_t op_tag, char *buf, size_t buflen) ++{ ++ switch ((ber_tag_t)op_tag) { ++ case 0: ++ return ""; ++ case LDAP_REQ_BIND: ++ return "bind"; ++ case LDAP_REQ_UNBIND: ++ return "unbind"; ++ case LDAP_REQ_SEARCH: ++ return "search"; ++ case LDAP_REQ_MODIFY: ++ return "modify"; ++ case LDAP_REQ_ADD: ++ return "add"; ++ case LDAP_REQ_DELETE: ++ return "delete"; ++ case LDAP_REQ_MODRDN: ++ return "modrdn"; ++ case LDAP_REQ_COMPARE: ++ return "compare"; ++ case LDAP_REQ_ABANDON: ++ return "abandon"; ++ case LDAP_REQ_EXTENDED: ++ return "extended"; ++ default: ++ snprintf(buf, buflen, "%" PRIu32, op_tag); ++ return buf; ++ } ++} ++ ++/* ++ * /slapd-.monitor/threadpool, matching what the dsctl ++ * reader derives from the instance config. Returns NULL when rundir or ++ * the slapd- name cannot be resolved: a file at any other path ++ * would be unreachable for the reader, so the caller disables the ++ * feature instead. ++ */ ++static char * ++tp_stats_make_path(void) ++{ ++ char *rundir = config_get_rundir(); ++ char *configdir = config_get_configdir(); ++ char *instname = NULL; ++ char *path = NULL; ++ ++ if (configdir != NULL) { ++ instname = PL_strrstr(configdir, "slapd-"); ++ } ++ ++ if (rundir != NULL && instname != NULL) { ++ path = slapi_ch_smprintf("%s/%s%s/%s", rundir, instname, ++ TP_STATS_DIR_SUFFIX, TP_STATS_FILENAME); ++ } ++ slapi_ch_free_string(&rundir); ++ slapi_ch_free_string(&configdir); ++ return path; ++} ++ ++/* ++ * Create the per-instance monitor directory the status file lives in. ++ * A pre-existing entry is accepted only when lstat says it is a real ++ * directory owned by the server (a planted symlink fails the check). ++ * The directory is never removed at shutdown: crash archives stay in it. ++ */ ++static int ++tp_stats_prepare_dir(const char *path) ++{ ++ const char *slash = strrchr(path, '/'); ++ struct stat st = {0}; ++ char *dir = NULL; ++ int rc = -1; ++ ++ if (slash == NULL) { ++ return -1; ++ } ++ dir = slapi_ch_smprintf("%.*s", (int)(slash - path), path); ++ ++ if (mkdir(dir, 0750) != 0) { ++ if (errno != EEXIST) { ++ int err = errno; ++ slapi_log_err(SLAPI_LOG_WARNING, TP_STATS_COMPONENT, ++ "Could not create thread-pool monitor directory %s: %d (%s)\n", ++ dir, err, slapd_system_strerror(err)); ++ goto done; ++ } ++ if (lstat(dir, &st) != 0 || !S_ISDIR(st.st_mode) || st.st_uid != geteuid()) { ++ slapi_log_err(SLAPI_LOG_WARNING, TP_STATS_COMPONENT, ++ "Refusing unsafe thread-pool monitor directory %s (mode=%o uid=%ld)\n", ++ dir, (unsigned int)st.st_mode, (long)st.st_uid); ++ goto done; ++ } ++ } ++ ++ if (chmod(dir, 0750) != 0) { ++ int err = errno; ++ slapi_log_err(SLAPI_LOG_WARNING, TP_STATS_COMPONENT, ++ "Could not set permissions on thread-pool monitor directory %s: %d (%s)\n", ++ dir, err, slapd_system_strerror(err)); ++ goto done; ++ } ++ rc = 0; ++ ++done: ++ slapi_ch_free_string(&dir); ++ return rc; ++} ++ ++static int ++tp_stats_name_cmp(const void *a, const void *b) ++{ ++ return strcmp(*(char *const *)a, *(char *const *)b); ++} ++ ++/* The expected suffix format is 'YYYYMMDD-HHMMSS' */ ++static bool ++tp_stats_is_archive_suffix(const char *suffix) ++{ ++ if (suffix == NULL || strlen(suffix) != TP_STATS_ARCHIVE_STAMP_LEN || ++ suffix[TP_STATS_ARCHIVE_DATE_LEN] != '-') { ++ return false; ++ } ++ for (size_t i = 0; i < TP_STATS_ARCHIVE_STAMP_LEN; i++) { ++ /* Every character is a digit except the '-' after the date part */ ++ if (i != TP_STATS_ARCHIVE_DATE_LEN && !isdigit((unsigned char)suffix[i])) { ++ return false; ++ } ++ } ++ return true; ++} ++ ++/* Remove the oldest archives so at most TP_STATS_ARCHIVE_KEEP remain */ ++static void ++tp_stats_prune_archives(const char *path) ++{ ++ const char *slash = strrchr(path, '/'); ++ const char *base = NULL; ++ size_t baselen; ++ char *dir = NULL; ++ DIR *dirp = NULL; ++ struct dirent *entry = NULL; ++ char **names = NULL; ++ size_t count = 0; ++ ++ if (slash == NULL) { ++ return; ++ } ++ base = slash + 1; ++ baselen = strlen(base); ++ dir = slapi_ch_smprintf("%.*s", (int)(slash - path), path); ++ dirp = opendir(dir); ++ if (dirp == NULL) { ++ slapi_ch_free_string(&dir); ++ return; ++ } ++ ++ while ((entry = readdir(dirp)) != NULL) { ++ const char *name = entry->d_name; ++ if (strncmp(name, base, baselen) != 0 || name[baselen] != '.' || ++ !tp_stats_is_archive_suffix(name + baselen + 1)) { ++ continue; ++ } ++ names = (char **)slapi_ch_realloc((char *)names, (count + 1) * sizeof(char *)); ++ names[count++] = slapi_ch_strdup(name); ++ } ++ closedir(dirp); ++ ++ if (count > TP_STATS_ARCHIVE_KEEP) { ++ /* The timestamp suffix sorts lexicographically in time order */ ++ qsort(names, count, sizeof(char *), tp_stats_name_cmp); ++ for (size_t i = 0; i < count - TP_STATS_ARCHIVE_KEEP; i++) { ++ char *victim = slapi_ch_smprintf("%s/%s", dir, names[i]); ++ if (unlink(victim) != 0) { ++ int err = errno; ++ slapi_log_err(SLAPI_LOG_WARNING, TP_STATS_COMPONENT, ++ "Could not remove old thread-pool status archive %s: %d (%s)\n", ++ victim, err, slapd_system_strerror(err)); ++ } else { ++ slapi_log_err(SLAPI_LOG_INFO, TP_STATS_COMPONENT, ++ "Removed old thread-pool status archive %s\n", victim); ++ } ++ slapi_ch_free_string(&victim); ++ } ++ } ++ ++ for (size_t i = 0; i < count; i++) { ++ slapi_ch_free_string(&names[i]); ++ } ++ slapi_ch_free((void **)&names); ++ slapi_ch_free_string(&dir); ++} ++ ++/* ++ * Preserve a leftover status file from a crashed previous run by renaming ++ * it to .YYYYMMDD-HHMMSS (the rotated-log naming). Never unlinks and ++ * never fails startup: on any failure it returns having done nothing and ++ * the caller's unlink handles the leftover as before. Only a genuine crash ++ * leftover is preserved: the magic must match and shutdown_clean must be ++ * unset (tp_stats_close sets it before attempting the unlink). ++ */ ++static void ++tp_stats_archive_crash_file(const char *path) ++{ ++ tp_stats_header_t hdr = {0}; ++ struct stat st = {0}; ++ struct tm tms = {0}; ++ char tbuf[32] = {0}; ++ char *archive = NULL; ++ time_t now; ++ ssize_t nread; ++ int fd; ++ ++ fd = open(path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC); ++ if (fd < 0) { ++ return; ++ } ++ if (fstat(fd, &st) != 0 || !S_ISREG(st.st_mode) || st.st_uid != geteuid() || ++ st.st_nlink != 1 || st.st_size < (off_t)TP_STATS_HEADER_SIZE) { ++ close(fd); ++ return; ++ } ++ nread = pread(fd, &hdr, sizeof(hdr), 0); ++ close(fd); ++ if (nread != (ssize_t)sizeof(hdr) || hdr.magic != TP_STATS_MAGIC || ++ hdr.shutdown_clean != 0) { ++ return; ++ } ++ ++ now = slapi_current_utc_time(); ++ if (localtime_r(&now, &tms) == NULL || ++ strftime(tbuf, sizeof(tbuf), TP_STATS_ARCHIVE_TIME_FMT, &tms) == 0) { ++ return; ++ } ++ ++ archive = slapi_ch_smprintf("%s.%s", path, tbuf); ++ if (rename(path, archive) != 0) { ++ int err = errno; ++ slapi_log_err(SLAPI_LOG_WARNING, TP_STATS_COMPONENT, ++ "Could not preserve thread-pool status file %s from unclean shutdown: " ++ "%d (%s); removing it\n", ++ path, err, slapd_system_strerror(err)); ++ slapi_ch_free_string(&archive); ++ return; ++ } ++ ++ slapi_log_err(SLAPI_LOG_NOTICE, TP_STATS_COMPONENT, ++ "Previous server run did not shut down cleanly; " ++ "thread-pool status preserved as %s\n", ++ archive); ++ slapi_ch_free_string(&archive); ++ tp_stats_prune_archives(path); ++} ++ ++static void ++tp_stats_cleanup_open_failure(int fd, char **path) ++{ ++ if (fd >= 0) { ++ close(fd); ++ } ++ if (path != NULL && *path != NULL) { ++ unlink(*path); ++ slapi_ch_free_string(path); ++ } ++} ++ ++void ++tp_collect_gauges(tp_gauges_t *out) ++{ ++ long cur_connections; ++ ++ if (out == NULL) { ++ return; ++ } ++ ++ out->cur_work_queue = (uint64_t)get_work_q_size(); ++ out->max_work_queue = (uint64_t)get_work_q_size_max(); ++ out->cur_busy_workers = (uint64_t)get_busy_worker_count(); ++ out->max_busy_workers = (uint64_t)get_max_busy_worker_count(); ++ out->ops_initiated = (uint64_t)g_get_num_ops_initiated(); ++ out->ops_completed = (uint64_t)g_get_num_ops_completed(); ++ ++ cur_connections = g_get_current_conn_count(); ++ out->cur_connections = cur_connections > 0 ? (uint64_t)cur_connections : 0; ++} ++ ++static void ++tp_stats_publish_gauges(tp_stats_header_t *header, tp_gauges_t *gauges) ++{ ++ slapi_atomic_store_64(&header->cur_work_queue, gauges->cur_work_queue, __ATOMIC_RELAXED); ++ slapi_atomic_store_64(&header->max_work_queue, gauges->max_work_queue, __ATOMIC_RELAXED); ++ slapi_atomic_store_64(&header->cur_busy_workers, gauges->cur_busy_workers, __ATOMIC_RELAXED); ++ slapi_atomic_store_64(&header->max_busy_workers, gauges->max_busy_workers, __ATOMIC_RELAXED); ++ slapi_atomic_store_64(&header->ops_initiated, gauges->ops_initiated, __ATOMIC_RELAXED); ++ slapi_atomic_store_64(&header->ops_completed, gauges->ops_completed, __ATOMIC_RELAXED); ++ slapi_atomic_store_64(&header->cur_connections, gauges->cur_connections, __ATOMIC_RELAXED); ++} ++ ++static void ++tp_stats_heartbeat(time_t when __attribute__((unused)), void *arg __attribute__((unused))) ++{ ++ tp_gauges_t gauges = {0}; ++ tp_stats_header_t *header = tp_stats_header; ++ ++ if (header == NULL) { ++ return; ++ } ++ ++ tp_collect_gauges(&gauges); ++ tp_stats_publish_gauges(header, &gauges); ++ slapi_atomic_store_64(&header->heartbeat_wall_sec, (uint64_t)slapi_current_utc_time(), __ATOMIC_RELAXED); ++ slapi_atomic_store_64(&header->heartbeat_mono_ns, tp_stats_mono_ns(), __ATOMIC_RELEASE); ++} ++ ++int ++tp_stats_init(uint32_t max_workers) ++{ ++ tp_stats_header_t *header = NULL; ++ void *mapping = MAP_FAILED; ++ struct stat st = {0}; ++ char *path = NULL; ++ int fd = -1; ++ int rc; ++ size_t len; ++ ++ if (!config_get_thread_pool_stats()) { ++ slapi_log_err(SLAPI_LOG_INFO, TP_STATS_COMPONENT, ++ "Thread-pool status diagnostics disabled by " CONFIG_THREAD_POOL_STATS_ATTRIBUTE "\n"); ++ return 0; ++ } ++ ++ if (max_workers == 0) { ++ slapi_log_err(SLAPI_LOG_WARNING, TP_STATS_COMPONENT, ++ "Thread-pool status mmap disabled: worker count is zero\n"); ++ return -1; ++ } ++ ++ if (tp_stats_header != NULL) { ++ return 0; ++ } ++ ++ len = TP_STATS_HEADER_SIZE + ((size_t)max_workers * TP_STATS_WORKER_SLOT_SIZE); ++ path = tp_stats_make_path(); ++ if (path == NULL) { ++ slapi_log_err(SLAPI_LOG_WARNING, TP_STATS_COMPONENT, ++ "Thread-pool status mmap disabled: could not resolve runtime path\n"); ++ return -1; ++ } ++ ++ if (tp_stats_prepare_dir(path) != 0) { ++ slapi_ch_free_string(&path); ++ return -1; ++ } ++ ++ /* Best effort: on any failure the leftover falls through to the unlink below */ ++ tp_stats_archive_crash_file(path); ++ ++ if (unlink(path) != 0 && errno != ENOENT) { ++ int err = errno; ++ struct stat lst = {0}; ++ const char *kind = "file"; ++ ++ if (lstat(path, &lst) == 0 && S_ISLNK(lst.st_mode)) { ++ kind = "symlink"; ++ } ++ slapi_log_err(SLAPI_LOG_WARNING, TP_STATS_COMPONENT, ++ "Could not remove stale thread-pool status %s %s: %d (%s). " ++ "Possible SELinux denial; thread-pool status diagnostics are disabled\n", ++ kind, path, err, slapd_system_strerror(err)); ++ slapi_ch_free_string(&path); ++ return -1; ++ } ++ ++ fd = open(path, O_RDWR | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0640); ++ if (fd < 0) { ++ int err = errno; ++ slapi_log_err(SLAPI_LOG_WARNING, TP_STATS_COMPONENT, ++ "Could not create thread-pool status file %s: %d (%s)\n", ++ path, err, slapd_system_strerror(err)); ++ tp_stats_cleanup_open_failure(fd, &path); ++ return -1; ++ } ++ ++ if (fstat(fd, &st) != 0) { ++ int err = errno; ++ slapi_log_err(SLAPI_LOG_WARNING, TP_STATS_COMPONENT, ++ "Could not inspect thread-pool status file %s: %d (%s)\n", ++ path, err, slapd_system_strerror(err)); ++ tp_stats_cleanup_open_failure(fd, &path); ++ return -1; ++ } ++ ++ if (!S_ISREG(st.st_mode) || st.st_uid != geteuid() || st.st_nlink != 1) { ++ slapi_log_err(SLAPI_LOG_WARNING, TP_STATS_COMPONENT, ++ "Refusing unsafe thread-pool status file %s (mode=%o uid=%ld nlink=%ld)\n", ++ path, (unsigned int)st.st_mode, (long)st.st_uid, (long)st.st_nlink); ++ tp_stats_cleanup_open_failure(fd, &path); ++ return -1; ++ } ++ ++ if (fchmod(fd, 0640) != 0) { ++ int err = errno; ++ slapi_log_err(SLAPI_LOG_WARNING, TP_STATS_COMPONENT, ++ "Could not set permissions on thread-pool status file %s: %d (%s)\n", ++ path, err, slapd_system_strerror(err)); ++ tp_stats_cleanup_open_failure(fd, &path); ++ return -1; ++ } ++ ++ /* ++ * Reserve backing pages up front: ftruncate alone leaves a sparse file, ++ * and a store into an unbacked page takes SIGBUS when the filesystem is ++ * full. With the reservation, slot and heartbeat writes can never fault. ++ * posix_fallocate returns the error code instead of setting errno. ++ */ ++ rc = posix_fallocate(fd, 0, (off_t)len); ++ if (rc == EOPNOTSUPP || rc == EINVAL) { ++ /* Filesystem without fallocate support: fall back to a sparse file. */ ++ if (ftruncate(fd, (off_t)len) != 0) { ++ int err = errno; ++ slapi_log_err(SLAPI_LOG_WARNING, TP_STATS_COMPONENT, ++ "Could not size thread-pool status file %s: %d (%s)\n", ++ path, err, slapd_system_strerror(err)); ++ tp_stats_cleanup_open_failure(fd, &path); ++ return -1; ++ } ++ } else if (rc != 0) { ++ slapi_log_err(SLAPI_LOG_WARNING, TP_STATS_COMPONENT, ++ "Could not reserve space for thread-pool status file %s: %d (%s)\n", ++ path, rc, slapd_system_strerror(rc)); ++ tp_stats_cleanup_open_failure(fd, &path); ++ return -1; ++ } ++ ++ mapping = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); ++ if (mapping == MAP_FAILED) { ++ int err = errno; ++ slapi_log_err(SLAPI_LOG_WARNING, TP_STATS_COMPONENT, ++ "Could not map thread-pool status file %s: %d (%s)\n", ++ path, err, slapd_system_strerror(err)); ++ tp_stats_cleanup_open_failure(fd, &path); ++ return -1; ++ } ++ ++ memset(mapping, 0, len); ++ header = (tp_stats_header_t *)mapping; ++ header->ver_major = TP_STATS_VER_MAJOR; ++ header->ver_minor = TP_STATS_VER_MINOR; ++ header->header_size = TP_STATS_HEADER_SIZE; ++ header->worker_slot_size = TP_STATS_WORKER_SLOT_SIZE; ++ header->max_workers = max_workers; ++ header->server_pid = (uint64_t)getpid(); ++ header->start_wall_sec = (uint64_t)slapi_current_utc_time(); ++ ++ tp_stats_header = header; ++ tp_stats_fd = fd; ++ tp_stats_len = len; ++ tp_stats_max_workers = max_workers; ++ tp_stats_path = path; ++ ++ tp_stats_heartbeat(0, NULL); ++ slapi_atomic_store_64(&header->magic, TP_STATS_MAGIC, __ATOMIC_RELEASE); ++ ++ slapi_log_err(SLAPI_LOG_INFO, TP_STATS_COMPONENT, ++ "Thread-pool status mmap ready at %s (%zu bytes, %" PRIu32 " workers)\n", ++ tp_stats_path, tp_stats_len, max_workers); ++ return 0; ++} ++ ++/* ++ * Register the periodic heartbeat. Must run only after init_op_threads(): ++ * the callback reads per_thread_snmp_vars through g_get_num_ops_initiated(), ++ * and alloc_per_thread_snmp_vars() reallocates that array with no ++ * synchronization against readers. ++ */ ++void ++tp_stats_start_heartbeat(void) ++{ ++ if (tp_stats_header == NULL || tp_stats_eq_ctx != NULL) { ++ return; ++ } ++ ++ tp_stats_eq_ctx = slapi_eq_repeat_rel(tp_stats_heartbeat, NULL, ++ slapi_current_rel_time_t(), ++ TP_STATS_HEARTBEAT_INTERVAL_MS); ++ if (tp_stats_eq_ctx == NULL) { ++ slapi_log_err(SLAPI_LOG_WARNING, TP_STATS_COMPONENT, ++ "Thread-pool status file %s was created, but heartbeat registration failed\n", ++ tp_stats_path); ++ } ++} ++ ++void ++tp_stats_close(void) ++{ ++ tp_stats_header_t *header = tp_stats_header; ++ ++ if (header == NULL) { ++ return; ++ } ++ ++ if (tp_stats_eq_ctx != NULL) { ++ slapi_eq_cancel_rel(tp_stats_eq_ctx); ++ tp_stats_eq_ctx = NULL; ++ } ++ ++ tp_store_32(&header->shutdown_clean, 1, __ATOMIC_RELEASE); ++ ++ if (tp_stats_path != NULL) { ++ if (unlink(tp_stats_path) != 0 && errno != ENOENT) { ++ int err = errno; ++ slapi_log_err(SLAPI_LOG_WARNING, TP_STATS_COMPONENT, ++ "Could not remove thread-pool status file %s: %d (%s)\n", ++ tp_stats_path, err, slapd_system_strerror(err)); ++ } ++ slapi_ch_free_string(&tp_stats_path); ++ } ++ ++ if (tp_stats_fd >= 0) { ++ close(tp_stats_fd); ++ tp_stats_fd = -1; ++ } ++ ++ /* ++ * Do not munmap: worker threads are unjoinable and a late slot write into ++ * an unmapped region would crash shutdown. The mapping dies with ns-slapd. ++ */ ++ tp_stats_header = NULL; ++ tp_stats_len = 0; ++ tp_stats_max_workers = 0; ++} ++ ++void ++tp_stats_worker_idle(uint32_t worker_idx) ++{ ++ tp_worker_slot_t *slot = tp_stats_get_slot(worker_idx); ++ ++ if (slot == NULL) { ++ return; ++ } ++ ++ slapi_atomic_store_64(&slot->conn_id, 0, __ATOMIC_RELAXED); ++ slapi_atomic_store_64(&slot->op_id, 0, __ATOMIC_RELAXED); ++ tp_store_32(&slot->op_tag, 0, __ATOMIC_RELAXED); ++ slapi_atomic_store_64(&slot->start_ns, 0, __ATOMIC_RELAXED); ++ tp_store_32(&slot->state, TP_WORKER_STATE_IDLE, __ATOMIC_RELEASE); ++} ++ ++void ++tp_stats_worker_busy(uint32_t worker_idx) ++{ ++ tp_worker_slot_t *slot = tp_stats_get_slot(worker_idx); ++ ++ if (slot == NULL) { ++ return; ++ } ++ ++ tp_store_32(&slot->state, TP_WORKER_STATE_BUSY, __ATOMIC_RELEASE); ++} ++ ++void ++tp_stats_worker_operation_start(uint32_t worker_idx, uint64_t conn_id, uint64_t op_id, uint32_t op_tag) ++{ ++ tp_worker_slot_t *slot = tp_stats_get_slot(worker_idx); ++ ++ if (slot == NULL) { ++ return; ++ } ++ ++ slapi_atomic_store_64(&slot->conn_id, conn_id, __ATOMIC_RELAXED); ++ slapi_atomic_store_64(&slot->op_id, op_id, __ATOMIC_RELAXED); ++ tp_store_32(&slot->op_tag, op_tag, __ATOMIC_RELAXED); ++ slapi_atomic_store_64(&slot->start_ns, tp_stats_mono_ns(), __ATOMIC_RELAXED); ++ tp_store_32(&slot->state, TP_WORKER_STATE_BUSY, __ATOMIC_RELEASE); ++} ++ ++void ++tp_stats_worker_operation_done(uint32_t worker_idx) ++{ ++ tp_worker_slot_t *slot = tp_stats_get_slot(worker_idx); ++ ++ if (slot == NULL) { ++ return; ++ } ++ ++ /* ++ * start_ns is the in-flight sentinel (op_id 0 is a valid first op on a ++ * connection): clear it first so a reader that still sees it set finds ++ * the op fields intact. ++ */ ++ slapi_atomic_store_64(&slot->start_ns, 0, __ATOMIC_RELAXED); ++ tp_store_32(&slot->op_tag, 0, __ATOMIC_RELAXED); ++ slapi_atomic_store_64(&slot->op_id, 0, __ATOMIC_RELAXED); ++} ++ ++void ++tp_stats_worker_exited(uint32_t worker_idx) ++{ ++ tp_worker_slot_t *slot = tp_stats_get_slot(worker_idx); ++ ++ if (slot == NULL) { ++ return; ++ } ++ ++ tp_store_32(&slot->state, TP_WORKER_STATE_EXITED, __ATOMIC_RELEASE); ++} ++ ++void ++tp_stats_as_entry(Slapi_Entry *e) ++{ ++ tp_stats_header_t *header = tp_stats_header; ++ struct berval val; ++ struct berval *vals[2]; ++ uint64_t now_ns; ++ ++ vals[0] = &val; ++ vals[1] = NULL; ++ attrlist_delete(&e->e_attrs, TP_STATS_ATTR_THREADPOOL_WORKER); ++ ++ if (header == NULL) { ++ return; ++ } ++ ++ now_ns = tp_stats_mono_ns(); ++ for (size_t i = 0; i < header->max_workers; i++) { ++ char buf[256]; ++ char op_buf[32]; ++ uint32_t state; ++ uint32_t op_tag; ++ uint64_t start_ns; ++ uint64_t duration_ns = 0; ++ const char *op_name; ++ ++ tp_worker_slot_t *slot = tp_stats_slot_at(header, i); ++ ++ state = tp_load_32(&slot->state, __ATOMIC_ACQUIRE); ++ if (state == TP_WORKER_STATE_UNUSED) { ++ continue; ++ } ++ ++ op_tag = tp_load_32(&slot->op_tag, __ATOMIC_RELAXED); ++ start_ns = slapi_atomic_load_64(&slot->start_ns, __ATOMIC_RELAXED); ++ /* start_ns is the in-flight sentinel; op_id 0 is a valid first op */ ++ if (start_ns != 0 && now_ns >= start_ns) { ++ duration_ns = now_ns - start_ns; ++ } ++ ++ op_name = tp_stats_op_name(op_tag, op_buf, sizeof(op_buf)); ++ snprintf(buf, sizeof(buf), ++ "worker=%zu state=%s op=%s duration_ns=%" PRIu64, ++ i + 1, tp_stats_state_name(state), op_name, duration_ns); ++ val.bv_val = buf; ++ val.bv_len = strlen(buf); ++ attrlist_merge(&e->e_attrs, TP_STATS_ATTR_THREADPOOL_WORKER, vals); ++ } ++} +diff --git a/ldap/servers/slapd/threadpool_stats.h b/ldap/servers/slapd/threadpool_stats.h +new file mode 100644 +index 000000000..4f30ef098 +--- /dev/null ++++ b/ldap/servers/slapd/threadpool_stats.h +@@ -0,0 +1,94 @@ ++/** BEGIN COPYRIGHT BLOCK ++ * Copyright (C) 2026 Red Hat, Inc. ++ * All rights reserved. ++ * ++ * License: GPL (version 3 or any later version). ++ * See LICENSE for details. ++ * END COPYRIGHT BLOCK **/ ++ ++#pragma once ++ ++#include ++ ++typedef struct slapi_entry Slapi_Entry; ++ ++#define TP_STATS_MAGIC 0x54504f4f4c535431ULL /* "TPOOLST1" */ ++#define TP_STATS_VER_MAJOR 1 ++#define TP_STATS_VER_MINOR 0 ++#define TP_STATS_HEADER_SIZE 4096 ++#define TP_STATS_WORKER_SLOT_SIZE 64 ++#define TP_STATS_ATTR_THREADPOOL_WORKER "threadpoolworker" ++ ++typedef enum { ++ TP_WORKER_STATE_UNUSED = 0, ++ TP_WORKER_STATE_IDLE = 1, ++ TP_WORKER_STATE_BUSY = 2, ++ TP_WORKER_STATE_EXITED = 3, ++} tp_worker_state_t; ++ ++typedef struct { ++ uint64_t cur_work_queue; ++ uint64_t max_work_queue; ++ uint64_t cur_busy_workers; ++ uint64_t max_busy_workers; ++ uint64_t ops_initiated; ++ uint64_t ops_completed; ++ uint64_t cur_connections; ++} tp_gauges_t; ++ ++/* ++ * Thread-pool status mmap ABI. ++ * ++ * The file is machine-local only: all integers are host-endian, fixed width, ++ * and naturally aligned. It contains no time_t, pointers, strings, DNs, IPs, ++ * filters, or other request content. ++ * ++ * start_ns doubles as the operation-in-flight sentinel. op_id 0 is a valid ++ * value (the first operation on a connection) and must not be used as one. ++ */ ++typedef struct __attribute__((aligned(64))) tp_worker_slot { ++ uint32_t state; ++ uint32_t op_tag; ++ uint64_t conn_id; ++ uint64_t op_id; ++ uint64_t start_ns; ++} tp_worker_slot_t; ++ ++typedef struct tp_stats_header { ++ uint64_t magic; ++ uint16_t ver_major; ++ uint16_t ver_minor; ++ uint32_t header_size; ++ uint32_t worker_slot_size; ++ uint32_t max_workers; ++ uint64_t server_pid; ++ uint64_t start_wall_sec; ++ uint64_t heartbeat_mono_ns; ++ uint64_t heartbeat_wall_sec; ++ uint32_t shutdown_clean; ++ uint32_t pad0; ++ uint64_t cur_work_queue; ++ uint64_t max_work_queue; ++ uint64_t cur_busy_workers; ++ uint64_t max_busy_workers; ++ uint64_t ops_initiated; ++ uint64_t ops_completed; ++ uint64_t cur_connections; ++ uint8_t reserved[3976]; ++} tp_stats_header_t; ++ ++_Static_assert(sizeof(tp_worker_slot_t) == TP_STATS_WORKER_SLOT_SIZE, ++ "tp_worker_slot_t size must remain ABI-stable"); ++_Static_assert(sizeof(tp_stats_header_t) == TP_STATS_HEADER_SIZE, ++ "tp_stats_header_t size must remain ABI-stable"); ++ ++void tp_collect_gauges(tp_gauges_t *out); ++int tp_stats_init(uint32_t max_workers); ++void tp_stats_start_heartbeat(void); ++void tp_stats_close(void); ++void tp_stats_worker_idle(uint32_t worker_idx); ++void tp_stats_worker_busy(uint32_t worker_idx); ++void tp_stats_worker_operation_start(uint32_t worker_idx, uint64_t conn_id, uint64_t op_id, uint32_t op_tag); ++void tp_stats_worker_operation_done(uint32_t worker_idx); ++void tp_stats_worker_exited(uint32_t worker_idx); ++void tp_stats_as_entry(Slapi_Entry *e); +diff --git a/src/lib389/cli/dsctl b/src/lib389/cli/dsctl +index 9ab830c69..9028f4acd 100755 +--- a/src/lib389/cli/dsctl ++++ b/src/lib389/cli/dsctl +@@ -26,6 +26,7 @@ from lib389.cli_ctl import dbgen as cli_dbgen + from lib389.cli_ctl import dsrc as cli_dsrc + from lib389.cli_ctl import cockpit as cli_cockpit + from lib389.cli_ctl import dblib as cli_dblib ++from lib389.cli_ctl import threadpool as cli_threadpool + from lib389.cli_ctl.instance import instance_remove_all + from lib389.cli_base import ( + disconnect_instance, +@@ -61,6 +62,7 @@ cli_dbgen.create_parser(subparsers) + cli_dsrc.create_parser(subparsers) + cli_cockpit.create_parser(subparsers) + cli_dblib.create_parser(subparsers) ++cli_threadpool.create_parser(subparsers) + + argcomplete.autocomplete(parser) + +diff --git a/src/lib389/lib389/cli_ctl/threadpool.py b/src/lib389/lib389/cli_ctl/threadpool.py +new file mode 100644 +index 000000000..009740a1c +--- /dev/null ++++ b/src/lib389/lib389/cli_ctl/threadpool.py +@@ -0,0 +1,468 @@ ++# --- BEGIN COPYRIGHT BLOCK --- ++# Copyright (C) 2026 Red Hat, Inc. ++# All rights reserved. ++# ++# License: GPL (version 3 or any later version). ++# See LICENSE for details. ++# --- END COPYRIGHT BLOCK --- ++ ++import errno ++import json ++import mmap ++import os ++import re ++import stat ++import struct ++import time ++ ++import psutil ++ ++from lib389._constants import DN_CONFIG ++from lib389.cli_base import CustomHelpFormatter ++from lib389.dseldif import DSEldif ++ ++ ++# File format constants; they mirror ldap/servers/slapd/threadpool_stats.h ++# and must stay in sync with it. ++TP_STATS_MAGIC = 0x54504f4f4c535431 # "TPOOLST1" ++TP_STATS_VER_MAJOR = 1 ++TP_STATS_HEADER_SIZE = 4096 ++TP_STATS_WORKER_SLOT_SIZE = 64 ++ ++# Byte-for-byte mirror of tp_stats_header_t. Each entry is ++# (field name, struct format char); "4x" skips the C struct's pad0 field. ++HEADER_FIELDS = [ ++ ("magic", "Q"), ++ ("ver_major", "H"), ++ ("ver_minor", "H"), ++ ("header_size", "I"), ++ ("worker_slot_size", "I"), ++ ("max_workers", "I"), ++ ("server_pid", "Q"), ++ ("start_wall_sec", "Q"), ++ ("heartbeat_mono_ns", "Q"), ++ ("heartbeat_wall_sec", "Q"), ++ ("shutdown_clean", "I"), ++ (None, "4x"), ++ ("cur_work_queue", "Q"), ++ ("max_work_queue", "Q"), ++ ("cur_busy_workers", "Q"), ++ ("max_busy_workers", "Q"), ++ ("ops_initiated", "Q"), ++ ("ops_completed", "Q"), ++ ("cur_connections", "Q"), ++] ++HEADER_FORMAT = "@" + "".join(fmt for _, fmt in HEADER_FIELDS) ++HEADER_NAMES = [name for name, _ in HEADER_FIELDS if name] ++ ++# Byte-for-byte mirror of tp_worker_slot_t; the slot is padded to ++# TP_STATS_WORKER_SLOT_SIZE by its alignment. ++WORKER_FIELDS = [ ++ ("state", "I"), ++ ("op_tag", "I"), ++ ("conn_id", "Q"), ++ ("op_id", "Q"), ++ ("start_ns", "Q"), ++] ++WORKER_FORMAT = "@" + "".join(fmt for _, fmt in WORKER_FIELDS) ++ ++# Python counterpart of the _Static_asserts in threadpool_stats.h ++assert struct.calcsize(HEADER_FORMAT) <= TP_STATS_HEADER_SIZE ++assert struct.calcsize(WORKER_FORMAT) <= TP_STATS_WORKER_SLOT_SIZE ++ ++# Reader-side staleness heuristics, not part of the file format ++NS_PER_SEC = 1_000_000_000 ++STALE_HEARTBEAT_NS = 30 * NS_PER_SEC ++IMPLAUSIBLE_HEARTBEAT_NS = 365 * 24 * 3600 * NS_PER_SEC ++ ++# tp_worker_state_t values ++STATE_NAMES = { ++ 0: "unused", ++ 1: "idle", ++ 2: "busy", ++ 3: "exited", ++} ++ ++# LDAP protocol request tags (LDAP_REQ_* in ldap.h) ++OP_NAMES = { ++ 0x60: "bind", ++ 0x42: "unbind", ++ 0x63: "search", ++ 0x66: "modify", ++ 0x68: "add", ++ 0x4A: "delete", ++ 0x6C: "modrdn", ++ 0x6E: "compare", ++ 0x50: "abandon", ++ 0x77: "extended", ++} ++ ++ ++def _server_file_prefix(serverid): ++ if serverid.startswith("slapd-"): ++ return serverid ++ return f"slapd-{serverid}" ++ ++ ++def _crash_archives(path): ++ """Crash archives preserved for the status file at path, oldest first""" ++ dirname, base = os.path.split(path) ++ pattern = re.compile(re.escape(base) + r"\.\d{8}-\d{6}$") ++ try: ++ names = sorted(name for name in os.listdir(dirname) if pattern.match(name)) ++ except OSError: ++ return [] ++ return [os.path.join(dirname, name) for name in names] ++ ++ ++def _config_threadnumber(dse): ++ value = dse.get(DN_CONFIG, "nsslapd-threadnumber", single=True, lower=True) ++ if value is None: ++ return None ++ try: ++ parsed = int(value) ++ except ValueError: ++ return None ++ return parsed if parsed > 0 else None ++ ++ ++def _config_tp_stats_enabled(dse): ++ value = dse.get(DN_CONFIG, "nsslapd-thread-pool-stats", single=True, lower=True) ++ if value is None: ++ return True ++ return value.lower() != "off" ++ ++ ++def _open_threadpool_file(path, inst, tp_stats_enabled, explicit=False): ++ try: ++ fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW) ++ except FileNotFoundError: ++ if explicit: ++ raise ValueError(f"thread-pool status file not found: {path}") ++ if inst.status(): ++ if not tp_stats_enabled: ++ raise ValueError( ++ "thread-pool status is disabled by nsslapd-thread-pool-stats in cn=config" ++ ) ++ raise ValueError( ++ "server is running but the thread-pool status file is missing " ++ "(initialization may have failed - check the errors log; " ++ "nsslapd-thread-pool-stats was switched on without a restart; " ++ "or the server predates this feature, or nsslapd-rundir mismatch)" ++ ) ++ raise ValueError("instance is not running (status file is removed on clean shutdown)") ++ except PermissionError: ++ raise ValueError("permission denied; run as root or a member of the dirsrv group") ++ except OSError as e: ++ if e.errno == errno.ELOOP: ++ raise ValueError("refusing to read thread-pool status through a symlink") ++ raise ++ ++ return fd ++ ++ ++def _validate_stat(path, st): ++ if not stat.S_ISREG(st.st_mode): ++ raise ValueError(f"refusing to read non-regular thread-pool status file: {path}") ++ if st.st_size < TP_STATS_HEADER_SIZE: ++ raise ValueError( ++ f"thread-pool status file is too short: {st.st_size} bytes " ++ f"(expected at least {TP_STATS_HEADER_SIZE})" ++ ) ++ ++ ++def _unpack_header(mm): ++ # _validate_stat checked the fstat size, but the mapping is what we read: ++ # guard against the file shrinking between fstat and mmap ++ if len(mm) < TP_STATS_HEADER_SIZE: ++ raise ValueError("thread-pool status header is truncated") ++ ++ header = dict(zip(HEADER_NAMES, struct.unpack_from(HEADER_FORMAT, mm, 0))) ++ ++ if header["magic"] != TP_STATS_MAGIC: ++ raise ValueError("bad thread-pool status magic; refusing to parse file") ++ if header["ver_major"] != TP_STATS_VER_MAJOR: ++ raise ValueError( ++ f"unsupported thread-pool status version " ++ f"{header['ver_major']}.{header['ver_minor']}" ++ ) ++ if header["header_size"] != TP_STATS_HEADER_SIZE: ++ raise ValueError( ++ f"unsupported thread-pool status header size {header['header_size']}" ++ ) ++ if header["worker_slot_size"] != TP_STATS_WORKER_SLOT_SIZE: ++ raise ValueError( ++ f"unsupported thread-pool worker slot size {header['worker_slot_size']}" ++ ) ++ if header["max_workers"] < 1 or header["max_workers"] > 65535: ++ raise ValueError(f"invalid thread-pool worker count {header['max_workers']}") ++ ++ expected_size = header["header_size"] + (header["max_workers"] * header["worker_slot_size"]) ++ if expected_size > len(mm): ++ raise ValueError( ++ f"thread-pool status file is truncated: {len(mm)} bytes " ++ f"(expected at least {expected_size})" ++ ) ++ ++ return header ++ ++ ++def _state_name(state): ++ return STATE_NAMES.get(state, f"unknown-{state}") ++ ++ ++def _op_name(op_tag): ++ if op_tag == 0: ++ return "" ++ return OP_NAMES.get(op_tag, str(op_tag)) ++ ++ ++def _duration_ns(now_ns, start_ns): ++ if start_ns == 0 or now_ns < start_ns: ++ return 0 ++ return now_ns - start_ns ++ ++ ++def _unpack_workers(mm, header, now_ns): ++ workers = [] ++ for idx in range(header["max_workers"]): ++ offset = header["header_size"] + (idx * header["worker_slot_size"]) ++ state, op_tag, conn_id, op_id, start_ns = struct.unpack_from(WORKER_FORMAT, mm, offset) ++ if state == 0: ++ continue ++ # start_ns is the in-flight sentinel; op_id 0 is a valid first op ++ in_flight = start_ns != 0 ++ workers.append({ ++ "idx": idx + 1, ++ "state": _state_name(state), ++ "op": _op_name(op_tag), ++ "conn": conn_id if conn_id != 0 else None, ++ "op_id": op_id if in_flight else None, ++ "duration_ns": _duration_ns(now_ns, start_ns), ++ }) ++ return workers ++ ++ ++def _pid_warnings(pid): ++ """Return (warnings, pid_alive); pid_alive means a live ns-slapd owns the pid""" ++ warnings = [] ++ if pid == 0: ++ warnings.append("status file does not contain a valid server pid") ++ return warnings, False ++ ++ try: ++ name = psutil.Process(pid).name() ++ except (psutil.NoSuchProcess, psutil.ZombieProcess): ++ warnings.append(f"stale file from a crashed or killed server (pid {pid} is not running)") ++ return warnings, False ++ except psutil.AccessDenied: ++ warnings.append(f"server pid {pid} exists but process name could not be inspected") ++ return warnings, True ++ ++ if name != "ns-slapd": ++ warnings.append(f"stale file: pid {pid} belongs to {name!r}, not 'ns-slapd'") ++ return warnings, False ++ return warnings, True ++ ++ ++def _heartbeat_age(now_ns, heartbeat_ns): ++ if heartbeat_ns == 0: ++ return None ++ return now_ns - heartbeat_ns ++ ++ ++def _heartbeat_warnings(pid_alive, age_ns): ++ warnings = [] ++ if age_ns is None: ++ warnings.append("thread-pool heartbeat has never been written") ++ elif age_ns < 0: ++ warnings.append("thread-pool heartbeat is from a different monotonic-clock domain") ++ elif age_ns > IMPLAUSIBLE_HEARTBEAT_NS: ++ warnings.append("thread-pool heartbeat age is implausible; the file may predate a reboot") ++ elif age_ns > STALE_HEARTBEAT_NS: ++ if pid_alive: ++ warnings.append("server process exists but diagnostics are stale; server may be stalled") ++ else: ++ warnings.append("thread-pool diagnostics are stale") ++ return warnings ++ ++ ++def _read_threadpool_status(inst, file_path=None): ++ warnings = [] ++ archives = [] ++ if file_path is not None: ++ path = file_path ++ configured_threads = None ++ tp_stats_enabled = True ++ else: ++ dse = DSEldif(inst) ++ rundir = dse.get(DN_CONFIG, "nsslapd-rundir", single=True, lower=True) ++ if rundir is None: ++ rundir = inst.ds_paths.run_dir ++ warnings.append("nsslapd-rundir is missing from dse.ldif; using lib389 run_dir fallback") ++ path = os.path.join(rundir, f"{_server_file_prefix(inst.serverid)}.monitor", "threadpool") ++ archives = _crash_archives(path) ++ configured_threads = _config_threadnumber(dse) ++ tp_stats_enabled = _config_tp_stats_enabled(dse) ++ ++ try: ++ fd = _open_threadpool_file(path, inst, tp_stats_enabled, explicit=file_path is not None) ++ except ValueError as e: ++ if archives: ++ raise ValueError(f"{e}; {len(archives)} crash archive(s) present, newest: {archives[-1]}") ++ raise ++ try: ++ st = os.fstat(fd) ++ _validate_stat(path, st) ++ with mmap.mmap(fd, 0, access=mmap.ACCESS_READ) as mm: ++ header = _unpack_header(mm) ++ now_ns = time.monotonic_ns() ++ age_ns = _heartbeat_age(now_ns, header["heartbeat_mono_ns"]) ++ pid_warnings, pid_alive = _pid_warnings(header["server_pid"]) ++ warnings.extend(pid_warnings) ++ warnings.extend(_heartbeat_warnings(pid_alive, age_ns)) ++ ++ if header["shutdown_clean"] != 0: ++ warnings.append("clean shutdown leftover") ++ if configured_threads is not None and configured_threads != header["max_workers"]: ++ warnings.append( ++ f"dse.ldif nsslapd-threadnumber is {configured_threads}, " ++ f"but status file was sized for {header['max_workers']} workers" ++ ) ++ ++ workers = _unpack_workers(mm, header, now_ns) ++ finally: ++ os.close(fd) ++ ++ if not tp_stats_enabled: ++ warnings.append( ++ "nsslapd-thread-pool-stats is off in cn=config; the running server " ++ "keeps publishing diagnostics until it is restarted" ++ ) ++ if archives: ++ warnings.append( ++ f"{len(archives)} crash archive(s) in {os.path.dirname(path)}, " ++ f"newest: {os.path.basename(archives[-1])} (read with --file)" ++ ) ++ ++ age_sec = None if age_ns is None else age_ns / NS_PER_SEC ++ start_wall = header["start_wall_sec"] ++ uptime_sec = max(0, int(time.time()) - start_wall) if start_wall else None ++ ++ return { ++ "type": "result", ++ "instance": inst.serverid, ++ "path": path, ++ "pid": header["server_pid"], ++ "version": { ++ "major": header["ver_major"], ++ "minor": header["ver_minor"], ++ }, ++ "start_wall_sec": start_wall, ++ "uptime_sec": uptime_sec, ++ "heartbeat_age_sec": age_sec, ++ "heartbeat_wall_sec": header["heartbeat_wall_sec"], ++ "pool": { ++ "max_workers": header["max_workers"], ++ "cur_busy_workers": header["cur_busy_workers"], ++ "max_busy_workers": header["max_busy_workers"], ++ "cur_work_queue": header["cur_work_queue"], ++ "max_work_queue": header["max_work_queue"], ++ "ops_initiated": header["ops_initiated"], ++ "ops_completed": header["ops_completed"], ++ "cur_connections": header["cur_connections"], ++ }, ++ "workers": workers, ++ "warnings": warnings, ++ } ++ ++ ++def _format_seconds(value): ++ if value is None: ++ return "unknown" ++ return f"{value:.3f}s" ++ ++ ++def _format_duration_ns(duration_ns): ++ if duration_ns == 0: ++ return "-" ++ seconds = duration_ns / NS_PER_SEC ++ if seconds < 1: ++ return f"{seconds * 1000:.1f}ms" ++ return f"{seconds:.3f}s" ++ ++ ++def _format_optional(value): ++ return "-" if value is None else str(value) ++ ++ ++def _emit_text(log, status): ++ pool = status["pool"] ++ log.info(f"Instance: {status['instance']}") ++ log.info(f"Path: {status['path']}") ++ log.info(f"PID: {status['pid']}") ++ log.info(f"Uptime: {_format_seconds(status['uptime_sec'])}") ++ log.info(f"Heartbeat age: {_format_seconds(status['heartbeat_age_sec'])}") ++ log.info( ++ "Workers: " ++ f"{pool['cur_busy_workers']}/{pool['max_workers']} busy " ++ f"(max {pool['max_busy_workers']})" ++ ) ++ log.info( ++ "Queue: " ++ f"{pool['cur_work_queue']} current " ++ f"(max {pool['max_work_queue']})" ++ ) ++ log.info( ++ "Operations: " ++ f"{pool['ops_initiated']} initiated, " ++ f"{pool['ops_completed']} completed" ++ ) ++ log.info(f"Current connections: {pool['cur_connections']}") ++ ++ if status["warnings"]: ++ log.info("Warnings:") ++ for warning in status["warnings"]: ++ log.info(f" - {warning}") ++ ++ log.info("") ++ log.info(f"{'IDX':>5} {'STATE':<8} {'OP':<10} {'CONN':>12} {'OP-ID':>12} {'DURATION':>12}") ++ for worker in status["workers"]: ++ op = worker["op"].upper() if worker["op"] else "-" ++ log.info( ++ f"{worker['idx']:>5} " ++ f"{worker['state'].upper():<8} " ++ f"{op:<10} " ++ f"{_format_optional(worker['conn']):>12} " ++ f"{_format_optional(worker['op_id']):>12} " ++ f"{_format_duration_ns(worker['duration_ns']):>12}" ++ ) ++ ++ ++def thread_pool_status(inst, log, args): ++ status = _read_threadpool_status(inst, file_path=args.file) ++ if args.json: ++ log.info(json.dumps(status, indent=4)) ++ else: ++ _emit_text(log, status) ++ ++ ++def create_parser(subparsers): ++ thread_pool_parser = subparsers.add_parser( ++ "thread-pool", ++ help="Offline thread pool diagnostics read from the local mmap status file", ++ formatter_class=CustomHelpFormatter, ++ ) ++ subcommands = thread_pool_parser.add_subparsers(help="action") ++ ++ status_parser = subcommands.add_parser( ++ "status", ++ help="Display pool gauges and per-worker activity without an LDAP connection", ++ formatter_class=CustomHelpFormatter, ++ ) ++ status_parser.add_argument( ++ "--file", default=None, ++ help="Read this thread-pool status file instead of the instance's live file " ++ "(e.g. a crash file preserved as threadpool.YYYYMMDD-HHMMSS)", ++ ) ++ status_parser.set_defaults(func=thread_pool_status) +diff --git a/src/lib389/lib389/monitor.py b/src/lib389/lib389/monitor.py +index 8b4acd1db..d66039ee2 100644 +--- a/src/lib389/lib389/monitor.py ++++ b/src/lib389/lib389/monitor.py +@@ -68,6 +68,13 @@ class Monitor(DSLdapObject): + maxbusyworkers = self.get_attr_vals_utf8('maxbusyworkers') + return (currentworkqueue, maxworkqueue, currentbusyworkers, maxbusyworkers) + ++ def get_thread_pool_workers(self): ++ """Get sanitized per-worker thread pool status values from cn=monitor ++ ++ :returns: Values of threadpoolworker attribute of cn=monitor ++ """ ++ return self.get_attr_vals_utf8('threadpoolworker') ++ + def get_backends(self): + """Get backends related attributes value for cn=monitor + +@@ -207,6 +214,7 @@ class Monitor(DSLdapObject): + 'maxworkqueue', + 'currentbusyworkers', + 'maxbusyworkers', ++ 'threadpoolworker', + ]) + status.update(stats) + +-- +2.54.0 + diff --git a/0016-Issue-7583-Compressed-logs-are-prematurely-deleted-7.patch b/0016-Issue-7583-Compressed-logs-are-prematurely-deleted-7.patch new file mode 100644 index 0000000..2d88c0c --- /dev/null +++ b/0016-Issue-7583-Compressed-logs-are-prematurely-deleted-7.patch @@ -0,0 +1,303 @@ +From deb9fa07939c76459afbd1070f62dd2a55b204df Mon Sep 17 00:00:00 2001 +From: Viktor Ashirov +Date: Wed, 24 Jun 2026 16:12:43 +0200 +Subject: [PATCH] Issue 7583 - Compressed logs are prematurely deleted (#7584) + +Bug Description: +When log compression is enabled and the full path to a rotated +compressed log file exceeds 75 characters, the server fails to read the +actual compressed file size and falls back to the uncompressed +maxlogsize value 100 MB. This causes the maxdiskspace deletion check to +use incorrect sizes, triggering log deletion before the configured disk +space limit is reached. + +Fix Description: +Use `sizeof(logfile)` instead of `sizeof(tbuf)` to construct the +compressed filename. + +Fixes: https://github.com/389ds/389-ds-base/issues/7583 + +Reviewed by: @progier389, @droideck (Thanks!) +--- + .../logging_long_path_compression_test.py | 219 ++++++++++++++++++ + ldap/servers/slapd/log.c | 10 +- + 2 files changed, 224 insertions(+), 5 deletions(-) + create mode 100644 dirsrvtests/tests/suites/logging/logging_long_path_compression_test.py + +diff --git a/dirsrvtests/tests/suites/logging/logging_long_path_compression_test.py b/dirsrvtests/tests/suites/logging/logging_long_path_compression_test.py +new file mode 100644 +index 000000000..61c53a658 +--- /dev/null ++++ b/dirsrvtests/tests/suites/logging/logging_long_path_compression_test.py +@@ -0,0 +1,219 @@ ++# --- BEGIN COPYRIGHT BLOCK --- ++# Copyright (C) 2026 Red Hat, Inc. ++# All rights reserved. ++# ++# License: GPL (version 3 or any later version). ++# See LICENSE for details. ++# --- END COPYRIGHT BLOCK --- ++ ++import glob ++import logging ++import os ++import re ++import shutil ++import subprocess ++import time ++ ++import pytest ++ ++from lib389._constants import DEFAULT_SUFFIX ++from test389.topologies import topology_st as topo ++ ++log = logging.getLogger(__name__) ++ ++pytestmark = pytest.mark.tier1 ++ ++TBUFSIZE = 75 ++LONG_DIR_NAME = 'someverylongpaththatislongerthan75chars' ++ ++ ++def generate_load(inst, threads=10, samples=6): ++ """Generate search load using ldclt to fill the access log fast. ++ Each sample is 10 seconds. Default 6 samples = 60 seconds.""" ++ port = inst.port ++ subprocess.run([ ++ 'ldclt', '-h', 'localhost', '-p', str(port), ++ '-b', DEFAULT_SUFFIX, ++ '-e', 'esearch', ++ '-f', 'uid=demo_user', ++ '-n', str(threads), ++ '-N', str(samples), ++ ], check=True, timeout=samples * 10 + 30) ++ ++ ++def parse_rotationinfo(filepath): ++ """Parse a .rotationinfo file and return list of dicts with path, ctime, size ++ for all 'Previous Log File' entries.""" ++ entries = [] ++ with open(filepath, 'r') as f: ++ for line in f: ++ m = re.match(r'LOGINFO:Previous Log File:(\S+)\s+\((\d+)\)\s+\((\d+)\)', line) ++ if m: ++ entries.append({ ++ 'path': m.group(1), ++ 'ctime': int(m.group(2)), ++ 'size': int(m.group(3)), ++ }) ++ return entries ++ ++ ++def get_rotated_log_files(log_dir, log_type='access'): ++ """Return sorted list of rotated log file paths.""" ++ return sorted(glob.glob(f'{log_dir}/{log_type}.2*')) ++ ++ ++def cleanup_rotated_logs(log_dir, log_type='access'): ++ """Remove all rotated log files.""" ++ for f in glob.glob(f'{log_dir}/{log_type}.2*'): ++ os.remove(f) ++ ++ ++@pytest.fixture() ++def long_path_setup(topo, request): ++ """Creates a long-name subdirectory for the access log, ++ reconfigures DS to use it with compression, returns the paths.""" ++ ++ inst = topo.standalone ++ log_dir = inst.get_log_dir() ++ original_accesslog = inst.config.get_attr_val_utf8('nsslapd-accesslog') ++ ++ long_subdir = os.path.join(log_dir, LONG_DIR_NAME) ++ long_access_log = os.path.join(long_subdir, 'access') ++ ++ # Verify the path will exceed TBUFSIZE with rotation suffix ++ sample_rotated = long_access_log + '.20260615-120000.gz' ++ assert len(sample_rotated) > TBUFSIZE, ( ++ f"Test setup error: rotated path ({len(sample_rotated)} chars) must exceed " ++ f"TBUFSIZE ({TBUFSIZE}) to trigger the bug" ++ ) ++ ++ os.makedirs(long_subdir, exist_ok=True) ++ os.chown(long_subdir, inst.get_user_uid(), inst.get_group_gid()) ++ ++ inst.config.set('nsslapd-accesslog', long_access_log) ++ inst.config.set('nsslapd-accesslog-compress', 'on') ++ inst.config.set('nsslapd-accesslog-maxlogsize', '1') ++ inst.config.set('nsslapd-accesslog-logmaxdiskspace', '10') ++ inst.config.set('nsslapd-accesslog-maxlogsperdir', '100') ++ inst.config.set('nsslapd-accesslog-logrotationsync-enabled', 'off') ++ inst.config.set('nsslapd-accesslog-logbuffering', 'on') ++ inst.config.set('nsslapd-accesslog-logexpirationtime', '-1') ++ inst.config.set('nsslapd-accesslog-logminfreediskspace', '5') ++ inst.config.set('nsslapd-accesslog-logrotationtime', '1') ++ inst.config.set('nsslapd-accesslog-logrotationtimeunit', 'minute') ++ inst.config.set('nsslapd-statlog-level', '1') ++ ++ def fin(): ++ inst.config.set('nsslapd-accesslog', original_accesslog) ++ inst.config.set('nsslapd-accesslog-compress', 'off') ++ inst.config.set('nsslapd-accesslog-logmaxdiskspace', '500') ++ inst.config.set('nsslapd-accesslog-maxlogsize', '100') ++ inst.config.set('nsslapd-accesslog-maxlogsperdir', '10') ++ inst.config.set('nsslapd-accesslog-logbuffering', 'on') ++ inst.config.set('nsslapd-accesslog-logexpirationtime', '1') ++ inst.config.set('nsslapd-accesslog-logexpirationtimeunit', 'month') ++ inst.config.set('nsslapd-accesslog-logrotationtime', '1') ++ inst.config.set('nsslapd-accesslog-logrotationtimeunit', 'day') ++ inst.config.set('nsslapd-accesslog-logminfreediskspace', '5') ++ inst.config.set('nsslapd-statlog-level', '0') ++ if os.path.exists(long_subdir): ++ shutil.rmtree(long_subdir) ++ ++ request.addfinalizer(fin) ++ ++ return { ++ 'inst': inst, ++ 'log_dir': log_dir, ++ 'long_subdir': long_subdir, ++ 'long_access_log': long_access_log, ++ } ++ ++ ++def test_compressed_log_long_path(topo, long_path_setup): ++ """Test that compressed log sizes in rotationinfo match actual file ++ sizes and that logs are not prematurely deleted when the access log ++ path exceeds 75 characters. ++ ++ :id: 7c3b4a2e-1f8d-4e5a-b9c7-6d2e8f0a3b1c ++ :setup: Standalone Instance ++ :steps: ++ 1. Create a long-name subdirectory so the full rotated log filename ++ exceeds 75 characters (TBUFSIZE). ++ 2. Set access log to the long path with compression enabled, ++ maxlogsize 1 MB, and maxdiskspace 10 MB. ++ 3. Generate LDAP load to trigger many log rotations. ++ 4. Parse access.rotationinfo and compare recorded sizes against ++ actual compressed file sizes on disk. ++ :expectedresults: ++ 1. Success ++ 2. Success ++ 3. At least 3 rotated compressed logs are created. ++ 4. Recorded sizes in rotationinfo must match actual file sizes, ++ not the maxlogsize fallback value. ++ """ ++ ++ inst = long_path_setup['inst'] ++ long_subdir = long_path_setup['long_subdir'] ++ long_access_log = long_path_setup['long_access_log'] ++ ++ # Generate load to trigger many rotations (6 samples × 10 sec = 60 sec) ++ generate_load(inst) ++ ++ # Check rotationinfo sizes ++ rotinfo_path = long_access_log + '.rotationinfo' ++ assert os.path.exists(rotinfo_path), \ ++ f"Rotationinfo file not found: {rotinfo_path}" ++ ++ entries = parse_rotationinfo(rotinfo_path) ++ log.info(f"Rotationinfo has {len(entries)} entries") ++ assert len(entries) >= 3, \ ++ f"Expected at least 3 rotated logs, got {len(entries)}" ++ ++ maxlogsize_mb = int(inst.config.get_attr_val_utf8('nsslapd-accesslog-maxlogsize')) ++ maxlogsize_bytes = maxlogsize_mb * 1024 * 1024 ++ ++ mismatches = [] ++ for entry in entries: ++ log_path = entry['path'] ++ recorded_size = entry['size'] ++ ++ actual_path = log_path ++ if not os.path.exists(actual_path) and os.path.exists(log_path + '.gz'): ++ actual_path = log_path + '.gz' ++ ++ if not os.path.exists(actual_path): ++ log.warning(f"File not found: {actual_path} (may have been deleted)") ++ continue ++ ++ actual_size = os.path.getsize(actual_path) ++ log.info(f" {os.path.basename(actual_path)}: " ++ f"recorded={recorded_size}, actual={actual_size}") ++ ++ if recorded_size != actual_size: ++ mismatches.append({ ++ 'file': actual_path, ++ 'recorded': recorded_size, ++ 'actual': actual_size, ++ }) ++ ++ assert len(mismatches) == 0, ( ++ f"Compressed log sizes in rotationinfo do not match actual file sizes! " ++ f"{len(mismatches)} of {len(entries)} entries differ. " ++ f"Mismatched files: " ++ f"{[m['file'] + ': recorded=' + str(m['recorded']) + ' actual=' + str(m['actual']) for m in mismatches]}" ++ ) ++ ++ # Log retained file count for debugging ++ rotated_logs = get_rotated_log_files(long_subdir, 'access') ++ log.info(f"Rotated logs retained: {len(rotated_logs)}") ++ for f in rotated_logs: ++ log.info(f" {os.path.basename(f)}: {os.path.getsize(f)} bytes") ++ ++ total_actual = sum(os.path.getsize(f) for f in rotated_logs) ++ log.info(f"Total actual disk usage of rotated logs: {total_actual} bytes " ++ f"({total_actual / (1024*1024):.2f} MB)") ++ ++ ++if __name__ == '__main__': ++ CURRENT_FILE = os.path.realpath(__file__) ++ pytest.main(["-s", CURRENT_FILE]) +diff --git a/ldap/servers/slapd/log.c b/ldap/servers/slapd/log.c +index fe075b16b..676a8fff2 100644 +--- a/ldap/servers/slapd/log.c ++++ b/ldap/servers/slapd/log.c +@@ -3532,7 +3532,7 @@ log__open_accesslogfile(int logfile_state, int locked) + PR_snprintf(tbuf, sizeof(tbuf), "%s.gz", tbuf); + + /* get and set the size of the new gziped file */ +- PR_snprintf(logfile, sizeof(tbuf), "%s.%s", loginfo.log_access_file, tbuf); ++ PR_snprintf(logfile, sizeof(logfile), "%s.%s", loginfo.log_access_file, tbuf); + if ((logp->l_size = log__getfilesize_with_filename(logfile)) == -1) { + /* Then assume that we have the max size */ + logp->l_size = loginfo.log_access_maxlogsize; +@@ -3703,7 +3703,7 @@ log__open_securitylogfile(int logfile_state, int locked) + PR_snprintf(tbuf, sizeof(tbuf), "%s.gz", tbuf); + + /* get and set the size of the new gziped file */ +- PR_snprintf(logfile, sizeof(tbuf), "%s.%s", loginfo.log_security_file, tbuf); ++ PR_snprintf(logfile, sizeof(logfile), "%s.%s", loginfo.log_security_file, tbuf); + if ((logp->l_size = log__getfilesize_with_filename(logfile)) == -1) { + /* Then assume that we have the max size */ + logp->l_size = loginfo.log_security_maxlogsize; +@@ -6469,7 +6469,7 @@ log__open_errorlogfile(int logfile_state, int locked) + PR_snprintf(tbuf, sizeof(tbuf), "%s.gz", tbuf); + + /* get and set the size of the new gziped file */ +- PR_snprintf(logfile, sizeof(tbuf), "%s.%s", loginfo.log_error_file, tbuf); ++ PR_snprintf(logfile, sizeof(logfile), "%s.%s", loginfo.log_error_file, tbuf); + if ((logp->l_size = log__getfilesize_with_filename(logfile)) == -1) { + /* Then assume that we have the max size */ + logp->l_size = loginfo.log_error_maxlogsize; +@@ -6634,7 +6634,7 @@ log__open_auditlogfile(int logfile_state, int locked) + PR_snprintf(tbuf, sizeof(tbuf), "%s.gz", tbuf); + + /* get and set the size of the new gziped file */ +- PR_snprintf(logfile, sizeof(tbuf), "%s.%s", loginfo.log_audit_file, tbuf); ++ PR_snprintf(logfile, sizeof(logfile), "%s.%s", loginfo.log_audit_file, tbuf); + if ((logp->l_size = log__getfilesize_with_filename(logfile)) == -1) { + /* Then assume that we have the max size */ + logp->l_size = loginfo.log_audit_maxlogsize; +@@ -6799,7 +6799,7 @@ log__open_auditfaillogfile(int logfile_state, int locked) + PR_snprintf(tbuf, sizeof(tbuf), "%s.gz", tbuf); + + /* get and set the size of the new gziped file */ +- PR_snprintf(logfile, sizeof(tbuf), "%s.%s", loginfo.log_auditfail_file, tbuf); ++ PR_snprintf(logfile, sizeof(logfile), "%s.%s", loginfo.log_auditfail_file, tbuf); + if ((logp->l_size = log__getfilesize_with_filename(logfile)) == -1) { + /* Then assume that we have the max size */ + logp->l_size = loginfo.log_auditfail_maxlogsize; +-- +2.54.0 + diff --git a/0017-Issue-7573-Post-import-cache-autotuning-does-not-rec.patch b/0017-Issue-7573-Post-import-cache-autotuning-does-not-rec.patch new file mode 100644 index 0000000..b989a33 --- /dev/null +++ b/0017-Issue-7573-Post-import-cache-autotuning-does-not-rec.patch @@ -0,0 +1,187 @@ +From 40fc75ff66eccd533cdc7f92464cdf19aab4fe5b Mon Sep 17 00:00:00 2001 +From: Viktor Ashirov +Date: Mon, 15 Jun 2026 12:39:34 +0200 +Subject: [PATCH] Issue 7573 - Post-import cache autotuning does not recompute + entry cache size (#7574) +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Bug Description: +When a new empty backend is created, and an online import is completed, +`dbmdb_start_autotune()` runs but doesn’t apply recomputed cache values. +A server restart is required for the new cache sizes to take effect. + +Fix Description: +Always apply autotuning when autosize > 0. + +Fixes: https://github.com/389ds/389-ds-base/issues/7573 +Relates: https://github.com/389ds/389-ds-base/issues/6805 + +Reviewed by: @progier389 (Thanks!) +--- + .../tests/suites/config/autotuning_test.py | 106 ++++++++++++++++++ + .../servers/slapd/back-ldbm/db-mdb/mdb_misc.c | 15 ++- + 2 files changed, 116 insertions(+), 5 deletions(-) + +diff --git a/dirsrvtests/tests/suites/config/autotuning_test.py b/dirsrvtests/tests/suites/config/autotuning_test.py +index b1d0eb010..cda5f9a39 100644 +--- a/dirsrvtests/tests/suites/config/autotuning_test.py ++++ b/dirsrvtests/tests/suites/config/autotuning_test.py +@@ -17,6 +17,8 @@ from test389.topologies import topology_st as topo + from lib389.backend import Backends + from lib389.idm.user import UserAccounts + from lib389.config import BDB_LDBMConfig, LMDB_LDBMConfig ++from lib389.tasks import ImportTask ++from lib389.dbgen import dbgen_users + + + from lib389._constants import ( +@@ -630,6 +632,110 @@ def test_cache_autosize_multi_backends(topo): + userroot_cachesize = userroot_ldbm.get_attr_val('nsslapd-cachememsize') + assert int(userroot_cachesize) != 77777777 + ++@pytest.mark.skipif(get_default_db_lib() == "bdb", ++ reason="MDB-specific test") ++def test_mdb_cache_autotune_after_import(topo): ++ """Check that cache autotuning re-applies after an online import ++ ++ When a fresh instance starts with an empty database, autotuning ++ computes a small cache size (64MB). After an online ldif2db import ++ populates the database, the post-import autotune call should recompute ++ and apply larger cache sizes based on the actual database page count ++ without requiring a restart. ++ ++ :id: a3e7b2c1-8f4d-4e6a-9c5b-1d2e3f4a5b6c ++ :setup: Standalone instance ++ :steps: ++ 1. Record the initial autotuned cache sizes (from empty DB) ++ 2. Generate an LDIF with 5000 entries ++ 3. Perform an online import (ldif2db) ++ 4. Check cache sizes immediately after import ++ 5. Restart the server ++ 6. Check cache sizes after restart ++ :expectedresults: ++ 1. Cache sizes should be at 64MB ++ 2. LDIF is generated successfully ++ 3. Import completes successfully ++ 4. Cache sizes should increase after import ++ 5. Server restarts successfully ++ 6. Cache sizes should be properly autotuned based on data ++ """ ++ ++ inst = topo.standalone ++ mdb_config_ldbm = LMDB_LDBMConfig(inst) ++ MEGABYTE_64 = 64 * 1024 * 1024 ++ ++ log.info("Recreating backend to get an empty database") ++ mdb_config_ldbm.set('nsslapd-cache-autosize', '25') ++ ++ backends = Backends(inst) ++ userroot = backends.get('userRoot') ++ userroot.delete() ++ ++ backends.create(properties={ ++ 'nsslapd-suffix': DEFAULT_SUFFIX, ++ 'name': 'userRoot', ++ }) ++ inst.restart() ++ ++ userroot_ldbm = DSLdapObject(inst, DN_USERROOT_LDBM) ++ cachememsize_before = int(userroot_ldbm.get_attr_val_utf8('nsslapd-cachememsize')) ++ dncachememsize_before = int(userroot_ldbm.get_attr_val_utf8('nsslapd-dncachememsize')) ++ log.info("Cache on empty DB: cachememsize=%d, dncachememsize=%d", ++ cachememsize_before, dncachememsize_before) ++ ++ assert cachememsize_before == MEGABYTE_64, ( ++ f"Expected 64MB entry cache on empty DB, got {cachememsize_before}") ++ ++ log.info("Generating LDIF with 5000 entries") ++ ldif_dir = inst.get_ldif_dir() ++ ldif_file = os.path.join(ldif_dir, 'autotune_test.ldif') ++ dbgen_users(inst, 5000, ldif_file, DEFAULT_SUFFIX, generic=True, ++ parent=f"ou=People,{DEFAULT_SUFFIX}") ++ ++ log.info("Performing online import") ++ import_task = ImportTask(inst) ++ import_task.import_suffix_from_ldif(ldiffile=ldif_file, suffix=DEFAULT_SUFFIX) ++ import_task.wait(timeout=300) ++ exit_code = import_task.get_exit_code() ++ assert exit_code == 0, f"Import task failed with exit code {exit_code}" ++ os.remove(ldif_file) ++ ++ people = DSLdapObject(inst, f"ou=People,{DEFAULT_SUFFIX}") ++ num_subordinates = int(people.get_attr_val_utf8('numSubordinates')) ++ log.info("Imported %d entries", num_subordinates) ++ assert num_subordinates >= 5000, \ ++ f"Expected at least 5000 entries, got {num_subordinates}" ++ ++ cachememsize_after_import = int(userroot_ldbm.get_attr_val_utf8('nsslapd-cachememsize')) ++ dncachememsize_after_import = int(userroot_ldbm.get_attr_val_utf8('nsslapd-dncachememsize')) ++ log.info("Cache after import (no restart): cachememsize=%d, dncachememsize=%d", ++ cachememsize_after_import, dncachememsize_after_import) ++ ++ assert cachememsize_after_import > MEGABYTE_64, ( ++ f"Post-import autotune should have increased entry cache above 64MB, " ++ f"got {cachememsize_after_import}") ++ assert dncachememsize_after_import > MEGABYTE_64, ( ++ f"Post-import autotune should have increased DN cache above 64MB, " ++ f"got {dncachememsize_after_import}") ++ ++ log.info("Restarting server") ++ inst.restart() ++ ++ userroot_ldbm = DSLdapObject(inst, DN_USERROOT_LDBM) ++ cachememsize_after_restart = int(userroot_ldbm.get_attr_val_utf8('nsslapd-cachememsize')) ++ dncachememsize_after_restart = int(userroot_ldbm.get_attr_val_utf8('nsslapd-dncachememsize')) ++ log.info("Cache after restart: cachememsize=%d, dncachememsize=%d", ++ cachememsize_after_restart, dncachememsize_after_restart) ++ ++ assert cachememsize_after_import == cachememsize_after_restart, ( ++ f"Post-import entry cache ({cachememsize_after_import}) " ++ f"!= post-restart entry cache ({cachememsize_after_restart})") ++ assert dncachememsize_after_import == dncachememsize_after_restart, ( ++ f"Post-import DN cache ({dncachememsize_after_import}) " ++ f"!= post-restart DN cache ({dncachememsize_after_restart})") ++ ++ + if __name__ == '__main__': + # Run isolated + # -s for DEBUG mode +diff --git a/ldap/servers/slapd/back-ldbm/db-mdb/mdb_misc.c b/ldap/servers/slapd/back-ldbm/db-mdb/mdb_misc.c +index 7a1860894..aa0cca96b 100644 +--- a/ldap/servers/slapd/back-ldbm/db-mdb/mdb_misc.c ++++ b/ldap/servers/slapd/back-ldbm/db-mdb/mdb_misc.c +@@ -319,11 +319,16 @@ dbmdb_start_autotune(struct ldbminfo *li) + dn_size = clamp_div * (64 * MEGABYTE); + } + +- /* This is the point where we decide to apply or not. If the cache +- * size is equal or less than MINCACHESIZE then we assume it does not +- * have a custom value and we can autotune ++ /* This is the point where we decide to apply or not. ++ * ++ * If autosize > 0, we always apply because the admin explicitly ++ * requested autotuning on every startup (and after online import). ++ * ++ * If autosize <= 0 (default), we only apply on first run when ++ * the cache still has the initial default value, so that a ++ * manually configured value is preserved. + */ +- if (cache_size <= MINCACHESIZE) { ++ if (li->li_cache_autosize > 0 || cache_size <= MINCACHESIZE) { + slapi_log_err(SLAPI_LOG_NOTICE, "mdb_start_autotune", + "cache autosizing: %s entry cache (%" PRIu64 " total): %s\n", + inst->inst_name, backend_count, +@@ -331,7 +336,7 @@ dbmdb_start_autotune(struct ldbminfo *li) + cache_set_max_entries(&(inst->inst_cache), -1, true /* autotuned */); + cache_set_max_size(&(inst->inst_cache), ec_size, CACHE_TYPE_ENTRY, true); + } +- if (dncache_size <= DEFAULT_DNCACHE_SIZE) { ++ if (li->li_cache_autosize > 0 || dncache_size <= DEFAULT_DNCACHE_SIZE) { + slapi_log_err(SLAPI_LOG_NOTICE, "mdb_start_autotune", + "cache autosizing: %s dn cache (%" PRIu64 " total): %s\n", + inst->inst_name, backend_count, +-- +2.54.0 + diff --git a/389-ds-base.spec b/389-ds-base.spec index 763b395..01ee325 100644 --- a/389-ds-base.spec +++ b/389-ds-base.spec @@ -64,6 +64,9 @@ Version: 3.3.0 Release: %{autorelease -n %{?with_asan:-e asan}}%{?dist} License: GPL-3.0-or-later WITH GPL-3.0-389-ds-base-exception AND (Apache-2.0 OR Apache-2.0 WITH LLVM-exception OR MIT) AND (Apache-2.0 OR LGPL-2.1-or-later OR MIT) AND (Apache-2.0 OR MIT) AND (MIT OR Apache-2.0) AND Unicode-3.0 AND (MIT OR Unlicense) AND Apache-2.0 AND MIT AND MPL-2.0 AND Zlib Conflicts: selinux-policy-base < 3.9.8 +# The dirsrv@.service NoNewPrivileges/MemoryDenyWriteExecute hardening +# requires SELinux policy rules only available from this version onward. +Requires: selinux-policy >= 42.1.22-1 Conflicts: freeipa-server < 4.0.3 Obsoletes: %{name} <= 1.4.4 URL: https://www.port389.org/ @@ -312,6 +315,17 @@ Patch: 0003-Issue-7554-deref-plugin-null-pointer-dereference-if-.patc Patch: 0004-Issue-3555-UI-Fix-audit-issue-with-npm-brace-expansi.patch Patch: 0005-Issue-7549-Substring-index-should-validate-minimum-n.patch Patch: 0006-Issue-7539-Server-shutdown-during-online-reindex-may.patch +Patch: 0007-Issue-7562-Error-NssSsl.add_cert-got-an-unexpected-k.patch +Patch: 0008-Issue-7500-Prevent-unsigned-integer-underflow-during.patch +Patch: 0009-Issue-7558-During-online-import-the-IDL-should-be-cr.patch +Patch: 0010-Issue-7593-Reject-invalid-SASL-packet-length-values-.patch +Patch: 0011-Issue-7593-Fix-testimony-docstring-for-SASL-overflow.patch +Patch: 0012-Issue-7284-Automated-test-for-creating-local-passwor.patch +Patch: 0013-Issue-7558-Total-init-sends-the-suffix-entry-twice-7.patch +Patch: 0014-Issue-7406-Fix-ldap-agent-SNMP-stats-file-loading-76.patch +Patch: 0015-Issue-7633-RFE-Add-offline-diagnostics-for-thread-po.patch +Patch: 0016-Issue-7583-Compressed-logs-are-prematurely-deleted-7.patch +Patch: 0017-Issue-7573-Post-import-cache-autotuning-does-not-rec.patch %description 389 Directory Server is an LDAPv3 compliant server. The base package includes