Coming from Symfony
Symfony's Lock, RateLimiter, Cache, Scheduler, Messenger and security firewall all have a grelmicro equivalent. This page maps each one, says where the behavior differs, and lists what grelmicro leaves to other libraries.
Quick reference
| Symfony | grelmicro | Page |
|---|---|---|
Firewall with stateless: true and access_token |
AuthenticatedRequests, with exclude= for public paths |
Authentication |
oidc token handler with audience, issuers, discovery |
JWTVerifier.discover(...) with audience= and issuer= |
JWT |
access_control, #[IsGranted] |
Authenticated(scopes=[...]) |
Authentication |
| RateLimiter component | RateLimiter.sliding_window(...), RateLimiter.token_bucket(...), RateLimitedRequests |
Rate limit |
Lock component, createLock(), refresh() |
Lock("name"), extend() |
Lock |
Cache Contracts get($key, fn), TagAwareCacheInterface |
@cached(cache, tags=[...]), cache.delete_tags(...) |
@cached |
Cache stampede lock, beta early expiration |
lock= and early= on @cached |
Stampede protection |
#[AsPeriodicTask], #[AsCronTask] |
@tasks.every(...), @tasks.cron("...") |
Task Scheduler |
Scheduler ->lock(...) |
gate=LeaderElection(...) |
Task Scheduler |
Scheduler ->stateful() with processOnlyLastMissedRun(true) |
A claimed cron task | Missed fires |
Messenger retries and failure_transport |
The outbox relay, its dead-letter state and outbox.redrive(...). @retry for a call in the same process |
Outbox, Retry |
WebTestCase, static::createClient() |
Your framework's TestClient with micro.fake() |
Testing |
Concept differences
The scheduler runs inside the service
Symfony runs schedules in a messenger:consume worker. grelmicro runs them in the service process, next to your routes. A process that serves no HTTP gets its probes from an OpsServer.
The schedule lock is a leader election
Symfony's ->lock() covers the whole schedule: one worker runs it and the others wait as standbys. That is gate=LeaderElection(...). gate="claim" is finer: each interval or fire goes to whichever worker claims it first.
The outbox is a table, not a bus
Messenger routes messages to transports such as Doctrine, AMQP or Redis. The grelmicro outbox is one PostgreSQL table, written inside your transaction and drained by a relay in the same service. For a broker, use FastStream.
Scopes, not roles
Authenticated(scopes=[...]) checks the scopes in the token. There are no roles and no voters. To check a role, read its claim with Claims in the handler and answer 403.
Locks are not reentrant
A second acquire of the same Lock from the same task raises LockReentrantError. Use two locks with different names when you need two independent locks.
Side by side
A job that runs on one worker
#[AsSchedule('default')]
class CleanupSchedule implements ScheduleProviderInterface
{
public function getSchedule(): Schedule
{
return new Schedule()
->add(RecurringMessage::every('1 minute', new Cleanup()))
->lock($this->lockFactory->createLock('cleanup'));
}
}
from grelmicro.coordination import LeaderElection
from grelmicro.providers.redis import RedisProvider
from grelmicro.task import Tasks
redis = RedisProvider("redis://localhost:6379/0")
leader = LeaderElection("my-service", backend=redis.leaderelection_backend())
tasks = Tasks()
tasks.add_task(leader)
@tasks.every(interval=60, gate=leader)
async def cleanup():
print("Running cleanup...")
A protected API
security:
firewalls:
main:
stateless: true
access_token:
token_handler:
oidc:
algorithms: ['RS256']
audience: 'orders-api'
issuers: ['https://auth.example.com/']
discovery:
base_uri: https://auth.example.com/
cache:
id: cache.app
access_control:
- { path: ^/catalog, roles: PUBLIC_ACCESS }
- { path: ^/, roles: IS_AUTHENTICATED }
from fastapi import FastAPI
from grelmicro import Grelmicro
from grelmicro.http import AuthenticatedRequests, ErrorResponses
from grelmicro.integrations.fastapi import (
Anonymous,
Authenticated,
CurrentPrincipal,
)
from grelmicro.security import JWTVerifier
micro = Grelmicro(
uses=[
ErrorResponses(),
AuthenticatedRequests(
JWTVerifier.discover(
"https://auth.example.com/", audience="orders-api"
),
exclude=("/livez", "/readyz"),
),
]
)
app = FastAPI()
@app.get("/orders")
async def list_orders(principal: CurrentPrincipal) -> dict[str, str | None]:
return {"caller": principal.subject}
@app.delete(
"/orders/{order_id}",
dependencies=[Authenticated(scopes=["orders:write"])],
)
async def cancel(order_id: int) -> dict[str, int]:
return {"cancelled": order_id}
@app.get("/catalog", dependencies=[Anonymous()])
async def catalog() -> list[str]:
return ["tea", "coffee"]
micro.install(app)
A rate limit
framework:
rate_limiter:
api:
policy: 'sliding_window'
limit: 100
interval: '1 minute'
from fastapi import FastAPI
from grelmicro import Grelmicro
from grelmicro.http import ErrorResponses, RateLimitedRequests
from grelmicro.providers.redis import RedisProvider
from grelmicro.resilience import RateLimiter, RateLimiterComponent
from grelmicro.security import TrustedProxies
redis = RedisProvider("redis://localhost:6379/0")
burst = RateLimiter.sliding_window("burst", limit=100, window=60)
daily = RateLimiter.sliding_window("daily", limit=10000, window=86400)
micro = Grelmicro(
uses=[
RateLimiterComponent(redis),
ErrorResponses(),
RateLimitedRequests(
burst,
daily,
trusted=TrustedProxies(["10.0.0.0/8"]),
exclude=("/livez", "/readyz"),
),
]
)
app = FastAPI()
micro.install(app)
Work that must not be lost
framework:
messenger:
failure_transport: failed
transports:
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
retry_strategy:
max_retries: 3
delay: 1000
multiplier: 2
failed: 'doctrine://default?queue_name=failed'
routing:
'App\Message\WelcomeEmail': async
import asyncio
import os
from pydantic import BaseModel
from grelmicro import Grelmicro
from grelmicro.outbox import Message, Outbox
from grelmicro.providers.postgres import PostgresProvider
postgres = PostgresProvider(os.environ["POSTGRES_URL"])
outbox = Outbox(postgres)
micro = Grelmicro(uses=[outbox])
delivered = asyncio.Event()
class WelcomeEmail(BaseModel):
to: str
user_id: int
async def send_email(to: str, idempotency_key: str) -> None:
print(f"welcome email to {to}")
delivered.set()
@outbox.handler(WelcomeEmail)
async def send_welcome(message: Message[WelcomeEmail]) -> None:
assert message.data is not None # a typed handler always gets its model
await send_email(to=message.data.to, idempotency_key=str(message.id))
async def sign_up(email: str) -> None:
async with postgres.client.acquire() as conn, conn.transaction():
user_id = await conn.fetchval(
"INSERT INTO users (email) VALUES ($1) RETURNING id", email
)
await outbox.publish(conn, WelcomeEmail(to=email, user_id=user_id))
async def main() -> None:
async with micro:
await postgres.client.execute(
"CREATE TABLE IF NOT EXISTS users (id serial PRIMARY KEY, email text)"
)
await sign_up("alice@example.com")
async with asyncio.timeout(5):
await delivered.wait()
if __name__ == "__main__":
asyncio.run(main())
False friends
| Word | In Symfony | In grelmicro |
|---|---|---|
| container | The service container that injects dependencies | The Grelmicro app that opens and closes your components, or a Docker container |
| component | A reusable PHP library, such as Lock | One pattern wired into the app, such as Cache or Coordination, with a start and a stop |
| provider | A user provider that loads users | A connection to one vendor, such as RedisProvider |
| profile | The profiler's record of one request | A Shield preset such as internal or api |
| tags | Service tags. Cache tags mean the same in both | Cache tags for bulk invalidation |
| middleware | Messenger bus middleware | ASGI middleware a component adds through micro.install(app) |
| channels | Monolog channels such as app or doctrine |
Where a configuration warning shows up: as a Python warning and in the log |
No equivalent, do this instead
- Voters: check ownership in the handler, with
CurrentPrincipal. - Console commands: use argparse, Typer or Click.
python -m grelmicro checkchecks your wiring in CI. - The profiler: use request spans from Tracing and Metrics.
- EventDispatcher: call the function. Use the Outbox for work after a commit.
- Doctrine: use SQLAlchemy or SQLModel.
Outbox.publishtakes theirAsyncSession. - Mercure: use your framework's WebSocket support with a broker. grelmicro still authenticates the handshake.
- A fixed-window rate limiter: grelmicro has sliding window and token bucket.