Skip to content

Add AsyncMappingWatcher and newChildAsync#1272

Merged
jrhee17 merged 20 commits intoline:mainfrom
m50d:watcher-mapasync
Mar 16, 2026
Merged

Add AsyncMappingWatcher and newChildAsync#1272
jrhee17 merged 20 commits intoline:mainfrom
m50d:watcher-mapasync

Conversation

@m50d
Copy link
Contributor

@m50d m50d commented Mar 9, 2026

Sometimes we may want to transform a Watcher with an asynchronous method. This PR creates an async version of MappingWatcher and adds a method to be used to create one.

  • Use an AtomicReference and slightly more advanced revision-comparison logic to ensure that an earlier update whose transform is slow cannot overwrite a later update
  • No support for custom executors - since the transformation function is returning a CompletableFuture, it can invoke a custom executor itself without needing any support from our side

@CLAassistant
Copy link

CLAassistant commented Mar 9, 2026

CLA assistant check
All committers have signed the CLA.

@coderabbitai
Copy link

coderabbitai bot commented Mar 9, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This pull request introduces asynchronous mapping capabilities to the Watcher API. The newChildAsync method enables watchers to transform values using async operations returning CompletableFuture. The MappingWatcher implementation is refactored to handle asynchronous mapping with proper failure handling and state management.

Changes

Cohort / File(s) Summary
Async Mapping API
client/java/src/main/java/com/linecorp/centraldogma/client/Watcher.java
Adds new newChildAsync method for asynchronous value transformation. Existing newChild(Function, Executor) now delegates to the async pathway via newChildAsync.
Async Mapping Implementation
client/java/src/main/java/com/linecorp/centraldogma/client/MappingWatcher.java
Reworked to support async mapping: mapper now returns CompletableFuture<U>. Introduces initialValueFuture, mappedLatest state tracking, centralized failure handling via reportFailure, and helper methods isUpdate(), notifyListener(), and notifyListeners().
Async Mapping Tests
client/java-armeria/src/test/java/com/linecorp/centraldogma/client/armeria/WatcherTest.java
Adds mapperAsyncFailure and mapperAsyncException tests validating that downstream watchers handle async mapper failures. Updates multipleNewChild to use newChildAsync with completed futures.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested reviewers

  • minwoox
  • trustin

Poem

🐰 A mapper's dance, now swift and free,
Futures flowing asynchronously,
Failures caught before they spread,
Child watchers wake with care and dread,
Async magic, complete indeed! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 6.06% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: introducing an async mapping capability via a new newChildAsync method and an async watcher implementation.
Description check ✅ Passed The description clearly relates to the changeset, explaining the motivation for async transformation of watchers and the design decisions (AtomicReference for concurrency safety, no custom executor support).

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
client/java-armeria/src/test/java/com/linecorp/centraldogma/client/armeria/WatcherTest.java (1)

125-154: Please add a reverse-completion regression test.

These tests cover exceptional paths, but the fragile part of AsyncMappingWatcher is the out-of-order success path. A deterministic test that completes revision N+1 before revision N would lock down the isUpdate(...)/AtomicReference logic this PR is introducing.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@client/java-armeria/src/test/java/com/linecorp/centraldogma/client/armeria/WatcherTest.java`
around lines 125 - 154, Add a deterministic regression test that exercises
AsyncMappingWatcher when an async mapping for revision N+1 completes before
revision N to validate the isUpdate/AtomicReference logic: create a watcher
(like in mapperAsyncFailure), capture its initialValueFuture(), then create two
CompletableFutures for successive revisions and feed them to the watcher via
newChildAsync so you can complete the N+1 future before completing N, then
assert the watcher resolves to the N+1 result (use
watcher.initialValueFuture().join() or appropriate assertion) and close the
original watcher; reference AsyncMappingWatcher behavior by using newChildAsync,
isUpdate semantics and AtomicReference expectations in the test name (e.g.,
mapperAsyncReverseCompletion) so the test deterministically verifies
out-of-order success handling.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@client/java/src/main/java/com/linecorp/centraldogma/client/AsyncMappingWatcher.java`:
- Around line 94-101: The mapper.apply(value) call in AsyncMappingWatcher can
return null which causes a NPE at mappedValueFuture.whenComplete and bypasses
the failure handling; update the code after calling mapper.apply(value) (the
mappedValueFuture variable) to check for null and if null call
reportFailure.accept(new NullPointerException("mapper returned null")) and also
completeExceptionally(initialValueFuture, sameException) or otherwise ensure
initialValueFuture is completed exceptionally, then return so execution does not
reach mappedValueFuture.whenComplete; reference symbols: AsyncMappingWatcher,
mapper.apply, mappedValueFuture, reportFailure, initialValueFuture,
whenComplete.
- Around line 101-118: The whenComplete handler for mappedValueFuture must
ignore completions after the watcher has been closed: inside the
mappedValueFuture.whenComplete((mappedValue, e) -> { ... }) lambda, check the
watcher shutdown flag (e.g. closed or a similar AtomicBoolean) at the top and
return immediately if closed to avoid calling reportFailure.accept(e), updating
mappedLatest, notifyListeners or completing initialValueFuture for in-flight
futures; keep the existing failure handling only when not closed so late
completions cannot overwrite mappedLatest or trigger listeners on a closed
AsyncMappingWatcher.

---

Nitpick comments:
In
`@client/java-armeria/src/test/java/com/linecorp/centraldogma/client/armeria/WatcherTest.java`:
- Around line 125-154: Add a deterministic regression test that exercises
AsyncMappingWatcher when an async mapping for revision N+1 completes before
revision N to validate the isUpdate/AtomicReference logic: create a watcher
(like in mapperAsyncFailure), capture its initialValueFuture(), then create two
CompletableFutures for successive revisions and feed them to the watcher via
newChildAsync so you can complete the N+1 future before completing N, then
assert the watcher resolves to the N+1 result (use
watcher.initialValueFuture().join() or appropriate assertion) and close the
original watcher; reference AsyncMappingWatcher behavior by using newChildAsync,
isUpdate semantics and AtomicReference expectations in the test name (e.g.,
mapperAsyncReverseCompletion) so the test deterministically verifies
out-of-order success handling.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 2a9873af-996f-4a71-ade0-8d8c2362e190

📥 Commits

Reviewing files that changed from the base of the PR and between f6e3d78 and 37b9a90.

📒 Files selected for processing (3)
  • client/java-armeria/src/test/java/com/linecorp/centraldogma/client/armeria/WatcherTest.java
  • client/java/src/main/java/com/linecorp/centraldogma/client/AsyncMappingWatcher.java
  • client/java/src/main/java/com/linecorp/centraldogma/client/Watcher.java

