Skip to content

Coming from Spring Boot

Most of what Spring Boot, Actuator, ShedLock and Resilience4j give a service has a grelmicro equivalent. This page maps each one, says where the behavior differs, and lists what grelmicro leaves to other libraries.

Quick reference

Spring grelmicro Page
Actuator liveness and readiness probes HealthChecks with /livez, /readyz and /healthz Health
A health indicator left out of readiness @health.check(..., critical=False) Health
A health indicator added to the liveness group @health.check(..., liveness=True) with HealthChecks(liveness=Liveness(...)) Catch a stuck worker
management.server.port OpsServer, for a process that serves no HTTP Ops server
Micrometer, /actuator/prometheus Metrics() and metrics_router() Metrics
@Timed @measure Metrics
@Scheduled(fixedDelay = ...), @Scheduled(cron = ...) @tasks.every(interval=...), @tasks.cron("...") Task Scheduler
ShedLock @SchedulerLock gate="claim" on a task Task Scheduler
@Cacheable, @CacheEvict @cached(cache, tags=[...]), cache.delete_tags(...) @cached
@Cacheable(sync = true) @cached(...), whose default lock="process" folds misses in one process. lock="cluster" folds them across replicas Stampede protection
Resilience4j CircuitBreaker, Retry, RateLimiter, Bulkhead, TimeLimiter CircuitBreaker, @retry, RateLimiter, Bulkhead, Timeout Resilience
SLF4J MDC add_context() in a handler, @instrument and span() Tracing
OAuth2 resource server with JWT AuthenticatedRequests with a JWTVerifier Authentication
hasAuthority("SCOPE_x") Authenticated(scopes=["x"]) Authentication
OAuth2 client credentials OAuthClient with ClientCredentials Tokens
@TransactionalEventListener Outbox.publish(...) and @outbox.handler Outbox
@SpringBootTest with MockMvc, @MockitoBean TestClient(app) with micro.fake() Testing

Concept differences

A claimed task renews its lease

ShedLock releases the lock when the task ends, and at the latest after lockAtMostFor. Without its KeepAliveLockProvider, a task that runs longer than lockAtMostFor can run on a second node at the same time. A grelmicro task with gate="claim" renews its lease while the body runs, so lease_duration only bounds how long a crashed worker keeps the claim. A bare async with TaskLock(...) does not renew and behaves like ShedLock: lockAtLeastFor is min_hold_duration and lockAtMostFor is lease_duration.

A claimed cron task takes no lock at all. It records each fire it claims, and a fire missed while every worker was down runs once when a worker comes back.

An interval waits for the previous run

@tasks.every(interval=60) counts from the end of one run to the start of the next, like fixedDelay. There is no fixedRate equivalent: a run that takes 20 seconds starts the next one 80 seconds after it started.

Health has three fixed endpoints, not groups

Actuator groups are named sets you define. grelmicro has three endpoints: /livez reflects the liveness checks, /readyz runs the critical checks, /healthz runs the rest. A check with critical=False stays out of readiness, and its failure keeps the answer at 200. There is no custom group.

Retry needs to know what to retry

Spring Framework 7 @Retryable retries every exception, three retries after the first call. grelmicro @retry requires when=, the exceptions worth retrying, and attempts counts every call, the first one included.

The circuit breaker counts consecutive failures

Resilience4j opens on a failure rate or a slow-call rate over a sliding window. grelmicro opens after a number of consecutive failures. Its state can live in Redis, Valkey or PostgreSQL, so every replica sees the same breaker.

Resilience runs in a fixed order

Resilience4j wraps a call as Retry(CircuitBreaker(RateLimiter(TimeLimiter(Bulkhead(call))))). A grelmicro Stack runs Fallback, Retry, CircuitBreaker, RateLimiter, Bulkhead and then Timeout, so the timeout sits inside the bulkhead.

Context lives on the call, not the thread

MDC is a map for each thread that you can write to anywhere. grelmicro context follows the call across await. micro.install(app) opens one for each request and message, and @instrument and span() open one for a block, so add_context in a route handler adds its fields to every log record until the response. Outside all of them, add_context does nothing.

The outbox survives a crash

@TransactionalEventListener runs after the commit, in memory. A crash between the commit and the listener loses the event. Outbox.publish(...) writes the message in the same transaction, and a relay delivers it at least once, with retries and a dead-letter state.

Side by side

A job that runs on one node

@Scheduled(fixedDelay = 60_000)
@SchedulerLock(name = "cleanup", lockAtMostFor = "5m", lockAtLeastFor = "30s")
public void cleanup() {
    // ...
}
from grelmicro.task import Tasks

tasks = Tasks()


@tasks.every(interval=60, gate="claim")
async def cleanup():
    print("Running cleanup...")

A check that stays out of readiness

management:
  endpoint:
    health:
      group:
        readiness:
          include: "readinessState,db"
from grelmicro.health import HealthChecks, HealthDetails

health = HealthChecks()


@health.check("external-api", critical=False)
async def check_external_api() -> HealthDetails | None:
    return None

A JWT resource server

@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
    http
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/catalog").permitAll()
            .requestMatchers(HttpMethod.DELETE, "/orders/**")
                .hasAuthority("SCOPE_orders:write")
            .anyRequest().authenticated())
        .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
    return http.build();
}
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 side effect after the commit

@Transactional
public void signUp(String email) {
    User user = users.save(new User(email));
    events.publishEvent(new UserSignedUp(user.getId(), email));
}

@TransactionalEventListener
public void sendWelcome(UserSignedUp event) {
    // ...
}
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 Spring In grelmicro
profile A named set of configuration, chosen by spring.profiles.active A Shield preset such as internal or api. GREL_ENVIRONMENT only names the deployment tier and selects no configuration
container The IoC container that builds and injects beans The Grelmicro app that opens and closes your components, or a Docker container
component Any bean found by @Component scanning One pattern wired into the app, such as Cache or Coordination, with a start and a stop
provider An AuthenticationProvider A connection to one vendor, such as RedisProvider
tags Micrometer meter dimensions Cache tags for bulk invalidation. Metric dimensions are called attributes

No equivalent, do this instead

  • Profiles: set the values for each environment in GREL_* variables or a mounted file. See Configuration.
  • Custom health groups: use critical=False and the ?exclude= query on /readyz and /healthz.
  • A failure-rate circuit breaker: grelmicro has the consecutive count only.
  • An application event bus: call the function. Use the Outbox for work after a commit, and FastStream for a broker.
  • Spring Data: use SQLAlchemy or SQLModel. Outbox.publish takes their AsyncSession.
  • WebSocket broadcast: use your framework's WebSocket support with a broker. grelmicro still authenticates the handshake.