Skip to content

A high-performance, concurrency-safe Go library for flash sale inventory deduction using Redis and Lua scripts.

License

Notifications You must be signed in to change notification settings

ahadiihsan/rack

Inventory Sentinel

Go Reference Go Report Card License: MIT

A high-performance, concurrency-safe Go library for flash sale inventory deduction using Redis and Lua scripts.

Features

  • πŸš€ Atomic Operations: Uses Redis Lua scripts for true atomicity under high concurrency
  • πŸ”’ No Overselling: Guaranteed stock protection even with thousands of concurrent requests
  • ⏱️ Auto-Expiring Reservations: TTL-based reservations prevent stock lockup
  • πŸ“Š Observability: Built-in Prometheus metrics and structured logging (slog)
  • 🏷️ Namespace Isolation: Run multiple flash sales on the same Redis instance
  • βœ… Idempotent Operations: Safe to retry commits and rollbacks

Installation

go get github.com/ahadiihsanrasyidin/inventory-sentinel

Quick Start

For a complete runnable example, see examples/basic/main.go.

package main

import (
    "context"
    "log"
    "time"

    sentinel "github.com/ahadiihsanrasyidin/inventory-sentinel"
    "github.com/redis/go-redis/v9"
)

func main() {
    ctx := context.Background()

    // Connect to Redis
    client := redis.NewClient(&redis.Options{
        Addr: "localhost:6379",
    })

    // Create sentinel with namespace
    s := sentinel.New(client,
        sentinel.WithNamespace("flash_sale"),
    )

    // Initialize stock (typically done by admin service)
    s.SetStock(ctx, "iphone15", 100)

    // Reserve 2 units for 5 minutes
    reservationID, err := s.Reserve(ctx, "iphone15", 2, 5*time.Minute)
    if err != nil {
        if err == sentinel.ErrInsufficientStock {
            log.Println("Sorry, item is sold out!")
            return
        }
        log.Fatal(err)
    }
    log.Printf("Reserved! ID: %s\n", reservationID)

    // Process payment...
    paymentSuccess := processPayment()

    if paymentSuccess {
        // Commit the reservation
        if err := s.Commit(ctx, reservationID); err != nil {
            log.Fatal(err)
        }
        log.Println("Purchase complete!")
    } else {
        // Rollback - return stock to pool
        if err := s.Rollback(ctx, reservationID); err != nil {
            log.Fatal(err)
        }
        log.Println("Payment failed, stock restored")
    }
}

func processPayment() bool {
    // Your payment logic here
    return true
}

Architecture

This library implements the Two-Phase Reservation Pattern:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     Reserve     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     Lua Script     β”Œβ”€β”€β”€β”€β”€β”€β”€β”
β”‚  Client  β”‚ ──────────────► β”‚   Sentinel   β”‚ ─────────────────► β”‚ Redis β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                    β””β”€β”€β”€β”€β”€β”€β”€β”˜
     β”‚                              β”‚                                 β”‚
     β”‚                              β”‚ ◄─── reservationID ──────────────
     β”‚                              β”‚                                 β”‚
     β”‚                       (process payment)                        β”‚
     β”‚                              β”‚                                 β”‚
     β”œβ”€β”€ Commit ──────────────────► β”‚ ───── Lua: delete key ─────────►│
     β”‚   (if payment ok)            β”‚                                 β”‚
     β”‚                              β”‚                                 β”‚
     └── Rollback ────────────────► β”‚ ───── Lua: restore stock ──────►│
         (if payment fails)

Why Lua Scripts?

Traditional WATCH/MULTI transactions in Redis require optimistic locking with client-side retries. Under the extreme concurrency of flash sales (thousands of requests per second), this causes:

  • Retry storms as transactions fail and retry
  • Cascading failures under load
  • Higher latency from multiple round-trips

Lua scripts execute atomically on the Redis server, eliminating:

  • The need for client-side retries
  • Multiple round-trips per operation
  • Race conditions between check and update

When & How to Use

1. Is it only for Flash Sales?

Strictly speaking: No. You can use it for everything. Realistically: You usually use it only for "Hot" items.

Why?

  • Regular Items (Low Traffic): Your PostgreSQL/MySQL database can easily handle 50 people buying a standard t-shirt at the same time. Using the Sentinel adds unnecessary complexity (maintenance, syncing) for items that don't need it.
  • Flash Sale Items (High Traffic): When 50,000 people try to buy an iPhone 15 at the exact same second, your database locks will cause the site to crash. This is where you switch to the Sentinel.

2. Can it handle Thousands of SKUs?

Yes, easily. Redis is incredibly fast.

  • Capacity: You can store millions of SKUs in Redis. A simple key like stock:sku_123 takes up negligible RAM.
  • Performance: Redis can handle ~100,000 operations per second. 10,000 SKUs is "nothing" to Redis.

So, the limit is not performance, the limit is operational complexity (keeping Redis and your main Database in sync).


3. How do we actually use this? (The Implementation Flow)

This is the "Engineer" part. You don't just "turn it on." You build a Hybrid Flow.

Imagine you are the backend for the "Checkout Service."

Phase 1: The "Warm Up" (Before the Sale)

You don't want to query the SQL DB for the stock during the flash sale.

  • Action: 1 hour before the sale, a cron job (or admin dashboard) pushes the stock count for the specific Flash Sale items from SQL -> Redis Sentinel.
  • Status: Redis now has sku:iphone_15 = 1000.

Phase 2: The Checkout (The "Guard" Logic)

When a user clicks "Checkout," your code makes a decision:

func Checkout(user User, cart Cart) {
    for _, item := range cart.Items {
        // Step 1: Check if this item is protected by Sentinel
        if sentinel.IsProtected(item.SkuID) {
            // FAST PATH: Flash Sale Item
            // 1. Try to reserve in Redis (0.5ms)
            reservationID, err := sentinel.Reserve(ctx, item.SkuID, item.Qty, 5*time.Minute)
            if err != nil {
                 return Error("Sold Out!") // Block request here. DB is never touched.
            }
            // 2. Attach reservation ID to the order context
            order.Metadata["reservation_id"] = reservationID
        } else {
            // SLOW PATH: Regular Item
            // Do standard DB check (SELECT FOR UPDATE)
        }
    }
    
    // Step 3: Proceed to Payment Gateway
    // If payment fails -> sentinel.Rollback(reservationID)
}

Phase 3: The Reconciliation (After Payment)

This is the most important part. The Sentinel is temporary. The SQL DB is permanent.

  1. User Pays: Payment Gateway returns "Success".
  2. Commit: Your backend calls sentinel.Commit(reservationID).
    • Note: In a pure "Sentinel" design, this Commit might just delete the temporary reservation key and permanently decrement the Redis counter.
  3. Async Sync: A background worker (or message queue like Kafka/RabbitMQ) sees the successful order and updates the SQL Database inventory to match reality.

Summary: The "Smart" Strategy

"I designed this library to be progressive.

  1. For 99% of products (long-tail), we use standard ACID database transactions.
  2. For the top 1% of viral products, we 'promote' them to the Sentinel.

This library allows the Platform Team to dynamically toggle 'High Concurrency Mode' for specific SKUs without redeploying the backend."

Configuration Options

// Namespace for key isolation
sentinel.WithNamespace("flash_sale")

// Structured logging with slog
sentinel.WithLogger(slog.Default())

// Prometheus metrics
metrics := sentinel.NewMetrics(sentinel.DefaultMetricsOpts())
metrics.MustRegister(prometheus.DefaultRegisterer)
sentinel.WithMetrics(metrics)

Prometheus Metrics

Metric Type Description
inventory_sentinel_reserve_total Counter Reserve attempts by status
inventory_sentinel_commit_total Counter Commit attempts by status
inventory_sentinel_rollback_total Counter Rollback attempts by status
inventory_sentinel_operation_duration_seconds Histogram Operation latency

Error Handling

var (
    ErrInsufficientStock         // Stock < requested amount
    ErrReservationNotFound       // Reservation expired or doesn't exist
    ErrReservationAlreadyCommitted // Double-commit attempt
    ErrInvalidAmount             // Amount <= 0
    ErrInvalidTTL                // TTL <= 0
    ErrEmptyKey                  // Empty SKU key
)

All errors can be checked using errors.Is():

if errors.Is(err, sentinel.ErrInsufficientStock) {
    // Handle sold out
}

Testing

The test suite uses testcontainers-go to run tests against a real Redis instance:

# Run all tests (requires Docker)
go test -v -race ./...

# Run specific concurrent load test
go test -v -run TestConcurrentReserve ./...

The concurrent load test spawns 50 goroutines competing for 1 item, verifying that exactly 1 succeeds.

License

MIT License - see LICENSE for details.

About

A high-performance, concurrency-safe Go library for flash sale inventory deduction using Redis and Lua scripts.

Topics

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published