Instrumenting Clojure protocols

I recently added support for instrumenting defprotocol in Plumatic Schema. It has worked out quite nicely. It requires no usage changes in invocation or extensions, supports any kind of protocol extension (interface, extend, and metadata), and runs on Clojure and ClojureScript (however, babashka is not possible yet).

So what does this even do? Basically, it allows types to be attached to Clojure protocol methods like you (syntactically) would an Interface in Java, but they would be checked at runtime whenever you call the protocol.

When setting out to design this solution, I searched for existing work on protocol instrumentation. I found several suggestions that a decent workaround is to wrap protocol calls in functions and instrument those:

Obviously, that didn’t deter me from pushing on—neither did it deter Victor from his creative solution involving method renaming.

Surprisingly, that’s about all I found in terms of attempts to solve this problem (even Victor played down his own solution as a workaround when I talked to him). Spec had no plans of adding it. Malli needed an implementation strategy. Schema was in the same boat, except their users have wanted it since 2014 and were looking to core.typed for inspiration. I would have brought them the unfortunate news that core.typed’s defprotocol wrapper would not help them to instrument it at all. I was optimistic I could find a real solution here. I knew this was worth looking into: at work, we have hundreds of protocols with schemas annotated as documentation only.

It became clear early on that any solution would involve implementation details for each platform. I leaned into that and set up a matrix build for all (popular) versions of Clojure and ClojureScript (including master snapshots) and all supported JVM versions and kicked off a cron job for good measure. My philosophy was to test aggressively, try to release fixes early, and give users an escape hatch if they’re truly stuck (eg., implementation details have broken instrumentation but they need to upgrade Clojure anyway).

I won’t go into the gnarly details of the implementation of protocols, but as Brandon predicted, there were several moving parts in the Clojure implementation:

  • the compiler sometimes inlines protocol invocations to interface method calls, thwarting instrumentation
  • protocol methods have resettable caches that need to be carefully propagated “through” any wrapping functions

However, I was glad to realize that nothing special was needed for metadata extension.

ClojureScript also had its own unique brand of strangeness. Notably, I had to be careful to avoid infinite loops where the protocol method would call itself via the instrumentation logic.

Babashka, it turns out, had no solution: protocols are multimethods, and you can’t instrument a clojure.core/defmethod call (brief discussion with borkdude).

Once it was clear my approach worked, the maintainer for Malli expressed interest in using it for Malli. Victor also successfully tried this approach on speced.def. Perhaps something like orchestra might accept it for spec (I haven’t asked).

I am currently building a shared library that will provide the building blocks for wrapping Clojure protocol methods so that any library that provides such functionality can delegate most implementation details there. We can then collaborate on keeping it up-to-date and working for as long as needed. For now, I am working on it in a private repository and I’ll open it up when it’s done.

You can try s/defprotocol in Plumatic Schema 1.4.0. You can study its implementation here.

08 Sep 2022