fix(api): retry item creation on duplicate part number collision

The sequence counter (sequences_by_name table) can get out of sync
with the items table if items were seeded/imported directly or if a
previous create failed after incrementing the sequence but before
the insert committed. This causes Generate() to return a part number
that already exists, hitting the unique constraint on items.part_number.

Add a retry loop (up to 5 attempts) in HandleCreateItem that detects
PostgreSQL unique violation errors (SQLSTATE 23505) via pgconn.PgError
and retries with the next sequence value. Non-duplicate errors still
fail immediately. If all retries are exhausted, returns 409 Conflict
instead of 500.
This commit is contained in:
Zoe Forbes
2026-02-01 14:36:32 -06:00
parent 071cba2ef9
commit 3a67d2082b

View File

@@ -3,6 +3,7 @@ package api
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"os" "os"
@@ -12,6 +13,7 @@ import (
"github.com/alexedwards/scs/v2" "github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/kindredsystems/silo/internal/auth" "github.com/kindredsystems/silo/internal/auth"
"github.com/kindredsystems/silo/internal/config" "github.com/kindredsystems/silo/internal/config"
"github.com/kindredsystems/silo/internal/db" "github.com/kindredsystems/silo/internal/db"
@@ -358,13 +360,6 @@ func (s *Server) HandleCreateItem(w http.ResponseWriter, r *http.Request) {
}, },
} }
partNumber, err := s.partgen.Generate(ctx, input)
if err != nil {
s.logger.Error().Err(err).Msg("failed to generate part number")
writeError(w, http.StatusBadRequest, "generation_failed", err.Error())
return
}
// Determine item type from category // Determine item type from category
itemType := "part" itemType := "part"
if len(req.Category) > 0 { if len(req.Category) > 0 {
@@ -376,32 +371,64 @@ func (s *Server) HandleCreateItem(w http.ResponseWriter, r *http.Request) {
} }
} }
// Create item
item := &db.Item{
PartNumber: partNumber,
ItemType: itemType,
Description: req.Description,
SourcingType: req.SourcingType,
SourcingLink: req.SourcingLink,
LongDescription: req.LongDescription,
StandardCost: req.StandardCost,
}
if user := auth.UserFromContext(ctx); user != nil {
item.CreatedBy = &user.Username
}
properties := req.Properties properties := req.Properties
if properties == nil { if properties == nil {
properties = make(map[string]any) properties = make(map[string]any)
} }
properties["category"] = req.Category properties["category"] = req.Category
if err := s.items.Create(ctx, item, properties); err != nil { // Retry loop: if the generated part number collides with an existing
// item (sequence counter out of sync), generate a new one and retry.
const maxRetries = 5
var item *db.Item
for attempt := 0; attempt < maxRetries; attempt++ {
partNumber, err := s.partgen.Generate(ctx, input)
if err != nil {
s.logger.Error().Err(err).Msg("failed to generate part number")
writeError(w, http.StatusBadRequest, "generation_failed", err.Error())
return
}
item = &db.Item{
PartNumber: partNumber,
ItemType: itemType,
Description: req.Description,
SourcingType: req.SourcingType,
SourcingLink: req.SourcingLink,
LongDescription: req.LongDescription,
StandardCost: req.StandardCost,
}
if user := auth.UserFromContext(ctx); user != nil {
item.CreatedBy = &user.Username
}
err = s.items.Create(ctx, item, properties)
if err == nil {
break // success
}
// Check if this is a duplicate key error — retry with next sequence
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
s.logger.Warn().
Str("part_number", partNumber).
Int("attempt", attempt+1).
Msg("duplicate part number, retrying with next sequence value")
continue
}
// Non-duplicate error, fail immediately
s.logger.Error().Err(err).Msg("failed to create item") s.logger.Error().Err(err).Msg("failed to create item")
writeError(w, http.StatusInternalServerError, "create_failed", err.Error()) writeError(w, http.StatusInternalServerError, "create_failed", err.Error())
return return
} }
if item == nil || item.ID == "" {
s.logger.Error().Int("retries", maxRetries).Msg("exhausted retries for part number generation")
writeError(w, http.StatusConflict, "duplicate_part_number", "Could not generate a unique part number after multiple attempts")
return
}
// Tag item with projects if provided // Tag item with projects if provided
if len(req.Projects) > 0 { if len(req.Projects) > 0 {
for _, projectCode := range req.Projects { for _, projectCode := range req.Projects {