compile.go 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
  1. package billingexpr
  2. import (
  3. "fmt"
  4. "math"
  5. "sync"
  6. "github.com/expr-lang/expr"
  7. "github.com/expr-lang/expr/ast"
  8. "github.com/expr-lang/expr/vm"
  9. )
  10. const maxCacheSize = 256
  11. type cachedEntry struct {
  12. prog *vm.Program
  13. usedVars map[string]bool
  14. }
  15. var (
  16. cacheMu sync.RWMutex
  17. cache = make(map[string]*cachedEntry, 64)
  18. )
  19. // compileEnvPrototype is the type-checking prototype used at compile time.
  20. // It declares the shape of the environment that RunExpr will provide.
  21. // The tier() function is a no-op placeholder here; the real one with
  22. // side-channel tracing is injected at runtime.
  23. var compileEnvPrototype = map[string]interface{}{
  24. "p": float64(0),
  25. "c": float64(0),
  26. "cr": float64(0),
  27. "cc": float64(0),
  28. "cc1h": float64(0),
  29. "prompt_tokens": float64(0),
  30. "completion_tokens": float64(0),
  31. "cache_read_tokens": float64(0),
  32. "cache_create_tokens": float64(0),
  33. "cache_create_1h_tokens": float64(0),
  34. "img": float64(0),
  35. "ai": float64(0),
  36. "ao": float64(0),
  37. "image_tokens": float64(0),
  38. "audio_input_tokens": float64(0),
  39. "audio_output_tokens": float64(0),
  40. "tier": func(string, float64) float64 { return 0 },
  41. "header": func(string) string { return "" },
  42. "param": func(string) interface{} { return nil },
  43. "has": func(interface{}, string) bool { return false },
  44. "hour": func(string) int { return 0 },
  45. "minute": func(string) int { return 0 },
  46. "weekday": func(string) int { return 0 },
  47. "month": func(string) int { return 0 },
  48. "day": func(string) int { return 0 },
  49. "max": math.Max,
  50. "min": math.Min,
  51. "abs": math.Abs,
  52. "ceil": math.Ceil,
  53. "floor": math.Floor,
  54. }
  55. // CompileFromCache compiles an expression string, using a cached program when
  56. // available. The cache is keyed by the SHA-256 hex digest of the expression.
  57. func CompileFromCache(exprStr string) (*vm.Program, error) {
  58. return compileFromCacheByHash(exprStr, ExprHashString(exprStr))
  59. }
  60. // CompileFromCacheByHash is like CompileFromCache but accepts a pre-computed
  61. // hash, useful when the caller already has the BillingSnapshot.ExprHash.
  62. func CompileFromCacheByHash(exprStr, hash string) (*vm.Program, error) {
  63. return compileFromCacheByHash(exprStr, hash)
  64. }
  65. func compileFromCacheByHash(exprStr, hash string) (*vm.Program, error) {
  66. cacheMu.RLock()
  67. if entry, ok := cache[hash]; ok {
  68. cacheMu.RUnlock()
  69. return entry.prog, nil
  70. }
  71. cacheMu.RUnlock()
  72. prog, err := expr.Compile(exprStr, expr.Env(compileEnvPrototype), expr.AsFloat64())
  73. if err != nil {
  74. return nil, fmt.Errorf("expr compile error: %w", err)
  75. }
  76. vars := extractUsedVars(prog)
  77. cacheMu.Lock()
  78. if len(cache) >= maxCacheSize {
  79. cache = make(map[string]*cachedEntry, 64)
  80. }
  81. cache[hash] = &cachedEntry{prog: prog, usedVars: vars}
  82. cacheMu.Unlock()
  83. return prog, nil
  84. }
  85. func extractUsedVars(prog *vm.Program) map[string]bool {
  86. vars := make(map[string]bool)
  87. node := prog.Node()
  88. ast.Find(node, func(n ast.Node) bool {
  89. if id, ok := n.(*ast.IdentifierNode); ok {
  90. vars[id.Value] = true
  91. }
  92. return false
  93. })
  94. return vars
  95. }
  96. // UsedVars returns the set of identifier names referenced by an expression.
  97. // The result is cached alongside the compiled program. Returns nil for empty input.
  98. func UsedVars(exprStr string) map[string]bool {
  99. if exprStr == "" {
  100. return nil
  101. }
  102. hash := ExprHashString(exprStr)
  103. cacheMu.RLock()
  104. if entry, ok := cache[hash]; ok {
  105. cacheMu.RUnlock()
  106. return entry.usedVars
  107. }
  108. cacheMu.RUnlock()
  109. // Compile (and cache) to populate usedVars
  110. if _, err := compileFromCacheByHash(exprStr, hash); err != nil {
  111. return nil
  112. }
  113. cacheMu.RLock()
  114. entry, ok := cache[hash]
  115. cacheMu.RUnlock()
  116. if ok {
  117. return entry.usedVars
  118. }
  119. return nil
  120. }
  121. // InvalidateCache clears the compiled-expression cache.
  122. // Called when billing rules are updated.
  123. func InvalidateCache() {
  124. cacheMu.Lock()
  125. cache = make(map[string]*cachedEntry, 64)
  126. cacheMu.Unlock()
  127. }