@@ -11,6 +11,8 @@ import (
1111 "github.com/google/uuid"
1212)
1313
14+ const MAX_STEP_CACHE_ITEM_SIZE = 64 * 1024 // 64KB
15+
1416type ContextVisit struct {
1517 Node NodeBaseInterface `json:"-"`
1618 NodeID string `json:"node_id"`
@@ -30,6 +32,82 @@ const (
3032 Ephemeral
3133)
3234
35+ type StepCacheEntry struct {
36+ Conclusion string `json:"conclusion"`
37+ Outputs map [string ]any `json:"outputs"`
38+ }
39+
40+ // HierarchicalMap is a memory-efficient map that chains to parent contexts.
41+ // Each context only stores its own entries; lookups traverse the parent chain.
42+ type HierarchicalMap [K comparable , V any ] struct {
43+ data map [K ]V
44+ parent * HierarchicalMap [K , V ]
45+ }
46+
47+ // NewHierarchicalMap creates a new map, optionally chained to a parent.
48+ func NewHierarchicalMap [K comparable , V any ](parent * HierarchicalMap [K , V ]) * HierarchicalMap [K , V ] {
49+ return & HierarchicalMap [K , V ]{
50+ data : make (map [K ]V ),
51+ parent : parent ,
52+ }
53+ }
54+
55+ // Get retrieves a value by key, traversing up the parent chain if not found locally.
56+ func (m * HierarchicalMap [K , V ]) Get (key K ) (V , bool ) {
57+ if val , ok := m .data [key ]; ok {
58+ return val , true
59+ }
60+ if m .parent != nil {
61+ return m .parent .Get (key )
62+ }
63+ var zero V
64+ return zero , false
65+ }
66+
67+ // GetLocal retrieves a value only from local data, not traversing parents.
68+ func (m * HierarchicalMap [K , V ]) GetLocal (key K ) (V , bool ) {
69+ val , ok := m .data [key ]
70+ return val , ok
71+ }
72+
73+ // Set stores a value in the current context only (does not affect parent).
74+ func (m * HierarchicalMap [K , V ]) Set (key K , value V ) {
75+ m .data [key ] = value
76+ }
77+
78+ // All returns all entries merged from the entire chain (child entries override parent).
79+ func (m * HierarchicalMap [K , V ]) All () map [K ]V {
80+ result := make (map [K ]V )
81+ m .collectAll (result )
82+ return result
83+ }
84+
85+ func (m * HierarchicalMap [K , V ]) collectAll (result map [K ]V ) {
86+ if m .parent != nil {
87+ m .parent .collectAll (result )
88+ }
89+ maps .Copy (result , m .data )
90+ }
91+
92+ // StepCache is a type alias for the step output cache.
93+ type StepCache = HierarchicalMap [string , * StepCacheEntry ]
94+
95+ // NewStepCache creates a new step cache, optionally chained to a parent.
96+ func NewStepCache (parent * StepCache ) * StepCache {
97+ return NewHierarchicalMap (parent )
98+ }
99+
100+ // GetOrCreateStepEntry retrieves an existing local entry or creates a new one.
101+ // Only checks local cache to avoid races on shared parent entries.
102+ func GetOrCreateStepEntry (cache * StepCache , key string ) * StepCacheEntry {
103+ if entry , ok := cache .GetLocal (key ); ok {
104+ return entry
105+ }
106+ entry := & StepCacheEntry {Outputs : make (map [string ]any )}
107+ cache .Set (key , entry )
108+ return entry
109+ }
110+
33111// ExecutionState is a structure whose main purpose is to provide the correct output values
34112// and environment variables requested by nodes that were executed in subsequent goroutines.
35113//
@@ -85,6 +163,7 @@ type ExecutionState struct {
85163 OutputCacheLock * sync.RWMutex `json:"-"`
86164 DataOutputCache map [string ]any `json:"dataOutputCache"`
87165 ExecutionOutputCache map [string ]any `json:"executionOutputCache"`
166+ StepCache * StepCache `json:"stepCache"`
88167
89168 DebugCallback DebugCallback `json:"-"`
90169}
@@ -157,6 +236,7 @@ func (c *ExecutionState) PushNewExecutionState(parentNode NodeBaseInterface) *Ex
157236 OutputCacheLock : & sync.RWMutex {},
158237 DataOutputCache : make (map [string ]any ),
159238 ExecutionOutputCache : make (map [string ]any ),
239+ StepCache : NewStepCache (c .StepCache ),
160240
161241 Visited : visited ,
162242 DebugCallback : c .DebugCallback ,
@@ -203,19 +283,44 @@ func (c *ExecutionState) GetDataFromOutputCache(nodeCacheId string, outputId str
203283 return nil , false
204284}
205285
206- func (c * ExecutionState ) CacheDataOutput (nodeCacheId string , outputId string , value any , ct CacheType ) {
286+ func (c * ExecutionState ) CacheDataOutput (node NodeBaseInterface , outputId string , value any , outputType string , ct CacheType ) {
207287 c .ContextStackLock .RLock ()
208288 defer c .ContextStackLock .RUnlock ()
209289
210290 c .OutputCacheLock .Lock ()
211291 defer c .OutputCacheLock .Unlock ()
212292
213- cacheId := nodeCacheId + ":" + outputId
293+ cacheId := node . GetCacheId () + ":" + outputId
214294 if ct == Permanent {
215295 c .ExecutionOutputCache [cacheId ] = value
216296 } else {
217297 c .DataOutputCache [cacheId ] = value
218298 }
299+
300+ // Only cache primitive types under 64KB
301+ if isPrimitiveType (outputType ) && isUnderCacheLimit (value ) {
302+ stepEntry := GetOrCreateStepEntry (c .StepCache , node .GetId ())
303+ stepEntry .Outputs [outputId ] = value
304+ }
305+ }
306+
307+ // isPrimitiveType returns true if the output type is a primitive type that should be cached.
308+ func isPrimitiveType (outputType string ) bool {
309+ switch outputType {
310+ case "string" , "number" , "bool" :
311+ return true
312+ default :
313+ return false
314+ }
315+ }
316+
317+ // isUnderCacheLimit checks if a string value is under the cache size limit.
318+ // Non-string primitives always return true as they are small.
319+ func isUnderCacheLimit (value any ) bool {
320+ if s , ok := value .(string ); ok {
321+ return len (s ) <= MAX_STEP_CACHE_ITEM_SIZE
322+ }
323+ return true
219324}
220325
221326func (c * ExecutionState ) EmptyDataOutputCache () {
0 commit comments