Comment on lines +94 to +101
final CompletableFuture<? extends U> mappedValueFuture;
try {
mappedValueFuture = mapper.apply(value);
} catch (Exception e) {
reportFailure.accept(e);
return;
}
mappedValueFuture.whenComplete((mappedValue, e) -> {
Copy link

@coderabbitai coderabbitai bot Mar 9, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Reject null futures from the mapper.

If mapper.apply(value) returns null, Line 101 throws outside the current failure path, so the child watcher can fail without going through reportFailure(...) or completing initialValueFuture consistently.

Suggested fix
             final CompletableFuture<? extends U> mappedValueFuture;
             try {
-                mappedValueFuture = mapper.apply(value);
+                mappedValueFuture = requireNonNull(mapper.apply(value),
+                                                  "mapper.apply() returned null CompletableFuture");
             } catch (Exception e) {
                 reportFailure.accept(e);
                 return;
             }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
final CompletableFuture<? extends U> mappedValueFuture;
try {
mappedValueFuture = mapper.apply(value);
} catch (Exception e) {
reportFailure.accept(e);
return;
}
mappedValueFuture.whenComplete((mappedValue, e) -> {
final CompletableFuture<? extends U> mappedValueFuture;
try {
mappedValueFuture = requireNonNull(mapper.apply(value),
"mapper.apply() returned null CompletableFuture");
} catch (Exception e) {
reportFailure.accept(e);
return;
}
mappedValueFuture.whenComplete((mappedValue, e) -> {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@client/java/src/main/java/com/linecorp/centraldogma/client/AsyncMappingWatcher.java`
around lines 94 - 101, The mapper.apply(value) call in AsyncMappingWatcher can
return null which causes a NPE at mappedValueFuture.whenComplete and bypasses
the failure handling; update the code after calling mapper.apply(value) (the
mappedValueFuture variable) to check for null and if null call
reportFailure.accept(new NullPointerException("mapper returned null")) and also
completeExceptionally(initialValueFuture, sameException) or otherwise ensure
initialValueFuture is completed exceptionally, then return so execution does not
reach mappedValueFuture.whenComplete; reference symbols: AsyncMappingWatcher,
mapper.apply, mappedValueFuture, reportFailure, initialValueFuture,
whenComplete.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Mapper cannot actually return null given the function type?

Copy link

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
# Check for `@NullMarked` annotations in the relevant package
fd 'package-info.java' --exec grep -l 'NullMarked' {}

# Also check for NullMarked at the class level in the same package
rg '@NullMarked' client/java/src/main/java/com/linecorp/centraldogma/client/ -n

# Check if NullAway or similar is configured
rg 'NullAway\|nullaway\|NullMarked' build.gradle --include='*.gradle' -rn 2>/dev/null || true
rg 'NullAway\|nullaway\|NullMarked' --type=gradle -rn 2>/dev/null || true

Length of output: 6362


@m50d, you are correct. The com.linecorp.centraldogma.client package is annotated with @NullMarked (Line 21 of package-info.java), so under JSpecify semantics the unannotated return type CompletableFuture<? extends U> is implicitly @NonNull. A mapper that returns null already violates the declared contract at the static-analysis level, making the defensive requireNonNull unnecessary. I'll withdraw this suggestion.

@minwoox minwoox added this to the 0.81.0 milestone Mar 9, 2026
@minwoox
Copy link
Contributor

minwoox commented Mar 9, 2026

Thanks for the PR!
It seems like some logic is duplicated in the MappingWatcher implementation. How about extracting the shared logic into an AbstractMappingWatcher that both classes can extend?

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
client/java/src/main/java/com/linecorp/centraldogma/client/MappingWatcher.java (1)

52-58: ⚠️ Potential issue | 🔴 Critical

Use the inherited closed flag here.

After the AbstractMappingWatcher refactor, this class still has its own closed field at Line 54. AbstractMappingWatcher.close() updates the base flag, but Line 65 reads the shadow field, so a watcher that has already been closed can still remap parent updates and mutate mappedLatest.

Suggested fix
     `@Nullable`
     private volatile Latest<U> mappedLatest;
-    private volatile boolean closed;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@client/java/src/main/java/com/linecorp/centraldogma/client/MappingWatcher.java`
around lines 52 - 58, Remove the shadowed private volatile boolean closed from
MappingWatcher and stop reading that field; use the inherited closed flag from
AbstractMappingWatcher instead (replace any checks that reference the local
"closed" with the superclass's closed state or its accessor, e.g.,
super.isClosed() or the inherited protected field). Ensure MappingWatcher only
mutates mappedLatest when the inherited closed flag indicates the watcher is
open, and verify AbstractMappingWatcher.close() semantics remain relied upon for
lifecycle state.
♻️ Duplicate comments (1)
client/java/src/main/java/com/linecorp/centraldogma/client/AsyncMappingWatcher.java (1)

86-93: ⚠️ Potential issue | 🟠 Major

Reject null futures from mapper.apply(...).

If mapper.apply(value) returns null, Line 93 throws before reportFailure runs, so the child can stay open with initialValueFuture still incomplete. Keep the null check inside the try so it follows the normal failure path.

Suggested fix
             final CompletableFuture<? extends U> mappedValueFuture;
             try {
-                mappedValueFuture = mapper.apply(value);
+                mappedValueFuture = requireNonNull(mapper.apply(value),
+                                                  "mapper.apply() returned null CompletableFuture");
             } catch (Exception e) {
                 reportFailure.accept(e);
                 return;
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@client/java/src/main/java/com/linecorp/centraldogma/client/AsyncMappingWatcher.java`
around lines 86 - 93, The mapper.apply(...) call in AsyncMappingWatcher
currently allows a null CompletableFuture to escape the try/catch, which causes
a later NPE and leaves the child watcher in an inconsistent state; wrap the null
check inside the same try block that calls mapper.apply(value) (i.e., after
assigning mappedValueFuture) and, if mappedValueFuture is null, call
reportFailure.accept(new NullPointerException("mapper returned null")) and
return so the failure follows the normal error path and initialValueFuture
doesn't remain unresolved.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@client/java/src/main/java/com/linecorp/centraldogma/client/AbstractMappingWatcher.java`:
- Around line 86-98: The notifyListeners loop checks the closed flag only before
enqueuing, allowing callbacks to run after close and bypasses notifyListener for
same-thread dispatch which skips exception isolation/logging; to fix, move the
closed check inside the dispatched task (or at the start of notifyListener) so
every invocation verifies closed at execution time, and always invoke
notifyListener (even when currentExecutor == executor) via the
executor.execute(...) path or a unified dispatch helper so exceptions are
handled/logged consistently; update references: notifyListeners, notifyListener,
closed, and the watch catch-up invocation to use the same dispatch helper so
post-close and initial catch-up follow identical rules.

In
`@client/java/src/main/java/com/linecorp/centraldogma/client/AsyncMappingWatcher.java`:
- Around line 75-80: The failure handler (reportFailure Consumer) must be gated
by the same staleness rule used in the success path so an out-of-order earlier
failure cannot close newer state; modify the reportFailure logic (and the
equivalent block around lines 93-99) to check the associated
revision/startedRevision against the current published mappedLatest (or a
tracked newestStartedRevision) and only call
initialValueFuture.completeExceptionally(e) and close() when the failure is for
the latest non-stale revision; in short, add the same revision-staleness guard
used when handling successful mapper.apply() completions before performing
failure completion or calling close().

---

Outside diff comments:
In
`@client/java/src/main/java/com/linecorp/centraldogma/client/MappingWatcher.java`:
- Around line 52-58: Remove the shadowed private volatile boolean closed from
MappingWatcher and stop reading that field; use the inherited closed flag from
AbstractMappingWatcher instead (replace any checks that reference the local
"closed" with the superclass's closed state or its accessor, e.g.,
super.isClosed() or the inherited protected field). Ensure MappingWatcher only
mutates mappedLatest when the inherited closed flag indicates the watcher is
open, and verify AbstractMappingWatcher.close() semantics remain relied upon for
lifecycle state.

---

Duplicate comments:
In
`@client/java/src/main/java/com/linecorp/centraldogma/client/AsyncMappingWatcher.java`:
- Around line 86-93: The mapper.apply(...) call in AsyncMappingWatcher currently
allows a null CompletableFuture to escape the try/catch, which causes a later
NPE and leaves the child watcher in an inconsistent state; wrap the null check
inside the same try block that calls mapper.apply(value) (i.e., after assigning
mappedValueFuture) and, if mappedValueFuture is null, call
reportFailure.accept(new NullPointerException("mapper returned null")) and
return so the failure follows the normal error path and initialValueFuture
doesn't remain unresolved.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 9c4372df-7bd3-4dd0-aba9-901e0c2ababe

📥 Commits

Reviewing files that changed from the base of the PR and between 37b9a90 and 6790a61.

📒 Files selected for processing (3)
  • client/java/src/main/java/com/linecorp/centraldogma/client/AbstractMappingWatcher.java
  • client/java/src/main/java/com/linecorp/centraldogma/client/AsyncMappingWatcher.java
  • client/java/src/main/java/com/linecorp/centraldogma/client/MappingWatcher.java

Comment on lines +86 to +98
protected final void notifyListeners(Latest<U> latest, @Nullable Executor currentExecutor) {
if (closed) {
return;
}

for (Map.Entry<BiConsumer<? super Revision, ? super U>, Executor> entry : updateListeners) {
final BiConsumer<? super Revision, ? super U> listener = entry.getKey();
final Executor executor = entry.getValue();
if (currentExecutor == executor) {
notifyListener(latest, listener);
} else {
executor.execute(() -> notifyListener(latest, listener));
}
Copy link

@coderabbitai coderabbitai bot Mar 9, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Centralize callback dispatch so post-close and catch-up notifications follow the same rules.

closed is only checked before the task is enqueued. If close() wins the race after scheduling but before execution, both normal updates and the catch-up callback in watch(...) can still invoke listeners after shutdown. Also, Line 128 bypasses notifyListener(...), so exceptions thrown during the initial catch-up notification are not logged/isolated the same way as later updates.

Suggested refactor
+    private void dispatchListener(Latest<U> latest,
+                                  BiConsumer<? super Revision, ? super U> listener,
+                                  Executor executor) {
+        executor.execute(() -> {
+            if (!closed) {
+                notifyListener(latest, listener);
+            }
+        });
+    }
+
     protected final void notifyListeners(Latest<U> latest, `@Nullable` Executor currentExecutor) {
         if (closed) {
             return;
         }
 
         for (Map.Entry<BiConsumer<? super Revision, ? super U>, Executor> entry : updateListeners) {
             final BiConsumer<? super Revision, ? super U> listener = entry.getKey();
             final Executor executor = entry.getValue();
             if (currentExecutor == executor) {
-                notifyListener(latest, listener);
+                if (!closed) {
+                    notifyListener(latest, listener);
+                }
             } else {
-                executor.execute(() -> notifyListener(latest, listener));
+                dispatchListener(latest, listener, executor);
             }
         }
     }
@@
         final Latest<U> mappedLatest = mappedLatest();
         if (mappedLatest != null) {
-            executor.execute(() -> listener.accept(mappedLatest.revision(), mappedLatest.value()));
+            dispatchListener(mappedLatest, listener, executor);
         }
     }

Also applies to: 119-128

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@client/java/src/main/java/com/linecorp/centraldogma/client/AbstractMappingWatcher.java`
around lines 86 - 98, The notifyListeners loop checks the closed flag only
before enqueuing, allowing callbacks to run after close and bypasses
notifyListener for same-thread dispatch which skips exception isolation/logging;
to fix, move the closed check inside the dispatched task (or at the start of
notifyListener) so every invocation verifies closed at execution time, and
always invoke notifyListener (even when currentExecutor == executor) via the
executor.execute(...) path or a unified dispatch helper so exceptions are
handled/logged consistently; update references: notifyListeners, notifyListener,
closed, and the watch catch-up invocation to use the same dispatch helper so
post-close and initial catch-up follow identical rules.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This matches the existing MappedWatcher behaviour. I assume the closing is best-effort rather than meant to be 100% reliable.

Copy link

Choose a reason for hiding this comment

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

@m50d, understood — best-effort close semantics are a reasonable and common design choice for watcher/listener patterns, especially when strict post-close suppression would add synchronization overhead with limited practical benefit. I'll note this for future reviews.


✏️ Learnings added
Learnt from: m50d
Repo: line/centraldogma PR: 1272
File: client/java/src/main/java/com/linecorp/centraldogma/client/AbstractMappingWatcher.java:86-98
Timestamp: 2026-03-09T07:39:08.241Z
Learning: In `AbstractMappingWatcher` (`client/java/src/main/java/com/linecorp/centraldogma/client/AbstractMappingWatcher.java`), the `closed` flag is checked in `notifyListeners` before enqueuing tasks, but not re-checked inside dispatched tasks. This is intentional best-effort close semantics, matching the existing `MappingWatcher` behavior. Do not flag the race between `close()` and listener dispatch as a bug; strict post-close suppression is not a design goal here.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: m50d
Repo: line/centraldogma PR: 1272
File: client/java/src/main/java/com/linecorp/centraldogma/client/AsyncMappingWatcher.java:75-80
Timestamp: 2026-03-09T07:38:15.243Z
Learning: In `AsyncMappingWatcher` (`client/java/src/main/java/com/linecorp/centraldogma/client/AsyncMappingWatcher.java`), closing the watcher on ANY mapper failure — including out-of-order (stale) async failures — is an intentional design decision. Do not flag this as a bug; the intent is to treat any mapper exception as a fatal signal that warrants closing the watcher, even if a newer revision has already succeeded.

Comment on lines +75 to +80
final Consumer<Throwable> reportFailure = (e) -> {
logger.warn("Unexpected exception is raised from mapper.apply(). mapper: {}", mapper, e);
if (!initialValueFuture.isDone()) {
initialValueFuture.completeExceptionally(e);
}
close();
Copy link

@coderabbitai coderabbitai bot Mar 9, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don't let stale async failures close newer state.

The success path suppresses out-of-order completions, but the failure path does not. If a later revision has already published mappedLatest and an earlier future fails afterwards, Line 98 still closes the watcher, so an outdated completion can tear down newer state. Gate failure handling with the same staleness rule, or track the newest started revision separately.

Also applies to: 93-99

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@client/java/src/main/java/com/linecorp/centraldogma/client/AsyncMappingWatcher.java`
around lines 75 - 80, The failure handler (reportFailure Consumer) must be gated
by the same staleness rule used in the success path so an out-of-order earlier
failure cannot close newer state; modify the reportFailure logic (and the
equivalent block around lines 93-99) to check the associated
revision/startedRevision against the current published mappedLatest (or a
tracked newestStartedRevision) and only call
initialValueFuture.completeExceptionally(e) and close() when the failure is for
the latest non-stale revision; in short, add the same revision-staleness guard
used when handling successful mapper.apply() completions before performing
failure completion or calling close().

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think this is ok? We intentionally close on any failure, even an out-of-order one.

Copy link

Choose a reason for hiding this comment

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

@m50d, understood — thanks for the clarification. Closing on any mapper failure (regardless of revision order) is a reasonable defensive posture: if the mapper throws or returns a failed future at all, it signals something is fundamentally wrong with the mapper itself, so tearing down the watcher eagerly makes sense.

Acknowledged and moving on.


✏️ Learnings added
Learnt from: m50d
Repo: line/centraldogma PR: 1272
File: client/java/src/main/java/com/linecorp/centraldogma/client/AsyncMappingWatcher.java:75-80
Timestamp: 2026-03-09T07:38:15.243Z
Learning: In `AsyncMappingWatcher` (`client/java/src/main/java/com/linecorp/centraldogma/client/AsyncMappingWatcher.java`), closing the watcher on ANY mapper failure — including out-of-order (stale) async failures — is an intentional design decision. Do not flag this as a bug; the intent is to treat any mapper exception as a fatal signal that warrants closing the watcher, even if a newer revision has already succeeded.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Copy link
Contributor

@jrhee17 jrhee17 left a comment

Choose a reason for hiding this comment

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

Left some minor questions, but changes look reasonable to me 👍

@m50d m50d requested a review from jrhee17 March 10, 2026 02:11
@ikhoon
Copy link
Contributor

ikhoon commented Mar 10, 2026

It seems like some logic is duplicated in the MappingWatcher implementation. How about extracting the shared logic into an AbstractMappingWatcher that both classes can extend?

What do you think about keeping only AsyncMappingWatcher and simplifying the implementation by update newChild to use an async mapper?

For example:

    default <U> Watcher<U> newChild(Function<? super T, ? extends U> mapper, Executor executor) {
        requireNonNull(mapper, "mapper");
        requireNonNull(executor, "executor");

        return AsyncMappingWatcher.of(this, t -> CompletableFuture.supplyAsync(() -> mapper.apply(t), executor),
                                 executor, false);
    }

    default <U> Watcher<U> newChildAsync(Function<? super T, CompletableFuture<? extends U>> mapper) {
        requireNonNull(mapper, "mapper");
        return AsyncMappingWatcher.of(this, mapper, null, false);
    }

@minwoox
Copy link
Contributor

minwoox commented Mar 11, 2026

What do you think about keeping only AsyncMappingWatcher and simplifying the implementation by update newChild to use an async mapper?

That's brilliant. 👍

@m50d
Copy link
Contributor Author

m50d commented Mar 11, 2026

What do you think about keeping only AsyncMappingWatcher and simplifying the implementation by update newChild to use an async mapper?

It would probably be slightly less efficient, but if we're ok with that then sure, it certainly simplifies things a bit. Let me implement.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@client/java/src/main/java/com/linecorp/centraldogma/client/MappingWatcher.java`:
- Around line 76-84: There are two identical registrations of an exceptionally
handler on parent.initialValueFuture() in MappingWatcher which is a duplicate;
remove one of the duplicate blocks so
parent.initialValueFuture().exceptionally(...) is only called once, leaving the
assignment this.mapper = mapper and a single
initialValueFuture.completeExceptionally(cause) handler intact to preserve
behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: a63e2fa7-6ec6-4277-8705-902e1e7c0556

📥 Commits

Reviewing files that changed from the base of the PR and between 701f4c7 and 8ad8ffa.

📒 Files selected for processing (2)
  • client/java/src/main/java/com/linecorp/centraldogma/client/MappingWatcher.java
  • client/java/src/main/java/com/linecorp/centraldogma/client/Watcher.java

Copy link
Contributor

@ikhoon ikhoon left a comment

Choose a reason for hiding this comment

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

Thanks, @m50d. 👍🙇‍♂️

Copy link
Contributor

@jrhee17 jrhee17 left a comment

Choose a reason for hiding this comment

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

Still looks good - CI still seems to be complaining about CLA for some reason

@m50d
Copy link
Contributor Author

m50d commented Mar 13, 2026

CI still seems to be complaining about CLA for some reason

I'm internal, should/can I sign the CLA?

Copy link
Contributor

@minwoox minwoox left a comment

Choose a reason for hiding this comment

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

Thanks! 👍 👍 👍

@jrhee17
Copy link
Contributor

jrhee17 commented Mar 13, 2026

I'm internal, should/can I sign the CLA?

Yes please - I believe the CLA isn't only about rights/ownership of the work; it's just to document that you’re allowed to contribute, and you’re OK with it being used and redistributed under the project rules

@jrhee17 jrhee17 merged commit 58de6f6 into line:main Mar 16, 2026
13 of 14 checks passed
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.

5 participants