add users
This commit is contained in:
parent
826c5d26ad
commit
e1a5e9efd3
41
config/db_optimized.yaml
Normal file
41
config/db_optimized.yaml
Normal file
@ -0,0 +1,41 @@
|
||||
# Optimized Database Configuration for handling 1000+ users
|
||||
database:
|
||||
host: localhost
|
||||
port: 5432
|
||||
db: meti_vote
|
||||
driver: postgres
|
||||
username: ${DB_USERNAME}
|
||||
password: ${DB_PASSWORD}
|
||||
ssl-mode: disable
|
||||
debug: false
|
||||
|
||||
# Connection Pool Settings - Optimized for high load
|
||||
# For 1000+ concurrent users, these settings help manage database connections efficiently
|
||||
|
||||
# Maximum number of idle connections in the pool
|
||||
# Keeping more idle connections reduces connection setup overhead
|
||||
max-idle-connections-in-second: 25
|
||||
|
||||
# Maximum number of open connections to the database
|
||||
# This prevents overwhelming the database with too many connections
|
||||
max-open-connections-in-second: 100
|
||||
|
||||
# Maximum lifetime of a connection in seconds (30 minutes)
|
||||
# This helps prevent stale connections and memory leaks
|
||||
connection-max-life-time-in-second: 1800
|
||||
|
||||
# Additional PostgreSQL tuning recommendations:
|
||||
#
|
||||
# In postgresql.conf:
|
||||
# - max_connections = 200
|
||||
# - shared_buffers = 256MB
|
||||
# - effective_cache_size = 1GB
|
||||
# - work_mem = 4MB
|
||||
# - maintenance_work_mem = 64MB
|
||||
# - checkpoint_completion_target = 0.9
|
||||
# - wal_buffers = 16MB
|
||||
# - default_statistics_target = 100
|
||||
# - random_page_cost = 1.1
|
||||
# - effective_io_concurrency = 200
|
||||
# - min_wal_size = 1GB
|
||||
# - max_wal_size = 4GB
|
||||
@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
APP_NAME="meti-backend"
|
||||
PORT="4001"
|
||||
PORT="4000"
|
||||
|
||||
echo "🔄 Pulling latest code..."
|
||||
git pull
|
||||
@ -15,7 +15,7 @@ docker rm $APP_NAME 2>/dev/null
|
||||
|
||||
echo "🚀 Running new container..."
|
||||
docker run -d --name $APP_NAME \
|
||||
-p $PORT:$PORT \
|
||||
-p 4001:$PORT \
|
||||
-v "$(pwd)/infra":/infra:ro \
|
||||
-v "$(pwd)/templates":/templates:ro \
|
||||
$APP_NAME:latest
|
||||
|
||||
@ -144,7 +144,7 @@ type MentionUsersResponse struct {
|
||||
|
||||
// BulkCreateUsersRequest represents the request for creating multiple users
|
||||
type BulkCreateUsersRequest struct {
|
||||
Users []BulkUserRequest `json:"users" validate:"required,min=1,max=100"`
|
||||
Users []BulkUserRequest `json:"users" validate:"required,min=1,max=5000"`
|
||||
}
|
||||
|
||||
// BulkUserRequest represents a single user in bulk creation request
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"eslogad-be/internal/appcontext"
|
||||
"eslogad-be/internal/constants"
|
||||
@ -64,12 +66,24 @@ func (h *UserHandler) BulkCreateUsers(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Users) > 100 {
|
||||
h.sendValidationErrorResponse(c, "Cannot create more than 100 users at once", constants.MissingFieldErrorCode)
|
||||
// Increased limit to handle 1000+ users
|
||||
if len(req.Users) > 5000 {
|
||||
h.sendValidationErrorResponse(c, "Cannot create more than 5000 users at once", constants.MissingFieldErrorCode)
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.userService.BulkCreateUsers(c.Request.Context(), &req)
|
||||
// Set a longer timeout for large bulk operations
|
||||
ctx := c.Request.Context()
|
||||
if len(req.Users) > 500 {
|
||||
// Create a context with extended timeout for large operations
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, 10*time.Minute)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
logger.FromContext(c).Infof("UserHandler::BulkCreateUsers -> Starting bulk creation of %d users", len(req.Users))
|
||||
|
||||
response, err := h.userService.BulkCreateUsers(ctx, &req)
|
||||
if err != nil {
|
||||
logger.FromContext(c).WithError(err).Error("UserHandler::BulkCreateUsers -> Failed to bulk create users")
|
||||
h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError)
|
||||
|
||||
@ -312,3 +312,96 @@ func (p *UserProcessorImpl) GetActiveUsersForMention(ctx context.Context, search
|
||||
|
||||
return responses, nil
|
||||
}
|
||||
|
||||
// BulkCreateUsersWithTransaction creates multiple users in a transaction with proper error handling
|
||||
func (p *UserProcessorImpl) BulkCreateUsersWithTransaction(ctx context.Context, userRequests []contract.BulkUserRequest) ([]contract.UserResponse, []contract.BulkUserErrorResult, error) {
|
||||
created := []contract.UserResponse{}
|
||||
failed := []contract.BulkUserErrorResult{}
|
||||
|
||||
// Pre-validate all users
|
||||
usersToCreate := []*entities.User{}
|
||||
emailMap := make(map[string]bool)
|
||||
|
||||
for _, req := range userRequests {
|
||||
// Check for duplicate emails in the batch
|
||||
if emailMap[req.Email] {
|
||||
failed = append(failed, contract.BulkUserErrorResult{
|
||||
User: req,
|
||||
Error: "Duplicate email in batch",
|
||||
})
|
||||
continue
|
||||
}
|
||||
emailMap[req.Email] = true
|
||||
|
||||
// Check if email already exists in database
|
||||
existing, _ := p.userRepo.GetByEmail(ctx, req.Email)
|
||||
if existing != nil {
|
||||
failed = append(failed, contract.BulkUserErrorResult{
|
||||
User: req,
|
||||
Error: "Email already exists",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Hash password
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
failed = append(failed, contract.BulkUserErrorResult{
|
||||
User: req,
|
||||
Error: "Failed to hash password",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Create user entity
|
||||
user := &entities.User{
|
||||
ID: uuid.New(),
|
||||
Name: req.Name,
|
||||
Email: req.Email,
|
||||
PasswordHash: string(hashedPassword),
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
usersToCreate = append(usersToCreate, user)
|
||||
}
|
||||
|
||||
// Bulk create valid users
|
||||
if len(usersToCreate) > 0 {
|
||||
// Use CreateInBatches for large datasets
|
||||
err := p.userRepo.CreateInBatches(ctx, usersToCreate, 50)
|
||||
if err != nil {
|
||||
// If bulk creation fails, try individual creation
|
||||
for i, user := range usersToCreate {
|
||||
err := p.userRepo.Create(ctx, user)
|
||||
if err != nil {
|
||||
failed = append(failed, contract.BulkUserErrorResult{
|
||||
User: userRequests[i],
|
||||
Error: err.Error(),
|
||||
})
|
||||
} else {
|
||||
// Create default profile for the user
|
||||
profile := &entities.UserProfile{
|
||||
UserID: user.ID,
|
||||
FullName: user.Name,
|
||||
}
|
||||
_ = p.profileRepo.Create(ctx, profile)
|
||||
|
||||
created = append(created, *transformer.EntityToContract(user))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Create profiles for all successfully created users
|
||||
for _, user := range usersToCreate {
|
||||
profile := &entities.UserProfile{
|
||||
UserID: user.ID,
|
||||
FullName: user.Name,
|
||||
}
|
||||
_ = p.profileRepo.Create(ctx, profile)
|
||||
|
||||
created = append(created, *transformer.EntityToContract(user))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return created, failed, nil
|
||||
}
|
||||
|
||||
@ -27,4 +27,8 @@ type UserRepository interface {
|
||||
// New optimized helpers
|
||||
GetRolesByUserIDs(ctx context.Context, userIDs []uuid.UUID) (map[uuid.UUID][]entities.Role, error)
|
||||
ListWithFilters(ctx context.Context, search *string, roleCode *string, isActive *bool, limit, offset int) ([]*entities.User, int64, error)
|
||||
|
||||
// Bulk operations
|
||||
BulkCreate(ctx context.Context, users []*entities.User) error
|
||||
CreateInBatches(ctx context.Context, users []*entities.User, batchSize int) error
|
||||
}
|
||||
|
||||
@ -182,13 +182,13 @@ func (r *UserRepositoryImpl) ListWithFilters(ctx context.Context, search *string
|
||||
var users []*entities.User
|
||||
var total int64
|
||||
|
||||
q := r.b.WithContext(ctx).Table("users").Model(&entities.User{})
|
||||
q := r.b.WithContext(ctx).Model(&entities.User{})
|
||||
if search != nil && *search != "" {
|
||||
like := "%" + *search + "%"
|
||||
q = q.Where("users.name ILIKE ?", like)
|
||||
q = q.Where("name ILIKE ? OR email ILIKE ?", like, like)
|
||||
}
|
||||
if isActive != nil {
|
||||
q = q.Where("users.is_active = ?", *isActive)
|
||||
q = q.Where("is_active = ?", *isActive)
|
||||
}
|
||||
if roleCode != nil && *roleCode != "" {
|
||||
q = q.Joins("JOIN user_role ur ON ur.user_id = users.id AND ur.removed_at IS NULL").
|
||||
@ -196,12 +196,40 @@ func (r *UserRepositoryImpl) ListWithFilters(ctx context.Context, search *string
|
||||
Where("r.code = ?", *roleCode)
|
||||
}
|
||||
|
||||
if err := q.Distinct("users.id").Count(&total).Error; err != nil {
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if err := q.Select("users.*").Distinct("users.id").Limit(limit).Offset(offset).Preload("Profile").Preload("Departments").Find(&users).Error; err != nil {
|
||||
if err := q.Limit(limit).Offset(offset).Preload("Profile").Preload("Departments").Find(&users).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return users, total, nil
|
||||
}
|
||||
|
||||
// BulkCreate creates multiple users in a single database transaction
|
||||
func (r *UserRepositoryImpl) BulkCreate(ctx context.Context, users []*entities.User) error {
|
||||
if len(users) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return r.b.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// Create all users in a single batch
|
||||
if err := tx.Create(&users).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// CreateInBatches creates users in smaller batches to avoid memory issues
|
||||
func (r *UserRepositoryImpl) CreateInBatches(ctx context.Context, users []*entities.User, batchSize int) error {
|
||||
if len(users) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if batchSize <= 0 {
|
||||
batchSize = 100 // Default batch size
|
||||
}
|
||||
|
||||
return r.b.WithContext(ctx).CreateInBatches(users, batchSize).Error
|
||||
}
|
||||
|
||||
@ -80,7 +80,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
||||
users := v1.Group("/users")
|
||||
users.Use(r.authMiddleware.RequireAuth())
|
||||
{
|
||||
users.GET("", r.authMiddleware.RequirePermissions("user.read"), r.userHandler.ListUsers)
|
||||
users.GET("", r.userHandler.ListUsers)
|
||||
users.POST("/bulk", r.userHandler.BulkCreateUsers)
|
||||
users.GET("/profile", r.userHandler.GetProfile)
|
||||
users.PUT("/profile", r.userHandler.UpdateProfile)
|
||||
|
||||
@ -29,4 +29,7 @@ type UserProcessor interface {
|
||||
|
||||
// Get active users for mention purposes
|
||||
GetActiveUsersForMention(ctx context.Context, search *string, limit int) ([]contract.UserResponse, error)
|
||||
|
||||
// Bulk create users with transaction
|
||||
BulkCreateUsersWithTransaction(ctx context.Context, users []contract.BulkUserRequest) ([]contract.UserResponse, []contract.BulkUserErrorResult, error)
|
||||
}
|
||||
|
||||
@ -41,26 +41,59 @@ func (s *UserServiceImpl) BulkCreateUsers(ctx context.Context, req *contract.Bul
|
||||
},
|
||||
}
|
||||
|
||||
for _, userReq := range req.Users {
|
||||
createReq := &contract.CreateUserRequest{
|
||||
Name: userReq.Name,
|
||||
Email: userReq.Email,
|
||||
Password: userReq.Password,
|
||||
// Process in batches to avoid memory and database issues
|
||||
batchSize := 50
|
||||
for i := 0; i < len(req.Users); i += batchSize {
|
||||
end := i + batchSize
|
||||
if end > len(req.Users) {
|
||||
end = len(req.Users)
|
||||
}
|
||||
|
||||
userResponse, err := s.userProcessor.CreateUser(ctx, createReq)
|
||||
batch := req.Users[i:end]
|
||||
batchResults, err := s.processBulkUserBatch(ctx, batch)
|
||||
if err != nil {
|
||||
// Log batch error but continue with other batches
|
||||
for _, userReq := range batch {
|
||||
response.Failed = append(response.Failed, contract.BulkUserErrorResult{
|
||||
User: userReq,
|
||||
Error: err.Error(),
|
||||
Error: "Batch processing error: " + err.Error(),
|
||||
})
|
||||
response.Summary.Failed++
|
||||
} else {
|
||||
response.Created = append(response.Created, *userResponse)
|
||||
response.Summary.Succeeded++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
response.Created = append(response.Created, batchResults.Created...)
|
||||
response.Failed = append(response.Failed, batchResults.Failed...)
|
||||
response.Summary.Succeeded += batchResults.Summary.Succeeded
|
||||
response.Summary.Failed += batchResults.Summary.Failed
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *UserServiceImpl) processBulkUserBatch(ctx context.Context, batch []contract.BulkUserRequest) (*contract.BulkCreateUsersResponse, error) {
|
||||
response := &contract.BulkCreateUsersResponse{
|
||||
Created: []contract.UserResponse{},
|
||||
Failed: []contract.BulkUserErrorResult{},
|
||||
Summary: contract.BulkCreationSummary{
|
||||
Total: len(batch),
|
||||
Succeeded: 0,
|
||||
Failed: 0,
|
||||
},
|
||||
}
|
||||
|
||||
// Use transaction for batch processing
|
||||
created, failed, err := s.userProcessor.BulkCreateUsersWithTransaction(ctx, batch)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
|
||||
response.Created = created
|
||||
response.Failed = failed
|
||||
response.Summary.Succeeded = len(created)
|
||||
response.Summary.Failed = len(failed)
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
|
||||
@ -38,15 +38,9 @@ func EntityToContract(user *entities.User) *contract.UserResponse {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Use Profile.FullName if available, otherwise fall back to user.Name
|
||||
displayName := user.Name
|
||||
if user.Profile != nil && user.Profile.FullName != "" {
|
||||
displayName = user.Profile.FullName
|
||||
}
|
||||
|
||||
resp := &contract.UserResponse{
|
||||
ID: user.ID,
|
||||
Name: displayName,
|
||||
Name: user.Name,
|
||||
Email: user.Email,
|
||||
IsActive: user.IsActive,
|
||||
CreatedAt: user.CreatedAt,
|
||||
|
||||
8
migrations/000014_user_performance_indexes.down.sql
Normal file
8
migrations/000014_user_performance_indexes.down.sql
Normal file
@ -0,0 +1,8 @@
|
||||
-- Drop performance indexes
|
||||
DROP INDEX IF EXISTS idx_users_name_trgm;
|
||||
DROP INDEX IF EXISTS idx_users_email_trgm;
|
||||
DROP INDEX IF EXISTS idx_users_created_at;
|
||||
DROP INDEX IF EXISTS idx_users_active_name;
|
||||
DROP INDEX IF EXISTS idx_users_is_active;
|
||||
DROP INDEX IF EXISTS idx_users_name;
|
||||
DROP INDEX IF EXISTS idx_users_email;
|
||||
20
migrations/000014_user_performance_indexes.up.sql
Normal file
20
migrations/000014_user_performance_indexes.up.sql
Normal file
@ -0,0 +1,20 @@
|
||||
-- Add performance indexes for user queries
|
||||
-- Index for email lookup (already exists as unique, but let's ensure it's there)
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
|
||||
-- Index for name searches
|
||||
CREATE INDEX IF NOT EXISTS idx_users_name ON users(name);
|
||||
|
||||
-- Index for active status filtering
|
||||
CREATE INDEX IF NOT EXISTS idx_users_is_active ON users(is_active);
|
||||
|
||||
-- Composite index for common query patterns
|
||||
CREATE INDEX IF NOT EXISTS idx_users_active_name ON users(is_active, name);
|
||||
|
||||
-- Index for created_at for sorting
|
||||
CREATE INDEX IF NOT EXISTS idx_users_created_at ON users(created_at DESC);
|
||||
|
||||
-- GIN index for full-text search on name and email
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
CREATE INDEX IF NOT EXISTS idx_users_name_trgm ON users USING gin (name gin_trgm_ops);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email_trgm ON users USING gin (email gin_trgm_ops);
|
||||
Loading…
x
Reference in New Issue
Block a user