Skip to content

[BUG] Two concurrent transactions get the same snapshot #2573

@maxmanuylov

Description

@maxmanuylov

Describe the bug
Two concurrent write transactions can get the same snapshot, which leads to one of them being ignored in the consequence read requests.

To Reproduce
Below is the minimal example. Steps to reproduce:

  1. docker-compose up
  2. test.sh

The test script creates three relations, then removes two of them simultaneously. In some runs everything is ok, but in some — both relations get marked as expired in DB, but only one of them is reflected in the "check" requests.

docker-compose.yaml

services:
  postgres:
    image: postgres:17-alpine
    environment:
      POSTGRES_USER: pg
      POSTGRES_PASSWORD: pg
      POSTGRES_DB: permify
    ports: [ "5434:5432" ]

  permify:
    image: ghcr.io/permify/permify:v1.4.6
    environment:
      PERMIFY_DATABASE_ENGINE: postgres
      PERMIFY_DATABASE_URI: postgres://pg:pg@postgres:5432/permify?sslmode=disable
    ports: [ "3477:3476" ]
    depends_on: [ postgres ]

schema.perm

entity user {}

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

  action read    = can_read
  action comment = can_comment
  action edit    = can_edit
}

test.sh

#!/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 "READ    = $(check_access 'read')"
echo "COMMENT = $(check_access 'comment')"
echo "EDIT    = $(check_access 'edit')"

drop_access 'can_comment' &
PID1=$!
drop_access 'can_edit' &
PID2=$!
wait $PID1
wait $PID2

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

sleep 10

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

Here is an example output of a good run:

{"schema_version":"d3svbsniceas738hr2e0"}
{"snap_token":"MAMAAAAAAAA="}
READ    = {"can":"CHECK_RESULT_ALLOWED","metadata":{"check_count":2}}
COMMENT = {"can":"CHECK_RESULT_ALLOWED","metadata":{"check_count":2}}
EDIT    = {"can":"CHECK_RESULT_ALLOWED","metadata":{"check_count":2}}
{"snap_token":"MgMAAAAAAAA="}
{"snap_token":"MwMAAAAAAAA="}
READ    = {"can":"CHECK_RESULT_ALLOWED","metadata":{"check_count":2}}
COMMENT = {"can":"CHECK_RESULT_DENIED","metadata":{"check_count":2}}
EDIT    = {"can":"CHECK_RESULT_DENIED","metadata":{"check_count":2}}
READ    = {"can":"CHECK_RESULT_ALLOWED","metadata":{"check_count":1}}
COMMENT = {"can":"CHECK_RESULT_DENIED","metadata":{"check_count":1}}
EDIT    = {"can":"CHECK_RESULT_DENIED","metadata":{"check_count":1}}

And here is an example output of a bad run:

{"schema_version":"d3svat7iceas738hr2b0"}
{"snap_token":"DQMAAAAAAAA="}
READ    = {"can":"CHECK_RESULT_ALLOWED","metadata":{"check_count":2}}
COMMENT = {"can":"CHECK_RESULT_ALLOWED","metadata":{"check_count":2}}
EDIT    = {"can":"CHECK_RESULT_ALLOWED","metadata":{"check_count":2}}
{"snap_token":"DgMAAAAAAAA="}
{"snap_token":"DwMAAAAAAAA="}
READ    = {"can":"CHECK_RESULT_ALLOWED","metadata":{"check_count":2}}
COMMENT = {"can":"CHECK_RESULT_ALLOWED","metadata":{"check_count":2}}
EDIT    = {"can":"CHECK_RESULT_DENIED","metadata":{"check_count":2}}
READ    = {"can":"CHECK_RESULT_ALLOWED","metadata":{"check_count":1}}
COMMENT = {"can":"CHECK_RESULT_ALLOWED","metadata":{"check_count":1}}
EDIT    = {"can":"CHECK_RESULT_DENIED","metadata":{"check_count":1}}

Here is the DB dump showing that several transactions get same snapshot on bad runs. Good runs get different snapshot.
Image
Image

Expected behavior
All runs are good

Workaround
For those looking for a workaround until this is fixed: looks like the only guaranteed way to have the actual data in your "check" requests is to make additional fake write after every actual write — this touches the snapshot and makes your actual write visible.

Environment (please complete the following information, because it helps us investigate better):

  • OS: macOS 14.3.1
  • Permify: 1.4.6

Metadata

Metadata

Labels

bugSometing isn't working

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions