/*
* QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals.
* Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
using System;
using System.Collections.Generic;
using System.Threading;
using QuantConnect.Interfaces;
using QuantConnect.Logging;
using QuantConnect.Orders;
using QuantConnect.Util;
namespace QuantConnect.Lean.Engine.TransactionHandlers
{
///
/// Runs order requests on background worker threads that pull from a single shared queue. The pool grows on
/// demand when the workers get saturated and keeps every request of an order processed in order.
///
///
/// Workers pull from one shared queue, so the load spreads across them instead of pinning each order to a thread
/// up front. To keep a single order (or combo group) in order, only one of its requests runs at a time. While one
/// runs the rest wait parked, and the same worker takes them next in arrival order. This state only exists while
/// an order has requests in flight, so nothing needs releasing once the order closes. When a single consumer
/// drains the queue, a lone fixed worker or the caller itself in synchronous mode (through
/// ), arrival order is already preserved so the per-order bookkeeping is skipped.
///
public class OrderRequestProcessingPool : IDisposable
{
// maximum time to wait for each worker thread to stop when disposing the pool
private static readonly TimeSpan ShutdownTimeout = TimeSpan.FromSeconds(60);
// the shared queue of requests cleared to run. every worker pulls from here so the load stays balanced
private readonly IBusyCollection _readyQueue;
private readonly List _threads;
// for each order (or combo group) being processed, the follow up requests waiting their turn in arrival order,
// or null until a second request actually needs parking. while the key is here the order is already running
private readonly Dictionary<(bool IsGroup, int Id), Queue> _inFlight = new();
// guards the in flight map, the threads list and the growth/shutdown flags
private readonly Lock _lock = new();
// maximum number of worker threads the pool can grow to on demand
private readonly int _maximumThreads;
// true when there are no worker threads and the caller drains the single queue itself
private readonly bool _synchronous;
// true when a single consumer drains the queue (synchronous or a single fixed worker), which already
// preserves arrival order across all orders so the per-order serialization is skipped entirely
private readonly bool _singleConsumer;
// set under the lock when shutting down so the pool stops growing while the queue drains, before the
// cancellation token is cancelled as the final hard stop
private bool _shuttingDown;
// number of workers currently processing a request, used to decide when the pool is saturated
private int _busyWorkers;
private readonly Action _processRequest;
private readonly Action _onError;
private readonly CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource();
///
/// True while the pool is processing order requests, false once it has been shut down.
///
public bool IsActive { get; private set; }
///
/// The number of worker threads currently running.
///
public int ThreadCount
{
get
{
lock (_lock)
{
return _threads.Count;
}
}
}
///
/// Creates a threaded pool and starts its initial worker threads. When concurrency is enabled the pool
/// starts at and grows on demand up to ,
/// otherwise it runs a single fixed worker thread.
///
/// True to grow the pool on demand, false to run a single worker thread
/// The number of worker threads the pool starts with when growing
/// The maximum number of worker threads the pool can grow to on demand
/// Handles a single order request
/// Invoked when processing fails unexpectedly
public OrderRequestProcessingPool(bool concurrencyEnabled, int minimumThreads, int maximumThreads,
Action processRequest, Action onError)
{
_synchronous = false;
_processRequest = processRequest;
_onError = onError;
// concurrency grows the pool minimum..maximum on demand, otherwise a single fixed thread is used
_maximumThreads = concurrencyEnabled ? Math.Max(1, maximumThreads) : 1;
_singleConsumer = _maximumThreads == 1;
var initialThreadsCount = concurrencyEnabled ? Math.Min(Math.Max(1, minimumThreads), _maximumThreads) : 1;
_readyQueue = new BusyBlockingCollection();
_threads = new(_maximumThreads);
IsActive = true;
for (var i = 0; i < initialThreadsCount; i++)
{
AddThread().Start();
}
}
///
/// Private constructor for the synchronous pool, a single non blocking queue and no worker threads.
///
private OrderRequestProcessingPool(Action processRequest, Action onError)
{
_synchronous = true;
_processRequest = processRequest;
_onError = onError;
_maximumThreads = 1;
_singleConsumer = true;
_readyQueue = new BusyCollection();
_threads = new(0);
IsActive = true;
}
///
/// Creates a synchronous pool with no worker threads. Its single queue is drained on the caller thread
/// via .
///
/// Handles a single order request
/// Invoked when processing fails unexpectedly
public static OrderRequestProcessingPool Synchronous(Action processRequest, Action onError)
{
return new OrderRequestProcessingPool(processRequest, onError);
}
///
/// Dispatches an order request to be processed. If the order already has a request in flight, the new one
/// waits parked so its worker runs it next and the order stays in arrival order. Otherwise it is queued for
/// any worker to pick up, growing the pool first when every worker is already busy.
///
/// The order request to process
/// The order the request belongs to, used to keep its requests ordered
public void Dispatch(OrderRequest request, Order order)
{
// a single consumer drains in arrival order across all orders, no need to serialize per order
if (_singleConsumer)
{
_readyQueue.Add(new WorkItem(request, default));
return;
}
var key = GetRoutingKey(order);
WorkItem readyItem = default;
Thread newThread = null;
var run = false;
lock (_lock)
{
if (_inFlight.TryGetValue(key, out var parked))
{
// the order is already being processed, park this request so its worker runs it next in order,
// allocating the queue only now that a second request has actually arrived
if (parked == null)
{
_inFlight[key] = parked = new Queue();
}
parked.Enqueue(request);
}
else
{
// claim the order without a queue, most orders never get a second request. grow the pool if
// every worker is already busy so this request would wait
_inFlight[key] = null;
newThread = TryExpand();
readyItem = new WorkItem(request, key);
run = true;
}
}
// start the new worker and add outside the lock: starting an OS thread and a potentially blocking
// add on a bounded queue shouldn't stall other dispatchers
if (run)
{
newThread?.Start();
_readyQueue.Add(readyItem);
}
}
///
/// Drains the pending order requests on the calling thread. Only used in synchronous mode, where there
/// are no worker threads and the caller pumps the single queue itself.
///
public void ProcessPending()
{
Drain(item => _processRequest(item.Request));
}
///
/// Waits until no order has requests in flight, up to the given timeout. In practice only the synchronous
/// early return runs. The threaded branch below is defensive, since its callers only reach it in backtesting
/// where the pool is synchronous, so it never runs in a live deployment.
///
/// The maximum time to wait
/// True if the pool was still processing when the timeout elapsed
public bool WaitForProcessing(TimeSpan timeout)
{
// synchronous mode has no worker thread to drain the queue, the caller pumps it via ProcessPending
if (_synchronous)
{
return false;
}
// re-check each pass since the shared queue signals idle as soon as a worker finds it empty, even if
// another worker is still processing or a request is parked
while (IsProcessing())
{
if (!_readyQueue.WaitHandle.WaitOne(timeout, _cancellationTokenSource.Token))
{
return true;
}
}
return false;
}
///
/// Whether any order still has a request in flight, either queued, being processed or parked.
///
private bool IsProcessing()
{
lock (_lock)
{
return _inFlight.Count > 0 || _readyQueue.IsBusy;
}
}
///
/// Stops every worker thread and waits for them to terminate, then releases the pool resources.
///
public void Dispose()
{
lock (_lock)
{
// already disposed, nothing else to do
if (_shuttingDown)
{
return;
}
// stop growing so the threads list is frozen and safe to iterate without taking a snapshot
_shuttingDown = true;
}
// let the workers drain whatever is queued and parked: once adding is complete their consuming
// enumerables finish naturally when the queue empties, so join before cancelling anything. Only
// escalate to StopSafely, which cancels the shared token and drops pending requests, on timeout
_readyQueue.CompleteAdding();
foreach (var thread in _threads)
{
try
{
if (thread != null && !thread.Join(ShutdownTimeout))
{
Log.Error($"OrderRequestProcessingPool.Dispose(): Exceeded timeout: {(int)ShutdownTimeout.TotalSeconds} seconds waiting for '{thread.Name}' to finish processing");
thread.StopSafely(ShutdownTimeout, _cancellationTokenSource);
}
}
catch (ThreadStateException)
{
// registered by a concurrent Dispatch but not started yet, nothing to drain on it
}
}
IsActive = false;
_readyQueue.DisposeSafely();
_cancellationTokenSource.DisposeSafely();
}
///
/// Creates and registers a worker thread without starting it, so callers can start it outside the lock.
/// Callers growing the pool on demand must hold .
///
/// The new worker thread, for the caller to start
private Thread AddThread()
{
var thread = new Thread(Run) { IsBackground = true, Name = $"Transaction Thread {_threads.Count}" };
_threads.Add(thread);
return thread;
}
///
/// Grows the pool by one worker when every existing worker is already busy, up to the maximum.
/// Caller must hold and start the returned thread, if any, outside of it.
///
/// The new worker thread to start, null when the pool doesn't need to grow
private Thread TryExpand()
{
if (_shuttingDown || _threads.Count >= _maximumThreads)
{
return null;
}
// only grow when every worker is already busy, so the request being enqueued would have to wait
if (Volatile.Read(ref _busyWorkers) >= _threads.Count)
{
Log.Trace($"OrderRequestProcessingPool.TryExpand(): adding new thread, current count {_threads.Count}");
return AddThread();
}
return null;
}
///
/// Worker thread loop that consumes ready requests until the pool is shut down. A single fixed worker
/// already consumes in arrival order so it skips the per-order bookkeeping.
///
private void Run()
{
if (_singleConsumer)
{
Drain(item => _processRequest(item.Request));
}
else
{
Drain(ProcessInOrder);
}
}
///
/// Consumes ready requests on the calling thread until the queue completes adding or the pool is shut down.
///
private void Drain(Action process)
{
try
{
foreach (var item in _readyQueue.GetConsumingEnumerable(_cancellationTokenSource.Token))
{
process(item);
}
}
catch (Exception err)
{
// unexpected error, we need to close down shop
_onError(err);
}
}
///
/// Processes a request and then drains, in arrival order, every follow up request parked for the same order,
/// so a single worker handles the whole order in sequence before moving on to other work.
///
private void ProcessInOrder(WorkItem item)
{
var request = item.Request;
Interlocked.Increment(ref _busyWorkers);
try
{
while (request != null)
{
_processRequest(request);
lock (_lock)
{
var parked = _inFlight[item.Key];
if (parked != null && parked.Count > 0)
{
request = parked.Dequeue();
}
else
{
// no more requests for this order in flight, drop its bookkeeping
_inFlight.Remove(item.Key);
request = null;
}
}
}
}
finally
{
Interlocked.Decrement(ref _busyWorkers);
}
}
///
/// Builds the routing key that ties an order's requests together, the combo group when it has one, otherwise
/// the order itself. Order ids and group ids are separate counters that can share a value, so the flag keeps
/// a simple order and a combo group from colliding.
///
private static (bool IsGroup, int Id) GetRoutingKey(Order order)
{
var group = order.GroupOrderManager;
return group?.Id > 0 ? (true, group.Id) : (false, order.Id);
}
///
/// Pairs a request with its routing key so the worker can drain the rest of the order without re-deriving it.
///
private readonly struct WorkItem
{
public OrderRequest Request { get; }
public (bool IsGroup, int Id) Key { get; }
public WorkItem(OrderRequest request, (bool IsGroup, int Id) key)
{
Request = request;
Key = key;
}
}
}
}