Skip to content

MINOR: Exclude expiryTimestamp from TokenInformation.hashCode - #23098

Open
kutsibalci wants to merge 1 commit into
apache:trunkfrom
kutsibalci:token-information-hashcode
Open

MINOR: Exclude expiryTimestamp from TokenInformation.hashCode#23098
kutsibalci wants to merge 1 commit into
apache:trunkfrom
kutsibalci:token-information-hashcode

Conversation

@kutsibalci

Copy link
Copy Markdown

TokenInformation.equals() compares six fields and leaves expiryTimestamp out. hashCode() hashes seven — the same six plus expiryTimestamp.

// equals()
return issueTimestamp == that.issueTimestamp &&
    maxTimestamp == that.maxTimestamp &&
    Objects.equals(owner, that.owner) &&
    Objects.equals(tokenRequester, that.tokenRequester) &&
    Objects.equals(renewers, that.renewers) &&
    Objects.equals(tokenId, that.tokenId);

// hashCode()
return Objects.hash(owner, tokenRequester, renewers, issueTimestamp, maxTimestamp, expiryTimestamp, tokenId);

So two instances can be equal and still hash differently, which is the one thing Object.hashCode requires never happens:

If two objects are equal according to the equals(Object) method, then calling the hashCode method on each of the two objects must produce the same integer result.

There is a second edge to it. expiryTimestamp is the only non-final field on the class, and setExpiryTimestamp is public and is what renewal calls. Hashing it means an instance's hash code can change after it has been put in a HashSet or used as a HashMap key, so the collection can no longer find an object it still holds.

Which side to change

The omission in equals() looks deliberate rather than accidental: renewing a token does not make it a different token, and tokenId already identifies it. Adding expiryTimestamp to equals() would change what token identity means and would leave a mutable field driving the hash. Dropping it from hashCode() keeps the existing notion of equality and makes the hash stable, so that is the direction taken here.

Observed behaviour

Built from the current trunk sources, with two instances of the same token that differ only in expiryTimestamp:

before after
a.equals(b) true true
a.hashCode() == b.hashCode() false true
set.contains(b) after set.add(a) false true
set.size() after adding both 2 1
map.get(b) after map.put(a, v) null v
set.contains(c) after c.setExpiryTimestamp(...) false true

The last row uses the same instance that was added to the set.

Scope

TokenInformation is public API — org.apache.kafka.common.security.token.delegation, not an internals package — and instances reach callers through Admin.describeDelegationToken() and DelegationTokenCache.tokens(), so user code can put them in hash-based collections.

I want to be accurate about the blast radius inside Kafka itself: DelegationTokenCache keys its maps by the token id String, not by TokenInformation, and I did not find a broker or client path that puts these objects in a HashSet or uses them as a map key today. So this is a defect in the published contract of a public class rather than a bug with a current reproducer in the broker. I would rather say that plainly than overstate it.

Tests

Adds TokenInformationTest, which did not exist. Against unmodified trunk, three of its four cases fail:

+-- TokenInformationTest
|   +-- testHashCodeIsStableAcrossRenewal()               [X] expected: <1465872210> but was: <1465934210>
|   +-- testEqualsAndHashCode()                           [OK]
|   +-- testEqualInstancesCollapseInHashSet()             [X] expected: <true> but was: <false>
|   '-- testHashCodeIgnoresExpiryTimestampLikeEquals()    [X] expected: <1465872210> but was: <1465903210>
[         1 tests successful ]
[         3 tests failed     ]

With this change, all four pass. testEqualsAndHashCode passes in both runs by design — it is the baseline case where the two instances carry the same expiry, and it would catch a fix that broke ordinary equality.

How this was found, and what I left alone

I walked every class body under src/main/java that declares both equals() and hashCode() and compared the set of instance fields each one reads. 500 classes declare both. TokenInformation is the only one where hashCode() reads a field that equals() ignores.

Seven classes have the mismatch the other way round — equals() compares a field that hashCode() skips:

StreamsMetadataImpl (topologyName), FetchParams (maxWaitMs), UnionSet (size), MemoryRecords (batches), KafkaChannel (remoteAddress, state), MetadataResponse.TopicMetadata (topicId).

I have not touched any of those. That direction does not violate the contract — equal objects still hash the same; unequal ones just collide more often than they need to. Whether any of them is worth tightening is a separate judgement about each class, not a correctness fix.

I also looked at, and deliberately left alone, cases the scan raised that turned out to be fine on reading: classes that cache the hash in a field (ConnectMetrics.MetricGroupId), classes that delegate to a helper (TestRecord.equalsFields), fields that are derived from others already compared (LagInfo.offsetLag), and ProcessorMetadata, where a comment states that needsCommit is excluded from both on purpose.

Checks

  • The scan re-run after this change reports no class where hashCode() reads a field equals() ignores.
  • equals() is untouched, so what counts as an equal token is unchanged.
  • One line of production code; the rest is the new test.

AI disclosure

AI-assisted (Claude Code), per the AI-Generated Contributions section of CONTRIBUTING.md; the commit carries a Generated-by trailer. The scan, the test and this description were produced with the tool, and I checked the result myself before opening: I read the equals() and hashCode() bodies, confirmed expiryTimestamp is the only non-final field and that setExpiryTimestamp is public, built the two variants from the real sources and ran the test against both, searched for the places TokenInformation is actually stored to work out how far this reaches, and went through the seven opposite-direction classes and the false positives above one at a time rather than reporting the scan output as-is. I understand the change and take responsibility for it.

TokenInformation.equals() compares six fields and deliberately leaves
expiryTimestamp out, since renewing a token does not make it a different
token. hashCode() hashes that field as well, so two instances that compare
equal can produce different hash codes. That breaks the Object.hashCode
contract and makes lookups in hash-based collections miss.

expiryTimestamp is also the only non-final field on the class, and it has a
public setter that renewal uses, so an instance's hash code can change while
it is already an element of a HashSet or a key in a HashMap.

Align hashCode() with equals() and add TokenInformationTest, which fails on
trunk for three of its four cases and passes with this change.

Generated-by: Claude Opus 5 (Claude Code)
@github-actions github-actions Bot added triage PRs from the community clients small Small PRs labels Aug 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

clients small Small PRs triage PRs from the community

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant