diff --git a/docker-compose.yml b/docker-compose.yml index eed1873..cb0f872 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -58,6 +58,47 @@ services: networks: - backend + # ------------------------------------------------------------------- + # Kafka broker — event backbone for task lifecycle events. + # Apache Kafka official image in KRaft mode (no Zookeeper). + # Single-node cluster: fine for development, not for production. + # Two client listeners: + # INTERNAL://kafka:9092 — used by services inside the compose network + # EXTERNAL://localhost:9094 — used by kcat/kafka-console tools from host + # ------------------------------------------------------------------- + kafka: + image: apache/kafka:3.7.0 + container_name: tmm-kafka + ports: + - "9094:9094" + environment: + # KRaft node identity + KAFKA_NODE_ID: 0 + KAFKA_PROCESS_ROLES: controller,broker + KAFKA_CONTROLLER_QUORUM_VOTERS: 0@kafka:9093 + KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER + # Listeners + KAFKA_LISTENERS: INTERNAL://:9092,CONTROLLER://:9093,EXTERNAL://:9094 + KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka:9092,EXTERNAL://localhost:9094 + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INTERNAL:PLAINTEXT,CONTROLLER:PLAINTEXT,EXTERNAL:PLAINTEXT + KAFKA_INTER_BROKER_LISTENER_NAME: INTERNAL + # Log directory (must match the volume mount path) + KAFKA_LOG_DIRS: /var/lib/kafka/data + # Single-node replication settings (no sensible defaults from apache/kafka) + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 + volumes: + - kafka-data:/var/lib/kafka/data + healthcheck: + test: ["CMD-SHELL", "/opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server localhost:9092 >/dev/null 2>&1"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + networks: + - backend + # ------------------------------------------------------------------- # Auth service — registro, login, JWT issuance, refresh tokens. # Depende de Postgres saludable antes de arrancar Flyway. @@ -106,6 +147,8 @@ services: condition: service_healthy mongo: condition: service_healthy + kafka: + condition: service_healthy healthcheck: test: ["CMD-SHELL", "wget -qO- http://localhost:8082/actuator/health | grep -q '\"status\":\"UP\"'"] interval: 10s @@ -153,6 +196,7 @@ services: volumes: postgres-data: mongo-data: + kafka-data: # ----------------------------------------------------------------------- # Red: bridge dedicada. Los contenedores se resuelven por nombre de diff --git a/docs/adr-007-kafka-event-bus.md b/docs/adr-007-kafka-event-bus.md new file mode 100644 index 0000000..e1a33a5 --- /dev/null +++ b/docs/adr-007-kafka-event-bus.md @@ -0,0 +1,317 @@ +# ADR-007: Kafka event bus for task lifecycle events + +## Status + +Accepted — 22 July 2026. + +Supersedes the synchronous `ActivityEventListener` pattern established for the activity log in Phase 6.5 (see ADR-001). Complements ADR-004 (border authentication) and ADR-005 (profile-specific configuration). + +## Context + +The `task-manager-api` monolith wrote activity log entries synchronously in the same JPA transaction as the domain change, via an `@EventListener` on Spring `ApplicationEvent`s. This coupling was acceptable within a single service but became structurally incompatible with the microservices split of Phase 10: + +- **Coupling by shared transaction.** A synchronous listener running inside the same transaction ties the durability of the domain change to the durability of the audit write. If the audit persistence path breaks, the domain transaction rolls back — a downstream concern silently vetoes upstream progress. + +- **No fan-out.** Any future reactive concern on task events (email notifications, search index updates, webhooks, analytics ingestion) would require adding another `@EventListener` in the same JVM. Every concern would share the same lifecycle, the same failure modes, and the same deployment cadence. + +- **No extraction path.** Extracting the activity log to a dedicated `activity-service` would require inventing an integration mechanism at extraction time. Deferring that decision to the extraction moment forces re-architecture under time pressure. + +- **Cross-service audit collection.** The activity log needs to capture events from multiple future services (task-service today, notification-service tomorrow, whatever). A synchronous per-service listener does not compose. + +The system already runs three Spring Boot services (`api-gateway`, `auth-service`, `task-service`) behind a border-authenticating gateway. Postgres provides transactional persistence for tasks and categories; MongoDB Atlas M0 provides the flexible-schema store for the activity log (see ADR-001). What is missing is an asynchronous decoupling mechanism between domain writes and downstream reactive concerns. + +Three broad categories of solution exist: + +1. **In-process asynchronous events.** Keep the `@EventListener` pattern but make it async (`@Async`). Reduces coupling in time but not in space — still same JVM, still no fan-out to other services. +2. **Direct HTTP calls to downstream services.** Task-service calls activity-service directly on each domain event. Introduces synchronous inter-service coupling; task-service must know about every downstream concern; failures of downstream services impact task-service availability. +3. **Message broker.** Publish domain events to a broker; downstream services consume independently. Decouples in time, space, and failure. Adds infrastructure and operational complexity. + +The message broker approach fits the trajectory of the codebase (already microservices, already using Docker Compose for orchestration, already accustomed to Testcontainers-based integration testing) and matches industry practice at the kinds of employers targeted (banca tech, consultoras, grandes empresas). Kafka specifically is the de facto standard in the JVM ecosystem for this workload. + +## Decision + +Adopt Apache Kafka as the event bus for domain lifecycle events emitted from `task-service`, with the specific configuration described below. The activity log becomes a Kafka consumer within `task-service` (temporarily co-located pending extraction to a dedicated `activity-service`). + +### D1 — Topology of topics + +Two topics: `task.events` and `category.events`. Each topic carries a `type` discriminator field in the payload (`CREATED`, `UPDATED`, `STATUS_CHANGED`, `DELETED` for tasks; `CREATED`, `UPDATED`, `DELETED` for categories). Rejected alternatives: a single generic `domain.events` topic (harder to schema-evolve independently, forces every consumer to filter), and seven type-specific topics (unnecessary granularity given consumers typically care about all events of a resource type). + +### D2 — Partitions + +Three partitions per topic at creation time. Kafka partitions cannot be shrunk without recreating the topic and cannot be expanded without breaking cross-partition ordering guarantees retrospectively. Three is chosen as a compromise between over-provisioning (waste at the current scale of one small user base) and under-provisioning (single-partition bottleneck under any real load). Revisit if the topic sees sustained multi-thousand-events-per-second traffic (not projected). + +### D3 — Message key + +`userId` (as `String`) for both topics. Consequences: + +- Events for the same user land in the same partition, giving per-user ordering guarantees for downstream consumers. +- Aligns with the identity axis established in ADR-004 (border-authenticated `X-User-Id` header) and with the `/me/activity` read-side pattern. +- Downstream reactive concerns (per-user email digests, per-user webhooks) can shard cleanly by consuming assigned partitions. +- Trade-off: a single high-activity user occupies one partition disproportionately. Acceptable at current scale; not a bottleneck in projected usage. + +### D4 — Initial consumer groups + +One consumer group at rollout: `activity-log-writer`. Every future reactive concern (email notifications, search index, webhooks) becomes a new consumer group with its own offset tracking. The two-topic topology combined with per-group offsets makes this additive: adding a concern never affects existing ones. + +### D5 — Commit strategy: at-least-once via manual acknowledgement, backed by indefinite exponential retry + +Consumer configuration: + +- `enable-auto-commit: false` +- `ack-mode: MANUAL_IMMEDIATE` +- `auto-offset-reset: earliest` + +Consumer code path: `mongoTemplate.insert(doc)` first, `ack.acknowledge()` second. The offset commits only after Mongo confirms the write. + +**Retry policy: `ExponentialBackOff(initial=1s, multiplier=2.0, maxInterval=30s)` with no total elapsed time cap.** Retries are effectively indefinite: the consumer blocks on a failing partition until the downstream dependency (Mongo) recovers. + +This retry policy is not the framework default and is explicitly configured in `KafkaConfig.java` as a `DefaultErrorHandler` bean. Rationale is documented in Consequences C1 below — the Spring Kafka default (`FixedBackOff(0ms, 9 attempts)`) silently discards messages after the retry budget is exhausted, breaking at-least-once when downstream outages exceed roughly 5 minutes. + +### D6 — Idempotency at the consumer: `_id = eventId` + +Each event carries a `eventId` (UUID) generated by the publisher. The consumer uses this UUID as the `_id` of the `ActivityEvent` document in MongoDB. + +Persistence pattern: `mongoTemplate.insert(doc)` inside a `try { ... } catch (DuplicateKeyException) { log.info("Duplicate skipped"); }`. + +Effect: processing the same event N times produces exactly one document. Combined with D5 (at-least-once delivery), this yields the "effectively exactly-once" pattern from the consumer's perspective — Kafka may deliver the message more than once (during retries, offset resets, or consumer restarts mid-processing), but the persisted state is idempotent. + +This idempotency is bidirectional (see Consequence C4 for the empirical observation from session 15 block 6.3): it prevents duplicates on normal reprocessing, and it makes offset resets a safe operational tool for recovering events that were lost due to bugs while they still exist in the topic. + +### D7 — Migration from `@EventListener` to `@KafkaListener` + +Complete migration, no coexistence. The legacy `ActivityEventListener` was removed in the same branch as the Kafka integration (commit `8bb1206`). During development (sessions 12-14), both listeners coexisted temporarily and wrote duplicate documents; this state was explicitly transient and closed at end of session 14. + +Reasoning: dual writes during migration served verification, but leaving them in production would double the write volume without benefit. The activity log post-migration writes only via the Kafka consumer. + +### D8 — Location of publishers + +Dedicated Spring components (`TaskEventKafkaPublisher`, `CategoryEventKafkaPublisher`) rather than adding methods to the existing `ActivityEventListener`. Rejected alternative: cluttering an already-doing-too-much class with additional responsibilities. Publishers own only the Kafka publishing concern. + +Each publisher listens to the local Spring `ApplicationEvent`s via `@TransactionalEventListener(phase = AFTER_COMMIT)` and translates them to Kafka messages via `KafkaTemplate.send(...)`. + +### D9 — Synchronization with the JPA transaction: `@TransactionalEventListener(phase = AFTER_COMMIT)` + +Events are published to Kafka only after the originating JPA transaction commits successfully. Events from rolled-back transactions are never published, preventing observers from reacting to state that never existed. + +This does **not** cover the case where the JPA transaction commits and then the Kafka publish fails (broker unavailable at that specific moment). That gap is closed only by the transactional outbox pattern, which is not implemented in this ADR — see the Deuda técnica section. + +### D10 — Thin events + +Payload of each event contains only: `eventId`, `type`, `userId`, resource ID (`taskId` or `categoryId`), `timestamp`. No business fields (no `title`, `status`, `oldStatus`, `newStatus`, `name`, etc.). + +Consequences: the activity log post-migration loses business detail for five of seven event types (Task CREATED, DELETED, STATUS_CHANGED; Category CREATED, DELETED). Task UPDATED and Category UPDATED were already field-less in the legacy listener, so no regression there. + +Rationale: + +- **Every field in a fat event is a backward-compatibility commitment forever.** Consumers may depend on any published field; removing or renaming becomes a breaking change coordinated across services. +- **Thin events reflect what happened, not the state of the thing.** Consumers that need current state can look it up from the source of truth (task-service's Postgres) via the resource ID. +- **Preparation for extraction.** When the activity log consumer moves to a dedicated `activity-service`, that service will not have direct access to task-service's Postgres. Thin events make that extraction transparent — nothing changes at extraction time; the consumer already carries no coupling to task-service internals. + +Option 2b — enriching only STATUS_CHANGED events with `oldStatus`/`newStatus` — was considered mid-implementation and rejected. See Alternatives Considered. + +## Consequences + +### C1 — The retry policy of the listener is part of the at-least-once guarantee (not an implementation detail) + +The commit-manual-after-write pattern (D5) is necessary but not sufficient for at-least-once delivery. It only works if retries are effective until success. + +**Empirical finding from session 15 block 6.1**: with Spring Kafka's `DefaultErrorHandler` default (`FixedBackOff(interval=0ms, maxAttempts=9)`), during a Mongo outage of ~17 minutes, only one of three events published was persisted. The other two were silently discarded: the error handler exhausted its 10 retry budget (each retry consuming ~30s of `MongoTimeoutException`), then advanced the offset without a Mongo write. + +Log excerpt: + +``` +2026-07-22T05:05:05.370Z ERROR DefaultErrorHandler: + Backoff FixedBackOff{interval=0, currentAttempts=10, maxAttempts=9} exhausted for task.events-2@7 +``` + +The framework's error message calls this "exhausted" — accurate but understated. Operationally, this is silent data loss. + +**Correction**: `ExponentialBackOff(1000L, 2.0)` with `setMaxInterval(30_000L)` and no `setMaxElapsedTime` call. Retries indefinite, wait growing 1s → 2s → 4s → 8s → 16s → 30s → 30s → 30s… A consumer blocks on the failing partition until the downstream recovers. + +Consumer lag becomes the visible signal of a stuck partition (monitored via `kafka-consumer-groups.sh --describe` in dev, via metrics/alerts in production). This replaces the invisible failure mode of the default configuration: a growing lag is an incident someone will notice; a silently dropped message is a data loss no one notices until an audit. + +The design decision to prefer a stuck consumer to silent loss is deliberate: **an incident visible in lag is better than data loss invisible in dashboards.** + +### C2 — Framework defaults must be made explicit or replaced + +`DefaultErrorHandler` with its `FixedBackOff(0, 9)` default is correct for many use cases (poison messages that will never succeed, deserialization failures, business bugs that require code changes). It is incorrect for the case of temporarily unavailable downstream dependencies. + +The lesson generalizes beyond Spring Kafka: any framework's default behavior encodes assumptions about failure modes. When your architecture makes different assumptions, you must either verify the default happens to match, or override it explicitly with reasoning documented in an ADR. + +### C3 — Business detail is lost from the activity log for five of seven event types + +Enumerated for clarity: + +| Event type | Pre-migration listener | Post-migration Kafka consumer | +|-------------------------|------------------------------|-------------------------------| +| TASK_CREATED | before={}, after={title, ...} | before={}, after={} | +| TASK_UPDATED | before={}, after={} | before={}, after={} | +| TASK_STATUS_CHANGED | before={status}, after={status} | before={}, after={} | +| TASK_DELETED | before={title, ...}, after={} | before={}, after={} | +| CATEGORY_CREATED | before={}, after={name} | before={}, after={} | +| CATEGORY_UPDATED | before={}, after={} | before={}, after={} | +| CATEGORY_DELETED | before={name}, after={} | before={}, after={} | + +Explicit trade-off: + +- The activity log becomes a "what happened when to whom" record, not a "what changed exactly" record. +- Users viewing `/me/activity` see a chronological list of actions on their resources by ID and type, without the resource content at the time of the event. +- Anyone needing "what was the title of task X on this date" must query task-service and rely on its own history (JPA auditing on the entity itself, out of scope for this ADR). + +Accepted as the cost of D10 (thin events) and preparation for extraction. If a business requirement emerges for richer audit content, revisit via a new ADR — options include enriching only specific event types with limited business fields (see Alternative A5), or building a separate CQRS-style projection service that maintains a full-history view by combining Kafka events with task-service queries. + +### C4 — Idempotency by `_id = eventId` is bidirectional + +**Empirical finding from session 15 block 6.3**: after a consumer group offset reset to `earliest`, the consumer reprocessed all 15 messages in the topic. 13 messages already had a corresponding document in Mongo — their re-insert raised `DuplicateKeyException` and was silently caught, offset advanced, no document created. Zero duplicates. + +Bonus finding: 2 messages did NOT have a corresponding document in Mongo (the two lost in the first-pass C1 bug before the fix). Their re-insert succeeded. **The reset recovered the events lost by the earlier bug, using only mechanisms already present in the design.** + +This is not a design goal — the primary purpose of D6 is to prevent duplicates from normal reprocessing. But it emerges as a valuable operational property: **when you discover retrospectively that events were lost, and the messages still exist in the topic, a controlled consumer group reset will recover them safely without duplicating what was already processed.** Without idempotency, a reset would multiply everything. + +Retention of messages in the topic is therefore a form of operational insurance. Default Kafka retention is 7 days; extending this for topics containing audit-relevant events may be worth revisiting (see Deuda técnica). + +### C5 — The consumer group is a contract between broker and consumer + +The offsets committed by the consumer live in Kafka (specifically in the internal `__consumer_offsets` topic), not in the consumer's memory. Consequences: + +- Consumer restarts are seamless: the consumer resumes from the last committed offset, not from `earliest` or `latest`. Verified empirically in session 15 block 6.2. +- If the consumer JVM crashes mid-processing (between `insert` success and `ack.acknowledge`), the next start reprocesses that message — caught by idempotency (D6). +- Multiple consumer instances in the same group share partitions cooperatively; Kafka rebalances assignment automatically. +- Adding a new consumer group to a topic later reads from `earliest` by default — the audit log started with `earliest` deliberately during rollout to capture all historical events already published. + +### C6 — Consumer bottleneck under per-partition failure + +Because message key is `userId` (D3), all events for a given user land in the same partition. If the consumer gets stuck retrying a message for user U (due to a persistent downstream failure specific to that message), the consumer cannot advance to messages for user V that were published later to the same partition. + +This is a deliberate trade-off: preserving per-user ordering requires per-user partition affinity, which requires per-user head-of-line blocking on failures. + +Mitigations available if this becomes a problem: + +- More partitions (spreads load, dilutes head-of-line effect across more consumers). +- Selective retry with dead-letter topic for poison messages (adds infrastructure, see Alternatives A1). +- Per-message retry limits with escalation (breaks the "no silent loss" guarantee, would require reworking C1). + +None applied today; monitor. + +### C7 — Operational infrastructure required + +Adopting Kafka is not free. What now exists in the operational surface: + +- Kafka broker in Docker Compose (single-node, KRaft combined mode, `apache/kafka:3.7.0`). Volume `kafka-data` for durability across Compose restarts. +- Two client listeners: `INTERNAL://kafka:9092` for compose-internal traffic, `EXTERNAL://localhost:9094` for host-side tooling (`kcat`, `kafka-console-consumer.sh`, etc.). +- Cascading healthchecks in Docker Compose to ensure ordering during `up`. +- New failure modes to understand: consumer lag, offset commits, rebalances, retention. +- New operational tools: `kafka-consumer-groups.sh` for observing/manipulating offsets, `kafka-console-consumer.sh` for inspecting messages, `kafka-console-producer.sh` for manually publishing test messages. + +Team members (currently one) must learn the operational vocabulary. Documented as "necessary skill" for the roadmap — this is directly employable knowledge. + +### C8 — Preparation for extraction to `activity-service` + +The consumer currently lives inside `task-service` for pragmatic reasons (fewer services during Phase 10 rollout). It writes to a MongoDB Atlas instance that also serves the read-side (`/me/activity` endpoint). + +The design contains no coupling between the write path (Kafka consumer) and the read path (JPA/Spring Data MongoDB repository) beyond sharing the same collection. When extraction to a dedicated `activity-service` becomes worthwhile (multiple services publishing events, or the audit log outgrowing Mongo Atlas M0), the extraction is mechanical: move the consumer + MongoDB config to the new service, update Docker Compose, done. No API changes, no coordination window. + +Estimated effort at time of extraction: one afternoon. Not scheduled; move only when driven by concrete need. + +## Alternatives considered + +### A1 — Dead-letter topic (DLT) instead of indefinite retry + +**Considered:** on retry exhaustion, publish the failed message to a dead-letter topic for out-of-band inspection and replay. Standard Spring Kafka pattern via `DeadLetterPublishingRecoverer`. + +**Rejected because:** DLT does not solve the problem it appears to solve here. Messages in the DLT are data lost until someone manually processes them. In a system with one operator (or a small team), that manual processing rarely happens; the DLT becomes a landfill. Worse, the primary consumer keeps advancing while events sit dead — the appearance of health while the audit log silently misses records. + +For the failure mode being addressed (temporarily unavailable downstream dependencies), indefinite retry with visible lag is operationally simpler and semantically correct. If a message is genuinely poison (cannot succeed regardless of retry), it will stay stuck with growing lag — that will trigger investigation, and manual intervention can remove or fix the message. That is not worse than a DLT, and it does not require additional infrastructure. + +If the volume of poison messages grows (which would indicate a bug elsewhere), revisit — but starting with indefinite retry and adding DLT reactively is safer than starting with DLT and discovering data loss retroactively. + +### A2 — Lookup by resource ID at consume time (fat activity log despite thin events) + +**Considered:** the consumer receives a thin event with a `taskId`, then queries task-service (or Postgres directly, given the consumer lives in `task-service` today) to load the current state of that task and store it in the activity log document. + +**Rejected because:** + +- **Consistency window**: the state at consume time is not the state at publish time. A task modified between publish and consume would produce misleading audit entries ("action=CREATED, title=X" where X is the current title, not the title at creation). +- **Coupling**: the consumer becomes dependent on the source-of-truth for reading, which contradicts the thin-events extraction argument (D10). Extracting to a dedicated `activity-service` would then require the extracted service to reach into task-service's database or call its API — reintroducing the coupling the extraction was meant to avoid. +- **Performance**: N events = N reads on the source. Fine at small scale; problematic under load. +- **Delete semantics**: what does "CATEGORY_DELETED" mean for a lookup? The row no longer exists. Special-casing per event type reintroduces complexity D10 was meant to avoid. + +### A3 — Fat events at the publisher + +**Considered:** the publisher includes all relevant business fields in the event payload (`title`, `status`, `oldStatus`, `newStatus`, `name`, etc.). The consumer stores them directly with no lookup. + +**Rejected because:** + +- Every field in the event becomes a permanent commitment. Renaming or removing fields breaks consumers. +- Consumers may inadvertently rely on internal fields that were included "just in case", making internal refactors of the publisher into breaking API changes. +- Schema evolution requires coordination even across a single team — with multiple teams (any real employer target), this becomes a permanent tax. +- Contradicts the industry direction toward event-driven architectures that decouple domain models from event contracts. + +Thin events are the recognized best practice for exactly these reasons. The trade-off (loss of business detail in the audit log, per C3) is accepted. + +### A4 — Event enrichment only for STATUS_CHANGED (option "2b") + +**Considered mid-implementation**, session 14 block 5: enrich only `STATUS_CHANGED` events with `oldStatus` and `newStatus`, keeping all other event types thin. The reasoning was that status transitions have specific audit value ("when did this task go from IN_PROGRESS to DONE?") that would be lost with pure thin events. + +**Rejected mid-block** after the developer reported cognitive fog ("me estoy haciendo un poco lío"). YAGNI honestly applied: no concrete requirement had emerged for this enrichment; the added asymmetry (one event type richer than others) would need its own justification when extraction happened; and the cost of adding it later (if a requirement emerges) is lower than the cost of maintaining an asymmetric contract permanently. + +Kept alive as an option for future revisiting if concrete requirements emerge. + +### A5 — `MongoTemplate` vs `Repository.insert()` + +**Considered:** using the `ActivityEventRepository` (Spring Data MongoDB) with `insert()` semantics instead of `MongoTemplate.insert()` directly. + +**Choice made:** `MongoTemplate` for explicitness. The `Repository.insert()` call would have worked identically — both raise `DuplicateKeyException` on `_id` conflict, both go through the same underlying driver. + +**Rationale**: pedagogical explicitness during rollout, not technical necessity. Making the low-level path visible in the consumer makes the persistence step obvious to anyone reading the code, without needing to know Spring Data MongoDB's insert-vs-save semantics. Future refactor to `Repository.insert()` is cosmetic and low-risk. + +**Correction (documented in session 14 tarde)**: an earlier version of this ADR draft argued `MongoTemplate` was "necessary" for idempotency. That was overstated. Repository would work equally. The choice is style, not capability. + +### A6 — Transactional outbox pattern + +**Considered:** publish events by writing them to an "outbox" table in the same Postgres transaction as the domain change, then have a separate process poll the outbox and publish to Kafka. Guarantees exactly-once semantics from the publisher's perspective (no lost events even if the broker is unreachable at publish time). + +**Rejected for the initial implementation because:** + +- Adds an outbox table, a polling worker or CDC connector (Debezium or similar), and coordination logic. Significant infrastructure for a scenario not yet observed in practice. +- The current `@TransactionalEventListener(AFTER_COMMIT)` (D9) covers the common case: events are not published if the transaction rolls back. +- The remaining gap (transaction commits + broker unreachable at that instant) is real but rare in a healthy system. When it occurs, the current implementation loses the event silently — this is an acknowledged deuda técnica, not a claim of exactly-once at the publisher. + +**Documented as pending** in the Deuda técnica section. The right time to implement outbox is when either (a) the failure mode becomes observable in practice (metrics on failed `send()` calls), or (b) a compliance requirement mandates exactly-once publishing (banking, healthcare, regulated audit trails). + +### A7 — Adding methods to the existing `ActivityEventListener` + +**Considered briefly**, rejected quickly. The legacy `ActivityEventListener` was single-responsibility (activity log write). Adding Kafka publishing to the same class would have violated SRP for a saving of zero effort. Dedicated publishers (D8) are cleaner. + +## Deuda técnica + +Ordered by expected impact: + +1. **Transactional outbox pattern** for guaranteed at-least-once publishing (closes the gap in D9 where transaction commit is followed by broker failure before publish). Implementation options: outbox table + polling worker; outbox table + Debezium CDC. Trigger for scheduling: metrics showing non-negligible rate of failed `send()` calls, or compliance requirement. + +2. **Extraction of the consumer to a dedicated `activity-service`.** Blocked on: a second event producer joining the system, or Mongo Atlas M0 becoming insufficient for the audit collection. Not urgent; well-prepared for by D10 and C8. + +3. **`serverSelectionTimeoutMS` of the Mongo driver.** Currently 30s (driver default). During downstream outages, each retry attempt consumes 30s waiting for the driver to give up. Reducing to 5-10s would make retries visibly faster in logs and dashboards without changing correctness. Cosmetic operational improvement; low priority. + +4. **`spring.json.write.dates.as.timestamps: false`.** Currently timestamps in event payloads are serialized as fractional-second epoch numbers (`1784696105.850459207`). ISO-8601 strings would be more readable in `kafka-console-consumer.sh` output and in any downstream tooling that parses events without a Jackson `ObjectMapper` configured identically. One-line config change. Low priority, cosmetic. + +5. **`logging.level.com.mtole.task.kafka: DEBUG`.** Add a profile-scoped or environment-variable-triggered DEBUG level for the Kafka packages, to enable `Received TaskEvent ...` and `Persisted ActivityEvent ...` logs during troubleshooting. Currently these lines never appear because default is INFO. Low priority. + +6. **Kafka topic retention configuration.** Default retention is 7 days. For audit-adjacent topics, longer retention (30 days? 90 days?) may be worth setting explicitly so that consumer group resets can recover from bugs discovered later (see C4). Decision deferred until retention emerges as a limiting factor. + +7. **Consumer lag monitoring.** Currently only observable manually via `kafka-consumer-groups.sh --describe`. Production readiness requires metrics export (Micrometer + Prometheus) and alerting on sustained lag growth. Not needed at current single-developer scale; required before any real user traffic. + +8. **Semantics of `retries: N` in Spring Cloud Gateway 2025.0.0.** Inherited from ADR-006 (session 10). Not related to this ADR directly but tracked in the same technical debt bucket for the broader Kafka + gateway story. + +## References + +- ADR-001: Polyglot persistence (Postgres + MongoDB) for the activity log. +- ADR-004: Border authentication at the API gateway. +- ADR-005: Profile-specific configuration pattern (`application.yml` base + `application-docker.yml` overrides). +- ADR-006: Resilience patterns (Circuit Breaker + Retry + TimeLimiter) at the API gateway. +- Commit `28da0d8`: fix of the retry policy (context in Consequence C1). +- Commit `8bb1206`: removal of the legacy `ActivityEventListener` (context in D7). +- Commit `2d859b5`: initial Kafka consumer for the activity log. +- Commits `2e417d6`, `cb7b69f`: Kafka publishers for Task and Category events. +- Commit `b34a24a`: Spring Kafka client configuration in task-service. +- Commit `2e1802b`: Kafka broker in Docker Compose. diff --git a/task-service/pom.xml b/task-service/pom.xml index bfe84f5..3bdca23 100644 --- a/task-service/pom.xml +++ b/task-service/pom.xml @@ -67,6 +67,11 @@ spring-boot-starter-data-mongodb + + + org.springframework.kafka + spring-kafka + org.mapstruct diff --git a/task-service/src/main/java/com/mtole/task/activity/ActivityEvent.java b/task-service/src/main/java/com/mtole/task/activity/ActivityEvent.java index 8c39b54..7d1a482 100644 --- a/task-service/src/main/java/com/mtole/task/activity/ActivityEvent.java +++ b/task-service/src/main/java/com/mtole/task/activity/ActivityEvent.java @@ -67,6 +67,26 @@ public ActivityEvent( this.after = after; this.timestamp = timestamp; } + // Constructor con id explícito. Usado por el consumer para poblar + // el _id de Mongo con el eventId del contrato Kafka (idempotencia). + public ActivityEvent( + String id, + Long userId, + String action, + String resourceType, + Long resourceId, + Map before, + Map after, + Instant timestamp) { + this.id = id; + this.userId = userId; + this.action = action; + this.resourceType = resourceType; + this.resourceId = resourceId; + this.before = before; + this.after = after; + this.timestamp = timestamp; + } public String getId() { return id; diff --git a/task-service/src/main/java/com/mtole/task/activity/ActivityEventListener.java b/task-service/src/main/java/com/mtole/task/activity/ActivityEventListener.java deleted file mode 100644 index d27f88c..0000000 --- a/task-service/src/main/java/com/mtole/task/activity/ActivityEventListener.java +++ /dev/null @@ -1,178 +0,0 @@ -package com.mtole.task.activity; - -import com.mtole.task.categories.events.CategoryCreatedEvent; -import com.mtole.task.categories.events.CategoryDeletedEvent; -import com.mtole.task.categories.events.CategoryUpdatedEvent; -import com.mtole.task.tasks.events.TaskCreatedEvent; -import com.mtole.task.tasks.events.TaskDeletedEvent; -import com.mtole.task.tasks.events.TaskStatusChangedEvent; -import com.mtole.task.tasks.events.TaskUpdatedEvent; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Component; - -import java.util.HashMap; -import java.util.Map; - -/** - * Listener de eventos de dominio. Traduce cada evento publicado por - * los services de PostgreSQL en un documento ActivityEvent que se - * persiste en MongoDB. - *

- * Síncrono por defecto: los métodos se ejecutan en el mismo hilo y - * dentro de la misma transacción que el publisher. Si la escritura - * en MongoDB falla, se produce rollback de la transacción JPA - * completa (la operación original en PostgreSQL también se revierte). - *

- * Si en el futuro hace falta desacoplar latencias (audit logging - * lento bloqueando los endpoints), se puede pasar a asíncrono - * añadiendo @Async + configurando un TaskExecutor, aceptando que el - * audit log puede perderse si la escritura en Mongo falla. - */ -@Component -public class ActivityEventListener { - - private static final Logger log = LoggerFactory.getLogger(ActivityEventListener.class); - - private final ActivityEventRepository repository; - - public ActivityEventListener(ActivityEventRepository repository) { - this.repository = repository; - } - - @EventListener - public void onTaskCreated(TaskCreatedEvent event) { - log.debug("Recording TaskCreated event for taskId={}", event.taskId()); - - Map after = new HashMap<>(); - after.put("title", event.title()); - after.put("status", event.status()); - if (event.categoryId() != null) { - after.put("categoryId", event.categoryId()); - } - - ActivityEvent doc = new ActivityEvent( - event.userId(), - "TASK_CREATED", - "TASK", - event.taskId(), - Map.of(), - after, - event.occurredAt() - ); - repository.save(doc); - } - - // TODO: implementar el resto de listeners: - // - onTaskUpdated(TaskUpdatedEvent) - - @EventListener - public void onTaskUpdated(TaskUpdatedEvent event) { - log.debug("Recording TaskUpdated event for taskId={}", event.taskId()); - - ActivityEvent doc = new ActivityEvent( - event.userId(), - "TASK_UPDATED", - "TASK", - event.taskId(), - Map.of(), - Map.of(), - event.occurredAt() - ); - repository.save(doc); - } - - @EventListener - public void onTaskStatusChanged(TaskStatusChangedEvent event) { - log.debug("Recording TaskStatusChanged event for taskId={}", event.taskId()); - - // before y after vacíos - ActivityEvent doc = new ActivityEvent( - event.userId(), - "TASK_STATUS_CHANGED", - "TASK", - event.taskId(), - Map.of("status", event.oldStatus()), - Map.of("status", event.newStatus()), - event.occurredAt() - ); - repository.save(doc); - } - - // - onTaskDeleted(TaskDeletedEvent) - @EventListener - public void onTaskDeleted(TaskDeletedEvent event) { - log.debug("Recording TaskDeleted event for taskId={}", event.taskId()); - - Map before = new HashMap<>(); - before.put("title", event.title()); - before.put("status", event.status()); - - ActivityEvent doc = new ActivityEvent( - event.userId(), - "TASK_DELETED", - "TASK", - event.taskId(), - before, - Map.of(), - event.occurredAt() - ); - repository.save(doc); - } - - // - onCategoryCreated(CategoryCreatedEvent) - @EventListener - public void onCategoryCreated(CategoryCreatedEvent event) { - log.debug("Recording CategoryCreated event for categoryId={}", event.categoryId()); - - Map after = new HashMap<>(); - after.put("name", event.name()); - - ActivityEvent doc = new ActivityEvent( - event.userId(), - "CATEGORY_CREATED", - "CATEGORY", - event.categoryId(), - Map.of(), - after, - event.occurredAt() - ); - repository.save(doc); - } - - // - onCategoryUpdated(CategoryUpdatedEvent) - @EventListener - public void onCategoryUpdated(CategoryUpdatedEvent event) { - log.debug("Recording CategoryUpdated event for categoryId={}", event.categoryId()); - ActivityEvent doc = new ActivityEvent( - event.userId(), - "CATEGORY_UPDATED", - "CATEGORY", - event.categoryId(), - Map.of(), - Map.of(), - event.occurredAt() - ); - repository.save(doc); - } - - // - onCategoryDeleted(CategoryDeletedEvent) - @EventListener - public void onCategoryDeleted(CategoryDeletedEvent event) { - log.debug("Recording CategoryDeleted event for categoryId={}", event.categoryId()); - Map before = new HashMap<>(); - before.put("name", event.name()); - - ActivityEvent doc = new ActivityEvent( - event.userId(), - "CATEGORY_DELETED", - "CATEGORY", - event.categoryId(), - before, - Map.of(), - event.occurredAt() - ); - repository.save(doc); - } -} diff --git a/task-service/src/main/java/com/mtole/task/kafka/ActivityLogKafkaConsumer.java b/task-service/src/main/java/com/mtole/task/kafka/ActivityLogKafkaConsumer.java new file mode 100644 index 0000000..1d5e6dc --- /dev/null +++ b/task-service/src/main/java/com/mtole/task/kafka/ActivityLogKafkaConsumer.java @@ -0,0 +1,97 @@ +package com.mtole.task.kafka; + +import org.springframework.dao.DuplicateKeyException; +import com.mtole.task.activity.ActivityEvent; +import com.mtole.task.kafka.events.CategoryEvent; +import com.mtole.task.kafka.events.TaskEvent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; + +import java.util.Map; + +import static com.mtole.task.kafka.KafkaConfig.TOPIC_CATEGORY_EVENTS; +import static com.mtole.task.kafka.KafkaConfig.TOPIC_TASK_EVENTS; + +/** + * Consumer del audit log. Escucha los topics `task.events` y + * `category.events`, y persiste un documento ActivityEvent en Mongo + * por cada mensaje recibido. + * + * Idempotencia: el _id del documento en Mongo es el eventId del + * contrato Kafka. Si el mismo mensaje se procesa más de una vez + * (por reintentos, reset de offsets, etc.), el insert lanza + * DuplicateKeyException y se ignora silenciosamente. Efecto: + * exactamente un documento por eventId. Patrón "effectively + * exactly-once". + * + * Semántica: at-least-once. El commit del offset se hace manualmente + * tras confirmación de escritura en Mongo. Si la escritura falla, + * el offset no se commitea y Kafka reintenta más tarde. + * + * Detalle de negocio: los eventos Kafka son thin (solo IDs y + * metadatos). El activity log resultante no guarda title/status/name. + * Trade-off documentado en ADR-007. + */ +@Component +public class ActivityLogKafkaConsumer { + + private static final Logger log = LoggerFactory.getLogger(ActivityLogKafkaConsumer.class); + + private final MongoTemplate mongoTemplate; + + public ActivityLogKafkaConsumer(MongoTemplate mongoTemplate) { + this.mongoTemplate = mongoTemplate; + } + + @KafkaListener(topics = TOPIC_TASK_EVENTS, groupId = "activity-log-writer") + public void onTaskEvent(TaskEvent event, Acknowledgment ack) { + log.debug("Received TaskEvent {} type={} task={}", event.eventId(), event.type(), event.taskId()); + + ActivityEvent doc = new ActivityEvent( + event.eventId().toString(), + event.userId(), + "TASK_" + event.type().name(), + "TASK", + event.taskId(), + Map.of(), + Map.of(), + event.timestamp() + ); + + persist(doc, event.eventId().toString()); + ack.acknowledge(); + } + + @KafkaListener(topics = TOPIC_CATEGORY_EVENTS, groupId = "activity-log-writer") + public void onCategoryEvent(CategoryEvent event, Acknowledgment ack) { + log.debug("Received CategoryEvent {} type={} category={}", + event.eventId(), event.type(), event.categoryId()); + + ActivityEvent doc = new ActivityEvent( + event.eventId().toString(), + event.userId(), + "CATEGORY_" + event.type().name(), + "CATEGORY", + event.categoryId(), + Map.of(), + Map.of(), + event.timestamp() + ); + + persist(doc, event.eventId().toString()); + ack.acknowledge(); + } + + private void persist(ActivityEvent doc, String eventId) { + try { + mongoTemplate.insert(doc); + log.debug("Persisted ActivityEvent id={}", eventId); + } catch (DuplicateKeyException ex) { + log.info("Duplicate ActivityEvent skipped id={}", eventId); + } + } +} diff --git a/task-service/src/main/java/com/mtole/task/kafka/CategoryEventKafkaPublisher.java b/task-service/src/main/java/com/mtole/task/kafka/CategoryEventKafkaPublisher.java new file mode 100644 index 0000000..c2cac9a --- /dev/null +++ b/task-service/src/main/java/com/mtole/task/kafka/CategoryEventKafkaPublisher.java @@ -0,0 +1,71 @@ +package com.mtole.task.kafka; + +import com.mtole.task.categories.events.CategoryCreatedEvent; +import com.mtole.task.categories.events.CategoryDeletedEvent; +import com.mtole.task.categories.events.CategoryUpdatedEvent; +import com.mtole.task.kafka.events.CategoryEvent; +import com.mtole.task.kafka.events.CategoryEventType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.util.UUID; + +import static com.mtole.task.kafka.KafkaConfig.TOPIC_CATEGORY_EVENTS; + +/** + * Publica eventos de dominio de Category al topic Kafka `category.events` + * tras el commit exitoso de la transacción JPA. + * + * Escucha los ApplicationEvent locales publicados por CategoryService y + * los traduce a CategoryEvent (contrato Kafka thin) con eventId nuevo. + * El eventId es generado aquí porque pertenece al contrato de Kafka, + * no al evento local Spring. + * + * Consistencia: @TransactionalEventListener(AFTER_COMMIT) garantiza que + * eventos de transacciones fallidas no se publican. No garantiza publicación + * ante fallo del broker (deuda técnica: transactional outbox, ADR-007). + */ +@Component +public class CategoryEventKafkaPublisher { + + private static final Logger log = LoggerFactory.getLogger(CategoryEventKafkaPublisher.class); + + private final KafkaTemplate kafkaTemplate; + + public CategoryEventKafkaPublisher(KafkaTemplate kafkaTemplate) { + this.kafkaTemplate = kafkaTemplate; + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onCategoryCreated(CategoryCreatedEvent event) { + publish(event.userId(), event.categoryId(), CategoryEventType.CREATED, event.occurredAt()); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onCategoryUpdated(CategoryUpdatedEvent event) { + publish(event.userId(), event.categoryId(), CategoryEventType.UPDATED, event.occurredAt()); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onCategoryDeleted(CategoryDeletedEvent event) { + publish(event.userId(), event.categoryId(), CategoryEventType.DELETED, event.occurredAt()); + } + + private void publish(Long userId, Long categoryId, CategoryEventType type, java.time.Instant occurredAt) { + CategoryEvent event = new CategoryEvent( + UUID.randomUUID(), + type, + userId, + categoryId, + occurredAt + ); + String key = userId.toString(); + kafkaTemplate.send(TOPIC_CATEGORY_EVENTS, key, event); + log.debug("Published CategoryEvent {} for user={} category={} type={}", + event.eventId(), userId, categoryId, type); + } +} diff --git a/task-service/src/main/java/com/mtole/task/kafka/KafkaConfig.java b/task-service/src/main/java/com/mtole/task/kafka/KafkaConfig.java new file mode 100644 index 0000000..e1d9bc5 --- /dev/null +++ b/task-service/src/main/java/com/mtole/task/kafka/KafkaConfig.java @@ -0,0 +1,50 @@ +package com.mtole.task.kafka; + +import org.apache.kafka.clients.admin.NewTopic; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.config.TopicBuilder; +import org.springframework.kafka.listener.DefaultErrorHandler; +import org.springframework.util.backoff.ExponentialBackOff; + +@Configuration +public class KafkaConfig { + + public static final String TOPIC_TASK_EVENTS = "task.events"; + public static final String TOPIC_CATEGORY_EVENTS = "category.events"; + + @Bean + public NewTopic taskEventsTopic() { + return TopicBuilder.name(TOPIC_TASK_EVENTS) + .partitions(3) + .replicas(1) + .build(); + } + + @Bean + public NewTopic categoryEventsTopic() { + return TopicBuilder.name(TOPIC_CATEGORY_EVENTS) + .partitions(3) + .replicas(1) + .build(); + } + + + /** + * ErrorHandler personalizado para consumers Kafka. + * + * Sustituye el DefaultErrorHandler por defecto de Spring Kafka, que aplica + * FixedBackOff(0ms, 9 intentos) y descarta el mensaje al agotar reintentos. + * Aquí usamos ExponentialBackOff sin techo total: reintentos indefinidos + * con espera creciente hasta 30s por intento. Preferimos consumer bloqueado + * en partición (incidente visible vía lag) a pérdida silenciosa. + * + * Aplica a todos los @KafkaListener del contexto por resolución por tipo. + */ + @Bean + public DefaultErrorHandler kafkaErrorHandler() { + ExponentialBackOff backOff = new ExponentialBackOff(1000L, 2.0); + backOff.setMaxInterval(30_000L); + return new DefaultErrorHandler(backOff); + } +} diff --git a/task-service/src/main/java/com/mtole/task/kafka/TaskEventKafkaPublisher.java b/task-service/src/main/java/com/mtole/task/kafka/TaskEventKafkaPublisher.java new file mode 100644 index 0000000..5beba12 --- /dev/null +++ b/task-service/src/main/java/com/mtole/task/kafka/TaskEventKafkaPublisher.java @@ -0,0 +1,77 @@ +package com.mtole.task.kafka; + +import com.mtole.task.kafka.events.TaskEvent; +import com.mtole.task.kafka.events.TaskEventType; +import com.mtole.task.tasks.events.TaskCreatedEvent; +import com.mtole.task.tasks.events.TaskDeletedEvent; +import com.mtole.task.tasks.events.TaskStatusChangedEvent; +import com.mtole.task.tasks.events.TaskUpdatedEvent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.util.UUID; + +import static com.mtole.task.kafka.KafkaConfig.TOPIC_TASK_EVENTS; + +/** + * Publica eventos de dominio de Task al topic Kafka `task.events` + * tras el commit exitoso de la transacción JPA. + * + * Escucha los ApplicationEvent locales publicados por TaskService y + * los traduce a TaskEvent (contrato Kafka thin) con eventId nuevo. + * El eventId es generado aquí porque pertenece al contrato de Kafka, + * no al evento local Spring. + * + * Consistencia: @TransactionalEventListener(AFTER_COMMIT) garantiza que + * eventos de transacciones fallidas no se publican. No garantiza publicación + * ante fallo del broker (deuda técnica: transactional outbox, ADR-007). + */ +@Component +public class TaskEventKafkaPublisher { + + private static final Logger log = LoggerFactory.getLogger(TaskEventKafkaPublisher.class); + + private final KafkaTemplate kafkaTemplate; + + public TaskEventKafkaPublisher(KafkaTemplate kafkaTemplate) { + this.kafkaTemplate = kafkaTemplate; + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onTaskCreated(TaskCreatedEvent event) { + publish(event.userId(), event.taskId(), TaskEventType.CREATED, event.occurredAt()); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onTaskUpdated(TaskUpdatedEvent event) { + publish(event.userId(), event.taskId(), TaskEventType.UPDATED, event.occurredAt()); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onTaskStatusChanged(TaskStatusChangedEvent event) { + publish(event.userId(), event.taskId(), TaskEventType.STATUS_CHANGED, event.occurredAt()); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onTaskDeleted(TaskDeletedEvent event) { + publish(event.userId(), event.taskId(), TaskEventType.DELETED, event.occurredAt()); + } + + private void publish(Long userId, Long taskId, TaskEventType type, java.time.Instant occurredAt) { + TaskEvent event = new TaskEvent( + UUID.randomUUID(), + type, + userId, + taskId, + occurredAt + ); + String key = userId.toString(); + kafkaTemplate.send(TOPIC_TASK_EVENTS, key, event); + log.debug("Published TaskEvent {} for user={} task={} type={}", + event.eventId(), userId, taskId, type); + } +} diff --git a/task-service/src/main/java/com/mtole/task/kafka/events/CategoryEvent.java b/task-service/src/main/java/com/mtole/task/kafka/events/CategoryEvent.java new file mode 100644 index 0000000..0ce5ce8 --- /dev/null +++ b/task-service/src/main/java/com/mtole/task/kafka/events/CategoryEvent.java @@ -0,0 +1,12 @@ +package com.mtole.task.kafka.events; + +import java.time.Instant; +import java.util.UUID; + +public record CategoryEvent( + UUID eventId, + CategoryEventType type, + Long userId, + Long categoryId, + Instant timestamp +) {} diff --git a/task-service/src/main/java/com/mtole/task/kafka/events/CategoryEventType.java b/task-service/src/main/java/com/mtole/task/kafka/events/CategoryEventType.java new file mode 100644 index 0000000..f09ed79 --- /dev/null +++ b/task-service/src/main/java/com/mtole/task/kafka/events/CategoryEventType.java @@ -0,0 +1,7 @@ +package com.mtole.task.kafka.events; + +public enum CategoryEventType { + CREATED, + UPDATED, + DELETED +} diff --git a/task-service/src/main/java/com/mtole/task/kafka/events/TaskEvent.java b/task-service/src/main/java/com/mtole/task/kafka/events/TaskEvent.java new file mode 100644 index 0000000..d092341 --- /dev/null +++ b/task-service/src/main/java/com/mtole/task/kafka/events/TaskEvent.java @@ -0,0 +1,12 @@ +package com.mtole.task.kafka.events; + +import java.time.Instant; +import java.util.UUID; + +public record TaskEvent( + UUID eventId, + TaskEventType type, + Long userId, + Long taskId, + Instant timestamp +){} diff --git a/task-service/src/main/java/com/mtole/task/kafka/events/TaskEventType.java b/task-service/src/main/java/com/mtole/task/kafka/events/TaskEventType.java new file mode 100644 index 0000000..8106d7b --- /dev/null +++ b/task-service/src/main/java/com/mtole/task/kafka/events/TaskEventType.java @@ -0,0 +1,8 @@ +package com.mtole.task.kafka.events; + +public enum TaskEventType { + CREATED, + UPDATED, + STATUS_CHANGED, + DELETED +} diff --git a/task-service/src/main/resources/application-docker.yml b/task-service/src/main/resources/application-docker.yml index b3c5105..8f2c319 100644 --- a/task-service/src/main/resources/application-docker.yml +++ b/task-service/src/main/resources/application-docker.yml @@ -13,3 +13,6 @@ spring: data: mongodb: uri: mongodb://mongo:27017/taskmanager + + kafka: + bootstrap-servers: kafka:9092 diff --git a/task-service/src/main/resources/application.yml b/task-service/src/main/resources/application.yml index 748cccb..59dec40 100644 --- a/task-service/src/main/resources/application.yml +++ b/task-service/src/main/resources/application.yml @@ -26,6 +26,22 @@ spring: database: taskmanager auto-index-creation: true + kafka: + bootstrap-servers: localhost:9092 + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.springframework.kafka.support.serializer.JsonSerializer + consumer: + group-id: activity-log-writer + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer + auto-offset-reset: earliest + enable-auto-commit: false + properties: + spring.json.trusted.packages: "com.mtole.task.kafka.events" + listener: + ack-mode: MANUAL_IMMEDIATE + management: endpoints: web: