| name | runner-dev |
| description | Development guide for the lemline-runner ecosystem. Use when working with the modular runner architecture (lemline-runner-* modules), messaging (commands/events channels), feature modules (waits, retries, parents, schedules, listeners, forks, failures), shared infrastructure (lemline-runner-common), CLI commands, configuration, repositories with Kotlin coroutines, or Flyway migrations. Covers the dual-channel architecture, outbox pattern, and production best practices for the Quarkus/Kotlin runtime. |
Lemline Runner Development Guide
Purpose
Guide development across the modular lemline-runner ecosystem - a collection of feature-focused modules built on shared infrastructure. The main lemline-runner module provides messaging, CLI, and configuration, while feature modules (lemline-runner-*) implement specific workflow capabilities.
Core Documentation:
- Main Runner README - Configuration, database, messaging setup
- CLI Commands
- Configuration
- Messaging Architecture
- Database Tables
- Repositories Guide
Module Architecture
Core Infrastructure
| Module | Purpose |
|---|---|
lemline-runner-common |
Shared infrastructure: outbox pattern, cleaner pattern, repository abstractions, model interfaces |
lemline-runner |
Main runtime: messaging (commands/events), CLI, configuration, activity runners |
Feature Modules
| Module | Purpose | Tables |
|---|---|---|
lemline-runner-definitions |
Workflow definition storage and cache sync | lemline_definitions |
lemline-runner-waits |
Wait/sleep task implementation | lemline_waits |
lemline-runner-retries |
Task retry scheduling with exponential backoff | lemline_retries |
lemline-runner-parents |
Parent-child workflow relationships (run task) | lemline_parents |
lemline-runner-forks |
Parallel branch execution (fork task) | lemline_forks, lemline_fork_branches |
lemline-runner-schedules |
Scheduled workflow execution (cron/interval/after) | lemline_schedules |
lemline-runner-listeners |
CloudEvent listeners (listen task) | lemline_listeners, lemline_listener_events |
lemline-runner-failures |
Failed workflow tracking and dead letter storage | lemline_failures |
Each feature module has its own README.md with detailed architecture, file reference, and usage patterns.
Quick Reference
If you need to work on...
A Specific Feature:
Read the module's README first:
- Wait task → lemline-runner-waits/README.md
- Retry logic → lemline-runner-retries/README.md
- Parent-child workflows → lemline-runner-parents/README.md
- Fork/parallel execution → lemline-runner-forks/README.md
- Scheduled workflows → lemline-runner-schedules/README.md
- CloudEvent listeners → lemline-runner-listeners/README.md
- Failure tracking → lemline-runner-failures/README.md
- Definition storage → lemline-runner-definitions/README.md
Messaging:
First, read runner-messaging.md
- Add a new command/event type → Modify WorkflowState.kt
- Update commands behavior → Modify WorkflowCommandHandler.kt
- Update events behavior → Modify WorkflowEventHandler.kt
Shared Infrastructure:
Read lemline-runner-common/README.md first
- Add new outbox processor → Extend
AbstractOutbox<T>in lemline-runner-common - Add new cleaner → Extend
AbstractCleaner<T>in lemline-runner-common - Add repository operations → Add to
lemline-runner-common/repositories/ops/ - Add model interface → Create in
lemline-runner-common/models/
Configuration:
First read runner-configuration.md
- Add/Update config property → Modify LemlineConfiguration.kt
- Add database/broker type → Read runner-configuration.md
CLI:
First read runner-cli.md
Critical Rules
✅ ALWAYS Do This
- Use
suspendfunctions for all database operations (Kotlin coroutines, NOT Mutiny) - Use native SQL via repositories (NOT Hibernate ORM/Panache)
- Use Flyway migrations for all schema changes
- Support all databases (PostgreSQL, MySQL, H2) - use database-agnostic SQL
- Use
FOR UPDATE SKIP LOCKEDfor outbox queries to prevent double-processing - Use IDV7 (UUID v7) for all entity IDs - time-sortable, globally unique
- Test with all supported databases when touching persistence layer
❌ NEVER Do This
- Use Mutiny (Uni/Multi) - use Kotlin coroutines with
suspendfunctions instead - Use Hibernate ORM/Panache - use native SQL with repositories
- Block the event loop - all I/O must be non-blocking
- Skip database migrations - never modify tables directly
- Use database-specific SQL without providing variants for all databases
- Commit sensitive data (.env, credentials, API keys)
Architecture Overview
Modular Feature Design
┌────────────────────────────────────────────────────────────────────┐
│ lemline-runner (core) │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Messaging │ │ CLI Commands │ │ Configuration │ │
│ │ (commands/events)│ │ (Picocli) │ │ (Quarkus) │ │
│ └──────────────────┘ └──────────────────┘ └──────────────────┘ │
└────────────────────────────────────────────────────────────────────┘
│
┌───────────────┴────────────────┐
│ lemline-runner-common │
│ (shared infrastructure) │
│ • Outbox pattern │
│ • Cleaner pattern │
│ • Repository abstractions │
│ • Model interfaces │
└───────────────┬────────────────┘
│
┌───────────────────────┼────────────────────────┐
│ │ │
┌───────▼──────┐ ┌───────────▼──────┐ ┌────────────▼─────┐
│ Feature │ │ Feature │ │ Feature │
│ Modules │ │ Modules │ │ Modules │
│ │ │ │ │ │
│ • waits │ │ • schedules │ │ • definitions │
│ • retries │ │ • listeners │ │ • failures │
│ • parents │ │ │ │ │
│ • forks │ │ │ │ │
└──────────────┘ └───────────────────┘ └──────────────────┘
Dual-Channel Messaging
┌─────────────────────────────────────────────────────────────────────────────┐
│ COMMANDS CHANNEL (high-throughput) │
│ commands ──► WorkflowCommandHandler ──► commands │
│ ▲ │ │
│ │ │ (needs persistence) │
└───────│─────────────────│───────────────────────────────────────────────────┘
│ │
┌───────│─────────────────│───────────────────────────────────────────────────┐
│ │ ▼ EVENTS CHANNEL │
│ │ events ──► WorkflowEventHandler ──► Feature Modules │
│ │ │ │
│ │ ┌─────────┴─────────┐ │
│ │ ▼ ▼ │
│ │ ┌─────────────────┐ ┌─────────────────┐ │
│ │ │ Feature Service │ │ Feature Service │ │
│ │ │ (e.g., Waits) │ │ (e.g., Parents) │ │
│ │ └────────┬────────┘ └────────┬────────┘ │
│ │ │ │ │
│ │ ▼ ▼ │
│ │ ┌─────────────────┐ ┌─────────────────┐ │
│ │ │ DB Table │ │ DB Table │ │
│ │ │ (lemline_waits) │ │(lemline_parents)│ │
│ │ └────────┬────────┘ └─────────────────┘ │
│ │ │ │
│ │ ▼ │
│ │ ┌─────────────────┐ │
│ │ │ Outbox Relay │ │
│ └──────────────│ (scheduled poll)│ │
│ └─────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
Key principle: State travels with messages. Database only used when necessary. Each feature is self-contained.
Key Files
| Purpose | File |
|---|---|
| Step execution | StepByStepRunner.kt |
| Command handling | WorkflowCommandHandler.kt |
| Event handling | WorkflowEventHandler.kt |
| Message structure | InstanceMessage.kt |
| Outbox base | AbstractOutbox.kt |
| Cleaner base | AbstractCleaner.kt |
Common Patterns
Creating a New Feature Module
1. Module Structure:
lemline-runner-myfeature/
├── src/
│ ├── main/kotlin/com/lemline/runner/myfeature/
│ │ ├── MyFeatureService.kt ← Business logic
│ │ ├── MyFeatureModel.kt ← Database entity
│ │ ├── MyFeatureRepository.kt ← Database operations
│ │ ├── MyFeatureOutbox.kt ← Outbox processor (optional)
│ │ ├── MyFeatureCleaner.kt ← Cleanup scheduler (optional)
│ │ └── MyFeatureConfig.kt ← Configuration
│ ├── test/kotlin/ ← Tests for all databases
│ └── testFixtures/kotlin/ ← Test utilities
├── build.gradle.kts
└── README.md ← Architecture and usage
2. Dependencies in build.gradle.kts:
dependencies {
implementation(project(":lemline-common"))
implementation(project(":lemline-core"))
implementation(project(":lemline-runner-common")) // Always required!
// Add other feature modules if needed
}
3. Model with Interfaces:
// Compose behavior from lemline-runner-common interfaces
data class MyFeatureModel(
override val id: IDV7,
override val instanceMessage: InstanceMessage<MyEvent>,
// Outbox fields
override val outboxScheduledFor: Instant,
override var outboxDelayedUntil: Instant? = outboxScheduledFor,
override var outboxAttemptCount: Int = 0,
override var outboxCompletedAt: Instant? = null,
override var outboxFailedAt: Instant? = null,
// ... other outbox fields
// Cleanup field
override var cleanupAfter: Instant? = null,
) : WithId, WithInstanceMessage, WithOutbox, WithCleanup
4. Repository:
@ApplicationScoped
class MyFeatureRepository : Repository<MyFeatureModel>(),
WithIdRepository<MyFeatureModel>,
OutboxRepository<MyFeatureModel>,
CleanerRepository<MyFeatureModel> {
override suspend fun findByUUID(uuid: IDV7): MyFeatureModel? {
// Use pool from base Repository
return pool.withConnection { conn ->
conn.preparedQuery("SELECT * FROM lemline_myfeature WHERE id = $1")
.execute(Tuple.of(uuid.value))
.awaitSuspending()
.firstOrNull()
?.let { MyFeatureModel.fromRow(it) }
}
}
// OutboxRepository provides findPendingWithLock() automatically
// CleanerRepository provides findOldCompleted() automatically
}
5. Service:
@ApplicationScoped
class MyFeatureService @Inject constructor(
private val repository: MyFeatureRepository,
private val commandEmitter: CommandEmitter
) {
suspend fun handleMyEventStarted(message: InstanceMessage<MyEventStarted>) {
val model = MyFeatureModel.from(message)
repository.insert(model)
}
}
6. Outbox (if needed):
@ApplicationScoped
class MyFeatureOutbox @Inject constructor(
private val repository: MyFeatureRepository,
private val emitter: WorkflowCommandEmitter,
private val config: MyFeatureConfig
) : AbstractOutbox<MyFeatureModel>(
name = "MyFeature",
config = config.outbox
) {
override suspend fun findEntitiesToProcess(limit: Int) =
repository.findPendingWithLock(limit)
override suspend fun process(entity: MyFeatureModel) {
emitter.send(createResumeCommand(entity))
}
override suspend fun markCompleted(entity: MyFeatureModel) {
repository.markCompleted(entity.id)
}
}
7. Register in WorkflowEventHandler:
// In lemline-runner/src/.../messaging/events/WorkflowEventHandler.kt
when (val state = message.state) {
is MyEventStarted -> myFeatureService.handleMyEventStarted(message)
// ...
}
Event Handling Pattern
// In feature module service
suspend fun handleMyEvent(message: InstanceMessage<MyEvent>) {
val event = message.state
// 1. Create model from event
val model = MyFeatureModel.from(message, event)
// 2. Persist to database
repository.insert(model)
// 3. If immediate resume needed (no outbox), emit command
if (!needsDelay) {
commandEmitter.send(createResumeCommand(message))
}
// Otherwise, outbox processor will handle it later
}
Testing Patterns
Multi-Database Repository Test
Test all 3 databases: PostgreSQL, MySQL, H2
// Base test with test logic
abstract class MyRepositoryTestBase : FunSpec({
lateinit var repository: MyRepository
test("should find by UUID") {
val model = createTestModel()
repository.insert(model)
val found = repository.findByUUID(model.id)
found shouldNotBe null
found?.id shouldBe model.id
}
})
// PostgreSQL test
@QuarkusTest
@TestProfile(PostgresProfile::class)
class MyRepositoryPostgresTest : MyRepositoryTestBase() {
@Inject
override lateinit var repository: MyRepository
}
// MySQL test
@QuarkusTest
@TestProfile(MySQLProfile::class)
class MyRepositoryMySQLTest : MyRepositoryTestBase() {
@Inject
override lateinit var repository: MyRepository
}
// H2 test
@QuarkusTest
@TestProfile(H2Profile::class)
class MyRepositoryH2Test : MyRepositoryTestBase() {
@Inject
override lateinit var repository: MyRepository
}
Feature Service Test
@QuarkusTest
class MyFeatureServiceTest : FunSpec({
@Inject
lateinit var service: MyFeatureService
@Inject
lateinit var repository: MyFeatureRepository
test("should handle event") {
val message = createTestMessage()
service.handleMyEvent(message)
// Verify database state
val stored = repository.findByUUID(message.workflowId)
stored shouldNotBe null
}
})
Outbox Test
@QuarkusTest
class MyFeatureOutboxTest : FunSpec({
@Inject
lateinit var outbox: MyFeatureOutbox
@Inject
lateinit var repository: MyFeatureRepository
test("should process pending entities") {
// Insert pending entity
val model = createPendingModel()
repository.insert(model)
// Process
outbox.doWork()
// Verify marked completed
val processed = repository.findByUUID(model.id)
processed?.outboxCompletedAt shouldNotBe null
}
})
Database Migrations
IMPORTANT: Place migrations in the feature module, not in lemline-runner!
Location in Feature Module
lemline-runner-myfeature/
└── src/main/resources/db/migration/
├── postgresql/
│ └── V{N}__Create_myfeature_table.sql
├── mysql/
│ └── V{N}__Create_myfeature_table.sql
└── h2/
└── V{N}__Create_myfeature_table.sql
Naming: V{N}__Description.sql where N is the next available version number across all modules.
Check existing versions first:
# Find highest version number
find . -name "V*.sql" | sort
Migration Template
-- PostgreSQL version
CREATE TABLE lemline_myfeature
(
id UUID PRIMARY KEY,
-- Instance message (serialized workflow state)
instance_message TEXT NOT NULL,
-- Feature-specific columns
my_custom_field VARCHAR(255),
-- Outbox columns (if using outbox pattern)
outbox_scheduled_for TIMESTAMP NOT NULL,
outbox_delayed_until TIMESTAMP NOT NULL,
outbox_attempt_count INT NOT NULL DEFAULT 0,
outbox_completed_at TIMESTAMP,
outbox_failed_at TIMESTAMP,
outbox_error_class VARCHAR(255),
outbox_error_message VARCHAR(500),
outbox_error_stacktrace TEXT,
-- Cleanup column (if using cleaner pattern)
cleanup_after TIMESTAMP,
-- Timestamps
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- Index for outbox queries (FOR UPDATE SKIP LOCKED)
CREATE INDEX idx_lemline_myfeature_pending
ON lemline_myfeature (outbox_delayed_until)
WHERE outbox_completed_at IS NULL AND outbox_failed_at IS NULL;
-- Index for cleanup queries
CREATE INDEX idx_lemline_myfeature_cleanup
ON lemline_myfeature (cleanup_after)
WHERE cleanup_after IS NOT NULL;
Database-Specific Considerations
PostgreSQL:
- Use
UUIDtype - Use
TEXTfor long strings - Partial indexes with
WHEREclause
MySQL:
- Use
CHAR(36)instead of UUID - Use
LONGTEXTfor long strings - No partial indexes - use regular index
H2:
- Use
UUIDtype - Use
CLOBfor very long strings - Supports partial indexes
Testing Migrations
# Test with PostgreSQL
./gradlew :lemline-runner-myfeature:test -Dquarkus.test.profile=postgres
# Test with MySQL
./gradlew :lemline-runner-myfeature:test -Dquarkus.test.profile=mysql
# Test with H2
./gradlew :lemline-runner-myfeature:test -Dquarkus.test.profile=h2
Running Tests
# All tests in main runner
./gradlew :lemline-runner:test
# All tests in feature module
./gradlew :lemline-runner-myfeature:test
# Specific test class
./gradlew :lemline-runner-myfeature:test --tests "com.lemline.runner.myfeature.MyTest"
# Test specific module with specific database
./gradlew :lemline-runner-waits:test -Dquarkus.test.profile=postgres
./gradlew :lemline-runner-waits:test -Dquarkus.test.profile=mysql
./gradlew :lemline-runner-waits:test -Dquarkus.test.profile=h2
# Test all runner modules
./gradlew test -p lemline-runner -p lemline-runner-common -p lemline-runner-waits -p lemline-runner-retries ...
Adding a New DSL Feature
Example: Adding support for a new task type that requires persistence
Add task to lemline-core (see core-dev skill)
- Create model in
lemline-core/src/.../models/tasks/ - Create processor in
lemline-core/src/.../processors/ - Throw
AsyncTaskExceptionif needs persistence
- Create model in
Create feature module
mkdir -p lemline-runner-myfeature/src/{main,test}/kotlin/com/lemline/runner/myfeature mkdir -p lemline-runner-myfeature/src/main/resources/db/migration/{postgresql,mysql,h2}Add to settings.gradle.kts
include("lemline-runner-myfeature")Create build.gradle.kts
dependencies { implementation(project(":lemline-common")) implementation(project(":lemline-core")) implementation(project(":lemline-runner-common")) }Implement feature (Model, Repository, Service, Outbox, Cleaner)
Add migrations for all 3 databases
Register in WorkflowEventHandler
// In lemline-runner when (val state = message.state) { is MyFeatureStarted -> myFeatureService.handleStarted(message) // ... }Add tests for all databases
Add to main runner dependencies
// In lemline-runner/build.gradle.kts implementation(project(":lemline-runner-myfeature"))Document in README.md in the feature module
Related Documentation
- CLAUDE.md - Project-wide guidelines and architecture overview
- AGENTS.md - Build, test, and lint commands
- core-dev skill - lemline-core module (DSL, orchestrators, processors)
- Serverless Workflow DSL - https://serverlessworkflow.io/
Module Dependency Graph
lemline-common
↑
│
lemline-core
↑
│
lemline-runner-common ←────┐
↑ │
│ │
├──────────────────────┼─────────────────┐
│ │ │
lemline-runner-* lemline-runner-* lemline-runner-*
(feature modules) (feature modules) (feature modules)
↑ ↑ ↑
│ │ │
└──────────────────────┴─────────────────┘
│
lemline-runner
(main runtime)
Key principle: Feature modules depend only on common modules, not on each other (except rare cases like schedules/parents).