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:     500

Client 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.gender gives 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.name

Server 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: true

Palmyra 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:         DEBUG

You’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,1s

Then 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 @RestController with JDBC batch inserts (JdbcTemplate.batchUpdate). Skip the Palmyra handler layer entirely for ingest-heavy flows.
  • Streaming import via CSVCsvHandler reads a stream of rows and issues one INSERT ... 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: 1800000

Watch 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#

  1. Does the client send fields=...? If not, every column comes back.
  2. Is the column being filtered/sorted on indexed?
  3. Is there a nested FK that’s expanding the payload?
  4. Is pagination skipping COUNT when it could?
  5. Is the slow-query log showing a single query or N of them?
  6. Is the connection pool saturated?
  7. Is onComplete / applyQueryFilter doing per-row work?

See also#

  • API format — the fields / filter / sort / limit reference
  • Mental Model — the “no N+1 by default” claim in context