Performance & Observability#
Palmyra is raw JDBC — no Hibernate session, no lazy loading, no dirty-check sweep. The claim on the home page is “sub-50 ms typical”; this page covers what you have to do to keep it that way, and what to measure when you don’t.
1. Pagination defaults#
Every QueryHandler response is paginated. Defaults from application.yml:
palmyra:
query:
default-limit: 15
max-limit: 500Client overrides come via query params: ?limit=30&offset=60. Clients that don’t send limit get default-limit. Clients that send limit > max-limit get truncated to max-limit — no error, just capped. Set max-limit conservatively — it’s your guard against accidental full-table dumps.
2. Nested FK reads — pick a depth#
When a model field is typed as another model (private MrcpPatientModel patient), Palmyra joins and returns the full nested object. Two things to know:
- Nested depth is recursive.
Exam.patient.gendergives you{"exam": {"patient": {"gender": {…}}}}. Every reference resolves until the graph terminates or cycles are detected. - No N+1. Nested objects come from joined SELECTs, not per-row fetches. The Mental Model diagram doesn’t have a “for each row, re-fetch the FK” step.
Still, deep nesting is payload weight. Two levers:
Lever A — @FetchConfig to cap depth#
@PalmyraType(type = "Exam")
@FetchConfig(depth = 1)
public class ExamModel {
@PalmyraField private MrcpPatientModel patient; // populated
// patient.gender → returned as just the id (no further nesting)
}Lever B — client-side field projection#
GET /exam?fields=id,code,examnumber,patient.externalCode,facility.nameServer only selects the listed columns. The default SPA grid passes fields from its column definitions automatically.
3. Client-driven filters — benchmarks you should write#
Any column with @PalmyraField(search = true) is filterable by the client. That’s powerful and a performance risk. Measure on your representative data:
| Query shape | Typical profile |
|---|---|
| Indexed single-column EQ | < 20 ms |
| Indexed composite filter, < 5 cols | < 50 ms |
| Unindexed column EQ | 50–500 ms — add an index |
LIKE '%needle%' (suffix+prefix) |
Seconds on large tables — reconsider (fulltext / prefix-only / don’t search) |
The filter query param reference lists all operators. Disallow expensive ones by leaving search = false on big free-text columns until you’ve added an index.
4. ignoreSinglePage — save a COUNT round-trip#
Grid pagination UIs typically show “Showing 1-15 of 2,172”, which needs a SELECT COUNT(*) alongside the SELECT … LIMIT …. When the result set is small enough that pagination doesn’t matter:
<SummaryGrid pagination={{ ignoreSinglePage: true }} ... />The client skips COUNT when the first page has fewer rows than limit. Saves a round-trip on every narrow-result grid (admin lists, lookups).
5. Query logging — turn it on in dev#
spring:
jpa:
show-sql: true
properties:
hibernate:
format_sql: truePalmyra doesn’t use Hibernate for its query path, so the above is Hibernate-only. For Palmyra’s native JDBC, enable DEBUG on the SQL runner:
logging:
level:
com.palmyralabs.palmyra.core.api2db: DEBUG
com.zitlab.palmyra.sqlstore: DEBUGYou’ll see the compiled SQL for every grid query with its parameter bindings. Copy-paste into EXPLAIN in your DB client.
6. Slow-query detection — a light-weight interceptor#
Palmyra doesn’t ship a slow-query log, but Spring Boot’s Actuator + Micrometer does the job. For HTTP endpoints:
management:
endpoints.web.exposure.include: health,metrics,prometheus
metrics:
distribution:
percentiles-histogram:
http.server.requests: true
slo:
http.server.requests: 100ms,500ms,1sThen Prometheus / Grafana / whatever surfaces http_server_requests_seconds_bucket per endpoint — tail latency of /api/palmyra/exam becomes a single chart.
For fine-grained “this particular SQL query is slow”, use your database’s own slow-query log (slow_query_log, long_query_time) — Palmyra’s SQL is just SQL, so the existing DBA toolbox works.
7. The N+1 that CAN still happen — onComplete side-effects#
Palmyra’s own read path is join-based. But a CreateHandler.onComplete or UpdateHandler.onUpdate hook that does a per-row lookup against an external service creates an N+1 at the handler level. Batch where possible — or move the call to a @Scheduled reconciler that processes rows in chunks.
8. Bulk inserts / updates#
CreateHandler and UpdateHandler are one-row-at-a-time by default. For bulk loads:
- Bulk import path — write a
@RestControllerwith JDBC batch inserts (JdbcTemplate.batchUpdate). Skip the Palmyra handler layer entirely for ingest-heavy flows. - Streaming import via CSV —
CsvHandlerreads a stream of rows and issues oneINSERT ... VALUES (...), (...)per chunk. Good when you can express import as a CSV upload.
Don’t loop CreateHandler.create in Java code — that’s one transaction per row with full validation each time.
9. Connection pool tuning#
Default HikariCP pool size is 10. A Palmyra app that serves a grid doing 5 concurrent queries per user at 50-user concurrency wants more. Starting point:
spring:
datasource:
hikari:
maximum-pool-size: 20
connection-timeout: 3000
validation-timeout: 1000
idle-timeout: 600000
max-lifetime: 1800000Watch pool saturation via Actuator (metrics/hikaricp.connections.usage). If it pegs at max, either raise the pool or raise the DB’s max_connections — whichever has the headroom.
10. Readiness & liveness probes#
management:
endpoint:
health:
probes.enabled: true
show-details: when-authorized
endpoints.web.exposure.include: health,metrics/actuator/health/liveness— “the JVM is up” (K8s restarts the pod on failure)./actuator/health/readiness— includes the DB connection check. K8s takes the pod out of rotation until the DB is reachable.
Both are served by Spring Boot Actuator; nothing Palmyra-specific.
Profiling checklist for a slow page#
- Does the client send
fields=...? If not, every column comes back. - Is the column being filtered/sorted on indexed?
- Is there a nested FK that’s expanding the payload?
- Is pagination skipping COUNT when it could?
- Is the slow-query log showing a single query or N of them?
- Is the connection pool saturated?
- Is
onComplete/applyQueryFilterdoing per-row work?
See also#
- API format — the
fields/filter/sort/limitreference - Mental Model — the “no N+1 by default” claim in context