Skip to content

Commit 84b17a8

Browse files
engalarclaude
andcommitted
perf(daemon): persistent backend + content cache — hot path 1s→300ms
Pre-connect MprBackend at per-MPR daemon startup (--mpr-path flag). Reuse the connection across requests via noOpConnectBackend wrapper that overrides Connect/Disconnect/IsConnected as no-ops. The concrete *MprBackend is embedded (not the interface) so duck-type checks (microflowsRepoProvider etc.) resolve correctly — fixing a nil-panic crash on show microflows in persistent mode. Reader.contentCache: in-memory store for mxunit file bytes, enabled via Reader.EnableContentCache() and MprBackend.EnableContentCache(). First request populates the cache; subsequent requests skip all file I/O. Cache is cleared (map reset, not nil) by InvalidateCache() so writes invalidate without disabling the cache. Add daemonRequestMu to serialize concurrent requests: SQLite is single-writer and os.Chdir is process-global. Result (minimal.mpr, Windows): cold start: ~25s (daemon spawn + SQLite open + buildUnitCache) hot path: ~300ms (was ~1000ms) — mainly launcher process startup Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent a781776 commit 84b17a8

7 files changed

Lines changed: 146 additions & 5 deletions

File tree

cmd/mxcli-launcher/mpr_daemon.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ func (e *Env) ensureMPRDaemon(mprAbsPath string) (string, error) {
5151
e.daemonBinaryPath(),
5252
"--serve", sockPath,
5353
"--idle-timeout", mprDaemonIdleTimeout.String(),
54+
"--mpr-path", mprAbsPath,
5455
)
5556
cmd.Stdout = nil
5657
cmd.Stderr = nil

cmd/mxcli/daemon_backend.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
package main
4+
5+
import (
6+
"fmt"
7+
"os"
8+
"strings"
9+
"sync"
10+
11+
mprbackend "github.com/mendixlabs/mxcli/mdl/backend/mpr"
12+
)
13+
14+
// persistentDaemonBackend holds the pre-connected backend for the daemon process.
15+
// Non-nil only in --serve + --mpr-path mode (per-MPR daemon).
16+
// Kept as concrete type so duck-type checks (microflowsRepoProvider etc.) work.
17+
// Never modified after main() sets it.
18+
var persistentDaemonBackend *mprbackend.MprBackend
19+
20+
// daemonRequestMu serializes command execution on the persistent backend.
21+
// SQLite is single-writer; os.Chdir is process-global — both require serialization.
22+
var daemonRequestMu sync.Mutex
23+
24+
// noOpConnectBackend wraps *mprbackend.MprBackend (not the interface) so that
25+
// duck-type checks like b.(microflowsRepoProvider) still succeed — the concrete
26+
// type's Microflows() / Nanoflows() / etc. methods are promoted and visible.
27+
// Only Connect / Disconnect / IsConnected are overridden to be no-ops so the
28+
// persistent SQLite connection is never closed between daemon requests.
29+
type noOpConnectBackend struct{ *mprbackend.MprBackend }
30+
31+
func (n *noOpConnectBackend) Connect(string) error { return nil }
32+
func (n *noOpConnectBackend) Disconnect() error { return nil }
33+
func (n *noOpConnectBackend) IsConnected() bool { return true }
34+
35+
// openPersistentBackend opens an MprBackend and returns it as FullBackend.
36+
// Called once at daemon startup; the connection lives for the daemon's lifetime.
37+
// EnableContentCache is called so mxunit file reads are cached in memory:
38+
// the first request populates the cache; subsequent requests skip all file I/O.
39+
func openPersistentBackend(mprPath string) (*mprbackend.MprBackend, error) {
40+
b := mprbackend.New()
41+
if err := b.Connect(mprPath); err != nil {
42+
return nil, fmt.Errorf("pre-connect %s: %w", mprPath, err)
43+
}
44+
b.EnableContentCache()
45+
return b, nil
46+
}
47+
48+
// extractMPRPath scans args for "--mpr-path <path>" or "--mpr-path=<path>".
49+
func extractMPRPath(args []string) string {
50+
const flag = "--mpr-path"
51+
for i, a := range args {
52+
if a == flag && i+1 < len(args) {
53+
return args[i+1]
54+
}
55+
if strings.HasPrefix(a, flag+"=") {
56+
return a[len(flag)+1:]
57+
}
58+
}
59+
return ""
60+
}
61+
62+
// closePersistentBackend is called (deferred) when the daemon exits.
63+
func closePersistentBackend() {
64+
if persistentDaemonBackend != nil {
65+
_ = persistentDaemonBackend.Disconnect()
66+
// Also log to stderr so it's visible in diagnostic output.
67+
fmt.Fprintln(os.Stderr, "mxcli-daemon: persistent backend closed")
68+
}
69+
}

