Skip to content

Protect concurrent grant updates (fix for error pq: tuple concurrently updated) #520

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
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
6 changes: 6 additions & 0 deletions examples/issues/178/dev.tfrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
provider_installation {
dev_overrides {
"cyrilgdn/postgresql" = "../../../"
}
direct {}
}
4 changes: 4 additions & 0 deletions examples/issues/178/locals.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
locals {
read_only_users = toset([for i in range(var.user_ro_count) : "user_ro_${i}"])
read_write_users = toset([for i in range(var.user_rw_count) : "user_rw_${i}"])
}
191 changes: 191 additions & 0 deletions examples/issues/178/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
terraform {
required_providers {
docker = {
source = "kreuzwerker/docker"
version = ">= 3.0.2"
}
postgresql = {
source = "cyrilgdn/postgresql"
version = ">= 1.25"
}
}
}

provider "docker" {
host = var.docker_host
}

resource "docker_image" "postgres" {
name = var.postgres_image
keep_locally = var.keep_image
}

resource "docker_container" "postgres" {
image = docker_image.postgres.image_id
name = "postgres"
wait = true
ports {
internal = var.POSTGRES_PORT
external = var.POSTGRES_PORT
}
env = [
"POSTGRES_PASSWORD=${var.POSTGRES_PASSWORD}"
]
healthcheck {
test = ["CMD-SHELL", "pg_isready"]
interval = "5s"
timeout = "5s"
retries = 5
start_period = "2s"
}
upload {
file = "/docker-entrypoint-initdb.d/mock-tables.sql"
content = <<EOS
CREATE DATABASE "test" OWNER "${var.POSTGRES_DBNAME}";
\connect ${var.POSTGRES_DBNAME}

DO $$
DECLARE
table_count int := ${var.table_count};
BEGIN
FOR count IN 0..table_count LOOP
EXECUTE format('CREATE TABLE table_%s (test int)', count);
END LOOP;
END $$;
EOS
}
}

provider "postgresql" {
scheme = "postgres"
host = var.POSTGRES_HOST
port = docker_container.postgres.ports[0].external
database = var.POSTGRES_PASSWORD
username = var.POSTGRES_PASSWORD
password = var.POSTGRES_PASSWORD
sslmode = "disable"
superuser = false
lock_grants = true
}

resource "postgresql_role" "readonly_role" {
name = "readonly"
login = false
superuser = false
create_database = false
create_role = false
inherit = false
replication = false
connection_limit = -1
}

resource "postgresql_role" "readwrite_role" {
name = "readwrite"
login = false
superuser = false
create_database = false
create_role = false
inherit = false
replication = false
connection_limit = -1
}

resource "postgresql_grant" "readonly_role" {
database = var.POSTGRES_DBNAME
role = postgresql_role.readonly_role.name
object_type = "table"
schema = "public"
privileges = ["SELECT"]
with_grant_option = false
}

resource "postgresql_grant" "readwrite_role" {
database = var.POSTGRES_DBNAME
role = postgresql_role.readwrite_role.name
object_type = "table"
schema = "public"
privileges = ["SELECT", "INSERT", "UPDATE", "DELETE"]
with_grant_option = false
}

resource "postgresql_role" "readonly_users" {
for_each = local.read_only_users
name = each.value
roles = [postgresql_role.readonly_role.name]
login = true
superuser = false
create_database = false
create_role = false
inherit = true
replication = false
connection_limit = -1
}

resource "postgresql_role" "readwrite_users" {
for_each = local.read_write_users
name = each.value
roles = [postgresql_role.readonly_role.name]
login = true
superuser = false
create_database = false
create_role = false
inherit = true
replication = false
connection_limit = -1
}

resource "postgresql_grant" "connect_db_readonly_role" {
for_each = postgresql_role.readonly_users
database = var.POSTGRES_DBNAME
object_type = "database"
privileges = ["CREATE", "CONNECT"]
role = each.value.name
}

resource "postgresql_grant" "connect_db_readwrite_role" {
for_each = postgresql_role.readwrite_users
database = var.POSTGRES_DBNAME
object_type = "database"
privileges = ["CREATE", "CONNECT"]
role = each.value.name
}

resource "postgresql_grant" "usage_readonly_role" {
for_each = postgresql_role.readonly_users
database = var.POSTGRES_DBNAME
role = each.value.name
object_type = "schema"
schema = "public"
privileges = ["USAGE"]
with_grant_option = false
}

resource "postgresql_grant" "usage_readwrite_role" {
for_each = postgresql_role.readwrite_users
database = var.POSTGRES_DBNAME
role = each.value.name
object_type = "schema"
schema = "public"
privileges = ["USAGE"]
with_grant_option = false
}

resource "postgresql_grant" "select_readonly_role" {
for_each = postgresql_role.readonly_users
database = var.POSTGRES_DBNAME
role = each.value.name
object_type = "table"
schema = "public"
privileges = ["SELECT"]
with_grant_option = false
}

