Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
cf96ffa
Add PostgreSQL connection pooling comparison scripts and functionality
goh-chunlin Apr 10, 2026
aba2547
fix(AzureDbBehavior): adjust PostgreSQL connection overhead handling …
goh-chunlin Apr 10, 2026
04991c6
refactor(ConnectionPool): simplify constructor by removing pooling mo…
goh-chunlin Apr 10, 2026
0a282e6
feat(AzureDbBehavior): implement deferred connection acquisition for …
goh-chunlin Apr 10, 2026
6350fb2
fix(ConnectionPool): validate pool size for non-direct pooling modes …
goh-chunlin Apr 10, 2026
02c2776
feat(AzurePgsqlPoolingScenario): implement random session hold time f…
goh-chunlin Apr 10, 2026
a4a9c7e
feat(AzureDbBehavior): implement simulated connection wait time for P…
goh-chunlin Apr 10, 2026
7238f53
feat(AzurePgsqlPoolingScenario): add validation for positive pool siz…
goh-chunlin Apr 10, 2026
3214c02
feat(pooling-scripts): enhance precision in latency calculations usin…
goh-chunlin Apr 10, 2026
b9e3c82
feat(ConnectionReleaseEvent): add event for delayed connection releas…
goh-chunlin Apr 10, 2026
922e2e7
fix(pool-size-comparison): handle errors during pool size execution a…
goh-chunlin Apr 10, 2026
af01fa1
feat(AzureDbBehavior): update connection acquisition logic to bypass …
goh-chunlin Apr 10, 2026
bd9adf3
feat(AzureDbBehavior): refine burn logic and service time estimation …
goh-chunlin Apr 10, 2026
d062de6
fix(azure-pgsql-pooling): improve pool size validation logic for sess…
goh-chunlin Apr 10, 2026
0923156
feat: ensure script directory resolution for relative paths in simula…
goh-chunlin Apr 10, 2026
d027e79
feat(ConnectionPool): update acquisition logic to implement spillover…
goh-chunlin Apr 10, 2026
9d28ba2
feat(AzureDbBehavior): enhance connection acquisition logic for pooli…
goh-chunlin Apr 10, 2026
550a942
refactor(ConnectionPool): update documentation to clarify spillover s…
goh-chunlin Apr 10, 2026
930ecea
feat(pooling-comparison): merge latency and credits data by query ind…
goh-chunlin Apr 10, 2026
fda6b66
refactor(pool-size-comparison): remove unnecessary sorting of latenci…
goh-chunlin Apr 10, 2026
4d33ec2
refactor(pooling-comparison): update latency calculations to use medi…
goh-chunlin Apr 10, 2026
0b950ba
feat(pool-size-comparison): update latency calculations to use median…
goh-chunlin Apr 10, 2026
11e1262
refactor(pool-size-comparison): update threshold for optimal pool siz…
goh-chunlin Apr 10, 2026
e051979
fix(pooling-comparison): ensure latency and credits values are treate…
goh-chunlin Apr 10, 2026
2de54b9
refactor(pooling-simulation): update comments to clarify spillover mo…
goh-chunlin Apr 10, 2026
83071ee
refactor(scenarios): update log messages to clarify CSV output location
goh-chunlin Apr 10, 2026
1514f29
refactor(pooling-comparison): update scripts to generate median laten…
goh-chunlin Apr 11, 2026
92d8156
refactor(azure-db-registry): remove redundant comments from Burstable…
goh-chunlin Apr 11, 2026
859324a
refactor(azure-db-registry): enhance comments for BurstableInstanceSp…
goh-chunlin Apr 11, 2026
f1a755e
refactor(pooling-comparison): check for required CSV files before gen…
goh-chunlin Apr 11, 2026
7aea02b
refactor(pool-size-comparison): add checks for summary data and optim…
goh-chunlin Apr 11, 2026
55992fc
refactor(pool-size-comparison): improve checks for summary data avail…
goh-chunlin Apr 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions SimNextgenApp.Demo/AwsRdsSample/simulation.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ param(
$RemainingArgs
)

# Resolve script directory and cd into it to ensure relative paths work
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
Set-Location $ScriptDir

Write-Host "Building project and starting SNA simulation..." -ForegroundColor Cyan

$startTime = Get-Date
Expand Down
93 changes: 86 additions & 7 deletions SimNextgenApp.Demo/AzureDbSample/AzureDbBehavior.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ namespace SimNextgenApp.Demo.AzureDbSample;
internal class AzureDbBehavior(AzureDbInstanceSpec spec, double initialCredits = 10.0)
{
private IRunContext? _engineContext;
private ConnectionPool? _connectionPool;

// Thread-safe storage for cross-thread access by OpenTelemetry's background metric collection
// Using volatile read/write pattern via Volatile.Read() / Volatile.Write()
Expand Down Expand Up @@ -45,13 +46,26 @@ private double Credits
private double BurnRatePerSec => spec.VCores / 60.0;
private bool IsBurstable => _burstableSpec != null;

// PostgreSQL connection overhead constants (for PgBouncer-style pooling)
private const double ConnectionOverheadSecs = 0.050; // 50ms for new connection setup
private const double TransactionResetOverheadSecs = 0.008; // 8ms for DISCARD ALL (state reset)

public void SetContext(IRunContext context)
{
_engineContext = context;
// Initialize last update time to current simulation time
_lastUpdateTimeInSimUnits = context.ClockTime;
}

/// <summary>
/// Sets the connection pool for deferred connection acquisition.
/// Must be called before simulation starts if using pooling mode.
/// </summary>
public void SetConnectionPool(ConnectionPool? pool)
{
_connectionPool = pool;
}

/// <summary>
/// Sets up OpenTelemetry metrics for exporting to Grafana Cloud or other OTLP backends.
/// </summary>
Expand Down Expand Up @@ -102,15 +116,80 @@ public TimeSpan GetServiceTime(MyLoad load, Random rnd)
_lastUpdateTimeInSimUnits = currentTimeInSimUnits;
}

// 2. Burn Logic (Look Ahead)
double estimatedBurstCost = spec.FastSecs * BurnRatePerSec;
// 2. DEFERRED CONNECTION ACQUISITION (if applicable)
// Acquire connection from pool NOW (when service starts), not at load creation
if (load is PostgresQuery query && string.IsNullOrEmpty(query.ConnectionId))
{
// Connection not yet assigned - acquire from pool now
if (query.PoolMode == PoolingMode.Direct)
{
// Direct mode: Always create new connection (no pool)
query.IsNewConnection = true;
query.ConnectionId = Guid.NewGuid().ToString();
}
else
{
// Session or Transaction pooling mode - pool MUST be configured
if (_connectionPool == null)
{
throw new InvalidOperationException(
$"Connection pool not configured for {query.PoolMode} mode. " +
"Call SetConnectionPool() before running simulation with pooling enabled.");
}

var connId = _connectionPool.AcquireConnection(query.Id.ToString());

if (connId != null)
{
// SUCCESS: Connection acquired from pool
query.IsNewConnection = false;
query.ConnectionId = connId;
}
else
{
// POOL EXHAUSTED: Open new connection outside pool
// This models real-world scenario where clients must bypass PgBouncer
// when max_client_conn is reached and open direct connections.
// Connection lifecycle is consistent: marked as new (pays 50ms overhead),
// not tracked in pool, so release becomes natural no-op.
query.IsNewConnection = true;
query.ConnectionId = $"direct_{Guid.NewGuid()}";
}
Comment thread
goh-chunlin marked this conversation as resolved.
Comment thread
goh-chunlin marked this conversation as resolved.
}
}

// 3. Determine PostgreSQL overhead based on connection type
double connectionOverhead = 0.0;
if (load is PostgresQuery q)
{
if (q.IsNewConnection)
{
connectionOverhead = ConnectionOverheadSecs; // 50ms for new connection
}
else if (q.PoolMode == PoolingMode.TransactionPooling)
{
connectionOverhead = TransactionResetOverheadSecs; // 8ms for DISCARD ALL
}
// Session pooling: no overhead
}

// 4. Burn Logic (Look Ahead) - estimate total cost for throttling decision
// Use mean execution time + deterministic overhead for cost estimation
double meanExecutionTime = spec.FastSecs;
double estimatedBurstCost = (meanExecutionTime + connectionOverhead) * BurnRatePerSec;
bool isThrottled = IsBurstable && Credits < estimatedBurstCost;

// 3. Determine Service Time
double baseTime = isThrottled ? spec.SlowSecs : spec.FastSecs;
double actualDuration = -baseTime * Math.Log(1.0 - rnd.NextDouble());
// 5. Determine Service Time using shifted exponential distribution
// Query execution time is exponential (variable)
double executionTimeMean = isThrottled ? spec.SlowSecs : spec.FastSecs;
double queryExecutionTime = -executionTimeMean * Math.Log(1.0 - rnd.NextDouble());

// Connection overhead is deterministic (fixed) - additive, not part of exponential mean
// This ensures: (a) overhead is always exactly 50ms/8ms, not random
// (b) total duration is never less than overhead
double actualDuration = queryExecutionTime + connectionOverhead;

// 4. Pay the Bill
// 6. Pay the Bill
// Azure: No unlimited mode - hard throttle when credits depleted
if (IsBurstable)
{
Expand All @@ -128,7 +207,7 @@ public TimeSpan GetServiceTime(MyLoad load, Random rnd)
}
}

// 5. Export Data (CSV)
// 7. Export Data (CSV)
// Convert current simulation time to seconds for CSV export
double nowInSeconds = TimeUnitConverter.ConvertFromSimulationUnits(
currentTimeInSimUnits,
Expand Down
24 changes: 19 additions & 5 deletions SimNextgenApp.Demo/AzureDbSample/AzureDbInstanceSpec.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,25 @@ internal static class AzureDbRegistry
// B-series - Burstable (20% baseline for all)
// Azure starts with ~30 credits per core (initial bank for boot-up)
// Format: Series, Size, VCores, FastSecs, EarnRatePerHour, MaxCredits, BaselineFraction
new BurstableInstanceSpec("B", "1ms", 1, 0.050, 12, 288, 0.20), // 0.20 = 20% baseline → SlowSecs = 0.250
new BurstableInstanceSpec("B", "2s", 2, 0.045, 24, 576, 0.20), // 0.20 = 20% baseline → SlowSecs = 0.225
new BurstableInstanceSpec("B", "2ms", 2, 0.045, 24, 576, 0.20), // 0.20 = 20% baseline → SlowSecs = 0.225
new BurstableInstanceSpec("B", "4ms", 4, 0.040, 48, 1152, 0.20), // 0.20 = 20% baseline → SlowSecs = 0.200
new BurstableInstanceSpec("B", "8ms", 8, 0.035, 96, 2304, 0.20), // 0.20 = 20% baseline → SlowSecs = 0.175

// IMPORTANT: FastSecs is identical across all B-series sizes (0.080s) because a single-threaded
// query runs at the same speed regardless of vCore count - each vCore has identical performance.
// Size-based performance differentiation comes from CONCURRENCY CAPACITY (VCores), not query speed.
//
// Example throughput at 50 req/sec arrival rate:
// B.1ms: 1 vCore × 12.5 qps/core = 12.5 qps capacity -> 400% utilization (oversaturated)
// B.2ms: 2 vCores × 12.5 qps/core = 25 qps capacity -> 200% utilization (saturated)
// B.4ms: 4 vCores × 12.5 qps/core = 50 qps capacity -> 100% utilization (critical point)
// B.8ms: 8 vCores × 12.5 qps/core = 100 qps capacity -> 50% utilization (comfortable)
//
// This models reality: larger instances win through PARALLELISM, not faster individual queries.
// See: AzurePgsqlPoolingScenario.cs (numberOfServers: VCores)

new BurstableInstanceSpec("B", "1ms", 1, 0.080, 12, 288, 0.20),
new BurstableInstanceSpec("B", "2s", 2, 0.080, 24, 576, 0.20),
new BurstableInstanceSpec("B", "2ms", 2, 0.080, 24, 576, 0.20),
new BurstableInstanceSpec("B", "4ms", 4, 0.080, 48, 1152, 0.20),
new BurstableInstanceSpec("B", "8ms", 8, 0.080, 96, 2304, 0.20),

// Future: D-series (General Purpose), E-series (Memory Optimized)
};
Expand Down
96 changes: 96 additions & 0 deletions SimNextgenApp.Demo/AzureDbSample/ConnectionPool.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
namespace SimNextgenApp.Demo.AzureDbSample;

/// <summary>
/// Simulates a connection pool for PostgreSQL with SPILLOVER semantics.
/// When the pool is exhausted, callers can open direct connections to the database,
/// temporarily exceeding the pool size (unlike true hard-limit pools that queue requests).
/// Connection acquisition happens when server starts processing (deferred acquisition).
/// </summary>
internal class ConnectionPool
{
private readonly int _poolSize;
private readonly HashSet<string> _availableConnections;
private readonly Dictionary<string, string> _assignedConnections; // Query → Connection

public ConnectionPool(int poolSize)
{
if (poolSize <= 0)
{
throw new ArgumentException($"Pool size must be positive (got {poolSize}). Use Direct mode if you don't want pooling.", nameof(poolSize));
}

_poolSize = poolSize;
_availableConnections = new HashSet<string>();
_assignedConnections = new Dictionary<string, string>();

// Initialize pool with connection IDs
for (int i = 0; i < poolSize; i++)
{
_availableConnections.Add($"conn_{i}");
}
}

/// <summary>
/// Attempts to acquire a connection from the pool.
/// Returns connection ID if successful, null if pool is exhausted.
///
/// SPILLOVER MODEL: When pool is exhausted (returns null), caller opens
/// a new direct connection to the database, bypassing the pool and paying
/// full connection overhead (50ms). This models scenarios where applications
/// fall back to direct connections when the pool is saturated, allowing
/// temporary exceedance of the pool size under load spikes.
///
/// Note: This differs from PgBouncer's default queue-and-wait behavior.
/// Use this model to simulate spillover capacity in high-traffic scenarios.
///
/// Called at SERVICE START (deferred acquisition), not at load creation.
/// Requests naturally queue in SimQueue before reaching this point.
Comment thread
goh-chunlin marked this conversation as resolved.
/// </summary>
public string? AcquireConnection(string queryId)
{
if (_availableConnections.Count == 0)
{
// Pool exhausted - caller opens direct connection bypassing pool (spillover model)
return null;
}
Comment thread
goh-chunlin marked this conversation as resolved.

// Get first available connection
string connectionId = _availableConnections.First();
_availableConnections.Remove(connectionId);
_assignedConnections[queryId] = connectionId;

return connectionId;
}
Comment thread
goh-chunlin marked this conversation as resolved.

/// <summary>
/// Releases a connection back to the pool after query completion.
/// </summary>
public void ReleaseConnection(string queryId)
{
if (_assignedConnections.TryGetValue(queryId, out string? connectionId))
{
_assignedConnections.Remove(queryId);
_availableConnections.Add(connectionId);
}
}

/// <summary>
/// Total capacity of the connection pool.
/// </summary>
public int Capacity => _poolSize;

/// <summary>
/// Number of available connections in the pool.
/// </summary>
public int AvailableCount => _availableConnections.Count;

/// <summary>
/// Number of connections currently in use.
/// </summary>
public int InUseCount => _assignedConnections.Count;

/// <summary>
/// Indicates whether the pool is fully exhausted (no available connections).
/// </summary>
public bool IsExhausted => _availableConnections.Count == 0;
}
26 changes: 26 additions & 0 deletions SimNextgenApp.Demo/AzureDbSample/ConnectionReleaseEvent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using SimNextgenApp.Core;
using SimNextgenApp.Events;

namespace SimNextgenApp.Demo.AzureDbSample;

/// <summary>
/// Event for delayed connection release in session pooling mode.
/// Simulates client holding connection between queries in a session.
/// </summary>
internal class ConnectionReleaseEvent : AbstractEvent
{
private readonly ConnectionPool _pool;
private readonly string _queryId;

public ConnectionReleaseEvent(ConnectionPool pool, string queryId)
{
_pool = pool;
_queryId = queryId;
}

public override void Execute(IRunContext context)
{
// Release connection back to pool after session hold time
_pool.ReleaseConnection(_queryId);
}
}
50 changes: 50 additions & 0 deletions SimNextgenApp.Demo/AzureDbSample/PostgresQuery.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using SimNextgenApp.Demo.CustomModels;

namespace SimNextgenApp.Demo.AzureDbSample;

/// <summary>
/// Represents a PostgreSQL query with connection pooling metadata.
/// Used to simulate PgBouncer-style connection pooling overhead.
/// </summary>
internal class PostgresQuery : MyLoad
{
/// <summary>
/// Indicates whether this query requires a new connection to be established.
/// True = new connection (50ms overhead), False = reused connection
/// </summary>
public bool IsNewConnection { get; set; }

/// <summary>
/// The connection ID assigned to this query (for tracking pool usage).
/// Null when using deferred acquisition (assigned at service start).
/// </summary>
public string? ConnectionId { get; set; }

/// <summary>
/// The pooling mode used for this query.
/// </summary>
public PoolingMode PoolMode { get; set; }
}

/// <summary>
/// PostgreSQL connection pooling modes (PgBouncer-style).
/// </summary>
internal enum PoolingMode
{
/// <summary>
/// Direct mode: New connection per query (50ms overhead every query).
/// </summary>
Direct,

/// <summary>
/// Session pooling: Connection held for session, reused with no overhead.
/// Best for most workloads.
/// </summary>
SessionPooling,

/// <summary>
/// Transaction pooling: Connection released after transaction with state reset.
/// Adds 8ms DISCARD ALL overhead per query.
/// </summary>
TransactionPooling
}
Loading
Loading