@@ -13,8 +13,7 @@ type Encoder struct{}
1313
1414// Encode serializes an element to []byte.
1515// Clean elements passthrough raw bytes unchanged.
16- // Dirty elements rebuild: start from raw fields, overlay dirty property values,
17- // recursively encode child elements.
16+ // Dirty elements rebuild using buildDoc.
1817func (e * Encoder ) Encode (elem element.Element ) ([]byte , error ) {
1918 raw := elem .Raw ()
2019 if raw != nil && ! elem .IsDirty () {
@@ -28,115 +27,180 @@ func (e *Encoder) Encode(elem element.Element) ([]byte, error) {
2827 return bson .Marshal (doc )
2928}
3029
31- // buildDoc constructs a bson.D for an element, merging raw fields with dirty overrides.
30+ // rebuildEntry records which properties of an element need re-encoding.
31+ type rebuildEntry struct {
32+ wp element.WritableProperty
33+ cp element.ChildProperty
34+ clp element.ChildListProperty
35+ }
36+
37+ // buildDoc constructs a bson.D for a dirty element.
38+ //
39+ // Key optimization over the previous implementation: instead of calling
40+ // bson.Unmarshal(raw, &bson.D) — which recursively decodes the entire BSON
41+ // tree into Go objects (3 591 allocs for a 25 KB microflow) — we iterate the
42+ // raw bytes with bson.Raw.Elements() and pass clean fields through as
43+ // bson.RawValue (zero-alloc). Only fields that are actually dirty are decoded
44+ // and re-encoded.
3245func (e * Encoder ) buildDoc (elem element.Element ) (bson.D , error ) {
33- // Start from raw if available (preserves unknown fields).
34- var doc bson.D
35- if raw := elem .Raw (); raw != nil {
36- if err := bson .Unmarshal (raw , & doc ); err != nil {
37- return nil , fmt .Errorf ("unmarshal raw for %s: %w" , elem .TypeName (), err )
38- }
39- } else {
40- // New element — start with identity fields.
41- doc = bson.D {
46+ // Build an index of properties that need rebuilding: dirty scalars,
47+ // dirty children, or child lists with at least one dirty member.
48+ rebuild := make (map [string ]* rebuildEntry , len (elem .Properties ()))
49+ for _ , prop := range elem .Properties () {
50+ wp , ok := prop .(element.WritableProperty )
51+ if ! ok {
52+ continue
53+ }
54+ cp , _ := prop .(element.ChildProperty )
55+ clp , _ := prop .(element.ChildListProperty )
56+
57+ needsRebuild := wp .Dirty ()
58+ if ! needsRebuild {
59+ if cp != nil {
60+ ch := cp .ChildElement ()
61+ needsRebuild = ch != nil && ch .IsDirty ()
62+ } else if clp != nil {
63+ needsRebuild = anyChildDirty (clp )
64+ }
65+ }
66+ if needsRebuild {
67+ rebuild [prop .Name ()] = & rebuildEntry {wp , cp , clp }
68+ }
69+ }
70+
71+ raw := elem .Raw ()
72+
73+ // === New element (no raw bytes) ===
74+ // Iterate elem.Properties() — not the rebuild map — to preserve stable
75+ // field ordering. Go map iteration is non-deterministic; using the map
76+ // as the source of ordering would produce different BSON byte sequences
77+ // across runs (TestSerializeWorkflowActivityGen_RoundTripIsStable).
78+ if raw == nil {
79+ doc := bson.D {
4280 {Key : "$ID" , Value : idToBinarySubtype0 (elem .ID ())},
4381 {Key : "$Type" , Value : elem .TypeName ()},
4482 }
83+ for _ , prop := range elem .Properties () {
84+ rb , needsRebuild := rebuild [prop .Name ()]
85+ if ! needsRebuild {
86+ continue
87+ }
88+ val , err := e .encodeEntry (rb )
89+ if err != nil {
90+ return nil , err
91+ }
92+ if val != nil {
93+ doc = append (doc , bson.E {Key : prop .Name (), Value : val })
94+ }
95+ }
96+ return doc , nil
97+ }
98+
99+ // === Existing element — iterate raw bytes, pass clean fields through ===
100+ rawElems , err := bson .Raw (raw ).Elements ()
101+ if err != nil {
102+ return nil , fmt .Errorf ("read raw elements for %s: %w" , elem .TypeName (), err )
45103 }
46104
47- // Overlay dirty properties.
105+ doc := make (bson.D , 0 , len (rawElems ))
106+ for _ , re := range rawElems {
107+ key := re .Key ()
108+ rb , dirty := rebuild [key ]
109+ if ! dirty {
110+ // Clean field: pass through raw bytes without allocating any Go objects.
111+ doc = append (doc , bson.E {Key : key , Value : re .Value ()})
112+ continue
113+ }
114+ // Dirty field: encode new value.
115+ val , err := e .encodeEntry (rb )
116+ if err != nil {
117+ return nil , err
118+ }
119+ if val != nil {
120+ doc = append (doc , bson.E {Key : key , Value : val })
121+ }
122+ // Mark handled so we don't append it again below.
123+ delete (rebuild , key )
124+ }
125+
126+ // Append dirty fields that didn't exist in the raw bytes (new properties).
127+ // Iterate Properties() for stable ordering — not the rebuild map.
48128 for _ , prop := range elem .Properties () {
49- wp , ok := prop .(element. WritableProperty )
129+ rb , ok := rebuild [ prop .Name ()]
50130 if ! ok {
51131 continue
52132 }
133+ val , err := e .encodeEntry (rb )
134+ if err != nil {
135+ return nil , err
136+ }
137+ if val != nil {
138+ doc = append (doc , bson.E {Key : prop .Name (), Value : val })
139+ }
140+ }
53141
54- key := prop .Name ()
55-
56- // Handle child properties (Part) — recursive encode.
57- // Three branches mirror the PartList logic below:
58- // 1. Self-dirty: Part.Set was called → re-encode child.
59- // 2. Child-dirty: Part itself unchanged but the held child was
60- // mutated in place → re-encode child so its changes reach
61- // the output (without this branch the parent passes through
62- // raw bytes and deep edits silently disappear).
63- // 3. Completely clean: leave the raw field untouched.
64- if cp , ok := prop .(element.ChildProperty ); ok {
65- child := cp .ChildElement ()
66- if wp .Dirty () {
67- if child != nil {
68- childDoc , err := e .buildDoc (child )
69- if err != nil {
70- return nil , err
71- }
72- doc = setField (doc , key , childDoc )
73- } else {
74- doc = setField (doc , key , nil )
75- }
76- } else if child != nil && child .IsDirty () {
142+ return doc , nil
143+ }
144+
145+ // encodeEntry produces the BSON value for a single dirty property.
146+ func (e * Encoder ) encodeEntry (rb * rebuildEntry ) (any , error ) {
147+ wp := rb .wp
148+
149+ // Child (Part) property.
150+ if rb .cp != nil {
151+ child := rb .cp .ChildElement ()
152+ if wp .Dirty () {
153+ if child == nil {
154+ return nil , nil // deleted
155+ }
156+ return e .buildDoc (child )
157+ }
158+ // Child itself is dirty (parent pointer unchanged).
159+ if child != nil && child .IsDirty () {
160+ return e .buildDoc (child )
161+ }
162+ return nil , nil
163+ }
164+
165+ // Child list (PartList) property.
166+ if rb .clp != nil {
167+ children := rb .clp .ChildElements ()
168+ if wp .Dirty () {
169+ // Full rebuild: all children re-encoded.
170+ arr := make (bson.A , 0 , 1 + len (children ))
171+ arr = append (arr , int32 (3 ))
172+ for _ , child := range children {
77173 childDoc , err := e .buildDoc (child )
78174 if err != nil {
79175 return nil , err
80176 }
81- doc = setField ( doc , key , childDoc )
177+ arr = append ( arr , childDoc )
82178 }
83- continue
179+ return arr , nil
84180 }
85-
86- // Handle child list properties (PartList) — three branches:
87- // 1. Self-dirty: list was modified (Append/Remove) → full rebuild.
88- // 2. Child-dirty: list unchanged but a child was modified → selective rebuild.
89- // 3. Completely clean → skip (don't touch the raw field).
90- if clp , ok := prop .(element.ChildListProperty ); ok {
91- if wp .Dirty () {
92- // Branch 1: full rebuild — all children re-encoded.
93- children := clp .ChildElements ()
94- arr := bson.A {int32 (3 )}
95- for _ , child := range children {
96- childDoc , err := e .buildDoc (child )
97- if err != nil {
98- return nil , err
99- }
100- arr = append (arr , childDoc )
101- }
102- doc = setField (doc , key , arr )
103- } else if anyChildDirty (clp ) {
104- // Branch 2: selective rebuild — dirty children re-encoded, clean ones pass through raw bytes.
105- children := clp .ChildElements ()
106- arr := bson.A {int32 (3 )}
107- for _ , child := range children {
108- if child .IsDirty () {
109- childDoc , err := e .buildDoc (child )
110- if err != nil {
111- return nil , err
112- }
113- arr = append (arr , childDoc )
114- } else {
115- arr = append (arr , bson .Raw (child .Raw ()))
116- }
181+ // Selective rebuild: dirty children re-encoded, clean ones pass through raw bytes.
182+ arr := make (bson.A , 0 , 1 + len (children ))
183+ arr = append (arr , int32 (3 ))
184+ for _ , child := range children {
185+ if child .IsDirty () {
186+ childDoc , err := e .buildDoc (child )
187+ if err != nil {
188+ return nil , err
117189 }
118- doc = setField (doc , key , arr )
190+ arr = append (arr , childDoc )
191+ } else {
192+ arr = append (arr , bson .Raw (child .Raw ()))
119193 }
120- // Branch 3: completely clean — leave raw field untouched.
121- continue
122- }
123-
124- // Scalar/Enum/Ref properties — only process if dirty.
125- if ! wp .Dirty () {
126- continue
127- }
128-
129- // Scalar/Enum/Ref properties — use BSONValue directly.
130- val := wp .BSONValue ()
131- // Convert element.ID to binary UUID for BSON compatibility.
132- if id , ok := val .(element.ID ); ok && id != "" {
133- doc = setField (doc , key , idToBinary (id ))
134- } else {
135- doc = setField (doc , key , val )
136194 }
195+ return arr , nil
137196 }
138197
139- return doc , nil
198+ // Scalar / Enum / Ref property.
199+ val := wp .BSONValue ()
200+ if id , ok := val .(element.ID ); ok && id != "" {
201+ return idToBinary (id ), nil
202+ }
203+ return val , nil
140204}
141205
142206// anyChildDirty reports whether any element in the ChildListProperty is dirty.
@@ -149,21 +213,8 @@ func anyChildDirty(clp element.ChildListProperty) bool {
149213 return false
150214}
151215
152- // setField replaces or appends a field in a bson.D.
153- func setField (doc bson.D , key string , val any ) bson.D {
154- for i , e := range doc {
155- if e .Key == key {
156- doc [i ].Value = val
157- return doc
158- }
159- }
160- return append (doc , bson.E {Key : key , Value : val })
161- }
162-
163- // idToBinarySubtype0 converts a UUID string to BSON Binary subtype 0
164- // using the Mendix byte-swap convention (via sdk/mpr.IDToBsonBinary).
165- // When id is empty (new element, no ID assigned), a fresh UUID is generated
166- // so the BSON always carries a valid Binary $ID.
216+ // idToBinarySubtype0 converts a UUID string to BSON Binary subtype 0.
217+ // When id is empty a fresh UUID is generated.
167218func idToBinarySubtype0 (id element.ID ) any {
168219 if id == "" {
169220 return mpr .IDToBsonBinary (mpr .GenerateID ())
0 commit comments