Playbook: MongoDB Slow Queries on regRecords — Investigation & Index Management
Issue: #1198
Scope: w3_db.regRecords (RESO EUROPA) and w4_db.regRecords (Keller) on rs0
Companion playbook: mongodb-compound-wildcard-indexes.md — the
application record of the indexes that were actually created for this issue.
This playbook is the operational / diagnostic guide: how to recognise the slow-query pattern, how to verify the current index state, and how to safely add an index on these (multi-million-document) collections without locking production. The companion playbook above is the change record of what was built on 2026-06-24.
TL;DR — current state (verified 2026-06-28 against rs0 PRIMARY bms-2)
The search-index fix this issue called for is already deployed. The remaining work is application-side (Pinbox24 repo) and monitoring (p24-infra), not a new database index.
| Collection | Docs | Search index present | Verified |
|---|---|---|---|
w3_db.regRecords | ~7.46M | p24_search_compound_wildcard {officeId:1, regId:1, $**:1} (+ 37-field wildcardProjection) | ✅ 2026-06-28 |
w4_db.regRecords | ~2.52M | p24_search_recorddata_wildcard {officeId:1, regId:1, deleted:1, recordData.$**:1} | ✅ 2026-06-28 |
Slow-query volume has collapsed from 1,271/day (2026-06-10) to 1–10/day (June 2026), consistent with these indexes being in place. The historical 223-second incidents were March 2026, before the indexes existed.
Do NOT add a MongoDB
$textindex to “fix” this. See Why not a$textindex — it is redundant with the deployed wildcard indexes, provides zero benefit without a Pinbox24 application change (different repo), is expensive on these collection sizes (w4_db.regRecordsalready carries ~11 GB of indexes), and risks OOM on bms-3 (MongoDB already uses ~21.7 GB of 32 GB).
Trigger — how to recognise this problem
- Grafana / mongod log shows
find/aggregateon*_db.regRecordstaking tens of seconds to minutes. - The query is the Pinbox24 “search records” feature: a
$matchwithofficeId+regId+ a large$orof non-anchored regex conditions across 7–100+ fields, then$sort+$limit+$facet. - Affected offices to date: RESO EUROPA (
officeId 590c6d334704d811efc9fd5a,w3_db) and Keller (officeId 5d36ccf9903f5002cca1e474,w4_db).
Root cause
Non-anchored regex (/term/i, no ^) cannot be served by a normal B-tree index. After the
{officeId, regId} prefix narrows the candidate set, MongoDB must evaluate every regex against every
candidate document in memory. With a large register (~23,800 docs for RESO EUROPA × 22+ regexes) this
reaches 223 s. The wildcard indexes let the planner union per-field index scans instead of loading all
documents; see the companion playbook’s “Expected improvement” section.
Step 1 — Confirm the current index state (read-only, safe)
Credentials: MONGODB_RS0_ADMIN_USER / MONGODB_RS0_ADMIN_PASSWORD from
secrets/administration.env.sops (or secrets/n8n-bms4.env.sops). Never print the values — load
into a variable and reference it only as a command argument.
# bms-4 (arbiter) and the rs0 peers are all in the port-27017 ufw allowlist, so you can connect
# directly with a replica-set URI from any p24-infra server — no SSH hop required.
export SOPS_AGE_KEY_FILE="$HOME/.age/p24-infra-keys.txt"
MUSER=$(sops -d --input-type dotenv --output-type dotenv secrets/administration.env.sops | grep '^MONGODB_RS0_ADMIN_USER=' | cut -d= -f2- | tr -d '"')
MPASS=$(sops -d --input-type dotenv --output-type dotenv secrets/administration.env.sops | grep '^MONGODB_RS0_ADMIN_PASSWORD=' | cut -d= -f2- | tr -d '"')
export MURI="mongodb://${MUSER}:${MPASS}@145.239.133.104:27017,51.68.155.224:27017/admin?replicaSet=rs0&authSource=admin"
unset MPASS # value now only inside $MURI; do not echo $MURI
mongosh "$MURI" --quiet --eval '
["w3_db","w4_db"].forEach(function(dbn){
var idx = db.getSiblingDB(dbn).regRecords.getIndexes();
var search = idx.filter(function(i){ return /p24_search/.test(i.name); });
print(dbn + ".regRecords: " + idx.length + " indexes; search index(es): " +
(search.length ? search.map(function(i){return i.name;}).join(", ") : "MISSING"));
});'
unset MURIExpected: each line lists a p24_search_* index. If one shows MISSING, the index was dropped or the
collection was restored from a backup predating 2026-06-24 — re-create it per the companion playbook.
Avoid
explain()on the PRIMARY for this query shape. MongoDB’s multi-plan trial briefly executes candidate plans even inqueryPlannermode; for a large$orregex over a 7M-document collection this can itself run for tens of seconds and add load. If you must profile, run it against the SECONDARY (bms-3) withreadPreference=secondaryand during low traffic.
Step 2 — Decide whether a new index is actually needed
Most of the time the answer is no — the wildcard indexes already cover the search path. Add a new
index only for a specific, profiled slow query that the existing indexes do not cover (e.g. a query
filtering on a date field with no supporting index, like the 2026-01-28 incident that fell back to the
_id index = full scan).
Decision checklist before creating any index:
- You have an actual slow query from the log/profiler (not a hypothesis).
-
getIndexes()confirms no existing index already covers it. - The new index is a normal B-tree compound index (e.g.
{officeId:1, regId:1, registeredData:1}) — not a second wildcard or a$textindex. - You have checked RAM headroom on bms-3 (
free -g; MongoDB already ~21.7 GB / 32 GB) and disk on bms-2 (df -h). - The build is scheduled off-peak and uses the rolling procedure below.
Why not a $text index
- Redundant — the deployed wildcard indexes already serve the multi-field search.
- No benefit without an app change — a
$textindex is only used by$text: {$search: ...}queries. Pinbox24 currently sends$orregex; until the Pinbox24 application (separate repo, running on bms-1) is rewritten to use$text, a text index sits unused. That app change is tracked as remaining work below, not in this repo. - One per collection — MongoDB allows a single
$textindex; adding one constrains future schema. - Cost — text indexes tokenise every word of every indexed field. On 7.46M / 2.52M documents this
is large and slow to build;
w4_db.regRecordsalready carries ~11 GB of indexes.
Step 3 — Safe index build on a large rs0 collection (only if Step 2 says yes)
MongoDB 7.0 builds indexes with the hybrid method (brief intent locks at start/end, the build itself
does not hold a collection-exclusive lock; the deprecated {background:true} option is ignored). Even
so, on a 7M-document collection the build consumes CPU/RAM. Prefer a rolling build to keep the
PRIMARY unaffected:
- Build on the SECONDARY first. Connect directly to bms-3 (the current SECONDARY), drop it from
the set is not required for a rolling build in modern MongoDB — instead, build on the PRIMARY with
a maintenance window, or use the documented rolling procedure (stop secondary → standalone → build →
rejoin). For p24-infra’s small two-data-bearing-node set, the simplest safe path is:
- Schedule an off-peak window.
- Create the index on the PRIMARY (bms-2). It replicates to bms-3 automatically.
- Watch
db.currentOp({ "command.createIndexes": { $exists: true } })andfree -gon both nodes.
- Verify with
getIndexes()on both bms-2 and bms-3. - Confirm it’s used by profiling the specific query on the SECONDARY (
readPreference=secondary).
Index creation command pattern (substitute the real fields; this is an example for a date-field gap):
mongosh "$MURI" --quiet --eval '
db.getSiblingDB("w3_db").regRecords.createIndex(
{ officeId: 1, regId: 1, registeredData: 1 },
{ name: "p24_regrecords_registereddata" }
);'dropIndex(...) is the instant, safe rollback (metadata-only; files removed in the background).
Remaining work (NOT in this repo / not a database index)
These are the real follow-ups; the database index portion of #1198 is complete.
| # | Item | Where | Notes |
|---|---|---|---|
| 1 | Reduce $limit: 10000 → 500 before $facet | Pinbox24 app repo (bms-1) | The pipeline paginates to 10 results; loading 10k before faceting is wasted work. Biggest remaining win, app-side only. |
| 2 | (Optional) migrate $or regex → $text: {$search} | Pinbox24 app repo | Only worthwhile with a text index; weigh against the already-effective wildcard indexes. |
| 3 | Deploy Mezmo agent on bms-2 + bms-3 | p24-infra | #1196 — per-query slow-log detail + Grafana alert on slow-query spikes. |
| 4 | Investigate external NoSQLBooster access 103.89.59.203 | p24-infra | See below. |
Security follow-up — 103.89.59.203
IP 103.89.59.203 connected directly via NoSQLBooster v9.1.6 (authenticated — it passed MongoDB
auth) and ran a slow find on w3_db.regRecords on 2026-01-28; last session 2026-02-05. No sessions
after ufw was enabled on bms-2/bms-3 on 2026-06-23 — the firewall now blocks it. Actions:
- Confirm the IP is not on the rs0 ufw allowlist (it should not be).
- Identify the credential owner. If the connecting MongoDB user cannot be accounted for, rotate the
relevant MongoDB password following
mongodb-credential-rotation.mdand log it indocs/secrets-rotation-log.md.
Escalation / error notification
If an index build fails, OOMs a node, or causes replication lag that affects Pinbox24, follow the
project error-notification standard: post to P24_DISCORD_INFRA_SCRIPTS_ERRORS_WEBHOOK_URL (red) and
open a GitHub issue in radieu/p24-infra with the failing command, node, and db.currentOp() output.
Admin credentials
MONGODB_RS0_ADMIN_USER / MONGODB_RS0_ADMIN_PASSWORD — secrets/administration.env.sops
(also in secrets/n8n-bms4.env.sops). Decrypt with the safe single-key extraction pattern; never echo
the value.
Audit Log — Log to infra_operations
After this operation completes, log it to the infra_operations audit table.
Python (Linux server — bms-4, vps-i1, vps-h1, or similar):
import sys
sys.path.insert(0, '/opt/p24-infra')
from scripts.lib.log_op import log_op
log_op(
actor="claude", # "radieu" for manual human ops, "claude" for agent
op_type="config_change",
resource="mongodb-rs0",
result="success", # "success" | "failed" | "skipped"
detail="MongoDB slow query resolved — index created on rs0",
env="bms-2",
gh_issue=2730,
)PowerShell (Windows dev machine):
$env:SUPABASE_URL = (Get-Content "C:\code_2026\p24-infra\.env.local" | Select-String "^SUPABASE_URL=").ToString().Split("=",2)[1].Trim()
$env:SUPABASE_SERVICE_KEY = (Get-Content "C:\code_2026\p24-infra\.env.local" | Select-String "^SUPABASE_SERVICE_KEY=").ToString().Split("=",2)[1].Trim()
python -c "
import os, sys
sys.path.insert(0, 'C:/code_2026/p24-infra')
from scripts.lib.log_op import log_op
log_op('claude', 'config_change', 'mongodb-rs0', 'success', 'MongoDB slow query resolved — index created on rs0', 'bms-2')
"
$env:SUPABASE_URL = ''; $env:SUPABASE_SERVICE_KEY = ''