Skip to content

nathsagar96/lynk

Repository files navigation

Lynk

A production-grade URL Shortener — Spring Boot 4 · Java 25 · Postgres · Redis · Keycloak

Shorten long URLs into compact Base62 codes, protect the management API behind Keycloak OIDC, throttle traffic with Bucket4j on Redis, and track click counts without blocking the redirect path.

Quick start · API docs · Architecture · Contributing

Java 25 Spring Boot 4.1.0 Postgres 18 Redis 8 Keycloak 26.5 License MIT Docker ready PRs welcome


Highlights

  • Stateless IDs — Snowflake + Base62, no DB round-trip to mint a code.
  • Hot-path redirects — Redis-cached, lock-free click counter synced every 30 s.
  • Event-driven cache management — Domain events (UrlCreatedEvent, UrlUpdatedEvent, UrlDeletedEvent) decouple cache operations from CRUD logic; adding a new cache strategy requires only a new @EventListener.
  • Standard errors — every failure is application/problem+json (RFC 7807) with HTTP status and title for programmatic handling.
  • Cluster-safe scheduling — ShedLock over JDBC, so cron jobs are safe behind a load balancer.
  • Security baked in — OAuth2 Resource Server, CORS allowlist, two-tier rate limiting with X-RateLimit-* headers, Cache-Control: private, no-store on redirects. CSP, HSTS, XSS protection, and frame-deny headers.
  • SSRF protection — URL validation rejects loopback, private networks, cloud metadata endpoints, and dangerous ports.
  • API first — Swagger UI and OpenAPI 3 generated from the code, no manual docs to drift.
  • First-class observability — Micrometer counters and timers exposed via /actuator/metrics, ECS-formatted structured logs (prod profile), graceful shutdown for in-flight drains.
  • Production Docker image — layered, non-root, healthchecked, ~150 MB on Temurin JRE 25 Alpine. Optional CDS training at build time shaves 100–200 ms off cold start.

Table of Contents

Quick Start

Get a running instance in under a minute. Requires JDK 25 and Docker.

# 1. Boot Postgres, Redis, and Keycloak (auto-imports the lynk realm)
docker compose up -d

# 2. Build and run the app (Flyway applies V1–V7 on first start)
./mvnw spring-boot:run

# 3. Verify
curl http://localhost:8080/actuator/health   # -> {"status":"UP", ...}
open   http://localhost:8080/swagger-ui.html # try-it-out

That's it — Spring Boot's spring-boot-docker-compose integration wires host/port for Postgres, Redis, and Keycloak from compose.yaml on startup, so no env vars are required for local development.

First-time Keycloak boot takes ~30 s. The lynk realm is imported from ./keycloak/lynk-realm.json automatically. Admin UI: http://localhost:8180 (default admin / admin).

Core Features

URL management

  • Create, fetch, update, and delete short URLs.
  • Auto-generated codes from Snowflake IDs encoded in Base62.
  • Optional custom aliases (3–20 chars, [a-zA-Z0-9_-]).
  • Optional per-URL expiration in hours (max 87600 / 10 years).
  • Public GET /{shortCode} returns a 302 redirect; expired URLs return 410.

Auth & security

  • OAuth2 Resource Server with Keycloak JWT; user identity is the sub claim.
  • Stateless session policy; CSRF disabled (token-based API).
  • CORS allowlist driven by app.cors.allowed-origins.
  • All errors rendered as application/problem+json (RFC 7807) with a timestamp property.
  • SSRF protection via @ValidUrl and UrlSanitizer: blocks loopback (127.0.0.1, ::1), private networks (10.x, 172.16-31.x, 192.168.x, fc/fd), cloud metadata (169.254.169.254), and dangerous ports (25, 21-23, 5432, 6379, 8080, 27017, etc.).

Performance & limits

  • Redis cache for shortCode → longUrl lookups (1-day TTL by default).
  • Bucket4j + Redis rate limiting, two tiers: API (100 / 10 per minute) and redirect (500 / 50 per minute). Degrades gracefully to local rate limiter when Redis is unavailable.
  • X-RateLimit-Limit and X-RateLimit-Remaining headers, CORS-exposed.
  • Click counter is incremented in Redis asynchronously and flushed to Postgres every 30 seconds, keeping the hot redirect path lock-free.

Operations

  • Spring Actuator: /actuator/health, /actuator/info, /actuator/metrics, /actuator/env, /actuator/loggers.
  • Micrometer metrics — lynk.urls.created, lynk.redirects (tagged cache_hit and outcome: ok / not_found / expired / unavailable), lynk.redirect.duration (timer), and lynk.click.sync.batch.
  • ECS-formatted structured logs to stdout, ready for any log aggregator that speaks Elastic Common Schema.
  • Graceful shutdown (server.shutdown=graceful, 30 s phase timeout) so in-flight redirects and click flushes complete before the JVM exits.
  • Swagger UI at /swagger-ui.html, OpenAPI 3 at /v3/api-docs.
  • Hourly expired-URL cleanup, cluster-safe via ShedLock.
  • Schema owned by Flyway; Hibernate runs in validate mode.
  • Multi-stage Docker build producing a layered, non-root image on Temurin JRE 25.

Technology Stack

Layer Tech Version
Runtime Java (Temurin) 25
Framework Spring Boot (WebMVC, Data JPA, Validation, Actuator, Flyway, Security OAuth2 RS) 4.1.0
Database PostgreSQL 18
Cache / counters Redis 8
Auth Keycloak 26.5
Rate limiting Bucket4j (Lettuce) 8.19.0
Distributed jobs ShedLock (JDBC provider) 7.7.0
Metrics Micrometer (/actuator/metrics, ECS-formatted logs) bundled
Migrations Flyway + flyway-database-postgresql bundled
API docs springdoc-openapi-starter-webmvc-ui 3.0.3
Container base eclipse-temurin:25-jre-alpine, layered JAR via spring-boot-loader
Tooling Lombok, Maven Wrapper, Testcontainers, Awaitility

See pom.xml for the full dependency list.

Architecture

                                    ┌───────────────────────────────┐
                                    │  Keycloak 26.5  (OIDC)       │
                                    │  realm: lynk (imported)      │
                                    │  JWT validation (JWKS)       │
                                    └──────────────┬────────────────┘
                                                   │
                                                   ▼
    ┌──────────┐  GET /{code}     ┌───────────────────────────────────────────────────┐
    │  Client  │─────────────────▶│  Lynk  (Spring Boot 4.1.0, Java 25)               │
    └──────────┘                  │                                                   │
        ▲    │  302 redirect      │  ┌──────────────┐   ┌─────────────────────────┐   │
        │    │                    │  │RateLimitFilter│   │  SecurityFilterChain    │   │
        │    │                    │  │(HIGHEST_PREC  │──▶│ (JWT required for       │   │
        │    │                    │  │ +1, actuator/ │   │  /api/v1/urls/*)        │   │
        │    │                    │  │ swagger excl.)│   └───────────┬─────────────┘   │
        │    │                    │  └──────────────┘               │                  │
        │    ▼                    │                                  ▼                  │
        │              ┌──────────┤                        ┌──────────────────┐        │
        │              │  async   │                        │  Controllers     │        │
        │              │  event   │                        │  (RedirectCtrl   │        │
        │              │          │                        │   UrlMappingCtrl)│        │
        │              │          │                        └────────┬─────────┘        │
        │              │          │                                 │                   │
        │              │          │                                 ▼                   │
        │              │          │                        ┌──────────────────┐        │
        │              │          │                        │  Services        │        │
        │              │          │                        │  ┌────────────┐  │        │
        │              │          │                        │  │UrlMapping  │  │        │
        │              │          │                        │  │Service     │  │        │
        │              │          │                        │  ├────────────┤  │        │
        │              │          │                        │  │Redirect    │  │        │
        │              │          │                        │  │Service     │  │        │
        │              │          │                        │  ├────────────┤  │        │
        │              │          │                        │  │ClickSync   │  │        │
        │              │          │                        │  │Service     │  │        │
        │              │          │                        │  └────────────┘  │        │
        │              │          │                        └────────┬─────────┘        │
        │              │          │                                 │                   │
        │              │          │               ┌─────────────────┼───────┐           │
        │              │          │               │ Events          │       │           │
        │              │          │               ▼                 ▼       │           │
        │              │          │   ┌─────────────────┐   ┌──────────┐    │           │
        │              │          │   │UrlCacheEvent    │   │Metrics   │    │           │
        │              │          │   │Listener         │   │EventListener│  │           │
        │              │          │   └────────┬────────┘   └──────────┘    │           │
        │              │          │            │                            │           │
        │              │          │            ▼                            │           │
        │              │          │   ┌────────────────────────────────┐    │           │
        │              │          │   │  Redis 8                      │    │           │
        │              │          │   │  ┌─────────┐ ┌──────────────┐ │    │           │
        │              │          │   │  │url:code │ │clicks:code   │ │    │           │
        │              │          │   │  │(cache)  │ │(async INCR)  │ │    │           │
        │              │          │   │  ├─────────┤ ├──────────────┤ │    │           │
        │              │          │   │  │rate-limit││(SCAN/GETDEL   │ │    │           │
        │              │          │   │  │:tier:IP │ │ every 30s)   │ │    │           │
        │              │          │   │  └─────────┘ └──────┬───────┘ │    │           │
        │              │          │   └──────────────────────┼─────────┘    │           │
        │              │          │                          │             │           │
        │              │          │                          ▼             │           │
        │              │          │   ┌────────────────────────────────┐    │           │
        │              │          │   │  Postgres 18 (Flyway V1-V7)   │    │           │
        │              │          │   │  url_mappings table            │    │           │
        │              │          │   │  ShedLock table                │    │           │
        │              │          │   └────────────────────────────────┘    │           │
        │              │          └────────────────────────────────────────┘           │
        │              │
        └──────────────┘  Click counted via Redis INCR, flushed to Postgres every 30s
                           by ClickSyncService (ShedLock-guarded). Cache events
                           handled by UrlCacheEventListener. Metrics via MetricsEventListener.

Cache management is event-driven: UrlMappingService publishes UrlCreatedEvent, UrlUpdatedEvent, and UrlDeletedEvent after each writing. UrlCacheEventListener handles cache write/evict operations, decoupling cache concerns from CRUD logic.

Click tracking is event-driven end-to-end:

  1. RedirectService.accessAndRedirect() publishes a ClickRecordedEvent.
  2. ClickTrackingListener listens with @Async("taskExecutor") + @TransactionalEventListener(phase = AFTER_COMMIT) and bumps clicks:{shortCode} in Redis.
  3. ClickSyncService.syncPendingClicks() runs every 30 s under a ShedLock lease and flushes the Redis counters into Postgres via a bulk incrementClickCountBy(). Key iteration uses SCAN (not KEYS) with a 1000-key batch so the hot path stays non-blocking, and each counter is atomically read-and-deleted (getAndDelete / GETDEL). If the DB writing fails, the counter is restored to Redis via INCRBY so clicks are not lost on transient Postgres errors — the next 30 s cycle will retry.

Caching is event-driven: UrlCacheEventListener handles UrlCreatedEvent (cache warm), UrlUpdatedEvent (evict old key and cache new key), and UrlDeletedEvent (evict). Reads hit Redis on the redirect path before Postgres is touched.

Project Structure

.
├── src/
│   ├── main/
│   │   ├── java/ly/lynk/
│   │   │   ├── Application.java                # Spring Boot entry point
│   │   │   ├── config/                         # Security, OpenAPI, RateLimit, ShedLock, Async, JPA
│   │   │   ├── controller/                     # URL CRUD + redirect
│   │   │   ├── dto/                            # Java records (request/response)
│   │   │   ├── event/                          # ClickRecordedEvent, UrlCreated/Updated/DeletedEvent
│   │   │   ├── exception/                      # Sealed exception hierarchy → RFC 7807
│   │   │   ├── filter/                         # RateLimitFilter
│   │   │   ├── listener/                       # ClickTrackingListener, MetricsEventListener, UrlCacheEventListener
│   │   │   ├── mapper/                         # Entity ↔ DTO
│   │   │   ├── model/                          # JPA entities
│   │   │   ├── repository/                     # Spring Data JPA
│   │   │   ├── service/                        # Business logic, schedulers, caching, URL sanitization
│   │   │   ├── util/                           # Snowflake, Base62, Redis keys, ShortCodeConstraints
│   │   │   └── validation/                     # @ValidUrl, UrlValidator (SSRF protection)
│   │   └── resources/
│   │       ├── application.yaml                # Base runtime config
│   │       ├── application-prod.yaml           # prod profile overrides (ECS logs, INFO, Swagger off)
│   │       └── db/migration/                   # Flyway V1–V7 (never edit a committed one)
│   └── test/
│       ├── java/ly/lynk/                       # *Test, *IT, slice tests
│       └── resources/application.yaml          # Test overrides (ShedLock off, etc.)
├── keycloak/
│   └── lynk-realm.json                         # Auto-imported realm
├── compose.yaml                                # Postgres 18, Redis 8, Keycloak 26.5
├── Dockerfile                                  # Multi-stage, non-root, layered JAR
├── pom.xml                                     # Spring Boot 4.1.0 + Java 25
├── AGENTS.md                                   # Contributor & agent conventions
└── README.md                                   # You are here

API Endpoints

Method Path Auth Purpose Rate limit tier
GET /{shortCode} none 302 redirect to the original URL. redirect (500 / 50/min)
GET /api/v1/urls JWT List short URLs (paginated). api (100 / 10/min)
POST /api/v1/urls JWT Create a short URL. api
GET /api/v1/urls/{shortCode} JWT Fetch metadata for a short code. api
PATCH /api/v1/urls/{shortCode} JWT Partial update (URL, TTL). api
DELETE /api/v1/urls/{shortCode} JWT Delete a short URL (returns 204). api
GET /actuator/health, /actuator/info none Liveness and build info.
GET /swagger-ui.html, /v3/api-docs/** none OpenAPI 3 / Swagger UI.

Examples

Create a short URL:

POST /api/v1/urls
Authorization: Bearer <jwt>
Content-Type: application/json

{
  "originalUrl": "https://verylongurl.example.com/some/really/deep/path?with=params",
  "customAlias": "my-link",
  "ttlHours": 48
}

Response (201 Created):

{
  "shortCode": "my-link",
  "shortUrl": "http://localhost:8080/my-link",
  "originalUrl": "https://verylongurl.example.com/some/really/deep/path?with=params",
  "clickCount": 0,
  "createdAt": "2026-06-01T10:15:30Z",
  "updatedAt": "2026-06-01T10:15:30Z",
  "expiresAt": "2026-06-03T10:15:30Z"
}

PATCH with {} is a no-op; only originalUrl and ttlHours are honored when present.

All error responses are application/problem+json (RFC 7807) with HTTP status and title for programmatic handling:

Status Title Meaning
400 Bad Request Validation failure (bad URL, non-positive TTL).
400 Invalid URL URL points to a private/reserved resource (SSRF protection).
401 Unauthorized Missing or invalid bearer token.
404 Short Code Not Found Short code not found.
409 Conflict Optimistic locking failure (concurrent update); retry the request.
410 Short URL Expired Short URL has expired.
429 Rate Limit Exceeded Rate limit exceeded; inspect X-RateLimit-Limit and X-RateLimit-Remaining.
500 Internal Server Error An unexpected error occurred (catch-all for unhandled exceptions).
503 Service Unavailable A downstream dependency (Postgres, Redis, Keycloak) is unavailable.

Example error body:

{
  "type": "about:blank",
  "title": "Short Code Not Found",
  "status": 404,
  "detail": "No URL mapping found for code: abc123",
  "timestamp": "2026-06-01T10:15:30Z"
}

Redirect responses (GET /{shortCode}) include Cache-Control: private, no-store so browsers and intermediaries never cache the 302.

For the full schema and try-it-out, see /swagger-ui.html.

Configuration

All app.* properties can be overridden via environment variables in the standard Spring Boot form (e.g. APP_RATE_LIMIT_API_CAPACITY=200). The properties marked with a short alias below also have an env-var shortcut baked into application.yaml.

Property Default Description
app.snowflake-node-id null (auto-derived) Snowflake node id (0–1023). When unset, derived from hostname. Must be unique per running instance.
app.base-url https://lynk.ly Base URL used to build the shortUrl field in responses.
app.cleanup-cron 0 0 * * * * Cron expression for UrlCleanupService (removes expired URLs).
app.scheduling.enabled true Master switch for @EnableScheduling + ShedLock. Tests set this to false.
shedlock.enabled true Master switch for ShedLock (merged into SchedulingConfig).
app.redis.cache-ttl-days 1 (7 under prod) TTL for the url:{shortCode} Redis cache entry.
app.rate-limit.api.capacity 100 API tier burst capacity per principal.
app.rate-limit.api.refill 10 API tier tokens refilled per period.
app.rate-limit.api.refill-period PT1M API tier refill period (ISO-8601 duration).
app.rate-limit.redirect.capacity 500 Redirect tier burst capacity per IP.
app.rate-limit.redirect.refill 50 Redirect tier tokens refilled per period.
app.rate-limit.redirect.refill-period PT1M Redirect tier refill period (ISO-8601 duration).
app.cors.allowed-origins http://localhost:5173 CORS allowlist (comma-separated) for the management API.
spring.data.redis.host localhost Redis hostname.
spring.data.redis.port 6379 Redis port.
server.forward-headers-strategy framework Trust X-Forwarded-* from a proxy in front of the app (set to none to disable).
HIKARI_MAXIMUM_POOL_SIZE 50 (prod) Max HikariCP connections in prod profile. Overridable via env var.
SPRING_PROFILES_ACTIVE unset Comma-separated Spring profiles. Set to prod to load application-prod.yaml.

Short env-var aliases

application.yaml defines a few env-var shortcuts so container manifests stay readable. Both forms are equivalent — the alias just supplies a default value.

Env var Resolves to
APP_BASE_URL app.base-url
CORS_ALLOWED_ORIGINS app.cors.allowed-origins
REDIS_HOST spring.data.redis.host
REDIS_PORT spring.data.redis.port
SNOWFLAKE_NODE_ID app.snowflake-node-id

prod profile

src/main/resources/application-prod.yaml is layered on top of the base config when SPRING_PROFILES_ACTIVE=prod is set. It enables:

  • ECS-formatted structured logs (logging.structured.format.console=ecs).
  • Quiet application logsly.lynk at INFO instead of DEBUG.
  • /actuator/health returns minimal details to anonymous callers (show-details: when-authorized).
  • Swagger UI and OpenAPI docs disabled.
  • 7-day Redis cache TTL for the url:{shortCode} cache.

Compose overrides

Defaults in compose.yaml can be overridden from the shell or a .env file:

Env var Default Used by
POSTGRES_DB lynk Postgres
POSTGRES_USER postgres Postgres
POSTGRES_PASSWORD secret Postgres
KEYCLOAK_ADMIN admin Keycloak
KEYCLOAK_ADMIN_PASSWORD admin Keycloak

Production deployments should externalize these via env vars or Spring Cloud Config. Tests override them in src/test/resources/application.yaml (ShedLock disabled, Swagger disabled, generous rate limits, stub JWT decoder).

Deployment

Build a Docker image

There are two supported paths. Pick the one that matches your needs.

Option A — Spring Boot Buildpacks (zero config, recommended for most cases)

Spring Boot's spring-boot:build-image goal uses Cloud Native Buildpacks (Paketo by default) to produce an OCI image directly from Maven — no Dockerfile required.

./mvnw spring-boot:build-image

The resulting image is tagged docker.io/library/lynk:0.0.14-SNAPSHOT (derived from <artifactId> and <version> in pom.xml). Run it with:

docker run --rm -p 8080:8080 docker.io/library/lynk:0.0.14-SNAPSHOT

Override the tag with -Dspring-boot.build-image.imageName=lynk:dev. Other useful properties:

Property Description
-Dspring-boot.build-image.imageName Set the image name and tag.
-Dspring-boot.build-image.builder Pin a specific Paketo builder (e.g. paketobuildpacks/builder-jammy-base).
-Dspring-boot.build-image.runImage Pin a specific run image.
-Dspring-boot.build-image.createdDate Set the Created label (e.g. now, an ISO-8601 timestamp, or disable).
-Dspring-boot.build-image.pullPolicy ALWAYS, IF_NOT_PRESENT (default), or NEVER.
-Dspring-boot.build-image.network Set the network mode for the build (e.g. host).
-Dspring-boot.build-image.cacheFrom Pull additional base layers for caching (repeatable).
-Dspring-boot.build-image.publish true to push the image to a registry once built.

Requires a local Docker daemon. The goal will fail fast with a clear message if Docker is not reachable.

Option B — Custom Dockerfile (full control)

If you need a hardened, distroless-style image or to bake in extra layers (/etc/passwd tweaks, health check, OCI labels, etc.), use the committed multi-stage Dockerfile.

docker build -t lynk:dev .

The image is multi-stage: it builds with Temurin JDK 25, extracts the layered Spring Boot JAR, and ships a non-root JRE 25 Alpine runtime with tini as PID 1 and an HTTP health check against /actuator/health.

Optional: CDS training for a faster cold start

The Dockerfile can run the app once against the bundled compose services to produce a Class Data Sharing archive (app.jsa). The runtime image then launches with -XX:SharedArchiveFile=app.jsa, typically shaving 100–200 ms off cold start.

DOCKER_BUILDKIT=1 docker build --build-arg TRAIN_CDS=1 -t lynk:cds .

TRAIN_CDS=1 requires docker compose up to be possible during the build (Postgres + Redis + Keycloak must boot for Flyway migrations and context refresh). If you skip the flag, the runtime simply ignores the missing -XX:SharedArchiveFile flag — the image is still functional.

Run the container

docker run --rm -p 8080:8080 \
  -e SPRING_PROFILES_ACTIVE=prod \
  -e SPRING_DATASOURCE_URL=jdbc:postgresql://<host>:5432/lynk \
  -e SPRING_DATASOURCE_USERNAME=postgres \
  -e SPRING_DATASOURCE_PASSWORD=secret \
  -e REDIS_HOST=<redis-host> \
  -e KEYCLOAK_ISSUER_URI=https://<keycloak>/realms/lynk \
  -e APP_SNOWFLAKE_NODE_ID=1 \
  -e APP_BASE_URL=https://lynk.example.com \
  -e CORS_ALLOWED_ORIGINS=https://app.example.com \
  lynk:dev

The short env-var aliases (REDIS_HOST, APP_BASE_URL, CORS_ALLOWED_ORIGINS) can also be set as standard Spring properties — both forms are equivalent.

Build & test (Maven)

Command What runs
./mvnw compile Compile main sources.
./mvnw test *Test.java / *Tests.java via Surefire.
./mvnw verify Surefire + *IT.java / *ITCase.java via Failsafe + Flyway validate.
./mvnw spring-boot:run Boot the dev server (needs Docker for compose services).
./mvnw spring-boot:build-image Build an OCI image via Cloud Native Buildpacks (no Dockerfile).

Run a single test class:

./mvnw test   -Dtest=UrlMappingServiceTest
./mvnw verify -Dit.test=UrlMappingIntegrationIT

Production checklist

  • Set a unique app.snowflake-node-id per replica (0–1023, never collides). When unset (default), the node ID is derived from the hostname's last segment.
  • Point spring.datasource.* at a managed Postgres 18 with backups + PITR.
  • Use a managed Redis 8 (cluster or sentinel) — Bucket4j + ShedLock both need it.
  • Terminate TLS in front of the app and set app.base-url to the public origin.
  • Tune app.rate-limit.* to your expected traffic.
  • Ship logs to your aggregator; ly.lynk is at DEBUG, root at INFO by default.

Troubleshooting

  • Port 5432 / 6379 / 8180 is already in use. Stop the conflicting service, or change the host port mapping in compose.yaml (for example "15432:5432").

  • App exits with "Unable to connect to Redis" (or Postgres). Run docker compose ps — the spring-boot-docker-compose integration requires Docker to be running before the app starts. If a container is unhealthy, docker compose logs <service> to inspect it.

  • Keycloak realm lynk is missing after a restart. The realm is imported from ./keycloak/lynk-realm.json only on the first boot of a fresh data volume. Re-import it via the Keycloak admin console, or run docker compose down -v and start again.

  • 401 on /api/v1/urls. The JWT must include a non-blank sub claim and must be issued by the configured realm (default http://localhost:8180/realms/lynk, overridable via KEYCLOAK_ISSUER_URI). Verify the token with curl -H "Authorization: Bearer $TOKEN" http://localhost:8180/realms/lynk/protocol/openid-connect/userinfo.

  • 429 Too Many Requests. Inspect the X-RateLimit-Limit and X-RateLimit-Remaining response headers to see which tier is exhausted. Tune app.rate-limit.api.* or app.rate-limit.redirect.* if the defaults are too aggressive for your workload.

  • Click counter is stuck at zero. Redis must be reachable from the app. ClickSyncService flushes counters to Postgres every 30 s, atomically GETDEL-ing each key in Redis; if the Postgres writing fails the counter is restored via INCRBY and retried on the next cycle, so a transient DB blip will not lose clicks. If Redis itself is down, the @Async listener cannot record clicks in the first place, and they will be missed for the duration of the outage.

  • docker compose warns about an unknown compose.yaml field. You are likely running an older Docker Compose V1. The repo uses the modern Compose Specification; upgrade to docker compose (V2) or docker-compose 2.20+.

Contributing

Contributions are welcome.

  1. Read AGENTS.md — it is the source of truth for layout, conventions, the Flyway contract, the Redis key prefix reference, and the test taxonomy (*Test vs *IT).
  2. Fork the repo, create a feature branch, push, open a PR.
  3. Make sure ./mvnw verify is green before requesting review — it runs Surefire, Failsafe, and flyway:validate against Testcontainers Postgres.
  4. Format with the Palantir Java Format IntelliJ plugin (see .idea/palantir-java-format.xml); it is not enforced by Maven, so check the diff before pushing.

License

Released under the MIT License. The license declaration is mirrored in Dockerfile via the org.opencontainers.image.licenses="MIT" label.

Acknowledgments

About

No description, website, or topics provided.

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors