This is an automated email from the ASF dual-hosted git repository.

hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin.git


The following commit(s) were added to refs/heads/master by this push:
     new 5d4c147  feat: add EnforcerPool for filtered enforcers
5d4c147 is described below

commit 5d4c14788fc0234ae24756f52d75f60935dda12a
Author: Yang Luo <[email protected]>
AuthorDate: Sun Nov 1 02:25:14 2026 +0800

    feat: add EnforcerPool for filtered enforcers
---
 README.md             |  24 ++++
 enforcer_pool.go      | 209 +++++++++++++++++++++++++++++++
 enforcer_pool_test.go | 332 ++++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 565 insertions(+)

diff --git a/README.md b/README.md
index c9747dd..f4a46f7 100644
--- a/README.md
+++ b/README.md
@@ -61,6 +61,7 @@ Apache Casbin is a powerful and efficient open-source access 
control library for
 - [Policy management](#policy-management)
 - [Policy persistence](#policy-persistence)
 - [Policy consistence between multiple 
nodes](#policy-consistence-between-multiple-nodes)
+- [Enforcer pooling](#enforcer-pooling)
 - [Role manager](#role-manager)
 - [Benchmarks](#benchmarks)
 - [Examples](#examples)
@@ -229,6 +230,29 @@ https://casbin.apache.org/docs/watchers
 
 https://casbin.apache.org/docs/production-deployment
 
+## Enforcer pooling
+
+If your application creates a filtered enforcer for every request, for example 
one enforcer per tenant, use `EnforcerPool` to reuse one enforcer per key and 
bound the cache with an LRU:
+
+```go
+// newAdapter creates a new filtered adapter for each enforcer.
+pool := casbin.NewFilteredEnforcerPool("model.conf", newAdapter, 1024)
+
+e, err := pool.Get(tenantID, []string{tenantID})
+if err != nil {
+    return err
+}
+allowed, err := e.Enforce("alice", "data1", "read")
+
+// After a policy change made outside this enforcer:
+pool.Invalidate(tenantID)
+
+// In a watcher callback:
+watcher.SetUpdateCallback(pool.OnUpdate)
+```
+
+`NewFilteredEnforcerPool` calls `newAdapter` once per enforcer, so filtered 
adapters that keep state are not shared. The filter passed to `Get` must be the 
type your adapter expects; use `NewEnforcerPool` with your own factory for 
other filter types. A `maxSize` of zero or less means no limit.
+
 ## Role manager
 
 https://casbin.apache.org/docs/role-managers
diff --git a/enforcer_pool.go b/enforcer_pool.go
new file mode 100644
index 0000000..18691b2
--- /dev/null
+++ b/enforcer_pool.go
@@ -0,0 +1,209 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package casbin
+
+import (
+       "container/list"
+       "errors"
+       "sync"
+
+       "github.com/casbin/casbin/v3/model"
+       "github.com/casbin/casbin/v3/persist"
+)
+
+// EnforcerFactory creates an enforcer for a cache entry. It is called only on
+// a cache miss. The enforcer is shared by every caller that uses the same key,
+// so it must be safe for concurrent use.
+type EnforcerFactory func(key string, filter interface{}) (*SyncedEnforcer, 
error)
+
+// EnforcerPool caches enforcers by key and bounds the cache with an LRU.
+// It is intended for applications that would otherwise create a filtered
+// enforcer for every request, for example one enforcer per tenant.
+//
+// The key must uniquely identify the filter: a cache hit returns the cached
+// enforcer without calling the factory, even if a different filter is passed.
+// Call Invalidate after a policy change that was not made through the cached
+// enforcer, and call InvalidateAll from a watcher callback to drop enforcers
+// built from a stale policy.
+type EnforcerPool struct {
+       mu         sync.Mutex
+       factory    EnforcerFactory
+       maxSize    int
+       generation uint64
+       enforcers  map[string]*enforcerPoolEntry
+       lru        *list.List
+}
+
+type enforcerPoolEntry struct {
+       enforcer *SyncedEnforcer
+       elem     *list.Element
+}
+
+var (
+       errNilEnforcerFactory = errors.New("casbin: nil enforcer factory")
+       errNilEnforcer        = errors.New("casbin: enforcer factory returned 
nil")
+       errNilAdapterFactory  = errors.New("casbin: nil adapter factory")
+)
+
+// NewEnforcerPool creates a pool that keeps at most maxSize enforcers.
+// A maxSize of zero or less means no limit.
+func NewEnforcerPool(factory EnforcerFactory, maxSize int) *EnforcerPool {
+       return &EnforcerPool{
+               factory:   factory,
+               maxSize:   maxSize,
+               enforcers: make(map[string]*enforcerPoolEntry),
+               lru:       list.New(),
+       }
+}
+
+// NewFilteredEnforcerPool creates a pool that builds one filtered enforcer per
+// key. newAdapter is called once per enforcer, so adapters that keep filter
+// state are not shared. The filter passed to Get must be the type the adapter
+// expects.
+func NewFilteredEnforcerPool(modelPath string, newAdapter func() 
persist.FilteredAdapter, maxSize int) *EnforcerPool {
+       return NewEnforcerPool(func(key string, filter interface{}) 
(*SyncedEnforcer, error) {
+               if newAdapter == nil {
+                       return nil, errNilAdapterFactory
+               }
+
+               m, err := model.NewModelFromFile(modelPath)
+               if err != nil {
+                       return nil, err
+               }
+
+               e, err := NewSyncedEnforcer(m)
+               if err != nil {
+                       return nil, err
+               }
+
+               e.SetAdapter(newAdapter())
+               if err := e.LoadFilteredPolicy(filter); err != nil {
+                       return nil, err
+               }
+
+               return e, nil
+       }, maxSize)
+}
+
+// Get returns the cached enforcer for key, calling the factory on a cache
+// miss. filter is passed to the factory unchanged.
+func (p *EnforcerPool) Get(key string, filter interface{}) (*SyncedEnforcer, 
error) {
+       if p.factory == nil {
+               return nil, errNilEnforcerFactory
+       }
+
+       for {
+               p.mu.Lock()
+               p.initLocked()
+               if entry, ok := p.enforcers[key]; ok {
+                       p.lru.MoveToFront(entry.elem)
+                       e := entry.enforcer
+                       p.mu.Unlock()
+                       return e, nil
+               }
+               generation := p.generation
+               p.mu.Unlock()
+
+               e, err := p.factory(key, filter)
+               if err != nil {
+                       return nil, err
+               }
+               if e == nil {
+                       return nil, errNilEnforcer
+               }
+
+               p.mu.Lock()
+               // The policy changed while the factory was loading, so the 
enforcer
+               // may have been built from stale data. Build it again.
+               if generation != p.generation {
+                       p.mu.Unlock()
+                       continue
+               }
+               if entry, ok := p.enforcers[key]; ok {
+                       p.lru.MoveToFront(entry.elem)
+                       e = entry.enforcer
+                       p.mu.Unlock()
+                       return e, nil
+               }
+
+               entry := &enforcerPoolEntry{enforcer: e}
+               entry.elem = p.lru.PushFront(key)
+               p.enforcers[key] = entry
+               p.evictLocked()
+               p.mu.Unlock()
+               return e, nil
+       }
+}
+
+// Invalidate drops the enforcer cached for key.
+func (p *EnforcerPool) Invalidate(key string) {
+       p.mu.Lock()
+       defer p.mu.Unlock()
+       p.initLocked()
+       p.generation++
+       if entry, ok := p.enforcers[key]; ok {
+               delete(p.enforcers, key)
+               p.lru.Remove(entry.elem)
+       }
+}
+
+// InvalidateAll drops every cached enforcer.
+func (p *EnforcerPool) InvalidateAll() {
+       p.mu.Lock()
+       defer p.mu.Unlock()
+       p.initLocked()
+       p.generation++
+       p.enforcers = make(map[string]*enforcerPoolEntry)
+       p.lru.Init()
+}
+
+// OnUpdate invalidates every cached enforcer. Its signature matches the
+// callback passed to persist.Watcher.SetUpdateCallback.
+func (p *EnforcerPool) OnUpdate(string) {
+       p.InvalidateAll()
+}
+
+// Len returns the number of cached enforcers.
+func (p *EnforcerPool) Len() int {
+       p.mu.Lock()
+       defer p.mu.Unlock()
+       return len(p.enforcers)
+}
+
+func (p *EnforcerPool) initLocked() {
+       if p.enforcers == nil {
+               p.enforcers = make(map[string]*enforcerPoolEntry)
+       }
+       if p.lru == nil {
+               p.lru = list.New()
+       }
+}
+
+func (p *EnforcerPool) evictLocked() {
+       if p.maxSize <= 0 {
+               return
+       }
+       for len(p.enforcers) > p.maxSize {
+               back := p.lru.Back()
+               if back == nil {
+                       return
+               }
+               delete(p.enforcers, back.Value.(string))
+               p.lru.Remove(back)
+       }
+}
diff --git a/enforcer_pool_test.go b/enforcer_pool_test.go
new file mode 100644
index 0000000..65d259c
--- /dev/null
+++ b/enforcer_pool_test.go
@@ -0,0 +1,332 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package casbin
+
+import (
+       "errors"
+       "strconv"
+       "sync"
+       "sync/atomic"
+       "testing"
+
+       "github.com/casbin/casbin/v3/persist"
+       fileadapter "github.com/casbin/casbin/v3/persist/file-adapter"
+)
+
+func newTestPoolEnforcer() (*SyncedEnforcer, error) {
+       return NewSyncedEnforcer("examples/basic_model.conf", 
"examples/basic_policy.csv")
+}
+
+func TestEnforcerPoolGetCaches(t *testing.T) {
+       calls := 0
+       pool := NewEnforcerPool(func(key string, filter interface{}) 
(*SyncedEnforcer, error) {
+               calls++
+               return newTestPoolEnforcer()
+       }, 10)
+
+       e1, err := pool.Get("tenant1", nil)
+       if err != nil {
+               t.Fatalf("Get: %v", err)
+       }
+       e2, err := pool.Get("tenant1", nil)
+       if err != nil {
+               t.Fatalf("Get: %v", err)
+       }
+
+       if calls != 1 {
+               t.Fatalf("factory calls = %d, want 1", calls)
+       }
+       if e1 != e2 {
+               t.Fatal("Get returned different enforcers for the same key")
+       }
+       if pool.Len() != 1 {
+               t.Fatalf("Len = %d, want 1", pool.Len())
+       }
+}
+
+func TestEnforcerPoolDifferentKeys(t *testing.T) {
+       calls := 0
+       pool := NewEnforcerPool(func(key string, filter interface{}) 
(*SyncedEnforcer, error) {
+               calls++
+               return newTestPoolEnforcer()
+       }, 10)
+
+       e1, err := pool.Get("tenant1", nil)
+       if err != nil {
+               t.Fatalf("Get: %v", err)
+       }
+       e2, err := pool.Get("tenant2", nil)
+       if err != nil {
+               t.Fatalf("Get: %v", err)
+       }
+
+       if calls != 2 {
+               t.Fatalf("factory calls = %d, want 2", calls)
+       }
+       if e1 == e2 {
+               t.Fatal("Get returned the same enforcer for different keys")
+       }
+       if pool.Len() != 2 {
+               t.Fatalf("Len = %d, want 2", pool.Len())
+       }
+}
+
+func TestEnforcerPoolInvalidate(t *testing.T) {
+       calls := 0
+       pool := NewEnforcerPool(func(key string, filter interface{}) 
(*SyncedEnforcer, error) {
+               calls++
+               return newTestPoolEnforcer()
+       }, 10)
+
+       e1, err := pool.Get("tenant1", nil)
+       if err != nil {
+               t.Fatalf("Get: %v", err)
+       }
+
+       pool.Invalidate("tenant1")
+       if pool.Len() != 0 {
+               t.Fatalf("Len = %d, want 0", pool.Len())
+       }
+
+       e2, err := pool.Get("tenant1", nil)
+       if err != nil {
+               t.Fatalf("Get: %v", err)
+       }
+       if calls != 2 {
+               t.Fatalf("factory calls = %d, want 2", calls)
+       }
+       if e1 == e2 {
+               t.Fatal("Get returned the invalidated enforcer")
+       }
+
+       pool.Invalidate("missing")
+}
+
+func TestEnforcerPoolInvalidateAll(t *testing.T) {
+       calls := 0
+       pool := NewEnforcerPool(func(key string, filter interface{}) 
(*SyncedEnforcer, error) {
+               calls++
+               return newTestPoolEnforcer()
+       }, 10)
+
+       if _, err := pool.Get("tenant1", nil); err != nil {
+               t.Fatalf("Get: %v", err)
+       }
+       if _, err := pool.Get("tenant2", nil); err != nil {
+               t.Fatalf("Get: %v", err)
+       }
+
+       pool.OnUpdate("policy changed")
+       if pool.Len() != 0 {
+               t.Fatalf("Len = %d, want 0", pool.Len())
+       }
+
+       if _, err := pool.Get("tenant1", nil); err != nil {
+               t.Fatalf("Get: %v", err)
+       }
+       if calls != 3 {
+               t.Fatalf("factory calls = %d, want 3", calls)
+       }
+}
+
+func TestEnforcerPoolLRU(t *testing.T) {
+       calls := 0
+       pool := NewEnforcerPool(func(key string, filter interface{}) 
(*SyncedEnforcer, error) {
+               calls++
+               return newTestPoolEnforcer()
+       }, 2)
+
+       get := func(key string) *SyncedEnforcer {
+               e, err := pool.Get(key, nil)
+               if err != nil {
+                       t.Fatalf("Get(%q): %v", key, err)
+               }
+               return e
+       }
+
+       get("a")
+       get("b")
+       get("a")
+       get("c")
+       get("b")
+       get("c")
+
+       if calls != 4 {
+               t.Fatalf("factory calls = %d, want 4", calls)
+       }
+       if pool.Len() != 2 {
+               t.Fatalf("Len = %d, want 2", pool.Len())
+       }
+}
+
+func TestEnforcerPoolUnbounded(t *testing.T) {
+       pool := NewEnforcerPool(func(key string, filter interface{}) 
(*SyncedEnforcer, error) {
+               return newTestPoolEnforcer()
+       }, 0)
+
+       for i := 0; i < 50; i++ {
+               if _, err := pool.Get(strconv.Itoa(i), nil); err != nil {
+                       t.Fatalf("Get: %v", err)
+               }
+       }
+       if pool.Len() != 50 {
+               t.Fatalf("Len = %d, want 50", pool.Len())
+       }
+}
+
+func TestEnforcerPoolFactoryError(t *testing.T) {
+       boom := errors.New("boom")
+       calls := 0
+       pool := NewEnforcerPool(func(key string, filter interface{}) 
(*SyncedEnforcer, error) {
+               calls++
+               return nil, boom
+       }, 10)
+
+       if _, err := pool.Get("tenant1", nil); !errors.Is(err, boom) {
+               t.Fatalf("Get error = %v, want %v", err, boom)
+       }
+       if pool.Len() != 0 {
+               t.Fatalf("Len = %d, want 0", pool.Len())
+       }
+
+       if _, err := pool.Get("tenant1", nil); !errors.Is(err, boom) {
+               t.Fatalf("Get error = %v, want %v", err, boom)
+       }
+       if calls != 2 {
+               t.Fatalf("factory calls = %d, want 2", calls)
+       }
+}
+
+func TestEnforcerPoolNilFactory(t *testing.T) {
+       pool := NewEnforcerPool(nil, 10)
+       if _, err := pool.Get("tenant1", nil); err != errNilEnforcerFactory {
+               t.Fatalf("Get error = %v, want %v", err, errNilEnforcerFactory)
+       }
+}
+
+func TestEnforcerPoolNilEnforcer(t *testing.T) {
+       pool := NewEnforcerPool(func(key string, filter interface{}) 
(*SyncedEnforcer, error) {
+               return nil, nil
+       }, 10)
+       if _, err := pool.Get("tenant1", nil); err != errNilEnforcer {
+               t.Fatalf("Get error = %v, want %v", err, errNilEnforcer)
+       }
+}
+
+func TestEnforcerPoolConcurrentGet(t *testing.T) {
+       pool := NewEnforcerPool(func(key string, filter interface{}) 
(*SyncedEnforcer, error) {
+               return newTestPoolEnforcer()
+       }, 10)
+
+       const n = 100
+       results := make([]*SyncedEnforcer, n)
+       var wg sync.WaitGroup
+       wg.Add(n)
+       for i := 0; i < n; i++ {
+               go func(i int) {
+                       defer wg.Done()
+                       e, err := pool.Get("tenant1", nil)
+                       if err != nil {
+                               t.Errorf("Get: %v", err)
+                               return
+                       }
+                       results[i] = e
+               }(i)
+       }
+       wg.Wait()
+
+       if t.Failed() {
+               return
+       }
+       for i := 1; i < n; i++ {
+               if results[i] != results[0] {
+                       t.Fatalf("Get returned different enforcers for the same 
key")
+               }
+       }
+       if pool.Len() != 1 {
+               t.Fatalf("Len = %d, want 1", pool.Len())
+       }
+}
+
+func TestEnforcerPoolInvalidateDuringLoad(t *testing.T) {
+       var calls int32
+       started := make(chan struct{})
+       release := make(chan struct{})
+       pool := NewEnforcerPool(func(key string, filter interface{}) 
(*SyncedEnforcer, error) {
+               if atomic.AddInt32(&calls, 1) == 1 {
+                       close(started)
+                       <-release
+               }
+               return newTestPoolEnforcer()
+       }, 10)
+
+       go func() {
+               <-started
+               pool.InvalidateAll()
+               close(release)
+       }()
+
+       if _, err := pool.Get("tenant1", nil); err != nil {
+               t.Fatalf("Get: %v", err)
+       }
+       if got := atomic.LoadInt32(&calls); got != 2 {
+               t.Fatalf("factory calls = %d, want 2", got)
+       }
+       if pool.Len() != 1 {
+               t.Fatalf("Len = %d, want 1", pool.Len())
+       }
+}
+
+func TestNewFilteredEnforcerPool(t *testing.T) {
+       pool := NewFilteredEnforcerPool(
+               "examples/rbac_with_domains_model.conf",
+               func() persist.FilteredAdapter {
+                       return 
fileadapter.NewFilteredAdapter("examples/rbac_with_domains_policy.csv")
+               },
+               10,
+       )
+
+       e, err := pool.Get("domain1", &fileadapter.Filter{P: []string{"", 
"domain1"}})
+       if err != nil {
+               t.Fatalf("Get: %v", err)
+       }
+
+       policy, err := e.GetPolicy()
+       if err != nil {
+               t.Fatalf("GetPolicy: %v", err)
+       }
+       if len(policy) != 2 {
+               t.Fatalf("policy length = %d, want 2", len(policy))
+       }
+
+       allowed, err := e.Enforce("admin", "domain1", "data1", "read")
+       if err != nil {
+               t.Fatalf("Enforce: %v", err)
+       }
+       if !allowed {
+               t.Fatal("domain1 policy was not loaded")
+       }
+
+       allowed, err = e.Enforce("admin", "domain2", "data2", "read")
+       if err != nil {
+               t.Fatalf("Enforce: %v", err)
+       }
+       if allowed {
+               t.Fatal("domain2 policy was loaded by the domain1 enforcer")
+       }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to