Welcome! Please see the About page for a little more info on how this works.

0 votes
in core.cache by

I'm using core.cache, in particular the clojure.core.cache.wrapped ns.

I found out that even after dereferencing one of the atoms provided by the namespace's factories, the obtained map will not = an equivalent-looking map:

(= {1 1} @(clojure.core.cache.wrapped/lru-cache-factory {1 1})) ;; => false

The reason appears to be that defcache LRUCache (which boils down to a deftype) does not implement equality.

Could this be fixed?

1 Answer

+1 vote
selected by
Best answer

It's not a bug. The cache may print as a hash map, but it isn't a hash map.

Two caches of the same type can be compared for equality (but not two caches of different types) -- per https://clojure.org/guides/equality because caches implement clojure.lang.IPersistentCollection's equiv method.

You can get at the underlying base field inside the caches, using field access via interop but you'll be relying on an implementation detail (the field name):
user=> (= {1 1} (.cache @(clojure.core.cache.wrapped/lru-cache-factory {1 1}))) true

I'm curious about the use case.  Caches unburden a program of details that it (by design) has no interest in.  But on the other hand, ...?
Thank you for the answer. Still, something seems off to me because in my original snippet, if you change the order of the `=` (e.g. evaluate `(= @(clojure.core.cache.wrapped/lru-cache-factory {1 1}) {1 1})`), then one will get `true`. One wouldn't expect the result of a `=` to change depending on which of the operands is placed at the left hand?
The API for caches is a peculiar one. Since they are immutable caches, any operation that would normally change some metadata in a mutable cache (TTL, usage data, etc) has to return a new instance of the cache instead.

On top of the actual cache API, a "map-like" API has been constructed so that you can use Clojure's get, assoc, dissoc operations on them but that facade can (and does) have some weird edge cases, such as a race condition between checking whether an item is in the cache and then looking it up (and finding it has "gone") -- even though the cache itself is immutable. This causes problems for the upstream consumers, such as core.memoize which has to contain spin/retry logic in order to avoid erroneously returning nil.

That not-quite-immutable-hash-map semantic is why I added the wrapped namespace that provides a more intuitive "mutable cache" (by wrapping the immutable cache in an atom), but the "safe" API for these caches is not really "map-like": lookup-or-miss is the safest function to use, providing a way to avoid race conditions and repeated execution, but still have an on-demand lookup that can (re-)compute the requested value if needed.

Being able to "seed" a cache after creation, with a new hash map of values, is inherently a mutating operation, as is "evict". Having a "map-like" get operation is a convenience but only if you are happy to get back nil (or a "missing" value) if the requested cache entry has expired.

As the readme notes: "The core.cache API is hard to use correctly." and it links to this https://dev.to/dpsutton/exploring-the-core-cache-api-57al which talks about bugs in the wild caused by treating caches as if they were "just maps".
@vemv re: switching the order of the arguments -- that's the same thing you see with trying to do set operations on two things, only one of which is actually a set: try it in one order and it works (by accident), try it in the other order and it fails (because you're doing a set op on a non-set).

Looking back at the source code in more depth, I think my answer above is incorrect in the details but still correct in the overall sense that Clojure says two deftype instances are only equal if they are the same type.

As my longer response to @pbwolf indicates, core.cache's "map-like" API is leaky and leads people to do the wrong thing with it :|
Thank you! These are useful insights.

Accordingly I ended up implementing a thin wrapper for ensuring that one uses the right methods and only can access the underlying data in a safe way.

Merely as an observation, I wonder if changing deftype->defrecord would have helped here?