8000 fix(offset): markProcessed runs outside OFFSET_COMMIT_LOCK, leaking the flush semaphore by minguyen9988 · Pull Request #1447 · Altinity/clickhouse-sink-connector · GitHub
[go: up one dir, main page]

Skip to content

fix(offset): markProcessed runs outside OFFSET_COMMIT_LOCK, leaking the flush semaphore - #1447

Merged
minguyen9988 merged 1 commit into
2.11.0from
fix-offset-commit-serialization-flush-leak
Sep 12, 2026
Merged

minguyen9988 merged 1 commit into
2.11.0from
fix-offset-commit-serialization-flush-leak

Conversation

@minguyen9988
@minguyen9988 minguyen9988 commented Sep 12, 2026 •
Copy link
Copy Markdown
Collaborator

fix(offset): markProcessed runs outside OFFSET_COMMIT_LOCK, leaking the flush semaphore

acknowledgeRecords() serializes only markBatchFinished() (via
markBatchFinishedSafely) and leaves markProcessed() outside the lock. That is
not sufficient: markProcessed() mutates the OffsetStorageWriter's
pending-offset map, which is exactly the state beginFlush() snapshots, and
neither is thread-safe. Debezium also builds a NEW RecordCommitter per batch
(EmbeddedEngine.buildRecordCommitter), so the committer's own synchronized
methods lock different monitors for different batches and provide no mutual
exclusion across worker threads.

Once two worker threads collide, EmbeddedEngine.commitOffsets returns early
when doFlush returns null WITHOUT calling cancelFlush, permanently leaking the
flushInProgress semaphore. Every subsequent beginFlush() in the JVM throws
ConnectException: OffsetStorageWriter is already flushing.

Two further behaviours turn a stuck writer into SILENT DIVERGENCE rather than
a visible failure:

  • ClickHouseBatchRunnable routes the failure through
    ClickHouseErrorClassifier, which sees no ClickHouse error code and
    defaults it to UNKNOWN/retriable. With errors.max.retries = -1 it retries
    forever.
  • JdbcOffsetBackingStore.set is asynchronous, so ClickHouse inserts keep
    succeeding against a frozen binlog position.

Observed in production on a MySQL -> ClickHouse deployment running 2.11.0: the
connector logged healthy batches for ~8h while its committed offset never
advanced past an event 8 hours old, and on restart the binlog client resumed
nine binlog files (~10 GiB) behind the position the dying process had been
reading. 3,930 occurrences of the exception in that window. Three sibling
connectors on 2.9.1 against the same ClickHouse host logged zero, as did a
second 2.11.0 connector running roughly a tenth of the volume -- the defect
needs two batch completions in flight.

Changes:

  • DebeziumOffsetManagement: markProcessed() and markBatchFinished() now run
    inside ONE OFFSET_COMMIT_LOCK critical section in both acknowledgeRecords
    variants. markBatchFinishedSafely is replaced by a public acknowledgeRecord
    entry point so every offset-committing path funnels through the same lock.
  • ClickHouseBatchWriter: its second, unserialized acknowledgement path --
    which called markProcessed()/markBatchFinished() directly, bypassing the
    lock entirely -- now routes through acknowledgeRecord. A poisoned writer is
    rethrown rather than logged and swallowed, and an InterruptedException
    re-sets the interrupt flag instead of continuing to acknowledge.
  • ClickHouseBatchRunnable: a poisoned writer is detected BEFORE the
    ClickHouse classifier and stops the task, so it can never be retried
    forever as UNKNOWN. A restart resumes from the last committed offset.

No lock-order change: acknowledgeRecord takes only OFFSET_COMMIT_LOCK, never
the class monitor.

Testing (2.11.0 @ af198d9, java-17, maven 3.9.11):

OffsetCommitSerializationTest reproduces the defect deterministically with a
committer that fails if any two offset-mutating calls overlap.

with this fix:                    3 run, 0 failures
acknowledgeRecord reverted to
the partial-lock shape:           3 run, 2 FAILURES, both naming
                                  markProcessed as the overlapping call

Full module suite, same tree, no Docker available on the runner:

unmodified 2.11.0:  Tests run: 344, Failures: 0, Errors: 20
with this fix:      Tests run: 347, Failures: 0, Errors: 20

