Salvatore Casolaro created ZOOKEEPER-5091:
---------------------------------------------
Summary: WatchManager reports connections with no active watches
after consuming the last standard watch
Key: ZOOKEEPER-5091
URL: https://issues.apache.org/jira/browse/ZOOKEEPER-5091
Project: ZooKeeper
Issue Type: Bug
Components: server
Affects Versions: 3.10.0
Environment: Operating system: Windows 11, amd64
Java version: 17.0.12
ZooKeeper version: 3.10.0-SNAPSHOT
Branch: master
Commit: 9102d0be12e66a5c592d8cbf0c6865f68ffa9252
Reporter: Salvatore Casolaro
After the last STANDARD watch associated with a connection is triggered and
consumed, WatchManager removes the active watch registration from the
path-oriented state but retains an empty entry for the connection in
watch2Paths.
As a result, getWatchesSummary() reports one connection even though it reports
zero watched paths and zero total watches. In addition, getWatches() reports
the connection session with an empty path set.
This report focuses on the externally observable inconsistency in the reporting
APIs, regardless of whether retaining an empty entry internally is intentional.
The behavior was reproduced on the Apache ZooKeeper master branch at commit
9102d0be12e66a5c592d8cbf0c6865f68ffa9252.
h2. Expected behavior
After the last STANDARD watch associated with a connection has been triggered
and consumed, the WatchManager reporting APIs should consistently reflect that
the connection has no active watch registrations.
In the reproduced scenario:
* containsWatcher(path, connection, STANDARD) should return false.
* containsWatcher(path, connection, null) should return false.
* size() should return 0.
* getWatchesSummary() should report zero active watch connections, zero
watched paths, and zero total watches.
* getWatches() should not report the connection session as having an active
watch registration.
* A second event for the same path should not notify the connection again.
This expected reporting behavior does not necessarily require removing an
internally cached empty entry from watch2Paths. If retaining the empty entry is
intentional, the reporting methods could instead exclude entries that have no
active paths.
h2. Actual behavior
The STANDARD watch is correctly triggered and consumed:
* The connection receives exactly one callback.
* containsWatcher(path, connection, STANDARD) returns false.
* containsWatcher(path, connection, null) returns false.
* size() returns 0.
* A second event does not trigger another callback.
However, getWatchesSummary() produces the following state:
{code:java}
Watch summary after trigger: connections=1, paths=0, watches=0
{code}
The regression test therefore fails with:
{code:java}
java.lang.AssertionError:
No connection with active watches must remain
Expected: 0
Actual: 1
{code}
A separate test of getWatches() also shows that the session is retained with an
empty path set:
{code:java}
java.lang.AssertionError:
A connection with no active watches must not appear in getWatches()
expected null, but was:<[]>
{code}
Therefore, the reporting APIs expose the following state:
{code:java}
containsWatcher(path, connection, STANDARD) = false
containsWatcher(path, connection, null) = false
size() = 0
getWatchesSummary().getNumConnections() = 1
getWatchesSummary().getNumPaths() = 0
getWatchesSummary().getTotalWatches() = 0
getWatches().getPaths(0x40L) = []
{code}
h2. Steps to reproduce
# Create a new WatchManager.
# Create a non-stale ServerCnxn mock with session ID 0x40.
# Register a STANDARD watch for the connection on path /a.
# Trigger a NodeDataChanged event on /a.
# Verify that the connection receives exactly one callback.
# Verify that containsWatcher() returns false and size() returns zero after
the trigger.
# Call getWatchesSummary().
# Observe that it reports one connection, zero watched paths, and zero total
watches.
# Call getWatches().
# Observe that session 0x40 is still present with an empty path set.
# Trigger another NodeDataChanged event on /a and verify that the connection
is not notified again.
h2. Minimal reproduction
The following JUnit 4 test reproduces the getWatchesSummary() behavior:
{code:java}
@Test
public void testTriggerWatch_LastStandardWatch_RemovesConnectionFromReports() {
ServerCnxn connection = createMockServerCnxn("C1", 0x40L);
List<ACL> acl = Collections.emptyList();
assertTrue(watchManager.addWatch("/a", connection, WatcherMode.STANDARD));
WatcherOrBitSet triggered = watchManager.triggerWatch("/a",
EventType.NodeDataChanged, 1L, acl, null);
assertNotNull(triggered);
assertTrue(triggered.contains(connection));
verify(connection, times(1)).process(any(WatchedEvent.class), same(acl));
assertFalse(watchManager.containsWatcher("/a", connection,
WatcherMode.STANDARD));
assertFalse(watchManager.containsWatcher("/a", connection, null));
assertEquals(0, watchManager.size());
WatchesSummary summary = watchManager.getWatchesSummary();
assertEquals("No connection with active watches must remain", 0,
summary.getNumConnections());
assertEquals(0, summary.getNumPaths());
assertEquals(0, summary.getTotalWatches());
}
{code}
The ServerCnxn mock used by the test is configured as follows:
{code:java}
private ServerCnxn createMockServerCnxn(String name, long sessionId) {
ServerCnxn connection = mock(ServerCnxn.class, name);
when(connection.getSessionId()).thenReturn(sessionId);
when(connection.isStale()).thenReturn(false);
return connection;
}
{code}
The following assertion independently reproduces the getWatches() behavior
after the same setup and trigger:
{code:java}
WatchesReport report = watchManager.getWatches();
assertNull("A connection with no active watches must not appear in
getWatches()", report.getPaths(0x40L));
{code}
The first test consistently obtains one connection instead of zero. The second
test obtains an empty set instead of null.
h2. Reproducibility
The getWatchesSummary() regression test was executed three times and produced
the same failure in each execution:
{code:java}
Expected: 0
Actual: 1
{code}
The separate getWatches() regression test was also executed three times and
produced the same result in each execution:
{code:java}
expected null, but was:<[]>
{code}
An additional diagnostic execution confirmed the complete summary state:
{code:java}
Watch summary after trigger: connections=1, paths=0, watches=0
{code}
h2. Possible cause
The behavior appears to originate in WatchManager.triggerWatch().
When the last STANDARD mode for a connection and path is consumed,
triggerWatch() removes the watcher from the path-oriented set and removes the
path from the map associated with the connection:
{code:java}
WatchStats newStats = stats.removeMode(WatcherMode.STANDARD);
if (newStats == WatchStats.NONE) {
iterator.remove();
paths.remove(localPath);
} else if (newStats != stats) {
paths.put(localPath, newStats);
}
{code}
The path entry is also removed from watchTable when its watcher set becomes
empty:
{code:java}
if (thisWatchers.isEmpty()) {
watchTable.remove(localPath);
}
{code}
However, triggerWatch() does not remove the connection key from watch2Paths
when paths becomes empty.
The resulting internal state appears to be equivalent to:
{code:java}
watchTable = {}
watch2Paths = { connection 0x40 -> {}}
{code}
getWatchesSummary() uses watch2Paths.size() as the number of connections:
{code:java}
return new WatchesSummary(watch2Paths.size(), watchTable.size(), totalWatches);
{code}
Therefore, the empty connection entry is counted even though it has no active
watch registrations.
Similarly, getWatches() iterates over all remaining watch2Paths entries and
reports the session with an empty set of paths.
Possible approaches include removing the connection from watch2Paths when its
path map becomes empty, or excluding empty entries from the reporting APIs if
retaining them internally is intentional. Any correction should preserve
connections that still have registrations on other paths or in other watch
modes.
h2. Relation to ZOOKEEPER-3131
This behavior appears closely related to, or may represent a regression or
incomplete fix of, ZOOKEEPER-3131.
ZOOKEEPER-3131 described empty watcher entries remaining in watch2Paths and
explicitly mentioned triggerWatch() as an affected cleanup path. That issue was
resolved as fixed.
The discussion also considered whether temporarily retaining an empty entry
could be useful as an optimization when clients are expected to register new
watches. Therefore, this report does not assume that removing the internal
entry is the only valid correction.
In the current implementation, however, the retained empty entry is directly
exposed through getWatchesSummary() and getWatches(), even though
containsWatcher() and size() show that no active watch registration remains.
h2. Regression tests
Two JUnit 4 regression tests reproduce the behavior using a non-stale
ServerCnxn mock:
* One verifies the state exposed by getWatchesSummary().
* One verifies the session-to-path state exposed by getWatches().
Both tests are currently marked with @Ignore in the complete experimental test
suite because they expose the reported behavior. The @Ignore annotations were
temporarily removed while reproducing the failures.
The complete regression tests can be provided if needed.
h2. Academic context
This issue was identified while working on an academic software testing project
provided within a course taught by Prof. Guglielmo De Angelis at the University
of Rome Tor Vergata.
GitHub profile:
[Prof. Guglielmo De Angelis on GitHub|https://github.com/gulyx]
--
This message was sent by Atlassian Jira
(v8.20.10#820010)