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:
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/kindredsystems/silo/internal/auth"
|
||||
"github.com/kindredsystems/silo/internal/config"
|
||||
"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
|
||||
itemType := "part"
|
||||
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
|
||||
if properties == nil {
|
||||
properties = make(map[string]any)
|
||||
}
|
||||
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")
|
||||
writeError(w, http.StatusInternalServerError, "create_failed", err.Error())
|
||||
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
|
||||
if len(req.Projects) > 0 {
|
||||
for _, projectCode := range req.Projects {
|
||||
|
||||
Reference in New Issue
Block a user