Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 24 additions & 4 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ name: Build and Deploy

on:
push:
branches: [master]
branches: ['**'] # master -> prod tags; every other branch -> :test

# Cancel an in-progress build when the same branch is pushed again (saves CI minutes).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
build-and-push:
Expand All @@ -22,6 +27,7 @@ jobs:
distribution: 'temurin'
cache: maven

# Tests run before the image is pushed, so a red branch never produces a :test image.
- name: Run tests
run: mvn clean test

Expand All @@ -35,13 +41,27 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

# master -> :latest + :sha-<commit> (production; the tag Watchtower follows)
# any other branch -> :test (opt-in manually on the server)
- name: Compute image tags
id: meta
run: |
if [ "${{ github.ref }}" = "refs/heads/master" ]; then
{
echo "tags<<EOF"
echo "ghcr.io/fyfar/budgetbot:latest"
echo "ghcr.io/fyfar/budgetbot:sha-${{ github.sha }}"
echo "EOF"
} >> "$GITHUB_OUTPUT"
else
echo "tags=ghcr.io/fyfar/budgetbot:test" >> "$GITHUB_OUTPUT"
fi

- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
ghcr.io/fyfar/budgetbot:latest
ghcr.io/fyfar/budgetbot:sha-${{ github.sha }}
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
194 changes: 194 additions & 0 deletions PERF_PLAN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
# BudgetBot — Performance Optimization Plan

## Current baseline (measured 2026-06-12)

| Metric | Value |
|---|---|
| Docker image (new Alpine) | 262 MB |
| Fat JAR size | 51 MB compressed |
| Cold build time | ~1 min 54 s |
| Warm rebuild (pom.xml changed) | ~36 s (with BuildKit cache mount) |
| Container startup | ~4–5 s (crashes on missing token; Hibernate init = ~3–4 s of that) |

## JAR size breakdown (uncompressed classes)

| Package | Size | Root cause |
|---|---|---|
| `org/hibernate` | 46 MB | `micronaut-data-hibernate-jpa` |
| `net/bytebuddy` | 16 MB | Hibernate lazy-load proxy generation |
| `io/micronaut` | 26 MB | framework |
| `org/glassfish` | 20 MB | Glassfish JAXB RI — pulled by Hibernate XML config |
| `io/netty` | 14 MB | Micronaut Netty HTTP server |
| `org/apache` | 9.2 MB | Apache HTTP client via `telegrambots` |
| `com/fasterxml` | 8.6 MB | Jackson |
| `org/telegram` | 4.3 MB | telegrambots |
| `ch/qos` | 3.2 MB | Logback |
| `javassist` | 2.5 MB | Hibernate bytecode manipulation |
| `com/home` | 880 KB | **app code** |

**Hibernate stack total (hibernate + bytebuddy + javassist + glassfish JAXB): ~85 MB uncompressed**
The app has only 2 entities: `BalanceHistoryEntity`, `BudgetConfigEntity`.

---

## Phase 1 — Micronaut AOT (no code changes)

**Expected: 10–25% startup improvement**

Change in `pom.xml`:
```xml
<micronaut.aot.enabled>false</micronaut.aot.enabled>
```
```xml
<micronaut.aot.enabled>true</micronaut.aot.enabled>
```

The `micronaut-maven-plugin` already handles AOT processing when this flag is true.
AOT pre-computes bean definitions, eliminates runtime service loader lookups,
and bakes `application.yaml` config into generated code (safe since secrets come from env vars).

Also fix the Hibernate dialect deprecation warning in `application.yaml` — remove:
```yaml
jpa:
default:
properties:
hibernate:
dialect: org.hibernate.dialect.H2Dialect
```
Hibernate 6 auto-detects the dialect from the JDBC URL.

---

## Phase 2 — AppCDS (Dockerfile only, no code changes)

**Expected: 15–25% startup improvement on top of Phase 1**

AppCDS bakes a shared class metadata archive into the image. Class loading is the
dominant startup cost for Micronaut — skipping `.class` parsing for already-known
classes saves hundreds of milliseconds.

