compile.go 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
  1. package billingexpr
  2. import (
  3. "fmt"
  4. "math"
  5. "strings"
  6. "sync"
  7. "github.com/expr-lang/expr"
  8. "github.com/expr-lang/expr/ast"
  9. "github.com/expr-lang/expr/vm"
  10. )
  11. const maxCacheSize = 256
  12. // DefaultExprVersion is used when an expression string has no version prefix.
  13. const DefaultExprVersion = 1
  14. // ParseExprVersion extracts the version tag and body from an expression string.
  15. // Format: "v1:tier(...)" → version=1, body="tier(...)".
  16. // No prefix defaults to DefaultExprVersion.
  17. func ParseExprVersion(exprStr string) (version int, body string) {
  18. if strings.HasPrefix(exprStr, "v1:") {
  19. return 1, exprStr[3:]
  20. }
  21. return DefaultExprVersion, exprStr
  22. }
  23. type cachedEntry struct {
  24. prog *vm.Program
  25. usedVars map[string]bool
  26. version int
  27. }
  28. var (
  29. cacheMu sync.RWMutex
  30. cache = make(map[string]*cachedEntry, 64)
  31. )
  32. // compileEnvPrototypeV1 is the v1 type-checking prototype used at compile time.
  33. var compileEnvPrototypeV1 = map[string]interface{}{
  34. "p": float64(0),
  35. "c": float64(0),
  36. "cr": float64(0),
  37. "cc": float64(0),
  38. "cc1h": float64(0),
  39. "img": float64(0),
  40. "img_o": float64(0),
  41. "ai": float64(0),
  42. "ao": float64(0),
  43. "tier": func(string, float64) float64 { return 0 },
  44. "header": func(string) string { return "" },
  45. "param": func(string) interface{} { return nil },
  46. "has": func(interface{}, string) bool { return false },
  47. "hour": func(string) int { return 0 },
  48. "minute": func(string) int { return 0 },
  49. "weekday": func(string) int { return 0 },
  50. "month": func(string) int { return 0 },
  51. "day": func(string) int { return 0 },
  52. "max": math.Max,
  53. "min": math.Min,
  54. "abs": math.Abs,
  55. "ceil": math.Ceil,
  56. "floor": math.Floor,
  57. }
  58. func getCompileEnv(version int) map[string]interface{} {
  59. switch version {
  60. default:
  61. return compileEnvPrototypeV1
  62. }
  63. }
  64. // CompileFromCache compiles an expression string, using a cached program when
  65. // available. The cache is keyed by the SHA-256 hex digest of the expression.
  66. func CompileFromCache(exprStr string) (*vm.Program, error) {
  67. return compileFromCacheByHash(exprStr, ExprHashString(exprStr))
  68. }
  69. // CompileFromCacheByHash is like CompileFromCache but accepts a pre-computed
  70. // hash, useful when the caller already has the BillingSnapshot.ExprHash.
  71. func CompileFromCacheByHash(exprStr, hash string) (*vm.Program, error) {
  72. return compileFromCacheByHash(exprStr, hash)
  73. }
  74. func compileFromCacheByHash(exprStr, hash string) (*vm.Program, error) {
  75. cacheMu.RLock()
  76. if entry, ok := cache[hash]; ok {
  77. cacheMu.RUnlock()
  78. return entry.prog, nil
  79. }
  80. cacheMu.RUnlock()
  81. version, body := ParseExprVersion(exprStr)
  82. prog, err := expr.Compile(body, expr.Env(getCompileEnv(version)), expr.AsFloat64())
  83. if err != nil {
  84. return nil, fmt.Errorf("expr compile error: %w", err)
  85. }
  86. vars := extractUsedVars(prog)
  87. cacheMu.Lock()
  88. if len(cache) >= maxCacheSize {
  89. cache = make(map[string]*cachedEntry, 64)
  90. }
  91. cache[hash] = &cachedEntry{prog: prog, usedVars: vars, version: version}
  92. cacheMu.Unlock()
  93. return prog, nil
  94. }
  95. // ExprVersion returns the version of a cached expression. Returns DefaultExprVersion
  96. // if the expression hasn't been compiled yet or is empty.
  97. func ExprVersion(exprStr string) int {
  98. if exprStr == "" {
  99. return DefaultExprVersion
  100. }
  101. hash := ExprHashString(exprStr)
  102. cacheMu.RLock()
  103. if entry, ok := cache[hash]; ok {
  104. cacheMu.RUnlock()
  105. return entry.version
  106. }
  107. cacheMu.RUnlock()
  108. v, _ := ParseExprVersion(exprStr)
  109. return v
  110. }
  111. func extractUsedVars(prog *vm.Program) map[string]bool {
  112. vars := make(map[string]bool)
  113. node := prog.Node()
  114. ast.Find(node, func(n ast.Node) bool {
  115. if id, ok := n.(*ast.IdentifierNode); ok {
  116. vars[id.Value] = true
  117. }
  118. return false
  119. })
  120. return vars
  121. }
  122. // UsedVars returns the set of identifier names referenced by an expression.
  123. // The result is cached alongside the compiled program. Returns nil for empty input.
  124. func UsedVars(exprStr string) map[string]bool {
  125. if exprStr == "" {
  126. return nil
  127. }
  128. hash := ExprHashString(exprStr)
  129. cacheMu.RLock()
  130. if entry, ok := cache[hash]; ok {
  131. cacheMu.RUnlock()
  132. return entry.usedVars
  133. }
  134. cacheMu.RUnlock()
  135. // Compile (and cache) to populate usedVars
  136. if _, err := compileFromCacheByHash(exprStr, hash); err != nil {
  137. return nil
  138. }
  139. cacheMu.RLock()
  140. entry, ok := cache[hash]
  141. cacheMu.RUnlock()
  142. if ok {
  143. return entry.usedVars
  144. }
  145. return nil
  146. }
  147. // InvalidateCache clears the compiled-expression cache.
  148. // Called when billing rules are updated.
  149. func InvalidateCache() {
  150. cacheMu.Lock()
  151. cache = make(map[string]*cachedEntry, 64)
  152. cacheMu.Unlock()
  153. }