Part of the sweep tracked in #518. Finding F27.
Summary
The nested-aggregate cycle guard carries the ancestor chain as template arguments (forms.hpp:2195-2222, :2252-2270):
recurseIntoNestedAggregateIfAny<Member, Ancestors..., Sub>(dom, property);
so annotateNestedAggregate<Leaf, Ancestors...> is a distinct instantiation per distinct root-to-node path. Any realistic domain model is a DAG — an Address under both Customer and Supplier, a Money everywhere — so the instantiation count is the path count, not the type count.
Verification status
Measured. Revision: master @ 4017228d. Ln { L(n-1) a; L(n-2) b; }, so path count = Fibonacci(n):
| depth |
paths |
compile |
| 6 |
8 |
4.1 s |
| 10 |
55 |
5.2 s |
| 14 |
377 |
13.7 s |
| 18 |
2584 |
86.6 s |
| 22 |
10946 |
did not finish in 120 s |
Growth tracks path count (86.6/13.7 = 6.3 vs 2584/377 = 6.9), confirming the mechanism. A plain chain of the same depth (Ln { L(n-1) a; L(n-1) b; }, one chain shape) stays flat at ~3.9 s — so it is specifically ancestor-set diversity that blows up, not depth.
Not verified: I did not check whether any in-tree action reaches a problematic path count; the ladder examples are shallow. This is a scaling property, reachable by a real domain model with shared sub-aggregates, not a defect biting today.
Suggested fix
Replace the type-list chain with a depth counter NTTP plus a runtime visited-set:
template <typename Sub, std::size_t Depth = 0>
void annotateNestedAggregate(glz::generic_u64& dom, glz::generic_u64& node,
std::unordered_set<std::string_view>& seen);
Bound Depth with static_assert(Depth < kMaxNestDepth, "…cyclic or excessively nested…"), preserving the existing diagnostic (which is good — forms.hpp:2199-2204, :2211-2217 names the problem, why it cannot be supported, and two concrete remedies).
That caps instantiations at kMaxNestDepth per type instead of one per path, and the runtime seen set also removes the currently-redundant re-annotation of the same $defs node once per path — the code notes this is "idempotent" at :2266-2268, which it is, but it is O(paths) of wasted work.
Related measurements worth recording here
The DOM round-trip is the other half of the schema compile cost. Single TU, gcc, -std=c++23, no optimisation:
| TU |
wall |
marginal |
int main(){} |
10 ms |
— |
+ <glaze/glaze.hpp> |
1834 ms |
the dependency |
+ <morph/forms/forms.hpp> |
2074 ms |
240 ms forms.hpp parse |
+ glz::read_json/write_json on glz::generic_u64 |
2640 ms |
566 ms DOM machinery |
+ glz::write_json_schema<A1>() |
2776 ms |
702 ms glaze schema writer |
+ morph::forms::schemaJson<A1>() |
4008 ms |
~660 ms morph's walkers |
| … over 10 distinct action types |
4376 ms |
41 ms per extra type |
The generic DOM is instantiated solely to add ~8 extension keys to a document glaze already built structurally. And the pipeline is longer than it looks: registry.hpp:368-412 parses the result again to add x-payloadFingerprint/x-payloadShape, so a served descriptor is text→DOM→text→DOM→text — five full passes.
glaze's schema struct already carries a user-extension channel (glz::json_schema<T>, schema::extra/merge_schema_attrs at schema.hpp:1015-1021). Contributing the extension keys through it removes the generic_u64 instantiation entirely (~570 ms/TU) and two of the five passes, with the same escaping guarantee. Failing that, registry.hpp's fingerprint keys can be contributed by mergeSchemaExtras itself — it already has the DOM open — collapsing five passes to three for free.
Finally, schemaJson<A>() caches correctly in a function-local static and then returns it by value (forms.hpp:3020-3025): measured at 200k calls on a 1153-byte schema = 5 ms, i.e. one allocation + memcpy per call. payload_schema.hpp:249,266 returns const std::string& for the identical pattern. Same at views.hpp:393, compounded at :419,430.
What would change the verdict
- Close the compile-time half if the depth limit is judged low enough in practice that path count never grows — but nothing enforces a limit today, and the failure is a 90-second compile rather than an error.
- Raise it if a real domain model in the ladder or in a downstream app hits it.
Part of the sweep tracked in #518. Finding F27.
Summary
The nested-aggregate cycle guard carries the ancestor chain as template arguments (
forms.hpp:2195-2222,:2252-2270):so
annotateNestedAggregate<Leaf, Ancestors...>is a distinct instantiation per distinct root-to-node path. Any realistic domain model is a DAG — anAddressunder bothCustomerandSupplier, aMoneyeverywhere — so the instantiation count is the path count, not the type count.Verification status
Measured. Revision:
master@4017228d.Ln { L(n-1) a; L(n-2) b; }, so path count = Fibonacci(n):Growth tracks path count (86.6/13.7 = 6.3 vs 2584/377 = 6.9), confirming the mechanism. A plain chain of the same depth (
Ln { L(n-1) a; L(n-1) b; }, one chain shape) stays flat at ~3.9 s — so it is specifically ancestor-set diversity that blows up, not depth.Not verified: I did not check whether any in-tree action reaches a problematic path count; the ladder examples are shallow. This is a scaling property, reachable by a real domain model with shared sub-aggregates, not a defect biting today.
Suggested fix
Replace the type-list chain with a depth counter NTTP plus a runtime visited-set:
Bound
Depthwithstatic_assert(Depth < kMaxNestDepth, "…cyclic or excessively nested…"), preserving the existing diagnostic (which is good —forms.hpp:2199-2204,:2211-2217names the problem, why it cannot be supported, and two concrete remedies).That caps instantiations at
kMaxNestDepthper type instead of one per path, and the runtimeseenset also removes the currently-redundant re-annotation of the same$defsnode once per path — the code notes this is "idempotent" at:2266-2268, which it is, but it is O(paths) of wasted work.Related measurements worth recording here
The DOM round-trip is the other half of the schema compile cost. Single TU, gcc,
-std=c++23, no optimisation:int main(){}+ <glaze/glaze.hpp>+ <morph/forms/forms.hpp>+ glz::read_json/write_jsononglz::generic_u64+ glz::write_json_schema<A1>()+ morph::forms::schemaJson<A1>()The generic DOM is instantiated solely to add ~8 extension keys to a document glaze already built structurally. And the pipeline is longer than it looks:
registry.hpp:368-412parses the result again to addx-payloadFingerprint/x-payloadShape, so a served descriptor is text→DOM→text→DOM→text — five full passes.glaze's
schemastruct already carries a user-extension channel (glz::json_schema<T>,schema::extra/merge_schema_attrsatschema.hpp:1015-1021). Contributing the extension keys through it removes thegeneric_u64instantiation entirely (~570 ms/TU) and two of the five passes, with the same escaping guarantee. Failing that,registry.hpp's fingerprint keys can be contributed bymergeSchemaExtrasitself — it already has the DOM open — collapsing five passes to three for free.Finally,
schemaJson<A>()caches correctly in a function-local static and then returns it by value (forms.hpp:3020-3025): measured at 200k calls on a 1153-byte schema = 5 ms, i.e. one allocation + memcpy per call.payload_schema.hpp:249,266returnsconst std::string&for the identical pattern. Same atviews.hpp:393, compounded at:419,430.What would change the verdict