Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions docs/adr/44994-parseworkflow-hot-path-caching-via-singletons.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# ADR-44994: ParseWorkflow Hot-Path Caching via Process-Lifetime Singletons

**Date**: 2026-07-13
**Status**: Draft
**Deciders**: Unknown

---

### Context

`ParseWorkflowFile` regressed from 371μs to 479μs (+29.2%) due to three per-call allocations that were unnecessary for the overwhelmingly common case where workflows have no imported models or frontmatter overrides. Specifically: (1) a 5KB deep copy of the 52-entry builtin model alias map on every parse call, (2) a full 52-node DFS cycle-detection traversal over static builtin aliases that never change, and (3) re-creation of a 14-entry `knownTools` map literal inside `NewTools()` on every invocation. With `ParseWorkflowFile` called on every workflow file load, these allocations accumulate significantly under load.

### Decision

We will eliminate redundant per-call allocations in `ParseWorkflowFile` using three complementary techniques: (1) a process-lifetime singleton for the builtin model alias map, returned directly on the fast path via `getBuiltinOnlyAliasMap()`; (2) a `sync.Once`-cached cycle-detection result for the builtin alias DFS, bypassed via `isBuiltinOnlyAliasMap()` which uses `unsafe.Pointer` map-header comparison for zero-cost identity checks; and (3) promotion of the `knownTools` map to a package-level variable. Template validation functions also gain `strings.Contains` fast-paths to skip regex when no template blocks are present.

### Alternatives Considered

#### Alternative 1: Retain deep copy on every call (status quo)

The original code deep-copied the builtin alias map on every `ParseWorkflowFile` call. This is safe and simple but wastes ~5KB of allocation and the full DFS traversal cost on the majority of calls where no custom aliases are used. Rejected because the regression was measurably impactful and the common case (no overrides) is overwhelmingly dominant.

#### Alternative 2: Use reflect.DeepEqual or content hashing for map identity

Instead of `unsafe.Pointer` header extraction, map identity could be established by hashing map contents or using `reflect.DeepEqual`. This avoids relying on Go runtime internals but is significantly more expensive than a single pointer comparison. Rejected because the purpose is a zero-overhead fast-path guard; a costly identity check defeats the optimization.

#### Alternative 3: Refactor callers to pass an explicit "no custom aliases" flag

Callers could explicitly signal "no custom aliases" and skip alias merging and validation entirely at a higher level. This would be more architecturally clean but requires threading a flag through multiple call sites and changes the API contract of `ParseWorkflowFile`. Rejected as disproportionately invasive relative to the focused caching approach.

### Consequences

#### Positive
- `ParseWorkflow` throughput improves ~50% below the regression baseline and ~35% below the historical baseline (~240,000 ns/op vs. 479,440 regression vs. 371,023 historical).
- Per-call allocations are eliminated for the common case (no imported models, no frontmatter overrides), reducing GC pressure under load.
- The `knownTools` promotion is a straightforward, safe refactor with no behavioral change.
- Template validation fast-paths reduce regex execution on the majority of workflow files that contain no template blocks.

#### Negative
- `isBuiltinOnlyAliasMap()` uses `unsafe.Pointer` to extract the Go map header pointer, relying on a runtime implementation detail (single machine-word map representation). This layout has been stable since Go 1.0 but is not part of the language specification.
- `MergeImportedModelAliases` now returns a shared, read-only map in the common case, introducing a mutation hazard: callers that previously assumed the returned map was freely mutable could silently corrupt shared state. The contract is documented in comments but not enforced by the type system.
- The singleton approach ties correctness to initialization order (`builtinOnlyAliasMapID` must be populated before `isBuiltinOnlyAliasMap` can return `true`), adding a subtle ordering dependency.

#### Neutral
- Two `sync.Once` variables and one `uintptr` are added as package-level state, increasing the package's global footprint slightly.
- `detectCircularModelAliases` is split into an outer dispatcher and `detectCircularModelAliasesUnoptimized`, slightly increasing call-stack depth for non-builtin alias maps.

---

*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.*
30 changes: 30 additions & 0 deletions pkg/workflow/model_alias_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"os"
"strconv"
"strings"
"sync"

