Skip to content

Commit aa2b145

Browse files
jfrolichclaudemununki
authored
fix: O(n) nested match decoder replaces O(n²) tuple pattern matching (#111)
* test: add reproduction for exponential compile time bug Large records with 26+ optional fields cause exponential compile time growth due to nested switch statements in generated decoder code. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: O(n) nested match decoder replaces O(n²) tuple pattern matching The previous decoder generated O(n²) pattern matches for records with n fields, causing exponential compile time growth in the ReScript type checker. This fix uses nested match expressions instead, generating O(n) AST nodes: - Before: 26 fields = ~102 seconds compile time - After: 26 fields = ~0.7 seconds compile time The fix is minimal and surgical - only records.ml is changed. It preserves all existing behavior and passes all tests. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: prioritize @spice.default over is_option in pattern matching For a field like `field: option<int> [@spice.default Some(1)]`, the previous pattern matching order would match `| _, true, _` (is_option=true) before `| _, _, Some d` (has default), causing the default value to be ignored. Reordered patterns to check for default values first: 1. `| _, _, Some d` - If there's ANY default, use it 2. `| true, _, None` - Optional field, no default -> Ok None 3. `| _, true, None` - Option type, no default -> Ok None 4. `| _, _, None` - Required field, no default -> error Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * changelog --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: mununki <woonki.moon@gmail.com>
1 parent 82acf19 commit aa2b145

4 files changed

Lines changed: 120 additions & 93 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## 0.3.2(unreleased)
44

5+
- Fixes O(n) nested match decoder replaces O(n²) tuple pattern matching to prevent the hang with the recode with many fields https://github.com/green-labs/ppx_spice/pull/111
6+
57
## 0.3.1
68

79
- Fixes [#107](https://github.com/green-labs/ppx_spice/issues/107) Arrays being reversed by Spice.arrayFromJson

src/ppx/records.ml

Lines changed: 53 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -43,73 +43,12 @@ let generate_encoder decls unboxed =
4343
|> Exp.fun_ Asttypes.Nolabel None [%pat? v]
4444
|> Utils.expr_func ~arity:1
4545

46-
let generate_flat_decoder_expr decls =
46+
(* O(n) nested match decoder - replaces O(n²) tuple pattern matching *)
47+
let generate_nested_decoder decls =
4748
let loc = !default_loc in
4849
let dict_expr = Exp.ident (mknoloc (Longident.Lident "dict")) in
49-
let field_results =
50-
List.map
51-
(fun d ->
52-
let { name; key; codecs; default; is_optional; is_option } = d in
53-
let result_name = name ^ "_result" in
54-
let decode_expr =
55-
match codecs with
56-
| _, Some decode ->
57-
let get_expr = [%expr Dict.get [%e dict_expr] [%e key]] in
58-
let decode_applied = [%expr [%e decode]] in
59-
let opt_map =
60-
[%expr Option.map [%e get_expr] [%e decode_applied]]
61-
in
62-
let default_expr =
63-
match (is_optional, is_option, default) with
64-
| true, _, Some d ->
65-
[%expr Option.getOr [%e opt_map] (Ok [%e d])]
66-
| true, _, None -> [%expr Option.getOr [%e opt_map] (Ok None)]
67-
| _, true, _ -> [%expr Option.getOr [%e opt_map] (Ok None)]
68-
| _, _, Some d -> [%expr Option.getOr [%e opt_map] (Ok [%e d])]
69-
| _, _, None ->
70-
[%expr
71-
Option.getOr [%e opt_map]
72-
(Spice.error ([%e key] ^ " missing") v)]
73-
in
74-
default_expr
75-
| _ -> [%expr Spice.error ([%e key] ^ " missing") v]
76-
in
77-
(result_name, decode_expr))
78-
decls
79-
in
80-
let let_bindings =
81-
List.map
82-
(fun (result_name, decode_expr) ->
83-
Vb.mk (Pat.var (mknoloc result_name)) decode_expr)
84-
field_results
85-
in
86-
let tuple_expr =
87-
match field_results with
88-
| [ (result_name, _) ] -> Exp.ident (mknoloc (Longident.Lident result_name))
89-
| _ ->
90-
Exp.tuple
91-
(List.map
92-
(fun (result_name, _) ->
93-
Exp.ident (mknoloc (Longident.Lident result_name)))
94-
field_results)
95-
in
96-
let ok_pattern =
97-
match decls with
98-
| [ d ] ->
99-
let { name; _ } = d in
100-
Pat.construct
101-
(mknoloc (Longident.Lident "Ok"))
102-
(Some (Pat.var (mknoloc name)))
103-
| _ ->
104-
Pat.tuple
105-
(List.map
106-
(fun d ->
107-
let { name; _ } = d in
108-
Pat.construct
109-
(mknoloc (Longident.Lident "Ok"))
110-
(Some (Pat.var (mknoloc name))))
111-
decls)
112-
in
50+
51+
(* Build the final Ok expression with the record *)
11352
let ok_expr =
11453
let record_fields =
11554
List.map
@@ -121,39 +60,60 @@ let generate_flat_decoder_expr decls =
12160
in
12261
[%expr Ok [%e Exp.record record_fields None]]
12362
in
124-
let error_patterns =
125-
List.mapi
126-
(fun i d ->
127-
let { key; _ } = d in
128-
let pats =
129-
List.init (List.length decls) (fun j ->
130-
if i = j then
131-
Pat.construct
132-
(mknoloc (Longident.Lident "Error"))
133-
(Some
134-
(Pat.constraint_
135-
(Pat.var (mknoloc "e"))
136-
(Typ.constr
137-
(mknoloc (Longident.parse "Spice.decodeError"))
138-
[])))
139-
else Pat.any ())
140-
in
141-
let pat = match pats with [ p ] -> p | _ -> Pat.tuple pats in
142-
Exp.case pat [%expr Spice.error ~path:[%e key] e.message e.value])
143-
decls
63+
64+
(* Generate decode expression for a single field *)
65+
let generate_decode_expr d =
66+
let { key; codecs; default; is_optional; is_option; _ } = d in
67+
match codecs with
68+
| _, Some decode ->
69+
let get_expr = [%expr Dict.get [%e dict_expr] [%e key]] in
70+
let decode_applied = [%expr [%e decode]] in
71+
let opt_map = [%expr Option.map [%e get_expr] [%e decode_applied]] in
72+
(match (is_optional, is_option, default) with
73+
| _, _, Some d -> [%expr Option.getOr [%e opt_map] (Ok [%e d])]
74+
| true, _, None -> [%expr Option.getOr [%e opt_map] (Ok None)]
75+
| _, true, None -> [%expr Option.getOr [%e opt_map] (Ok None)]
76+
| _, _, None ->
77+
[%expr
78+
Option.getOr [%e opt_map] (Spice.error ([%e key] ^ " missing") v)])
79+
| _ -> [%expr Spice.error ([%e key] ^ " missing") v]
14480
in
145-
let match_expr =
146-
Exp.match_ tuple_expr (Exp.case ok_pattern ok_expr :: error_patterns)
81+
82+
(* Build nested matches from the last field backwards *)
83+
let build_nested_matches decls inner_expr =
84+
let rec loop acc = function
85+
| [] -> acc
86+
| d :: rest ->
87+
let { name; key; _ } = d in
88+
let decode_expr = generate_decode_expr d in
89+
let var_pat = Pat.var (mknoloc name) in
90+
let ok_case =
91+
Exp.case
92+
(Pat.construct (mknoloc (Longident.Lident "Ok")) (Some var_pat))
93+
acc
94+
in
95+
let error_case =
96+
Exp.case
97+
(Pat.construct
98+
(mknoloc (Longident.Lident "Error"))
99+
(Some
100+
(Pat.constraint_
101+
(Pat.var (mknoloc "e"))
102+
(Typ.constr
103+
(mknoloc (Longident.parse "Spice.decodeError"))
104+
[]))))
105+
[%expr Spice.error ~path:[%e key] e.message e.value]
106+
in
107+
let match_expr = Exp.match_ decode_expr [ ok_case; error_case ] in
108+
loop match_expr rest
109+
in
110+
loop inner_expr (List.rev decls)
147111
in
148-
List.fold_right
149-
(fun vb acc -> Exp.let_ Nonrecursive [ vb ] acc)
150-
let_bindings match_expr
151112

152-
let generate_nested_switches_recurse _path decls _remaining_decls =
153-
generate_flat_decoder_expr decls
113+
build_nested_matches decls ok_expr
154114

155115
let generate_nested_switches decls =
156-
generate_nested_switches_recurse [] decls decls
116+
generate_nested_decoder decls
157117

158118
let generate_decoder decls unboxed =
159119
match unboxed with

test/src/LargeRecordRepro.res

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Reproduction of exponential compile time bug
2+
// Compile time grows ~4x per 2 additional fields:
3+
// - 20 fields: ~1s
4+
// - 22 fields: ~4s
5+
// - 24 fields: ~17s
6+
// - 26 fields: ~71s
7+
// - 28 fields: >180s (timeout)
8+
// - 31 fields: effectively infinite
9+
//
10+
// The issue is in the generated decoder code which creates nested switch
11+
// statements with exponential complexity in the ReScript type checker.
12+
13+
@spice
14+
type t = {
15+
f1: option<string>,
16+
f2: option<string>,
17+
f3: option<string>,
18+
f4: option<string>,
19+
f5: option<string>,
20+
f6: option<string>,
21+
f7: option<string>,
22+
f8: option<string>,
23+
f9: option<string>,
24+
f10: option<string>,
25+
f11: option<string>,
26+
f12: option<string>,
27+
f13: option<string>,
28+
f14: option<string>,
29+
f15: option<string>,
30+
f16: option<string>,
31+
f17: option<string>,
32+
f18: option<string>,
33+
f19: option<string>,
34+
f20: option<string>,
35+
f21: option<string>,
36+
f22: option<string>,
37+
f23: option<string>,
38+
f24: option<string>,
39+
f25: option<string>,
40+
f26: option<string>,
41+
}

test/src/SmallRecord.res

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// 20 fields - should compile in ~1s
2+
@spice
3+
type t = {
4+
f1: option<string>,
5+
f2: option<string>,
6+
f3: option<string>,
7+
f4: option<string>,
8+
f5: option<string>,
9+
f6: option<string>,
10+
f7: option<string>,
11+
f8: option<string>,
12+
f9: option<string>,
13+
f10: option<string>,
14+
f11: option<string>,
15+
f12: option<string>,
16+
f13: option<string>,
17+
f14: option<string>,
18+
f15: option<string>,
19+
f16: option<string>,
20+
f17: option<string>,
21+
f18: option<string>,
22+
f19: option<string>,
23+
f20: option<string>,
24+
}

0 commit comments

Comments
 (0)