Skip to content
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

asMap() does not conform to the Map contract #174

Closed
ben-manes opened this issue Aug 8, 2021 · 5 comments
Closed

asMap() does not conform to the Map contract #174

ben-manes opened this issue Aug 8, 2021 · 5 comments
Assignees
Labels
Milestone

Comments

@ben-manes
Copy link

ben-manes commented Aug 8, 2021

I've been using Guava's conformance tests since Caffeine's inception as a secondary guard in addition to my own suite. I recently extended it to run across more cache configurations, similar to how the core suite runs a test scenario against every configuration that matches its specification constraint. The intent is to ensure that a particular feature does not interact poorly and break the expected behavior. That's a brute force approach, e.g. that change expanded the suite by ~350,000 cases.

I did a quick check to see if other caches passed the suite, and unfortunately I found issues here (83 failures, 13 errors). That's not surprising, e.g. another library had failures too (jhalterman/expiringmap#48).

In this library a few examples are,

  1. A computeIfAbsent that returns null indicates that the mapping should not be established. Instead this is treated as a null value, causing either an error or to be cached based on the builder configuration.
  2. equals(map) should be symmetric and therefore two maps with the same mappings will be equal to one another. This is violated where cache.asMap().equals(other) fails if other does not implement ConcurrentMapWrapper, whereas other.equals(cache.asMap()) will pass. Similarly hashCode violates the basic equality contract. While a documented break, it seems like an unnecessary violation.
  3. The key, value, and entry views have numerous problems with their iterators, causing issues such as due to the Map.Entry not implementing equality so that entrySet.containsAll(entrySet) fails.

If helpful, Caffeine's test runners are defined in CaffeineMapTests and MapTestFactory.

A simple runner for cache2k
import java.util.Map;
import java.util.function.Supplier;

import org.cache2k.Cache2kBuilder;

import com.google.common.collect.testing.ConcurrentMapTestSuiteBuilder;
import com.google.common.collect.testing.TestStringMapGenerator;
import com.google.common.collect.testing.features.CollectionFeature;
import com.google.common.collect.testing.features.CollectionSize;
import com.google.common.collect.testing.features.MapFeature;

import junit.framework.Test;
import junit.framework.TestCase;
import junit.framework.TestSuite;

/**
 * Guava testlib map tests for the asMap() view.
 */
public final class Cache2kMapTests extends TestCase {

  public static Test suite() {
    var suite = new TestSuite();
    suite.addTest(suite("cache2k_disallowNull", /* permitNullValues */ false, () -> {
      return new Cache2kBuilder<String, String>() {};
    }));
    suite.addTest(suite("cache2k_allowNull", /* permitNullValues */ true, () -> {
      return new Cache2kBuilder<String, String>() {};
    }));
    return suite;
  }

  private static Test suite(String name, boolean permitNullValues,
      Supplier<Cache2kBuilder<String, String>> supplier) {
    var suite = ConcurrentMapTestSuiteBuilder
        .using(new TestStringMapGenerator() {
          @Override protected Map<String, String> create(Map.Entry<String, String>[] entries) {
            var map = supplier.get().permitNullValues(permitNullValues).build().asMap();
            for (var entry : entries) {
              map.put(entry.getKey(), entry.getValue());
            }
            return map;
          }
        })
        .named(name)
        .withFeatures(
            MapFeature.GENERAL_PURPOSE,
            MapFeature.ALLOWS_NULL_ENTRY_QUERIES,
            CollectionFeature.SUPPORTS_ITERATOR_REMOVE,
            CollectionSize.ANY);
    if (permitNullValues) {
      suite.withFeatures(MapFeature.ALLOWS_NULL_VALUES);
    }
    return suite.createTestSuite();
  }
}
@cruftex cruftex added the bug label Aug 8, 2021
@cruftex cruftex added this to the v2.4 milestone Aug 8, 2021
@cruftex
Copy link
Member

cruftex commented Aug 8, 2021

Thanks, @ben-manes for putting this together. Great stuff. Getting the map implemented correctly for every detail is quite time consuming, so I did cut a few corners. With the test suite it should be possible to get it right quite quickly.
Most likely I will also implement equals and hashCode accordingly to avoid any bad surprises for users.

@cruftex
Copy link
Member

cruftex commented Aug 10, 2021

Just made the necessary changes.

Unfortunately the Map contract for computeIfAbsent in case null values are allowed is different to what is specified in the counterpart in the cache interface Cache.computeIfAbsent. The pseudo code of its current semantics is:

  if (!cache.containsKey(key)) {
      V value = function.apply(key);
      cache.put(key, value);
      return value;
  } else {
     return cache.peek(key);
  }

The pseudo code aligned to the contract specified on the Map interface would be:

  V value = cache.peek(key);
  if (value != null) {
     value = function.apply(key);
     if (value != null) {
        cache.put(key, value);
      }
   }
   return value;
  }

In the last commit I changed the semantics of Cache.computeIfAbsent to the Map contract to avoid confusion. However, I don't think its wise because:

  • Change of the existing interface contract might break code
  • The defined semantics of the Map are not useful if null is used for negative caching, which would be the typical use case

So probably I better keep the semantics on Cache.computeIfAbsent as is and implement the slightly differing semantics on the map interface only. OTOH, having identical methods names with slightly different semantics is a pitfall as well. However, with null values not permitted, the semantics are identical anyways.

@ben-manes
Copy link
Author

This is a confusion for null-valued maps, causing a recent bug in TreeMap (JDK-8259622) (you might want to add that test case). I suppose Cache.invoke(key, EntryProcessor) could be recommended for when the value can be cached as null since it lets the user explicitly manage the entry's lifecycle. I think it would be more surprising to have computeIfAbsent mean different things.

@cruftex
Copy link
Member

cruftex commented Aug 11, 2021

I think it would be more surprising to have computeIfAbsent mean different things.

Looking further into it, problem is, when changing computeIfAbsent things become inconsistent on the Cache interface, too. E.g. putIfAbsent does not treat the null value as absent as well. So, I think I better stay with the current semantics on the cache interface.

I think there are not so many users that actually do enable null values, anyways.

In the long run we could rename the methods, e.g. from computeIfAbsent to copmuteIfMissing to symbolize the difference to the map's methods.

@cruftex
Copy link
Member

cruftex commented Aug 11, 2021

Fixed and released as bug fix, see https://github.com/cache2k/cache2k/releases/tag/v2.2.1.Final

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

2 participants
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy