Skip to content

Reuse recursive ref-transformers#1250

Merged
opqdonut merged 1 commit intometosin:masterfrom
alexander-yakushev:recursive-ref-transform
Jan 26, 2026
Merged

Reuse recursive ref-transformers#1250
opqdonut merged 1 commit intometosin:masterfrom
alexander-yakushev:recursive-ref-transform

Conversation

@alexander-yakushev
Copy link
Contributor

@alexander-yakushev alexander-yakushev commented Jan 4, 2026

This addresses a similar problem to #1245 but this time for transformers, not validators. The issue was discovered very recently in Metabase, where a decoder for a recursive schema definiton would grow infinitely, depending on the provided data (and from the look of it, the growth is quadratic). Here's a simplified reproducer:

(require '[clj-memory-meter.core :as mm])

(def my-sch
   [:schema
    {:registry {"hiccup" [:orn
                          [:node
                           [:catn
                            [:name keyword?]
                            [:props [:map-of keyword? any?]]
                            [:children [:* [:schema [:ref "hiccup"]]]]]]
                          [:primitive any?]]}}
    "hiccup"])

(def my-decoder (decoder my-sch (malli.transform/default-value-transformer)))

(mm/measure my-decoder) ;; 15.2 KiB - before any data has passed through the decoder

(defn generate-nested-hiccup [n]
   (if (zero? n)
     [:p {} "Hello, world of data"]
     [:div {} (generate-nested-hiccup (dec n))]))

(my-decoder (generate-nested-hiccup 100))
(mm/measure my-decoder)  ;; 157.3 KiB

(my-decoder (generate-nested-hiccup 200))
(mm/measure my-decoder) ;; 297.9 KiB - linear growth here but I've seen quadratic too

The solution is similar and inspired by @frenchy64's #1245, however, here I had the luxury of options map and so could avoid dynvar shenanigans. However, I would still need feedback on whether this approach is valid. Anyway, after the patch:

(def my-decoder (decoder my-sch (malli.transform/default-value-transformer)))

(mm/measure my-decoder) ; 42.0 KiB - initial footprint is bigger,
                        ; probably because it hangs onto a few more clojure.core vars

(my-decoder (generate-nested-hiccup 100))
(mm/measure my-decoder) ; 3.1 KiB - dropped down because I also benchmark with
                        ; https://github.com/metosin/malli/pull/1249, so you
                        ; can observe thunk cleaning at work

(my-decoder (generate-nested-hiccup 200))
(mm/measure my-decoder) ; 3.1 KiB - doesn't grow at all

@alexander-yakushev alexander-yakushev changed the title Reuse recursive ref-transforms Reuse recursive ref-transformers Jan 4, 2026
@dragonsahead
Copy link

Hi @opqdonut, this seems to be ready to merge, what's the process to make that happen?

metamben added a commit to metabase/metabase that referenced this pull request Jan 5, 2026
This is a temporary fix until metosin/malli#1250 gets merged into Malli.
metamben added a commit to metabase/metabase that referenced this pull request Jan 5, 2026
github-automation-metabase pushed a commit to metabase/metabase that referenced this pull request Jan 5, 2026
metamben added a commit to metabase/metabase that referenced this pull request Jan 5, 2026
github-automation-metabase added a commit to metabase/metabase that referenced this pull request Jan 5, 2026
…67742)

This is a temporary fix until metosin/malli#1250 gets merged into Malli.

Co-authored-by: metamben <103100869+metamben@users.noreply.github.com>
metamben pushed a commit to metabase/metabase that referenced this pull request Jan 5, 2026
@opqdonut
Copy link
Member

opqdonut commented Jan 7, 2026

I'll try to have time to review this this friday.

Feedback from @frenchy64 would be welcome as well, since he's thought a lot about this topic.

Copy link
Collaborator

@frenchy64 frenchy64 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks good, and should work with lazy and eager refs.

There might be further optimization opportunities for eager refs. For those, I think we wouldn't need -memoize or extra thunks, we can just create the transformer eagerly. If you want to investigate that, we should be able to contain the difference to a single branch like the if lazy test in the validator.

(let [key [(-identify-ref-schema this) method]]
(or (some-> (get-in options [::ref-transformer-cache key]) clojure.core/deref)
(let [knot (atom nil)
this-transformer (-value-transformer transformer this method options)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like a testing point. Could you add a unit test that counts how many times -value-transformer is called for a custom transformer that records this info? Then call the transformer and ensure the number is constant no matter the depth of the input?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've just shamelessly stolen your test case from #1254 for this, it is accurate down to comments 😄. Hope it's OK.

(let [this-transformer (-value-transformer transformer this method options)
deref-transformer (-memoize (fn [] (-transformer (rf) transformer method options)))]
(-intercepting this-transformer (fn [x] (if-some [t (deref-transformer)] (t x) x)))))
(let [key [(-identify-ref-schema this) method]]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here we don't include transformer (I assume) because it's constant as we recur. Is that also true of method?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. I'm not sure if the transformer can be changed mid-flight in the call chain. If it can, then it is possible to include transformer into the cache key too – it won't be expensive because most (all) of the time the transformers will be pointer-equal.

@opqdonut opqdonut moved this to ⌛Waiting in Metosin Open Source Backlog Jan 9, 2026
@alexander-yakushev
Copy link
Contributor Author

Added the test you've requested. I'm not sure if this is still relevant after the discussions on Slack and in:

However, this approach seems complexity-cheap and still brings decent benefit in the absence of a more holistic solution.

@opqdonut
Copy link
Member

@frenchy64 Do you think it makes sense to merge this or are you working on something that will have the same effect?

@frenchy64
Copy link
Collaborator

This PR is still relevant, I'll take a final look over and leave a review. Maybe there's a better approach but this seems like a great starting point, especially with a test.

@frenchy64 frenchy64 self-requested a review January 23, 2026 14:17
@opqdonut opqdonut merged commit 69366e4 into metosin:master Jan 26, 2026
16 checks passed
@github-project-automation github-project-automation bot moved this from ⌛Waiting to ✅ Done in Metosin Open Source Backlog Jan 26, 2026
Abhijnya002 pushed a commit to Abhijnya002/metabase that referenced this pull request Mar 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: ✅ Done

Development

Successfully merging this pull request may close these issues.

4 participants