Date: 2026-10-29 (rewritten after a full assessment)

Repos: FLW-API, Identity-API, TM-API, MMU-API, Common-API (all on release-3.12.0), AMRIT-DB (main; no release-3.12.0 branch), STOP-TB-App

Related:STOP-TB-App PR #321 (sync reliability fixes); STOP-TB sync gap analysis and Aug 2026 field log analysis (available on request)


Contents

  1. Summary

  2. How it works today

  3. Requirements

  4. Problem 1: "What changed since X?"

  5. Problem 2: All data on the tablet

  6. Problem 3: Station hand-offs and overwrites

  7. Target design

  8. Order of work

  9. Testing and measurement

  10. Rollout

  11. Decisions needed

  12. Appendix: evidence

  13. Implementation status

  14. Round 2: closing the remaining gaps

  15. What we learned on UAT

  16. Adding a new record type to the sync


1. Summary

Three problems, in the order to solve them:

  1. "What changed since X?" cannot be answered today.

  2. Tablets need all the data, not one village. After login, users take tablets to villages and work offline.

  3. Stations overwrite each other. Registrar → screening → nurse → lab technician → counsellor each work on their own tablet.

Problem 1 is the foundation: problems 2 and 3 both depend on it.

2. How it works today

Diagram 1: Current data flow (Mermaid source below; image attachment d1.png)

flowchart LR
  subgraph Village["Village (offline)"]
    T1[Tablet<br/>Room DB]
  end
  subgraph Camp["Camp (local Wi-Fi)"]
    T2[Registrar tablet]
    T3[Nurse tablet]
    T4[Lab / counsellor tablets]
    HUB[(Camp hub laptop<br/>FLW / Identity / TM / MMU APIs<br/>own MySQL, VanID = N)]
  end
  C[(Central server<br/>stoptb.piramalswasthya.org)]

  T2 -- "push edited records (all fields of each)<br/>pull EVERYTHING ~every 10s" --> HUB
  T3 -- "push edited records (all fields of each)<br/>pull EVERYTHING" --> HUB
  T4 -- "push / pull" --> HUB
  HUB -- "upsync (manual / 30 min, off by default)<br/>keeps hub LastModDate" --> C
  C -- "downsync (manual, VanID = N only)<br/>keeps central LastModDate" --> HUB
  T1 -. "syncs when back at a camp" .-> HUB

What a pull does now (STOP-TB app):

Hub ↔ central (MMU-API data sync):

3. Requirements

#

Requirement

R1

A pull with nothing new costs about 1 small request and moves almost no data.

R2

A pull with changes downloads only the changed records: creates, updates and deletes.

R3

Correct no matter which code path wrote the row: JPA, native SQL, hub↔central sync, or manual fixes.

R4

The tablet holds all data for the user's scope and works fully offline.

R5

A record saved at one station appears at the next station within about 30 seconds. On demand when the person walks up, it appears within seconds.

R6

One station never silently overwrites another station's newer data.

R7

Backward compatible: old apps keep working, and a new app against an old server falls back to a full pull.

R8

No Room migrations in the app for sync state. Sync positions go in encrypted shared preferences.

4. Problem 1: "What changed since X?"

4.1 Why LastModDate > X fails today

Hole

Where

Effect

Writable mappings freeze or replace the timestamp

i_beneficiarydetails_rmnch, i_householddetails (Identity RmnchDataSyncServiceImpl:187/485), i_beneficiaryimage, t_benvisitdetail (FLW/MMU/TM), i_ben_flow_outreach

Stored as the tablet's clock, NULL, or unchanged, so the change is never selected

lastModDate = curdate()

TM BenVisitDetailRepo:64

Moved back to midnight

Central → hub downsync keeps central's timestamp

MMU DownSyncDataFromServerImpl.updateRecord

A row updated on central reaches the hub with an old time, so the hub's cursor has passed it

Hub → central upsync keeps the hub's timestamp

GetDataFromVanAndSyncToDBImpl

Same problem in the other direction

No ON UPDATE on the column

tb_diagnostic_order.last_mod_date

Bulk or native updates aren't seen

Hard deletes, no deleted flag

TM visit and complaint deletes; t_question_response delete-and-reinsert; no flag on tb_suspected, tb_confirmed_cases, form tables