cmd/mxcli/daemon_server.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ func runDaemonServer(sockPath string, idleTimeout time.Duration, onReady func())
7070
mu.Lock()
7171
lastReq = time.Now()
7272
mu.Unlock()
73+
// Serialize command execution: SQLite is single-writer and os.Chdir
74+
// is process-global, so concurrent requests would corrupt state.
75+
daemonRequestMu.Lock()
76+
defer daemonRequestMu.Unlock()
7377
handleConn(conn)
7478
}()
7579
}

cmd/mxcli/main.go

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,16 @@ func main() {
3232
// Intercept --serve <socket-path> BEFORE cobra parses flags.
3333
if sockPath := extractServeSocket(os.Args[1:]); sockPath != "" {
3434
idleTimeout := extractIdleTimeout(os.Args[1:])
35+
// Per-MPR daemon: pre-connect and hold the backend for the process lifetime.
36+
if mprPath := extractMPRPath(os.Args[1:]); mprPath != "" {
37+
b, err := openPersistentBackend(mprPath)
38+
if err != nil {
39+
fmt.Fprintf(os.Stderr, "mxcli-daemon: %v\n", err)
40+
os.Exit(1)
41+
}
42+
persistentDaemonBackend = b
43+
defer closePersistentBackend()
44+
}
3545
runDaemonServer(sockPath, idleTimeout, nil)
3646
return
3747
}
@@ -185,7 +195,18 @@ func resolveFormat(cmd *cobra.Command, defaultFormat string) string {
185195
func newLoggedExecutor(mode string, out io.Writer) (*executor.Executor, *diaglog.Logger) {
186196
logger := diaglog.Init(version, mode, globalVerboseLevel)
187197
exec := executor.New(out)
188-
exec.SetBackendFactory(func() backend.FullBackend { return mprbackend.New() })
198+
if persistentDaemonBackend != nil {
199+
// Per-MPR daemon: wrap the concrete *MprBackend (not the interface) so
200+
// duck-type checks (microflowsRepoProvider etc.) still resolve correctly,
201+
// while Connect/Disconnect are no-ops to keep the SQLite connection and
202+
// Reader.unitCache alive across requests.
203+
pb := persistentDaemonBackend
204+
exec.SetBackendFactory(func() backend.FullBackend {
205+
return &noOpConnectBackend{pb}
206+
})
207+
} else {
208+
exec.SetBackendFactory(func() backend.FullBackend { return mprbackend.New() })
209+
}
189210
exec.SetLogger(logger)
190211
if globalJSONFlag {
191212
exec.SetFormat(executor.FormatJSON)

mdl/backend/mpr/backend.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,15 @@ func (b *MprBackend) Disconnect() error {
122122
func (b *MprBackend) IsConnected() bool { return b.reader != nil }
123123
func (b *MprBackend) Path() string { return b.path }
124124

125+
// EnableContentCache activates in-memory caching of mxunit file contents.
126+
// Call once after Connect when the backend will be held persistently across
127+
// multiple requests (per-MPR daemon mode). The cache is cleared on any write.
128+
func (b *MprBackend) EnableContentCache() {
129+
if b.reader != nil {
130+
b.reader.EnableContentCache()
131+
}
132+
}
133+
125134
func (b *MprBackend) Version() types.MPRVersion { return types.MPRVersion(b.msdkReader.Version()) }
126135
func (b *MprBackend) ProjectVersion() *types.ProjectVersion {
127136
return convertProjectVersionFromMsdk(b.msdkReader.ProjectVersion())

modelsdk/mpr/reader.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ type Reader struct {
4040
unitCache []cachedUnit
4141
unitCacheValid bool
4242

43+
// contentCache stores raw BSON bytes per unit ID (MPR v2 only).
44+
// Populated on first read; survives across requests when the Reader is
45+
// held persistently by the per-MPR daemon. Cleared by InvalidateCache.
46+
// nil means caching is disabled (zero cost on the normal per-request path).
47+
contentCache map[string][]byte
48+
4349
// overlay holds unit bytes injected by BufferedUnitStore so that reads
4450
// within the same import file see buffered (uncommitted) writes.
4551
// nil means no overlay is active — zero cost on the normal path.

modelsdk/mpr/reader_units.go

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -188,30 +188,61 @@ func (r *Reader) buildUnitCache() error {
188188
return nil
189189
}
190190

191-
// InvalidateCache marks the unit cache as invalid.
191+
// InvalidateCache marks the unit cache as invalid and clears content cache entries.
192192
// Should be called after any write operation.
193193
func (r *Reader) InvalidateCache() {
194194
r.unitCacheValid = false
195+
// Clear content cache entries but keep the map non-nil so caching stays active.
196+
// If contentCache is nil (per-request mode), remain disabled.
197+
if r.contentCache != nil {
198+
clear(r.contentCache)
199+
}
200+
}
201+
202+
// EnableContentCache activates the in-memory content cache for this reader.
203+
// Call once after Connect in persistent daemon mode. The cache survives across
204+
// requests; InvalidateCache empties it (but keeps caching active) on writes.
205+
func (r *Reader) EnableContentCache() {
206+
if r.contentCache == nil {
207+
r.contentCache = make(map[string][]byte)
208+
}
195209
}
196210

197211
// readMprContents reads content from the mprcontents folder for v2 format.
198212
// The path is: mprcontents/XX/YY/UUID.mxunit where XX and YY are first two chars of UUID.
213+
//
214+
// When r.contentCache is non-nil (persistent daemon mode), the result is cached
215+
// in memory so subsequent reads of the same unit skip the file I/O entirely.
216+
// The cache is invalidated by InvalidateCache (called after every write).
199217
func (r *Reader) readMprContents(unitUUID string) ([]byte, error) {
200218
if len(unitUUID) < 4 {
201219
return nil, fmt.Errorf("invalid unit UUID: %s", unitUUID)
202220
}
203221

222+
// Fast path: content cache hit (persistent daemon only).
223+
if r.contentCache != nil {
224+
if data, ok := r.contentCache[unitUUID]; ok {
225+
return data, nil
226+
}
227+
}
228+
204229
// Build path: mprcontents/XX/YY/UUID.mxunit
205-
// UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
206-
// First two chars are positions 0-1, next two are positions 2-3
207230
path := filepath.Join(
208231
r.contentsDir,
209232
unitUUID[0:2],
210233
unitUUID[2:4],
211234
unitUUID+".mxunit",
212235
)
236+
data, err := os.ReadFile(path)
237+
if err != nil {
238+
return nil, err
239+
}
213240

214-
return os.ReadFile(path)
241+
// Populate cache (persistent daemon only).
242+
if r.contentCache != nil {
243+
r.contentCache[unitUUID] = data
244+
}
245+
return data, nil
215246
}
216247

217248
// getTypeFromContents extracts the $Type field from BSON contents.

0 commit comments

Comments
 (0)