The 20 errors are identical in both runs and are all
"Previous attempts to find a Docker environment failed" from the
container-backed tests; they are pre-existing and unrelated.

…he flush semaphore

acknowledgeRecords() serializes only markBatchFinished() (via
markBatchFinishedSafely) and leaves markProcessed() outside the lock. That is
not sufficient: markProcessed() mutates the OffsetStorageWriter's
pending-offset map, which is exactly the state beginFlush() snapshots, and
neither is thread-safe. Debezium also builds a NEW RecordCommitter per batch
(EmbeddedEngine.buildRecordCommitter), so the committer's own synchronized
methods lock different monitors for different batches and provide no mutual
exclusion across worker threads.

Once two worker threads collide, EmbeddedEngine.commitOffsets returns early
when doFlush returns null WITHOUT calling cancelFlush, permanently leaking the
flushInProgress semaphore. Every subsequent beginFlush() in the JVM throws
ConnectException: OffsetStorageWriter is already flushing.

Two further behaviours turn a stuck writer into SILENT DIVERGENCE rather than
a visible failure:

  - ClickHouseBatchRunnable routes the failure through
    ClickHouseErrorClassifier, which sees no ClickHouse error code and
    defaults it to UNKNOWN/retriable. With errors.max.retries = -1 it retries
    forever.
  - JdbcOffsetBackingStore.set is asynchronous, so ClickHouse inserts keep
    succeeding against a frozen binlog position.

Observed in production on a MySQL -> ClickHouse deployment running 2.11.0: the
connector logged healthy batches for ~8h while its committed offset never
advanced past an event 8 hours old, and on restart the binlog client resumed
nine binlog files (~10 GiB) behind the position the dying process had been
reading. 3,930 occurrences of the exception in that window. Three sibling
connectors on 2.9.1 against the same ClickHouse host logged zero, as did a
second 2.11.0 connector running roughly a tenth of the volume -- the defect
needs two batch completions in flight.

Changes:

  - DebeziumOffsetManagement: markProcessed() and markBatchFinished() now run
    inside ONE OFFSET_COMMIT_LOCK critical section in both acknowledgeRecords
    variants. markBatchFinishedSafely is replaced by a public acknowledgeRecord
    entry point so every offset-committing path funnels through the same lock.
  - ClickHouseBatchWriter: its second, unserialized acknowledgement path --
    which called markProcessed()/markBatchFinished() directly, bypassing the
    lock entirely -- now routes through acknowledgeRecord. A poisoned writer is
    rethrown rather than logged and swallowed, and an InterruptedException
    re-sets the interrupt flag instead of continuing to acknowledge.
  - ClickHouseBatchRunnable: a poisoned writer is detected BEFORE the
    ClickHouse classifier and stops the task, so it can never be retried
    forever as UNKNOWN. A restart resumes from the last committed offset.

No lock-order change: acknowledgeRecord takes only OFFSET_COMMIT_LOCK, never
the class monitor.

Testing (2.11.0 @ af198d9, java-17, maven 3.9.11):

  OffsetCommitSerializationTest reproduces the defect deterministically with a
  committer that fails if any two offset-mutating calls overlap.

    with this fix:                    3 run, 0 failures
    acknowledgeRecord reverted to
    the partial-lock shape:           3 run, 2 FAILURES, both naming
                                      markProcessed as the overlapping call

  Full module suite, same tree, no Docker available on the runner:

    unmodified 2.11.0:  Tests run: 344, Failures: 0, Errors: 20
    with this fix:      Tests run: 347, Failures: 0, Errors: 20

  The 20 errors are identical in both runs and are all
  "Previous attempts to find a Docker environment failed" from the
  container-backed tests; they are pre-existing and unrelated.
@minguyen9988 minguyen9988 changed the title WIP: DO NOT MERGE - fix(offset): markProcessed runs outside OFFSET_COMMIT_LOCK, leaking the flush semaphore fix(offset): markProcessed runs outside OFFSET_COMMIT_LOCK, leaking the flush semaphore Sep 12, 2026
@minguyen9988
minguyen9988 merged commit 50cd189 into 2.11.0 Sep 12, 2026
9 of 10 checks passed
@minguyen9988
minguyen9988 deleted the fix-offset-commit-serialization-flush-leak branch September 12, 2026 13:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

0