Skip to content

Commit f9127d0

Browse files
Add module cache
1 parent 65723a2 commit f9127d0

1 file changed

Lines changed: 94 additions & 19 deletions

File tree

pkg/workflows/wasm/host/module.go

Lines changed: 94 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ package host
33
import (
44
"bytes"
55
"context"
6+
"crypto/sha256"
67
"encoding/base64"
78
"encoding/binary"
9+
"encoding/hex"
810
"encoding/json"
911
"errors"
1012
"fmt"
@@ -16,6 +18,7 @@ import (
1618
"path/filepath"
1719
"regexp"
1820
"strings"
21+
"sync"
1922
"time"
2023

2124
"github.com/andybalholm/brotli"
@@ -128,11 +131,14 @@ type ExecutionHelper = host.ExecutionHelper
128131

129132
type module struct {
130133
// The compiled module is not kept resident for the lifetime of the module
131-
// instance. Instead it is compiled once, serialized to a file in moduleDir,
132-
// and the in-memory *wasmtime.Module is closed. Each guest invocation
133-
// (callWasm) deserializes its own *wasmtime.Module from modulePath and closes
134-
// it when the invocation returns, so nothing is held while suspended.
135-
moduleDir string
134+
// instance. Instead it is compiled once, serialized, and written to the
135+
// process-global content-addressed moduleFileCache (see moduleCache); the
136+
// in-memory *wasmtime.Module is then closed. modulePath points at the cache
137+
// entry, which is keyed by the SHA-256 of the serialized bytes so that
138+
// modules compiled from identical binaries share a single file. Each guest
139+
// invocation (callWasm) deserializes its own *wasmtime.Module from modulePath
140+
// and closes it when the invocation returns, so nothing is held while
141+
// suspended.
136142
modulePath string
137143

138144
cfg *ModuleConfig
@@ -152,6 +158,77 @@ type module struct {
152158
// callWasmFunc runs one guest invocation for the v2 (no-DAG) execution path.
153159
type callWasmFunc func(timeout time.Duration, req *sdkpb.ExecuteRequest, linkWasm linkFn[*sdkpb.ExecutionResult], exec *execution[*sdkpb.ExecutionResult]) (time.Duration, error)
154160

161+
// moduleFileCache is the process-global store for serialized wasmtime modules.
162+
var moduleFileCache = &moduleCache{refs: map[string]int{}}
163+
164+
// moduleCache is a content-addressed, reference-counted store for serialized
165+
// wasmtime modules. Modules compiled from identical binaries serialize to
166+
// identical bytes, so they are written once to a shared directory under a key
167+
// derived from the SHA-256 of the serialized bytes and shared by every module
168+
// instance that references them. The reference count tracks live users of each
169+
// key so a file is removed only when the last module referencing it is closed.
170+
type moduleCache struct {
171+
mu sync.Mutex
172+
dir string
173+
refs map[string]int
174+
}
175+
176+
// store writes serialized to the cache, keyed by the SHA-256 of its contents,
177+
// and returns the path to the cache entry. The refcount check under the mutex
178+
// is the atomic decision: only the first reference to a given key writes the
179+
// file; subsequent references reuse the existing one (dedupe). The returned path
180+
// must later be passed to release exactly once (see module.Close).
181+
func (c *moduleCache) store(serialized []byte) (string, error) {
182+
sum := sha256.Sum256(serialized)
183+
key := hex.EncodeToString(sum[:])
184+
185+
c.mu.Lock()
186+
defer c.mu.Unlock()
187+
188+
if err := c.ensureDir(); err != nil {
189+
return "", err
190+
}
191+
path := filepath.Join(c.dir, key)
192+
193+
if c.refs[key] == 0 {
194+
if err := os.WriteFile(path, serialized, 0o600); err != nil {
195+
return "", fmt.Errorf("error writing serialized module: %w", err)
196+
}
197+
}
198+
c.refs[key]++
199+
return path, nil
200+
}
201+
202+
// release drops one reference to the cache entry at path, removing the file once
203+
// the last reference is gone. It is safe to call with a path returned by store.
204+
func (c *moduleCache) release(path string) {
205+
key := filepath.Base(path)
206+
207+
c.mu.Lock()
208+
defer c.mu.Unlock()
209+
210+
switch {
211+
case c.refs[key] <= 1:
212+
delete(c.refs, key)
213+
_ = os.Remove(path)
214+
default:
215+
c.refs[key]--
216+
}
217+
}
218+
219+
// ensureDir lazily creates the shared cache directory. The caller must hold c.mu.
220+
func (c *moduleCache) ensureDir() error {
221+
if c.dir != "" {
222+
return nil
223+
}
224+
dir, err := os.MkdirTemp("", "wasm-module-cache-")
225+
if err != nil {
226+
return fmt.Errorf("error creating module cache dir: %w", err)
227+
}
228+
c.dir = dir
229+
return nil
230+
}
231+
155232
var _ ModuleV1 = (*module)(nil)
156233

157234
type linkFn[T any] func(m *module, store *wasmtime.Store, mod *wasmtime.Module, exec *execution[T]) (*wasmtime.Instance, error)
@@ -416,33 +493,29 @@ func newModule(modCfg *ModuleConfig, binary []byte) (*module, error) {
416493

417494
modCfg.SdkLabeler(v2ImportName)
418495

419-
// Serialize the compiled module to a file and close the in-memory module: it
420-
// is not kept resident for the lifetime of the instance. Each guest
421-
// invocation deserializes its own copy from this file (see callWasm).
496+
// Serialize the compiled module and close the in-memory module: it is not
497+
// kept resident for the lifetime of the instance. The serialized bytes are
498+
// stored in the process-global content-addressed cache, keyed by their
499+
// SHA-256, so modules compiled from identical binaries share one file. Each
500+
// guest invocation deserializes its own copy from this file (see callWasm).
422501
serialized, err := mod.Serialize()
423502
mod.Close()
424503
if err != nil {
425504
return nil, fmt.Errorf("error serializing wasmtime module: %w", err)
426505
}
427506

428-
moduleDir, err := os.MkdirTemp("", "wasm-module-")
507+
modulePath, err := moduleFileCache.store(serialized)
429508
if err != nil {
430-
return nil, fmt.Errorf("error creating module temp dir: %w", err)
431-
}
432-
modulePath := filepath.Join(moduleDir, "module.bin")
433-
if err := os.WriteFile(modulePath, serialized, 0o600); err != nil {
434-
_ = os.RemoveAll(moduleDir)
435-
return nil, fmt.Errorf("error writing serialized module: %w", err)
509+
return nil, err
436510
}
437511

438512
metrics, err := newModuleMetrics()
439513
if err != nil {
440-
_ = os.RemoveAll(moduleDir)
514+
moduleFileCache.release(modulePath)
441515
return nil, fmt.Errorf("error creating module metrics: %w", err)
442516
}
443517

444518
m := &module{
445-
moduleDir: moduleDir,
446519
modulePath: modulePath,
447520
cfg: modCfg,
448521
metrics: metrics,
@@ -603,8 +676,10 @@ func (m *module) Start() {}
603676

604677
func (m *module) Close() {
605678
// The engine and its epoch ticker are process-global (see wasmEngine) and
606-
// shared by all modules, so they are intentionally not stopped here.
607-
_ = os.RemoveAll(m.moduleDir)
679+
// shared by all modules, so they are intentionally not stopped here. The
680+
// cache entry is reference counted and removed only once the last module
681+
// referencing these serialized bytes is closed (see moduleCache).
682+
moduleFileCache.release(m.modulePath)
608683
}
609684

610685
func (m *module) IsLegacyDAG() bool {

0 commit comments

Comments
 (0)