Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
5b2ea66
Initial plan
Copilot Feb 26, 2026
ca558a9
Convert MongoDB from standalone to replica set
Copilot Feb 26, 2026
dbfb84c
Add startDatabase.js to .gitignore exceptions
Copilot Feb 26, 2026
34a4308
Remove setupMongo.js and its .gitignore reference
Copilot Feb 26, 2026
46d3f10
Fix prettier formatting in startDatabase.js and tasks.json
Copilot Feb 26, 2026
d0942b0
Update run-mongo VSCode task to use npm run database
Copilot Feb 26, 2026
2c9afee
Make startDatabase.js failure modes fatal with proper exit codes
Copilot Feb 26, 2026
36d0f2f
Fix exit code logic: use ?? instead of || to preserve clean exit (0)
Copilot Feb 26, 2026
49ca484
Merge branch 'master' into copilot/convert-standalone-to-replica-set
imnasnainaec Mar 2, 2026
33d6a56
Fix replica-set loading (by Copilot locally)
imnasnainaec Mar 3, 2026
369677c
Update postStart hook to reconfigure replica set member host after po…
Copilot Mar 11, 2026
a75f87a
Merge branch 'master' into copilot/convert-standalone-to-replica-set
imnasnainaec Mar 11, 2026
d909c9d
Allow mongo to stop
imnasnainaec Mar 11, 2026
e5a2234
Re-architecture initial run
imnasnainaec Mar 12, 2026
3997ae8
Bump mongo from 7.0.28 to 7.0.30
imnasnainaec Mar 12, 2026
4f5b3bc
Simplify
imnasnainaec Mar 12, 2026
fc59ce2
Fix first db init
imnasnainaec Mar 12, 2026
4ee89ff
[npm run database] Enable Ctrl+C kill
imnasnainaec Mar 13, 2026
70aac1a
[npm run database] Tidy
imnasnainaec Mar 13, 2026
7281a51
[npm run database] Tidy
imnasnainaec Mar 13, 2026
6aadfbe
Add exit handling and comments
imnasnainaec Mar 13, 2026
8b20487
Fix return 0 on error
imnasnainaec Mar 13, 2026
76f7e35
Fix ping loop
imnasnainaec Mar 13, 2026
68b4048
Fix conf reconfig
imnasnainaec Mar 13, 2026
c4a58a6
Clarify init waiting
imnasnainaec Mar 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Session.vim
scripts/*.js
!scripts/frontendScripts.js
!scripts/jestTest.js
!scripts/setupMongo.js
!scripts/startDatabase.js
database/*.js
*.log
*-debug.log*
Expand Down
4 changes: 2 additions & 2 deletions .vscode/tasks.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@
},
{
"label": "run-mongo",
"command": "mongod",
"command": "npm",
"type": "process",
"args": ["--dbpath", "${workspaceFolder}/mongo_database"],
"args": ["run", "database"],
"problemMatcher": "$tsc"
}
]
Expand Down
4 changes: 2 additions & 2 deletions Backend/appsettings.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"MongoDB": {
"ConnectionString": "mongodb://localhost:27017",
"ContainerConnectionString": "mongodb://database:27017",
"ConnectionString": "mongodb://localhost:27017/?replicaSet=rs0",
"ContainerConnectionString": "mongodb://database:27017/?replicaSet=rs0",
"CombineDatabase": "CombineDatabase"
},
"Logging": {
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -473,7 +473,7 @@ npm run license-report-frontend

To browse the database locally during development, open [MongoDB Compass](https://www.mongodb.com/try/download/compass).

1. Under New Connection, enter `mongodb://localhost:27017`
1. Under New Connection, enter `mongodb://localhost:27017/?replicaSet=rs0`
2. Under Databases, select CombineDatabase

### Add or Update Dictionary Files
Expand Down
20 changes: 10 additions & 10 deletions database/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,21 @@
# - Intel/AMD 64-bit
# - ARM 64-bit
############################################################
FROM mongo:7.0.28-jammy@sha256:8ddd3db4d2638eb914cce56284e2f0d6daf140bba31679b2af86f7d790a4c77e
FROM mongo:7.0.30-jammy@sha256:aee9bae9f1a5507a51e19f24b015162cbcd7004695d99175dbccc427e20760e2

WORKDIR /

RUN mkdir /data/semantic-domains
RUN mkdir -p /data/semantic-domains /opt/thecombine

# Copy semantic domain import files
COPY semantic_domains/* /data/semantic-domains/

# from https://hub.docker.com/_/mongo
# Initializing a fresh instance
# When a container is started for the first time it will execute files
# with extensions .sh and .js that are found in /docker-entrypoint-initdb.d.
# Files will be executed in alphabetical order. .js files will be executed
# by mongosh (mongo on versions below 6) using the database specified by
# the MONGO_INITDB_DATABASE variable, if it is present, or test otherwise.
# You may also switch databases within the .js script.
COPY init/* /docker-entrypoint-initdb.d/
# Scripts in /docker-entrypoint-initdb.d run only on first startup of an empty
# data directory. We intentionally keep setup scripts out of initdb.d and run
# them from Kubernetes postStart to avoid first-boot race conditions.
COPY init/update-semantic-domains.sh /opt/thecombine/update-semantic-domains.sh

# Replica set readiness/alignment runs from Kubernetes postStart hook on every
# container start, so this script is intentionally kept out of initdb.d.
COPY init/00-replica-set.js /opt/thecombine/00-replica-set.js
81 changes: 81 additions & 0 deletions database/init/00-replica-set.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Ensure a single-node replica set is initialized and advertising the
// expected host for this environment.
//
// MONGO_INITDB_REPLICA_HOST can be set to the resolvable hostname:port
// used to advertise this member (e.g. "database:27017" in Kubernetes).
const host = process.env.MONGO_INITDB_REPLICA_HOST || "localhost:27017";
const maxWaitMs = 60 * 1000;
const intervalMs = 1000;
const start = Date.now();

/** Ensure the primary host is correctly configured */
function ensurePrimaryHost(forceReconfig) {
let conf;
try {
conf = rs.conf();
} catch (error) {
conf = db.getSiblingDB("local").system.replset.findOne();
if (!forceReconfig || !conf) {
throw error;
}
}

if (!conf.members?.length) {
throw new Error("Replica set config has no members");
}

if (conf.members[0].host !== host) {
print(`Updating replica set member host to ${host}`);
conf.members[0].host = host;
conf.version = (conf.version || 1) + 1;
rs.reconfig(conf, { force: forceReconfig });
return false;
}

return true;
}

// Wait for replica set to be initialized.
let replicaSetInitiated = false;
while (Date.now() - start < maxWaitMs) {
try {
rs.initiate({ _id: "rs0", members: [{ _id: 0, host: host }] });
print(`Initialized replica set with host ${host}`);
replicaSetInitiated = true;
break;
} catch (err) {
if (String(err).includes("already initialized")) {
print("Replica set already initialized");
replicaSetInitiated = true;
break;
}

print(`Replica set init deferred: ${err}`);
}

sleep(intervalMs);
}
if (!replicaSetInitiated) {
throw new Error(`Replica set not initialized after ${maxWaitMs}ms`);
}

// Wait for this member to be PRIMARY with the correct host.
while (Date.now() - start < maxWaitMs) {
try {
if (db.hello().isWritablePrimary) {
if (ensurePrimaryHost(false)) {
print(`Replica set is PRIMARY with correct host: ${host}`);
quit(0);
}
} else {
ensurePrimaryHost(true);
}
} catch (err) {
print(`Host alignment deferred: ${err}`);
}

sleep(intervalMs);
}
throw new Error(
`Replica set did not reach PRIMARY state with host ${host} after ${maxWaitMs}ms`
);
34 changes: 34 additions & 0 deletions deploy/helm/thecombine/charts/database/templates/database.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,40 @@ spec:
- image: {{ include "database.containerImage" . }}
imagePullPolicy: {{ .Values.global.imagePullPolicy }}
name: database
args:
- "--replSet"
- "rs0"
lifecycle:
postStart:
exec:
command:
- /bin/sh
- -c
- |
set -e
echo "[postStart] Waiting for mongod to accept connections"
attempts=0
until mongosh --quiet --host 127.0.0.1 --eval "db.adminCommand({ ping: 1 }).ok" >/dev/null 2>&1; do
attempts=$((attempts + 1))
if [ "${attempts}" -ge 120 ]; then
echo "[postStart] Failed to connect to mongod after ${attempts} attempts, exiting"
exit 1
fi
sleep 1
done
echo "[postStart] Ensuring replica set host"
mongosh --quiet --host 127.0.0.1 /opt/thecombine/00-replica-set.js || exit $?
needs_semantic_import="$(mongosh --quiet --host 127.0.0.1 --eval "const combineDb = db.getSiblingDB('CombineDatabase'); const treeCount = combineDb.SemanticDomainTree.countDocuments({}); const domainCount = combineDb.SemanticDomains.countDocuments({}); print(treeCount === 0 || domainCount === 0 ? 'yes' : 'no');")"
if [ "${needs_semantic_import}" = "yes" ]; then
/bin/bash /opt/thecombine/update-semantic-domains.sh
fi
env:
- name: POD_IP
valueFrom:
fieldRef:
fieldPath: status.podIP
- name: MONGO_INITDB_REPLICA_HOST
value: "$(POD_IP):27017"
ports:
- containerPort: 27017
resources:
Expand Down
2 changes: 1 addition & 1 deletion docs/deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -415,7 +415,7 @@ Notes:
rerun manually:

```console
kubectl -n thecombine exec deployment/database -- /docker-entrypoint-initdb.d/update-semantic-domains.sh
kubectl -n thecombine exec deployment/database -- /opt/thecombine/update-semantic-domains.sh
```

## Maintenance
Expand Down
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@
"backend": "dotnet watch --project Backend/BackendFramework.csproj",
"build": "parcel build",
"build:analyze": "npm run build -- --reporter @parcel/reporter-bundle-analyzer",
"predatabase": "node scripts/setupMongo.js",
"database": "mongod --dbpath=./mongo_database",
"database": "node scripts/startDatabase.js",
"drop-database": "tsc scripts/dropDB.ts && node scripts/dropDB.js",
"find-circular-deps": "npx --ignore-scripts -y madge -c src/index.tsx --ts-config tsconfig.json",
"fmt-backend": " dotnet format && dotnet format Backend.Tests",
Expand Down
7 changes: 0 additions & 7 deletions scripts/setupMongo.js

This file was deleted.

173 changes: 173 additions & 0 deletions scripts/startDatabase.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
"use strict";

const { spawn, spawnSync } = require("child_process");
const { emitKeypressEvents } = require("readline");

const { ensureDir } = require("fs-extra");

const dbPath = "./mongo_database";
const replSetName = "rs0";
const maxAttempts = 30;
const retryIntervalSeconds = 1;
const mongoshTimeoutSeconds = 10;
let mongodProcess;
let exiting = false;

/** Check if TTY and raw mode are available. */
function canUseRawMode() {
return process.stdin.isTTY && typeof process.stdin.setRawMode === "function";
}

/** Disable raw mode. */
function stopRawMode() {
if (canUseRawMode()) {
process.stdin.setRawMode(false);
process.stdin.pause();
}
}

/** Enable raw mode to capture Ctrl+C when it doesn't otherwise work. */
function startRawMode() {
if (canUseRawMode()) {
emitKeypressEvents(process.stdin);
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.on("keypress", (_text, key) => {
if (key?.ctrl && key.name === "c") {
forceExit(130);
}
});
}
}

/** Unless already exiting, forcibly kill mongod and exit process. */
function forceExit(code = 0) {
if (exiting) {
return;
}

exiting = true;

stopRawMode();

if (mongodProcess && !mongodProcess.killed) {
mongodProcess.kill("SIGKILL");
}
process.exit(code);
}

/** Set up handlers for various interrupts. */
function setUpInterruptHandling() {
process.on("SIGINT", () => forceExit(130));
process.on("SIGBREAK", () => forceExit(131));
process.on("SIGTERM", () => forceExit(143));
startRawMode();
}

/** Convert error to string. */
function getErrorMessage(error) {
return error instanceof Error
? `${error.code}: ${error.message}`
: String(error);
}

/** Run a mongosh command with timeout and interrupt handling. */
function runMongosh(args, options = {}) {
const result = spawnSync("mongosh", [...args, "--quiet"], {
timeout: mongoshTimeoutSeconds * 1000,
killSignal: "SIGTERM",
...options,
});

if (result.error) {
throw result.error;
}
if (result.signal) {
throw new Error(`mongosh exited due to signal ${result.signal}`);
}

return result;
}

/** Ping with mongosh until available, up to a preset number of attempts. */
async function waitForMongo() {
for (let i = 0; i < maxAttempts; i++) {
let result;
try {
result = runMongosh(["--eval", "db.adminCommand('ping')"]);
} catch (err) {
console.warn(`ping attempt ${i + 1} failed: ${getErrorMessage(err)}`);
}
if (result?.status === 0) {
return true;
}
if (i < maxAttempts - 1) {
await new Promise((res) => setTimeout(res, retryIntervalSeconds * 1000));
}
}

console.error(`MongoDB pings failed after ${maxAttempts} attempts.`);
return false;
}

/** Start a replica set if not already initialized. */
async function initReplicaSet() {
try {
const result = runMongosh(
[
"--eval",
`try { rs.status() } catch { rs.initiate({ _id: '${replSetName}', members: [{ _id: 0, host: 'localhost:27017' }] }) }`,
],
{ stdio: "inherit" }
);
return result.status === 0;
Comment on lines +114 to +123
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Mar 13, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "[scripts/startDatabase.js]"
sed -n '115,168p' scripts/startDatabase.js

echo
echo "[database/init/00-replica-set.js]"
sed -n '37,65p' database/init/00-replica-set.js

Repository: sillsdev/TheCombine

Length of output: 2181


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Full context of scripts/startDatabase.js ==="
wc -l scripts/startDatabase.js

echo
echo "=== Lines 1-50 (variable definitions) ==="
sed -n '1,50p' scripts/startDatabase.js

echo
echo "=== Lines 150-170 (checking the 'Also applies to' lines) ==="
sed -n '150,170p' scripts/startDatabase.js

echo
echo "=== Searching for maxAttempts and retryIntervalSeconds ==="
rg "maxAttempts|retryIntervalSeconds" scripts/startDatabase.js

Repository: sillsdev/TheCombine

Length of output: 2202


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== waitForMongo function (to see the retry pattern) ==="
sed -n '65,110p' scripts/startDatabase.js

echo
echo "=== runMongosh function (to verify how it's called) ==="
rg -A 5 "function runMongosh" scripts/startDatabase.js

echo
echo "=== Check getErrorMessage function ==="
rg -A 3 "function getErrorMessage" scripts/startDatabase.js

Repository: sillsdev/TheCombine

Length of output: 1708


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Checking the complete main() function (lines 130-174) ==="
sed -n '130,174p' scripts/startDatabase.js

echo
echo "=== Verify package.json line 30 to confirm parallel startup ==="
sed -n '25,35p' package.json

Repository: sillsdev/TheCombine

Length of output: 2118


Add PRIMARY readiness check after replica set initialization.

initReplicaSet() returns success as soon as rs.initiate() is accepted, but the replica set member is not usable until it elects itself PRIMARY. Since package.json starts backend and database in parallel (line 30: run-p), the startup can race this window. The Kubernetes bootstrap in database/init/00-replica-set.js already implements the correct pattern with db.hello().isWritablePrimary; apply the same here.

Add a waitForPrimary() function that polls for db.hello().isWritablePrimary with the same retry pattern as waitForMongo(), then call it in main() after initReplicaSet() succeeds. Required variables (maxAttempts, retryIntervalSeconds) are already defined.

Suggested fix
 async function initReplicaSet() {
   try {
     const result = runMongosh(
       [
         "--eval",
         `try { rs.status() } catch { rs.initiate({ _id: '${replSetName}', members: [{ _id: 0, host: 'localhost:27017' }] }) }`,
       ],
       { stdio: "inherit" }
     );
     return result.status === 0;
   } catch (error) {
     console.error(`Replica set init failed: ${getErrorMessage(error)}`);
     return false;
   }
 }
+
+async function waitForPrimary() {
+  for (let i = 0; i < maxAttempts; i++) {
+    try {
+      const result = runMongosh([
+        "--eval",
+        "quit(db.hello().isWritablePrimary ? 0 : 1)",
+      ]);
+      if (result.status === 0) {
+        return true;
+      }
+    } catch (error) {
+      console.warn(`MongoDB primary check failed: ${getErrorMessage(error)}`);
+    }
+
+    await new Promise((res) => setTimeout(res, retryIntervalSeconds * 1000));
+  }
+
+  return false;
+}
 
 async function main() {
   setUpInterruptHandling();
@@ -161,6 +181,10 @@ async function main() {
   if (!(await initReplicaSet())) {
     console.error("Replica set initialization failed");
     forceExit(1);
   }
+  if (!(await waitForPrimary())) {
+    console.error("Replica set did not become PRIMARY in time");
+    forceExit(1);
+  }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/startDatabase.js` around lines 115 - 124, initReplicaSet currently
returns as soon as rs.initiate is accepted but doesn't wait for the node to
become PRIMARY; add a new waitForPrimary() that polls mongosh evaluating
db.hello().isWritablePrimary with the existing retry pattern (use maxAttempts
and retryIntervalSeconds like waitForMongo()), make initReplicaSet remain
responsible for initiating the set and in main() call waitForPrimary() after
initReplicaSet() succeeds so the startup waits until the replica set member is
elected PRIMARY before proceeding.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Is this fixed?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Search for waitForPrimary in startDatabase.js ==="
rg -n "waitForPrimary" scripts/startDatabase.js || echo "NOT FOUND"

echo
echo "=== Full file content ==="
cat scripts/startDatabase.js

Length of output: 4305


@imnasnainaec, not yet fixed. The waitForPrimary() function is absent from scripts/startDatabase.js, and main() still returns immediately after initReplicaSet() without polling for PRIMARY election. The race condition with run-p (parallel backend + database startup) remains.

Here's the change needed — it slots cleanly between initReplicaSet and main:

/** Wait until the replica set member becomes PRIMARY. */
async function waitForPrimary() {
  for (let i = 0; i < maxAttempts; i++) {
    try {
      const result = runMongosh([
        "--eval",
        "quit(db.hello().isWritablePrimary ? 0 : 1)",
      ]);
      if (result.status === 0) {
        return true;
      }
    } catch (error) {
      console.warn(`MongoDB primary check failed: ${getErrorMessage(error)}`);
    }
    if (i < maxAttempts - 1) {
      await new Promise((res) => setTimeout(res, retryIntervalSeconds * 1000));
    }
  }
  return false;
}

And in main(), after the existing initReplicaSet block:

  if (!(await waitForPrimary())) {
    console.error("Replica set did not become PRIMARY in time");
    forceExit(1);
  }

} catch (error) {
console.error(`Replica set init failed: ${getErrorMessage(error)}`);
return false;
}
}

async function main() {
setUpInterruptHandling();

await ensureDir(dbPath);

// Start the mongod process
mongodProcess = spawn(
"mongod",
["--dbpath", dbPath, "--replSet", replSetName, "--quiet"],
{ stdio: "inherit" }
);

// Exit when the mongod process errors
mongodProcess.on("error", (err) => {
console.error(`mongod error: ${err.message}`);
forceExit(1);
});

// Exit when the mongod process exits
mongodProcess.on("exit", (code, signal) => {
if (exiting) {
return;
}

if (code || signal) {
console.error(`mongod exited with code ${code}, signal ${signal}`);
}
forceExit(signal ? 1 : (code ?? 1));
});

if (!(await waitForMongo())) {
console.error("MongoDB did not start in time");
forceExit(1);
}
if (!(await initReplicaSet())) {
console.error("Replica set initialization failed");
forceExit(1);
}
}

main().catch((err) => {
console.error(err);
forceExit(1);
});
Loading