Async fair-share semaphore for multi-tenant resource management
Project description
aio-fairshare
Async fair-share semaphore for multi-tenant resource management in Python.
What is it?
aio-fairshare distributes a fixed pool of resources (e.g., browser tabs, database connections, API slots) fairly among active tenants. Unlike a regular semaphore, it dynamically adjusts each tenant's share as tenants join or leave.
The Problem
Imagine you have 10 browser tabs and multiple concurrent requests:
Regular Semaphore:
- Request A arrives first, grabs all 10 tabs
- Request B waits... and waits...
- Request A finishes after 60 seconds
- Request B finally starts
Fair-Share Semaphore:
- Request A arrives, can use up to 10 tabs
- Request B arrives, now each can use up to 5 tabs
- Both requests proceed concurrently!
- Request B finishes, Request A can now use all 10 tabs again
Installation
pip install aio-fairshare
Quick Start
import asyncio
from aio_fairshare import FairShareSemaphore
async def main():
# Create semaphore with 10 slots
semaphore = FairShareSemaphore(max_slots=10)
async def process_request(request_id: str):
# Register as a tenant
async with semaphore.tenant(request_id):
# Acquire slots (respects fair share)
async with semaphore.acquire():
print(f"{request_id}: Working with a slot")
await asyncio.sleep(1)
# Run multiple requests concurrently
await asyncio.gather(
process_request("request-1"),
process_request("request-2"),
process_request("request-3"),
)
asyncio.run(main())
Real-World Example: Browser Automation
from playwright.async_api import async_playwright
from aio_fairshare import FairShareSemaphore
# Global semaphore for browser tabs
tab_semaphore = FairShareSemaphore(max_slots=10)
async def scrape_urls(request_id: str, urls: list[str]):
async with async_playwright() as p:
browser = await p.chromium.launch()
async with tab_semaphore.tenant(request_id):
async def scrape_url(url: str):
async with tab_semaphore.acquire():
page = await browser.new_page()
try:
await page.goto(url)
return await page.title()
finally:
await page.close()
# Process URLs concurrently, respecting fair share
results = await asyncio.gather(*[scrape_url(u) for u in urls])
await browser.close()
return results
# Multiple requests can run concurrently with fair resource sharing
await asyncio.gather(
scrape_urls("req-1", ["https://example1.com", "https://example2.com"]),
scrape_urls("req-2", ["https://example3.com", "https://example4.com"]),
)
API Reference
FairShareSemaphore
FairShareSemaphore(
max_slots: int,
*,
min_share: int = 1,
poll_interval: float = 0.01,
share_calculator: Callable[[int, int], int] | None = None,
)
Parameters:
max_slots: Maximum number of concurrent slotsmin_share: Minimum slots guaranteed per tenant (default: 1)poll_interval: Seconds between acquisition attempts (default: 0.01)share_calculator: Custom function to calculate per-tenant share
Methods
tenant(tenant_id: str) -> TenantContext
Register a tenant and get a context for acquiring slots.
async with semaphore.tenant("my-tenant"):
# Tenant is registered
async with semaphore.acquire():
# Slot acquired
...
acquire(tenant_id: str | None = None) -> AsyncContextManager
Acquire a slot. Blocks until a slot is available within the tenant's fair share.
async with semaphore.acquire():
# Do work
...
stats() -> SemaphoreStats
Get current statistics.
stats = await semaphore.stats()
print(f"Active tenants: {stats.active_tenants}")
print(f"Available slots: {stats.available_slots}")
Context Variables
For advanced use cases, you can manage tenant ID via context variables:
from aio_fairshare import set_tenant_id, get_tenant_id, tenant_context
# Option 1: Set directly
set_tenant_id("my-tenant")
# Option 2: Use context manager
async with tenant_context("my-tenant"):
print(get_tenant_id()) # "my-tenant"
How Fair Sharing Works
| Tenants | Max Slots | Share per Tenant |
|---|---|---|
| 1 | 10 | 10 |
| 2 | 10 | 5 |
| 3 | 10 | 3 |
| 5 | 10 | 2 |
| 10 | 10 | 1 |
The share is recalculated dynamically whenever a tenant joins or leaves.
Custom Share Calculator
You can provide a custom function to calculate shares:
def priority_calculator(max_slots: int, num_tenants: int) -> int:
"""Give each tenant a bit more than equal share."""
return max_slots // num_tenants + 2
semaphore = FairShareSemaphore(
max_slots=20,
share_calculator=priority_calculator,
)
Comparison with Alternatives
| Feature | asyncio.Semaphore |
aiolimiter |
aio-fairshare |
|---|---|---|---|
| Global limit | ✅ | ✅ | ✅ |
| Per-tenant limit | ❌ | ✅ (fixed) | ✅ (dynamic) |
| Fair sharing | ❌ | ❌ | ✅ |
| Dynamic adjustment | ❌ | ❌ | ✅ |
| Zero dependencies | ✅ | ✅ | ✅ |
Development
# Clone the repo
git clone https://github.com/yourusername/aio-fairshare
cd aio-fairshare
# Install dev dependencies
pip install -e ".[dev]"
# Run tests
pytest
# Run linter
ruff check .
# Run type checker
mypy aio_fairshare
License
MIT License. See LICENSE for details.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters