-
Notifications
You must be signed in to change notification settings - Fork 131
Add AsyncMappingWatcher and newChildAsync
#1272
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 10 commits
1fa97c8
3f67044
2d6cabd
f013e2a
0b3cfc9
5d391a8
37b9a90
a125807
0f7234b
6790a61
6f1d5d2
701f4c7
e244805
12b90d6
758e711
7b80f77
8ad8ffa
23d0fe5
1188e8a
a5f2c6b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,136 @@ | ||
| /* | ||
| * Copyright 2026 LINE Corporation | ||
| * | ||
| * LINE Corporation licenses this file to you under the Apache License, | ||
| * version 2.0 (the "License"); you may not use this file except in compliance | ||
| * with the License. You may obtain a copy of the License at: | ||
| * | ||
| * https://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, software | ||
| * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT | ||
| * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the | ||
| * License for the specific language governing permissions and limitations | ||
| * under the License. | ||
| */ | ||
| package com.linecorp.centraldogma.client; | ||
|
|
||
| import static java.util.Objects.requireNonNull; | ||
|
|
||
| import java.util.List; | ||
| import java.util.Map; | ||
| import java.util.concurrent.CompletableFuture; | ||
| import java.util.concurrent.CopyOnWriteArrayList; | ||
| import java.util.concurrent.Executor; | ||
| import java.util.concurrent.ScheduledExecutorService; | ||
| import java.util.function.BiConsumer; | ||
|
|
||
| import org.jspecify.annotations.Nullable; | ||
| import org.slf4j.Logger; | ||
| import org.slf4j.LoggerFactory; | ||
|
|
||
| import com.google.common.collect.Maps; | ||
|
|
||
| import com.linecorp.centraldogma.common.Revision; | ||
|
|
||
| abstract class AbstractMappingWatcher<T, U> implements Watcher<U> { | ||
| private static final Logger logger = LoggerFactory.getLogger(AbstractMappingWatcher.class); | ||
|
|
||
| final CompletableFuture<Latest<U>> initialValueFuture = new CompletableFuture<>(); | ||
| volatile boolean closed; | ||
| final Watcher<T> parent; | ||
|
|
||
| private final boolean closeParentWhenClosing; | ||
| private final List<Map.Entry<BiConsumer<? super Revision, ? super U>, Executor>> updateListeners = | ||
| new CopyOnWriteArrayList<>(); | ||
|
|
||
| AbstractMappingWatcher(Watcher<T> parent, boolean closeParentWhenClosing) { | ||
| this.parent = parent; | ||
| this.closeParentWhenClosing = closeParentWhenClosing; | ||
| parent.initialValueFuture().exceptionally(cause -> { | ||
| initialValueFuture.completeExceptionally(cause); | ||
| return null; | ||
| }); | ||
| } | ||
|
|
||
| @Override | ||
| public final ScheduledExecutorService watchScheduler() { | ||
| return parent.watchScheduler(); | ||
| } | ||
|
|
||
| @Override | ||
| public final CompletableFuture<Latest<U>> initialValueFuture() { | ||
| return initialValueFuture; | ||
| } | ||
|
|
||
| @Override | ||
| public final void close() { | ||
| closed = true; | ||
| if (!initialValueFuture.isDone()) { | ||
| initialValueFuture.cancel(false); | ||
| } | ||
| if (closeParentWhenClosing) { | ||
| parent.close(); | ||
| } | ||
| } | ||
|
|
||
| private void notifyListener(Latest<U> latest, BiConsumer<? super Revision, ? super U> listener) { | ||
| try { | ||
| listener.accept(latest.revision(), latest.value()); | ||
| } catch (Exception e) { | ||
| logger.warn("Unexpected exception is raised from {}: rev={}", | ||
| listener, latest.revision(), e); | ||
| } | ||
| } | ||
|
|
||
| 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)); | ||
| } | ||
|
||
| } | ||
| } | ||
|
|
||
| protected abstract @Nullable Latest<U> mappedLatest(); | ||
|
|
||
| @Override | ||
| public final Latest<U> latest() { | ||
| final Latest<U> mappedLatest = mappedLatest(); | ||
| if (mappedLatest == null) { | ||
| throw new IllegalStateException("value not available yet"); | ||
| } | ||
| return mappedLatest; | ||
| } | ||
|
|
||
| @Override | ||
| public final void watch(BiConsumer<? super Revision, ? super U> listener, Executor executor) { | ||
| requireNonNull(listener, "listener"); | ||
| requireNonNull(executor, "executor"); | ||
| updateListeners.add(Maps.immutableEntry(listener, executor)); | ||
|
|
||
| final Latest<U> mappedLatest = mappedLatest(); | ||
| if (mappedLatest != null) { | ||
| // There's a chance that listener.accept(...) is called twice for the same value | ||
| // if this watch method is called: | ||
| // - after "mappedLatest = newLatest;" is invoked. | ||
| // - and before notifyListener() is called. | ||
| // However, it's such a rare case and we usually call `watch` method after creating a Watcher, | ||
| // which means mappedLatest is probably not set yet, so we don't use a lock to guarantee | ||
| // the atomicity. | ||
| executor.execute(() -> listener.accept(mappedLatest.revision(), mappedLatest.value())); | ||
| } | ||
| } | ||
|
|
||
| @Override | ||
| public final void watch(BiConsumer<? super Revision, ? super U> listener) { | ||
| watch(listener, parent.watchScheduler()); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,131 @@ | ||||||||||||||||||||||||||||||||||||
| /* | ||||||||||||||||||||||||||||||||||||
| * Copyright 2026 LINE Corporation | ||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||
| * LINE Corporation licenses this file to you under the Apache License, | ||||||||||||||||||||||||||||||||||||
| * version 2.0 (the "License"); you may not use this file except in compliance | ||||||||||||||||||||||||||||||||||||
| * with the License. You may obtain a copy of the License at: | ||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||
| * https://www.apache.org/licenses/LICENSE-2.0 | ||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||
| * Unless required by applicable law or agreed to in writing, software | ||||||||||||||||||||||||||||||||||||
| * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT | ||||||||||||||||||||||||||||||||||||
| * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the | ||||||||||||||||||||||||||||||||||||
| * License for the specific language governing permissions and limitations | ||||||||||||||||||||||||||||||||||||
| * under the License. | ||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||
| package com.linecorp.centraldogma.client; | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| import static com.google.common.base.MoreObjects.toStringHelper; | ||||||||||||||||||||||||||||||||||||
| import static java.util.Objects.requireNonNull; | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| import java.util.List; | ||||||||||||||||||||||||||||||||||||
| import java.util.Map.Entry; | ||||||||||||||||||||||||||||||||||||
| import java.util.Objects; | ||||||||||||||||||||||||||||||||||||
| import java.util.concurrent.CompletableFuture; | ||||||||||||||||||||||||||||||||||||
| import java.util.concurrent.CopyOnWriteArrayList; | ||||||||||||||||||||||||||||||||||||
| import java.util.concurrent.Executor; | ||||||||||||||||||||||||||||||||||||
| import java.util.concurrent.atomic.AtomicReference; | ||||||||||||||||||||||||||||||||||||
| import java.util.function.BiConsumer; | ||||||||||||||||||||||||||||||||||||
| import java.util.function.Consumer; | ||||||||||||||||||||||||||||||||||||
| import java.util.function.Function; | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| import org.jspecify.annotations.Nullable; | ||||||||||||||||||||||||||||||||||||
| import org.slf4j.Logger; | ||||||||||||||||||||||||||||||||||||
| import org.slf4j.LoggerFactory; | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| import com.linecorp.centraldogma.common.Revision; | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| final class AsyncMappingWatcher<T, U> extends AbstractMappingWatcher<T, U> { | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| private static final Logger logger = LoggerFactory.getLogger(AsyncMappingWatcher.class); | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| static <T, U> AsyncMappingWatcher<T, U> of(Watcher<T> parent, | ||||||||||||||||||||||||||||||||||||
| Function<? super T, ? extends CompletableFuture<? extends U>> | ||||||||||||||||||||||||||||||||||||
| mapper, | ||||||||||||||||||||||||||||||||||||
| boolean closeParentWhenClosing) { | ||||||||||||||||||||||||||||||||||||
| requireNonNull(parent, "parent"); | ||||||||||||||||||||||||||||||||||||
| requireNonNull(mapper, "mapper"); | ||||||||||||||||||||||||||||||||||||
| return new AsyncMappingWatcher<>(parent, mapper, closeParentWhenClosing); | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| private final Function<? super T, ? extends CompletableFuture<? extends U>> mapper; | ||||||||||||||||||||||||||||||||||||
| private final List<Entry<BiConsumer<? super Revision, ? super U>, Executor>> updateListeners = | ||||||||||||||||||||||||||||||||||||
m50d marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||||||||||
| new CopyOnWriteArrayList<>(); | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| private final AtomicReference<@Nullable Latest<U>> mappedLatest = new AtomicReference<>(); | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| private static <U> boolean isUpdate(Latest<U> newLatest, @Nullable Latest<U> existing) { | ||||||||||||||||||||||||||||||||||||
| if (existing == null) { | ||||||||||||||||||||||||||||||||||||
| return true; | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| if (Objects.equals(existing.value(), newLatest.value())) { | ||||||||||||||||||||||||||||||||||||
| return false; | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| return newLatest.revision().compareTo(existing.revision()) >= 0; | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| AsyncMappingWatcher(Watcher<T> parent, Function<? super T, ? extends CompletableFuture<? extends U>> mapper, | ||||||||||||||||||||||||||||||||||||
| boolean closeParentWhenClosing) { | ||||||||||||||||||||||||||||||||||||
| super(parent, closeParentWhenClosing); | ||||||||||||||||||||||||||||||||||||
| this.mapper = mapper; | ||||||||||||||||||||||||||||||||||||
| parent.initialValueFuture().exceptionally(cause -> { | ||||||||||||||||||||||||||||||||||||
| initialValueFuture.completeExceptionally(cause); | ||||||||||||||||||||||||||||||||||||
| return null; | ||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||
| final Consumer<Throwable> reportFailure = (e) -> { | ||||||||||||||||||||||||||||||||||||
| logger.warn("Unexpected exception is raised from mapper.apply(). mapper: {}", mapper, e); | ||||||||||||||||||||||||||||||||||||
m50d marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||||||||||
| if (!initialValueFuture.isDone()) { | ||||||||||||||||||||||||||||||||||||
| initialValueFuture.completeExceptionally(e); | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| close(); | ||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||
| parent.watch((revision, value) -> { | ||||||||||||||||||||||||||||||||||||
| if (closed) { | ||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| 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 = 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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
Uh oh!
There was an error while loading. Please reload this page.