Skip to content
Open
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
78 changes: 58 additions & 20 deletions Sources/Rego/Builtins/BuiltinsCache.swift
Original file line number Diff line number Diff line change
@@ -1,22 +1,29 @@
import AST
import Foundation

/// BuiltinsCache defines the caching strategy used by the top-down evaluation.
/// Note that Golang implementation uses `type FooCachingKey string` approach to redefine string keys as distinct types.
/// For Swift implementation, type aliasing does not create a new type, so we will have to use namespaces to separate keys.
/// This implementation wraps a simple dictionary with composite keys made of namespace + key and values being RegoValues.
/// Since Dictionary is not concurrency-safe, neither is BuiltinsCache, but since its intent is to be used within a single evaluation,
/// we do not concurrency support.
internal struct BuiltinsCache {
struct Namespace: Hashable, Sendable {
/// Per-evaluation cache for OPA builtins.
///
/// Lightweight cache for (custom) OPA builtins.
/// Stores `RegoValue` entries under namespaced string keys to avoid
/// collisions between different builtins or key spaces.
///
/// It is designed for use within a single top-down policy evaluation, but you can also
/// provide your own cache instance to reuse results between evaluations
/// (for example, to avoid redundant costly network-bound builtin calls).
public final class BuiltinsCache: Sendable {
/// Key namespace for isolating builtin domains.
public struct Namespace: Hashable, Sendable {
let ns: String
init(_ ns: String) {
self.ns = ns
}

/// Default namespace.
public static let global: Namespace = Namespace("__global__")

// Helper for .namespace syntax within BuiltinsCache.subscript.
/// Create a custom namespace.
///
/// - Parameter ns: The namespace name.
public static func namespace(_ ns: String) -> Namespace {
return Namespace(ns)
}
Expand All @@ -33,32 +40,63 @@ internal struct BuiltinsCache {
}
}

private var cache: [CompositeKey: RegoValue]
private let lock = NSLock()
private nonisolated(unsafe) var _cache: [CompositeKey: RegoValue]

internal init() {
self.cache = [CompositeKey: RegoValue]()
/// Creates a new, empty builtin cache.
///
/// The cache stores `RegoValue` entries keyed by a combination of a string and a namespace.
/// It is designed for use within a single top-down policy evaluation, but you can also
/// provide your own cache instance to reuse results between evaluations
/// (for example, to avoid redundant costly network-bound builtin calls).
public init() {
self._cache = [CompositeKey: RegoValue]()
}

internal subscript(key: String, namespace: Namespace = .global) -> RegoValue? {
/// Accesses or updates a cached value scoped by a key and optional namespace.
///
/// Use this subscript to read or write entries in the builtin cache.
/// Setting a value to `nil` removes the corresponding cache entry.
///
/// - Parameters:
/// - key: The cache key used to identify the entry within the given namespace.
/// - namespace: The namespace to scope the key under to avoid collisions between different builtins.. Defaults to ``Namespace/global``.
///
/// - Note: Writing to the same key multiple times simply overwrites the previous value,
/// the cache does not enforce immutability or write-once semantics.
public subscript(key: String, namespace: Namespace = .global) -> RegoValue? {
get {
return self.cache[CompositeKey(key, namespace: namespace)]
self.lock.withLock {
self._cache[CompositeKey(key, namespace: namespace)]
}
}
set(newValue) {
let k = CompositeKey(key, namespace: namespace)

guard let newValue else {
self.cache[k] = nil
self.lock.withLock {
self._cache[k] = nil
}
return
}
self.cache[k] = newValue
self.lock.withLock {
self._cache[k] = newValue
}
}
}

internal mutating func removeAll() {
self.cache.removeAll()
/// Remove all cached entries.
public func removeAll() {
self.lock.withLock {
self._cache.removeAll()
}
}

internal var count: Int {
return self.cache.count
/// Current number of entries in cache.
public var count: Int {
self.lock.withLock {
self._cache.count
}
}
}

32 changes: 23 additions & 9 deletions Sources/Rego/Builtins/Registry.swift
Original file line number Diff line number Diff line change
@@ -1,24 +1,38 @@
import AST
import Foundation

public typealias Builtin = @Sendable (BuiltinContext, [AST.RegoValue]) async throws -> AST.RegoValue

/// The OPA builtin signature.
///
/// Called by the evaluator with the per-evaluation `BuiltinContext` and the
/// positional, already-resolved Rego arguments.
///
/// - Parameters:
/// - context: Evaluation context (e.g., cache, tracer).
/// - args: Positional arguments of the builtin as `AST.RegoValue`.
/// - Returns: The returned value of the builtin as `AST.RegoValue`.
/// - Throws: If the builtin logic throws.
public typealias Builtin = @Sendable (_ context: BuiltinContext, _ args: [AST.RegoValue]) async throws -> AST.RegoValue

/// Context available within builtin evaluation.
public struct BuiltinContext {
/// Source location at which the builtin invocation occurred.
public let location: OPA.Trace.Location
public var tracer: OPA.Trace.QueryTracer?
internal let cache: Ptr<BuiltinsCache>
/// Tracer that records events during evaluation.
public let tracer: OPA.Trace.QueryTracer?
/// Per-evaluation builtin cache.
///
/// Shared by all builtin invocations within a single top-down policy evaluation.
public let cache: BuiltinsCache

init(
location: OPA.Trace.Location = .init(),
tracer: OPA.Trace.QueryTracer? = nil,
cache: Ptr<BuiltinsCache>? = nil
cache: BuiltinsCache = .init()
) {
self.location = location
self.tracer = tracer
// Note that we will create a new cache if one hasn't been provided -
// some builtin evaluations (e.g. UUID) expect the cache.
// In most/all? cases, we expect a shared cache to be passed in here from EvaluationContext
self.cache = cache ?? Ptr<BuiltinsCache>(toCopyOf: BuiltinsCache())
// Shared cache to be passed from `EvaluationContext` or created on the fly
self.cache = cache
}
}

Expand Down
4 changes: 2 additions & 2 deletions Sources/Rego/Builtins/Uuid.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ extension BuiltinFuncs {
throw BuiltinError.argumentTypeMismatch(arg: "k", got: args[0].typeName, want: "string")
}

let existing: RegoValue? = ctx.cache.v[key, .namespace(uuidNamespace)]
let existing: RegoValue? = ctx.cache[key, .namespace(uuidNamespace)]
guard let existing else {
let newUUID = RegoValue.string(UUID().uuidString)
ctx.cache.v[key, .namespace(uuidNamespace)] = newUUID
ctx.cache[key, .namespace(uuidNamespace)] = newUUID

return newUUID
}
Expand Down
5 changes: 5 additions & 0 deletions Sources/Rego/Engine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,15 @@ extension OPA.Engine {
/// - Parameters:
/// - input: The input data to evaluate the query against.
/// - tracer: (optional) The tracer to use for this evaluation.
/// - cache: (optional) Per-evaluation builtin cache shared across all builtin invocations within a single top-down policy evaluation.
/// You can provide a custom cache instance to reuse results between evaluations.
/// By default, a new, empty cache is created for each evaluation.
/// - strictBuiltins: (optional) Whether to run in strict builtin evaluation mode.
/// In strict mode, builtin errors abort evaluation, rather than returning undefined.
public func evaluate(
input: AST.RegoValue = .undefined,
tracer: OPA.Trace.QueryTracer? = nil,
cache: BuiltinsCache = .init(),
Copy link
Contributor

Choose a reason for hiding this comment

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

Given that this is a per-evaluation cache, and the scope of this function is a new evaluation, what is the use case for allowing the caller to pass in an existing cache, which would likely violate our constraints around non-concurrent access?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Great question! I completely agree that in most cases, the caller shouldn’t pass in an existing cache.
By default, each evaluation should have its own isolated cache instance.

That said, we’ve encountered scenarios where it’s useful to reuse a warmed cache across multiple sequential evaluations (never concurrently). This can significantly reduce overhead for deterministic builtins that e.g. perform costly network calls that are stable for a short period of time. To support that optimization, I exposed the cache parameter on evaluate() so developers can opt in when it’s safe.

I’ll extend the documentation to make it clear that the cache must never be shared across concurrent evaluations, only reused sequentially under controlled conditions.

Copy link
Contributor

Choose a reason for hiding this comment

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

I think this mixes two separate types of caches in a problematic way.
For the per-evaluation cache we make no accommodations for synchronization. We agree this does not need to be exposed externally.

There's a separate concept of an inter-query cache, which spans evaluations, where we need to give consideration to synchronization, TTL/expiration, etc. I propose we keep these two layers of cache separate and take on designing the inter-query cache in a separate PR.

Copy link
Contributor Author

@philippzagar philippzagar Oct 10, 2025

Choose a reason for hiding this comment

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

In general, agreed. In the long run, we should absolutely separate the two cache types with clear semantics (per-evaluation vs. inter-query, including TTL and synchronization) and proper API.

That said, the current approach simply offers an opt-in escape hatch for advanced users to reuse a warmed cache sequentially, never concurrently. It’s clearly and extensively documented and leaves both the choice and responsibility to the developer.

A full inter-query cache will definitely need a proper design pass with synchronization and TTL management, but until then, this lightweight, documented option offers a practical middle ground, giving developers flexibility without impacting default behavior.

Also worth noting: the QueryTracer parameter follows a very similar model, it’s not enforced as Sendable and developers are responsible to ensure it’s not shared across concurrent evaluations. So this pattern doesn’t really break precedent; it simply extends the same level of flexibility to caching.

Copy link
Contributor

@shomron shomron Oct 13, 2025

Choose a reason for hiding this comment

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

Also worth noting: the QueryTracer parameter follows a very similar model, it’s not enforced as Sendable and developers are responsible to ensure it’s not shared across concurrent evaluations. So this pattern doesn’t really break precedent;

That precedent seems like a bug to me - the QueryTracer should absolutely be Sendable to indicate its use across concurrency contexts.

strictBuiltins: Bool = false
) async throws -> ResultSet {
let ctx = EvaluationContext(
Expand All @@ -114,6 +118,7 @@ extension OPA.Engine {
store: self.store,
builtins: self.builtinRegistry,
tracer: tracer,
cache: cache,
strictBuiltins: strictBuiltins
)

Expand Down
6 changes: 6 additions & 0 deletions Sources/Rego/Evaluator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ internal struct EvaluationContext {
public var store: OPA.Store
public var builtins: BuiltinRegistry
public var tracer: OPA.Trace.QueryTracer?
/// Per-evaluation builtin cache.
///
/// Shared by all builtin invocations within a single top-down policy evaluation.
public let cache: BuiltinsCache
public var strictBuiltins: Bool = false

init(
Expand All @@ -21,13 +25,15 @@ internal struct EvaluationContext {
store: OPA.Store = NullStore(),
builtins: BuiltinRegistry = .defaultRegistry,
tracer: OPA.Trace.QueryTracer? = nil,
cache: BuiltinsCache = .init(),
strictBuiltins: Bool = false
) {
self.query = query
self.input = input
self.store = store
self.builtins = builtins
self.tracer = tracer
self.cache = cache
self.strictBuiltins = strictBuiltins
}
}
Expand Down
3 changes: 2 additions & 1 deletion Sources/Rego/IREvaluator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1032,7 +1032,8 @@ private func evalCall(

let bctx = BuiltinContext(
location: try frame.v.currentLocation(withContext: ctx, stmt: caller),
tracer: ctx.ctx.tracer
tracer: ctx.ctx.tracer,
cache: ctx.ctx.cache
)

return try await ctx.ctx.builtins.invoke(
Expand Down
4 changes: 2 additions & 2 deletions Sources/Rego/Tracer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ extension OPA.Trace {
case note
}

/// Describes the type of traceable operation that occured
/// Describes the type of traceable operation that occurred
public enum Operation: String, Codable, Hashable, Sendable {
// Subset of the Go OPA topdown trace op's, add more as needed
case enter
Expand All @@ -26,7 +26,7 @@ extension OPA.Trace {
case note
}

/// Describes the source location at which a trace event occured
/// Describes the source location at which a trace event occurred
public struct Location: Codable, Hashable, Sendable {
public var row: Int = 0
public var col: Int = 0
Expand Down
70 changes: 66 additions & 4 deletions Tests/RegoTests/BuiltinTests/BuiltinsCacheTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@ import Testing

@testable import Rego

@Suite("BuiltinsCacheTests")
struct BuiltinsCacheTests {
extension BuiltinTests {
@Suite("BuiltinsCacheTests", .tags(.builtins))
struct BuiltinsCacheTests {}
}

extension BuiltinTests.BuiltinsCacheTests {
@Test("caching items with the same keys but different namespaces")
func cachesDifferentNamespacesAsDifferentEntries() {
var cache = BuiltinsCache()
let cache = BuiltinsCache()

// Set a value in the implied global namespace
cache["3!"] = 6
Expand All @@ -35,7 +39,7 @@ struct BuiltinsCacheTests {

@Test("cache removal and counting")
func testRemoval() {
var cache = BuiltinsCache()
let cache = BuiltinsCache()
cache["3!"] = 6
#expect(cache.count == 1)

Expand All @@ -51,4 +55,62 @@ struct BuiltinsCacheTests {
#expect(cache["3!"] == nil)
#expect(cache.count == 0)
}

static let cacheKey = "test_key"
static let builtinRegistry: BuiltinRegistry =
.init(
builtins: [
"custom_cache_builtin_1": { ctx, args in
guard args.count == 1 else {
throw BuiltinError.argumentCountMismatch(got: args.count, want: 1)
}

// Write to builtin cache
ctx.cache[Self.cacheKey] = args[0]
// Read from builtin cache
let cacheEntry = try #require(ctx.cache[Self.cacheKey], "Expected entry is not present in cache")

return cacheEntry
},
"custom_cache_builtin_2": { ctx, args in
// Read from builtin cache
let value = try #require(ctx.cache[Self.cacheKey], "Expected entry is not present in cache")
// Reset entry
ctx.cache[Self.cacheKey] = nil
return value
}
]
)

@Test("Shared cache across builtin evals")
func test() async throws {
// One builtin cache that is handed to both builtins
let cache = BuiltinsCache()
// Dummy value in cache
let testCacheValue: RegoValue = .string("test_value")

let result1 = await Result {
try await Self.builtinRegistry.invoke(
withContext: BuiltinContext(cache: cache),
name: "custom_cache_builtin_1",
args: [testCacheValue],
strict: true
)
}
try #expect(result1.get() == testCacheValue)
#expect(cache.count == 1)
#expect(cache[Self.cacheKey] == testCacheValue)

let result2 = await Result {
try await Self.builtinRegistry.invoke(
withContext: BuiltinContext(cache: cache),
name: "custom_cache_builtin_2",
args: [testCacheValue],
strict: true
)
}
try #expect(result2.get() == testCacheValue)
#expect(cache.count == 0)
#expect(cache[Self.cacheKey] == nil)
}
}