Deletes can't be synced

Child changes don't touch the parent

complaints, drugs, vitals, answers

A changed parent can't be detected from its children

Second precision; commits land after their timestamp; JVM and DB time zones not pinned

Most columns

A change can fall behind a cursor that has already moved on

No indexes on the change columns

Most tables

Slow scans on large tables

You could patch each of these one by one (§4.4 option B), but holes would keep coming back as new code is written.

4.2 Recommended: a change log on each camp laptop, filled by triggers

Every server gets one change log, in db_iemr, installed by AMRIT-DB's main migrations (dbiemr/V132, dbidentity/V32); triggers on db_identity tables write to it across schemas, so the tablet holds a single token. Nothing is set or created per server, so the next db.war update installs it on all camp laptops. The log is never synced between servers: it isn't in the upsync table list (m_synctabledetail) and must not be added. Only camp laptops (hubs) serve the feed to tablets; central and state servers carry the log and triggers but nothing reads them.

CREATE TABLE t_sync_change_log (            -- one table, in db_iemr; db_identity triggers write to it too
  seq            BIGINT AUTO_INCREMENT PRIMARY KEY,   -- order of changes, local to this server
  entity         VARCHAR(40) NOT NULL,                -- record type the app receives (beneficiary, tb_screening, ...)
  row_pk         BIGINT NOT NULL,                     -- that record's key (BenRegId for beneficiary)
  op             CHAR(1) NOT NULL,                    -- I, U or D
  ben_reg_id     BIGINT NULL,                         -- whichever ID the source row carries;
  beneficiary_id BIGINT NULL,                         -- the feed resolves the village from these when it reads
  changed_at     DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
  KEY (ben_reg_id, seq), KEY (beneficiary_id, seq), KEY (entity, row_pk),
  KEY (changed_at)                                    -- for the clean-up of old lines (§4.6)
);

Worked example: how the token picks up an edit to an older record

The number the tablet remembers is the line number in the log (seq), not a record ID. Every change appends a new line at the end, whichever record it touches:

Log (seq = line number, always increasing)
 ...
 #1000  beneficiary 900   updated   ← this tablet has read up to here
 #1001  tb_screening 455  inserted
 #1002  beneficiary 900   updated   ← another tablet edits record 900 again
 #1003  vitals 77         inserted

So an edit to an old record always reaches every tablet: the edit creates a new line after each tablet's bookmark.

Diagram 2: Change log filled by triggers (Mermaid source below; image attachment d2.png)

flowchart TB
  subgraph Writers["Anything that writes"]
    A[FLW/Identity/TM/MMU APIs<br/>JPA + native SQL]
    B[Downsync from central /<br/>upsync bookkeeping]
    M[Manual fixes / Flyway backfills]
  end
  subgraph DB["MySQL on the camp laptop (hub)"]
    TBL[(STOP-TB tables<br/>beneficiary, TB, OPD, forms, orders)]
    TRG{{AFTER INSERT / UPDATE / DELETE<br/>triggers}}
    LOG[(t_sync_change_log<br/>seq, entity, pk, op, ben_reg_id, beneficiary_id)]
  end
  FEED[FLW-API<br/>POST /stoptb/sync/changes]
  TAB[Tablet]

  A --> TBL
  B --> TBL
  M --> TBL
  TBL --> TRG --> LOG
  LOG --> FEED -->|"changes + nextToken"| TAB

4.3 One "changes" endpoint instead of 15+ calls

POST flw-api/stoptb/sync/changes, served by FLW-API wherever the change log is installed. Tablets call it on their camp hub (or UAT central when testing).

Request:  { token: "<opaque or null>", scope: { villageIds: [...] }, limit: 500 }
Response: { serverId, items: [ { type: "beneficiary" | "household" | "tbScreening" | "generalExam" | "generalOpdVisit"
                                       | "diagnostics" | "suspected" | "confirmed" | "diagnosticOrder" | "formResponse",
                                 op: "upsert" | "delete", key: {...}, version: <seq>, data: {...} } ],
            nextToken, hasMore, snapshot: true|false }

Diagram 3: Changes feed: snapshot, then changes (Mermaid source below; image attachment d3.png)

