Skip to content

feat(#10280): make the replication interval configurable - #11475

Open
dacar1988 wants to merge 4 commits into
medic:masterfrom
dacar1988:10280-configurable-sync-interval
Open

dacar1988 wants to merge 4 commits into
medic:masterfrom
dacar1988:10280-configurable-sync-interval

Conversation

@dacar1988

@dacar1988 dacar1988 commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Description

The webapp syncs offline users with the server every 5 minutes, and this is hard-coded. This PR adds a global app setting, replication_interval_minutes, so a deployment can sync less often to reduce server load.

How it works

  • Not set (or null): 5 minutes, so nothing changes for existing projects.
  • A number from 5 to 60 (fractions are fine, e.g. 7.5): the periodic sync runs at that interval. The maximum is 60 because timers can pause while a device sleeps, so much longer intervals may not fire in practice.
  • Anything else (1, 4.99, 61, "30", "abc"…): 5 minutes, plus one console warning naming the setting and the value.
  • The metadata database syncs every 30 minutes or at the configured interval, whichever is longer.
  • The setting is read once, when DBSyncService is created. A changed value is used after the device has downloaded the new settings and the app has been reloaded (the usual "Update available" prompt).
  • Startup sync, Sync now, and uploads after local changes work as before.

Changes after review (thanks @jkuester)

  • Minimum is now the 5-minute default.
  • The interval is loaded once and cached in a readonly property, instead of on every sync. Because reading the settings is asynchronous, it is a cached promise that both timers wait for.
  • Removed the typeof check.
  • The metadata interval is Math.max(30 min, interval).
  • Maximum lowered to 60 minutes, and no fallback around the settings read.
  • Docs: feat(#10280): document the replication_interval_minutes setting cht-docs#2268, plus a follow-up for the 60-minute maximum.

Testing

  • New unit tests in db-sync.service.spec.ts (fake timers): the 5 and 60 limits, fractions, invalid values, overlapping first syncs, going online before the setting has loaded, metadata timing, and online-only users. All 76 tests in the file pass, and so do npm run lint and npm run unit-webapp.
  • Manual test on a local CHT built from this branch, with an offline chw user in Chrome. For each value I changed the setting on the server, synced, and reloaded from the "Update available" prompt. I recorded the sync timers the app created and timed the 5- and 10-minute ones in real time:
replication_interval_minutes Sync every Metadata every Console warning
not set 5 min (downloads at 0.2, 5.2, 10.2 min) 30 min no
5 5 min 30 min no
7.5 7.5 min 30 min no
10 10 min (downloads at 11.5, 21.5 min) 30 min no
60 60 min 60 min no
1, 4.99, 61, "abc", "30" 5 min 30 min yes
removed again 5 min 30 min no

Before the reload the app kept the old value, as expected.

Closes #10280

Code review checklist

AI Disclosure: the first version was planned and tested with Claude Code (Opus) and implemented with OpenAI Codex (GPT-6.1 Sol). The review changes were implemented with OpenAI Codex. Claude Code reviewed them and ran the manual test above. Everything was done under my direction, and I reviewed the code and the results.

License

The software is provided under AGPL-3.0. Contributions to this project are accepted under the same license.

dacar1988 and others added 2 commits October 4, 2026 12:22
Adds the replication_interval_minutes app setting (1-1440 minutes). Missing values keep the 5 minute default; invalid values fall back to it with a console warning.
@dacar1988 dacar1988 changed the title 10280 configurable sync interval feat(#10280): make the replication interval configurable Oct 5, 2026
@jkuester
jkuester self-requested a review October 6, 2026 19:36
@jkuester jkuester added the ai-review Triggers the ai-review workflow to run on the PR. label Oct 8, 2026
@github-actions

github-actions Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Note

🤖 This is an AI-generated review, requested by @jkuester. Its content and suggestions can be wrong. Please respond with a comment if you have doubts regarding any of the review. A maintainer will also review this PR.

🔍 OpenCodeReview found 1 issue(s) in this PR.

  • ✅ Successfully posted inline: 1 comment(s)

Requirements

  • Delivered — Expose the replication interval as a single global app setting named replication_interval_minutes (not per-role) — webapp/src/ts/services/db-sync.service.ts:34, read as a top-level key at webapp/src/ts/services/db-sync.service.ts:390
  • Delivered — When the setting is absent or null, the periodic sync keeps the 5-minute default — webapp/src/ts/services/db-sync.service.ts:391, tested at webapp/tests/karma/ts/services/db-sync.service.spec.ts:421 and webapp/tests/karma/ts/services/db-sync.service.spec.ts:481
  • Delivered — A number from 1 to 1440 sets the periodic sync interval — webapp/src/ts/services/db-sync.service.ts:405 applied at webapp/src/ts/services/db-sync.service.ts:325, tested at webapp/tests/karma/ts/services/db-sync.service.spec.ts:432 and webapp/tests/karma/ts/services/db-sync.service.spec.ts:446
  • Delivered — Fractional minutes are accepted — webapp/src/ts/services/db-sync.service.ts:396, tested at webapp/tests/karma/ts/services/db-sync.service.spec.ts:455
  • Delivered — Any other value falls back to the 5-minute default and logs a console warning naming the setting and the bad value — webapp/src/ts/services/db-sync.service.ts:398, tested at webapp/tests/karma/ts/services/db-sync.service.spec.ts:466
  • Delivered — DBSyncService.sync() reads the setting through SettingsService on every sync, before the timer is re-armed — webapp/src/ts/services/db-sync.service.ts:420 ahead of webapp/src/ts/services/db-sync.service.ts:432, which re-arms at webapp/src/ts/services/db-sync.service.ts:264
  • Delivered — A failed settings read keeps the last good interval and lets the sync carry on — webapp/src/ts/services/db-sync.service.ts:386, tested at webapp/tests/karma/ts/services/db-sync.service.spec.ts:493 and webapp/tests/karma/ts/services/db-sync.service.spec.ts:506
  • Delivered — A changed value takes effect from the next timer re-arm once the updated settings doc is downloaded — cache invalidation on the settings doc at webapp/src/ts/services/settings.service.ts:27, re-read at webapp/src/ts/services/db-sync.service.ts:420, tested at webapp/tests/karma/ts/services/db-sync.service.spec.ts:520 and webapp/tests/karma/ts/services/db-sync.service.spec.ts:554
  • Delivered — The timer reset stays synchronous — webapp/src/ts/services/db-sync.service.ts:316
  • Delivered — The meta DB interval is unchanged — webapp/src/ts/services/db-sync.service.ts:428 still uses the constant at webapp/src/ts/services/db-sync.service.ts:37
  • Delivered — The startup sync is unchanged — webapp/src/ts/app.component.ts:244
  • Delivered — The immediate upload after local changes is unchanged — webapp/src/ts/app.component.ts:473
  • Delivered — Online-only users do not read the setting — webapp/src/ts/services/db-sync.service.ts:414, tested at webapp/tests/karma/ts/services/db-sync.service.spec.ts:573

Preconditions to confirm

  • cht-conf's compile-app-settings must carry the new top-level key through into the deployed settings doc; per the CHT app_settings reference there is no top-level settings schema in this repo to register it in — webapp/src/ts/services/db-sync.service.ts:34
  • The cht-docs entry for the new setting is still outstanding; the PR's "Documented" checklist item is unchecked and no doc source lives in this repo — webapp/src/ts/services/db-sync.service.ts:34

Undisclosed Changes

None

Alternative Approaches

  • The change already follows this repo's established pattern for an optional numeric app setting — a module-level default constant plus a runtime read through SettingsService — the same shape as webapp/src/ts/services/task-notifications.service.ts:105 for tasks.max_task_notifications; no better approach found.
  • Per the CHT app_settings reference, range validation for comparable numeric settings also happens at config compile time in cht-conf; that would complement, not replace, the runtime fallback at webapp/src/ts/services/db-sync.service.ts:396, and lives outside this repo.

Comment thread webapp/src/ts/services/db-sync.service.ts Outdated
@github-actions github-actions Bot removed the ai-review Triggers the ai-review workflow to run on the PR. label Oct 8, 2026

@jkuester jkuester left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for the PR @dacar1988!

Could you also open one for https://github.com/medic/cht-docs so that we can capture the config details out on https://docs.communityhealthtoolkit.org/ ?

The main docs updates would be:

  1. building/reference/app-settings/_index.md - Add entry to "Optional Settings" table
  2. building/navigation.md "Synchronization Triggers" → "Periodic Sync" - This line hardcodes "The main application database syncs every 5 minutes." Change it to say 5 minutes is the default and link to the new setting.
  3. technical-overview/data/performance/replication.md - This page covers replication tuning (depth, primary contacts, and similar) but never says how often replication runs.
    • A short "Replication interval" section would fit here, covering the tradeoffs: a shorter interval means fresher data but more server load; a longer interval could help ease CPU pressure for deployments with lots of users.
    • Also add the app-settings page to its relatedContent.

const SYNC_INTERVAL = 5 * 60 * 1000; // 5 minutes
const DEFAULT_SYNC_INTERVAL = 5 * 60 * 1000; // 5 minutes
const SYNC_INTERVAL_SETTING = 'replication_interval_minutes';
const MIN_SYNC_INTERVAL_MINUTES = 1;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Lets just use the DEFAULT_SYNC_INTERVAL as the minimum. All of the users syncing puts a non-trivial amount of load on the server, so there are real problems with syncing more frequently (and few advantages).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done, the minimum is now the 5-minute default. Thanks.

}

await this.migrateDb();
await this.loadSyncInterval();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The whole app automatically reloads whenever the settings doc changes. So, it should be safe to grab and cache the configured replication_interval_minutes value when this service is constructed (instead of re-checking here on every sync). Ideally let's return the proper sync interval from the loadSyncInterval method (falling back to the default if none is configured) and set this.syncInterval. Then syncInterval can be a readonly constant.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done. The interval is now loaded once when the service is created and kept in a readonly property. Since reading the settings is async, it’s a cached promise that both timers wait for.

Comment on lines +382 to +388
let settings;
try {
settings = await this.settingsService.get();
} catch (err) {
console.warn('Error reading settings, keeping the current replication interval', err);
return;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We verify the settings exist before starting the app. So, should be safe to assume we get a value back here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I kept the catch on purpose. The interval is now read once and cached, so if that one read ever failed, every later sync would fail too until the app is reloaded. With the catch it just falls back to 5 minutes and logs a warning. Happy to remove it if you prefer.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I appreciate the extra thinking here and concern for the blast radius if this fails. However, the existence of the settings doc is something we explicitly check for before the app starts.

So, this call should always succeed by the time we get here. The majority of the other places in the codebase where we call settingsService.get() we do not catch any failure and I would prefer we do not add it here because it could confuse future developers who will wonder if they need to also be checking for an error.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done, removed.

Comment on lines +396 to +397
if (typeof minutes !== 'number' || !Number.isFinite(minutes) ||
minutes < MIN_SYNC_INTERVAL_MINUTES || minutes > MAX_SYNC_INTERVAL_MINUTES) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No need for the typeof check here since Number.isFinite can handle non-number values.

Suggested change
if (typeof minutes !== 'number' || !Number.isFinite(minutes) ||
minutes < MIN_SYNC_INTERVAL_MINUTES || minutes > MAX_SYNC_INTERVAL_MINUTES) {
if (!Number.isFinite(minutes) || minutes < MIN_SYNC_INTERVAL_MINUTES || minutes > MAX_SYNC_INTERVAL_MINUTES) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done, thanks.

const SYNC_INTERVAL_SETTING = 'replication_interval_minutes';
const MIN_SYNC_INTERVAL_MINUTES = 1;
const MAX_SYNC_INTERVAL_MINUTES = 24 * 60;
const META_SYNC_INTERVAL = 30 * 60 * 1000; // 30 minutes

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It does not make much sense to be syncing the metadata more often than the actual data. 🤔

I think maybe we should have a this.metaSyncInterval property too. It should me Math.max(META_SYNC_INTERVAL, this.syncInterval);. This way, if the sync interval is configured to me more than 30min, we will also raise the meta sync interval too.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done, the metadata interval is now Math.max(META_SYNC_INTERVAL, syncInterval). With 60 minutes, both sync every hour.

Use the five-minute default as the minimum and adjust metadata replication to the configured interval when it exceeds 30 minutes. Cache settings once per service instance, retain a default on read failure, and cover timer initialization and reload behavior.
@dacar1988

Copy link
Copy Markdown
Contributor Author

One thing I noticed while testing longer intervals: every local change triggers an upload-only sync, and that restarts the periodic timer (startSyncMedic → resetSyncInterval). With a 10-minute interval and a save every 4 minutes, the app didn’t download anything for 21 minutes. This already happens today with 5 minutes, but it matters more with long intervals. I mentioned it in the docs. Would you like me to change it (e.g. only restart the timer after a sync that downloaded), or leave it for a separate issue?

@dacar1988

Copy link
Copy Markdown
Contributor Author

CI note: ci-integration-all-k3d failed in setup before any test ran (docker pull from Public ECR returned toomanyrequests: Rate exceeded). Could someone re-run it, please?

@jkuester jkuester left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

every local change triggers an upload-only sync, and that restarts the periodic timer (startSyncMedic → resetSyncInterval).

Woah, great catch! I had not realized the local changes feed triggers an upload-only sync and not a download. I have done some digging on the history of this. Looks like the original implementation of the changes-driven sync from many years ago just triggered a full sync download+upload. But, in 2023 when we updated the replication protocol and added the ability to do the upload-only sync, this got used for the changes-driven sync (which makes sense because the sync is being triggered by a local change and that is what we want to send back to the server). However, the code that reset the sync interval during a changes-driven sync was not updated and the interval continued to get reset after an upload-only sync.

I agree with your assessment that this incongruity becomes a much bigger problem at higher intervals. (Such that it could pretty much prevent changes from getting synced back down to the device automatically during a session).

The only "risk" I can think of with updating the logic to not clear the sync interval for upload-only syncs is that it will result in more download syncs during a user's active session (when changes are being made on the device). The way things are now, any change on the device will reset the interval and so if a user makes 20 changes in an hour, a download sync may never be triggered at all during that hour. If we avoid resetting the interval for upload-only syncs, the we will sync 12 times in that hour (same as if the app was just open and idle). Honestly this just feels like things working as desired (seems undesired that user activity would suppress downloading).

I think in this PR we just need to update the interval reset logic to only trigger during a full sync and to not reset the interval for an upload-only sync. @witash can I get your feedback on this? 🙏 Can you think of any risks I am missing here? Are you concerned with the possible performance impact?

expect(settingsService.get.callCount).to.equal(1);
});

it('keeps the initial interval until reload, including after quick and forced syncs', async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor, but I am not really sure we need this as a unit test. The scenario is coherent, but not really necessary to test given how the code is written. (Not really hitting any new logic paths.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done, removed.

const DEFAULT_SYNC_INTERVAL = 5 * 60 * 1000; // 5 minutes
const SYNC_INTERVAL_SETTING = 'replication_interval_minutes';
const MIN_SYNC_INTERVAL_MINUTES = DEFAULT_SYNC_INTERVAL / (60 * 1000);
const MAX_SYNC_INTERVAL_MINUTES = 24 * 60;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Been doing some reading around how the setInterval behaves across longer time ranges. As far as I can tell it is actually not based on "wall-clock time", but instead is an internal timer that pauses during sleep (except on Windows...). This is not an issue for a 5m interval (since there is a good chance the user will be actively using the app for >5min). However, for a much longer interval (e.g. 24h) I am worried that the actual realized sync intervals could be much longer (by wall-clock time) than intended. An Android device will normally suspend when not in use (screen has been off for some time). I expect (though have not manually confirmed) this will result in the timer getting paused and then resuming when the device wakes up. If a user only opens their phone for a few hours of the day, then the rest of the time, the timer may be paused. In that case the setInterval might not trigger the sync for weeks.

The incongruity/risk here seems to grow as the actual interval value is raised. So, I think the best thing to do is set the actual max at 60m. That is still +10x the current limit, which gives admins some options. But it is low enough that we can expect it to actually be triggered in normal usage conditions. To properly support longer sync intervals, I think we would need to consider an alternative approach that would leverage wall-clock time instead.

Suggested change
const MAX_SYNC_INTERVAL_MINUTES = 24 * 60;
const MAX_SYNC_INTERVAL_MINUTES = 60;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

😬 I did already merge the docs PR with the 24h max. So, if we do change this value, we will need another docs PR to correct that....

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done, the max is now 60. Docs follow-up: medic/cht-docs#2271.

Remove the settings read fallback and two unnecessary tests, as requested in review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make downward sync time configurable

3 participants