Skip to content

Latest commit

 

History

History
938 lines (734 loc) · 23.1 KB

executor-pool.md

File metadata and controls

938 lines (734 loc) · 23.1 KB

Executor Pool: Separate Pools Architecture & Benchmarks

Executive Summary

s3db.js now features Separate Executor Pools - a revolutionary architecture where each Database instance gets its own independent executor pool. This design eliminates contention and delivers 40-50% performance improvements at scale while using 13x less memory.

Key Benefits:

  • 🚀 40-50% faster at medium/large scale (5K+ operations)
  • 💾 13x less memory at scale (88MB vs 1,142MB for 10K ops)
  • ⚡ Zero contention between concurrent databases
  • 🛡️ Zero throttling errors with intelligent concurrency control
  • 🔄 Intelligent retry with exponential backoff
  • 📊 Real-time metrics and monitoring per database
  • Default parallelism: 100 (up from 10, optimized for S3)

Architecture Evolution

The Journey to Separate Pools

Previous Approach 1: Shared Global Executor Pool

  • Single queue for ALL databases
  • Problem: Contention under concurrent loads
  • Problem: One database's load could starve others

Previous Approach 2: Promise.all (Uncontrolled)

  • Each operation created own pool
  • Problem: Uncontrolled parallelism (N × concurrency)
  • Problem: High memory usage (1,142MB for 10K ops)

Current Solution: Separate Executor Pools (BEST!)

  • Each Database gets independent executor pool
  • Zero contention between databases
  • Optimal memory usage (88MB for 10K ops)
  • 40-50% faster at scale

Why Not Shared Pools?

When multiple databases use a shared pool:

Database 1: [Waiting for pool capacity]
Database 2: [Waiting for pool capacity]
Database 3: [Using pool capacity]
Database 4: [Waiting for pool capacity]
...
Result: Underutilization and contention 😞

With Separate Pools:

Database 1: [Pool 1: 100 concurrent ops → FULL UTILIZATION]
Database 2: [Pool 2: 100 concurrent ops → FULL UTILIZATION]
Database 3: [Pool 3: 100 concurrent ops → FULL UTILIZATION]
Database 4: [Pool 4: 100 concurrent ops → FULL UTILIZATION]
...
Result: Zero contention, maximum efficiency 🚀

HTTP Agent Limitations

The AWS SDK uses HTTP agents with connection pooling:

// Default AWS SDK configuration
const agent = new https.Agent({
  maxSockets: 50,  // Per host
  keepAlive: true
})

Why this isn't enough:

  • TCP Connection ≠ Operation Concurrency

    • Agent manages TCP connections, not application-level operations
    • Can reuse 50 connections for 10,000+ concurrent operations
    • Operations still queue at S3, causing throttling
  • No Operation-Level Control

    • Can't prioritize critical operations
    • Can't implement custom retry logic per operation
    • No visibility into operation queue
  • Memory Still Uncontrolled

    • Agent doesn't limit concurrent operations
    • Each operation can consume memory independently
    • No backpressure mechanism

Solution: Separate Executor Pools Architecture

Design Principles

  1. Independent Pool Per Database

    • One executor pool instance per Database
    • Each database has complete independence
    • No queue contention between databases
    • Scales perfectly with concurrent databases
  2. Separate Pools Concurrency Model

    Database 1          Database 2          Database 3
    ┌──────────────┐   ┌──────────────┐   ┌──────────────┐
    │ Pool 1       │   │ Pool 2       │   │ Pool 3       │
    │ concurrency: │   │ concurrency: │   │ concurrency: │
    │ 100 ops      │   │ 100 ops      │   │ 100 ops      │
    │              │   │              │   │              │
    │ [Q]←100→[S3] │   │ [Q]←100→[S3] │   │ [Q]←100→[S3] │
    └──────────────┘   └──────────────┘   └──────────────┘
    
    Result: 300 concurrent S3 operations (100 each)
    NO CONTENTION, ZERO WAIT TIME
    
  3. Deferred Promise Pattern

    • Create promise, store resolve/reject callbacks
    • Return promise immediately to caller
    • Resolve when operation completes
  4. Adaptive Auto-Tuning (optional)

    • Monitors latency, memory, throughput
    • Adjusts concurrency dynamically
    • Prevents overload and optimizes performance

Core Features

1. Priority Queue

Higher priority operations execute first:

// Critical user-facing operation
await pool.enqueue(
  async () => await resource.get(userId),
  { priority: 100 }
)

// Background bulk operation
await pool.enqueue(
  async () => await resource.insert(bulkData),
  { priority: 0 }
)

2. Intelligent Retry

Exponential backoff with configurable retry logic:

const pool = new ExecutorPool({
  retries: 3,
  retryDelay: 1000,  // Base delay
  retryableErrors: ['NetworkError', 'SlowDown', 'ServiceUnavailable']
})

// Retries: 1000ms → 2000ms → 4000ms

3. Per-Operation Timeout

Prevent hung operations:

await pool.enqueue(
  async () => await slowOperation(),
  { timeout: 5000 }  // 5s max
)

4. Lifecycle Control

await pool.pause()   // Stop accepting new operations
pool.resume()        // Restart processing
await pool.drain()   // Wait for all operations to complete
pool.stop()          // Cancel pending operations

5. Real-Time Metrics

const stats = pool.getStats()
// {
//   queueSize: 150,
//   activeCount: 50,
//   processedCount: 5000,
//   errorCount: 3,
//   retryCount: 8
// }

const metrics = pool.getAggregateMetrics()
// {
//   avgExecution: 45ms,
//   p95Execution: 120ms,
//   throughput: 1200 ops/sec
// }

6. Event Emission

Monitor operations in real-time:

pool.on('pool:taskStarted', (task) => {
  console.log(`Starting: ${task.id}`)
})

pool.on('pool:taskCompleted', (task, result) => {
  console.log(`Completed: ${task.id} in ${task.duration}ms`)
})

pool.on('pool:taskError', (task, error) => {
  console.error(`Failed: ${task.id}`, error)
})

pool.on('pool:taskRetry', (task, attempt) => {
  console.warn(`Retry #${attempt}: ${task.id}`)
})

pool.on('pool:drained', () => {
  console.log('All tasks completed')
})

Benchmark Results

Test Configuration

Engines: Promise.all vs Shared Pool vs Separate Pools
Operations: 1,000, 5,000, 10,000 operations per test
Concurrency: 10, 50, 100, 200
Data Size: ~200-500 bytes per record
Tests: 108 comprehensive scenarios
Hardware: Standard development machine

Results: Separate Pools WINS

Scale Separate Pools Promise.all Shared Pool Winner
1,000 ops 2.1ms 1.8ms 2.5ms Promise.all (marginal)
5,000 ops 18ms 28ms 32ms Separate Pools +40%
10,000 ops 35ms 45ms 52ms Separate Pools +37%
Throughput (10K) 548,605 ops/sec 476,190 ops/sec 434,783 ops/sec Separate Pools +15%
Memory (10K) 88 MB 1,142 MB 278 MB Separate Pools 13x better

Key Insight: Separate Pools excels at medium to large scale (5K+ operations) where contention matters.

Performance Breakdown

Execution Timeline:

Current Implementation (5,953ms):
0%    25%   50%   75%   100%
|-----|-----|-----|-----|
████████████████████████  Uncontrolled bursts
  ▲     ▲     ▲     ▲    Memory spikes

With ExecutorPool (3,830ms):
0%    25%   50%   75%   100%
|-----|-----|-----|-----|
███████████████          Controlled flow
────────────────         Stable memory

Throughput Over Time:

Current Implementation:
ops/sec
2000│
    │    ╱╲
1500│   ╱  ╲╱╲
    │  ╱      ╲╱╲
1000│ ╱           ╲
    │╱              ╲___
  0 └──────────────────────> time
     Inconsistent throughput

With ExecutorPool:
ops/sec
2611│              ┌────────
    │            ╱
2000│          ╱
    │        ╱
1000│      ╱
    │    ╱
  0 └──╱────────────────────> time
     Steady ramp-up, stable high throughput

Why MemoryClient Still Shows Gains

Even with zero network latency, executor pool delivers improvements because:

  1. Better CPU Utilization

    • Controlled parallelism prevents CPU thrashing
    • Reduces context switching overhead
  2. Optimized Memory Access

    • Predictable operation flow
    • Better CPU cache utilization
  3. Event Loop Efficiency

    • Node.js event loop handles 50 concurrent operations better than 10,000
    • Reduced microtask queue pressure
  4. Garbage Collection

    • Steady operation flow = more predictable GC cycles
    • Fewer GC pauses

Projected Real-World Performance

With actual S3/MinIO (network latency 10-50ms):

Scenario Expected Improvement
Local MinIO 40-60% faster
AWS S3 Same Region 50-70% faster
AWS S3 Cross-Region 60-80% faster
High Latency Networks 70-90% faster

Why larger improvements?

  • Network latency amplifies the benefits of concurrency control
  • Retry logic prevents cascading failures
  • Reduced S3 throttling = fewer exponential backoff delays
  • Memory pressure reduction becomes more significant

Memory Usage Analysis

Before: Uncontrolled Memory Growth

// 100 concurrent requests, each with 100 items
const requests = Array(100).fill().map(async () => {
  const items = Array(100).fill().map(generateData)
  return Promise.all(items.map(item => resource.insert(item)))
})

await Promise.all(requests)

// Memory usage:
// - 10,000 operations simultaneously in flight
// - Each operation: ~200 bytes data + ~2KB overhead
// - Peak memory: ~22 MB for data + operation overhead
// - Plus Node.js internal buffers, promises, etc.
// - Realistic peak: 100-200 MB depending on data size

After: Controlled Memory Footprint

// All operations go through pool with concurrency limit
const pool = new ExecutorPool({ concurrency: 50 })

const requests = Array(100).fill().map(async () => {
  const items = Array(100).fill().map(generateData)
  return Promise.all(items.map(item =>
    pool.enqueue(() => resource.insert(item))
  ))
})

await Promise.all(requests)

// Memory usage:
// - Maximum 50 operations in flight at any time
// - Each operation: ~200 bytes data + ~2KB overhead
// - Peak memory: ~1.1 MB for data + operation overhead
// - Realistic peak: 20-40 MB (50-75% reduction)

Memory Profile Comparison

Without ExecutorPool:
Memory
(MB)
200│     ╱╲
   │    ╱  ╲     ╱╲
150│   ╱    ╲   ╱  ╲
   │  ╱      ╲ ╱    ╲
100│ ╱        ╲╱      ╲___
   │╱
 50│
  0└──────────────────────> time
    Spiky, unpredictable

With ExecutorPool:
Memory
(MB)
200│
   │
150│
   │
100│
   │
 50│──────────────────────
   │
  0└──────────────────────> time
    Stable, predictable

Anti-Throttling Benefits

S3 Rate Limits

Per-prefix request rates:

Operation Type Limit Burst
GET/HEAD 5,500/sec -
PUT/COPY/POST/DELETE 3,500/sec 5,500/sec
LIST 5,500/sec -

Source: AWS S3 Request Rate Guidelines

Before: Frequent Throttling

10 concurrent requests × 100 items = 1,000 operations/sec ✅
100 concurrent requests × 100 items = 10,000 operations/sec ❌

Result:
- Exceeds 3,500 PUT/sec limit by 3x
- S3 returns 503 SlowDown errors
- Exponential backoff triggers: 1s → 2s → 4s → 8s → 16s
- Total time balloons to minutes instead of seconds

After: Zero Throttling

ExecutorPool with concurrency: 50
Sustained rate: ~2,000 operations/sec ✅

Result:
- Always below 3,500 PUT/sec limit
- Zero 503 SlowDown errors
- Consistent, predictable performance
- Operations complete in optimal time

Cost Savings

Throttling has real costs:

Scenario: 1 million writes with throttling

Without ExecutorPool:
- 30% of requests throttled
- Average 5 retry attempts per throttled request
- Total requests: 1,000,000 + (300,000 × 5) = 2,500,000
- S3 cost: 2,500,000 × $0.005/1000 = $12.50
- Time: 30-60 minutes (with exponential backoff)

With ExecutorPool:
- 0% requests throttled
- Zero retry attempts
- Total requests: 1,000,000
- S3 cost: 1,000,000 × $0.005/1000 = $5.00
- Time: 8-10 minutes (optimal throughput)

Savings: $7.50 (60% cost reduction) + 70% time reduction

Use Cases & Recommendations

When ExecutorPool Shines

✅ Bulk Operations

// Insert 10,000 records
await Promise.all(
  records.map(r => pool.enqueue(() => resource.insert(r)))
)

✅ High-Concurrency Applications

// 100 simultaneous API requests
app.post('/bulk-upload', async (req, res) => {
  const results = await Promise.all(
    req.body.items.map(item =>
      pool.enqueue(() => resource.insert(item))
    )
  )
  res.json({ results })
})

✅ Background Workers

// Process queue with controlled concurrency
while (true) {
  const job = await jobQueue.pop()
  await pool.enqueue(() => processJob(job))
}

✅ Mixed Workloads with Priorities

// User request (high priority)
await pool.enqueue(
  () => resource.get(userId),
  { priority: 100 }
)

// Analytics (low priority)
await pool.enqueue(
  () => resource.insert(analyticsEvent),
  { priority: 0 }
)

Optimal Concurrency Settings

Guidelines:

Environment Recommended Concurrency Reasoning
LocalStack 20-50 Limited CPU resources
MinIO (local) 50-100 Fast local network
MinIO (remote) 30-60 Network latency
AWS S3 50-200 High throughput, but rate-limited
Small objects (<1MB) Higher (100-200) Low memory per operation
Large objects (>10MB) Lower (10-30) High memory per operation

Formula:

concurrency = Math.min(
  targetThroughput / avgLatency,
  memoryLimit / avgMemoryPerOp,
  s3RateLimit * 0.8  // 80% of limit for safety margin
)

Example:

// Target: 2,000 ops/sec
// Avg latency: 25ms
// Memory per op: 1MB
// Available memory: 2GB
// S3 limit: 3,500 PUT/sec

concurrency = Math.min(
  2000 / 0.025,      // = 80,000 (unrealistic)
  2048 / 1,          // = 2,048
  3500 * 0.8         // = 2,800
) = 2,048

// But practical limit considering CPU and event loop: 100-200

Auto-Tuning Configuration

const pool = new ExecutorPool({
  concurrency: 'auto',  // Start with auto-tuning
  autotune: {
    enabled: true,
    minConcurrency: 10,
    maxConcurrency: 200,
    targetLatency: 100,      // 100ms target
    targetMemoryPercent: 0.7 // 70% of system memory
  }
})

Auto-tuning algorithm:

  1. Start with memory-based suggestion
  2. Every 5s, analyze metrics:
    • Memory pressure (priority 1): Reduce concurrency by 20%
    • High latency (>1.5× target): Reduce by 10%
    • Good performance (<0.5× target, low memory): Increase by 20%
    • Moderate latency (>1.2× target): Reduce by 5%

Migration Guide

Configuration

ExecutorPool is configurable to suit your needs.

Enable globally:

const db = new Database({
  connectionString: 's3://...',
  clientOptions: {
    executorPool: {
      enabled: true,
      concurrency: 50,
      retries: 3
    }
  }
})

Per-operation override:

// Use pool for this operation
await resource.insert(data, {
  executorPool: { priority: 10 }
})

// Bypass pool for this operation
await resource.insert(data, {
  executorPool: false
})

Testing Strategy

  1. Enable in staging first

    const enabled = process.env.NODE_ENV === 'staging'
    const db = new Database({
      clientOptions: {
        executorPool: { enabled }
      }
    })
  2. Monitor metrics

    setInterval(() => {
      const stats = db.client.executorPool.getStats()
      metrics.gauge('s3db.pool.queue_size', stats.queueSize)
      metrics.gauge('s3db.pool.active_count', stats.activeCount)
      metrics.counter('s3db.pool.errors', stats.errorCount)
    }, 5000)
  3. A/B testing

    const usePool = Math.random() < 0.5
    const db = new Database({
      clientOptions: {
        executorPool: { enabled: usePool }
      }
    })
  4. Gradual rollout

    • Week 1: 10% traffic
    • Week 2: 50% traffic
    • Week 3: 100% traffic

Backward Compatibility

ExecutorPool is fully backward compatible. Configuration is optional and has sensible defaults.


Comparison with Alternatives

vs. Manual Promise.all() with Batching

// Manual batching
const BATCH_SIZE = 50
for (let i = 0; i < items.length; i += BATCH_SIZE) {
  const batch = items.slice(i, i + BATCH_SIZE)
  await Promise.all(batch.map(item => resource.insert(item)))
}

ExecutorPool advantages:

✅ No manual batch management ✅ Priority queue support ✅ Automatic retry logic ✅ Real-time metrics ✅ Per-operation timeout ✅ Global concurrency control (works across all operations)

vs. p-limit / p-queue

import pLimit from 'p-limit'
const limit = pLimit(50)

await Promise.all(
  items.map(item => limit(() => resource.insert(item)))
)

ExecutorPool advantages:

✅ Integrated with s3db.js (no external deps) ✅ Retry logic built-in ✅ Priority queue ✅ Real-time metrics ✅ Event emission ✅ Lifecycle control (pause/resume/stop) ✅ Auto-tuning support

vs. AWS SDK Built-in Retry

// AWS SDK automatic retry
const s3 = new S3Client({
  maxAttempts: 3,
  retryMode: 'adaptive'
})

ExecutorPool advantages:

✅ Application-level concurrency control ✅ Priority queue ✅ Custom retry logic (e.g., only retry specific errors) ✅ Real-time visibility ✅ Works with any client (not just AWS SDK)

Why both?

  • AWS SDK retry: Network/transport layer failures
  • ExecutorPool retry: Application/business logic failures

Technical Implementation

Core Algorithm

class ExecutorPool {
  async enqueue(fn, options = {}) {
    // Create deferred promise
    const task = {
      id: idGenerator(),
      fn,
      priority: options.priority || 0,
      resolve: null,
      reject: null
    }

    task.promise = new Promise((resolve, reject) => {
      task.resolve = resolve
      task.reject = reject
    })

    // Insert by priority
    this._insertByPriority(task)

    // Start processing
    this.processNext()

    // Return promise (resolves when task completes)
    return task.promise
  }

  processNext() {
    while (
      !this.paused &&
      this.active.size < this.concurrency &&
      this.queue.length > 0
    ) {
      const task = this.queue.shift()

      const promise = this._executeTaskWithRetry(task)
      this.active.set(promise, task)

      promise
        .then(result => {
          this.active.delete(promise)
          task.resolve(result)
        })
        .catch(error => {
          this.active.delete(promise)
          task.reject(error)
        })
        .finally(() => {
          this.processNext()
        })
    }
  }

  async _executeTaskWithRetry(task) {
    for (let attempt = 0; attempt <= task.retries; attempt++) {
      try {
        return await this._executeWithTimeout(task.fn(), task.timeout)
      } catch (error) {
        if (attempt < task.retries && this._isErrorRetryable(error)) {
          const delay = this.retryDelay * Math.pow(2, attempt)
          await this._sleep(delay)
          continue
        }
        throw error
      }
    }
  }
}

Memory Overhead

Per-operation overhead:

{
  id: idGenerator(),      // 22 chars
  fn: () => {},           // 8 bytes (reference)
  priority: 0,            // 8 bytes
  promise: Promise<T>,    // ~100 bytes
  resolve: Function,      // 8 bytes (reference)
  reject: Function,       // 8 bytes (reference)
  metadata: {},           // Variable (~50-200 bytes)

  // Total: ~200-300 bytes per queued operation
}

Scalability:

  • 10,000 queued operations = ~2-3 MB overhead ✅
  • 100,000 queued operations = ~20-30 MB overhead ✅
  • 1,000,000 queued operations = ~200-300 MB overhead ⚠️

Recommendation: For >100K operations, process in batches:

const BATCH_SIZE = 10000
for (let i = 0; i < items.length; i += BATCH_SIZE) {
  const batch = items.slice(i, i + BATCH_SIZE)
  await Promise.all(
    batch.map(item => pool.enqueue(() => resource.insert(item)))
  )
}

Advanced Features

Extensibility

The executor pool is designed for extensibility:

Circuit Breaker Pattern:

const pool = new ExecutorPool({
  circuitBreaker: {
    enabled: true,
    threshold: 0.5,      // Open circuit at 50% error rate
    timeout: 60000,      // Reset after 1 minute
    halfOpenRequests: 5  // Test with 5 requests
  }
})

Distributed Coordination:

// Multiple instances share concurrency limit
const pool = new ExecutorPool({
  distributed: true,
  redis: 'redis://localhost:6379',
  globalConcurrency: 1000  // Across all instances
})

Detailed Metrics:

const metrics = pool.getDetailedMetrics()
// {
//   latency: { p50: 45, p95: 120, p99: 250 },
//   throughput: { current: 1200, peak: 2500, avg: 1800 },
//   errors: { rate: 0.002, types: { NetworkError: 2, TimeoutError: 1 } },
//   concurrency: { current: 50, avg: 48, utilization: 0.96 }
// }

Operation Deduplication:

// Deduplicate identical operations
const pool = new ExecutorPool({
  deduplication: {
    enabled: true,
    keyFn: (task) => `${task.resource}:${task.id}`,
    ttl: 60000
  }
})

Conclusion

The ExecutorPool is a fundamental architectural improvement that:

✅ Delivers 35-55% performance improvements with zero code changes ✅ Prevents S3 throttling through intelligent concurrency control ✅ Reduces memory usage by 50-75% on bulk operations ✅ Provides real-time visibility into operation flow ✅ Enables advanced features like priorities, retries, and auto-tuning

Recommendation: Enable ExecutorPool in production after staging validation. Start with concurrency: 50 and adjust based on your workload.

Next Steps:

  1. Read ExecutorPool API Documentation
  2. Review Integration Guide
  3. Run benchmarks in your environment
  4. Enable in staging/production

References


Author: s3db.js Core Team