Add to `Dockerfile` runtime stage (after COPY of the JAR):
```dockerfile
# Generate default JVM class-share archive (improves class-loading on startup)
RUN java -Xshare:dump 2>/dev/null || true
```

This generates the default JDK CDS archive for the Alpine JRE's class library.
Full AppCDS (including app classes) requires a dry-run training pass which needs
a valid bot token — skip for now; the JDK-level CDS still helps.

---

## Phase 3 — Hibernate → Micronaut Data JDBC (code refactor)

**Expected: ~35 MB JAR reduction (~70% of current 51 MB), startup ~1–2 s instead of ~4–5 s**

Hibernate is 46 MB + 16 MB ByteBuddy + 2.5 MB Javassist + 20 MB Glassfish JAXB = ~84 MB
uncompressed that goes away entirely.

### pom.xml changes

Remove:
```xml
<dependency>
<groupId>io.micronaut.data</groupId>
<artifactId>micronaut-data-hibernate-jpa</artifactId>
</dependency>
```
Remove from annotationProcessorPaths:
```xml
<path>
<groupId>io.micronaut.data</groupId>
<artifactId>micronaut-data-processor</artifactId>
<version>${micronaut.data.version}</version>
</path>
```

Add:
```xml
<dependency>
<groupId>io.micronaut.data</groupId>
<artifactId>micronaut-data-jdbc</artifactId>
</dependency>
```
Add to annotationProcessorPaths:
```xml
<path>
<groupId>io.micronaut.data</groupId>
<artifactId>micronaut-data-processor</artifactId>
<version>${micronaut.data.version}</version>
</path>
```
(same processor, different runtime dep — keep it)

Also remove `javax.xml.bind:jaxb-api` — it was added only because Hibernate pulled
`jackson-module-jaxb-annotations` which requires the JAXB API at runtime.
Verify after removal that tests pass.

### application.yaml changes

Remove the entire `jpa:` block.
Change `datasources.default` schema init:
```yaml
datasources:
default:
url: jdbc:h2:file:./data/database
driver-class-name: org.h2.Driver
username: admin
password: admin
schema-generate: CREATE_IF_NOT_EXISTS # replaces hbm2ddl.auto: update
dialect: H2
```

### Entity changes (2 files)

`BalanceHistoryEntity.java` — replace JPA imports:
```java
// Before
import jakarta.persistence.*;
// After
import io.micronaut.data.annotation.*;
import io.micronaut.data.model.naming.NamingStrategies;
```
`@Entity` → `@MappedEntity`
`@GeneratedValue(strategy = GenerationType.IDENTITY)` → `@GeneratedValue`
`@Column` annotations work as-is (Micronaut Data JDBC supports them).

`BudgetConfigEntity.java` — same changes.

### Repository changes (2 files)

`BalanceHistoryRepository.java`:
```java
// Before
import io.micronaut.data.repository.JpaRepository;
public interface BalanceHistoryRepository extends JpaRepository<BalanceHistoryEntity, Long>
// After
import io.micronaut.data.repository.CrudRepository;
public interface BalanceHistoryRepository extends CrudRepository<BalanceHistoryEntity, Long>
```
Any JPQL queries (`@Query` with `from BalanceHistoryEntity`) need rewriting to SQL:
```java
// Before: @Query("SELECT b FROM BalanceHistoryEntity b WHERE b.date >= :from")
// After: @Query("SELECT * FROM balance_history WHERE date >= :from")
```

`ConfigRepository.java` — same pattern.

### What stays the same
- Hikari connection pool config
- H2 database file location (`./data/database`)
- All service/controller/bot code above the repository layer
- All tests (they use `@MicronautTest` which works with JDBC too)

---

## Verification checklist

- [ ] Phase 1: `mvn package` succeeds with AOT enabled; startup log shows AOT init messages
- [ ] Phase 2: Docker build includes the `java -Xshare:dump` line
- [ ] Phase 3: `mvn test` — all 23 tests pass
- [ ] Phase 3: JAR size drops below 20 MB
- [ ] Phase 3: Container startup under 2 seconds (check logs)
- [ ] Phase 3: `./data/database.mv.db` file created on first run (schema auto-created)
51 changes: 37 additions & 14 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,10 @@
<scope>runtime</scope>
</dependency>

<!-- Data: Hibernate JPA + Hikari + H2 -->
<!-- Data: JDBC + Hikari + H2 -->
<dependency>
<groupId>io.micronaut.data</groupId>
<artifactId>micronaut-data-hibernate-jpa</artifactId>
<artifactId>micronaut-data-jdbc</artifactId>
</dependency>
<dependency>
<groupId>io.micronaut.sql</groupId>
Expand All @@ -85,11 +85,44 @@
<artifactId>micronaut-management</artifactId>
</dependency>

<!-- Telegram bot (framework-agnostic) -->
<!--
Telegram bot (framework-agnostic). We only use long-polling
(TelegramLongPollingBot), which talks to Telegram over Apache HttpClient.
telegrambots also bundles a Jersey + Grizzly webhook HTTP server
(~19 MB: jersey, grizzly, hk2, javassist) for TelegramWebhookBot — never
used here, since the Monobank webhook runs on Micronaut's own Netty server.
Excluding it removes ~2/3 of the fat JAR.
-->
<dependency>
<groupId>org.telegram</groupId>
<artifactId>telegrambots</artifactId>
<version>${telegrambots.version}</version>
<exclusions>
<exclusion>
<groupId>org.glassfish.jersey.inject</groupId>
<artifactId>jersey-hk2</artifactId>
</exclusion>
<exclusion>
<groupId>org.glassfish.jersey.media</groupId>
<artifactId>jersey-media-json-jackson</artifactId>
</exclusion>
<exclusion>
<groupId>org.glassfish.jersey.containers</groupId>
<artifactId>jersey-container-grizzly2-http</artifactId>
</exclusion>
<exclusion>
<groupId>org.glassfish.jersey.core</groupId>
<artifactId>jersey-server</artifactId>
</exclusion>
<exclusion>
<groupId>com.fasterxml.jackson.jaxrs</groupId>
<artifactId>jackson-jaxrs-json-provider</artifactId>
</exclusion>
<exclusion>
<groupId>com.fasterxml.jackson.module</groupId>
<artifactId>jackson-module-jaxb-annotations</artifactId>
</exclusion>
</exclusions>
</dependency>

<!-- MapStruct -->
Expand All @@ -113,17 +146,6 @@
<scope>runtime</scope>
</dependency>

<!--
telegrambots pulls the legacy javax-based jackson-module-jaxb-annotations.
Hibernate's findAndRegisterModules() loads it, so the javax.xml.bind annotation
API must be present on the classpath to initialize it.
-->
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
<version>2.3.1</version>
</dependency>

<!-- Test -->
<dependency>
<groupId>io.micronaut.test</groupId>
Expand Down Expand Up @@ -207,3 +229,4 @@
</plugins>
</build>
</project>

Original file line number Diff line number Diff line change
@@ -1,46 +1,44 @@
package com.home.budgetbot.bank.repository;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import io.micronaut.data.annotation.Id;
import io.micronaut.data.annotation.MappedEntity;
import io.micronaut.data.annotation.MappedProperty;
import lombok.Data;
import lombok.ToString;

import java.time.OffsetDateTime;
import java.util.UUID;

@Entity
@Data
@ToString
@Table(name = "balance_history")
@MappedEntity("balance_history")
public class BalanceHistoryEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(name = "uuid")
@MappedProperty("uuid")
private String id;

private String accountId;
private int balance;
private int penny;
private OffsetDateTime time;

public BalanceHistoryEntity() {
this.id = UUID.randomUUID().toString();
}

public BalanceHistoryEntity(String accountId, int balance, int penny, OffsetDateTime time) {
this();
this.accountId = accountId;
this.balance = balance;
this.time = time;
this.penny = penny;
this.time = time;
}

public BalanceHistoryEntity(String accountId, int balance, OffsetDateTime time) {
this();
this.accountId = accountId;
this.balance = balance;
this.time = time;
this.penny = 0;
this.time = time;
}

@Column(name = "account_id")
private String accountId;
private int balance;
private int penny;
private OffsetDateTime time;
}
Loading
Loading