Skip to content

A ServerState update landing during a save is marked saved and never written to disk #916

Description

@vharseko

Symptom

SchemaReplicationTest.pushSchemaFilesChange fails intermittently in CI. Seen on build-maven (ubuntu-latest, 21) of run 33668788856 (PR #893, whose change touches only the JDBC backend); the other eleven jobs of the same run, running the same 32152 tests, passed.

[ERROR] SchemaReplicationTest.pushSchemaFilesChange:233
  The Schema persistentState (CSN:000001a063a153b7000100000003) has not been saved to
  .../SchemaReplicationTest/package-instance/config/schema/99-user.ldif : dn: cn=schema
  ...
  ds-sync-state: 000001a063a15094000100000002
  ds-sync-state: 000001a063a15226000200000001
  modifyTimestamp: 20260902193831Z
  expected [true] but found [false]

The test published a schema change, received the ModifyMsg back from the broker, and then waited 10 s for the CSN of that change to reach 99-user.ldif (SchemaReplicationTest.java:229-241). It never did: the file still carried serverId 1, seq 2 while serverId 1, seq 3 was awaited, and its modifyTimestamp — 19:38:31 — predates the start of the test method (19:38:32.3). Over the whole 10 s window the file was not rewritten once, although the checkpointer ticks every second.

What the run rules out

  • The checkpointer thread was alive and idle in its normal wait — the dump taken on failure shows "Replica DS(1) state checkpointer for domain "cn=schema"" daemon Id=94 TIMED_WAITING on LDAPReplicationDomain$ServerStateFlush.
  • The write was not attempted and rejected: runUpdateStateEntry() logs DEBUG_ERROR_UPDATING_RUV on any result code other than success, and the server error log is silent between 19:38:32 and the timer giving up at 19:38:42.
  • The state did reach memory: ReplicationDomain.publish() calls state.update(csn) synchronously (ReplicationDomain.java:3659-3667), and the test had already received the message that publish sent.
  • A REPLACE of ds-sync-state does rewrite the file synchronously inside the modify — SchemaBackend puts a non-schema attribute into extraAttributes and adds FILE_USER_SCHEMA_ELEMENTS to modifiedSchemaFiles (SchemaBackend.java:577-592).

So the modify that would have written the awaited CSN was never issued.

The race

PersistentServerState.save() decides whether to write from a flag that the write itself clears:

// PersistentServerState.java:108-114
public void save()
{
  if (!state.isSaved())
  {
    state.setSaved(updateStateEntry());
  }
}

updateStateEntry() serialises the state once, at the top of runUpdateStateEntry() — new LDAPAttribute(REPLICATION_STATE, state.toASN1ArrayList()) — and only then runs the modify, which rewrites 99-user.ldif and takes hundreds of milliseconds. ServerState.update() sets saved = false unconditionally (ServerState.java:78-85).

A publish() landing after the snapshot is taken and before setSaved(true) is therefore lost: its CSN is not in the attribute being written, and the saved = false it set is overwritten by the setSaved(true) of the write that did not carry it. Nothing sets the flag again, so the state is not written until the next meaningful update on that domain.

On a busy domain the next update arrives shortly and the loss heals itself, which is why this is not seen elsewhere. cn=schema is the domain where changes are rare, so a lost flag can leave the persisted state stale indefinitely — and that is exactly what the test observes: one save at 19:38:31, and then silence.

Impact beyond the test

ds-sync-state on disk can lag the replica's real state by an unbounded amount, until some later change happens to arrive. After a crash or restart the domain resumes from that stale point, and for the schema domain the window can be the whole life of the server. It is the safe direction (changes are replayed rather than skipped), but it defeats the checkpointing the flush thread exists to provide, and it makes any test — or operator — that reads the persisted state see something arbitrarily old.

Suggested fix

Clear the flag before the snapshot rather than after the write, so that an update racing with the write leaves the flag set for the next tick:

public void save()
{
  if (!state.isSaved())
  {
    state.setSaved(true);          // an update() from here on clears it again
    if (!updateStateEntry())
    {
      state.setSaved(false);
    }
  }
}

The cost is at most one redundant write per race. A version counter compared across the write would achieve the same without that redundancy.

A regression test can drive it directly: hold the modify (or simply publish a change while a save is in flight) and assert that the following tick writes the newer CSN.

Activity

  1. vharseko commented on Sep 7, 2026

    @vharseko
    MemberAuthor

    Walked the code for this. The diagnosis holds, the failure data pins the race window tighter than the description does, and one of the arguments in the description does not survive contact with the modify pipeline.

    Confirmed against the code

    • The published CSN reaches the shared ServerState synchronously: processSchemaChange → synchronizeSchemaModifications (LDAPReplicationDomain.java:4485) → synchronize (:2116, publish at :2170) → PendingChanges.pushCommittedChanges (PendingChanges.java:166) → ReplicationDomain.publish → state.update(csn) (ReplicationDomain.java:3659-3666), and ModifyMsg does contribute to the domain state (UpdateMsg.java:293).
    • PersistentServerState wraps the same ServerState instance the domain publishes into (LDAPReplicationDomain.java:695 passes getServerState()), so the saved flag is shared between the publisher and the checkpointer.
    • setSaved(true) has exactly one origin — save() (PersistentServerState.java:112). There is no other way for the flag to be lost, which rules out the alternative "something else marked it clean" explanations.
    • The ds-sync-state REPLACE does rewrite 99-user.ldif inside the modify: SchemaBackend.java:577-593 (non-schema attribute → extraAttributes + FILE_USER_SCHEMA_ELEMENTS) → SchemaHandler.updateSchemaAndSchemaFiles under exclusiveLock (SchemaHandler.java:434-448).

    The failure data pins the window to 401 ms

    Decoding the CSNs in the dumped file, and the one being awaited:

    CSN time what it is
    000001a063a15094000100000002 19:38:31.444 the local DELETE from pushSchemaChange
    000001a063a15226000200000001 19:38:31.846 the remote change replayed by replaySchemaChange (serverId 2)
    000001a063a153b7000100000003 19:38:32.247 the change awaited by pushSchemaFilesChange

    A replayed CSN enters the state only in the post-operation commit (RemotePendingChanges.java:238-240), i.e. after the replay itself has rewritten the schema files. So the file that carries serverId 2, seq 1 can only have been written by a state save whose snapshot was taken between 19:38:31.846 and 19:38:32.247 — and whose setSaved(true) necessarily landed after 19:38:32.247, otherwise the state.update() of that publish would have left the flag dirty and the next tick (≤ 1 s) would have written it. That is exactly the interleaving described, with the snapshot-to-completion window bounded by 401 ms.

    Why the write was slow enough to straddle the publish: the checkpointer's modify waits for SchemaHandler's exclusiveLock (SchemaHandler.java:437), which the concurrent replay was holding while rewriting the schema files. As a second-order effect, state.save() runs inside synchronized (this) next to wait(1000) (LDAPReplicationDomain.java:558-568), so a slow save also stretches the following tick.

    Correction: the modifyTimestamp argument does not hold

    its modifyTimestamp — 19:38:31 — predates the start of the test method … Over the whole 10 s window the file was not rewritten once

    modifyTimestamp is put into the entry by LastModPlugin (LastModPlugin.java:179-186), and pre-operation plugins are skipped for synchronization operations (LocalBackendModifyOperation.java:323-334) — which is what runUpdateStateEntry marks its modify as (PersistentServerState.java:263). Neither a state save nor a replay refreshes that attribute; the value in the file is left over from the last client modify (the DELETE at 19:38:31.444). The file was rewritten inside the window — it could not otherwise contain serverId 2, seq 1 — it just kept the stale timestamp. The conclusion is unchanged: the file's contents prove the lost flag more strongly than its timestamp ever could.

    The suggested patch needs a companion change

    Clearing the flag before the snapshot is not sufficient on its own, because ServerState.update() publishes saved = false before mutating the map (ServerState.java:85, with the putIfAbsent/replace at :93/:102). The interleaving saved = false → [saver: setSaved(true), snapshot] → put still loses the CSN — a narrower window, same bug.

    The dirty marker has to be published after the mutation it advertises. With both halves in place the invariant holds for every interleaving: if the put precedes the snapshot the CSN is in the write; if it follows the snapshot then its saved = false necessarily follows setSaved(true), so the next tick writes it. The cost is at most one redundant write per race. Moving the assignment also stops a non-meaningful update() (an older or duplicate CSN) from marking the state dirty. A version counter buys nothing extra here — it needs the same "increment after the mutation" ordering.

    One more point, independent of the race above: save() has no mutual exclusion while being called from three different threads — ServerStateFlush (LDAPReplicationDomain.java:566, :576), disable() (:3871) and backupStart() (:4113). Two overlapping saves can complete out of order, leaving the older snapshot on disk and the flag clean.

    Regression test

    Deterministic and thread-free: extract the modify execution of runUpdateStateEntry behind a package-private seam, and in PersistentServerStateTest (same package, already runs against a started server) override it so that a state.update(lateCSN) lands inside the write — i.e. after the snapshot has been taken. Then save() must leave the state dirty (fails on current code), and the following save() must put lateCSN on disk, verified by reloading through a fresh PersistentServerState. SchemaReplicationTest.pushSchemaFilesChange needs no change: with the fix the worst case is a write one tick later, well inside its 10 s budget.

  2. added a commit that references this issue on Sep 8, 2026
  3. added a commit that references this issue on Sep 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugconcurrencyThread-safety / race-condition bugsreplicationtestsTest suites: fixing, enabling, un-disabling

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions