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 (
|
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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user