diff --git a/Dockerfile.local b/Dockerfile.local index e5fc4760b..11772f750 100644 --- a/Dockerfile.local +++ b/Dockerfile.local @@ -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 diff --git a/docker-compose.yaml b/docker-compose.yaml index f7025f6de..53bc6af31 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,4 +1,3 @@ -version: "3.9" services: permify: build: @@ -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 diff --git a/docs/api-reference/apidocs.swagger.json b/docs/api-reference/apidocs.swagger.json index ac5b8841f..649bc928e 100644 --- a/docs/api-reference/apidocs.swagger.json +++ b/docs/api-reference/apidocs.swagger.json @@ -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", diff --git a/docs/api-reference/openapiv2/apidocs.swagger.json b/docs/api-reference/openapiv2/apidocs.swagger.json index 34612da6d..a234965d3 100644 --- a/docs/api-reference/openapiv2/apidocs.swagger.json +++ b/docs/api-reference/openapiv2/apidocs.swagger.json @@ -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", diff --git a/internal/info.go b/internal/info.go index f7ce80d66..db2c88ccf 100644 --- a/internal/info.go +++ b/internal/info.go @@ -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 diff --git a/internal/storage/decorators/singleflight/data_reader.go b/internal/storage/decorators/singleflight/data_reader.go index 0160f2606..f9564223e 100644 --- a/internal/storage/decorators/singleflight/data_reader.go +++ b/internal/storage/decorators/singleflight/data_reader.go @@ -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 diff --git a/internal/storage/decorators/singleflight/data_reader_test.go b/internal/storage/decorators/singleflight/data_reader_test.go new file mode 100644 index 000000000..67b53f6d4 --- /dev/null +++ b/internal/storage/decorators/singleflight/data_reader_test.go @@ -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))) + }) + }) +}) diff --git a/internal/storage/decorators/singleflight/schema_reader.go b/internal/storage/decorators/singleflight/schema_reader.go index f32a3a25c..894104b7e 100644 --- a/internal/storage/decorators/singleflight/schema_reader.go +++ b/internal/storage/decorators/singleflight/schema_reader.go @@ -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 diff --git a/internal/storage/decorators/singleflight/schema_reader_test.go b/internal/storage/decorators/singleflight/schema_reader_test.go new file mode 100644 index 000000000..35bb14e59 --- /dev/null +++ b/internal/storage/decorators/singleflight/schema_reader_test.go @@ -0,0 +1,180 @@ +package singleflight + +import ( + "context" + "sync" + "sync/atomic" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/Permify/permify/internal/storage" +) + +// MockSchemaReader is a mock implementation of storage.SchemaReader for testing +type MockSchemaReader struct { + storage.NoopSchemaReader + headVersionCalls map[string]*int64 + mu sync.Mutex +} + +func NewMockSchemaReader() *MockSchemaReader { + return &MockSchemaReader{ + headVersionCalls: make(map[string]*int64), + } +} + +func (m *MockSchemaReader) HeadVersion(ctx context.Context, tenantID string) (string, error) { + // Track call count per tenant + m.mu.Lock() + counter, exists := m.headVersionCalls[tenantID] + if !exists { + counter = new(int64) + m.headVersionCalls[tenantID] = counter + } + m.mu.Unlock() + + // Increment call count + atomic.AddInt64(counter, 1) + + // Simulate some work + time.Sleep(10 * time.Millisecond) + + return "version-" + tenantID, nil +} + +func GetVersionCallCount(m *MockSchemaReader, tenantID string) int64 { + m.mu.Lock() + defer m.mu.Unlock() + if counter, exists := m.headVersionCalls[tenantID]; exists { + return atomic.LoadInt64(counter) + } + return 0 +} + +var _ = Describe("Singleflight SchemaReader", func() { + var ( + mockDelegate storage.SchemaReader + reader *SchemaReader + ctx context.Context + ) + + BeforeEach(func() { + mockDelegate = NewMockSchemaReader() + reader = NewSchemaReader(mockDelegate) + ctx = context.Background() + }) + + Describe("HeadVersion", 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.HeadVersion(ctx, tenantID) + Expect(err).ShouldNot(HaveOccurred()) + }() + } + + wg.Wait() + + // Only 1 call should reach the delegate due to deduplication + mock := mockDelegate.(*MockSchemaReader) + callCount := GetVersionCallCount(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.HeadVersion(ctx, tenant1) + Expect(err).ShouldNot(HaveOccurred()) + }() + } + + // Launch concurrent requests for tenant2 + for i := 0; i < numConcurrentRequests; i++ { + go func() { + defer wg.Done() + _, err := reader.HeadVersion(ctx, tenant2) + Expect(err).ShouldNot(HaveOccurred()) + }() + } + + wg.Wait() + + mock := mockDelegate.(*MockSchemaReader) + + // Each tenant should have exactly 1 call due to deduplication within the tenant + Expect(GetVersionCallCount(mock, tenant1)).To(Equal(int64(1))) + Expect(GetVersionCallCount(mock, tenant2)).To(Equal(int64(1))) + }) + + It("should return correct version for each tenant", func() { + tenant1 := "tenant1" + tenant2 := "tenant2" + + var wg sync.WaitGroup + wg.Add(2) + + var result1, result2 string + + go func() { + defer wg.Done() + var err error + result1, err = reader.HeadVersion(ctx, tenant1) + Expect(err).ShouldNot(HaveOccurred()) + }() + + go func() { + defer wg.Done() + var err error + result2, err = reader.HeadVersion(ctx, tenant2) + Expect(err).ShouldNot(HaveOccurred()) + }() + + wg.Wait() + + // Verify that each tenant gets its own version + Expect(result1).To(Equal("version-" + tenant1)) + Expect(result2).To(Equal("version-" + tenant2)) + }) + + It("should not deduplicate sequential requests", func() { + tenantID := "tenant1" + + // First request + _, err := reader.HeadVersion(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.HeadVersion(ctx, tenantID) + Expect(err).ShouldNot(HaveOccurred()) + + mock := mockDelegate.(*MockSchemaReader) + + // Should have 2 calls to the delegate + callCount := GetVersionCallCount(mock, tenantID) + Expect(callCount).To(Equal(int64(2))) + }) + }) +}) diff --git a/internal/storage/decorators/singleflight/singleflight_test.go b/internal/storage/decorators/singleflight/singleflight_test.go new file mode 100644 index 000000000..14d19b45a --- /dev/null +++ b/internal/storage/decorators/singleflight/singleflight_test.go @@ -0,0 +1,13 @@ +package singleflight + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestSingleflight(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "singleflight-suite") +} diff --git a/internal/storage/postgres/utils/common.go b/internal/storage/postgres/utils/common.go index 36c7e658d..2dd5c396b 100644 --- a/internal/storage/postgres/utils/common.go +++ b/internal/storage/postgres/utils/common.go @@ -39,7 +39,6 @@ const ( // SnapshotQuery adds conditions to a SELECT query for checking transaction visibility based on created and expired transaction IDs. // Optimized version with parameterized queries for security. func SnapshotQuery(sl squirrel.SelectBuilder, value uint64, snapshotValue string) squirrel.SelectBuilder { - slog.Info("SnapshotQuery called", slog.Uint64("xid", value), slog.String("snapshot", snapshotValue)) // Backward compatibility: if snapshot is empty, use old method if snapshotValue == "" { // Create a subquery for the snapshot associated with the provided value. diff --git a/pkg/pb/base/v1/openapi.pb.go b/pkg/pb/base/v1/openapi.pb.go index e309a0c01..bbcdb7c25 100644 --- a/pkg/pb/base/v1/openapi.pb.go +++ b/pkg/pb/base/v1/openapi.pb.go @@ -28,7 +28,7 @@ const file_base_v1_openapi_proto_rawDesc = "" + "\x15base/v1/openapi.proto\x12\abase.v1\x1a.protoc-gen-openapiv2/options/annotations.protoB\xf9\x03\x92A\xeb\x02\x12\x9c\x02\n" + "\vPermify API\x12mPermify is an open source authorization service for creating fine-grained and scalable authorization systems.\"J\n" + "\vAPI Support\x12)https://github.com/Permify/permify/issues\x1a\x10hello@permify.co*J\n" + - "\x10AGPL-3.0 license\x126https://github.com/Permify/permify/blob/master/LICENSE2\x06v1.4.8*\x01\x022\x10application/json:\x10application/jsonZ#\n" + + "\x10AGPL-3.0 license\x126https://github.com/Permify/permify/blob/master/LICENSE2\x06v1.4.9*\x01\x022\x10application/json:\x10application/jsonZ#\n" + "!\n" + "\n" + "ApiKeyAuth\x12\x13\b\x02\x1a\rAuthorization \x02\n" + diff --git a/proto/base/v1/openapi.proto b/proto/base/v1/openapi.proto index 1fd7ff06a..506362287 100644 --- a/proto/base/v1/openapi.proto +++ b/proto/base/v1/openapi.proto @@ -9,7 +9,7 @@ option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { 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";