Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
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.6",
"version": "v1.4.7",
"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.6",
"version": "v1.4.7",
"contact": {
"name": "API Support",
"url": "https://github.com/Permify/permify/issues",
Expand Down
35 changes: 35 additions & 0 deletions integration-test/race_condition/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Race Condition Integration Test

This test validates the fix for concurrent transaction race condition in Permify.

## 🚀 Quick Start

### 1. Start Test Environment

```bash
cd integration-test/race_condition
docker compose -f docker-compose-test.yml up --build -d
```

### 2. Run Tests

```bash
# Simple test
./test_race_condition.sh

# Parallel test (more aggressive)
./test_race_condition_parallel.sh
```

## 🧹 Cleanup

```bash
docker compose -f docker-compose-test.yml down -v
```

## 📁 Files

- `schema.perm` - Test schema
- `test_race_condition.sh` - Simple test
- `test_race_condition_parallel.sh` - Parallel test
- `docker-compose-test.yml` - Test environment
17 changes: 17 additions & 0 deletions integration-test/race_condition/docker-compose-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
services:
postgres:
image: postgres:17-alpine
environment:
POSTGRES_USER: pg
POSTGRES_PASSWORD: pg
POSTGRES_DB: permify
ports: [ "5434:5432" ]

permify:
build: ../../
environment:
PERMIFY_DATABASE_ENGINE: postgres
PERMIFY_DATABASE_URI: postgres://pg:pg@postgres:5432/permify?sslmode=disable
ports: [ "3477:3476" ]
depends_on: [ postgres ]
command: ["serve", "--http-enabled", "--log-level=debug"]
11 changes: 11 additions & 0 deletions integration-test/race_condition/schema.perm
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
entity user {}

entity doc {
relation can_read @user
relation can_comment @user
relation can_edit @user

permission read = can_read
permission comment = can_comment
permission edit = can_edit
}
76 changes: 76 additions & 0 deletions integration-test/race_condition/test_race_condition.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
#!/bin/bash
set -e

# Write schema
curl -sS -w '\n' 'http://localhost:3477/v1/tenants/t1/schemas/write' \
-H "Content-Type: application/json" \
-d "$(cat "schema.perm" | jq -Rs '{schema: . }')"

# Grant permissions
curl -sS -w '\n' 'http://localhost:3477/v1/tenants/t1/data/write' \
-H "Content-Type: application/json" \
-d '{
"metadata": {},
"tuples": [
{"entity": {"type": "doc", "id": "d1"}, "relation": "can_read", "subject": {"type": "user", "id": "u1"}},
{"entity": {"type": "doc", "id": "d1"}, "relation": "can_comment", "subject": {"type": "user", "id": "u1"}},
{"entity": {"type": "doc", "id": "d1"}, "relation": "can_edit", "subject": {"type": "user", "id": "u1"}}
]
}'

# Define functions
function check_access() {
ACTION=$1

curl -sS -w '\n' 'http://localhost:3477/v1/tenants/t1/permissions/check' \
-H "Content-Type: application/json" \
-d "$(jq -Rn --arg action "$ACTION" '{
metadata: {depth: 20},
entity: {type: "doc", id: "d1"},
permission: $action,
subject: {type: "user", id: "u1"}
}')"
}

function drop_access() {
RELATION=$1

curl -sS -w '\n' 'http://localhost:3477/v1/tenants/t1/data/delete' \
-H "Content-Type: application/json" \
-d "$(jq -Rn --arg relation "$RELATION" '{
tuple_filter: {
entity: {type: "doc", id: "d1"},
relation: $relation,
subject: {type: "user", "ids": ["u1"]}
},
attribute_filter: {}
}')"
}

echo "=== BEFORE DELETE ==="
echo "READ = $(check_access 'read')"
echo "COMMENT = $(check_access 'comment')"
echo "EDIT = $(check_access 'edit')"

echo ""
echo "=== CONCURRENT DELETE ==="
drop_access 'can_comment' &
PID1=$!
drop_access 'can_edit' &
PID2=$!
wait $PID1
wait $PID2

echo ""
echo "=== AFTER DELETE ==="
echo "READ = $(check_access 'read')"
echo "COMMENT = $(check_access 'comment')"
echo "EDIT = $(check_access 'edit')"

sleep 10

echo ""
echo "=== AFTER 10s WAIT ==="
echo "READ = $(check_access 'read')"
echo "COMMENT = $(check_access 'comment')"
echo "EDIT = $(check_access 'edit')"
98 changes: 98 additions & 0 deletions integration-test/race_condition/test_race_condition_parallel.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
#!/bin/bash
set -e

# Write schema
curl -sS -w '\n' 'http://localhost:3477/v1/tenants/t1/schemas/write' \
-H "Content-Type: application/json" \
-d "$(cat "schema.perm" | jq -Rs '{schema: . }')"

# Grant permissions
curl -sS -w '\n' 'http://localhost:3477/v1/tenants/t1/data/write' \
-H "Content-Type: application/json" \
-d '{
"metadata": {},
"tuples": [
{"entity": {"type": "doc", "id": "d1"}, "relation": "can_read", "subject": {"type": "user", "id": "u1"}},
{"entity": {"type": "doc", "id": "d1"}, "relation": "can_comment", "subject": {"type": "user", "id": "u1"}},
{"entity": {"type": "doc", "id": "d1"}, "relation": "can_edit", "subject": {"type": "user", "id": "u1"}}
]
}'

# Define functions
function check_access() {
ACTION=$1
curl -sS -w '\n' 'http://localhost:3477/v1/tenants/t1/permissions/check' \
-H "Content-Type: application/json" \
-d "$(jq -Rn --arg action "$ACTION" '{
metadata: {depth: 20},
entity: {type: "doc", id: "d1"},
permission: $action,
subject: {type: "user", id: "u1"}
}')"
}

function drop_access() {
RELATION=$1
curl -sS -w '\n' 'http://localhost:3477/v1/tenants/t1/data/delete' \
-H "Content-Type: application/json" \
-d "$(jq -Rn --arg relation "$RELATION" '{
tuple_filter: {
entity: {type: "doc", id: "d1"},
relation: $relation,
subject: {type: "user", "ids": ["u1"]}
},
attribute_filter: {}
}')"
}

echo "=== BEFORE DELETE ==="
echo "READ = $(check_access 'read')"
echo "COMMENT = $(check_access 'comment')"
echo "EDIT = $(check_access 'edit')"

echo ""
echo "=== CONCURRENT DELETE ==="
drop_access 'can_comment' &
PID1=$!
drop_access 'can_edit' &
PID2=$!
wait $PID1
wait $PID2

echo ""
echo "=== AFTER DELETE (PARALLEL CHECKS) ==="
check_access 'read' &
CHECK_PID1=$!
check_access 'comment' &
CHECK_PID2=$!
check_access 'edit' &
CHECK_PID3=$!
wait $CHECK_PID1
wait $CHECK_PID2
wait $CHECK_PID3
Comment thread
tolgaozen marked this conversation as resolved.

echo ""
echo "=== AFTER 1s WAIT (PARALLEL CHECKS) ==="
sleep 1
check_access 'read' &
CHECK_PID4=$!
check_access 'comment' &
CHECK_PID5=$!
check_access 'edit' &
CHECK_PID6=$!
wait $CHECK_PID4
wait $CHECK_PID5
wait $CHECK_PID6

echo ""
echo "=== AFTER 5s WAIT (PARALLEL CHECKS) ==="
sleep 5
check_access 'read' &
CHECK_PID7=$!
check_access 'comment' &
CHECK_PID8=$!
check_access 'edit' &
CHECK_PID9=$!
wait $CHECK_PID7
wait $CHECK_PID8
wait $CHECK_PID9
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.6"
Version = "v1.4.7"
)

// Function to create a single line of the ASCII art with centered content and color
Expand Down
31 changes: 16 additions & 15 deletions internal/storage/postgres/data_reader.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ type DataReader struct {
// It initializes a new DataReader with a given database, a logger, and sets transaction options to be read-only with Repeatable Read isolation level.
func NewDataReader(database *db.Postgres) *DataReader {
return &DataReader{
database: database, // Set the database to the passed in PostgreSQL instance
txOptions: pgx.TxOptions{IsoLevel: pgx.ReadCommitted, AccessMode: pgx.ReadOnly}, // Set the transaction options
database: database, // Set the database to the passed in PostgreSQL instance
txOptions: pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}, // Set the transaction options
}
}

Expand All @@ -57,7 +57,7 @@ func (r *DataReader) QueryRelationships(ctx context.Context, tenantID string, fi
var args []interface{}
builder := r.database.Builder.Select("entity_type, entity_id, relation, subject_type, subject_id, subject_relation").From(RelationTuplesTable).Where(squirrel.Eq{"tenant_id": tenantID})
builder = utils.TuplesFilterQueryForSelectBuilder(builder, filter)
builder = utils.SnapshotQuery(builder, st.(snapshot.Token).Value.Uint)
builder = utils.SnapshotQuery(builder, st.(snapshot.Token).Value.Uint, st.(snapshot.Token).Snapshot)

if pagination.Cursor() != "" {
var t database.ContinuousToken
Expand Down Expand Up @@ -132,7 +132,7 @@ func (r *DataReader) ReadRelationships(ctx context.Context, tenantID string, fil
// Build the relationships query based on the provided filter, snapshot value, and pagination settings.
builder := r.database.Builder.Select("id, entity_type, entity_id, relation, subject_type, subject_id, subject_relation").From(RelationTuplesTable).Where(squirrel.Eq{"tenant_id": tenantID})
builder = utils.TuplesFilterQueryForSelectBuilder(builder, filter)
builder = utils.SnapshotQuery(builder, st.(snapshot.Token).Value.Uint)
builder = utils.SnapshotQuery(builder, st.(snapshot.Token).Value.Uint, st.(snapshot.Token).Snapshot)

// Apply the pagination token and limit to the query.
if pagination.Token() != "" {
Expand Down Expand Up @@ -219,7 +219,7 @@ func (r *DataReader) QuerySingleAttribute(ctx context.Context, tenantID string,
var args []interface{}
builder := r.database.Builder.Select("entity_type, entity_id, attribute, value").From(AttributesTable).Where(squirrel.Eq{"tenant_id": tenantID})
builder = utils.AttributesFilterQueryForSelectBuilder(builder, filter)
builder = utils.SnapshotQuery(builder, st.(snapshot.Token).Value.Uint)
builder = utils.SnapshotQuery(builder, st.(snapshot.Token).Value.Uint, st.(snapshot.Token).Snapshot)

// Generate the SQL query and arguments.
var query string
Expand Down Expand Up @@ -278,7 +278,7 @@ func (r *DataReader) QueryAttributes(ctx context.Context, tenantID string, filte
var args []interface{}
builder := r.database.Builder.Select("entity_type, entity_id, attribute, value").From(AttributesTable).Where(squirrel.Eq{"tenant_id": tenantID})
builder = utils.AttributesFilterQueryForSelectBuilder(builder, filter)
builder = utils.SnapshotQuery(builder, st.(snapshot.Token).Value.Uint)
builder = utils.SnapshotQuery(builder, st.(snapshot.Token).Value.Uint, st.(snapshot.Token).Snapshot)

if pagination.Cursor() != "" {
var t database.ContinuousToken
Expand Down Expand Up @@ -366,7 +366,7 @@ func (r *DataReader) ReadAttributes(ctx context.Context, tenantID string, filter
// Build the relationships query based on the provided filter, snapshot value, and pagination settings.
builder := r.database.Builder.Select("id, entity_type, entity_id, attribute, value").From(AttributesTable).Where(squirrel.Eq{"tenant_id": tenantID})
builder = utils.AttributesFilterQueryForSelectBuilder(builder, filter)
builder = utils.SnapshotQuery(builder, st.(snapshot.Token).Value.Uint)
builder = utils.SnapshotQuery(builder, st.(snapshot.Token).Value.Uint, st.(snapshot.Token).Snapshot)

// Apply the pagination token and limit to the query.
if pagination.Token() != "" {
Expand Down Expand Up @@ -479,7 +479,7 @@ func (r *DataReader) QueryUniqueSubjectReferences(ctx context.Context, tenantID
})

// Apply snapshot filter
builder = utils.SnapshotQuery(builder, st.(snapshot.Token).Value.Uint)
builder = utils.SnapshotQuery(builder, st.(snapshot.Token).Value.Uint, st.(snapshot.Token).Snapshot)

// Apply exclusion if the list is not empty
if len(excluded) > 0 {
Expand Down Expand Up @@ -556,11 +556,12 @@ func (r *DataReader) HeadSnapshot(ctx context.Context, tenantID string) (token.S
defer span.End()
// Log snapshot operation
slog.DebugContext(ctx, "getting head snapshot for tenant_id", slog.String("tenant_id", tenantID))
// Declare transaction ID variable
// Declare transaction ID and snapshot variables
var xid db.XID8
var snapshotValue string

// Build the query to find the highest transaction ID associated with the tenant.
builder := r.database.Builder.Select("id").From(TransactionsTable).Where(squirrel.Eq{"tenant_id": tenantID}).OrderBy("id DESC").Limit(1)
// Build the query to find the highest transaction ID and snapshot associated with the tenant.
builder := r.database.Builder.Select("id", "snapshot").From(TransactionsTable).Where(squirrel.Eq{"tenant_id": tenantID}).OrderBy("id DESC").Limit(1)
query, args, err := builder.ToSql()
if err != nil {
return nil, utils.HandleError(ctx, span, err, base.ErrorCode_ERROR_CODE_SQL_BUILDER)
Expand All @@ -569,18 +570,18 @@ func (r *DataReader) HeadSnapshot(ctx context.Context, tenantID string) (token.S
// TODO: To optimize this query, create the following index concurrently to avoid table locks:
// CREATE INDEX CONCURRENTLY idx_transactions_tenant_id_id ON transactions(tenant_id, id DESC);

// Execute the query and retrieve the highest transaction ID.
err = r.database.ReadPool.QueryRow(ctx, query, args...).Scan(&xid)
// Execute the query and retrieve the highest transaction ID and snapshot.
err = r.database.ReadPool.QueryRow(ctx, query, args...).Scan(&xid, &snapshotValue)
if err != nil {
// If no rows are found, return a snapshot token with a value of 0.
if errors.Is(err, pgx.ErrNoRows) {
return snapshot.Token{Value: db.XID8{Uint: 0}}, nil
return snapshot.Token{Value: db.XID8{Uint: 0}, Snapshot: ""}, nil
}
return nil, utils.HandleError(ctx, span, err, base.ErrorCode_ERROR_CODE_SCAN)
}

slog.DebugContext(ctx, "successfully retrieved latest snapshot token")
// Return snapshot token
// Return the latest snapshot token associated with the tenant.
return snapshot.Token{Value: xid}, nil
return snapshot.Token{Value: xid, Snapshot: snapshotValue}, nil
}
Loading