Skip to content
Merged
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
11 changes: 10 additions & 1 deletion Dockerfile.local
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
# Use the correct Go version
FROM golang:1.25.3-alpine@sha256:aee43c3ccbf24fdffb7295693b6e33b21e01baec1b2a55acc351fde345e9ec34

RUN apk --no-cache add curl
RUN apk --no-cache add curl git

# Build grpc-health-probe
WORKDIR /tmp
RUN git clone https://github.com/grpc-ecosystem/grpc-health-probe.git && \
cd grpc-health-probe && \
CGO_ENABLED=0 go install -a -tags netgo -ldflags=-w && \
cp /go/bin/grpc-health-probe /usr/local/bin/grpc_health_probe && \
cd / && rm -rf /tmp/grpc-health-probe

# Install the air binary so we get live code-reloading when we save files
RUN curl -sSfL https://raw.githubusercontent.com/cosmtrek/air/master/install.sh | sh -s -- -b $(go env GOPATH)/bin

Expand Down
3 changes: 1 addition & 2 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
version: "3.9"
services:
permify:
build:
Expand All @@ -13,7 +12,7 @@ services:
depends_on:
- "database"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3476/healthz"]
test: ["CMD", "grpc_health_probe", "-addr", "localhost:3478"]
interval: 10s
retries: 10
start_period: 60s
Expand Down
2 changes: 1 addition & 1 deletion docs/api-reference/apidocs.swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"info": {
"title": "Permify API",
"description": "Permify is an open source authorization service for creating fine-grained and scalable authorization systems.",
"version": "v1.4.8",
"version": "v1.4.9",
"contact": {
"name": "API Support",
"url": "https://github.com/Permify/permify/issues",
Expand Down
2 changes: 1 addition & 1 deletion docs/api-reference/openapiv2/apidocs.swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"info": {
"title": "Permify API",
"description": "Permify is an open source authorization service for creating fine-grained and scalable authorization systems.",
"version": "v1.4.8",
"version": "v1.4.9",
"contact": {
"name": "API Support",
"url": "https://github.com/Permify/permify/issues",
Expand Down
2 changes: 1 addition & 1 deletion internal/info.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ var Identifier = ""
*/
const (
// Version is the last release of the Permify (e.g. v0.1.0)
Version = "v1.4.8"
Version = "v1.4.9"
)

// Function to create a single line of the ASCII art with centered content and color
Expand Down
2 changes: 1 addition & 1 deletion internal/storage/decorators/singleflight/data_reader.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ func (r *DataReader) QueryUniqueSubjectReferences(ctx context.Context, tenantID

// HeadSnapshot - Reads the latest version of the snapshot from the repository.
func (r *DataReader) HeadSnapshot(ctx context.Context, tenantID string) (token.SnapToken, error) {
rev, _, err := r.group.Do(ctx, tenantID, func(ctx context.Context) (token.SnapToken, error) {
rev, _, err := r.group.Do(ctx, tenantID, func(ctx context.Context) (token.SnapToken, error) { // tenantID ensures proper tenant isolation in deduplication
return r.delegate.HeadSnapshot(ctx, tenantID)
})
return rev, err
Expand Down
181 changes: 181 additions & 0 deletions internal/storage/decorators/singleflight/data_reader_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
package singleflight

import (
"context"
"sync"
"sync/atomic"
"time"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"

"github.com/Permify/permify/internal/storage"
"github.com/Permify/permify/pkg/token"
)

// MockDataReader is a mock implementation of storage.DataReader for testing
type MockDataReader struct {
storage.NoopDataReader
headSnapshotCalls map[string]*int64
mu sync.Mutex
}

func NewMockDataReader() *MockDataReader {
return &MockDataReader{
headSnapshotCalls: make(map[string]*int64),
}
}

func (m *MockDataReader) HeadSnapshot(ctx context.Context, tenantID string) (token.SnapToken, error) {
// Track call count per tenant
m.mu.Lock()
counter, exists := m.headSnapshotCalls[tenantID]
if !exists {
counter = new(int64)
m.headSnapshotCalls[tenantID] = counter
}
m.mu.Unlock()

// Increment call count
atomic.AddInt64(counter, 1)

// Simulate some work
time.Sleep(10 * time.Millisecond)

return token.NoopToken{Value: "snapshot-" + tenantID}, nil
}

func GetCallCount(m *MockDataReader, tenantID string) int64 {
m.mu.Lock()
defer m.mu.Unlock()
if counter, exists := m.headSnapshotCalls[tenantID]; exists {
return atomic.LoadInt64(counter)
}
return 0
}

var _ = Describe("Singleflight DataReader", func() {
var (
mockDelegate storage.DataReader
reader *DataReader
ctx context.Context
)

BeforeEach(func() {
mockDelegate = NewMockDataReader()
reader = NewDataReader(mockDelegate)
ctx = context.Background()
})

Describe("HeadSnapshot", func() {
It("should deduplicate concurrent requests for the same tenant", func() {
tenantID := "tenant1"
numConcurrentRequests := 10

var wg sync.WaitGroup
wg.Add(numConcurrentRequests)

// Launch concurrent requests for the same tenant
for i := 0; i < numConcurrentRequests; i++ {
go func() {
defer wg.Done()
_, err := reader.HeadSnapshot(ctx, tenantID)
Expect(err).ShouldNot(HaveOccurred())
}()
}

wg.Wait()

// Only 1 call should reach the delegate due to deduplication
mock := mockDelegate.(*MockDataReader)
callCount := GetCallCount(mock, tenantID)
Expect(callCount).To(Equal(int64(1)))
})

It("should isolate requests for different tenants", func() {
tenant1 := "tenant1"
tenant2 := "tenant2"
numConcurrentRequests := 5

var wg sync.WaitGroup
wg.Add(numConcurrentRequests * 2)

// Launch concurrent requests for tenant1
for i := 0; i < numConcurrentRequests; i++ {
go func() {
defer wg.Done()
_, err := reader.HeadSnapshot(ctx, tenant1)
Expect(err).ShouldNot(HaveOccurred())
}()
}

// Launch concurrent requests for tenant2
for i := 0; i < numConcurrentRequests; i++ {
go func() {
defer wg.Done()
_, err := reader.HeadSnapshot(ctx, tenant2)
Expect(err).ShouldNot(HaveOccurred())
}()
}

wg.Wait()

mock := mockDelegate.(*MockDataReader)

// Each tenant should have exactly 1 call due to deduplication within the tenant
Expect(GetCallCount(mock, tenant1)).To(Equal(int64(1)))
Expect(GetCallCount(mock, tenant2)).To(Equal(int64(1)))
})

It("should return correct snapshot for each tenant", func() {
tenant1 := "tenant1"
tenant2 := "tenant2"

var wg sync.WaitGroup
wg.Add(2)

var result1, result2 token.SnapToken

go func() {
defer wg.Done()
var err error
result1, err = reader.HeadSnapshot(ctx, tenant1)
Expect(err).ShouldNot(HaveOccurred())
}()

go func() {
defer wg.Done()
var err error
result2, err = reader.HeadSnapshot(ctx, tenant2)
Expect(err).ShouldNot(HaveOccurred())
}()

wg.Wait()

// Verify that each tenant gets its own snapshot
Expect(result1.(token.NoopToken).Value).To(Equal("snapshot-" + tenant1))
Expect(result2.(token.NoopToken).Value).To(Equal("snapshot-" + tenant2))
})

It("should not deduplicate sequential requests", func() {
tenantID := "tenant1"

// First request
_, err := reader.HeadSnapshot(ctx, tenantID)
Expect(err).ShouldNot(HaveOccurred())

// Small delay to ensure first request completes
time.Sleep(50 * time.Millisecond)

// Second request (should trigger another call to delegate)
_, err = reader.HeadSnapshot(ctx, tenantID)
Expect(err).ShouldNot(HaveOccurred())

mock := mockDelegate.(*MockDataReader)

// Should have 2 calls to the delegate
callCount := GetCallCount(mock, tenantID)
Expect(callCount).To(Equal(int64(2)))
})
})
})
2 changes: 1 addition & 1 deletion internal/storage/decorators/singleflight/schema_reader.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ func (r *SchemaReader) ReadRuleDefinition(ctx context.Context, tenantID, ruleNam

// HeadVersion - Finds the latest version of the schema.
func (r *SchemaReader) HeadVersion(ctx context.Context, tenantID string) (version string, err error) {
rev, _, err := r.group.Do(ctx, tenantID, func(ctx context.Context) (string, error) {
rev, _, err := r.group.Do(ctx, tenantID, func(ctx context.Context) (string, error) { // tenantID ensures proper tenant isolation in deduplication
return r.delegate.HeadVersion(ctx, tenantID)
})
return rev, err
Expand Down
Loading