sequenceDiagram
  participant T as Tablet
  participant H as Camp hub (FLW-API)
  participant L as Change log
  T->>H: changes(token=null, scope)
  H->>L: note current safe point S
  loop snapshot pages (keyset)
    H-->>T: items (snapshot=true), nextToken=page cursor
  end
  H-->>T: last page, nextToken={hubId, S}
  Note over T: saves token in encrypted prefs
  loop every tick (camp) / on reconnect
    T->>H: changes(token={hubId, S})
    H->>L: seq > S AND scope, ≤ safe point
    H-->>T: only the changed rows (often 0), nextToken={hubId, S'}
  end
  T->>H: changes(token={otherHub, X})
  H-->>T: token is from another server → snapshot again

4.4 Options considered

Option

Correct?

Cost

Verdict

A. LastModDate > X as it is today

No (§4.1)

None

Rejected

B. Fix every write path and add ON UPDATE, indexes and deleted flags; >= X - lag

Mostly; keeps breaking as new code is written; hub↔central timestamps still need a fix in the sync code

Many small changes in 5 repos

Fallback if triggers are refused

C. Trigger-filled change log plus one changes endpoint

Yes, for every write path including deletes and hub↔central sync

One migration per schema, one endpoint, payload builders

Recommended

Things to check for option C: how much the triggers slow writes on i_ben_flow_outreach and the identity tables (measure on a hub), that a trigger never causes an insert to fail (keep the body minimal), and that the log is never replicated: it stays on each camp laptop.

4.5 How it works end to end

What changes besides the log table

Where

Change

Needed for delta pull?

AMRIT-DB (main migrations, every server)

t_sync_change_log, triggers on each STOP-TB table, hourly clean-up of old log lines

Yes

FLW-API: new endpoint /stoptb/sync/changes

Reads the log and returns the changed records

Yes

FLW-API: record builders

The code that turns a database row into the JSON the app receives already exists inside today's getAll endpoints; reuse it, made to work on a batch of IDs instead of a whole village

Yes (refactor, not new logic)

Common/FLW user detail

Fix the get(0) bug so all of a user's villages are returned

Yes, for full offline data

Existing pull endpoints (getBeneficiaryData, tbScreening/getAll, …)

No change. Old app versions keep using them.

No

Existing save endpoints

Field ownership and version check (§6)

No; later, for station conflicts

App

New sync engine: one endpoint, saves the token, applies changes and deletes

Yes

How the server returns "everything after line N"

The tablet sends token = {hub 415, N = 1000}, scope villages [12, 14].

Step 1: find what changed. The log query:

SELECT table_name, row_pk, MAX(seq) AS last_seq, MAX(op = 'D') AS deleted
FROM t_sync_change_log
WHERE village_id IN (12, 14) AND seq > 1000 AND seq <= :safePoint
GROUP BY table_name, row_pk
ORDER BY last_seq
LIMIT 500;

This returns a short list, e.g. "beneficiary 900, tb_screening 455, vitals 77". A record changed five times appears once.

Step 2: load the current records, one query per table:SELECT … FROM tb_screening WHERE id IN (455, …), the same for beneficiaries, vitals and so on. The existing builders turn them into JSON; deleted ones go out as op: delete.

Step 3: return the records plus nextToken = {hub 415, last_seq of the last item}. If there were more than 500, hasMore = true and the tablet calls again with the new token.

Every query is either indexed or a lookup by ID, so the work is proportional to what changed, not to the size of the village.

What the tablet does each sync

The tablet never works out which records to fetch. It keeps one bookmark and the hub decides what is new from the log.

What it keeps: the token {hubVanId, seq}, e.g. {415, 1000}, meaning "I have applied everything from hub 415 up to log line #1000". It lives in encrypted prefs.

One sync round:

  1. Ask: send the token and the user's villages: { token: {415, 1000}, scope: { villageIds: [12, 14] }, limit: 500 }.

  2. The hub answers as described above: the changed records in their current state, deletes as op: delete, plus nextToken (e.g. {415, 1003}) and hasMore. If nothing changed, items is empty and nextToken is the safe point.

  3. Apply and save together: in one Room transaction the tablet upserts or deletes the items and then saves nextToken. If the app is killed halfway, the token was not saved, so the next round re-reads the same lines; applying them twice is harmless. Records with unsynced local edits are not overwritten (the same rule as today's pull).

  4. Repeat while hasMore is true, straight away with the new token, until nothing is left.

When the hub sends a snapshot instead of changes:

Situation

Why

No token (first login, re-login, reinstall)

Nothing to compare against

Token from another hub (hubVanId differs)

Line numbers are local to each hub and can't be compared

Token older than the 30-day retention

The log lines it points after have been cleaned up

The normal case in camp: nothing changed, so one indexed lookup returns zero rows and the token stays the same. That is what the 10-second camp loop costs.

Logging in again, a new tablet, or a different hub

This is the snapshot path. It's the same for re-login, a new tablet, a reinstall, a different hub, or a token older than the log's 30-day retention.

So re-login costs one full, properly paged download, then only changes.

Caution on logout: wiping the database at logout deletes records that haven't synced. The logout dialog warns with the count (fixed in PR #321), but it still lets the user go ahead. With tablets working offline in villages, consider blocking logout until everything has synced, or at least requiring a second confirmation (decision 8 in §11).

4.6 Scaling

The change log is one table, but each row is small: a line number, record type, row ID, change type, beneficiary IDs and time. That's roughly 100–150 bytes per line including indexes, and none of it is patient data. The log notes that something changed; the data itself stays in the normal tables.

Size per camp laptop (estimates; to be measured in step 1):

Where

Changes per day

Kept 30 days

Size

One camp hub (~300 people/day, ~50 changes each)

~15,000

~450,000 lines

~60 MB

Why reads stay fast at any size:

Keeping it small:

  1. Hourly clean-up. A MySQL event deletes lines older than the retention period (30 days), in small batches (for example 10,000 rows at a time) so it never holds long locks. The changed_at index keeps each batch quick. At hub scale that's about 15,000 lines a day, which is trivial.

  2. Log the parent, not noisy child rows. Counselling answers are deleted and re-inserted on every save, so 40 questions would mean 80 log lines. Instead, log one line for the parent form response. The same goes for drug and complaint rows under an OPD visit. The feed then sends the parent with its children.

  3. Each server keeps its own log, never synced. A laptop's log is bounded by one camp's activity (about 60 MB at 30 days), whatever the number of camps. Central and state servers also carry a log of their own writes (one small insert per save), cleaned up the same way; nothing reads it there.

Cost on saves: each save also writes one small extra row, in the same transaction. That's microseconds per save, but it must be measured on a real camp laptop (step 1). Saves running at the same time don't block each other on the counter in MySQL's default configuration (innodb_autoinc_lock_mode = 2).

Alternative if triggers are a concern: read MySQL's own change history (the binlog) with a tool such as Debezium. It adds nothing to saves, but needs Kafka or another extra service running on every camp laptop, which is a lot for field hardware. For hubs, triggers are the simpler choice.

5. Problem 2: All data on the tablet

5.1 Scope

Option

Size

Privacy

Notes

Selected village (today)

Small

Lowest exposure

Doesn't work offline across villages

All villages assigned to the user

Typically 4k–20k beneficiaries

Matches existing access rights

Recommended. The app already partly supports this: RecordsRepo.allVillageIds, dashboard assignedVillageIds

The camp's catchment area

Medium

Medium

Needs a camp → village mapping that doesn't exist yet

Whole service area (providerServiceMapID)

100k+

Highest exposure

Rejected as the default

Fix needed first:UserServiceImpl only reads the user's first role-mapping row (getUserRole(userId).get(0)), so villages on other rows are dropped. It should combine villages from all rows.

5.2 Size

Beneficiaries

Records (no photos)

SQLite on tablet

Photos (~50% have one)

4,000

~20 MB

~15–20 MB

~70–100 MB

20,000

~100 MB

~80–100 MB

~350–500 MB

100,000

~500 MB

~400–500 MB

~1.7–2.5 GB

These figures are for the first download. With the changes feed, later pulls move only what changed.

5.3 Photos

5.4 App changes for full scope

6. Problem 3: Station hand-offs and overwrites

6.1 Who writes what

Station (role)

Writes

Registrar

Beneficiary demographics; anthropometry; TB screening (both registrar and nurse have full screening rights)

Nurse

Anthropometry, vitals/general exam, general OPD, TB screening, diagnostics orders, TB suspected, diagnostic order push

Lab technician

Diagnostic results (manualResult, tb_stoptb_diagnostics status/results)

Counsellor

Counselling and contact-tracing forms, TB confirmed, TB suspected, beneficiary death fields

How saves work today:

6.2 What gets lost

#

Scenario

Result

L1

The registrar corrects a name; the nurse's tablet still has the old copy and saves anthropometry

Nurse's push restores the old name. The registrar's next pull overwrites the correct local copy too.

L2

Registrar records 52 kg, nurse 50 kg; later, any push from the registrar's tablet

52 kg is appended as the "latest" anthropometry row again

L3

Nurse saves diagnostics orders, lab tech saves results from an older copy (one row per beneficiary)

Last push wins on every column; nulls erase the other's data

L4

Server writes a lab result into tb_suspected; the nurse or counsellor later pushes their suspected record

modelMapper copies the stale or null result over the real one

L5

Registrar and nurse both screen the same day (same visit key)

The last push replaces every symptom

L6

Two counsellors answer the same form question

Last write wins (lower risk: the merge is per question)

6.3 Fix, in order of value

  1. Field ownership by role, checked on the server and respected in the app:

  2. Partial pushes: send only the fields that changed, and treat a missing key as "leave it alone". The liquidCultureResult handling already does this; extend it to every field.

  3. Version check, using the change log:

  4. "Fetch this beneficiary now": a quick hand-off when a person reaches a station.

Diagram 4: Station hand-off with version check (Mermaid source below; image attachment d4.png)

sequenceDiagram
  participant R as Registrar tablet
  participant H as Camp hub
  participant N as Nurse tablet
  R->>H: push beneficiary (changed fields: name) v=40
  H-->>R: ok, v=57
  Note over N: beneficiary walks up to the nurse
  N->>H: fetch beneficiary 123 (all records)
  H-->>N: beneficiary v=57 + TB rows
  N->>H: push anthropometry (own endpoint, base v=57)
  H-->>N: ok, v=61
  Note over N,H: if the nurse's base were older than the server's version<br/>for a field she owns, she gets a conflict:<br/>re-apply her owned fields, push again
  loop every camp tick
    R->>H: changes(token)
    H-->>R: beneficiary 123 v=61 (weight)
  end

7. Target design

Diagram 5: Target design (Mermaid source below; image attachment d5.png)

flowchart LR
  subgraph Tablet
    UI[Screens<br/>village = view filter]
    DB[(Room DB<br/>whole user scope)]
    SY[Sync engine<br/>token per server,<br/>field-level push]
  end
  subgraph Hub["Camp hub laptop"]
    API["FLW-API<br/>/sync/changes<br/>/beneficiary/:id/all<br/>save endpoints + version check"]
    MY[(MySQL + t_sync_change_log<br/>triggers)]
    PH[Photo store]
  end
  C[(Central)]
  UI --> DB
  DB --> UI
  DB --> SY
  SY --> DB
  SY -->|"changes(token, scope)"| API
  SY -->|"push changed fields + version"| API
  SY -->|"photos, Wi-Fi only, lazy"| PH
  API --> MY
  MY --> API
  MY -->|"upsync"| C
  C -->|"downsync (triggers record arrivals)"| MY

Layer

Change

AMRIT-DB

t_sync_change_log plus triggers (both schemas), in the main migrations dbiemr/V132 and dbidentity/V32, on every server (see below). Everywhere: add deleted to tb_suspected, tb_confirmed_cases, form tables; indexes

FLW-API

/stoptb/sync/changes; /stoptb/beneficiary/{id}/all; version check and field ownership on STOP-TB saves; move the suspected incentive logic out of the read path; multi-village scope

Identity-API

Version check and field ownership on rmnch/syncDataToAmrit; stop the client's updatedDate overwriting LastModDate; anthropometry outside the beneficiary push

Common-API / FLW user detail

Combine villages from every role-mapping row (get(0) bug)

MMU-API (hub sync)

Nothing strictly required: triggers on the hub record rows arriving by downsync. Never add t_sync_change_log to the upsync table list. Fix the accidental LastModDate bumps for cleanliness.

STOP-TB-App

Sync engine on the changes feed with a token per server; full-scope sync; village as a view filter; lazy photos; field-level push with conflict handling; on-demand fetch; Paging 3

How the change log is installed. It is part of AMRIT-DB's main migrations (dbiemr/V132__Sync_Change_Log.sql, dbidentity/V32__Sync_Change_Log_Beneficiary.sql), so it runs on every server with the next db.war: no .env setting, no new database, nothing to do on the 140 camp laptops.

8. Order of work

Step

Work

Repos

Unlocks

0

Quick app-only trims: master data at most once a day; confirmed cases scoped by village

App

Less load now (small)

1

Change log plus triggers on a hub-sized DB; measure write overhead

AMRIT-DB

Everything below

2

/sync/changes: snapshot plus changes, keyset paging, safe point, token per server; payload builders for all types; no photos

FLW-API

R1–R3

3

App sync engine on the changes feed (one dataset type at a time, falling back to the old endpoints), token in encrypted prefs

App

R1–R3 on devices

4

Full scope: combined user villages, multi-village feed, village as a view filter, Paging 3 for big lists

Common/FLW-API, App

R4

5

Photos as references plus lazy encrypted download

FLW/Identity, App

R4 at 20k+

6

Field ownership: split the anthropometry push, stop whole-record dirtying, server ignores fields the user's role doesn't own, modelMapper skips nulls

Identity, FLW, App

Removes most L1–L5

7

Version check on push (conflict + re-apply owned fields); fetch-beneficiary-now

FLW, Identity, App

R5, R6

8

Delete flags and app handling of deletes

AMRIT-DB, FLW, App

R2 deletes

Steps 1–3 deliver "what changed since X?". Step 4 can start in parallel once step 2 has its scope parameter. Steps 6–7 can run in parallel with 4–5.

9. Testing and measurement

10. Rollout

  1. AMRIT-DB: one db.war release goes everywhere and installs the change log and triggers on every server (main migrations V132 / V32, §7). Check the privileges in §7 on central and state servers first. Delete flags and indexes go everywhere. Test on UAT central first.

  2. FLW-API / Identity-API release with the new endpoints. Old apps are unaffected.

  3. App release that uses the changes feed when the hub offers it, and old endpoints otherwise.

  4. Field ownership and version checks are switched on per endpoint once every app in the field sends versions. Until then, a push without a version is accepted as it is today.

11. Decisions needed

  1. Change tracking: do you accept DB triggers plus a change log (recommended), or should we go with the fallback of fixing timestamps everywhere?

  2. Scope: is it all villages assigned to the user (recommended), the camp catchment, or the whole service area?

  3. Photos: lazy and Wi-Fi only, or none on the tablet unless the record is opened?

  4. Field ownership map: confirm which role owns which fields, especially screening (registrar and nurse both edit it today) and death/outcome (counsellor).

  5. Deletes on the tablet: remove the row, or keep it and hide it?

  6. Retention: how long to keep the change log (30 days is suggested), and what a tablet that has been away longer gets (a fresh snapshot).

  7. Old-data policy: what happens to data on a tablet when the user is no longer assigned to a village?

  8. Logout with unsynced records: block logout until everything has synced, or allow it after a second confirmation? Logout wipes the tablet's database, so unsynced records are lost.

12. Appendix: evidence

13. Implementation status (2026-10-29)

Built and pushed, not yet merged or deployed. PRs: AMRIT-DB #225, FLW-API #393, STOP-TB-App #322.

Repo

Branch

What

AMRIT-DB

feature/stoptb-sync-change-log

Main migrations dbiemr/V132 (log table, 53 triggers incl. t_prescription for OPD notes, hourly clean-up event, flow-table indexes) and dbidentity/V32 (27 beneficiary triggers). Tested on a fresh MySQL 8.0 with binlog on and no extra settings: all migrations apply, triggers log as expected.

FLW-API

feature/stoptb-sync-changes-feed

POST /stoptb/sync/changes (snapshot then delta, types filter, {supported:false} fallback); by-ID variants of every record builder; StopTBSyncCoverageTest (every mapped table is synced or listed as not synced); token tests.

STOP-TB-App

feature/stoptb-changes-feed, PR #322 to release-2.3 (replaces #321 and contains it)

ChangesFeedRepo; PullFromAmritWorker uses the feed first and falls back to the full pull; PullTBFromAmritWorker skips the datasets the feed covered.

Differences from the design above:

Not done yet: