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
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