resource "postgresql_grant" "crud_readwrite_role" {
for_each = postgresql_role.readwrite_users
database = var.POSTGRES_DBNAME
role = each.value.name
object_type = "table"
schema = "public"
privileges = ["SELECT", "UPDATE", "INSERT", "DELETE"]
with_grant_option = false
}
3 changes: 3 additions & 0 deletions examples/issues/178/test.tftest.hcl
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
run "concurrent_grants" {
command = apply
}
73 changes: 73 additions & 0 deletions examples/issues/178/variables.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
variable "postgres_image" {
description = "Which postgres docker image to use"
default = "postgres:17"
type = string
sensitive = false
}

variable "docker_host" {
description = "Socket path to docker host to use for testing"
default = "unix:///var/run/docker.sock"
type = string
sensitive = false
}

variable "table_count" {
description = "Number of mock tables to create"
default = 300
type = number
sensitive = false
}

variable "user_ro_count" {
description = "Number of mock RO users to create"
default = 30
type = number
sensitive = false
}

variable "user_rw_count" {
description = "Number of mock RW users to create"
default = 30
type = number
sensitive = false
}

variable "POSTGRES_DBNAME" {
default = "postgres"
type = string
sensitive = false
}

variable "POSTGRES_USER" {
default = "postgres"
type = string
sensitive = false
}

variable "POSTGRES_PASSWORD" {
description = "Password for docker POSTGRES_USER"
default = "postgres"
type = string
sensitive = false
}

variable "POSTGRES_HOST" {
default = "127.0.0.1"
type = string
sensitive = false
}

variable "POSTGRES_PORT" {
description = "Which port postgres should listen on."
default = 5432
type = number
sensitive = false
}

variable "keep_image" {
description = "If true, then the Docker image won't be deleted on destroy operation. If this is false, it will delete the image from the docker local storage on destroy operation."
default = true
type = bool
sensitive = false
}
9 changes: 9 additions & 0 deletions postgresql/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,10 @@ func (db *DBConnection) isSuperuser() (bool, error) {
return superuser, nil
}

func (db *DBConnection) IsLockGrants() bool {
return db.client.IsLockGrants()
}

type ClientCertificateConfig struct {
CertificatePath string
KeyPath string
Expand All @@ -183,6 +187,7 @@ type Config struct {
SSLClientCert *ClientCertificateConfig
SSLRootCertPath string
GCPIAMImpersonateServiceAccount string
LockGrants bool
}

// Client struct holding connection string
Expand Down Expand Up @@ -333,6 +338,10 @@ func (c *Client) Connect() (*DBConnection, error) {
return conn, nil
}

func (c *Client) IsLockGrants() bool {
return c.config.LockGrants
}

// fingerprintCapabilities queries PostgreSQL to populate a local catalog of
// capabilities. This is only run once per Client.
func fingerprintCapabilities(db *sql.DB) (*semver.Version, error) {
Expand Down
11 changes: 10 additions & 1 deletion postgresql/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ package postgresql
import (
"context"
"fmt"
"os"

"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/sts"
"os"

"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
Expand All @@ -23,6 +24,7 @@ import (
const (
defaultProviderMaxOpenConnections = 20
defaultExpectedPostgreSQLVersion = "9.0.0"
defaultLockGrants = false
)

// Provider returns a terraform.ResourceProvider.
Expand Down Expand Up @@ -199,6 +201,12 @@ func Provider() *schema.Provider {
Description: "Specify the expected version of PostgreSQL.",
ValidateFunc: validateExpectedVersion,
},
"lock_grants": {
Type: schema.TypeBool,
Optional: true,
Default: defaultLockGrants,
Description: "Wrap GRANT/REVOKEs in a lock to avoid executing them in parallel.",
},
},

ResourcesMap: map[string]*schema.Resource{
Expand Down Expand Up @@ -382,6 +390,7 @@ func providerConfigure(d *schema.ResourceData) (interface{}, error) {
ExpectedVersion: version,
SSLRootCertPath: d.Get("sslrootcert").(string),
GCPIAMImpersonateServiceAccount: d.Get("gcp_iam_impersonate_service_account").(string),
LockGrants: d.Get("lock_grants").(bool),
}

if value, ok := d.GetOk("clientcert"); ok {
Expand Down
5 changes: 3 additions & 2 deletions postgresql/resource_postgresql_grant.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ func resourcePostgreSQLGrantCreateOrUpdate(db *DBConnection, d *schema.ResourceD
return err
}

if objectType == "database" {
if db.IsLockGrants() || objectType == "database" {
if err := pgLockDatabase(txn, database); err != nil {
return err
}
Expand Down Expand Up @@ -238,7 +238,8 @@ func resourcePostgreSQLGrantDelete(db *DBConnection, d *schema.ResourceData) err
}

objectType := d.Get("object_type").(string)
if objectType == "database" {

if db.IsLockGrants() || objectType == "database" {
if err := pgLockDatabase(txn, database); err != nil {
return err
}
Expand Down