compile.go 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
  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. "len": float64(0),
  37. "cr": float64(0),
  38. "cc": float64(0),
  39. "cc1h": float64(0),
  40. "img": float64(0),
  41. "img_o": float64(0),
  42. "ai": float64(0),
  43. "ao": float64(0),
  44. "tier": func(string, float64) float64 { return 0 },
  45. "header": func(string) string { return "" },
  46. "param": func(string) interface{} { return nil },
  47. "has": func(interface{}, string) bool { return false },
  48. "hour": func(string) int { return 0 },
  49. "minute": func(string) int { return 0 },
  50. "weekday": func(string) int { return 0 },
  51. "month": func(string) int { return 0 },
  52. "day": func(string) int { return 0 },
  53. "max": math.Max,
  54. "min": math.Min,
  55. "abs": math.Abs,
  56. "ceil": math.Ceil,
  57. "floor": math.Floor,
  58. }
  59. func getCompileEnv(version int) map[string]interface{} {
  60. switch version {
  61. default:
  62. return compileEnvPrototypeV1
  63. }
  64. }
  65. // CompileFromCache compiles an expression string, using a cached program when
  66. // available. The cache is keyed by the SHA-256 hex digest of the expression.
  67. func CompileFromCache(exprStr string) (*vm.Program, error) {
  68. return compileFromCacheByHash(exprStr, ExprHashString(exprStr))
  69. }
  70. // CompileFromCacheByHash is like CompileFromCache but accepts a pre-computed
  71. // hash, useful when the caller already has the BillingSnapshot.ExprHash.
  72. func CompileFromCacheByHash(exprStr, hash string) (*vm.Program, error) {
  73. return compileFromCacheByHash(exprStr, hash)
  74. }
  75. func compileFromCacheByHash(exprStr, hash string) (*vm.Program, error) {
  76. cacheMu.RLock()
  77. if entry, ok := cache[hash]; ok {
  78. cacheMu.RUnlock()
  79. return entry.prog, nil
  80. }
  81. cacheMu.RUnlock()
  82. version, body := ParseExprVersion(exprStr)
  83. prog, err := expr.Compile(body, expr.Env(getCompileEnv(version)), expr.AsFloat64())
  84. if err != nil {
  85. return nil, fmt.Errorf("expr compile error: %w", err)
  86. }
  87. vars := extractUsedVars(prog)
  88. cacheMu.Lock()
  89. if len(cache) >= maxCacheSize {
  90. cache = make(map[string]*cachedEntry, 64)
  91. }
  92. cache[hash] = &cachedEntry{prog: prog, usedVars: vars, version: version}
  93. cacheMu.Unlock()
  94. return prog, nil
  95. }
  96. // ExprVersion returns the version of a cached expression. Returns DefaultExprVersion
  97. // if the expression hasn't been compiled yet or is empty.
  98. func ExprVersion(exprStr string) int {
  99. if exprStr == "" {
  100. return DefaultExprVersion
  101. }
  102. hash := ExprHashString(exprStr)
  103. cacheMu.RLock()
  104. if entry, ok := cache[hash]; ok {
  105. cacheMu.RUnlock()
  106. return entry.version
  107. }
  108. cacheMu.RUnlock()
  109. v, _ := ParseExprVersion(exprStr)
  110. return v
  111. }
  112. func extractUsedVars(prog *vm.Program) map[string]bool {
  113. vars := make(map[string]bool)
  114. node := prog.Node()
  115. ast.Find(node, func(n ast.Node) bool {
  116. if id, ok := n.(*ast.IdentifierNode); ok {
  117. vars[id.Value] = true
  118. }
  119. return false
  120. })
  121. return vars
  122. }
  123. // UsedVars returns the set of identifier names referenced by an expression.
  124. // The result is cached alongside the compiled program. Returns nil for empty input.
  125. func UsedVars(exprStr string) map[string]bool {
  126. if exprStr == "" {
  127. return nil
  128. }
  129. hash := ExprHashString(exprStr)
  130. cacheMu.RLock()
  131. if entry, ok := cache[hash]; ok {
  132. cacheMu.RUnlock()
  133. return entry.usedVars
  134. }
  135. cacheMu.RUnlock()
  136. // Compile (and cache) to populate usedVars
  137. if _, err := compileFromCacheByHash(exprStr, hash); err != nil {
  138. return nil
  139. }
  140. cacheMu.RLock()
  141. entry, ok := cache[hash]
  142. cacheMu.RUnlock()
  143. if ok {
  144. return entry.usedVars
  145. }
  146. return nil
  147. }
  148. // InvalidateCache clears the compiled-expression cache.
  149. // Called when billing rules are updated.
  150. func InvalidateCache() {
  151. cacheMu.Lock()
  152. cache = make(map[string]*cachedEntry, 64)
  153. cacheMu.Unlock()
  154. }