"github.com/github/gh-aw/pkg/console"
"github.com/github/gh-aw/pkg/setutil"
Expand All @@ -34,6 +35,14 @@ import (

var modelAliasValidationLog = newValidationLogger("model_alias")

// builtinCycleCheckOnce caches the result of the first cycle-detection DFS over
// the pure builtin alias map. The builtins are a compile-time constant so cycles
// can only appear once; checking them on every ParseWorkflowFile call is wasteful.
var (
builtinCycleCheckOnce sync.Once
builtinCycleCheckErr error
)

// ─── Known-parameter validation ───────────────────────────────────────────────

// ValidateEffortParam validates the "effort" parameter value (V-MAF-002).
Expand Down Expand Up @@ -241,6 +250,27 @@ func (c *Compiler) warnUnrecognizedModelParams(identifiers []string, markdownPat
func detectCircularModelAliases(aliasMap map[string][]string, markdownPath string) error {
modelAliasValidationLog.Printf("Checking for circular alias references in %d aliases", len(aliasMap))

// Fast path: the builtin alias map is a compile-time constant and cycle-free by
// construction. Cache the result of the first DFS check so that the expensive
// sort + 52-node traversal is skipped on subsequent ParseWorkflowFile calls that
// use only builtins (no imports, no frontmatter overrides).
if isBuiltinOnlyAliasMap(aliasMap) {
builtinCycleCheckOnce.Do(func() {
// Builtin aliases are cycle-free by construction (validated in CI).
// Pass a sentinel path so that if a cycle is ever introduced into the
// builtin data (a bug) the error message clearly identifies the source
// rather than referencing a caller's workflow path.
builtinCycleCheckErr = detectCircularModelAliasesUnoptimized(aliasMap, "<builtin-aliases>")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The PR fixes a performance regression but adds no benchmark assertions or regression guard. The existing BenchmarkParseWorkflow will detect future regressions only if someone remembers to run it with -benchtime and compare — it's a passive check.

💡 Suggested addition

Add a sub-benchmark or TestMain-style baseline guard that fails if ParseWorkflow exceeds a threshold, e.g.:

func BenchmarkParseWorkflow_NoRegression(b *testing.B) {
    // Fails CI if median ns/op exceeds the pre-regression baseline (479k ns/op)
    // giving a 20% headroom above the new ~240k target.
    const maxNsPerOp = 380_000
    // ... run benchmark and assert b.Elapsed()/N < maxNsPerOp
}

Alternatively, document the expected ns/op threshold in a comment on BenchmarkParseWorkflow so reviewers have context when they see benchmark output in CI.

@copilot please address this.

})
return builtinCycleCheckErr
}

return detectCircularModelAliasesUnoptimized(aliasMap, markdownPath)
}

// detectCircularModelAliasesUnoptimized is the full DFS implementation used when the
// alias map may include user-defined aliases (imports or frontmatter overrides).
func detectCircularModelAliasesUnoptimized(aliasMap map[string][]string, markdownPath string) error {
// visited tracks keys for which all DFS descendants have been fully explored
// (no cycle detected from that key).
visited := map[string]struct {
Expand Down
68 changes: 66 additions & 2 deletions pkg/workflow/model_aliases.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import (
"fmt"
"maps"
"sync"
"unsafe"

"github.com/github/gh-aw/pkg/logger"
)
Expand Down Expand Up @@ -64,6 +65,54 @@ func loadBuiltinModelAliases() (map[string][]string, error) {
return builtinModelAliasesData, builtinModelAliasesErr
}

// builtinOnlyAliasMap is the canonical map returned by MergeImportedModelAliases
// when there are no imported or frontmatter overrides. It is set once via
// sync.Once so that pointer-equality checks in validateModelAliasMap can detect
// the common case and skip the redundant cycle-detection DFS.
var (
builtinOnlyAliasMapOnce sync.Once
builtinOnlyAliasMap map[string][]string
builtinOnlyAliasMapID uintptr // unsafe map-header pointer, set once under builtinOnlyAliasMapOnce
)

// mapHeaderPointer extracts the pointer stored in the map value's header
// (the *runtime.hmap pointer). Two map values backed by the same hash table
// return the same value. A nil map returns 0.
//
// This relies on the Go runtime representation of map values as a single
// machine-word pointer. This layout has been stable since Go 1.0 and is
// consistent across all supported Go versions (see runtime/map.go). It is
// the same technique used internally by reflect.Value.Pointer() for maps.
func mapHeaderPointer(m map[string][]string) uintptr {
return *(*uintptr)(unsafe.Pointer(&m))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Undocumented runtime internals in mapHeaderPointer

This function deferences the map variable as a *uintptr to extract the internal *runtime.hmap pointer. While this layout has been stable in practice, it is not part of the Go language spec or any exported ABI contract, and the comment's claim that reflect.Value.Pointer() uses "the same technique" is misleading — reflect is part of the runtime and has privileged access.

Risks:

  • A future Go version (especially with the register-based calling convention or potential map rewrite in Go 1.24+) could change map value layout, causing a silent wrong result or crash.
  • GC-compaction (if ever introduced) could move the hmap, making the stored uintptr stale.

Prefer using reflect.ValueOf(m).Pointer() — it provides the same identity comparison, is in the standard library, and is explicitly documented to return the hash-table pointer for maps:

import "reflect"

func mapHeaderPointer(m map[string][]string) uintptr {
    return reflect.ValueOf(m).Pointer()
}

This is zero additional allocation (reflect.Value is stack-allocated for non-interface values) and removes the dependency on undocumented layout.

@copilot please address this.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnosing-bugs] mapHeaderPointer relies on an undocumented Go runtime internal (map values as single-word *hmap pointers). While stable since Go 1.0, this is fragile — a future runtime change (or GC move in a hypothetical moving GC) could silently break the identity check.

💡 Simpler alternative without unsafe

Since getBuiltinOnlyAliasMap is the only place that sets builtinOnlyAliasMap, you can replace mapHeaderPointer + builtinOnlyAliasMapID with an atomic bool:

var builtinOnlyAliasMapSet atomic.Bool

func getBuiltinOnlyAliasMap() map[string][]string {
    builtinOnlyAliasMapOnce.Do(func() {
        data, _ := loadBuiltinModelAliases() // handle error appropriately
        builtinOnlyAliasMap = data
        builtinOnlyAliasMapSet.Store(true)
    })
    return builtinOnlyAliasMap
}

func isBuiltinOnlyAliasMap(m map[string][]string) bool {
    // A caller can only hold the shared map after getBuiltinOnlyAliasMap returns it.
    // If the set flag is false, no caller can hold it yet.
    return builtinOnlyAliasMapSet.Load() && m != nil && len(m) == len(builtinOnlyAliasMap) &&
        // cheap pointer check via reflect for read-only assertion
        reflect.ValueOf(m).Pointer() == reflect.ValueOf(builtinOnlyAliasMap).Pointer()
}

Or more simply, just keep a bool sentinel inside the Once and use an explicit pointer stored as unsafe.Pointer via atomic.Pointer[map[string][]string] — all safer than raw uintptr arithmetic.

@copilot please address this.

}

// getBuiltinOnlyAliasMap returns the shared, read-only builtin alias map.
// The map must never be mutated by callers; it is shared across all parse calls.
func getBuiltinOnlyAliasMap() map[string][]string {
builtinOnlyAliasMapOnce.Do(func() {
data, err := loadBuiltinModelAliases()
if err != nil {
panic(err)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnosing-bugs] panic in a hot-path singleton hides the error from callers and kills the whole process — even if the caller could recover gracefully.

💡 Suggested fix: return the error instead

Change the signature to (map[string][]string, error) and cache the error:

func getBuiltinOnlyAliasMap() (map[string][]string, error) {
    builtinOnlyAliasMapOnce.Do(func() {
        data, err := loadBuiltinModelAliases()
        if err != nil {
            builtinOnlyAliasMapErr = err
            return
        }
        builtinOnlyAliasMap = data
        builtinOnlyAliasMapID = mapHeaderPointer(data)
    })
    return builtinOnlyAliasMap, builtinOnlyAliasMapErr
}

This keeps error surfacing consistent with how loadBuiltinModelAliases errors are handled elsewhere.

@copilot please address this.

}
builtinOnlyAliasMap = data
builtinOnlyAliasMapID = mapHeaderPointer(data)
})
return builtinOnlyAliasMap
}

// isBuiltinOnlyAliasMap reports whether m is the shared read-only builtin alias map
// returned by getBuiltinOnlyAliasMap. It uses unsafe map-header pointer extraction
// for identity comparison since Go maps cannot be compared with ==.
//
// It is safe to call this function before getBuiltinOnlyAliasMap has been invoked:
// in that case builtinOnlyAliasMapID is 0 and the function returns false, which is
// correct because no caller can hold a reference to the shared map before it exists.
func isBuiltinOnlyAliasMap(m map[string][]string) bool {
id := builtinOnlyAliasMapID

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Data race on builtinOnlyAliasMapID

isBuiltinOnlyAliasMap reads builtinOnlyAliasMapID on this line without any synchronization. The write happens inside builtinOnlyAliasMapOnce.Do (line 99), but the Go memory model only guarantees visibility to goroutines that participate in the Once; concurrent goroutines reading the variable directly can observe a stale (zero) value or, on weakly-ordered hardware, a torn read.

Fix: replace the plain uintptr with atomic.Uintptr:

// var block
builtinOnlyAliasMapID atomic.Uintptr

// getBuiltinOnlyAliasMap Once.Do:
builtinOnlyAliasMapID.Store(mapHeaderPointer(data))

// isBuiltinOnlyAliasMap:
id := builtinOnlyAliasMapID.Load()

@copilot please address this.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnosing-bugs] builtinOnlyAliasMapID is a plain uintptr read outside the sync.Once — this is a data race on first concurrent use.

The write on line 99 happens inside builtinOnlyAliasMapOnce.Do, but the read on line 112 (id := builtinOnlyAliasMapID) happens without any synchronisation. The sync.Once guarantees the write is ordered before subsequent Do callers unblock, but isBuiltinOnlyAliasMap can be called concurrently before Do has run — leading to a torn read on 32-bit platforms or under the race detector.

💡 Fix: use atomic.Uintptr
import "sync/atomic"

var builtinOnlyAliasMapID atomic.Uintptr

// in Once.Do:
builtinOnlyAliasMapID.Store(mapHeaderPointer(data))

// in isBuiltinOnlyAliasMap:
id := builtinOnlyAliasMapID.Load()
return id != 0 && mapHeaderPointer(m) == id

@copilot please address this.

return id != 0 && mapHeaderPointer(m) == id
Comment on lines +112 to +113
}

// BuiltinModelAliases returns the built-in model alias map that covers the main
// model families supported by gh-aw. The returned map is a freshly allocated
// copy so callers may freely modify it.
Expand Down Expand Up @@ -119,10 +168,25 @@ func BuiltinModelAliases() map[string][]string {
// key wins among imports (same "first-wins among peers" semantics as features).
// 3. Main workflow frontmatter aliases (highest priority — main workflow file wins)
//
// If both importedModels and frontmatterModels are nil/empty, the builtin aliases are
// returned as-is (identical to MergeModelAliases(nil)).
// ⚠ Return-value mutability contract:
// - When both importedModels and frontmatterModels are nil/empty the function
// returns the shared, read-only builtin alias map directly (no allocation).
// Callers MUST NOT mutate the returned map; it is shared across all concurrent
// parse calls. Use isBuiltinOnlyAliasMap() to detect this case if needed.
Comment on lines +173 to +175
// - When either parameter is non-empty a freshly allocated, mutable copy is
// returned and callers may freely modify it.
func MergeImportedModelAliases(importedModels []map[string][]string, frontmatterModels map[string][]string) map[string][]string {
modelAliasesLog.Printf("Merging model aliases: %d import(s), %d frontmatter override(s)", len(importedModels), len(frontmatterModels))

// Fast path: the vast majority of workflows have no imported or frontmatter model
// aliases. Avoid deep-copying the 52-entry builtin map (154 string slices) on every
// ParseWorkflowFile call by returning the shared read-only builtin map directly.
if len(importedModels) == 0 && len(frontmatterModels) == 0 {
result := getBuiltinOnlyAliasMap()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shared singleton returned to callers who may mutate it

When both inputs are empty, MergeImportedModelAliases now returns the shared builtinOnlyAliasMap singleton directly. The doc comment warns callers not to mutate it, but the current call site in workflow_builder.go immediately assigns it to workflowData.ModelMappings, which is later assigned to apiProxy.Models (awf_config.go:530). If any code downstream of that ever writes to the map (e.g., apiProxy.Models[key] = value), it will silently corrupt the shared singleton for all subsequent parses.

The doc comment alone is an insufficient guard against this class of bug in a growing codebase. Consider one of:

  1. Returning a thin read-only wrapper (if the interface allows), or
  2. Documenting the invariant at both the assignment sites, or
  3. Adding a -race-compatible test that calls ParseWorkflowFile concurrently to catch future violations.

@copilot please address this.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnosing-bugs] The returned shared map has no mutation guard — any caller that accidentally writes to it (e.g., a test doing result["my-alias"] = ...) will silently corrupt state for all concurrent parsers.

💡 Suggested safeguard

Add a test that verifies mutation attempts on the returned map are caught early:

func TestMergeImportedModelAliases_NoMutationOfBuiltinMap(t *testing.T) {
    result := MergeImportedModelAliases(nil, nil)
    before := len(result)
    // Deliberately mutate — should be safe only because callers won't do this,
    // but document that it would corrupt state:
    // result["should-not-be-here"] = []string{"x"} // this would break other tests
    _ = before
    // At minimum, assert the returned map equals the builtin map by reference
    assert.True(t, isBuiltinOnlyAliasMap(result), "fast-path should return the shared builtin map")
}

If enforcing immutability at runtime is critical, consider wrapping in a type that panics on write rather than relying on documentation alone.

@copilot please address this.

modelAliasesLog.Printf("Fast path: returning shared builtin alias map (%d entries)", len(result))
return result
}

merged := BuiltinModelAliases()

// Layer 2 — imported models (first import to define a key wins among imports).
Expand Down
17 changes: 17 additions & 0 deletions pkg/workflow/template_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@ var (
func validateNoIncludesInTemplateRegions(markdown string) error {
templateValidationLog.Print("Validating that imports are not inside template regions")

// Fast path: skip expensive regex if the markdown contains no {{#if blocks.
// templateRegionPattern exclusively matches {{#if ... }}...{{/if}} constructs;
// other handlebars-style tags (e.g. {{#unless}}) are not used in AWF workflows.
if !strings.Contains(markdown, "{{#if") {
return nil
}

// Use pre-compiled regex from package level for performance
matches := templateRegionPattern.FindAllStringSubmatch(markdown, -1)
templateValidationLog.Printf("Found %d template regions to validate", len(matches))
Expand Down Expand Up @@ -109,6 +116,11 @@ func validateNoIncludesInTemplateRegions(markdown string) error {
func validateNoPreExpandedExperimentPlaceholders(markdown string) error {
templateValidationLog.Print("Validating that pre-expanded experiment placeholders are not used in template conditions")

// Fast path: skip expensive regex if the markdown contains no template conditions.
if !strings.Contains(markdown, "{{#") {
return nil
}

// Collect conditions from both {{#if ...}} and all elseif variants
ifConditions := TemplateIfPattern.FindAllStringSubmatch(markdown, -1)
elseifConditions := TemplateElseIfPattern.FindAllStringSubmatch(markdown, -1)
Expand Down Expand Up @@ -149,6 +161,11 @@ func validateNoPreExpandedExperimentPlaceholders(markdown string) error {
func detectDoubleQuotedExperimentComparisons(markdown string) []string {
templateValidationLog.Print("Checking for double-quoted experiment comparison expressions")

// Fast path: skip expensive regex if the markdown contains no template conditions.
if !strings.Contains(markdown, "{{#") {
return nil
}

ifConditions := TemplateIfPattern.FindAllStringSubmatch(markdown, -1)
elseifConditions := TemplateElseIfPattern.FindAllStringSubmatch(markdown, -1)
allConditions := append(ifConditions, elseifConditions...)
Expand Down
37 changes: 19 additions & 18 deletions pkg/workflow/tools_parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,25 @@ func toAnySlice(ss []string) []any {
}

// NewTools creates a new Tools instance from a map
// knownTools is the set of built-in tool names that NewTools handles explicitly.
// It is a package-level variable to avoid re-allocating this map on every call.
Comment on lines +90 to +91
var knownTools = map[string]struct{}{
"github": {},
"bash": {},
"web-fetch": {},
"web-search": {},
"edit": {},
"playwright": {},
"agentic-workflows": {},
"cache-memory": {},
"comment-memory": {},
"repo-memory": {},
"safety-prompt": {},
"timeout": {},
"startup-timeout": {},
"cli-proxy": {},
}

func NewTools(toolsMap map[string]any) *Tools {
toolsParserLog.Printf("Creating tools configuration from map with %d entries", len(toolsMap))
if toolsMap == nil {
Expand Down Expand Up @@ -155,24 +174,6 @@ func NewTools(toolsMap map[string]any) *Tools {
}

// Extract custom MCP tools (anything not in the known list)
knownTools := map[string]struct {
}{
"github": {},
"bash": {},
"web-fetch": {},
"web-search": {},
"edit": {},
"playwright": {},
"agentic-workflows": {},
"cache-memory": {},
"comment-memory": {},
"repo-memory": {},
"safety-prompt": {},
"timeout": {},
"startup-timeout": {},
"cli-proxy": {},
}

customCount := 0
for name, config := range toolsMap {
if !setutil.Contains(knownTools, name) {
Expand Down
Loading