用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
直接命令不会经过审查 Prompt;运行前请先检查来源。
npx skills add https://github.com/navikt/helved-utbetaling --skill kafka-topology命令会保持在同一行。复制前请横向滚动并检查完整内容。
想先保存到本地?可下载 SkillsMP 当前能够提供的文件。
This skill should be used when the user needs guidance on any of the 200+ http4k modules - it contains patterns and API usage examples for all http4k technologies.
Custom JDBC patterns for helved-utbetaling - Migrator, Dao<T>, transaction { }, CoroutineDatasource. Triggers - "write a migration", "create a DAO", "database query", "transaction", "/database".
Build Ktor routes, StatusPages handlers, and authentication blocks following helved-utbetaling conventions. Triggers on "Ktor route", "REST endpoint", "API handler", "StatusPages", "/ktor-routing".
基于 SOC 职业分类
正在显示 SKILL.md
| name | kafka-topology |
| description | Build Kafka Streams topologies using the helved-utbetaling custom DSL |
| license | MIT |
| compatibility | opencode |
| metadata | {"audience":"ai-assistant","language":"kotlin","framework":"kafka-streams","domain":"nav-payment-system"} |
I guide you to use the custom Kafka Streams DSL in libs/kafka/ correctly. This DSL wraps raw Kafka Streams with type-safe builders, custom processors, and opinionated error handling. Do NOT use raw Kafka Streams APIs -- use these abstractions instead.
Load this skill when writing or modifying Kafka topologies, Topics, state stores, or stream processors.
Prerequisites: Root
AGENTS.mdcovers general architecture.apps/AGENTS.mdhas the Kafka topic reference table. Loadlibs-referenceskill for full library API details.
Entry point -- the topology { } function with Topology as the receiver:
val topology = topology("app-name") {
// Available operations inside this block:
consume(topic) // -> ConsumedStream<K,V>
consume(table) // -> KTable<K,V>
globalKTable(table) // -> GlobalKTable<K,V>
intercept { builder -> } // escape hatch to raw StreamsBuilder
}
Type-safe topic abstraction with key/value serdes:
object Topics {
val utbetalinger = Topic<String, Utbetaling>(
name = "helved.utbetalinger.v1",
keySerde = Serdes.String(),
valueSerde = JsonSerde(Utbetaling::class)
)
}
Wraps a Topic with a state store name. Used for KTable materialization:
val sakerTable = Table<SakKey, Set<UtbetalingId>>(
topic = Topics.saker,
storeName = "saker-store"
)
Store defines a state store. StateStore is the read-only runtime wrapper:
val store: StateStore<K, V> = streams.getStore(myStore)
store.getOrNull(key) // K -> V?
store.iterator() // Iterator<KeyValue<K, V>>
store.filter { k, v -> predicate }
The DSL enforces a type-safe progression. Each type exposes only valid operations:
consume(topic) -> ConsumedStream
.map { k, v -> Pair(k2, v2) } -> MappedStream
.branch(predicate) { ... } -> BranchedStream
.forEach { k, v -> } (terminal)
.groupByKey() -> GroupedStream
MappedStream
.join(table) { v, tableV -> } -> JoinedStream
.leftJoin(table) { v, tableV -> } -> JoinedStream
.branch(predicate) { ... } -> BranchedMappedStream
.produce(topic) (terminal)
BranchedStream / BranchedMappedStream
.branch(predicate) { ... } (chain more branches)
.default { ... } (catch-all, required to end branching)
GroupedStream
.aggregate(init, aggregator) -> KTable
.windowedBy(window) -> TimeWindowedStream / SessionWindowedStream
.branch() requires a .default {} to terminate.produce(topic) is the terminal operation to write to a topic.forEach {} is the terminal operation for side effects (DB writes, logging).rekey { newKey } changes the stream key (triggers repartition).repartition(numPartitions) explicitly repartitionsTop-level functions in Serde.kt:
string() // Serdes<String, String>
bytes() // Serdes<ByteArray, ByteArray>
json<V>() // Serdes<String, V> using Jackson
jsonList<V>() // Serdes<String, List<V>>
xml<V>() // Serdes<String, V> using Jackson XML
jaxb<V>() // Serdes<String, V> using JAXB
Result.catch { }Wraps business logic in a Result<V, StatusReply>. Catches ApiError and Throwable:
consume(topic)
.map { key, value ->
Result.catch {
// Business logic that may throw
processPayment(value)
}
}
.branch(Result::isErr) { stream ->
// Route errors to status topic
stream.map { key, err -> Pair(key, err.unwrap()) }
.produce(Topics.status)
}
.default { stream ->
// Happy path continues
stream.map { key, ok -> Pair(key, ok.unwrap()) }
.produce(Topics.output)
}
Configured on the Kafka Streams instance (not in topology code):
DeserializationAgainHandler / DeserializationNextHandler -- deserialization failuresProductionAgainHandler / ProductionNextHandler -- production failuresUncaughtHandler -- shuts down the Kafka client| Processor | Purpose |
|---|---|
Processor<Kin,Vin,Kout,Vout> | Simple stateless transform |
StateProcessor<K,V,U,R> | Processor with named state store access |
StateScheduleProcessor<K,V> | Wall-clock scheduled punctuator on KTable state |
SuppressProcessor | Buffers windowed records, emits after inactivity gap |
DedupProcessor | Deduplicates by key+value hash within retention period |
EnrichMetadataProcessor | Enriches record with Metadata (topic, partition, offset, timestamps, headers) |
Every processor must have a unique name. The Named value class registers names in a global Names singleton that fails on duplicates:
// Names are typically derived from topic + operation
// The DSL handles this automatically for most operations
// Custom processors need explicit naming
In tests, clear the Names singleton between test runs (typically in @AfterEach).
Consumes all topics as raw bytes, enriches with metadata, persists to DB:
topology("peisschtappern") {
consume(Topics.oppdrag) // bytes()
.process(EnrichMetadataProcessor())
.forEach { key, value ->
// Persist to DB (uses runBlocking since Kafka Streams is not coroutine-based)
dao.insert(key, value)
}
}
topology("utsjekk") {
val sakerTable = globalKTable(Tables.saker, retention = 24.hours)
consume(Topics.utbetalinger)
.groupByKey()
.aggregate(
initializer = { emptySet() },
aggregator = { key, value, acc -> acc + value.uid }
)
consume(Topics.status)
.forEach { key, status ->
// Write to DB (not coroutine-based)
runBlocking {
withContext(jdbcCtx) {
StatusDao.upsert(key, status)
}
}
}
}
Multiple sub-topologies per fagsystem, each following the same flow:
topology("abetal") {
val sakerTable = globalKTable(Tables.saker)
val pendingTable = globalKTable(Tables.pendingUtbetalinger)
// One sub-topology per fagsystem (dp, aap, ts, tp, historisk)
consume(Topics.dpExternal)
.repartition(3)
.merge(consume(Topics.dpInternal))
.map { key, value -> Pair(SakKey(value), value) }
.leftJoin(sakerTable) { value, existingSaker ->
Result.catch { aggregate(value, existingSaker) }
}
.branch(Result::isErr) { stream ->
stream.map { k, v -> Pair(k, v.unwrap()) }
.produce(Topics.status)
}
.default { stream ->
stream.map { k, v -> Pair(k, v.unwrap()) }
.produce(Topics.oppdrag)
// Also produce pending + status
}
// Kvittering handling: join oppdrag with pending to produce final utbetalinger
consume(Topics.oppdrag)
.filter { _, oppdrag -> oppdrag.hasKvittering() }
.flatMap { _, oppdrag -> oppdrag.uids.map { uid -> Pair(uid, oppdrag) } }
.leftJoin(pendingTable) { oppdrag, pending ->
if (pending != null) pending.withKvittering(oppdrag)
else null // retry later
}
.branch({ _, v -> v == null }) { stream ->
stream.produce(Topics.retryOppdrag) // not found yet, retry
}
.default { stream ->
stream.produce(Topics.utbetalinger) // final payment
}
}
Key patterns in abetal:
.repartition(3) for consistent processing across partitions.merge() combines external + internal topics.leftJoin() with GlobalKTable for stateful processing.branch() / .default {} for routing errors vs happy pathResult.catch {} wraps all business logic.flatMap() to fan out from oppdrag to individual UIDsobject TestRuntime {
val kafka = StreamsMock()
}
@Test
fun `produces oppdrag from payment request`() {
TestRuntime.kafka.connect(createTopology())
val input = TestRuntime.kafka.testTopic(Topics.dpExternal)
val output = TestRuntime.kafka.testTopic(Topics.oppdrag)
input.produce("key1", PaymentRequest(...))
output.assertThat()
.hasTotal(1)
.has("key1", expectedOppdrag)
}
@Test
fun `routes errors to status topic`() {
TestRuntime.kafka.connect(createTopology())
val input = TestRuntime.kafka.testTopic(Topics.dpExternal)
val status = TestRuntime.kafka.testTopic(Topics.status)
input.produce("key1", invalidRequest)
status.assertThat()
.hasTotal(1)
.has("key1", StatusReply(Status.FEILET, ...))
}
topic.assertThat()
.hasTotal(n) // exact count
.has(key, expectedValue) // key-value match
.hasNot(key, unexpectedValue) // negative match
.hasTombstone(key) // null value for key
.hasHeader("name", "value") // header check
.isEmpty() // no records
val store = TestRuntime.kafka.getStore(Tables.saker)
val saker = store.getOrNull(sakKey)
assertNotNull(saker)
TestRuntime.kafka.advanceWallClockTime(Duration.ofMinutes(5))
// Triggers any StateScheduleProcessor punctuators
runBlocking for DB access inside forEach. Do NOT use suspend functions in stream processors.Topic, Table, consume(), etc.)..branch() chain must end with .default {}.Named singleton enforces this at topology construction time.KafkaProducer/KafkaConsumer via KafkaFactory for request-reply correlation, not the topology DSL.