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
50 changes: 50 additions & 0 deletions platform-api/internal/repository/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*
* Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/

package repository

import "strings"

// IsUniqueViolation reports whether err is a database unique-constraint (or
// primary-key) violation. It is dialect-aware across the three databases the
// control plane supports — SQLite, PostgreSQL and SQL Server — matching on the
// stable substrings/error codes each driver surfaces.
//
// Detection is by err.Error() substring, so a violation wrapped with
// fmt.Errorf("...: %w", err) is still detected without unwrapping.
func IsUniqueViolation(err error) bool {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mm, so we have to update this func when we introduce a new DB type, neh

@thivindu thivindu Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, since we are throwing the raw error from the repository layer and we need to catch this specific error.

if err == nil {
return false
}
s := err.Error()
switch {
// SQLite: "UNIQUE constraint failed: <table>.<column>"
case strings.Contains(s, "UNIQUE constraint failed"):
return true
// PostgreSQL (pgx): SQLSTATE 23505, "duplicate key value violates unique constraint".
case strings.Contains(s, "duplicate key value violates unique constraint"),
strings.Contains(s, "23505"):
return true
// SQL Server: error 2627 ("Violation of UNIQUE KEY constraint" / PRIMARY KEY) and
// error 2601 ("Cannot insert duplicate key row in object ... with unique index").
case strings.Contains(s, "Violation of UNIQUE KEY constraint"),
strings.Contains(s, "Cannot insert duplicate key"):
return true
default:
return false
}
}
49 changes: 49 additions & 0 deletions platform-api/internal/repository/errors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*
* Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/

package repository

import (
"errors"
"fmt"
"testing"
)

func TestIsUniqueViolation(t *testing.T) {
cases := []struct {
name string
err error
want bool
}{
{"nil", nil, false},
{"unrelated", errors.New("connection refused"), false},
{"sqlite", errors.New("UNIQUE constraint failed: mcp_proxies.organization_uuid, mcp_proxies.handle"), true},
{"postgres message", errors.New(`ERROR: duplicate key value violates unique constraint "artifacts_org_handle_key" (SQLSTATE 23505)`), true},
{"postgres sqlstate only", errors.New("SQLSTATE 23505"), true},
{"sqlserver 2627", errors.New("mssql: Violation of UNIQUE KEY constraint 'UQ_artifacts'. Cannot insert duplicate key in object 'dbo.artifacts'."), true},
{"sqlserver 2601", errors.New("mssql: Cannot insert duplicate key row in object 'dbo.artifacts' with unique index 'IX_artifacts_handle'."), true},
// A violation wrapped with %w (as the per-kind importers do) must still be detected.
{"wrapped sqlite", fmt.Errorf("failed to create MCP proxy: %w", errors.New("UNIQUE constraint failed: mcp_proxies.handle")), true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := IsUniqueViolation(tc.err); got != tc.want {
t.Errorf("IsUniqueViolation(%v) = %v, want %v", tc.err, got, tc.want)
}
})
}
}
16 changes: 3 additions & 13 deletions platform-api/internal/repository/user_identity_mapping.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,18 +147,8 @@ func dedupeNonEmpty(ids []string) []string {
return unique
}

// isUniqueViolation detects DB unique-constraint violations across SQLite and PostgreSQL.
// isUniqueViolation detects DB unique-constraint violations. It delegates to the
// shared, dialect-aware IsUniqueViolation (SQLite, PostgreSQL and SQL Server).
func isUniqueViolation(err error) bool {
if err == nil {
return false
}
s := err.Error()
if strings.Contains(s, "UNIQUE constraint failed") {
return true
}
if strings.Contains(s, "duplicate key value violates unique constraint") ||
strings.Contains(s, "23505") {
return true
}
return false
return IsUniqueViolation(err)
}
89 changes: 52 additions & 37 deletions platform-api/internal/service/artifact_import.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,10 @@ func (s *ArtifactImportService) getGatewayForOrg(orgID, gatewayID string) (*mode
return gateway, nil
}

// importConflictMaxRetries bounds how many times importValidated re-resolves and retries a
// single artifact import after a unique-constraint violation
const importConflictMaxRetries = 3

// importValidated imports a single artifact, assuming the gateway has already been resolved
// and validated for the org (so a batch validates the gateway once instead of per artifact).
func (s *ArtifactImportService) importValidated(orgID, gatewayID string, req dto.ImportGatewayArtifactRequest) (*dto.ImportGatewayArtifactResponse, error) {
Expand Down Expand Up @@ -268,14 +272,52 @@ func (s *ArtifactImportService) importValidated(orgID, gatewayID string, req dto
ictx.ProjectID = project.ID
}

// The control plane owns the artifact UUID; it does NOT reuse the data-plane UUID
// (the gateway mints a fresh one whenever the artifact is recreated). Match an
// already-imported artifact by its handle, which is unique per organization and
// stable across delete/recreate. When found, reuse its CP UUID (so a recreate
// re-attaches to the same artifact and only adds a new deployment); otherwise mint
// a new CP UUID. Org-level kinds (e.g. templates) are not in the artifacts table —
// GetByHandle returns nil and the importer resolves existence itself.
// Resolve the existing artifact by handle, decide the last-in-wins write mode, and run the
// per-kind importer — retrying on a unique-constraint violation to close a TOCTOU race.
handle := utils.ImportHandle(ictx.Configuration)
var result *ImportResult
var err error
for attempt := 0; ; attempt++ {
result, err = s.resolveAndImport(importer, ictx, handle, orgID, kind, req.DeployedAt)
if err == nil {
break
}
if attempt < importConflictMaxRetries && repository.IsUniqueViolation(err) {
s.slogger.Info("Concurrent same-handle import detected; re-resolving and retrying as update",
"kind", kind, "handle", handle, "orgId", orgID, "attempt", attempt+1)
continue
}
return nil, err
}

// Persist deployment + status for deployable kinds (everything except templates).
if result.Deployable {
if err := s.writeDeployment(ictx, result.ID); err != nil {
return nil, err
}
}

return &dto.ImportGatewayArtifactResponse{
ID: result.ID,
Status: req.Status,
Origin: constants.OriginDP,
CreatedAt: req.CreatedAt,
UpdatedAt: req.UpdatedAt,
DeployedAt: req.DeployedAt,
DeployedVersion: result.DeployedVersion,
}, nil
}

// resolveAndImport resolves the existing artifact by handle, computes the last-in-wins
// metadata write mode for this push, and runs the per-kind importer once.
func (s *ArtifactImportService) resolveAndImport(
importer GatewayArtifactImporter,
ictx *ImportContext,
handle, orgID, kind string,
incomingDeployedAt *time.Time,
) (*ImportResult, error) {
// The control plane owns the artifact UUID; it does NOT reuse the data-plane UUID
// (the gateway mints a fresh one whenever the artifact is recreated).
existing, err := s.artifactRepo.GetByHandle(handle, orgID)
if err != nil {
return nil, fmt.Errorf("failed to look up existing artifact: %w", err)
Expand All @@ -290,14 +332,10 @@ func (s *ArtifactImportService) importValidated(orgID, gatewayID string, req dto
ictx.Existing = existing
} else {
ictx.ID = uuid.NewString()
ictx.Existing = nil
}

// Decide the working-copy (metadata) write mode using last-in-wins by deployment time.
// The "latest deployment" watermark is derived from the artifact's existing deployment
// rows (deployments.created_at, set to each push's gateway deployment time): a push wins
// only when its deployment time is later than every recorded deployment of the artifact.
// Org-level kinds not backed by the artifacts table (templates) have existing == nil here
// and recompute the decision themselves in the importer using their own watermark.
var currentOrigin string
var currentDeployedAt *time.Time
if existing != nil {
Expand All @@ -308,32 +346,9 @@ func (s *ArtifactImportService) importValidated(orgID, gatewayID string, req dto
}
currentDeployedAt = latest
}
ictx.MetadataMode = utils.DecideMetadataWrite(existing == nil, currentOrigin, currentDeployedAt, req.DeployedAt)

result, err := importer.Import(ictx)
if err != nil {
return nil, err
}
ictx.MetadataMode = utils.DecideMetadataWrite(existing == nil, currentOrigin, currentDeployedAt, incomingDeployedAt)

// Persist deployment + status for deployable kinds (everything except templates). This
// runs for every non-undeploy push — including a stale (utils.SkipWorkingCopy) one — so each
// push is recorded as a deployment (with created_at = the gateway deployment time) and
// each gateway's own deployment status stays accurate even when its push lost the race.
if result.Deployable {
if err := s.writeDeployment(ictx, result.ID); err != nil {
return nil, err
}
}

return &dto.ImportGatewayArtifactResponse{
ID: result.ID,
Status: req.Status,
Origin: constants.OriginDP,
CreatedAt: req.CreatedAt,
UpdatedAt: req.UpdatedAt,
DeployedAt: req.DeployedAt,
DeployedVersion: result.DeployedVersion,
}, nil
return importer.Import(ictx)
}

// writeDeployment records the immutable deployment artifact, upserts the current
Expand Down
Loading
Loading