<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Theo Kim - Backend &amp; Cloud Systems Engineer</title><description>A bilingual portfolio and technical blog for Theo Kim, focused on backend engineering, Kubernetes platform work, observability, and product systems.</description><link>https://theokimdev.com/</link><language>en-US</language><atom:link href="https://theokimdev.com/rss.xml" rel="self" type="application/rss+xml"/><lastBuildDate>Sun, 26 Jul 2026 06:00:00 GMT</lastBuildDate><item><title>Scaling Healthcare Chat Beyond an Echo WebSocket</title><link>https://theokimdev.com/en/blog/post-60-spring-boot-websocket/</link><guid isPermaLink="true">https://theokimdev.com/en/blog/post-60-spring-boot-websocket/</guid><description>A synthetic two-instance healthcare chat lab covering authentication, origin checks, durable-before-ack writes, Redis fan-out, idempotency, backpressure, reconnects, and observability.</description><pubDate>Sun, 26 Jul 2026 06:00:00 GMT</pubDate><content:encoded>&lt;p&gt;An echo server proves that a WebSocket frame can travel through one process. It does not answer the questions that make chat reliable: who authenticated the connection, whether an Origin is allowed, what happens when the sender and receiver reach different instances, when a message is acknowledged, how duplicates are handled, and what happens when a client cannot drain its socket.&lt;/p&gt;
&lt;p&gt;I built a synthetic healthcare chat experiment with two application instances behind Nginx, Redis Streams for durable message records, Redis Pub/Sub for live cross-instance fan-out, message-ID deduplication, bounded payloads and rates, slow-consumer protection, and Prometheus metrics. The payloads are synthetic and contain no protected health information.&lt;/p&gt;
&lt;p&gt;The integration check connects a sender directly to instance A and a receiver directly to instance B. It proves the message crosses the shared Redis path and that resending the same message ID does not append or broadcast it twice.&lt;/p&gt;
&lt;h2 id=&quot;websocket-changes-the-session-not-all-of-http&quot;&gt;WebSocket changes the session, not all of HTTP&lt;/h2&gt;
&lt;p&gt;WebSocket begins as an HTTP Upgrade request and then becomes a persistent full-duplex connection. The server can send frames without waiting for a new request.&lt;/p&gt;
&lt;p&gt;That does not mean ordinary HTTP or gRPC creates a new TCP connection for every request. HTTP keep-alive reuses connections, and HTTP/2 multiplexes streams; gRPC commonly runs on long-lived HTTP/2 connections. The distinctive WebSocket concern is the application session attached to a bidirectional socket.&lt;/p&gt;
&lt;p&gt;Long-lived sessions change the operating model:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;deploys and load-balancer timeouts disconnect clients;&lt;/li&gt;
&lt;li&gt;authentication can expire while the connection remains open;&lt;/li&gt;
&lt;li&gt;each instance holds connection state;&lt;/li&gt;
&lt;li&gt;slow readers accumulate buffered data;&lt;/li&gt;
&lt;li&gt;reconnects and duplicate sends are normal;&lt;/li&gt;
&lt;li&gt;cross-instance delivery needs shared infrastructure.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;the-measured-architecture&quot;&gt;The measured architecture&lt;/h2&gt;
&lt;p&gt;The local topology is:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;browser / test clients
          |
      Nginx :18060
       /          \
  app-a :8080   app-b :8080
       \          /
        Redis :6379
       /          \
 Streams       Pub/Sub
 durable log   live fan-out
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Nginx uses WebSocket Upgrade forwarding and balances new connections. Existing WebSocket connections stay on the instance that accepted them, so sticky sessions do not solve cross-instance messaging. The shared channel does.&lt;/p&gt;
&lt;p&gt;The browser UI shows which instance accepted the connection and the Redis stream ID assigned before acknowledgement:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theokimdev.com/resources/blog/post-60-spring-boot-websocket/healthcare-chat-scaleout.png&quot; alt=&quot;Synthetic healthcare consultation chat connected through app-b, showing three messages persisted and delivered through the shared Redis path&quot;&gt;&lt;/p&gt;
&lt;h2 id=&quot;authenticate-before-accepting-application-messages&quot;&gt;Authenticate before accepting application messages&lt;/h2&gt;
&lt;p&gt;The lab requires the first frame within five seconds:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;type&quot;: &quot;authenticate&quot;,
  &quot;token&quot;: &quot;local-lab-token&quot;,
  &quot;consultationId&quot;: &quot;demo-consultation&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;An invalid or missing authentication frame closes the socket with policy code &lt;code&gt;1008&lt;/code&gt;. The consultation ID is syntax-validated and bounded before it becomes part of a Redis key.&lt;/p&gt;
&lt;p&gt;The first-frame mechanism keeps the experiment small. In production, authenticate during the HTTP handshake when possible, use a secure cookie or supported subprotocol rather than a token in the URL, validate consultation membership server-side, and bind the session to a short-lived identity. Query-string tokens leak into logs and histories.&lt;/p&gt;
&lt;p&gt;Authorization is not complete at connection time. Membership can be revoked, a token can expire, and each message type can require a different permission. Long-lived sessions need revalidation or bounded lifetime.&lt;/p&gt;
&lt;h2 id=&quot;validate-origin-separately-from-authentication&quot;&gt;Validate Origin separately from authentication&lt;/h2&gt;
&lt;p&gt;Browsers send an &lt;code&gt;Origin&lt;/code&gt; header during the handshake. The server allows exactly the expected site origin and rejects other upgrades with HTTP 403:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;server.on(&apos;upgrade&apos;, (request, socket, head) =&amp;gt; {
  if (
    request.url !== &apos;/ws&apos; ||
    request.headers.origin !== allowedOrigin
  ) {
    socket.write(
      &apos;HTTP/1.1 403 Forbidden\r\n&apos; +
      &apos;Connection: close\r\n\r\n&apos;
    );
    socket.destroy();
    return;
  }

  websocketServer.handleUpgrade(
    request, socket, head,
    ws =&amp;gt; websocketServer.emit(&apos;connection&apos;, ws)
  );
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;Origin&lt;/code&gt; validation reduces cross-site WebSocket hijacking risk. It is not identity; non-browser clients can choose their headers. Keep authentication and authorization independent.&lt;/p&gt;
&lt;p&gt;Avoid &lt;code&gt;setAllowedOrigins(&quot;*&quot;)&lt;/code&gt; on an authenticated WebSocket endpoint. Use the exact production origins and test rejection.&lt;/p&gt;
&lt;h2 id=&quot;persist-before-acknowledging&quot;&gt;Persist before acknowledging&lt;/h2&gt;
&lt;p&gt;The message path is:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;client frame
  -&amp;gt; parse, size, schema, rate, and authorization checks
  -&amp;gt; claim message ID with SET NX
  -&amp;gt; XADD to consultation stream
  -&amp;gt; PUBLISH live event
  -&amp;gt; send acknowledgement to sender
  -&amp;gt; every subscribed instance forwards to local sockets
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The critical code is:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const duplicateKey =
  `consultation:${consultationId}:message:${message.messageId}`;

const firstSeen = await redis.set(
  duplicateKey, &apos;1&apos;, { NX: true, EX: 300 }
);

if (!firstSeen) {
  socket.send(JSON.stringify({
    type: &apos;ack&apos;,
    messageId: message.messageId,
    duplicate: true
  }));
  return;
}

const streamId = await redis.xAdd(
  `consultation:${consultationId}:messages`,
  &apos;*&apos;,
  {
    messageId: message.messageId,
    text: message.text,
    instanceId
  }
);

await redis.publish(channel, liveEvent);
socket.send(JSON.stringify({
  type: &apos;ack&apos;, messageId, streamId, duplicate: false
}));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The client receives success only after the durable stream append. Pub/Sub is for low-latency fan-out, not durability. A subscriber disconnected at publish time misses that notification and must recover from the stream.&lt;/p&gt;
&lt;p&gt;There is still a failure boundary between &lt;code&gt;XADD&lt;/code&gt; and &lt;code&gt;PUBLISH&lt;/code&gt;: a process can persist the message and die before live fan-out. A production system needs a stream-driven dispatcher or recovery scan so durable-but-unpublished messages are eventually delivered. The compact lab demonstrates the boundary rather than claiming to eliminate it.&lt;/p&gt;
&lt;h2 id=&quot;make-client-retries-idempotent&quot;&gt;Make client retries idempotent&lt;/h2&gt;
&lt;p&gt;Mobile networks disappear. A client can send a message, lose the acknowledgement, reconnect, and send again. Every client message therefore carries a stable &lt;code&gt;messageId&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The Redis &lt;code&gt;SET NX&lt;/code&gt; marker prevents a second stream append and returns an acknowledgement marked &lt;code&gt;duplicate&lt;/code&gt;. The integration test sends the same ID twice and verifies one delivery.&lt;/p&gt;
&lt;p&gt;The five-minute marker TTL is sufficient only for this lab. Production deduplication retention must cover the longest retry and offline window, or uniqueness must be durable in the message store. A database unique key or stream-side index can provide a longer boundary.&lt;/p&gt;
&lt;h2 id=&quot;backpressure-must-have-a-policy&quot;&gt;Backpressure must have a policy&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;socket.send()&lt;/code&gt; does not mean the peer consumed the data. If the client is slow, the server-side buffered amount grows.&lt;/p&gt;
&lt;p&gt;The experiment closes a socket with &lt;code&gt;1013&lt;/code&gt; when its buffered amount exceeds 64 KiB:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;if (session.socket.bufferedAmount &amp;gt; 65_536) {
  rejected.inc({
    instance: instanceId,
    reason: &apos;slow_consumer&apos;
  });
  session.socket.close(1013, &apos;consumer is too slow&apos;);
  continue;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The client can reconnect and resume from a durable cursor. Keeping an unlimited buffer would turn one slow or abandoned connection into a memory leak.&lt;/p&gt;
&lt;p&gt;Other bounded inputs are equally important:&lt;/p&gt;

































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Boundary&lt;/th&gt;&lt;th&gt;Lab policy&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Frame size&lt;/td&gt;&lt;td&gt;Maximum 4 KiB; close &lt;code&gt;1009&lt;/code&gt; when exceeded&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Text length&lt;/td&gt;&lt;td&gt;Maximum 1,000 characters&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Send rate&lt;/td&gt;&lt;td&gt;Maximum 10 messages/s per connection&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Authentication delay&lt;/td&gt;&lt;td&gt;First frame within 5 s&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Buffered outbound data&lt;/td&gt;&lt;td&gt;Close at 64 KiB with &lt;code&gt;1013&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Invalid schema or policy&lt;/td&gt;&lt;td&gt;Close with &lt;code&gt;1008&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Per-connection rate limiting is not enough for abuse prevention. Production also needs account, consultation, tenant, and IP-aware limits at an appropriate shared boundary.&lt;/p&gt;
&lt;h2 id=&quot;scale-out-needs-a-reconnect-protocol&quot;&gt;Scale-out needs a reconnect protocol&lt;/h2&gt;
&lt;p&gt;Redis Pub/Sub forwards live events to both app instances. Each instance sends only to its local authenticated sessions for that consultation.&lt;/p&gt;
&lt;p&gt;When a client reconnects, it should provide its last durable stream ID. The server can read messages after that cursor, deliver the gap, and then resume live subscription. The browser in this lab does not implement cursor recovery; the stream exists so the missing production step is explicit.&lt;/p&gt;
&lt;p&gt;A robust protocol defines:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;message ID and server stream ID;&lt;/li&gt;
&lt;li&gt;acknowledgement semantics;&lt;/li&gt;
&lt;li&gt;reconnect cursor and replay limit;&lt;/li&gt;
&lt;li&gt;duplicate behavior;&lt;/li&gt;
&lt;li&gt;ordering per consultation;&lt;/li&gt;
&lt;li&gt;authorization during replay;&lt;/li&gt;
&lt;li&gt;tombstone or edit semantics;&lt;/li&gt;
&lt;li&gt;retention and archival behavior.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Without a cursor, reconnect means “start receiving now,” which is not enough for chat.&lt;/p&gt;
&lt;h2 id=&quot;protect-healthcare-data-by-reducing-it&quot;&gt;Protect healthcare data by reducing it&lt;/h2&gt;
&lt;p&gt;Realtime healthcare messaging can contain sensitive data. The safest observability payload is usually metadata, not message text.&lt;/p&gt;
&lt;p&gt;I would record:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;active authenticated connections by instance;&lt;/li&gt;
&lt;li&gt;accepted and rejected message counts by bounded reason;&lt;/li&gt;
&lt;li&gt;authentication and authorization failures;&lt;/li&gt;
&lt;li&gt;Redis append and publish latency;&lt;/li&gt;
&lt;li&gt;send-buffer closures;&lt;/li&gt;
&lt;li&gt;reconnect and replay counts;&lt;/li&gt;
&lt;li&gt;delivery acknowledgement latency.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I would not put consultation IDs, patient IDs, message IDs, or message bodies into metric labels. Logs should use access-controlled correlation identifiers and a retention policy appropriate to the data classification. Traces must redact payloads and credentials.&lt;/p&gt;
&lt;p&gt;TLS is mandatory outside the loopback lab: use &lt;code&gt;wss://&lt;/code&gt;, validate proxy trust boundaries, and set idle timeouts and heartbeat behavior consistently across client, load balancer, ingress, and server.&lt;/p&gt;
&lt;h2 id=&quot;failure-behavior-is-the-design&quot;&gt;Failure behavior is the design&lt;/h2&gt;









































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Failure&lt;/th&gt;&lt;th&gt;Required behavior&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;App instance terminates&lt;/td&gt;&lt;td&gt;Socket reconnects to another instance and resumes from cursor&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Redis Pub/Sub notification missed&lt;/td&gt;&lt;td&gt;Recover durable message from Stream&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Duplicate client send&lt;/td&gt;&lt;td&gt;Acknowledge duplicate; do not append or broadcast twice&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Redis unavailable before append&lt;/td&gt;&lt;td&gt;Do not acknowledge success&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Persist succeeds, publish fails&lt;/td&gt;&lt;td&gt;Dispatcher later republishes from durable record&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Client stops reading&lt;/td&gt;&lt;td&gt;Bound buffer, close, and require cursor resume&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Authorization revoked&lt;/td&gt;&lt;td&gt;Close session and reject replay&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Deploy drains instance&lt;/td&gt;&lt;td&gt;Stop accepting new sockets, notify/close existing sessions, then terminate&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The integration test currently proves cross-instance live delivery, message-ID deduplication, instance health, and Prometheus availability. The next production-grade test would kill the sender instance after persistence, reconnect the receiver with a cursor, and prove recovery without duplication.&lt;/p&gt;
&lt;p&gt;The &lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc6455&quot;&gt;WebSocket protocol specification&lt;/a&gt;, &lt;a href=&quot;https://docs.spring.io/spring-framework/reference/web/websocket/server.html&quot;&gt;Spring Framework WebSocket documentation&lt;/a&gt;, &lt;a href=&quot;https://redis.io/docs/latest/develop/interact/pubsub/&quot;&gt;Redis Pub/Sub documentation&lt;/a&gt;, and &lt;a href=&quot;https://redis.io/docs/latest/develop/data-types/streams/&quot;&gt;Redis Streams documentation&lt;/a&gt; define the protocol and storage primitives. Reliability comes from the application contract built around them.&lt;/p&gt;</content:encoded><language>en-US</language><category>Realtime Systems</category><category>WebSocket</category><category>Redis Streams</category><category>Backpressure</category><category>Authentication</category><category>Healthcare</category><author>ystc1247@gmail.com</author></item><item><title>A Kubernetes Rollout That Failed Safely: Readiness, Deadlines, and Rollback</title><link>https://theokimdev.com/en/blog/post-59-article/</link><guid isPermaLink="true">https://theokimdev.com/en/blog/post-59-article/</guid><description>A runnable Kind experiment that deploys four replicas, measures a zero-error rolling update, injects a broken readiness release, reaches the progress deadline, and rolls back.</description><pubDate>Sun, 26 Jul 2026 06:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A deployment strategy is only credible when a failed release has been exercised. I built a local Kubernetes experiment that rolls four replicas from v1 to v2, continuously probes the service, deploys an image that never becomes ready, waits for the Deployment progress deadline, and rolls back.&lt;/p&gt;
&lt;p&gt;The observed result was:&lt;/p&gt;

































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Stage&lt;/th&gt;&lt;th&gt;Outcome&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Initial v1 deployment&lt;/td&gt;&lt;td&gt;Four available replicas&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;v1 → v2 rolling update&lt;/td&gt;&lt;td&gt;Completed&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Continuous availability&lt;/td&gt;&lt;td&gt;264 probes, zero non-200 responses&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Broken image rollout&lt;/td&gt;&lt;td&gt;Exceeded its progress deadline&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;kubectl rollout undo&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Restored four available v2 replicas&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Versions served&lt;/td&gt;&lt;td&gt;v1 and v2; broken never entered Service endpoints&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;This does not prove every Kubernetes rollout is zero-downtime. It proves that this manifest, application, and failure mode behaved as designed in Kind.&lt;/p&gt;
&lt;h2 id=&quot;make-availability-a-deployment-constraint&quot;&gt;Make availability a Deployment constraint&lt;/h2&gt;
&lt;p&gt;The core manifest is:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
  namespace: rollout-lab
spec:
  replicas: 4
  revisionHistoryLimit: 5
  minReadySeconds: 3
  progressDeadlineSeconds: 15
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 0
      maxSurge: 1
  template:
    spec:
      terminationGracePeriodSeconds: 10
      containers:
        - name: api
          image: rollout-lab:v1
          imagePullPolicy: Never
          ports:
            - containerPort: 8080
          readinessProbe:
            httpGet:
              path: /ready
              port: 8080
            periodSeconds: 1
            failureThreshold: 2
          livenessProbe:
            httpGet:
              path: /health
              port: 8080
            periodSeconds: 5
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;maxUnavailable: 0&lt;/code&gt; tells the Deployment controller not to reduce available replicas during the rollout. &lt;code&gt;maxSurge: 1&lt;/code&gt; allows one extra pod while replacement proceeds. With four replicas, the controller can run up to five during transition.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;minReadySeconds: 3&lt;/code&gt; requires a pod to remain ready briefly before it counts as available. &lt;code&gt;progressDeadlineSeconds: 15&lt;/code&gt; makes a stalled rollout observable as &lt;code&gt;ProgressDeadlineExceeded&lt;/code&gt;; it does not roll the Deployment back automatically.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;imagePullPolicy: Never&lt;/code&gt; is deliberate only for Kind because the experiment loads local images into the node. Production should use immutable registry digests and a pull policy consistent with that release process.&lt;/p&gt;
&lt;h2 id=&quot;readiness-and-liveness-answer-different-questions&quot;&gt;Readiness and liveness answer different questions&lt;/h2&gt;
&lt;p&gt;The test application exposes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;mux.HandleFunc(&quot;/health&quot;, func(w http.ResponseWriter, _ *http.Request) {
    w.WriteHeader(http.StatusOK)
})

mux.HandleFunc(&quot;/ready&quot;, func(w http.ResponseWriter, _ *http.Request) {
    if broken == &quot;true&quot; {
        http.Error(w, &quot;not ready&quot;, http.StatusServiceUnavailable)
        return
    }
    w.WriteHeader(http.StatusOK)
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Liveness asks whether Kubernetes should restart the container. Readiness asks whether the pod should receive Service traffic. A dependency outage often should fail readiness without failing liveness; restarting a healthy process does not repair a remote database.&lt;/p&gt;
&lt;p&gt;The broken image remains live but never becomes ready. Kubernetes starts it, probes it, and keeps it out of Service endpoints. Existing v2 pods continue serving while the rollout stalls.&lt;/p&gt;
&lt;p&gt;Readiness must represent the ability to serve the endpoint safely. A hard-coded 200 response can produce a “successful” rollout that sends traffic to an unusable application.&lt;/p&gt;
&lt;h2 id=&quot;probe-availability-during-the-rollout&quot;&gt;Probe availability during the rollout&lt;/h2&gt;
&lt;p&gt;The experiment starts a background request loop after v1 is available:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;while true; do
  timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)
  response=$(curl -sS --max-time 1 \
    -w &apos;|%{http_code}&apos; \
    http://localhost:18059/ \
    | tr -d &apos;\n&apos; || true)
  printf &apos;%s|%s\n&apos; &quot;$timestamp&quot; &quot;$response&quot; \
    &amp;gt;&amp;gt; artifacts/availability.log
  sleep 0.1
done
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The JSON response includes the application version, so the artifact proves both v1 and v2 served traffic. It also records the status of every probe.&lt;/p&gt;
&lt;p&gt;My first parser counted the JSON line and HTTP status line separately, reporting 244 false failures even though every request returned 200. Removing the response newline corrected the evidence. Measurement code is production code: validate its record format before trusting a deployment conclusion.&lt;/p&gt;
&lt;h2 id=&quot;exercise-a-healthy-rollout&quot;&gt;Exercise a healthy rollout&lt;/h2&gt;
&lt;p&gt;The release command changes the image and waits for the controller:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;kubectl --context kind-rollout-lab \
  -n rollout-lab \
  set image deployment/api api=rollout-lab:v2

kubectl --context kind-rollout-lab \
  -n rollout-lab \
  rollout status deployment/api --timeout=90s
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With &lt;code&gt;maxUnavailable: 0&lt;/code&gt;, the controller creates a v2 pod, waits until it is available, then removes an old pod. The rollout repeats until four v2 pods are available.&lt;/p&gt;
&lt;p&gt;The external loop recorded no non-200 response. That says more than &lt;code&gt;rollout status&lt;/code&gt;: it observes the Service path while the controller changes pods.&lt;/p&gt;
&lt;h2 id=&quot;inject-a-readiness-failure-and-wait-for-the-deadline&quot;&gt;Inject a readiness failure and wait for the deadline&lt;/h2&gt;
&lt;p&gt;The broken image is compiled with readiness forced false:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;kubectl --context kind-rollout-lab \
  -n rollout-lab \
  set image deployment/api api=rollout-lab:broken

kubectl --context kind-rollout-lab \
  -n rollout-lab \
  rollout status deployment/api --timeout=25s
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The new pod cannot become available, so the Deployment cannot continue without violating &lt;code&gt;maxUnavailable: 0&lt;/code&gt;. After the configured progress deadline, Kubernetes reports the stalled rollout. The old ready replicas keep serving.&lt;/p&gt;
&lt;p&gt;The response should begin with conditions and events:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;kubectl -n rollout-lab describe deployment api
kubectl -n rollout-lab get pods -o wide
kubectl -n rollout-lab get events --sort-by=.lastTimestamp
kubectl -n rollout-lab logs &amp;lt;broken-pod&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Do not immediately restart everything. Preserve the evidence that distinguishes image pull, scheduling, startup, readiness, crash, and application failures.&lt;/p&gt;
&lt;h2 id=&quot;roll-back-deliberately&quot;&gt;Roll back deliberately&lt;/h2&gt;
&lt;p&gt;Kubernetes stores previous ReplicaSet revisions up to &lt;code&gt;revisionHistoryLimit&lt;/code&gt;. The experiment runs:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;kubectl --context kind-rollout-lab \
  -n rollout-lab \
  rollout undo deployment/api

kubectl --context kind-rollout-lab \
  -n rollout-lab \
  rollout status deployment/api --timeout=90s
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The Deployment returns to v2 and reaches four available replicas. The availability loop remains active until rollback completes.&lt;/p&gt;
&lt;p&gt;Rollback safety depends on more than the image. Database migrations, queue schemas, cache formats, and external side effects must remain backward compatible. An application rollback cannot reverse a destructive migration or undo messages already published.&lt;/p&gt;
&lt;h2 id=&quot;understand-what-the-pdb-does-not-do&quot;&gt;Understand what the PDB does not do&lt;/h2&gt;
&lt;p&gt;The lab includes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: api
spec:
  minAvailable: 3
  selector:
    matchLabels:
      app: rollout-api
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The PDB limits voluntary disruptions such as a node drain. It is not the control for Deployment rolling-update availability; &lt;code&gt;maxUnavailable&lt;/code&gt; and &lt;code&gt;maxSurge&lt;/code&gt; govern that. A PDB also cannot protect against involuntary node failure.&lt;/p&gt;
&lt;p&gt;This is why copying a PDB into a manifest does not guarantee uptime. It protects one class of disruption when the eviction path honors it.&lt;/p&gt;
&lt;h2 id=&quot;rolling-blue-green-and-canary-answer-different-risks&quot;&gt;Rolling, blue-green, and canary answer different risks&lt;/h2&gt;
&lt;p&gt;The experiment measures rolling update behavior. Other strategies change the exposure model:&lt;/p&gt;





























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Strategy&lt;/th&gt;&lt;th&gt;Exposure&lt;/th&gt;&lt;th&gt;Rollback path&lt;/th&gt;&lt;th&gt;Main cost&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Rolling&lt;/td&gt;&lt;td&gt;Replace pods gradually&lt;/td&gt;&lt;td&gt;Reverse Deployment revision&lt;/td&gt;&lt;td&gt;Old and new versions coexist&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Blue-green&lt;/td&gt;&lt;td&gt;Switch traffic between full environments&lt;/td&gt;&lt;td&gt;Switch traffic back&lt;/td&gt;&lt;td&gt;Duplicate environment capacity and state compatibility&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Canary&lt;/td&gt;&lt;td&gt;Send a controlled traffic fraction to new version&lt;/td&gt;&lt;td&gt;Remove canary traffic&lt;/td&gt;&lt;td&gt;Requires trustworthy segmentation and automated analysis&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Canary is useful only if the team can measure the canary separately and has enough representative traffic. Blue-green is safe only if data contracts work in both directions and the traffic switch is tested. Rolling is simple only when old and new versions can coexist.&lt;/p&gt;
&lt;h2 id=&quot;production-gates-i-would-add&quot;&gt;Production gates I would add&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Use immutable image digests and record the release revision.&lt;/li&gt;
&lt;li&gt;Set startup, readiness, and liveness probes from measured application behavior.&lt;/li&gt;
&lt;li&gt;Define CPU and memory requests so surge pods can actually schedule.&lt;/li&gt;
&lt;li&gt;Verify termination handling and connection draining.&lt;/li&gt;
&lt;li&gt;Require backward-compatible database and event-schema changes.&lt;/li&gt;
&lt;li&gt;Probe the real ingress path during rollout, not only pod IPs.&lt;/li&gt;
&lt;li&gt;Gate on service-level error and latency signals, not pod readiness alone.&lt;/li&gt;
&lt;li&gt;Set a progress deadline and make stalled rollout alerts actionable.&lt;/li&gt;
&lt;li&gt;Test rollback in a staging environment with realistic state.&lt;/li&gt;
&lt;li&gt;Preserve rollout logs, conditions, and external probe evidence.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The &lt;a href=&quot;https://kubernetes.io/docs/concepts/workloads/controllers/deployment/&quot;&gt;Kubernetes Deployment documentation&lt;/a&gt; defines rolling-update and rollback behavior, while the &lt;a href=&quot;https://kubernetes.io/docs/concepts/configuration/liveness-readiness-startup-probes/&quot;&gt;probe documentation&lt;/a&gt; and &lt;a href=&quot;https://kubernetes.io/docs/tasks/run-application/configure-pdb/&quot;&gt;PodDisruptionBudget documentation&lt;/a&gt; define the health and disruption boundaries used here.&lt;/p&gt;</content:encoded><language>en-US</language><category>Kubernetes</category><category>Rolling Update</category><category>Readiness Probe</category><category>Rollback</category><category>Kind</category><author>ystc1247@gmail.com</author></item><item><title>Kafka Delivery Semantics in Practice: Ordering, Duplicates, Rebalances, Retries, and DLT</title><link>https://theokimdev.com/en/blog/post-56-kafka/</link><guid isPermaLink="true">https://theokimdev.com/en/blog/post-56-kafka/</guid><description>A measured Spring Kafka experiment showing what keyed partitions, idempotent producers, database deduplication, consumer rebalances, bounded retries, and dead-letter topics actually guarantee.</description><pubDate>Sun, 26 Jul 2026 06:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Kafka is a replicated log, not a promise that every business effect happens exactly once. It gives strong building blocks—ordered partition logs, offsets, consumer groups, replication, and an idempotent producer—but application correctness still depends on keys, transaction boundaries, duplicate handling, retry policy, and dead-letter operations.&lt;/p&gt;
&lt;p&gt;I tested those boundaries with a three-partition &lt;code&gt;orders.v1&lt;/code&gt; topic, a Spring Boot producer, two consumer instances, PostgreSQL projection tables, bounded retries, and &lt;code&gt;orders.v1-dlt&lt;/code&gt;. The experiment sent ordered events, duplicated one event, injected one permanent failure, and added a second consumer while traffic continued.&lt;/p&gt;
&lt;p&gt;The final evidence was:&lt;/p&gt;





























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Scenario&lt;/th&gt;&lt;th&gt;Observed result&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Five events for &lt;code&gt;order-a&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;last_sequence=5&lt;/code&gt;, &lt;code&gt;applied_events=5&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Same &lt;code&gt;event_id&lt;/code&gt; for &lt;code&gt;order-b&lt;/code&gt; sent twice&lt;/td&gt;&lt;td&gt;One database effect&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Six events for &lt;code&gt;order-c&lt;/code&gt; while consumer group changed&lt;/td&gt;&lt;td&gt;&lt;code&gt;last_sequence=6&lt;/code&gt;, &lt;code&gt;applied_events=6&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Permanent poison event&lt;/td&gt;&lt;td&gt;One record in &lt;code&gt;orders.v1-dlt&lt;/code&gt; after bounded retries&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Consumer group after settling&lt;/td&gt;&lt;td&gt;Zero lag on partitions containing records&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;That result demonstrates a design under controlled failure. It does not turn Kafka into an exactly-once database.&lt;/p&gt;
&lt;h2 id=&quot;ordering-exists-inside-one-partition&quot;&gt;Ordering exists inside one partition&lt;/h2&gt;
&lt;p&gt;A Kafka topic is divided into partition logs. Records in one partition have monotonically increasing offsets. Kafka does not define a single total order across all partitions.&lt;/p&gt;
&lt;p&gt;For an order workflow, the producer key is the aggregate identifier:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;kafkaTemplate.send(
        &quot;orders.v1&quot;,
        event.orderId(),
        serializedEvent
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Records with the same key are assigned to the same partition under a stable partitioning scheme, so &lt;code&gt;order-a&lt;/code&gt; events can be consumed in partition order. Different orders can proceed in parallel.&lt;/p&gt;
&lt;p&gt;This leads to several operational consequences:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A hot key cannot be parallelized across consumers without changing the domain model.&lt;/li&gt;
&lt;li&gt;Increasing the topic’s partition count can change key-to-partition mapping for new records.&lt;/li&gt;
&lt;li&gt;Ordering is lost if a retry design republishes failed events to an unrelated topic and later merges them without sequence checks.&lt;/li&gt;
&lt;li&gt;The consumer should persist an aggregate sequence when missing or stale transitions would be dangerous.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;“Kafka preserves order” is incomplete. The useful statement is “Kafka preserves record order within a partition, and this producer deliberately maps one aggregate to one partition.”&lt;/p&gt;
&lt;h2 id=&quot;producer-idempotence-is-necessary-but-not-end-to-end-idempotence&quot;&gt;Producer idempotence is necessary but not end-to-end idempotence&lt;/h2&gt;
&lt;p&gt;The producer configuration is:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;spring:
  kafka:
    producer:
      acks: all
      retries: 2147483647
      properties:
        enable.idempotence: true
        max.in.flight.requests.per.connection: 5
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.apache.kafka.common.serialization.StringSerializer
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;acks=all&lt;/code&gt; waits for the configured in-sync replica acknowledgement. Idempotence lets the broker deduplicate producer retries within the producer session and preserves ordering under the supported in-flight limit.&lt;/p&gt;
&lt;p&gt;It does not deduplicate:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;an HTTP caller submitting the same business command twice;&lt;/li&gt;
&lt;li&gt;an application publishing the same event ID in two separate calls;&lt;/li&gt;
&lt;li&gt;a consumer replay after its process crashes;&lt;/li&gt;
&lt;li&gt;an external side effect such as a payment or email.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The experiment proves this by publishing the same &lt;code&gt;order-b&lt;/code&gt; event twice. Both records may exist in Kafka. The database projection applies the event once because &lt;code&gt;event_id&lt;/code&gt; is unique.&lt;/p&gt;
&lt;h2 id=&quot;put-consumer-idempotency-in-the-same-database-transaction&quot;&gt;Put consumer idempotency in the same database transaction&lt;/h2&gt;
&lt;p&gt;The consumer inserts a processed-event marker and updates its read model in one PostgreSQL transaction:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Transactional
public boolean apply(
        OrderEvent event,
        int partition,
        long offset
) {
    int inserted = jdbc.sql(&quot;&quot;&quot;
        INSERT INTO processed_events(
            event_id, order_id, sequence,
            partition_id, partition_offset
        )
        VALUES (?, ?, ?, ?, ?)
        ON CONFLICT DO NOTHING
        &quot;&quot;&quot;)
        .params(
            event.eventId(), event.orderId(), event.sequence(),
            partition, offset
        )
        .update();

    if (inserted == 0) return false;

    jdbc.sql(&quot;&quot;&quot;
        INSERT INTO order_projection(
            order_id, last_sequence, applied_events
        )
        VALUES (?, ?, 1)
        ON CONFLICT (order_id) DO UPDATE
        SET last_sequence = EXCLUDED.last_sequence,
            applied_events = order_projection.applied_events + 1,
            updated_at = now()
        &quot;&quot;&quot;)
        .params(event.orderId(), event.sequence())
        .update();

    return true;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The listener uses record acknowledgement. If the database transaction commits but the process dies before Kafka commits the offset, Kafka delivers the record again. The duplicate insert does nothing, so the read model does not receive a second effect.&lt;/p&gt;
&lt;p&gt;This pattern requires marker retention to match replay expectations. Deleting &lt;code&gt;processed_events&lt;/code&gt; after seven days while retaining Kafka data for thirty days makes an older replay unsafe.&lt;/p&gt;
&lt;p&gt;For a strict state machine, I would also reject a sequence that is not exactly the expected successor. Kafka partition order reduces out-of-order delivery; it does not validate the domain transition.&lt;/p&gt;
&lt;h2 id=&quot;rebalance-changes-ownership-not-record-identity&quot;&gt;Rebalance changes ownership, not record identity&lt;/h2&gt;
&lt;p&gt;Consumer-group members divide partitions among themselves. When a member joins, leaves, stalls beyond its liveness interval, or subscriptions change, Kafka reassigns partitions.&lt;/p&gt;
&lt;p&gt;The lab starts one consumer container, publishes records, then scales to two containers and publishes the &lt;code&gt;order-c&lt;/code&gt; sequence. Each application instance configures listener concurrency three, so six consumer threads compete for three partitions. Only three can own work; extra consumers are idle.&lt;/p&gt;
&lt;p&gt;That is a useful capacity rule:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;maximum active consumers in one group for one topic
  &amp;lt;= number of assigned partitions
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;More consumer processes do not create more parallelism after every partition has an owner. They may improve failover, but they also increase group coordination and resource cost.&lt;/p&gt;
&lt;p&gt;During reassignment, a record can be processed and then redelivered if its effect completes before its offset is committed. The database idempotency key makes that replay safe. Long processing should also use realistic poll, heartbeat, batch, and timeout settings; otherwise the consumer can trigger avoidable rebalances.&lt;/p&gt;
&lt;h2 id=&quot;bound-retries-and-separate-permanent-failures&quot;&gt;Bound retries and separate permanent failures&lt;/h2&gt;
&lt;p&gt;The listener error handler uses two 500 ms retries, then publishes the record to a matching partition in &lt;code&gt;orders.v1-dlt&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Bean
CommonErrorHandler orderErrorHandler(
        KafkaTemplate&amp;lt;String, String&amp;gt; template
) {
    DeadLetterPublishingRecoverer recoverer =
        new DeadLetterPublishingRecoverer(
            template,
            (record, error) -&amp;gt; new TopicPartition(
                &quot;orders.v1-dlt&quot;, record.partition())
        );

    return new DefaultErrorHandler(
        recoverer,
        new FixedBackOff(500L, 2L)
    );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Infinite retry on a poison record blocks its partition and every later record behind it. Immediate dead-lettering treats transient network failure as permanent. Bounded retry makes the decision explicit.&lt;/p&gt;
&lt;p&gt;The DLT is not a trash can. It needs:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the original topic, partition, offset, key, timestamp, and failure class;&lt;/li&gt;
&lt;li&gt;an alert on arrival and growth;&lt;/li&gt;
&lt;li&gt;an owner who can distinguish bad data from a code or dependency failure;&lt;/li&gt;
&lt;li&gt;a replay tool that preserves idempotency and ordering decisions;&lt;/li&gt;
&lt;li&gt;retention long enough for the response process;&lt;/li&gt;
&lt;li&gt;access control because failed payloads can still contain sensitive data.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If later events depend on the failed one, continuing after dead-lettering can create a domain gap. The correct choice may be to stop that key, quarantine the aggregate, or make the projection sequence-aware.&lt;/p&gt;
&lt;h2 id=&quot;inspect-both-the-log-and-the-effect&quot;&gt;Inspect both the log and the effect&lt;/h2&gt;
&lt;p&gt;The Kafka UI showed 14 input records and one dead-letter record across three partitions:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theokimdev.com/resources/blog/post-56-kafka/kafka-ui-delivery-semantics.png&quot; alt=&quot;Kafbat UI showing the measured orders.v1 topic with 14 records and orders.v1-dlt with one poison record&quot;&gt;&lt;/p&gt;
&lt;p&gt;The 14 records break down as five &lt;code&gt;order-a&lt;/code&gt; events, two copies of one &lt;code&gt;order-b&lt;/code&gt; event, one poison event, and six &lt;code&gt;order-c&lt;/code&gt; events. The PostgreSQL artifact is equally important:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;processed&quot;: [
    {&quot;order_id&quot;:&quot;order-a&quot;,&quot;last_sequence&quot;:5,&quot;applied_events&quot;:5},
    {&quot;order_id&quot;:&quot;order-b&quot;,&quot;last_sequence&quot;:1,&quot;applied_events&quot;:1},
    {&quot;order_id&quot;:&quot;order-c&quot;,&quot;last_sequence&quot;:6,&quot;applied_events&quot;:6}
  ],
  &quot;dltRecords&quot;: 1
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Broker message count alone cannot prove a correct business effect. Database state alone cannot show backlog or poison records. The operating view needs both.&lt;/p&gt;
&lt;p&gt;Useful metrics include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;consumer lag and oldest unprocessed record age;&lt;/li&gt;
&lt;li&gt;rebalance count and duration;&lt;/li&gt;
&lt;li&gt;processing latency and failure rate by bounded exception category;&lt;/li&gt;
&lt;li&gt;retry and DLT rates;&lt;/li&gt;
&lt;li&gt;duplicate-event count;&lt;/li&gt;
&lt;li&gt;producer send latency and error rate;&lt;/li&gt;
&lt;li&gt;under-replicated or offline partitions.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;serialization-is-a-contract&quot;&gt;Serialization is a contract&lt;/h2&gt;
&lt;p&gt;The lab serializes the event to JSON explicitly and sends a string. That avoided an accidental dependency on an optional Jackson serializer and made the wire payload visible in tests.&lt;/p&gt;
&lt;p&gt;A production event needs more than a Java record:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;public record OrderEvent(
    UUID eventId,
    String orderId,
    int sequence,
    boolean failAlways
) {}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Define schema ownership, compatibility rules, required fields, default behavior, and how consumers handle unknown versions. Avoid publishing internal entity shapes. Whether the registry uses Avro, Protobuf, or JSON Schema, compatibility should be checked before deployment.&lt;/p&gt;
&lt;h2 id=&quot;what-the-guarantees-really-are&quot;&gt;What the guarantees really are&lt;/h2&gt;








































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Layer&lt;/th&gt;&lt;th&gt;Guarantee used here&lt;/th&gt;&lt;th&gt;What it does not guarantee&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Partition&lt;/td&gt;&lt;td&gt;Ordered offsets&lt;/td&gt;&lt;td&gt;Global topic order&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Key&lt;/td&gt;&lt;td&gt;Same aggregate routes together under stable partitioning&lt;/td&gt;&lt;td&gt;Ordering after careless repartitioning&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Idempotent producer&lt;/td&gt;&lt;td&gt;Deduplicates producer retries in its session&lt;/td&gt;&lt;td&gt;Business-command or consumer deduplication&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Consumer group&lt;/td&gt;&lt;td&gt;One partition owner per group assignment&lt;/td&gt;&lt;td&gt;Exactly one external side effect&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Database event key&lt;/td&gt;&lt;td&gt;One projection effect per event ID&lt;/td&gt;&lt;td&gt;Correct domain order by itself&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Bounded retry + DLT&lt;/td&gt;&lt;td&gt;Poison record stops blocking forever&lt;/td&gt;&lt;td&gt;Safe continuation or automatic repair&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;My practical Kafka checklist is now:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Choose a key from the ordering boundary.&lt;/li&gt;
&lt;li&gt;Size partitions from required parallelism and future cost, not fashion.&lt;/li&gt;
&lt;li&gt;Enable appropriate producer durability and idempotence.&lt;/li&gt;
&lt;li&gt;Make consumer effects transactional and idempotent.&lt;/li&gt;
&lt;li&gt;Test a crash after the effect but before offset commit.&lt;/li&gt;
&lt;li&gt;Add a consumer during traffic and observe the rebalance.&lt;/li&gt;
&lt;li&gt;Bound retries and operate the DLT as a queue with an owner.&lt;/li&gt;
&lt;li&gt;Version the event schema independently of application entities.&lt;/li&gt;
&lt;li&gt;Alert on lag age, failures, duplicates, and DLT growth.&lt;/li&gt;
&lt;li&gt;State the guarantee precisely; do not write “exactly once” without naming the boundary.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The &lt;a href=&quot;https://kafka.apache.org/documentation/#design&quot;&gt;Kafka design documentation&lt;/a&gt; describes the log, replication, and delivery model, while the &lt;a href=&quot;https://docs.spring.io/spring-kafka/reference/&quot;&gt;Spring for Apache Kafka reference&lt;/a&gt; documents listener containers, error handling, transactions, and dead-letter recovery.&lt;/p&gt;</content:encoded><language>en-US</language><category>Distributed Systems</category><category>Kafka</category><category>Spring Kafka</category><category>Ordering</category><category>Idempotency</category><category>Dead Letter Topic</category><author>ystc1247@gmail.com</author></item><item><title>The HTTP Client Boundary Behind a Feign PATCH Failure</title><link>https://theokimdev.com/en/blog/post-62-feignclient-patch-method/</link><guid isPermaLink="true">https://theokimdev.com/en/blog/post-62-feignclient-patch-method/</guid><description>A Spring Cloud OpenFeign boundary study covering concrete transport selection, PATCH verification, timeouts, mutation retries, idempotency, error mapping, and safe logging.</description><pubDate>Sun, 26 Jul 2026 05:55:00 GMT</pubDate><content:encoded>&lt;p&gt;This failure looked like an annotation problem:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;java.net.ProtocolException: Invalid HTTP method: PATCH
  at java.net.HttpURLConnection.setRequestMethod(...)
  at feign.Client$Default.execute(...)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;@PatchMapping&lt;/code&gt; was correct. The abstraction below it was not the transport I thought the application was using. The stack trace showed &lt;code&gt;Feign.Client$Default&lt;/code&gt; and &lt;code&gt;HttpURLConnection&lt;/code&gt;, which made the failure a client-boundary problem rather than a controller problem.&lt;/p&gt;
&lt;p&gt;A robust fix needs more than adding an OkHttp dependency. It must make the concrete transport explicit, verify the bytes that leave the process, and define timeouts, mutation retry policy, idempotency, error mapping, and log redaction together.&lt;/p&gt;
&lt;p&gt;I tested that boundary with Spring Boot 4.1, Spring Cloud 2025.1.2, OpenFeign, OkHttp, and MockWebServer.&lt;/p&gt;
&lt;h2 id=&quot;trace-the-abstraction-downward&quot;&gt;Trace the abstraction downward&lt;/h2&gt;
&lt;p&gt;When a declarative HTTP call fails, inspect these layers in order:&lt;/p&gt;

































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Layer&lt;/th&gt;&lt;th&gt;Question&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Java method&lt;/td&gt;&lt;td&gt;Are path, verb, headers, and body declared correctly?&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Feign contract&lt;/td&gt;&lt;td&gt;Did Spring MVC annotations become the intended request template?&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Feign client bean&lt;/td&gt;&lt;td&gt;Which &lt;code&gt;feign.Client&lt;/code&gt; implementation was injected?&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Transport&lt;/td&gt;&lt;td&gt;Does the concrete client support the method, TLS, proxy, and timeout behavior?&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Network/upstream&lt;/td&gt;&lt;td&gt;What request arrived, and what response or delay occurred?&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Application boundary&lt;/td&gt;&lt;td&gt;How are conflicts, timeouts, and retries represented to callers?&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The stack trace is evidence. If it names &lt;code&gt;Client$Default&lt;/code&gt;, changing an unrelated encoder or controller is unlikely to solve a transport method failure.&lt;/p&gt;
&lt;p&gt;Also verify the dependency graph. Spring Boot and Spring Cloud versions must come from compatible release trains; mixing arbitrary Feign artifacts can produce a configuration that compiles but does not activate the intended client.&lt;/p&gt;
&lt;h2 id=&quot;make-the-declarative-contract-narrow&quot;&gt;Make the declarative contract narrow&lt;/h2&gt;
&lt;p&gt;The client describes an inventory mutation:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@FeignClient(
    name = &quot;inventory&quot;,
    url = &quot;${inventory.base-url}&quot;,
    configuration = InventoryClientConfiguration.class
)
interface InventoryClient {
    @PatchMapping(
        path = &quot;/inventory/{sku}&quot;,
        consumes = &quot;application/json&quot;
    )
    InventoryResponse adjust(
        @PathVariable String sku,
        @RequestHeader(&quot;Idempotency-Key&quot;) String idempotencyKey,
        @RequestHeader(HttpHeaders.AUTHORIZATION) String authorization,
        @RequestBody InventoryAdjustment request
    );
}

record InventoryAdjustment(int delta) {}
record InventoryResponse(String sku, int available) {}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The interface contains the wire contract. Application code calls a gateway instead of depending on Feign response types or exceptions directly.&lt;/p&gt;
&lt;h2 id=&quot;register-the-transport-explicitly&quot;&gt;Register the transport explicitly&lt;/h2&gt;
&lt;p&gt;Relying on a property to discover an optional transport is easy to misconfigure. The experiment creates the OkHttp transport and exposes the exact Feign client bean:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Bean
feign.Client inventoryTransport() {
    okhttp3.OkHttpClient transport =
        new okhttp3.OkHttpClient.Builder()
            .connectTimeout(Duration.ofMillis(300))
            .readTimeout(Duration.ofMillis(500))
            .callTimeout(Duration.ofSeconds(1))
            .build();

    return new feign.okhttp.OkHttpClient(transport);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is an application boundary, so explicit wiring is useful. It removes ambiguity about which HTTP stack will execute PATCH.&lt;/p&gt;
&lt;p&gt;The timeouts have different meanings:&lt;/p&gt;





















&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Timeout&lt;/th&gt;&lt;th&gt;Bounds&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Connect&lt;/td&gt;&lt;td&gt;Establishing the connection&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Read&lt;/td&gt;&lt;td&gt;Waiting for response bytes&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Call&lt;/td&gt;&lt;td&gt;The entire exchange, including connection and body&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;A timeout is not a latency SLO. It is a resource and failure boundary. Set it from downstream behavior and the caller’s total deadline, not from a copied default.&lt;/p&gt;
&lt;h2 id=&quot;do-not-retry-a-mutation-blindly&quot;&gt;Do not retry a mutation blindly&lt;/h2&gt;
&lt;p&gt;After a timeout, the caller may not know whether the upstream applied the PATCH. An automatic retry can decrement inventory twice.&lt;/p&gt;
&lt;p&gt;The experiment disables Feign retries:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Bean
Retryer inventoryRetryer() {
    return Retryer.NEVER_RETRY;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The gateway requires a stable command ID and sends it as an idempotency key:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;public InventoryResult adjust(
        String sku,
        int delta,
        String commandId,
        String serviceToken
) {
    Objects.requireNonNull(
        commandId,
        &quot;a stable commandId is required for mutation idempotency&quot;
    );

    InventoryResponse response = client.adjust(
        sku,
        commandId,
        &quot;Bearer &quot; + serviceToken,
        new InventoryAdjustment(delta)
    );

    return new InventoryResult(response.sku(), response.available());
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;An idempotency header helps only if the upstream stores the key and the resulting outcome atomically. The client cannot declare a mutation safe by itself.&lt;/p&gt;
&lt;p&gt;For reads, bounded retries may be acceptable when the remaining deadline, backoff, and load amplification are understood. For writes, start with no automatic retry and add one only after end-to-end idempotency is proven.&lt;/p&gt;
&lt;h2 id=&quot;map-upstream-semantics-at-the-gateway&quot;&gt;Map upstream semantics at the gateway&lt;/h2&gt;
&lt;p&gt;A 409 response has business meaning in this API. The Feign error decoder translates it into an application exception:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Bean
ErrorDecoder inventoryErrors() {
    return (methodKey, response) -&amp;gt; response.status() == 409
        ? new InventoryConflictException(
              &quot;inventory version conflict&quot;)
        : new ErrorDecoder.Default()
              .decode(methodKey, response);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The rest of the application should not need to parse Feign exception messages. The gateway can map:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;400 to a request-contract defect or caller error;&lt;/li&gt;
&lt;li&gt;401/403 to service authentication or authorization failure;&lt;/li&gt;
&lt;li&gt;404 to domain absence when that is part of the upstream contract;&lt;/li&gt;
&lt;li&gt;409 to a version/idempotency conflict;&lt;/li&gt;
&lt;li&gt;429/503 to controlled availability failure with retry metadata;&lt;/li&gt;
&lt;li&gt;timeout to an ambiguous outcome for a mutation.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Preserve the upstream status and correlation metadata in structured diagnostics, but do not return internal response bodies blindly to clients.&lt;/p&gt;
&lt;h2 id=&quot;verify-the-actual-request&quot;&gt;Verify the actual request&lt;/h2&gt;
&lt;p&gt;A context test with MockWebServer records the request at the transport boundary:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;upstream.enqueue(new MockResponse()
    .setResponseCode(200)
    .addHeader(&quot;Content-Type&quot;, &quot;application/json&quot;)
    .setBody(&quot;{\&quot;sku\&quot;:\&quot;MED-42\&quot;,\&quot;available\&quot;:11}&quot;));

InventoryResult result = gateway.adjust(
    &quot;MED-42&quot;, -1, &quot;cmd-018&quot;, &quot;secret-token&quot;);

RecordedRequest request = upstream.takeRequest(1, TimeUnit.SECONDS);

assertThat(request.getMethod()).isEqualTo(&quot;PATCH&quot;);
assertThat(request.getHeader(&quot;Idempotency-Key&quot;)).isEqualTo(&quot;cmd-018&quot;);
assertThat(request.getHeader(&quot;Authorization&quot;))
    .isEqualTo(&quot;Bearer secret-token&quot;);
assertThat(request.getBody().readUtf8())
    .isEqualTo(&quot;{\&quot;delta\&quot;:-1}&quot;);
assertThat(result.available()).isEqualTo(11);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This proves the annotation, encoder, headers, transport, and decoder as one path. A unit test of the interface cannot do that.&lt;/p&gt;
&lt;p&gt;Two additional tests protect failure semantics:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A 409 response becomes &lt;code&gt;InventoryConflictException&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;An upstream delayed by 800 ms exceeds the 500 ms read timeout and produces exactly one recorded request.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The request-count assertion is critical. It proves the timed-out mutation was not retried invisibly.&lt;/p&gt;
&lt;h2 id=&quot;log-the-boundary-without-leaking-it&quot;&gt;Log the boundary without leaking it&lt;/h2&gt;
&lt;p&gt;Feign logging can expose Authorization headers and bodies. The experiment uses BASIC logging, which records method, URL, status, and timing without full payloads.&lt;/p&gt;
&lt;p&gt;In production I would emit structured metrics and traces for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;client name, normalized route, method, status class, and outcome;&lt;/li&gt;
&lt;li&gt;latency and timeout phase;&lt;/li&gt;
&lt;li&gt;retry attempt count;&lt;/li&gt;
&lt;li&gt;circuit-breaker state when present;&lt;/li&gt;
&lt;li&gt;a correlation or trace ID.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Do not use SKU, user ID, raw URL, or exception text as unbounded metric labels. Redact credentials and sensitive bodies in logs and traces.&lt;/p&gt;
&lt;h2 id=&quot;a-practical-debugging-sequence&quot;&gt;A practical debugging sequence&lt;/h2&gt;
&lt;p&gt;When an HTTP client method fails:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Read the deepest relevant stack frame and identify the concrete transport.&lt;/li&gt;
&lt;li&gt;Inspect the resolved dependency graph and compatible Spring release train.&lt;/li&gt;
&lt;li&gt;Make the &lt;code&gt;feign.Client&lt;/code&gt; bean explicit.&lt;/li&gt;
&lt;li&gt;Reproduce against a recording server and assert method, path, headers, and body.&lt;/li&gt;
&lt;li&gt;Test timeout and response mapping, not only the 200 path.&lt;/li&gt;
&lt;li&gt;Prove mutation idempotency before enabling retries.&lt;/li&gt;
&lt;li&gt;Keep credentials and bodies out of client logs.&lt;/li&gt;
&lt;li&gt;Monitor the client boundary with bounded labels and traces.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The PATCH exception was useful because it exposed a hidden dependency boundary. The durable solution is not “use OkHttp whenever PATCH fails.” It is to treat outbound HTTP as a production interface with an explicit transport and tested failure semantics.&lt;/p&gt;
&lt;p&gt;The primary references are the &lt;a href=&quot;https://docs.spring.io/spring-cloud-openfeign/reference/&quot;&gt;Spring Cloud OpenFeign documentation&lt;/a&gt;, &lt;a href=&quot;https://github.com/OpenFeign/feign&quot;&gt;OpenFeign project documentation&lt;/a&gt;, &lt;a href=&quot;https://square.github.io/okhttp/&quot;&gt;OkHttp documentation&lt;/a&gt;, and the HTTP PATCH specification, &lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc5789&quot;&gt;RFC 5789&lt;/a&gt;.&lt;/p&gt;</content:encoded><language>en-US</language><category>Distributed Systems</category><category>Spring Cloud OpenFeign</category><category>OkHttp</category><category>HTTP PATCH</category><category>Idempotency</category><category>Resilience</category><author>ystc1247@gmail.com</author></item><item><title>Protecting a JPA Aggregate: Invariants, DTOs, and Optimistic Locking</title><link>https://theokimdev.com/en/blog/post-55-getter-setter/</link><guid isPermaLink="true">https://theokimdev.com/en/blog/post-55-getter-setter/</guid><description>A concrete JPA order aggregate showing why unrestricted setters are unsafe, how behavior methods enforce invariants, and why child-only edits need deliberate versioning.</description><pubDate>Sun, 26 Jul 2026 05:55:00 GMT</pubDate><content:encoded>&lt;p&gt;The problem with entity setters is not that &lt;code&gt;setName&lt;/code&gt; is aesthetically unpleasant. The problem is that a public mutation can bypass the rules that make several fields and child rows valid together.&lt;/p&gt;
&lt;p&gt;I built a small purchase-order aggregate around three invariants:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A line quantity must be between 1 and 100.&lt;/li&gt;
&lt;li&gt;An order cannot be confirmed without at least one line.&lt;/li&gt;
&lt;li&gt;A confirmed order cannot be edited.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The design uses no public setters. It exposes commands such as &lt;code&gt;addLine&lt;/code&gt;, &lt;code&gt;changeQuantity&lt;/code&gt;, and &lt;code&gt;confirm&lt;/code&gt;, returns immutable read views, and verifies stale concurrent edits with real Hibernate optimistic locking.&lt;/p&gt;
&lt;h2 id=&quot;start-from-an-invalid-state-that-setters-allow&quot;&gt;Start from an invalid state that setters allow&lt;/h2&gt;
&lt;p&gt;This shape is convenient but cannot protect the aggregate:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Entity
@Getter
@Setter
class PurchaseOrder {
    private OrderStatus status;

    @OneToMany(mappedBy = &quot;order&quot;)
    private List&amp;lt;OrderLine&amp;gt; lines;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Any caller can now:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;order.setStatus(CONFIRMED); // even when lines is empty
order.setLines(List.of());  // even after confirmation
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A service may remember to validate first, but another service, mapper, test fixture, or deserializer may not. The entity has no single boundary where its rules are guaranteed.&lt;/p&gt;
&lt;p&gt;This is not an argument against Lombok or every getter. A generated accessor does not create a Swagger endpoint; API documentation comes from controller mappings and schemas. A getter also does not cause &lt;code&gt;LazyInitializationException&lt;/code&gt; by itself. That exception occurs when code accesses an uninitialized lazy association after its persistence context is unavailable. The real questions are which state is public, who may mutate it, and where loading occurs.&lt;/p&gt;
&lt;h2 id=&quot;put-commands-on-the-aggregate-root&quot;&gt;Put commands on the aggregate root&lt;/h2&gt;
&lt;p&gt;The root owns its lines and exposes business operations:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Entity
@Table(name = &quot;purchase_orders&quot;)
public class PurchaseOrder {
    @Id @GeneratedValue
    private Long id;

    @Version
    private long version;

    @Enumerated(EnumType.STRING)
    private OrderStatus status = OrderStatus.DRAFT;

    private long changeSequence;

    @OneToMany(
        mappedBy = &quot;order&quot;,
        cascade = CascadeType.ALL,
        orphanRemoval = true
    )
    private final List&amp;lt;OrderLine&amp;gt; lines = new ArrayList&amp;lt;&amp;gt;();

    protected PurchaseOrder() {}

    public static PurchaseOrder draft() {
        return new PurchaseOrder();
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The protected no-argument constructor exists for JPA. Application code uses the named factory, which starts the aggregate in a valid state.&lt;/p&gt;
&lt;p&gt;Each method says what the caller intends and enforces the relevant rules:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;public void addLine(String sku, int quantity) {
    requireDraft();
    Objects.requireNonNull(sku, &quot;sku&quot;);
    lines.add(new OrderLine(this, sku, quantity));
    changeSequence++;
}

public void changeQuantity(String sku, int quantity) {
    requireDraft();
    OrderLine line = lines.stream()
            .filter(candidate -&amp;gt; candidate.sku().equals(sku))
            .findFirst()
            .orElseThrow(() -&amp;gt; new IllegalArgumentException(
                    &quot;unknown sku: &quot; + sku));
    line.changeQuantity(quantity);
    changeSequence++;
}

public void confirm() {
    requireDraft();
    if (lines.isEmpty()) {
        throw new IllegalStateException(
                &quot;an order needs at least one line&quot;);
    }
    status = OrderStatus.CONFIRMED;
    changeSequence++;
}

private void requireDraft() {
    if (status != OrderStatus.DRAFT) {
        throw new IllegalStateException(
                &quot;confirmed orders cannot be edited&quot;);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;changeQuantity&lt;/code&gt; is more useful than &lt;code&gt;setQuantity&lt;/code&gt;. It identifies the line, verifies aggregate status, delegates the line-level range rule, and participates in aggregate versioning.&lt;/p&gt;
&lt;p&gt;The child still protects its local invariant:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;void changeQuantity(int quantity) {
    if (quantity &amp;lt; 1 || quantity &amp;gt; 100) {
        throw new IllegalArgumentException(
                &quot;quantity must be between 1 and 100&quot;);
    }
    this.quantity = quantity;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The root protects cross-object rules; the child protects a rule about its own value.&lt;/p&gt;
&lt;h2 id=&quot;do-not-serialize-the-entity-as-the-api&quot;&gt;Do not serialize the entity as the API&lt;/h2&gt;
&lt;p&gt;Returning JPA entities from controllers couples the HTTP schema to persistence and makes lazy loading, bidirectional relationships, and accidental field exposure part of request handling.&lt;/p&gt;
&lt;p&gt;The read boundary is an immutable DTO:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;public record OrderView(
        Long id,
        long version,
        String status,
        List&amp;lt;PurchaseOrder.OrderLineView&amp;gt; lines
) {
    public static OrderView from(PurchaseOrder order) {
        return new OrderView(
                order.id(),
                order.version(),
                order.status().name(),
                order.lineViews()
        );
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Mapping should occur inside a transaction or after a query that deliberately fetches the required graph. The controller returns &lt;code&gt;OrderView&lt;/code&gt;, not a live Hibernate proxy.&lt;/p&gt;
&lt;p&gt;This also makes the API contract explicit. Adding an internal persistence field does not automatically publish it, and removing a getter from the entity does not unexpectedly break JSON.&lt;/p&gt;
&lt;h2 id=&quot;optimistic-locking-must-cover-the-whole-aggregate&quot;&gt;Optimistic locking must cover the whole aggregate&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;@Version&lt;/code&gt; prevents a stale root-row update from silently overwriting a newer one. The usual SQL shape is conceptually:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;UPDATE purchase_orders
SET status = ?, version = version + 1
WHERE id = ? AND version = ?;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If no row matches, Hibernate reports an optimistic-lock failure.&lt;/p&gt;
&lt;p&gt;There is a subtle boundary: changing only an &lt;code&gt;OrderLine&lt;/code&gt; does not necessarily dirty the &lt;code&gt;purchase_orders&lt;/code&gt; row. A version field on the root therefore may not protect concurrent child-only edits.&lt;/p&gt;
&lt;p&gt;The experiment makes every aggregate mutation increment &lt;code&gt;changeSequence&lt;/code&gt; on the root. That deliberately dirties the root row, so its version advances when a line changes. Other valid strategies include putting versions on children, forcing an optimistic lock on the root, or remodeling the transaction boundary. The important part is testing the actual SQL behavior rather than assuming &lt;code&gt;@Version&lt;/code&gt; automatically covers a graph.&lt;/p&gt;
&lt;p&gt;The integration test loads two detached copies, commits one editor, then attempts to merge the stale editor:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;PurchaseOrder firstEditor = loadDetached(id);
PurchaseOrder staleEditor = loadDetached(id);

firstEditor.changeQuantity(&quot;SKU-1&quot;, 2);
mergeAndCommit(firstEditor);

staleEditor.changeQuantity(&quot;SKU-1&quot;, 3);
assertThatThrownBy(() -&amp;gt; mergeAndCommit(staleEditor))
        .isInstanceOf(OptimisticLockException.class);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That test originally exposed the child-only version gap. Adding a root mutation made the test pass for the right reason.&lt;/p&gt;
&lt;h2 id=&quot;test-the-domain-without-hibernate-then-test-hibernate&quot;&gt;Test the domain without Hibernate, then test Hibernate&lt;/h2&gt;
&lt;p&gt;Most invariants are plain unit tests:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Test
void confirmedOrderCannotBeMutated() {
    PurchaseOrder order = PurchaseOrder.draft();
    order.addLine(&quot;SKU-1&quot;, 2);
    order.confirm();

    assertThatThrownBy(() -&amp;gt;
            order.changeQuantity(&quot;SKU-1&quot;, 3))
        .isInstanceOf(IllegalStateException.class);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These are fast and show that the object protects itself. The separate &lt;code&gt;@DataJpaTest&lt;/code&gt; proves mapping, cascade behavior, and optimistic locking against Hibernate and H2.&lt;/p&gt;
&lt;p&gt;Both levels are necessary. A unit test cannot prove SQL version checks. A persistence test should not be the only place a business rule is readable.&lt;/p&gt;
&lt;h2 id=&quot;where-accessors-remain-reasonable&quot;&gt;Where accessors remain reasonable&lt;/h2&gt;
&lt;p&gt;Accessors are not forbidden. I use them deliberately:&lt;/p&gt;

































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Type&lt;/th&gt;&lt;th&gt;Typical choice&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Request/response DTO&lt;/td&gt;&lt;td&gt;Immutable record or explicit constructor&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Configuration binding&lt;/td&gt;&lt;td&gt;Framework-compatible immutable properties where possible&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;JPA identifier/version&lt;/td&gt;&lt;td&gt;Narrow read access when application code needs it&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Aggregate collection&lt;/td&gt;&lt;td&gt;Immutable view or mapped DTO, never the mutable list&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Business mutation&lt;/td&gt;&lt;td&gt;Named method that enforces the rule&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Persistence-only constructor&lt;/td&gt;&lt;td&gt;&lt;code&gt;protected&lt;/code&gt; no-argument constructor&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The test is whether the public method preserves meaning. &lt;code&gt;order.confirm()&lt;/code&gt; describes a transition. &lt;code&gt;order.setStatus(CONFIRMED)&lt;/code&gt; exposes storage.&lt;/p&gt;
&lt;h2 id=&quot;transaction-and-loading-boundaries-still-matter&quot;&gt;Transaction and loading boundaries still matter&lt;/h2&gt;
&lt;p&gt;A well-encapsulated entity does not solve every JPA problem. Services still need to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;load the aggregate with the relationships required for the command;&lt;/li&gt;
&lt;li&gt;keep one transaction around the command and commit;&lt;/li&gt;
&lt;li&gt;avoid N+1 query patterns when mapping lists of aggregates;&lt;/li&gt;
&lt;li&gt;decide how optimistic-lock conflicts become API responses;&lt;/li&gt;
&lt;li&gt;avoid leaking entities beyond the persistence boundary;&lt;/li&gt;
&lt;li&gt;publish external events only after durable state is committed.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The concrete lesson is not “never use &lt;code&gt;@Getter&lt;/code&gt;.” It is: make the aggregate the only place where its invariants can be changed, make the API a separate model, and test the concurrency boundary with the real ORM.&lt;/p&gt;
&lt;p&gt;The &lt;a href=&quot;https://jakarta.ee/specifications/persistence/&quot;&gt;Jakarta Persistence specification&lt;/a&gt; defines entity and version semantics, while the &lt;a href=&quot;https://docs.jboss.org/hibernate/orm/current/userguide/html_single/Hibernate_User_Guide.html&quot;&gt;Hibernate user guide&lt;/a&gt; documents Hibernate’s mapping and optimistic-lock behavior.&lt;/p&gt;</content:encoded><language>en-US</language><category>Domain Modeling</category><category>JPA</category><category>Hibernate</category><category>Aggregate</category><category>Optimistic Locking</category><category>Java</category><author>ystc1247@gmail.com</author></item><item><title>When a Redis Leaderboard Lies: Outbox, Idempotency, and Reconciliation</title><link>https://theokimdev.com/en/blog/post-51-zset/</link><guid isPermaLink="true">https://theokimdev.com/en/blog/post-51-zset/</guid><description>A cache-consistency failure study for Redis ZSET leaderboards using a PostgreSQL source of truth, transactional outbox, idempotent projection, and repair loop.</description><pubDate>Sun, 26 Jul 2026 05:55:00 GMT</pubDate><content:encoded>&lt;p&gt;A Redis sorted set makes a leaderboard read cheap: &lt;code&gt;ZREVRANGE&lt;/code&gt; returns the highest scores without asking the relational database to aggregate every request. The difficult part is not the ZSET operation. It is keeping that projection trustworthy when the database commit, Redis write, worker process, or network fails.&lt;/p&gt;
&lt;p&gt;The unsafe implementation is also the most tempting:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;scoreRepository.increment(userId, delta);     // database write
redisTemplate.opsForZSet()
        .incrementScore(&quot;leaderboard&quot;, userId, delta); // Redis write
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Those writes do not share a transaction. If the database commits and Redis fails, the leaderboard is stale. If Redis succeeds and the database rolls back, the leaderboard invents a score. A retry can apply the delta twice. Reversing the order changes which inconsistency is possible; it does not remove the inconsistency.&lt;/p&gt;
&lt;p&gt;I built a failure experiment around a different contract:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;PostgreSQL is the source of truth.&lt;/li&gt;
&lt;li&gt;A score command has a stable event ID.&lt;/li&gt;
&lt;li&gt;The score change and an outbox row commit in one database transaction.&lt;/li&gt;
&lt;li&gt;A worker projects absolute score and version into Redis.&lt;/li&gt;
&lt;li&gt;Projection is idempotent and rejects stale versions.&lt;/li&gt;
&lt;li&gt;Reconciliation detects and repairs missing, stale, ahead, or corrupt cache state.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is an eventually consistent read model, not a distributed transaction disguised as a service method.&lt;/p&gt;
&lt;h2 id=&quot;define-the-consistency-contract&quot;&gt;Define the consistency contract&lt;/h2&gt;
&lt;p&gt;Before choosing code, decide what the product may observe:&lt;/p&gt;





































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Question&lt;/th&gt;&lt;th&gt;Contract in this experiment&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Authoritative score&lt;/td&gt;&lt;td&gt;PostgreSQL &lt;code&gt;user_scores.score&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Leaderboard freshness&lt;/td&gt;&lt;td&gt;Eventual; visible lag while the worker is down&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Duplicate client command&lt;/td&gt;&lt;td&gt;One database effect per &lt;code&gt;event_id&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Duplicate worker delivery&lt;/td&gt;&lt;td&gt;Safe; absolute score and version are idempotent&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Redis loss&lt;/td&gt;&lt;td&gt;Rebuild from PostgreSQL&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Redis corruption&lt;/td&gt;&lt;td&gt;Detect and repair through reconciliation&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Read during lag&lt;/td&gt;&lt;td&gt;Return the projection with explicit freshness, or fall back when the product requires it&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Redis is allowed to be behind. It is never allowed to silently become the authority for a business score.&lt;/p&gt;
&lt;h2 id=&quot;commit-the-score-and-outbox-atomically&quot;&gt;Commit the score and outbox atomically&lt;/h2&gt;
&lt;p&gt;The relational schema separates command identity, current state, and pending projection work:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE TABLE score_events (
  event_id uuid PRIMARY KEY,
  user_id text NOT NULL,
  delta integer NOT NULL CHECK (delta BETWEEN -1000 AND 1000),
  created_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE user_scores (
  user_id text PRIMARY KEY,
  score bigint NOT NULL DEFAULT 0,
  version bigint NOT NULL DEFAULT 0,
  updated_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE leaderboard_outbox (
  id bigserial PRIMARY KEY,
  event_id uuid NOT NULL UNIQUE REFERENCES score_events(event_id),
  user_id text NOT NULL,
  absolute_score bigint NOT NULL,
  score_version bigint NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  published_at timestamptz
);

CREATE INDEX leaderboard_outbox_pending_idx
  ON leaderboard_outbox (id)
  WHERE published_at IS NULL;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The command handler performs three changes inside one PostgreSQL transaction:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;await client.query(&apos;BEGIN&apos;);

const inserted = await client.query(
  `INSERT INTO score_events(event_id, user_id, delta)
   VALUES ($1, $2, $3)
   ON CONFLICT DO NOTHING
   RETURNING event_id`,
  [eventId, userId, delta]
);

if (inserted.rowCount === 0) {
  await client.query(&apos;COMMIT&apos;);
  return { duplicate: true };
}

const score = await client.query(
  `INSERT INTO user_scores(user_id, score, version)
   VALUES ($1, $2, 1)
   ON CONFLICT (user_id) DO UPDATE
   SET score = user_scores.score + EXCLUDED.score,
       version = user_scores.version + 1,
       updated_at = now()
   RETURNING user_id, score, version`,
  [userId, delta]
);

await client.query(
  `INSERT INTO leaderboard_outbox
     (event_id, user_id, absolute_score, score_version)
   VALUES ($1, $2, $3, $4)`,
  [eventId, userId, score.rows[0].score, score.rows[0].version]
);

await client.query(&apos;COMMIT&apos;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The primary key on &lt;code&gt;score_events.event_id&lt;/code&gt; is the command idempotency boundary. If an HTTP retry sends the same command again, the score is not incremented twice.&lt;/p&gt;
&lt;p&gt;The outbox stores the absolute score and monotonic version, not another delta. That decision makes worker replay safe. Applying “score is 17 at version 2” twice is idempotent; applying “add 7” twice is not.&lt;/p&gt;
&lt;h2 id=&quot;project-with-at-least-once-delivery-in-mind&quot;&gt;Project with at-least-once delivery in mind&lt;/h2&gt;
&lt;p&gt;The worker claims pending rows with &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt;, which allows several workers to claim different batches:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT id, event_id, user_id, absolute_score, score_version
FROM leaderboard_outbox
WHERE published_at IS NULL
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 100;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For every row it updates three Redis structures atomically through Lua:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-lua&quot;&gt;local currentVersion = tonumber(
  redis.call(&apos;HGET&apos;, KEYS[3], ARGV[1]) or &apos;-1&apos;
)
local incomingVersion = tonumber(ARGV[3])

if redis.call(&apos;EXISTS&apos;, KEYS[1]) == 1 then
  return &apos;duplicate&apos;
end

if incomingVersion &amp;gt; currentVersion then
  redis.call(&apos;ZADD&apos;, KEYS[2], ARGV[2], ARGV[1])
  redis.call(&apos;HSET&apos;, KEYS[3], ARGV[1], incomingVersion)
  redis.call(&apos;SET&apos;, KEYS[1], &apos;1&apos;, &apos;EX&apos;, ARGV[4])
  return &apos;applied&apos;
end

redis.call(&apos;SET&apos;, KEYS[1], &apos;1&apos;, &apos;EX&apos;, ARGV[4])
return &apos;stale&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The keys represent an event marker, the leaderboard ZSET, and a user-version hash. Lua makes the comparison and writes atomic inside Redis.&lt;/p&gt;
&lt;p&gt;The version check remains important after the event marker expires. If an old outbox row is replayed later, it cannot overwrite a newer absolute score. The marker is an optimization for duplicate work; the monotonic version is the correctness boundary.&lt;/p&gt;
&lt;h2 id=&quot;design-for-the-crash-between-redis-and-postgresql&quot;&gt;Design for the crash between Redis and PostgreSQL&lt;/h2&gt;
&lt;p&gt;The worker performs this sequence:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Claim an outbox row in PostgreSQL.&lt;/li&gt;
&lt;li&gt;Apply the Redis projection.&lt;/li&gt;
&lt;li&gt;Mark the outbox row published.&lt;/li&gt;
&lt;li&gt;Commit.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;It can crash after step 2. PostgreSQL rolls back the claim, so another worker reads the same row. The Redis operation must therefore tolerate duplicate delivery. That is why the projection uses an event marker, an absolute score, and a version check.&lt;/p&gt;
&lt;p&gt;Holding the database transaction open across a Redis call is a conscious tradeoff in this compact experiment. &lt;code&gt;SKIP LOCKED&lt;/code&gt; prevents one slow batch from stopping all workers, but a production design should bound Redis timeouts, keep batches small, monitor lock age, and consider a leased-claim state when external latency is high.&lt;/p&gt;
&lt;p&gt;Exactly-once delivery is not the goal. One durable database effect plus an idempotent at-least-once projection is easier to reason about and test.&lt;/p&gt;
&lt;h2 id=&quot;reconcile-because-idempotency-is-not-repair&quot;&gt;Reconcile because idempotency is not repair&lt;/h2&gt;
&lt;p&gt;Idempotency handles duplicates. It does not repair deletion, manual edits, expired Redis data, or a software defect that wrote the wrong score.&lt;/p&gt;
&lt;p&gt;The reconciliation job reads source rows and classifies each projection:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;export function compareProjection(source, projection) {
  if (!projection) return &apos;missing&apos;;
  if (Number(projection.version) &amp;lt; Number(source.version)) return &apos;stale&apos;;
  if (Number(projection.version) &amp;gt; Number(source.version)) return &apos;ahead&apos;;
  if (Number(projection.score) !== Number(source.score)) return &apos;corrupt&apos;;
  return &apos;current&apos;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;ahead&lt;/code&gt; is not automatically safe. It can mean the source read is stale, the wrong database is being queried, or Redis contains invented state. I would alert and investigate before forcing a repair in a system where cross-region or replica lag is possible.&lt;/p&gt;
&lt;p&gt;For missing, stale, or corrupt rows, the repair path writes the source’s absolute score and version. A full rebuild can delete the projection and replay every source row into a new key, then atomically switch readers to that key.&lt;/p&gt;
&lt;h2 id=&quot;what-the-failure-experiment-observed&quot;&gt;What the failure experiment observed&lt;/h2&gt;
&lt;p&gt;The experiment intentionally duplicated a command, stopped the worker, and corrupted one Redis score:&lt;/p&gt;















































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Stage&lt;/th&gt;&lt;th&gt;PostgreSQL source&lt;/th&gt;&lt;th&gt;Redis projection&lt;/th&gt;&lt;th&gt;Result&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;First command for &lt;code&gt;user-a&lt;/code&gt;&lt;/td&gt;&lt;td&gt;score 10, version 1&lt;/td&gt;&lt;td&gt;score 10&lt;/td&gt;&lt;td&gt;Applied&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Same event ID again&lt;/td&gt;&lt;td&gt;score 10, version 1&lt;/td&gt;&lt;td&gt;score 10&lt;/td&gt;&lt;td&gt;Duplicate detected; no second effect&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Worker stopped&lt;/td&gt;&lt;td&gt;&lt;code&gt;user-a&lt;/code&gt; 17, &lt;code&gt;user-b&lt;/code&gt; 25&lt;/td&gt;&lt;td&gt;only &lt;code&gt;user-a&lt;/code&gt; 10&lt;/td&gt;&lt;td&gt;Expected visible projection lag&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Worker restarted&lt;/td&gt;&lt;td&gt;&lt;code&gt;user-a&lt;/code&gt; 17, &lt;code&gt;user-b&lt;/code&gt; 25&lt;/td&gt;&lt;td&gt;both projected&lt;/td&gt;&lt;td&gt;Outbox drained&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Redis &lt;code&gt;user-a&lt;/code&gt; changed to 999&lt;/td&gt;&lt;td&gt;source still 17&lt;/td&gt;&lt;td&gt;corrupt score 999&lt;/td&gt;&lt;td&gt;Reconciliation classified corruption&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Repair complete&lt;/td&gt;&lt;td&gt;&lt;code&gt;user-b&lt;/code&gt; 25, &lt;code&gt;user-a&lt;/code&gt; 17&lt;/td&gt;&lt;td&gt;identical ranking&lt;/td&gt;&lt;td&gt;One of two rows repaired&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The final artifact reported two rows checked: one corrupt and one current. It repaired the corrupt row, and the final source ordering exactly matched the ZSET ordering.&lt;/p&gt;
&lt;p&gt;That result demonstrates the failure paths; it does not establish throughput. A production run also needs outbox age, pending count, worker error rate, reconciliation drift count, Redis latency, and leaderboard freshness metrics.&lt;/p&gt;
&lt;h2 id=&quot;expose-freshness-to-the-product&quot;&gt;Expose freshness to the product&lt;/h2&gt;
&lt;p&gt;Eventual consistency becomes a product problem when nobody defines “eventual.” Useful options include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;return &lt;code&gt;projectedThrough&lt;/code&gt; or projection age with the leaderboard;&lt;/li&gt;
&lt;li&gt;fall back to a database query when the outbox age exceeds a limit;&lt;/li&gt;
&lt;li&gt;hide a user’s just-submitted rank until their version is projected;&lt;/li&gt;
&lt;li&gt;offer read-your-write behavior from the command response while global ranking catches up;&lt;/li&gt;
&lt;li&gt;fail closed for rewards or payouts that require authoritative ordering.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The Redis &lt;code&gt;score&lt;/code&gt; is a double. Integer values are exact only within the IEEE-754 safe integer range, so very large counters or composite ranking scores need a deliberate representation and tie-break rule.&lt;/p&gt;
&lt;h2 id=&quot;the-operational-checklist&quot;&gt;The operational checklist&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Keep the business score authoritative in one durable system.&lt;/li&gt;
&lt;li&gt;Require a stable command/event ID at the API boundary.&lt;/li&gt;
&lt;li&gt;Commit source state and outbox work in one database transaction.&lt;/li&gt;
&lt;li&gt;Project absolute state with a monotonic version.&lt;/li&gt;
&lt;li&gt;Assume the worker will deliver at least once.&lt;/li&gt;
&lt;li&gt;Make Redis comparison and writes atomic.&lt;/li&gt;
&lt;li&gt;Monitor outbox age, not only row count.&lt;/li&gt;
&lt;li&gt;Reconcile on a schedule and make full rebuilds routine.&lt;/li&gt;
&lt;li&gt;Define freshness and fallback behavior for readers.&lt;/li&gt;
&lt;li&gt;Never award money or irreversible benefits from an unverified cache projection.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Redis documents ZSET behavior in its &lt;a href=&quot;https://redis.io/docs/latest/develop/data-types/sorted-sets/&quot;&gt;sorted-set documentation&lt;/a&gt;, while Spring’s &lt;a href=&quot;https://docs.spring.io/spring-data/redis/reference/&quot;&gt;Redis reference&lt;/a&gt; covers the client-side integration. Neither replaces the application-level consistency contract.&lt;/p&gt;</content:encoded><language>en-US</language><category>Distributed Data</category><category>Redis</category><category>ZSET</category><category>PostgreSQL</category><category>Outbox</category><category>Idempotency</category><author>ystc1247@gmail.com</author></item><item><title>Reducing Spring Boot Log Cost Without Hiding Failures</title><link>https://theokimdev.com/en/blog/post-63-article/</link><guid isPermaLink="true">https://theokimdev.com/en/blog/post-63-article/</guid><description>A measured route-aware logging design that removes health-probe noise while preserving business events, 5xx errors, structured fields, and Prometheus evidence.</description><pubDate>Sun, 26 Jul 2026 05:45:00 GMT</pubDate><content:encoded>&lt;p&gt;Log volume is a reliability and cost concern, but content-substring filtering is a dangerous fix. Dropping every message that contains &lt;code&gt;/health&lt;/code&gt;, &lt;code&gt;kube-probe&lt;/code&gt;, or &lt;code&gt;ELB-HealthChecker&lt;/code&gt; can hide an exception whose text happens to include the same string. It also breaks when a proxy changes wording.&lt;/p&gt;
&lt;p&gt;The safer boundary is logger identity: classify the request from structured HTTP state, send routine successful probes to a dedicated logger, keep business access events separate, and force every 5xx event onto an unsuppressed ERROR path.&lt;/p&gt;
&lt;p&gt;I measured that design with two otherwise identical Spring Boot 4.1 containers. The baseline enabled health access logs; the filtered variant disabled only the dedicated health logger. Each received 2,000 health checks, 100 successful business requests, and 10 injected 5xx requests.&lt;/p&gt;
&lt;h2 id=&quot;decide-which-signal-is-redundant&quot;&gt;Decide which signal is redundant&lt;/h2&gt;
&lt;p&gt;Before suppressing a log, write down what would be lost and what equivalent evidence remains:&lt;/p&gt;



































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Signal&lt;/th&gt;&lt;th&gt;Keep in logs?&lt;/th&gt;&lt;th&gt;Replacement or reason&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Successful health access event&lt;/td&gt;&lt;td&gt;Usually no&lt;/td&gt;&lt;td&gt;Counter, status metric, and probe alert&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Failed health request&lt;/td&gt;&lt;td&gt;Yes&lt;/td&gt;&lt;td&gt;Incident evidence; log at ERROR&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Successful business access event&lt;/td&gt;&lt;td&gt;Yes or sampled&lt;/td&gt;&lt;td&gt;Request audit and latency context&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Business 5xx&lt;/td&gt;&lt;td&gt;Yes&lt;/td&gt;&lt;td&gt;Never suppress through the health logger&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Request body or credentials&lt;/td&gt;&lt;td&gt;No by default&lt;/td&gt;&lt;td&gt;High privacy and security risk&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;“The log platform is expensive” is not enough. Measure the source volume, prove another signal exists, and define how to restore samples during an incident.&lt;/p&gt;
&lt;h2 id=&quot;classify-from-the-parsed-request-not-formatted-text&quot;&gt;Classify from the parsed request, not formatted text&lt;/h2&gt;
&lt;p&gt;The classifier uses an exact path comparison and gives errors priority:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;final class AccessLogClassifier {
    static final String HEALTH_LOGGER = &quot;lab.access.health&quot;;
    static final String APPLICATION_LOGGER = &quot;lab.access.application&quot;;
    static final String ERROR_LOGGER = &quot;lab.access.error&quot;;

    static String loggerFor(String path, int status) {
        if (status &amp;gt;= 500) return ERROR_LOGGER;
        return path.equals(&quot;/actuator/health&quot;)
                ? HEALTH_LOGGER
                : APPLICATION_LOGGER;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;/actuator/health&lt;/code&gt; is probe traffic. &lt;code&gt;/api/errors/actuator/health&lt;/code&gt; is an application path and must not be suppressed. A 503 from &lt;code&gt;/actuator/health&lt;/code&gt; goes to &lt;code&gt;lab.access.error&lt;/code&gt;, not &lt;code&gt;lab.access.health&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The filter emits structured fields after the response completes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;String path = request.getRequestURI();
if (path.equals(&quot;/actuator/health&quot;)) probeRequests.increment();

var logger = LoggerFactory.getLogger(
        AccessLogClassifier.loggerFor(path, response.getStatus()));

LoggingEventBuilder event = response.getStatus() &amp;gt;= 500
        ? logger.atError()
        : logger.atInfo();

event.addKeyValue(&quot;http.request.method&quot;, request.getMethod())
     .addKeyValue(&quot;url.path&quot;, path)
     .addKeyValue(&quot;http.response.status_code&quot;, response.getStatus())
     .addKeyValue(&quot;event.duration_ms&quot;, durationMs)
     .log(&quot;request completed&quot;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The ERROR call is not cosmetic. During the experiment I found that routing a 5xx to &lt;code&gt;lab.access.error&lt;/code&gt; while still calling &lt;code&gt;atInfo()&lt;/code&gt; allowed an ERROR logger threshold to discard it. The corrected implementation selects both the error logger and the error level, and the measurement includes explicit 5xx requests so the regression cannot hide.&lt;/p&gt;
&lt;h2 id=&quot;disable-one-logger-through-configuration&quot;&gt;Disable one logger through configuration&lt;/h2&gt;
&lt;p&gt;Spring Boot structured logging produces machine-readable ECS JSON. The only difference between variants is the health logger level:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;logging:
  structured:
    format:
      console: ecs
  level:
    lab.access.health: ${HEALTH_ACCESS_LOG_LEVEL:OFF}
    lab.access.application: INFO
    lab.access.error: ERROR
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is more stable than a Logback filter that inspects &lt;code&gt;formattedMessage&lt;/code&gt;. The application has already decided what kind of event it is, and configuration decides whether that class is retained.&lt;/p&gt;
&lt;p&gt;Structured fields also avoid fragile parsing. &lt;code&gt;http.response.status_code=500&lt;/code&gt; is safer to query than a regular expression over a custom sentence. Request IDs are validated and bounded before entering MDC; secrets and bodies are not logged.&lt;/p&gt;
&lt;h2 id=&quot;preserve-the-metric-before-removing-the-log&quot;&gt;Preserve the metric before removing the log&lt;/h2&gt;
&lt;p&gt;The filter increments a Micrometer counter independently of logging:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;probeRequests = Counter.builder(&quot;http.probe.requests&quot;)
        .description(&quot;Health probe requests independent of access-log sampling&quot;)
        .register(registry);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Prometheus can alert on failed probes or unexpected probe volume without ingesting one access event per successful check. The experiment verifies that the counter remains queryable after &lt;code&gt;lab.access.health&lt;/code&gt; is disabled.&lt;/p&gt;
&lt;p&gt;Metrics and logs are not interchangeable. A metric preserves count and trend; an error log preserves diagnostic context. The design intentionally keeps both where each is strongest.&lt;/p&gt;
&lt;h2 id=&quot;measure-before-and-after&quot;&gt;Measure before and after&lt;/h2&gt;
&lt;p&gt;Both variants received the same bounded workload. The captured container logs produced:&lt;/p&gt;



































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Signal&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Baseline&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Health logger off&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Captured bytes&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;933,744&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;51,220&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Successful health access events&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;2,001&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Successful business access events&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;100&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;100&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Error events&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;10&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;10&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Byte reduction&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;—&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;94.51%&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The extra baseline health event is the readiness request used before the measured loop. It is included rather than silently adjusted away.&lt;/p&gt;
&lt;p&gt;The result demonstrates selectivity: every business access event and every injected error remained, while routine health access events disappeared. It does not prove the same percentage for another service because JSON shape, stack traces, sidecars, probe intervals, and traffic mix differ.&lt;/p&gt;
&lt;h2 id=&quot;make-the-cost-projection-explicit&quot;&gt;Make the cost projection explicit&lt;/h2&gt;
&lt;p&gt;The projection uses the measured byte difference per health request:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;avoided bytes/request
  = (baseline bytes - filtered bytes) / 2,000

monthly probe requests
  = 30 days × 24 hours × 60 minutes × 12 probes/minute
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For one replica, one probe every five seconds, and an illustrative ingestion rate of &lt;code&gt;$0.50/GiB&lt;/code&gt;, the model yields:&lt;/p&gt;

















&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Projection input or output&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Avoided ingestion&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0.213 GiB/month&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Illustrative avoided ingestion charge&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;$0.1065/month&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;That tiny number is an important result. For one small service, this optimization barely affects the bill. The value can grow across many replicas, several probes, verbose access formats, retention tiers, and indexing charges, but it must be calculated from the actual platform. Do not present an illustrative ingestion rate as a cloud invoice.&lt;/p&gt;
&lt;h2 id=&quot;guardrails-for-production&quot;&gt;Guardrails for production&lt;/h2&gt;
&lt;p&gt;I would ship this only with the following conditions:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Match a normalized route or dedicated filter-chain attribute, not message text.&lt;/li&gt;
&lt;li&gt;Check status first so 5xx events can never enter the suppressible logger.&lt;/li&gt;
&lt;li&gt;Emit 5xx events at ERROR and test the configured threshold.&lt;/li&gt;
&lt;li&gt;Preserve probe counts and failures as metrics with alerts.&lt;/li&gt;
&lt;li&gt;Keep business access logging and application exceptions independently configurable.&lt;/li&gt;
&lt;li&gt;Validate request IDs and never log tokens, credentials, or bodies by default.&lt;/li&gt;
&lt;li&gt;Re-enable or sample the health logger temporarily when incident analysis requires it.&lt;/li&gt;
&lt;li&gt;Compare actual ingest, index, retention, and query costs before claiming savings.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If log pressure comes from business traffic rather than probes, use deliberate sampling, aggregation, shorter retention, or narrower structured fields. Do not hide all successful requests without deciding what audit and latency evidence the service still needs.&lt;/p&gt;
&lt;p&gt;The implementation choices follow the &lt;a href=&quot;https://docs.spring.io/spring-boot/reference/features/logging.html&quot;&gt;Spring Boot logging reference&lt;/a&gt;, &lt;a href=&quot;https://docs.spring.io/spring-boot/reference/actuator/metrics.html&quot;&gt;Spring Boot Actuator metrics reference&lt;/a&gt;, and &lt;a href=&quot;https://logback.qos.ch/manual/filters.html&quot;&gt;Logback filter documentation&lt;/a&gt;.&lt;/p&gt;</content:encoded><language>en-US</language><category>Observability</category><category>Spring Boot</category><category>Logback</category><category>Structured Logging</category><category>Prometheus</category><category>Cost</category><author>ystc1247@gmail.com</author></item><item><title>A Reproducible JMeter Load Test: Throughput, p95, Errors, and Evidence</title><link>https://theokimdev.com/en/blog/post-52-jmeter/</link><guid isPermaLink="true">https://theokimdev.com/en/blog/post-52-jmeter/</guid><description>A saved, non-GUI JMeter experiment that controls workload shape, records raw samples, calculates independent percentiles, and avoids false capacity conclusions.</description><pubDate>Sun, 26 Jul 2026 05:45:00 GMT</pubDate><content:encoded>&lt;p&gt;JMeter is a load and performance test runner, not a unit-testing framework. A useful JMeter result is also not “the server handled 500 TPS.” It is a reproducible statement about one build, one workload shape, one environment, and several signals: offered load, achieved throughput, latency distribution, errors, and saturation.&lt;/p&gt;
&lt;p&gt;I built a bounded experiment around a deterministic HTTP API and JMeter 5.6.3. The plan lives in a saved &lt;code&gt;.jmx&lt;/code&gt; file, runs in non-GUI mode, writes raw JTL samples, generates the official HTML report, and passes or fails through an independently calculated gate.&lt;/p&gt;
&lt;h2 id=&quot;define-the-question-before-the-thread-group&quot;&gt;Define the question before the thread group&lt;/h2&gt;
&lt;p&gt;The experiment asks a deliberately narrow question:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Can this local target sustain approximately 20 requests per second for 30 seconds while keeping the error ratio below 5% and p95 latency below 250 ms?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The target is synthetic. It injects known behavior so the analysis has something to find:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;export function classifyRequest(sequence) {
  if (!Number.isInteger(sequence) || sequence &amp;lt; 1) {
    return { status: 400, delayMs: 0 };
  }
  if (sequence % 25 === 0) return { status: 503, delayMs: 30 };
  if (sequence % 10 === 0) return { status: 200, delayMs: 180 };
  return { status: 200, delayMs: 20 };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The API is not pretending to represent a real capacity limit. It makes latency and failure signals deterministic enough to verify the test machinery.&lt;/p&gt;
&lt;h2 id=&quot;concurrency-is-not-throughput&quot;&gt;Concurrency is not throughput&lt;/h2&gt;
&lt;p&gt;A JMeter thread is a virtual user. Twenty threads do not imply 20 requests per second: response time, timers, ramp-up, connection reuse, and client-side scheduling all affect the achieved rate.&lt;/p&gt;
&lt;p&gt;The saved plan controls the important variables:&lt;/p&gt;













































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Variable&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Value&lt;/th&gt;&lt;th&gt;Reason&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Threads&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;20&lt;/td&gt;&lt;td&gt;Enough concurrency to sustain the target rate&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Ramp-up&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;10 s&lt;/td&gt;&lt;td&gt;Avoid an accidental synchronized connection burst&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Duration&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;30 s&lt;/td&gt;&lt;td&gt;Bounds the experiment&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Throughput timer&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;1,200/min&lt;/td&gt;&lt;td&gt;Targets approximately 20 requests/s&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Connect timeout&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;1 s&lt;/td&gt;&lt;td&gt;Separates connection failure from slow response&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Response timeout&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;2 s&lt;/td&gt;&lt;td&gt;Bounds stalled samples&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Keep-alive&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;Enabled&lt;/td&gt;&lt;td&gt;Exercises normal connection reuse&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The core plan is version-controlled, not reconstructed through screenshots:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;&amp;lt;ThreadGroup testname=&quot;Steady state&quot; enabled=&quot;true&quot;&amp;gt;
  &amp;lt;stringProp name=&quot;ThreadGroup.num_threads&quot;&amp;gt;${__P(threads,20)}&amp;lt;/stringProp&amp;gt;
  &amp;lt;stringProp name=&quot;ThreadGroup.ramp_time&quot;&amp;gt;10&amp;lt;/stringProp&amp;gt;
  &amp;lt;boolProp name=&quot;ThreadGroup.scheduler&quot;&amp;gt;true&amp;lt;/boolProp&amp;gt;
  &amp;lt;stringProp name=&quot;ThreadGroup.duration&quot;&amp;gt;${__P(duration,30)}&amp;lt;/stringProp&amp;gt;
&amp;lt;/ThreadGroup&amp;gt;

&amp;lt;ConstantThroughputTimer testname=&quot;Bounded throughput&quot; enabled=&quot;true&quot;&amp;gt;
  &amp;lt;doubleProp&amp;gt;
    &amp;lt;name&amp;gt;throughput&amp;lt;/name&amp;gt;
    &amp;lt;value&amp;gt;1200.0&amp;lt;/value&amp;gt;
  &amp;lt;/doubleProp&amp;gt;
  &amp;lt;intProp name=&quot;calcMode&quot;&amp;gt;1&amp;lt;/intProp&amp;gt;
&amp;lt;/ConstantThroughputTimer&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The workload sends a unique request ID, uses per-thread query data, and calls one GET endpoint. A production scenario would also define authentication, realistic data distribution, cache state, think time, and the ratio between different operations.&lt;/p&gt;
&lt;h2 id=&quot;run-jmeter-without-the-gui&quot;&gt;Run JMeter without the GUI&lt;/h2&gt;
&lt;p&gt;The GUI is useful for authoring and debugging. It is a poor load generator because listeners, result trees, and rendering consume memory and CPU that should be generating traffic.&lt;/p&gt;
&lt;p&gt;The repeatable execution path is:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;jmeter \
  -n \
  -t /tests/api-load-plan.jmx \
  -Jthreads=20 \
  -Jduration=30 \
  -l /artifacts/results.jtl \
  -e \
  -o /artifacts/report \
  -f
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;-n&lt;/code&gt; selects non-GUI mode, &lt;code&gt;-t&lt;/code&gt; selects the saved plan, &lt;code&gt;-l&lt;/code&gt; preserves raw samples, and &lt;code&gt;-e -o&lt;/code&gt; generates the HTML report. Keeping the JTL matters: it lets another program verify the report instead of treating a screenshot as the source of truth.&lt;/p&gt;
&lt;h2 id=&quot;read-the-distribution-not-the-average&quot;&gt;Read the distribution, not the average&lt;/h2&gt;
&lt;p&gt;The measured run produced:&lt;/p&gt;









































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Signal&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Observed value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Samples&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;617&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Failed samples&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;16&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Error ratio&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;2.59%&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Achieved throughput&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;20.58 requests/s&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;p50&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;24 ms&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;p95&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;184 ms&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;p99&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;188 ms&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Maximum&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;193 ms&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;img src=&quot;https://theokimdev.com/resources/blog/post-52-jmeter/jmeter-html-report.png&quot; alt=&quot;Apache JMeter HTML dashboard from the measured 617-sample API run, including the 2.59 percent error ratio and latency percentiles&quot;&gt;&lt;/p&gt;
&lt;p&gt;The average was 37.72 ms, which hides the deliberately slow tenth-request path. p50 stays near the 20 ms baseline, while p95 and p99 move into the 180 ms class. That is why an average alone is unsafe for an SLO or capacity decision.&lt;/p&gt;
&lt;p&gt;The 16 failures also require context. The injected sequence is per virtual user. Because threads ramp up over ten seconds, only 16 threads reached their 25th request before the 30-second scheduler ended. The observed 2.59% is therefore consistent with the actual workload, but it is not a universal failure probability.&lt;/p&gt;
&lt;p&gt;The achieved 20.58 requests/s is close to the requested 20. It is not exactly 20 because the scheduler starts and stops samples around the duration boundary, and throughput is calculated from first-sample to last-sample timestamps.&lt;/p&gt;
&lt;h2 id=&quot;verify-the-result-independently&quot;&gt;Verify the result independently&lt;/h2&gt;
&lt;p&gt;The gate reads the JTL-derived JSON rather than scraping the HTML:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const failures = [];

if (summary.errorRate &amp;gt; 0.05) {
  failures.push(`error rate ${summary.errorRate} exceeds 0.05`);
}
if (summary.latencyMs.p95 &amp;gt; 250) {
  failures.push(`p95 ${summary.latencyMs.p95}ms exceeds 250ms`);
}
if (summary.samples &amp;lt; 300) {
  failures.push(`only ${summary.samples} samples were recorded`);
}

if (failures.length) {
  console.error(failures.join(&apos;\n&apos;));
  process.exit(1);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three checks are important here:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The error ratio is part of the result, not discarded before latency analysis.&lt;/li&gt;
&lt;li&gt;The percentile is calculated from individual elapsed samples.&lt;/li&gt;
&lt;li&gt;A minimum sample count prevents an accidentally short run from passing.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A stronger production gate would distinguish warm-up from steady state, define acceptable response codes per request type, verify the achieved load band, and compare several repetitions rather than one run.&lt;/p&gt;
&lt;h2 id=&quot;correlate-the-bottleneck&quot;&gt;Correlate the bottleneck&lt;/h2&gt;
&lt;p&gt;JMeter describes what the client observed. It does not identify the bottleneck. During a real test, align the JTL timestamps with:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;application request rate, error ratio, and latency histograms;&lt;/li&gt;
&lt;li&gt;CPU throttling, allocation rate, garbage collection, and thread pools;&lt;/li&gt;
&lt;li&gt;database connection-pool wait time, slow queries, locks, and I/O;&lt;/li&gt;
&lt;li&gt;downstream latency, retries, circuit breakers, and rate limits;&lt;/li&gt;
&lt;li&gt;load-generator CPU, memory, sockets, and network throughput.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If the generator is saturated, a flat throughput curve may describe the client machine rather than the service. Run the generator separately from the system under test and monitor both.&lt;/p&gt;
&lt;h2 id=&quot;what-this-run-does-not-prove&quot;&gt;What this run does not prove&lt;/h2&gt;
&lt;p&gt;This local result proves that the saved scenario and gates behave as designed. It does not prove production capacity, a safe autoscaling target, or the maximum sustainable throughput.&lt;/p&gt;
&lt;p&gt;Before making one of those claims, I would add:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;representative production data volume and cache state;&lt;/li&gt;
&lt;li&gt;a warm-up phase and a stable measurement phase;&lt;/li&gt;
&lt;li&gt;repeated runs of the same commit with variance reported;&lt;/li&gt;
&lt;li&gt;step, stress, and soak profiles in addition to steady load;&lt;/li&gt;
&lt;li&gt;server-side saturation evidence and a clearly defined stop condition;&lt;/li&gt;
&lt;li&gt;a separate test for coordinated omission if request timing requires it.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The practical rule is simple: save the workload, run it without the GUI, preserve raw samples, report percentiles and errors together, and refuse to call one uncontrolled run a capacity number.&lt;/p&gt;
&lt;p&gt;The &lt;a href=&quot;https://jmeter.apache.org/usermanual/get-started.html&quot;&gt;JMeter getting-started guide&lt;/a&gt;, &lt;a href=&quot;https://jmeter.apache.org/usermanual/generating-dashboard&quot;&gt;dashboard report documentation&lt;/a&gt;, and &lt;a href=&quot;https://jmeter.apache.org/usermanual/best-practices.html&quot;&gt;best-practices guide&lt;/a&gt; are the primary references for the execution choices above.&lt;/p&gt;</content:encoded><language>en-US</language><category>Performance Testing</category><category>JMeter</category><category>Load Testing</category><category>Latency</category><category>SLO</category><author>ystc1247@gmail.com</author></item><item><title>Operating a Spring Boot SLO with Prometheus, Alertmanager, and Grafana</title><link>https://theokimdev.com/en/blog/post-49-prometheus-grafana/</link><guid isPermaLink="true">https://theokimdev.com/en/blog/post-49-prometheus-grafana/</guid><description>A measured Spring Boot observability lab covering SLOs, recording rules, burn-rate alerts, cardinality, secure exposure, Grafana provisioning, and monitoring cost.</description><pubDate>Sun, 26 Jul 2026 05:45:00 GMT</pubDate><content:encoded>&lt;p&gt;Installing Prometheus and Grafana is not an observability strategy. The useful unit is an operating loop: define reliability, collect a bounded signal, turn expensive queries into recording rules, page on sustained budget consumption, and make the whole path testable.&lt;/p&gt;
&lt;p&gt;I built that loop around a small Spring Boot 4.1 API. The experiment generated 240 known requests—216 normal, 12 deliberately slow, and 12 failed—then verified the application, scrape target, Prometheus rules, Alertmanager, and provisioned dashboard. The point was not the dashboard screenshot. It was proving that every panel and alert represented a decision I could act on.&lt;/p&gt;
&lt;h2 id=&quot;start-with-the-service-level-objective&quot;&gt;Start with the service-level objective&lt;/h2&gt;
&lt;p&gt;An alert such as “CPU above 80%” says that a resource is busy. It does not say whether users are receiving a reliable service. I started with two user-facing objectives instead:&lt;/p&gt;




















&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Indicator&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Objective&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Error budget&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Non-5xx HTTP requests&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;99.5% over 30 days&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0.5% may fail&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Request latency&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;95% below 250 ms&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;5% may be slower&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The availability SLI for one window is:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;success ratio = 1 - (5xx request rate / total request rate)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The 0.5% error budget is not permission to ignore failures. It is a common scale for release risk and incident urgency. Consuming it slowly may justify a ticket; consuming it 14.4 times faster than planned is a paging condition.&lt;/p&gt;
&lt;p&gt;This lab uses five-minute and one-hour windows so the behavior is visible locally. A production alert policy should use windows derived from the real SLO period, traffic shape, and on-call response time.&lt;/p&gt;
&lt;h2 id=&quot;keep-the-metrics-endpoint-off-the-public-edge&quot;&gt;Keep the metrics endpoint off the public edge&lt;/h2&gt;
&lt;p&gt;Spring Boot exposes the Prometheus registry through Actuator:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-groovy&quot;&gt;implementation &apos;org.springframework.boot:spring-boot-starter-actuator&apos;
implementation &apos;io.micrometer:micrometer-registry-prometheus&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;management:
  server:
    port: 9091
  endpoints:
    web:
      exposure:
        include: health,prometheus
  metrics:
    distribution:
      percentiles-histogram:
        http.server.requests: true
      slo:
        http.server.requests: 50ms,100ms,250ms,500ms,1s
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The application listens on &lt;code&gt;8080&lt;/code&gt;; the management server listens on &lt;code&gt;9091&lt;/code&gt;. In the Compose experiment only &lt;code&gt;8080&lt;/code&gt; is published. Prometheus reaches &lt;code&gt;app:9091&lt;/code&gt; on the private network.&lt;/p&gt;
&lt;p&gt;That distinction matters. Do not solve production monitoring by opening Grafana on 3000 and Prometheus on 9090 to the internet. Operator surfaces expose topology, labels, queries, and sometimes control endpoints. Put them behind private networking and authenticated TLS termination. Disable default credentials, restrict administration, and treat Prometheus lifecycle or write endpoints as privileged operations.&lt;/p&gt;
&lt;p&gt;The local ports in this experiment are loopback-bound so they cannot accept remote traffic:&lt;/p&gt;



































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Component&lt;/th&gt;&lt;th&gt;Host binding&lt;/th&gt;&lt;th&gt;Purpose&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;API&lt;/td&gt;&lt;td&gt;&lt;code&gt;127.0.0.1:18049&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Generate known application traffic&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Grafana&lt;/td&gt;&lt;td&gt;&lt;code&gt;127.0.0.1:13049&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Read the provisioned dashboard&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Prometheus&lt;/td&gt;&lt;td&gt;&lt;code&gt;127.0.0.1:19090&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Inspect targets and PromQL&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Alertmanager&lt;/td&gt;&lt;td&gt;&lt;code&gt;127.0.0.1:19093&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Inspect alert routing&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Actuator&lt;/td&gt;&lt;td&gt;Not published&lt;/td&gt;&lt;td&gt;Scraped only inside the Compose network&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;h2 id=&quot;cardinality-is-part-of-the-design&quot;&gt;Cardinality is part of the design&lt;/h2&gt;
&lt;p&gt;A metric name is only half of a Prometheus time series. Every unique label set creates another series. A label such as &lt;code&gt;user_id&lt;/code&gt;, request UUID, raw exception message, or unnormalized URL can turn one useful metric into millions of expensive series.&lt;/p&gt;
&lt;p&gt;For HTTP metrics, use bounded labels such as method, status class, service, and route template. &lt;code&gt;/orders/{orderId}&lt;/code&gt; is bounded; &lt;code&gt;/orders/6fdb...&lt;/code&gt; is not. Put request-specific evidence in logs or traces, where it can be sampled and retained under a different cost model.&lt;/p&gt;
&lt;p&gt;Before adding a label, I ask three questions:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Is its value set bounded and understood?&lt;/li&gt;
&lt;li&gt;Will I aggregate or alert on this dimension?&lt;/li&gt;
&lt;li&gt;Could a trace or structured log answer the same high-cardinality question?&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;record-the-queries-the-dashboard-and-alerts-share&quot;&gt;Record the queries the dashboard and alerts share&lt;/h2&gt;
&lt;p&gt;Dashboards and alerts should not each embed a slightly different copy of a long query. Prometheus recording rules evaluate once and store a reusable time series:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;groups:
  - name: api-slo-recording
    interval: 5s
    rules:
      - record: job:http_requests:rate5m
        expr: &amp;gt;-
          sum by (job) (
            rate(http_server_requests_seconds_count{uri!=&quot;/actuator/prometheus&quot;}[5m])
          )

      - record: job:http_errors:ratio_rate5m
        expr: &amp;gt;-
          sum by (job) (
            rate(http_server_requests_seconds_count{status=~&quot;5..&quot;}[5m])
          ) / clamp_min(job:http_requests:rate5m, 0.001)

      - record: job:http_request_duration_seconds:p95_5m
        expr: &amp;gt;-
          histogram_quantile(
            0.95,
            sum by (job, le) (
              rate(http_server_requests_seconds_bucket[5m])
            )
          )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;clamp_min&lt;/code&gt; keeps a near-idle denominator from producing an undefined ratio. It does not make a low-traffic SLO statistically meaningful; that still requires enough events or a different indicator.&lt;/p&gt;
&lt;p&gt;The latency query aggregates histogram buckets by &lt;code&gt;le&lt;/code&gt; before calling &lt;code&gt;histogram_quantile&lt;/code&gt;. Client-side percentiles cannot be averaged across instances. Histograms keep the aggregation possible, but bucket boundaries must still match the latency decisions the service cares about.&lt;/p&gt;
&lt;h2 id=&quot;page-on-budget-burn-not-on-a-decorative-graph&quot;&gt;Page on budget burn, not on a decorative graph&lt;/h2&gt;
&lt;p&gt;Prometheus evaluates alert rules. Alertmanager then groups, inhibits, silences, and routes alert instances. Grafana visualizes the same state; it is not a replacement for the alert-delivery path.&lt;/p&gt;
&lt;p&gt;The local fast-burn rule requires both windows to exceed the same threshold:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;- alert: ApiErrorBudgetFastBurn
  expr: &amp;gt;-
    job:http_errors:ratio_rate5m &amp;gt; (14.4 * 0.005)
    and
    job:http_errors:ratio_rate1h &amp;gt; (14.4 * 0.005)
  for: 1m
  labels:
    severity: page
  annotations:
    summary: API is rapidly consuming its 99.5% success SLO error budget
    response: Check recent releases and dependencies; roll back or mitigate first.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;At a 99.5% objective, &lt;code&gt;14.4 × 0.005&lt;/code&gt; is a 7.2% error ratio. The short window detects a sharp event; the longer window reduces sensitivity to a brief spike. Production policies usually combine multiple burn rates and windows so fast incidents page while slower erosion creates a ticket.&lt;/p&gt;
&lt;p&gt;Every alert needs an owner and a first action. “API errors high” is weak. “Check the latest release and dependencies; mitigate or roll back before investigating lower-priority causes” gives the responder a useful starting point.&lt;/p&gt;
&lt;h2 id=&quot;provision-the-dashboard-as-code&quot;&gt;Provision the dashboard as code&lt;/h2&gt;
&lt;p&gt;The data source and dashboard are committed and mounted into Grafana. A recreated container produces the same panels and queries, so dashboard drift becomes reviewable.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theokimdev.com/resources/blog/post-49-prometheus-grafana/grafana-slo-dashboard.png&quot; alt=&quot;Provisioned Grafana dashboard showing success rate, p95 latency, request rate, and error-budget burn from the measured Spring Boot experiment&quot;&gt;&lt;/p&gt;
&lt;p&gt;The burn chart is more useful than a wall of JVM gauges because it connects failures to the reliability objective. JVM memory, connection pools, and CPU remain valuable diagnostic panels, but they answer “why” after the service-level signal answers “whether users are affected.”&lt;/p&gt;
&lt;h2 id=&quot;verify-the-entire-path&quot;&gt;Verify the entire path&lt;/h2&gt;
&lt;p&gt;The experiment runs these checks rather than trusting that containers started:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;docker compose exec -T prometheus \
  promtool check config /etc/prometheus/prometheus.yml

docker compose exec -T prometheus \
  promtool check rules /etc/prometheus/rules.yml

curl -fsS http://localhost:19090/-/ready
curl -fsS http://localhost:19093/-/ready
curl -fsS http://localhost:13049/api/health
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It also queries the Prometheus API for the scrape target and a recording-rule result. A green Grafana container with an empty data source is not a successful monitoring deployment.&lt;/p&gt;
&lt;p&gt;The observed traffic mix was deliberately unhealthy enough to exercise the queries:&lt;/p&gt;






























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Request class&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Count&lt;/th&gt;&lt;th&gt;Intended signal&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Normal&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;216&lt;/td&gt;&lt;td&gt;Baseline request rate and latency&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Slow&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;12&lt;/td&gt;&lt;td&gt;Upper histogram buckets and p95 movement&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Failed&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;12&lt;/td&gt;&lt;td&gt;5xx ratio and error-budget burn&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Total&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;240&lt;/td&gt;&lt;td&gt;Known denominator for the experiment&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;This is functional observability validation, not a capacity benchmark. It proves signal wiring and rule behavior.&lt;/p&gt;
&lt;h2 id=&quot;put-cost-into-the-review&quot;&gt;Put cost into the review&lt;/h2&gt;
&lt;p&gt;Prometheus cost is driven by active series, scrape frequency, retention, rule evaluation, query load, and replication. Grafana cost is driven by its database, rendering/query activity, plugins, and high availability. Long-term storage adds its own object-store and compaction model.&lt;/p&gt;
&lt;p&gt;For every new metric family, estimate:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;series ≈ product of label cardinalities
samples/day ≈ series × 86,400 / scrape_interval_seconds
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then measure actual active-series and ingestion changes after deployment. Do not infer a cloud bill from one formula; compression, retention, remote write, replicas, and vendor pricing change the result.&lt;/p&gt;
&lt;p&gt;My production checklist is now short and strict:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Define the SLI and SLO before building the dashboard.&lt;/li&gt;
&lt;li&gt;Keep labels bounded and route-normalized.&lt;/li&gt;
&lt;li&gt;Put shared PromQL in tested recording rules.&lt;/li&gt;
&lt;li&gt;Route actionable alerts through Alertmanager with an owner and response.&lt;/li&gt;
&lt;li&gt;Keep management and operator surfaces private, authenticated, and encrypted.&lt;/li&gt;
&lt;li&gt;Provision dashboards and data sources from version-controlled files.&lt;/li&gt;
&lt;li&gt;Measure series growth and ingestion cost after every instrumentation change.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The relevant primary references are the &lt;a href=&quot;https://docs.spring.io/spring-boot/reference/actuator/metrics.html&quot;&gt;Spring Boot Actuator metrics documentation&lt;/a&gt;, &lt;a href=&quot;https://prometheus.io/docs/prometheus/latest/configuration/recording_rules/&quot;&gt;Prometheus recording-rule documentation&lt;/a&gt;, &lt;a href=&quot;https://prometheus.io/docs/prometheus/latest/configuration/alerting_rules/&quot;&gt;Prometheus alerting-rule documentation&lt;/a&gt;, and &lt;a href=&quot;https://grafana.com/docs/grafana/latest/administration/provisioning/&quot;&gt;Grafana provisioning documentation&lt;/a&gt;.&lt;/p&gt;</content:encoded><language>en-US</language><category>Observability</category><category>Spring Boot</category><category>Prometheus</category><category>Alertmanager</category><category>Grafana</category><category>SLO</category><author>ystc1247@gmail.com</author></item><item><title>Building PostgreSQL Consistency Lab: From Invisible Race Conditions to an Evidence-Backed Workbench</title><link>https://theokimdev.com/en/blog/post-98-postgres-consistency-lab-from-anomalies-to-observability/</link><guid isPermaLink="true">https://theokimdev.com/en/blog/post-98-postgres-consistency-lab-from-anomalies-to-observability/</guid><description>A detailed retrospective on building PostgreSQL Consistency Lab: deterministic transaction failures, isolated sandboxes, managed actors, 16 real scenarios, bounded workloads, PostgreSQL observability, a synchronized dashboard, and one shared Go control plane for CLI, REST/SSE, and MCP.</description><pubDate>Sun, 26 Jul 2026 01:00:00 GMT</pubDate><content:encoded>&lt;h2 id=&quot;the-failures-that-do-not-look-like-failures&quot;&gt;The failures that do not look like failures&lt;/h2&gt;
&lt;p&gt;Some of the most expensive database bugs do not begin with an error. Both requests return successfully. Both transactions commit. The logs contain no stack trace. The API emits two &lt;code&gt;200&lt;/code&gt; responses, but one business operation quietly disappears.&lt;/p&gt;
&lt;p&gt;That class of failure bothered me more than an ordinary syntax or constraint error. A broken query that raises immediately is unpleasant, but it is visible. A lost update, write skew, double-claimed job, missing outbox event, or badly chosen index can survive code review and remain invisible until concurrency or traffic gives it the exact conditions it needs. By the time the symptom reaches an operator, the transaction boundary that caused it is gone.&lt;/p&gt;
&lt;p&gt;I had encountered the ingredients many times in backend work: transaction annotations that looked correct from the application side, lock waits that only made sense after inspecting PostgreSQL, retries that accidentally repeated a side effect, indexes that improved one read while making every write more expensive, and monitoring graphs whose numbers could be read three different ways. The recurring problem was not a lack of definitions. I knew what &lt;code&gt;READ COMMITTED&lt;/code&gt;, &lt;code&gt;SERIALIZABLE&lt;/code&gt;, &lt;code&gt;SELECT FOR UPDATE&lt;/code&gt;, &lt;code&gt;SKIP LOCKED&lt;/code&gt;, partial indexes, and the transactional outbox were. The problem was that definitions are much easier to remember than behavior is to prove.&lt;/p&gt;
&lt;p&gt;PostgreSQL Consistency Lab began as an attempt to close that gap.&lt;/p&gt;
&lt;p&gt;The first repository description was deliberately narrow: a transaction, lock, and index failure simulator. Its original definition of done for each scenario already contained the important constraints:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;start from a production-shaped story;&lt;/li&gt;
&lt;li&gt;reproduce the failure deterministically;&lt;/li&gt;
&lt;li&gt;implement and run a correct alternative;&lt;/li&gt;
&lt;li&gt;measure the result instead of relying on intuition;&lt;/li&gt;
&lt;li&gt;preserve lock, SQLSTATE, or query-plan evidence where it matters;&lt;/li&gt;
&lt;li&gt;explain when the fix is appropriate and what it costs.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The earliest scaffold framed this partly as backend engineering practice. That was useful for choosing the first scenarios, but it became too small a description of the finished system. I did not want another repository that listed concurrency anomalies in Markdown or showed a polished UI over fixture data. I wanted one local environment where I could create a real PostgreSQL failure, stop at the interesting moment, inspect the database, compare strategies, replay the evidence, and then prove that the lab cleaned up only what it owned.&lt;/p&gt;
&lt;p&gt;That requirement changed the project from a scenario runner into a database engineering workbench.&lt;/p&gt;
&lt;h2 id=&quot;the-first-architecture-was-intentionally-disposable&quot;&gt;The first architecture was intentionally disposable&lt;/h2&gt;
&lt;p&gt;The repository was created on July 9, 2026 as a docs-first Kotlin and Gradle scaffold. The initial architecture was small:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;CLI
 └── ScenarioRunner
      ├── ScenarioCatalog
      ├── DatabaseFixture
      ├── ConcurrentActors
      ├── ProbeCollectors
      └── ReportWriter
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It was a reasonable starting point. PostgreSQL would run in Docker Compose. Each scenario would own a fixture. Concurrent actors would meet at explicit barriers. Probe collectors would retain outcomes, final state, latency, locks, plans, and SQLSTATEs. A Markdown-first report writer would make the evidence portable.&lt;/p&gt;
&lt;p&gt;The first ADR selected Kotlin because it was familiar for backend work and expressive enough for the initial harness. Before scenario implementation began, however, the target changed. I wanted a custom dashboard and a local MCP server in addition to the CLI. Long-lived transactions needed to remain attached to real database connections. Workloads needed visible worker and pool state. The MCP adapter needed structured objects rather than console parsing. A single runtime would have to serve several transports without letting each transport invent its own database behavior.&lt;/p&gt;
&lt;p&gt;At that point I replaced the Kotlin anchor with Go 1.25 and &lt;code&gt;pgx&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The language change was not about claiming that Go was universally better. It was about making the concurrency model of this particular tool obvious. Goroutines and channels were a compact fit for two database actors meeting at a barrier. &lt;code&gt;pgx&lt;/code&gt; exposed PostgreSQL transactions, SQLSTATEs, pools, and extended-protocol execution directly. The official Go MCP SDK could be added later without creating another runtime. Rust would have offered stronger compile-time ownership, but it also would have put more language machinery between the reader and the transaction schedule I wanted the project to explain.&lt;/p&gt;
&lt;p&gt;The second ADR therefore established a more durable dependency direction:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;CLI / HTTP / MCP
       │
       ▼
shared Go application services
       │
       ▼
pgx / PostgreSQL 16
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;MCP would be an adapter. HTTP would be an adapter. The CLI would be an adapter. None of them would shell out to another interface or parse human-readable output. This decision looks ordinary in the final repository, but it prevented a great deal of later drift. When emergency stop, sandbox validation, actor ownership, or scenario evidence changed, there was one implementation to update.&lt;/p&gt;
&lt;h2 id=&quot;the-smallest-experiment-losing-one-of-two-committed-updates&quot;&gt;The smallest experiment: losing one of two committed updates&lt;/h2&gt;
&lt;p&gt;The first executable scenario was &lt;code&gt;tx-lost-update&lt;/code&gt;. I chose it because it captures the reason the lab exists: a correctness failure can occur even though both transactions commit.&lt;/p&gt;
&lt;p&gt;The fixture starts with one account:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;create table consistency_lab.accounts (
    id bigint primary key,
    balance bigint not null,
    version bigint not null default 0
);

insert into consistency_lab.accounts (id, balance)
values (1, 100);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two actors each add &lt;code&gt;10&lt;/code&gt;. The broken implementation reads the balance in application code and later writes the calculated value. Under &lt;code&gt;READ COMMITTED&lt;/code&gt;, both actors can read &lt;code&gt;100&lt;/code&gt;, both calculate &lt;code&gt;110&lt;/code&gt;, and both commit an update to &lt;code&gt;110&lt;/code&gt;. The database did exactly what it was asked to do. The business invariant did not survive.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Coordinator        Actor A                 Actor B
    |                  |                       |
    |              BEGIN RC                BEGIN RC
    |              SELECT 100              SELECT 100
    |&amp;lt;------------- both actors ready -----------------&amp;gt;|
    |------------- release update barrier --------------&amp;gt;|
    |              UPDATE 110              UPDATE 110 waits
    |              COMMIT                  lock acquired
    |                                      UPDATE 110
    |                                      COMMIT
    |---------------- final balance: 110 ----------------&amp;gt;|
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The important part is the barrier. A test based on &lt;code&gt;sleep(100 * time.Millisecond)&lt;/code&gt; might fail on one machine and pass on another. It would demonstrate scheduler luck, not a transaction anomaly. The coordinator releases the updates only after both transactions have recorded the stale read. The final balance of &lt;code&gt;110&lt;/code&gt; is therefore the expected broken outcome, not a flaky surprise.&lt;/p&gt;
&lt;p&gt;The first fix used an atomic update:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;update consistency_lab.accounts
set balance = balance + $1
where id = 1;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;PostgreSQL serializes the row updates, and the expression is evaluated against the current row version. Both commits are represented, so the final balance becomes &lt;code&gt;120&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The completed scenario later grew to five strategies:&lt;/p&gt;



































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Strategy&lt;/th&gt;&lt;th&gt;Mechanism&lt;/th&gt;&lt;th&gt;Result and cost to expose&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;broken&lt;/code&gt;&lt;/td&gt;&lt;td&gt;application read-modify-write&lt;/td&gt;&lt;td&gt;both commits can produce &lt;code&gt;110/120&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;atomic&lt;/code&gt;&lt;/td&gt;&lt;td&gt;one in-database update expression&lt;/td&gt;&lt;td&gt;preserves the increment with row serialization&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;optimistic_version&lt;/code&gt;&lt;/td&gt;&lt;td&gt;version predicate and conflict detection&lt;/td&gt;&lt;td&gt;preserves correctness by surfacing a conflict&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;select_for_update&lt;/code&gt;&lt;/td&gt;&lt;td&gt;pessimistic row lock before calculation&lt;/td&gt;&lt;td&gt;preserves correctness while making waiting explicit&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;serializable_retry&lt;/code&gt;&lt;/td&gt;&lt;td&gt;serializable transaction plus bounded retry&lt;/td&gt;&lt;td&gt;preserves the invariant and may expose SQLSTATE &lt;code&gt;40001&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;This set became the pattern for the rest of the catalog. The lab does not label a strategy “fixed” merely because the final value is correct. It also records the mechanism that made it correct: wait, conflict, retry, constraint, plan change, additional index size, or extra ledger write. A repair without its cost is only half an explanation.&lt;/p&gt;
&lt;h2 id=&quot;moving-from-one-fixture-to-marked-sandboxes&quot;&gt;Moving from one fixture to marked sandboxes&lt;/h2&gt;
&lt;p&gt;The original lost-update runner used a shared schema and an advisory lock to prevent two executions from resetting the fixture simultaneously. That was adequate for one bounded CLI scenario. It was not enough for a dashboard that could create schemas, open long transactions, run workloads, build indexes, and accept commands from MCP.&lt;/p&gt;
&lt;p&gt;The next vertical slice introduced an explicit PostgreSQL ownership boundary.&lt;/p&gt;
&lt;p&gt;Every writable database must contain a lab marker. If the marker is missing or its environment is not &lt;code&gt;lab&lt;/code&gt;, mutation is refused. The marker is intentionally simple, because its job is not to provide authentication. Its job is to make an accidental connection to an ordinary database fail closed.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;create table if not exists public.pg_consistency_lab_marker (
    marker_id boolean primary key default true check (marker_id),
    instance_id uuid not null,
    created_at timestamptz not null default now(),
    environment text not null check (environment = &apos;lab&apos;)
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The database then separates durable control records from disposable workload objects:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;PostgreSQL 16
├── public.pg_consistency_lab_marker
├── lab_control
│   ├── sandboxes
│   ├── experiments and ordered events
│   ├── actors and actor operations
│   ├── workload runs and samples
│   ├── artifacts
│   └── audit log
└── lab_sbx_&amp;lt;opaque id&amp;gt;
    ├── seeded application tables
    ├── scenario-specific temporary objects
    ├── indexes, functions, and triggers
    └── generated non-login runtime role
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;User-provided names remain display labels. SQL identifiers are generated. A sandbox schema has the &lt;code&gt;lab_sbx_&lt;/code&gt; prefix and a random opaque suffix. Each sandbox is paired with a generated non-login role that receives privileges only for that schema. Query Lab and composition assertions execute under that role with a sandbox search path, transaction mode, timeouts, and result bounds. The dashboard never receives PostgreSQL credentials.&lt;/p&gt;
&lt;p&gt;Creation is transactional. The manager verifies the marker, resolves a named profile, validates the sandbox kind and display label, creates the control record and schema, installs and seeds the template, runs &lt;code&gt;ANALYZE&lt;/code&gt;, and finally marks the sandbox ready. If seeding fails, the schema and registration roll back together.&lt;/p&gt;
&lt;p&gt;Deletion follows the ownership record in the opposite direction. It loads a registered sandbox, refuses a schema outside the generated prefix, checks for active lab owners, drops the exact schema, and removes the registry entry. It does not accept an arbitrary schema name from the UI. &lt;code&gt;DROP SCHEMA ... CASCADE&lt;/code&gt; is powerful, but the dangerous part is not the SQL keyword; it is whether the caller can widen the target.&lt;/p&gt;
&lt;h2 id=&quot;a-production-shaped-seed-without-pretending-to-be-production-sized&quot;&gt;A production-shaped seed without pretending to be production-sized&lt;/h2&gt;
&lt;p&gt;I wanted the default dataset to support realistic access patterns without turning a laptop launch into a scale test. The built-in commerce and operations model therefore contains 11 connected tables:&lt;/p&gt;





















































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Table&lt;/th&gt;&lt;th&gt;Diagnostic purpose&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;tenants&lt;/code&gt;&lt;/td&gt;&lt;td&gt;tenancy, plan, and regional skew&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;users&lt;/code&gt;&lt;/td&gt;&lt;td&gt;tenant-scoped identities, status, recency, and JSON profile data&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;products&lt;/code&gt;&lt;/td&gt;&lt;td&gt;catalog state, price, lifecycle, and JSON attributes&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;inventory&lt;/code&gt;&lt;/td&gt;&lt;td&gt;reservations, sold counts, hot rows, and optimistic versions&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;orders&lt;/code&gt;&lt;/td&gt;&lt;td&gt;idempotency, lifecycle, tenant feeds, totals, and versions&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;order_items&lt;/code&gt;&lt;/td&gt;&lt;td&gt;multi-row order/product fan-out&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;payments&lt;/code&gt;&lt;/td&gt;&lt;td&gt;provider uniqueness, attempts, and payment state&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;idempotency_keys&lt;/code&gt;&lt;/td&gt;&lt;td&gt;request and result deduplication&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;job_queue&lt;/code&gt;&lt;/td&gt;&lt;td&gt;priority, availability, lease, retry, and worker ownership&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;outbox_events&lt;/code&gt;&lt;/td&gt;&lt;td&gt;transactional event publication state&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;api_requests&lt;/code&gt;&lt;/td&gt;&lt;td&gt;route, latency, database time, and query-count history&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The template is intentionally index-rich. It contains primary and unique constraints, composite B-tree indexes, partial indexes, covering indexes, GIN indexes for JSON data, and BRIN indexes for time-shaped history. Three functions support timestamp maintenance, guarded inventory reservation, and &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt; job claiming. Triggers update order and payment timestamps.&lt;/p&gt;
&lt;p&gt;The smoke profile plans 195,005 rows: 5 tenants, 1,000 users, 2,000 products, 10,000 orders, and 100,000 request-history rows plus their connected data. In one observed run, PostgreSQL reported 11 tables, 36 index relations, 3 functions, and roughly 46.9 MB of total table relation storage after seeding and &lt;code&gt;ANALYZE&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The local profile is much larger—25 tenants, 100,000 users, 100,000 products, 1,000,000 orders, and 5,000,000 history rows, with more than 10 million planned rows overall. It is defined so the same model can grow, but seeding it requires an explicit confirmation. Routine validation does not silently turn into a disk and memory experiment.&lt;/p&gt;
&lt;p&gt;This distinction became one of the project’s recurring principles: production-shaped is not the same as production-sized. Skew, access paths, queues, outbox records, JSON attributes, history, and competing reads and writes can reveal the behavior I care about at bounded scale. A larger row count is useful only when the question actually depends on it.&lt;/p&gt;
&lt;h2 id=&quot;why-a-real-actor-must-own-a-real-connection&quot;&gt;Why a real actor must own a real connection&lt;/h2&gt;
&lt;p&gt;An HTTP request is stateless. A PostgreSQL transaction is not. Its locks, backend PID, isolation level, temporary settings, and failure state belong to one connection. If the API returned after &lt;code&gt;BEGIN&lt;/code&gt; and later chose any pool connection for the next command, the lab would be simulating a transaction rather than holding one.&lt;/p&gt;
&lt;p&gt;The actor manager therefore owns a dedicated PostgreSQL connection for each actor handle. The handle is opaque to clients, but its control record contains the sandbox, experiment, backend PID, owner, purpose, transaction state, TTL, cancellation state, and cleanup result.&lt;/p&gt;
&lt;p&gt;An actor can begin, run a reviewed operation, commit, roll back, cancel, or close. A blocking database operation runs asynchronously and returns an operation handle, allowing the API request to finish while the PostgreSQL connection remains blocked. The client can inspect the operation and the lock graph separately.&lt;/p&gt;
&lt;p&gt;Observation joins the owned identities with real PostgreSQL state from &lt;code&gt;pg_stat_activity&lt;/code&gt;, &lt;code&gt;pg_locks&lt;/code&gt;, and &lt;code&gt;pg_blocking_pids&lt;/code&gt;. The wait-for edge is not reconstructed from a UI label. The lab can say that actor B is waiting on actor A because PostgreSQL reports the exact backend relationship and both PIDs map to registered actors.&lt;/p&gt;
&lt;p&gt;Actor safety is deliberately stricter than an ordinary connection pool:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the smoke profile permits at most eight actors;&lt;/li&gt;
&lt;li&gt;each actor has a bounded TTL, with an absolute maximum of 300 seconds;&lt;/li&gt;
&lt;li&gt;one actor can have at most one pending operation;&lt;/li&gt;
&lt;li&gt;operation duration, returned rows, and returned bytes are bounded;&lt;/li&gt;
&lt;li&gt;statement, lock, and idle-in-transaction timeouts are set on the session;&lt;/li&gt;
&lt;li&gt;cancellation and process shutdown roll back incomplete transactions;&lt;/li&gt;
&lt;li&gt;a backend can be terminated only when it is registered as lab-owned;&lt;/li&gt;
&lt;li&gt;a failed session reset removes the connection from the pool rather than returning contaminated state.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This work made intentional lock experiments possible. More importantly, it made their cleanup a first-class result rather than a hopeful &lt;code&gt;defer&lt;/code&gt; at the edge of an HTTP handler.&lt;/p&gt;
&lt;h2 id=&quot;a-small-transaction-language-instead-of-arbitrary-orchestration&quot;&gt;A small transaction language instead of arbitrary orchestration&lt;/h2&gt;
&lt;p&gt;Predefined scenarios are useful, but database incidents often depend on a schedule that is slightly different from the catalog. I wanted to compose those schedules without exposing a general SQL workflow engine.&lt;/p&gt;
&lt;p&gt;The transaction composer is a bounded JSON state machine. A composition declares one to eight actors, a TTL, and at most 64 steps. Supported steps are intentionally limited:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;begin
execute_operation
wait_at_barrier
release_barrier
commit
rollback
assert_sqlstate
assert_invariant
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Operations come from the actor allowlist. An invariant is one bounded read-only &lt;code&gt;SELECT&lt;/code&gt; executed under the sandbox role. &lt;code&gt;expectBlocked&lt;/code&gt; does not sleep for an arbitrary interval and assume a lock exists. It polls PostgreSQL until the exact actor is blocked, then stores a lock snapshot filtered to the composition’s actor IDs.&lt;/p&gt;
&lt;p&gt;The schedule can therefore express a row-blocking experiment like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Actor A: BEGIN
Actor A: update inventory row
Actor A: arrive at &quot;lock-held&quot;

Actor B: BEGIN
Actor B: attempt update on the same row asynchronously
Coordinator: prove B is blocked by A in PostgreSQL

Actor A: COMMIT
Actor B: operation completes
Actor B: COMMIT
Coordinator: assert the final inventory version
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every terminal path—success, validation failure, timeout, explicit cancellation, emergency stop, or process shutdown—rolls back and closes every actor opened by the composition. A completed report stores step outcomes and captured evidence as a durable artifact.&lt;/p&gt;
&lt;p&gt;This was a better fit than arbitrary SQL scripts for two reasons. First, the limited step vocabulary makes ownership and cleanup inspectable. Second, the JSON schedule is transport-neutral: the dashboard and MCP server can submit the same composition to the same runtime.&lt;/p&gt;
&lt;h2 id=&quot;turning-the-catalog-into-16-executable-comparisons&quot;&gt;Turning the catalog into 16 executable comparisons&lt;/h2&gt;
&lt;p&gt;The original scenario catalog was a backlog. By the interactive-platform milestone, all 16 entries were executable against real PostgreSQL. Each one has a production story, invariant, broken strategy, fixed strategy, expected outcome, typed evidence, and exact cleanup.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theokimdev.com/resources/blog/post-98-postgres-consistency-lab-from-anomalies-to-observability/scenario-catalog.jpg&quot; alt=&quot;The real Scenarios workspace showing executable transaction, lock, and queue comparisons with their invariants, strategies, evidence, and run controls&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;transaction-anomalies&quot;&gt;Transaction anomalies&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;tx-lost-update&lt;/code&gt; is the foundational case. It compares stale application read-modify-write with atomic update, optimistic versioning, row locking, and serializable retry.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;tx-write-skew&lt;/code&gt; models a constraint that spans multiple rows. Two transactions each observe that another row satisfies the invariant, then update different rows. Row-level repeatable reads can be individually consistent while the cross-row business rule fails. The fixed path uses &lt;code&gt;SERIALIZABLE&lt;/code&gt; with bounded retry and preserves SQLSTATE &lt;code&gt;40001&lt;/code&gt; when PostgreSQL aborts a dangerous serialization graph.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;tx-capacity-phantom&lt;/code&gt; models two requests that both see remaining capacity and both insert. The repair moves the invariant onto a capacity counter protected by a lock instead of trusting two independent check-then-insert decisions.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;tx-idempotency-race&lt;/code&gt; shows why checking for an idempotency key before doing work is not itself idempotency. Two requests can both observe absence. A unique ledger makes the database arbitrate the race and lets one request own the side effect.&lt;/p&gt;
&lt;h3 id=&quot;locks-and-queues&quot;&gt;Locks and queues&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;lock-deadlock-order&lt;/code&gt; makes two transactions update the same rows in opposite order. The broken path produces a real PostgreSQL deadlock victim with SQLSTATE &lt;code&gt;40P01&lt;/code&gt;. The fixed path acquires the rows in stable order, so both transactions commit. The comparison retains commit count, victim count, duration, SQLSTATE, and cleanup state rather than showing only a red or green badge.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theokimdev.com/resources/blog/post-98-postgres-consistency-lab-from-anomalies-to-observability/deadlock-comparison.jpg&quot; alt=&quot;A real deadlock comparison artifact showing one 40P01 victim under opposite lock ordering and two commits under stable ordering&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;lock-hot-row-contention&lt;/code&gt; compares an application read-modify-write counter with an atomic update while recording correctness and latency. It is a reminder that the same row lock can be both the mechanism that preserves correctness and the source of tail-latency pressure.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;queue-double-claim&lt;/code&gt; lets two workers select a ready job before either records ownership. The fixed claim uses &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt; so one lease maps to one worker.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;queue-skip-locked&lt;/code&gt; isolates a different question: should one worker holding the first job prevent another worker from making progress on the next ready job? The comparison preserves blocking evidence and SQLSTATE &lt;code&gt;55P03&lt;/code&gt; where relevant.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;queue-stuck-lease&lt;/code&gt; models the worker that dies after claiming a job. A ready-only query can strand the work forever. The fixed strategy makes expired leases eligible again without stealing a lease that is still live.&lt;/p&gt;
&lt;h3 id=&quot;index-and-plan-behavior&quot;&gt;Index and plan behavior&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;idx-bad-composite-order&lt;/code&gt; compares a composite index whose leading column does not match the tenant-and-time access path with the correct tenant/time ordering.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;idx-partial-comparison&lt;/code&gt; compares a full status index with a predicate-matched index over the active subset. The lab records index size and plan evidence instead of assuming that a smaller partial index is always selected.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;idx-covering-comparison&lt;/code&gt; contrasts a heap-dependent read with an &lt;code&gt;INCLUDE&lt;/code&gt; covering index. The report retains heap fetch and buffer evidence where PostgreSQL exposes it.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;idx-write-amplification&lt;/code&gt; compares minimal indexing with five secondary indexes under writes. This scenario exists because an index review that considers reads only is incomplete. Extra access paths have to be maintained, produce WAL, occupy storage, and alter write latency.&lt;/p&gt;
&lt;p&gt;Index scenarios preserve PostgreSQL &lt;code&gt;FORMAT JSON&lt;/code&gt; plan nodes rather than parsed console text. Cost, actual timing when requested, rows, loops, buffers, index names, sizes, heap fetches, latency, and WAL evidence remain structured so both the report and dashboard can use them.&lt;/p&gt;
&lt;h3 id=&quot;event-consistency&quot;&gt;Event consistency&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;event-missing-after-commit&lt;/code&gt; shows the gap between a successful business commit and a later process-local publish. If the process fails in between, downstream state never learns about a change that the database already accepted. The transactional-outbox path writes business state and publication intent together.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;event-transactional-outbox&lt;/code&gt; makes the boundary even more explicit by comparing split transactions with one transaction that owns both the business row and the outbox row.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;event-idempotent-consumer&lt;/code&gt; replays delivery after partial success. The broken consumer repeats its side effect. The fixed consumer records a durable ledger entry so at-least-once delivery does not become at-least-once business mutation.&lt;/p&gt;
&lt;p&gt;The important acceptance rule across all 16 scenarios is slightly counterintuitive: a broken strategy that unexpectedly passes is a failed experiment. The lab is not rewarding green output. It is proving that the intended failure was actually reproduced, that the repair actually held, and that both paths removed their exact fixtures and released their sessions.&lt;/p&gt;
&lt;h2 id=&quot;workloads-pressure-has-a-shape&quot;&gt;Workloads: pressure has a shape&lt;/h2&gt;
&lt;p&gt;Individual scenarios isolate one invariant. They do not show how several ordinary operations interact under sustained pressure. The workload engine adds that layer without turning the tool into an unrestricted load generator.&lt;/p&gt;
&lt;p&gt;A run has separate warm-up, measurement, and cool-down phases. Workers and connection-pool slots are configured independently so connection acquisition pressure remains visible. Traffic can be rate-limited or run as quickly as the bounded worker and pool envelope permits. A deterministic seed controls each worker’s selection path.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theokimdev.com/resources/blog/post-98-postgres-consistency-lab-from-anomalies-to-observability/workload-studio.jpg&quot; alt=&quot;The real Workload Studio running the flash-sale preset with bounded configuration, throughput, tail latency, latency buckets, and worker-state evidence&quot;&gt;&lt;/p&gt;
&lt;p&gt;The engine includes 12 named presets:&lt;/p&gt;

























































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Preset&lt;/th&gt;&lt;th&gt;Pressure shape&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;steady-traffic&lt;/code&gt;&lt;/td&gt;&lt;td&gt;read-heavy product, order, session, queue, and outbox traffic&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;peak-hour&lt;/code&gt;&lt;/td&gt;&lt;td&gt;recent-order and checkout pressure with tenant skew&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;flash-sale&lt;/code&gt;&lt;/td&gt;&lt;td&gt;write-heavy contention on a deliberately small inventory set&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;hot-tenant&lt;/code&gt;&lt;/td&gt;&lt;td&gt;one tenant dominates feeds and writes&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;hot-product&lt;/code&gt;&lt;/td&gt;&lt;td&gt;one product dominates point reads and version updates&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;queue-burst&lt;/code&gt;&lt;/td&gt;&lt;td&gt;concurrent claims, completion, and outbox publication&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;reporting-vs-oltp&lt;/code&gt;&lt;/td&gt;&lt;td&gt;aggregation and history scans compete with point work&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;index-build-under-load&lt;/code&gt;&lt;/td&gt;&lt;td&gt;normal traffic continues during an index comparison&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;function-regression&lt;/code&gt;&lt;/td&gt;&lt;td&gt;stored inventory functions compete with direct updates&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;connection-saturation&lt;/code&gt;&lt;/td&gt;&lt;td&gt;short work exposes acquisition and pool pressure&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;wal-heavy-writes&lt;/code&gt;&lt;/td&gt;&lt;td&gt;sustained writes produce visible WAL movement&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;large-sort-hash&lt;/code&gt;&lt;/td&gt;&lt;td&gt;analytical queries expose temporary-file behavior&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Custom mixes combine 14 reviewed operations, from product reads and recent orders to inventory reservations, order creation, payment callbacks, idempotency acquisition, queue claim/complete, outbox publication, aggregation, historical scans, large sorts, and one sandbox-scoped custom read query.&lt;/p&gt;
&lt;p&gt;The configuration surface is deliberately detailed because “four workers for ten seconds” says little about the resulting system. It includes workers, pool size, target operations per second, statements per transaction, isolation, seed, uniform or Zipf-like distribution, hot-key and hot-tenant ratios, payload size, think time, retry count and backoff, statement and lock timeouts, and operation, row, WAL, temporary-byte, and latency budgets.&lt;/p&gt;
&lt;p&gt;Workers record actual state transitions:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;rate_wait
pool_wait
connection_acquire
executing
retry_backoff
think_time
finished
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every completed operation records its selected target, latency, transaction outcome, SQLSTATE, and affected rows. One-second samples contain interval operation/error/transaction/row deltas, fixed latency buckets, worker states, connection-pool pressure, and bounded hot-target aggregates. The final report retains cumulative counts and database baseline, experiment, and recovery deltas.&lt;/p&gt;
&lt;p&gt;The distinction between interval and cumulative data matters. PostgreSQL transaction counters and WAL values do not become rates merely because they are drawn as a line. The monitoring service labels current gauges, cumulative counters, derived interval rates, experiment deltas, and recovery deltas separately.&lt;/p&gt;
&lt;p&gt;WAL, temporary bytes, and some database counters are cluster-wide signals. The lab uses them as conservative safety budgets and correlation evidence. It does not claim that every observed byte belongs exclusively to the selected workload. That limitation is more useful on the screen than a false level of attribution.&lt;/p&gt;
&lt;h2 id=&quot;query-lab-index-studio-and-bounded-sql&quot;&gt;Query Lab, Index Studio, and bounded SQL&lt;/h2&gt;
&lt;p&gt;The project needed custom queries, but “local only” was not enough reason to expose arbitrary database control. Query Lab therefore accepts one PostgreSQL statement and executes it through the extended protocol. It does not split a string on semicolons and hope for the best.&lt;/p&gt;
&lt;p&gt;The request specifies read or write mode, statement and lock timeouts, row and byte limits, and whether to capture a plan. Execution assumes the generated non-login sandbox role. Read mode is transaction-read-only. SQL text is bounded, output is capped at 1,000 rows and 1 MiB, and duration is capped at 30 seconds. Results include normalized query information, SQL digest and preview, rows, bytes, SQLSTATE, timing, WAL delta, and a structured JSON plan artifact when requested.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theokimdev.com/resources/blog/post-98-postgres-consistency-lab-from-anomalies-to-observability/query-plan-diff.jpg&quot; alt=&quot;The real Query Lab comparing structured before-and-after PostgreSQL plan nodes after an index change&quot;&gt;&lt;/p&gt;
&lt;p&gt;There is a bounded lexical classifier, but it is only defense in depth. The actual safety boundary is the combination of one extended-protocol statement, transaction mode, sandbox role, PostgreSQL privileges, qualified ownership, timeouts, cancellation, and result limits. Calling a lexical check a full SQL parser would create exactly the kind of false confidence the lab is meant to avoid.&lt;/p&gt;
&lt;p&gt;Index Studio performs typed mutations. Creation receives a table, columns, method, optional &lt;code&gt;INCLUDE&lt;/code&gt; columns, and a reviewed predicate. Identifiers are built from inspected catalog metadata. Drop refuses constraint-owned indexes. PostgreSQL progress remains visible, although index create/drop is still synchronous at the adapter boundary and does not yet have its own cancellable long-operation handle.&lt;/p&gt;
&lt;p&gt;Function Lab is intentionally more observational. It exposes inspected functions, volatility, parallel safety, definitions, and triggers. Function execution can use Query Lab, but arbitrary function creation was not opened merely to make the page look complete.&lt;/p&gt;
&lt;h2 id=&quot;one-runtime-four-clients&quot;&gt;One runtime, four clients&lt;/h2&gt;
&lt;p&gt;By the main platform milestone, &lt;code&gt;internal/platform.Runtime&lt;/code&gt; composed the sandbox, actor, composer, scenario, workload, query, monitoring, control, and safety services once. Four clients used it in different ways.&lt;/p&gt;
&lt;p&gt;The CLI remained the smallest route. &lt;code&gt;list&lt;/code&gt; shows the implemented catalog. A run creates an exact temporary custom smoke sandbox, starts the scenario through the shared service, polls the durable experiment, prints text or JSON, and deletes the sandbox. It does not own a second scenario implementation.&lt;/p&gt;
&lt;p&gt;The HTTP API exposes loopback-only REST commands and snapshots under &lt;code&gt;/api/v1&lt;/code&gt;. Request bodies are bounded, unknown JSON fields are rejected, and domain failures map to stable safe error codes. High-value route families cover sandboxes, scenarios, compositions, actors, locks, workloads, queries, indexes, monitoring, experiments, replay, artifacts, and emergency stop.&lt;/p&gt;
&lt;p&gt;Server-Sent Events carry ordered lifecycle notifications. An event is persisted before it is fanned out. Reconnecting clients can replay at most 500 events using &lt;code&gt;Last-Event-ID&lt;/code&gt;, and a heartbeat keeps the one-way stream observable. SSE is enough because commands already travel through REST; adding WebSocket would have created a second bidirectional command path without solving a present problem.&lt;/p&gt;
&lt;p&gt;The MCP server exposes 32 typed stdio tools over the same runtime. It can discover profiles and capabilities, create and inspect sandboxes, run scenarios and compositions, manage actors, inspect locks, start and cancel workloads, run bounded queries, perform typed index changes, inspect experiments and artifacts, and invoke emergency stop. It does not shell out to the CLI, parse console text, accept credentials, or accept an arbitrary backend PID.&lt;/p&gt;
&lt;p&gt;The React and TypeScript dashboard is the complete operator surface. It calls the HTTP API, subscribes to SSE, and never connects to PostgreSQL. The first usable version already had ten operational pages. The visual-observability milestone added an eleventh: Experiment Workspace.&lt;/p&gt;
&lt;h2 id=&quot;the-dashboard-had-to-explain-time-not-only-display-numbers&quot;&gt;The dashboard had to explain time, not only display numbers&lt;/h2&gt;
&lt;p&gt;The early dashboard could operate the lab, but diagnosis still required mentally joining metric cards, JSON blocks, tables, and several pages. That was not enough. Database behavior is temporal and relational. I needed to see which phase was active, when tail latency moved, which actor waited on which backend, whether a plan changed, and whether the evidence on screen was live, durable, cumulative, sampled, or unavailable.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theokimdev.com/resources/blog/post-98-postgres-consistency-lab-from-anomalies-to-observability/experiment-workspace.jpg&quot; alt=&quot;The real Experiment Workspace aligning flash-sale lifecycle events, measurement and recovery phases, retained samples, and the shared time cursor&quot;&gt;&lt;/p&gt;
&lt;p&gt;The final dashboard contains these workspaces:&lt;/p&gt;





















































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Workspace&lt;/th&gt;&lt;th&gt;What it owns&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Overview&lt;/td&gt;&lt;td&gt;service health, active sandbox, safety limits, and recent experiment state&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Experiment Workspace&lt;/td&gt;&lt;td&gt;synchronized lifecycle, workload, wait, actor, plan, and raw evidence replay&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Sandboxes&lt;/td&gt;&lt;td&gt;create, seed, select, inspect, and delete isolated schemas&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Scenarios&lt;/td&gt;&lt;td&gt;browse and run all 16 comparisons and inspect artifacts&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Transaction &amp;amp; Lock Lab&lt;/td&gt;&lt;td&gt;manage actors, inspect blockers, and run bounded compositions&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Workload Studio&lt;/td&gt;&lt;td&gt;configure pressure and inspect live and durable telemetry&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Query Lab&lt;/td&gt;&lt;td&gt;execute bounded SQL and inspect plan tree, flame, diff, and JSON views&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Index Studio&lt;/td&gt;&lt;td&gt;inspect access paths and run validated create/drop comparisons&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Function Lab&lt;/td&gt;&lt;td&gt;inspect functions, volatility, safety, definitions, and triggers&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Database Monitoring&lt;/td&gt;&lt;td&gt;sessions, locks, queries, tables, indexes, functions, progress, and rates&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Experiment History&lt;/td&gt;&lt;td&gt;durable status, cancellation, artifacts, and replayable evidence&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;img src=&quot;https://theokimdev.com/resources/blog/post-98-postgres-consistency-lab-from-anomalies-to-observability/database-monitoring.jpg&quot; alt=&quot;The real PostgreSQL DBM workspace separating current gauges, cumulative counters, derived rates, and the selected evidence range&quot;&gt;&lt;/p&gt;
&lt;p&gt;Standard time, area, bar, and line charts use Recharts. PostgreSQL-specific relationships use bounded native SVG and HTML: wait-for graphs, actor lanes, schema topology, plan trees, plan flame views, histogram heatmaps, and worker-state matrices. I did not add a general graph library for data whose maximum size and semantics were already known.&lt;/p&gt;
&lt;p&gt;The backend gained a read-only visualization service that composes one experiment with its chronological events, artifacts, plans, workload report and samples, phase ranges, captured lock snapshots, and optional live monitoring. Completed experiments never receive a current monitoring snapshot disguised as historical data. Sandbox topology is a separate bounded catalog DTO containing tables, columns, foreign keys, indexes, functions, triggers, and storage ownership.&lt;/p&gt;
&lt;p&gt;The Experiment Workspace uses one inspection cursor across the timeline, charts, workload samples, phases, and the nearest real captured lock snapshot. This sounds like a presentation detail, but it is an evidence contract. If the operator selects one moment, every chronological panel must describe the same interval.&lt;/p&gt;
&lt;h2 id=&quot;a-null-array-exposed-a-deeper-time-contract-problem&quot;&gt;A null array exposed a deeper time-contract problem&lt;/h2&gt;
&lt;p&gt;One of the most useful bugs appeared between workload creation and the first one-second sample. The Go manager initialized &lt;code&gt;Samples&lt;/code&gt; as an empty slice. A clone operation copied the zero-length slice onto &lt;code&gt;nil&lt;/code&gt;, so JSON encoded a valid live run as:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;samples&quot;: null
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The TypeScript contract declared an array, and the workload visualization immediately called array operations. The whole page fell into its error boundary during a legitimate empty state.&lt;/p&gt;
&lt;p&gt;The direct fix was small: clone into an allocated zero-length slice so the API emits &lt;code&gt;&quot;samples&quot;:[]&lt;/code&gt;. A regression test checks both non-nil identity and the exact JSON contract. The frontend also normalizes older or transitional &lt;code&gt;null&lt;/code&gt; collections at the API boundary, and evidence views remain null-safe.&lt;/p&gt;
&lt;p&gt;I could have stopped there. Instead, the failure made another inconsistency obvious. Overview, Monitoring, and Experiment Workspace had one notion of a selected window; Workload Studio and History had none. A line chart could show one minute while its histogram, worker matrix, phase band, or event list still represented the full run.&lt;/p&gt;
&lt;p&gt;The fix introduced one shared &lt;code&gt;TimeRange&lt;/code&gt; model:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;relative: recent 1m, 5m, or 15m
all:      every retained timestamp
custom:   validated absolute start and end
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Relative ranges anchor to the latest retained evidence, not the wall clock. Otherwise opening yesterday’s replay and choosing “5m” would produce an empty chart. Each page merges the bounds of its available evidence, resolves one inclusive interval, filters before downsampling, clips overlapping phase bands, and sends the same interval to charts, events, locks, histograms, worker states, and hot-target views.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theokimdev.com/resources/blog/post-98-postgres-consistency-lab-from-anomalies-to-observability/responsive-experiment-workspace.jpg&quot; alt=&quot;The real Experiment Workspace at a 430-pixel viewport, preserving the active sandbox, emergency control, experiment selector, time range, and lifecycle evidence without horizontal overflow&quot;&gt;&lt;/p&gt;
&lt;p&gt;The selected cursor clears when the range changes. Invalid or out-of-retention custom ranges remain unapplied and show an inline error without destroying the operator’s draft. Switching sandboxes clears browser-only monitoring and event history so evidence cannot cross an ownership boundary. Monitoring history is capped at 360 snapshots—about 18 minutes at the nominal three-second interval—while rendered charts are downsampled to bounded point counts.&lt;/p&gt;
&lt;p&gt;The rule underneath all of this is simple: a measured zero and missing evidence are different facts.&lt;/p&gt;
&lt;p&gt;If no transaction is waiting in a captured snapshot, zero is meaningful. If the old experiment never stored a lock snapshot, the wait graph is unavailable. If a workload predates latency buckets, the heatmap is unavailable. If the browser was paused, resume does not invent the missed samples. The dashboard says so directly.&lt;/p&gt;
&lt;h2 id=&quot;emergency-stop-is-an-ownership-test&quot;&gt;Emergency stop is an ownership test&lt;/h2&gt;
&lt;p&gt;A lab that intentionally creates locks, long transactions, DDL, connection pressure, and expensive queries needs a credible stop path. A global red button that calls &lt;code&gt;pg_terminate_backend&lt;/code&gt; on whatever looks busy would be worse than no button at all.&lt;/p&gt;
&lt;p&gt;Every HTTP and MCP mutation takes a read side of a shared runtime lease. Emergency stop takes the write side, marks the runtime as stopping, waits for in-flight mutations to leave their critical section, rejects new mutations, cancels active scenarios, compositions, workloads, and queries, rolls back registered actors, and then verifies cleanup.&lt;/p&gt;
&lt;p&gt;No API accepts a caller-provided PID for termination. The runtime can act only on backend identities already registered to lab-owned actors or operations. Sandbox deletion is refused while a managed owner remains active. Scenario cleanup drops only the exact objects it tracked. Composer cleanup owns only its actor IDs. The emergency path returns evidence of what it attempted and what remained.&lt;/p&gt;
&lt;p&gt;The safety envelope is explicit rather than implied:&lt;/p&gt;

















































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Boundary&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Limit&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;shared Go pool&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;80 connections, 2 minimum, 5-minute idle lifetime&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;smoke profile&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;4 workers, 8 actors, 60 seconds&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;local profile&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;32 workers, 64 actors, 900 seconds; seeded data requires confirmation&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;actor&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;300-second absolute TTL and one pending operation&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;actor operation&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;60 seconds, 100 rows, 64 KiB result&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;composition&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;8 actors, 64 steps, 120 seconds plus profile cap&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;workload&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;10,000 ops/s, batch 100, 10 retries, 5,000,000 operations plus budgets&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;workload evidence&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;600 samples and 50,000 retained latency observations&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Query Lab&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;30 seconds, 1,000 rows, 1 MiB result, 32 KiB SQL&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;events and artifacts&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;500-event replay, 64 KiB event, 2 MiB artifact&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The numbers are not presented as ideal limits for every machine. They are reviewable defaults that prevent an innocent dashboard input from becoming an unbounded operation.&lt;/p&gt;
&lt;h2 id=&quot;validation-had-to-use-postgresql-not-a-mock-of-postgresql&quot;&gt;Validation had to use PostgreSQL, not a mock of PostgreSQL&lt;/h2&gt;
&lt;p&gt;Unit tests cover validation, catalogs, adapters, serialization, transforms, and frontend components. They cannot prove a deadlock, a serialization failure, a blocker edge, &lt;code&gt;SKIP LOCKED&lt;/code&gt; ownership, a query plan, or session cleanup.&lt;/p&gt;
&lt;p&gt;The integration suite therefore runs against the real Docker PostgreSQL 16 instance. It covers actor blocking and TTL expiry, composer barriers, every scenario report, workload phases and safety budgets, Query and Index boundaries, audit and ordered events, REST and MCP parity, cancellation, emergency stop, CLI temporary-sandbox cleanup, and profile seeding. Selected concurrency packages also run under Go’s race detector using a cgo-capable container.&lt;/p&gt;
&lt;p&gt;The dashboard has separate type checking, unit tests, and a production build. The final time-range delivery passed five frontend test files with 15 tests. The production build kept chart code in lazy chunks so Recharts did not enter the initial application bundle.&lt;/p&gt;
&lt;p&gt;The most important tests assert cleanup as part of success. The all-catalog integration run executed all 16 entries against one exact custom smoke sandbox, confirmed every broken and fixed expectation, matched the number of comparison artifacts to the catalog, and then verified that no tracked scenario tables or experiment sessions remained.&lt;/p&gt;
&lt;p&gt;The main platform acceptance run observed, among other results:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;lost update produced &lt;code&gt;110/120&lt;/code&gt; for the broken path and &lt;code&gt;120/120&lt;/code&gt; for all four repairs;&lt;/li&gt;
&lt;li&gt;serializable retry exposed a real &lt;code&gt;40001&lt;/code&gt; conflict;&lt;/li&gt;
&lt;li&gt;the deadlock comparison produced one &lt;code&gt;40P01&lt;/code&gt; victim under opposite ordering and two commits under stable ordering;&lt;/li&gt;
&lt;li&gt;queue blocking exposed &lt;code&gt;55P03&lt;/code&gt; evidence;&lt;/li&gt;
&lt;li&gt;an event-consumer comparison produced two side effects for two broken deliveries, versus one side effect and one ledger row for the fixed path;&lt;/li&gt;
&lt;li&gt;a row-lock composition completed ten of ten steps, preserved its invariant, and left zero actors;&lt;/li&gt;
&lt;li&gt;one query/index run changed a sequential scan plus sort at 1.461 ms and 144 shared hits to an index scan at 0.155 ms, 8 hits, and 2 reads in that local state;&lt;/li&gt;
&lt;li&gt;emergency stop ended a custom serializable workload in about 517 ms and left zero &lt;code&gt;pcl/%&lt;/code&gt; backends.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The visual-observability acceptance run later recorded a flash-sale workload with 8,646 attempts, 8,643 successes, 3 timeouts, 1 retry, p95 latency of 6.237 ms, and 1,886,284 observed WAL bytes. Its query-plan comparison changed an estimated root cost from 4,907 to 8.34. The responsive dashboard fit a 430-pixel viewport without document-level horizontal overflow.&lt;/p&gt;
&lt;p&gt;These are useful results because they prove the evidence path in one controlled environment. They are not PostgreSQL capacity claims. Warm local caches, Docker Desktop, one PostgreSQL version, a smoke dataset, and short measurement windows cannot predict production throughput or latency. The article would be less accurate if it presented the most impressive number without its boundary.&lt;/p&gt;
&lt;h2 id=&quot;the-project-failed-in-useful-ways-while-it-was-being-built&quot;&gt;The project failed in useful ways while it was being built&lt;/h2&gt;
&lt;p&gt;The clean final architecture hides several failures that influenced it.&lt;/p&gt;
&lt;p&gt;The first was architectural. The Kotlin scaffold was replaced before it accumulated real implementation. Keeping it only because it was the first choice would have made the later dashboard and MCP work harder to compose. The lesson was not “always use Go”; it was that a scaffold should be cheap enough to discard when the control-plane requirements become clearer.&lt;/p&gt;
&lt;p&gt;The second appeared in concurrent integration tests. A workload test used a 64 MiB cluster-WAL ceiling while index scenarios were running in parallel. The workload could stop because another valid lab experiment moved the same cluster-wide counter. That was not merely test flakiness. It exposed the difference between a conservative safety signal and exact per-run attribution. The tests were split so one path uses a deliberately high shared-counter ceiling and another dedicated run proves the one-byte WAL stop.&lt;/p&gt;
&lt;p&gt;The same concurrent suite found composer artifacts containing unrelated global actor edges. The fix filtered lock snapshots to the exact composition actor IDs. Again, the failure improved the product contract: evidence that is correct globally can still be wrong for a scoped experiment.&lt;/p&gt;
&lt;p&gt;The dashboard proxy intermittently returned &lt;code&gt;502&lt;/code&gt; through a Docker Desktop host-hairpin route even though direct API and container-to-container health checks passed. The application services were placed on an idempotent named Docker network, and Vite now proxies directly to the API container by DNS while published ports remain loopback-only.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;samples: null&lt;/code&gt; crash demonstrated that a typed frontend cannot repair a dishonest transport contract by declaration alone. Fixing both producer and consumer was necessary because durable or older payloads can outlive the backend bug.&lt;/p&gt;
&lt;p&gt;The time controls exposed a subtler interaction problem: controlled native date inputs could lose an in-progress edit during a periodic parent render. The final control lets the browser own the draft until Apply, then validates and commits it to shared range state.&lt;/p&gt;
&lt;p&gt;Each of these failures points to the same engineering rule. Observability is not the act of drawing data. It is preserving ownership, time, scope, and absence all the way from the database to the screen.&lt;/p&gt;
&lt;h2 id=&quot;what-i-would-not-claim-after-building-it&quot;&gt;What I would not claim after building it&lt;/h2&gt;
&lt;p&gt;PostgreSQL Consistency Lab is intentionally broad, but it is not a production database administrator.&lt;/p&gt;
&lt;p&gt;It does not connect to a real production database. It has no remote authentication model, no multi-user tenancy, and no unrestricted database, role, extension, or server control. MCP is local stdio. The API and dashboard bind to loopback. Open transactions do not survive control-plane process restart.&lt;/p&gt;
&lt;p&gt;Monitoring history outside an experiment remains browser-memory-only. If the browser was closed, a 15-minute selection cannot recover samples that were never collected. Durable experiment replay is the historical source of truth.&lt;/p&gt;
&lt;p&gt;Lock history exists only where a composition or capture path persisted a snapshot. A completed scenario report cannot reconstruct every intermediate lock edge after the fact. Actor lanes also cannot recreate metadata that was never stored in older events.&lt;/p&gt;
&lt;p&gt;Index creation and deletion remain synchronous. A large index build can be observed through PostgreSQL progress, but it does not yet have a dedicated cancellable operation handle. Plan-node matching intentionally leaves a replaced scan strategy unmatched rather than inventing a false correspondence.&lt;/p&gt;
&lt;p&gt;WAL, temporary-file, and database counters are useful cluster-wide evidence, not perfect workload attribution. Query Lab’s lexical classifier is not a complete SQL parser. Local timings are comparative evidence, not capacity promises. The guarded local profile is defined, but the default validation story remains the smoke profile.&lt;/p&gt;
&lt;p&gt;Those limits are not an appendix added to make the project sound responsible. They are part of its design. The lab became useful precisely because it stopped pretending that every visible number had the same provenance.&lt;/p&gt;
&lt;h2 id=&quot;what-the-project-became&quot;&gt;What the project became&lt;/h2&gt;
&lt;p&gt;The repository began with a list of database failures that I wanted to reproduce. It ended with one shared Go runtime, a marked PostgreSQL instance, generated sandbox schemas and roles, durable control records, managed connection-bound actors, a bounded transaction composer, 16 deterministic scenario comparisons, 12 workload presets, 14 reviewed operations, Query and Index Labs, PostgreSQL monitoring, replayable events, 32 MCP tools, and an eleven-workspace React dashboard.&lt;/p&gt;
&lt;p&gt;The size of that list is not the part I value most.&lt;/p&gt;
&lt;p&gt;The important part is that the system connects a business invariant to the database event that violated it. It can show that two successful commits produced the wrong balance. It can stop while one actor is genuinely blocked by another. It can retain the SQLSTATE that made a retry necessary. It can compare a query plan without erasing the write cost of the index. It can display zero when zero was measured and unavailable when nothing was captured. It can generate dangerous conditions and still refuse to touch an unregistered backend.&lt;/p&gt;
&lt;p&gt;That is why the dashboard did not become decoration around the scenario runner. It became the place where all of those evidence boundaries meet.&lt;/p&gt;
&lt;p&gt;What I originally wanted was a better way to study transaction anomalies. What I actually built was a local environment for asking a harder question: when a database system says that something succeeded, what evidence is required before I believe that the business invariant survived?&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Repository: &lt;a href=&quot;https://github.com/ysbc1247/postgres-consistency-lab&quot;&gt;PostgreSQL Consistency Lab&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content:encoded><language>en-US</language><category>Database</category><category>PostgreSQL</category><category>Transactions</category><category>Concurrency</category><category>Locks</category><category>Observability</category><category>Go</category><category>React</category><category>MCP</category><author>ystc1247@gmail.com</author></item><item><title>[Quant Trading System 5/5] Confirming no_trade with Research Gates and Multi-Horizon Diagnostics</title><link>https://theokimdev.com/en/blog/post-92-quant-trading-system-09-research-gate-dashboard/</link><guid isPermaLink="true">https://theokimdev.com/en/blog/post-92-quant-trading-system-09-research-gate-dashboard/</guid><description>How a research dashboard recorded each candidate’s first failing gate, then a frozen multi-horizon diagnostic confirmed no_trade before test access or model training.</description><pubDate>Sat, 25 Jul 2026 02:40:00 GMT</pubDate><content:encoded>&lt;h2 id=&quot;the-more-candidates-exist-the-more-rejection-rules-matter&quot;&gt;The More Candidates Exist, The More Rejection Rules Matter&lt;/h2&gt;
&lt;p&gt;After the early baselines and sensitivity passes, the candidate universe grew. There were time-of-day reversals, session-extreme reversals, MACD crosses, ES/MES spreads, volatility compression rules, and roll-week relative-value ideas. Some were validation-positive. Some beat random baselines in null tests. Some retained validation net even after execution stress.&lt;/p&gt;
&lt;p&gt;At this point, the system needed clearer rejection rules more than more candidates. As the candidate universe grows, the human naturally wants to look at the most attractive row: highest validation net, best profit factor, or most plausible name. That is why the quant trading system needed a research gate dashboard.&lt;/p&gt;
&lt;p&gt;The goal was simple:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;put every scored candidate in one table
record the first gate that kills each candidate
keep the test split locked
include pathwise execution stress
keep no_trade until every gate passes
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The dashboard was less a visualization and more a decision artifact. Its job was not to say “this candidate looks good.” Its job was to say “this candidate died here.”&lt;/p&gt;
&lt;h2 id=&quot;fresh-hypotheses-and-null-tests&quot;&gt;Fresh Hypotheses And Null Tests&lt;/h2&gt;
&lt;p&gt;Before the research gate dashboard, I added fresh OHLCV hypothesis families. Instead of endlessly tuning old parameter-sweep rows, I created more economically motivated families: time-of-day open reversal, volatility compression breakout, ES lead-lag, liquidity regime trend, MES/ES relative value, and roll-week transition.&lt;/p&gt;
&lt;p&gt;The best validation-looking row was &lt;code&gt;time_of_day_open_reversal_long&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;best fresh validation hypothesis: time_of_day_open_reversal_long
validation net: 114.620 points across 60 trades
validation average net: 1.910333 points
validation top-5 day concentration: 0.385194
same hypothesis train net: -418.882 points
inner train-fold positive share: 0.0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On validation alone, this looks decent. Its top-five day concentration is much lower than the previous exit-label winner. Some validation rows also beat random baselines in null tests. But train net and inner train-fold stability remained the blockers.&lt;/p&gt;
&lt;p&gt;The important decision was not “it beats a null, therefore it passes.” Null tests are necessary, not sufficient. If train is negative and inner train folds are unstable, beating random timestamps on validation does not make a candidate.&lt;/p&gt;
&lt;h2 id=&quot;market-advantage-scorecard&quot;&gt;Market Advantage Scorecard&lt;/h2&gt;
&lt;p&gt;The market advantage scorecard then collected validation net, random-baseline percentile, execution feasibility, train failure, and concentration into one ranking surface.&lt;/p&gt;
&lt;p&gt;The top rows looked like this:&lt;/p&gt;



































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Candidate&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Score&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Validation net&lt;/th&gt;&lt;th&gt;Main failure&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;time_of_day_open_reversal_long&lt;/code&gt;&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;11.0&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;114.620&lt;/td&gt;&lt;td&gt;train net, inner folds&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;session_extreme_reversal_short...score_ge_50p0&lt;/code&gt;&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;11.0&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;67.576&lt;/td&gt;&lt;td&gt;train net, inner folds&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;time_of_day_open_reversal_short&lt;/code&gt;&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;11.0&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;43.142&lt;/td&gt;&lt;td&gt;train net, inner folds&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;horizon_30m_mes_vs_es_spread_gt_0p0001&lt;/code&gt;&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;10.0&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;175.124&lt;/td&gt;&lt;td&gt;train net, folds, concentration&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The interesting thing is that a high score is still not a pass. The advantage score is useful for ranking candidates worth inspecting. It does not decide whether a candidate is tradeable. Candidate promotion must depend on gate survival, not score.&lt;/p&gt;
&lt;p&gt;The final scorecard selected policy was again &lt;code&gt;no_trade&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id=&quot;failure-attribution&quot;&gt;Failure Attribution&lt;/h2&gt;
&lt;p&gt;The top candidates were then decomposed with failure attribution.&lt;/p&gt;

























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Candidate&lt;/th&gt;&lt;th&gt;Validation-positive evidence&lt;/th&gt;&lt;th&gt;Failure evidence&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;time_of_day_open_reversal_long&lt;/code&gt;&lt;/td&gt;&lt;td&gt;validation net &lt;code&gt;114.620&lt;/code&gt;&lt;/td&gt;&lt;td&gt;train net &lt;code&gt;-418.882&lt;/code&gt;, worst train era &lt;code&gt;train_middle&lt;/code&gt; at &lt;code&gt;-185.044&lt;/code&gt;, fold share &lt;code&gt;0.0&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;session_extreme_reversal_short...&lt;/code&gt;&lt;/td&gt;&lt;td&gt;validation net &lt;code&gt;67.576&lt;/code&gt;&lt;/td&gt;&lt;td&gt;train net &lt;code&gt;-151.662&lt;/code&gt;, worst train era &lt;code&gt;train_late&lt;/code&gt; at &lt;code&gt;-85.386&lt;/code&gt;, fold share &lt;code&gt;0.333333&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;horizon_30m_mes_vs_es_spread_gt_0p0001&lt;/code&gt;&lt;/td&gt;&lt;td&gt;validation net &lt;code&gt;175.124&lt;/code&gt;&lt;/td&gt;&lt;td&gt;train net &lt;code&gt;-2,894.096&lt;/code&gt;, worst train era &lt;code&gt;train_middle&lt;/code&gt; at &lt;code&gt;-1,300.972&lt;/code&gt;, top-five validation day concentration &lt;code&gt;0.555726&lt;/code&gt;, fold share &lt;code&gt;0.0&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;This attribution was not meant to make candidates look better. It was meant to make their failure understandable. “Failed train net positive” is useful, but knowing the train era, session segment, and concentration pattern gives the next research question a sharper shape.&lt;/p&gt;
&lt;h2 id=&quot;the-regime-map-was-not-an-entry-generator&quot;&gt;The Regime Map Was Not An Entry Generator&lt;/h2&gt;
&lt;p&gt;I also built an OHLCV regime map. It split market conditions by volatility, volume, session phase, roll phase, trend/chop state, ES trend direction, and ES/MES divergence.&lt;/p&gt;
&lt;p&gt;The rule was that the regime map was not an entry generator. If I inspect validation and then pick favorable cells, I have created another overfit surface. So I treated the regime map only as an avoid-regime design surface.&lt;/p&gt;
&lt;p&gt;The report conclusion followed that rule:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;The regime map did not create entries.
The strongest validation single-factor rows still had negative average directional net after fixed OHLCV costs.
Any future regime filter must be frozen before validation and retested as a new deterministic candidate.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I think sentences like this create project quality. Some analyses exist not to create a result, but to constrain the next analysis so it does not fool itself.&lt;/p&gt;
&lt;h2 id=&quot;pathwise-execution-stress&quot;&gt;Pathwise Execution Stress&lt;/h2&gt;
&lt;p&gt;The MBP execution diagnostics were then attached to OHLCV candidate trade paths. Active-symbol five-second p90 adverse penalties were about &lt;code&gt;0.625&lt;/code&gt; points per side, while 30-second p90 adverse penalties were about &lt;code&gt;1.375&lt;/code&gt; to &lt;code&gt;1.625&lt;/code&gt; points. All-MES calibration was harsher.&lt;/p&gt;
&lt;p&gt;Once this stress is applied, validation-positive candidates weaken quickly.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;fixed OHLCV cost assumptions are not enough for candidate promotion
active-symbol 1000ms stress is now a gate
all-MES stress remains a stricter optional gate
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;At this point, the candidate dashboard was no longer just a train/validation PnL table. It became a control panel combining cost, nulls, concentration, fold stability, and execution stress.&lt;/p&gt;
&lt;h2 id=&quot;gate-dashboard-results&quot;&gt;Gate Dashboard Results&lt;/h2&gt;
&lt;p&gt;The final research gate dashboard produced &lt;code&gt;19&lt;/code&gt; candidate rows and &lt;code&gt;171&lt;/code&gt; gate rows.&lt;/p&gt;





















&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Metric&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Dashboard rows&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;19&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Gate matrix rows&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;171&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Passing candidates&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The main failed-gate counts were:&lt;/p&gt;









































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Gate&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Failed candidates&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;inner_train_folds_stable&lt;/code&gt;&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;19&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;train_net_positive&lt;/code&gt;&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;18&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;pathwise_active_1000ms_positive&lt;/code&gt;&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;15&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;beats_one_random_p95&lt;/code&gt;&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;14&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;cost_stress_net_positive&lt;/code&gt;&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;14&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;validation_net_positive&lt;/code&gt;&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;14&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;random_baseline_percentile_gte_0p50&lt;/code&gt;&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;12&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;day_concentration_lte_0p50&lt;/code&gt;&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;6&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Even the first row of the dashboard did not pass.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;time_of_day_open_reversal_long
advantage_score: 11
train_total_net_points: -418.882
validation_total_net_points: 114.620
pathwise_active_1000ms_net_points: 77.120
primary_kill_reason: train_net_positive
passes_advantage_gate: false
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This row captures the message of the whole phase. Validation is positive, and validation net remains after pathwise stress. But train fails. Therefore it is rejected.&lt;/p&gt;
&lt;h2 id=&quot;what-the-dashboard-is-for&quot;&gt;What The Dashboard Is For&lt;/h2&gt;
&lt;p&gt;Building this dashboard made the research loop calmer. When a candidate looks good, I do not have to manually restart suspicion from zero. The gate matrix tells me where it dies. And if it does not pass, it stays behind &lt;code&gt;no_trade&lt;/code&gt; no matter how interesting it looks.&lt;/p&gt;
&lt;p&gt;In trading research, a dashboard should be decision discipline, not decoration. It matters less which candidates survived, and more why candidates were rejected. In this phase, none survived. But the remaining standard became much stronger.&lt;/p&gt;
&lt;p&gt;The current policy remained &lt;code&gt;no_trade&lt;/code&gt;. That result is not a failure. It is evidence that the gates did their job.&lt;/p&gt;
&lt;p&gt;The dashboard established a consistent rejection discipline, but one final question remained. I needed to know whether the conclusion depended too heavily on the five-minute horizon, and whether the same frozen entries and gates would survive a change in the time axis.&lt;/p&gt;
&lt;h2 id=&quot;why-i-revisited-the-horizon&quot;&gt;Why I Revisited The Horizon&lt;/h2&gt;
&lt;p&gt;By this point, the system had become fairly strict. Raw tick data had been turned into a silver dataset. One-minute OHLCV and MBP-based execution diagnostics were in place. Several hypothesis families were evaluated on the same chronological split. The result was no longer judged only by whether validation net was positive. A candidate had to survive the no-trade baseline, sign flip, random timestamp tests, same time-of-day random day tests, day shuffling, and execution stress before it could move forward.&lt;/p&gt;
&lt;p&gt;A candidate could be weak at five minutes but structurally meaningful at thirty or sixty minutes. The opposite could also be true. A sixty-minute candidate could look good simply because holding longer allowed a lucky validation window to dominate the result. So in the last stage, I did not create new candidates. I kept the existing candidate entry timestamps fixed and changed only the label horizon.&lt;/p&gt;
&lt;p&gt;The important part was not treating this as a new search surface. If I looked across horizons and picked whichever validation result looked best, the system would become another overfitting machine. The question I wanted to ask was not “which horizon made money?” It was “does the same candidate survive when the time axis is moved slightly?”&lt;/p&gt;
&lt;h2 id=&quot;horizon-was-not-a-search-surface&quot;&gt;Horizon Was Not A Search Surface&lt;/h2&gt;
&lt;p&gt;The diagnostic scope was fixed on purpose. The horizon grid was &lt;code&gt;1m, 3m, 5m, 10m, 15m, 30m, 60m&lt;/code&gt;, and the candidate universe was every row in &lt;code&gt;market_advantage_scorecard_v1&lt;/code&gt;. The entry policy was also fixed. The same candidate entry timestamps were relabeled. Entries were not reselected, and thresholds were not retuned by horizon.&lt;/p&gt;
&lt;p&gt;Another important rule was train-first evaluation. If I found a good validation horizon first and checked train afterward, validation would already have been used as a search surface. So the order was reversed. Train net, inner train folds, and train adjacent-horizon stability were checked before validation gates. The test split remained locked and excluded.&lt;/p&gt;
&lt;p&gt;The code makes that intention explicit.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;HORIZON_GRID_MINUTES = (1, 3, 5, 10, 15, 30, 60)

GATE_ORDER = (
    &quot;train_net_positive&quot;,
    &quot;inner_train_folds_stable&quot;,
    &quot;adjacent_train_horizon_positive&quot;,
    &quot;validation_net_positive&quot;,
    &quot;adjacent_validation_horizon_positive&quot;,
    &quot;validation_day_concentration_lte_0p50&quot;,
    &quot;validation_beats_no_trade&quot;,
    &quot;validation_beats_all_random_medians&quot;,
    &quot;validation_beats_one_random_p95&quot;,
    &quot;validation_beats_sign_flip_and_no_trade&quot;,
    &quot;mbp_active_1000ms_cost_stress_positive&quot;,
    &quot;execution_feasibility_gte_0p50&quot;,
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This gate order is more than a code constant. It is a research posture. It says that the prettiest validation number does not get first priority. A candidate that loses money on train, fails inner folds, or cannot survive nearby horizons does not move into model training just because it looks attractive in a validation table.&lt;/p&gt;
&lt;h2 id=&quot;first-the-five-minute-label-was-locked-again&quot;&gt;First, The Five-Minute Label Was Locked Again&lt;/h2&gt;
&lt;p&gt;Before running the multi-horizon diagnostic, I recomputed the existing five-minute label. If that check had shown a mismatch, every later horizon comparison would have been unstable. The five-minute label in the existing training matrix had to match the newly computed five-minute label exactly so that the rest of the horizon grid could be interpreted on the same basis.&lt;/p&gt;

































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Metric&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Matrix rows&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;281,145&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Compared rows&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;281,145&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Missing 5m label rows&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Mismatches&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Max gross abs diff&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Max net abs diff&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;I liked this table not because the numbers were impressive, but because nothing happened. All 281,145 rows were compared, and the five-minute gross/net label difference was zero. That meant the multi-horizon result was not an artifact of a silently changed label path.&lt;/p&gt;
&lt;p&gt;I keep adding checks like this for a simple reason. In quant research, the most dangerous failure is not a model failing openly. It is a data generation path changing quietly while the researcher mistakes the movement for performance. The label recompute check was a small guard against that mistake.&lt;/p&gt;
&lt;h2 id=&quot;the-best-looking-validation-rows-still-failed&quot;&gt;The Best-Looking Validation Rows Still Failed&lt;/h2&gt;
&lt;p&gt;The most tempting part of the report was the set of top validation-looking rows. A few candidates were clearly positive on validation. At the sixty-minute horizon especially, some rows looked good enough to make training feel plausible.&lt;/p&gt;













































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;candidate_id&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;horizon&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;train net&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;validation net&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;active 1000ms stressed net&lt;/th&gt;&lt;th&gt;primary kill&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;horizon_30m_mes_vs_es_spread_gt_0p0001&lt;/code&gt;&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;60m&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;-1980.15&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;402.11&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;312.735&lt;/td&gt;&lt;td&gt;&lt;code&gt;train_net_positive&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;vol_compression_breakout_long&lt;/code&gt;&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;60m&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;-426.906&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;330.782&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;101.657&lt;/td&gt;&lt;td&gt;&lt;code&gt;train_net_positive&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;vol_compression_breakout_short&lt;/code&gt;&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;60m&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;-134.448&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;180.368&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;84.493&lt;/td&gt;&lt;td&gt;&lt;code&gt;train_net_positive&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;time_of_day_open_reversal_long&lt;/code&gt;&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;30m&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;-206.882&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;155.37&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;57.87&lt;/td&gt;&lt;td&gt;&lt;code&gt;train_net_positive&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;If this table were read only from the validation side, it would be attractive. The first candidate had 402.11 validation net points, and still had 312.735 points after active MES 1000ms stress. &lt;code&gt;vol_compression_breakout_long&lt;/code&gt; also showed 330.782 validation net points.&lt;/p&gt;
&lt;p&gt;But all of these candidates died for the same reason. Their train net was negative.&lt;/p&gt;
&lt;p&gt;This was the point where I felt the most temptation. When validation is good, some null tests are beaten, and execution stress still leaves a positive number, it is very easy to want to train a model. But changing the rule at that moment would mean letting the result rewrite the research process. What I wanted from this project was not a way to force one candidate through. I wanted a process where the pass conditions were defined in advance, and only candidates that survived those conditions could move forward.&lt;/p&gt;
&lt;h2 id=&quot;beating-a-null-test-does-not-override-the-train-gate&quot;&gt;Beating A Null Test Does Not Override The Train Gate&lt;/h2&gt;
&lt;p&gt;One interesting detail was that some candidates did beat validation null tests quite well. For example, the sixty-minute version of &lt;code&gt;horizon_30m_mes_vs_es_spread_gt_0p0001&lt;/code&gt; produced 402.11 validation net points and beat no-trade, sign-flipped direction, same trade count random timestamps, circular shifts, same time-of-day random days, and shuffled day labels. Against random timestamps, its actual percentile was 0.995.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;candidate_id&quot;: &quot;horizon_30m_mes_vs_es_spread_gt_0p0001&quot;,
  &quot;horizon_minutes&quot;: 60,
  &quot;actual_total_net_points&quot;: 402.11,
  &quot;same_trade_count_random_timestamps_p95&quot;: 215.547,
  &quot;actual_percentile_vs_random_timestamps&quot;: 0.995
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Even so, the candidate did not pass. The reason is simple. A null test checks whether the validation result looks accidental under that validation setting. It does not give a candidate permission to ignore a negative train result.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;vol_compression_breakout_long&lt;/code&gt; showed a similar pattern. Its sixty-minute validation actual net was 330.782 points, but the same trade count random timestamps p95 was 345.307 points, and the actual percentile was 0.945. It also failed the p95 threshold under same time-of-day random days. Even a good-looking candidate did not beat every null test.&lt;/p&gt;
&lt;p&gt;This made the role of multi-horizon diagnostics clearer. Extending the horizon should not be a way to rescue a candidate. It should be a pressure test that reveals whether the candidate shines only on one convenient time scale.&lt;/p&gt;
&lt;h2 id=&quot;execution-realism-reduced-the-edge-further&quot;&gt;Execution Realism Reduced The Edge Further&lt;/h2&gt;
&lt;p&gt;The last layer was MBP-based execution stress. This is not a perfect simulation of fills, but adding active MES symbol assumptions, 1000ms latency, and adverse movement penalties shows how much validation net survives once execution becomes less idealized.&lt;/p&gt;









































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;candidate_id&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;horizon&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;trades&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;base net&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;stressed net&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;avg adverse penalty&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;feasibility&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;horizon_30m_mes_vs_es_spread_gt_0p0001&lt;/code&gt;&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;60m&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;55&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;402.11&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;312.735&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;1.625&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0.782653&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;vol_compression_breakout_long&lt;/code&gt;&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;60m&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;141&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;330.782&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;101.657&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;1.625&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0.782653&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;time_of_day_open_reversal_long&lt;/code&gt;&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;5m&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;60&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;114.62&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;77.12&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0.625&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0.912182&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Some candidates remained positive even after this stress. But positivity alone was not the point. &lt;code&gt;vol_compression_breakout_long&lt;/code&gt; fell from 330.782 base net to 101.657 stressed net. It was still positive, but a large part of the apparent edge depended on execution assumptions.&lt;/p&gt;
&lt;p&gt;Numbers like this should lead to more questions, not immediate model training. Did the candidate capture a real market structure, or did a validation window, horizon choice, latency assumption, and specific date mix leave behind a fragile positive residue? The kind of result I kept trying to avoid was exactly this ambiguous positive number. A negative result is simple. The difficult result is a positive number that becomes convincing if the explanation is adjusted slightly.&lt;/p&gt;
&lt;h2 id=&quot;the-gate-matrix-made-the-decision&quot;&gt;The Gate Matrix Made The Decision&lt;/h2&gt;
&lt;p&gt;The full gate failure count made the conclusion clearer.&lt;/p&gt;

















































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;gate_name&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;failed rows&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;inner_train_folds_stable&lt;/code&gt;&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;132&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;train_net_positive&lt;/code&gt;&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;120&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;adjacent_train_horizon_positive&lt;/code&gt;&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;118&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;mbp_active_1000ms_cost_stress_positive&lt;/code&gt;&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;112&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;validation_beats_one_random_p95&lt;/code&gt;&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;106&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;validation_net_positive&lt;/code&gt;&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;98&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;adjacent_validation_horizon_positive&lt;/code&gt;&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;88&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;validation_beats_all_random_medians&lt;/code&gt;&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;78&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;validation_day_concentration_lte_0p50&lt;/code&gt;&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;38&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;execution_feasibility_gte_0p50&lt;/code&gt;&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;7&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The most frequent failures were &lt;code&gt;inner_train_folds_stable&lt;/code&gt;, &lt;code&gt;train_net_positive&lt;/code&gt;, and &lt;code&gt;adjacent_train_horizon_positive&lt;/code&gt;. That matters. The candidates were not failing only on validation. They were already breaking at earlier stability gates.&lt;/p&gt;
&lt;p&gt;So the final decision was recorded like this.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;candidate_ready_for_test_or_ml&quot;: false,
  &quot;horizon_minutes&quot;: null,
  &quot;reason&quot;: &quot;no candidate horizon passed train, fold, adjacent-horizon, validation, null, cost-stress, and MBP feasibility gates&quot;,
  &quot;selected_candidate_id&quot;: &quot;no_trade&quot;,
  &quot;source_benchmark&quot;: &quot;none&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This JSON is disappointing, but I like it. The research system did not say, “one candidate looks good, so train a model anyway.” It passed every candidate through the same procedure and recorded that no candidate passed.&lt;/p&gt;
&lt;h2 id=&quot;not-training-a-model-is-also-a-result&quot;&gt;Not Training A Model Is Also A Result&lt;/h2&gt;
&lt;p&gt;After this stage, I still did not open the test split. I did not train an ML model. I did not start paper trading. From the outside, that can look like the project stopped. To me, it is the opposite. The system finally gained the ability to decide not to act.&lt;/p&gt;
&lt;p&gt;The dangerous flow in quant research is usually similar. Generate many candidates, find one that looks good on validation, attach a plausible explanation, train a model, and keep running more backtests. That flow is fast and enjoyable, but almost every step can hide overfitting.&lt;/p&gt;
&lt;p&gt;The multi-horizon diagnostic was built to interrupt that flow. Keep the same entry policy across horizons. Look at train first. Check adjacent horizons. Add null tests. Add execution stress. Only then allow a candidate to move forward. If the result is &lt;code&gt;no_trade&lt;/code&gt;, that is still a legitimate system output.&lt;/p&gt;
&lt;p&gt;In fact, the conclusion made the next step clearer. What the system needs now is not model training. It needs better candidate generation. Better features, clearer market microstructure hypotheses, stricter regime segmentation, and a more realistic execution model should come first. A model is a tool for compressing an edge when an edge exists. It is not a machine for creating edge where none has been demonstrated.&lt;/p&gt;
&lt;p&gt;So the current conclusion of this series is simple. The Quant Trading System is not ready to trade yet. But it is ready to refuse weak trades. I think that difference matters more than it first appears.&lt;/p&gt;</content:encoded><language>en-US</language><category>ML</category><category>Quant Trading</category><category>Diagnostics</category><category>Execution Stress</category><category>Multi-Horizon</category><category>Null Test</category><category>Research Dashboard</category><category>no_trade</category><author>ystc1247@gmail.com</author></item><item><title>[Quant Trading System 4/5] Decomposing the Train-Validation Gap with MBP Execution Reality</title><link>https://theokimdev.com/en/blog/post-90-quant-trading-system-07-train-validation-gap/</link><guid isPermaLink="true">https://theokimdev.com/en/blog/post-90-quant-trading-system-07-train-validation-gap/</guid><description>A forensic analysis of timing, contract, direction, fold, and cost fragility, followed by MBP-1 spread, latency, and adverse-movement calibration for OHLCV candidates.</description><pubDate>Sat, 25 Jul 2026 02:30:00 GMT</pubDate><content:encoded>&lt;h2 id=&quot;stopping-at-validation-good-train-bad-is-not-enough&quot;&gt;Stopping At “Validation Good, Train Bad” Is Not Enough&lt;/h2&gt;
&lt;p&gt;When the parameter sweep produced its first good-looking row, the easy reaction would have been one of two extremes. Either trust validation and push forward, or reject it immediately because train is negative. Neither was enough.&lt;/p&gt;
&lt;p&gt;The row was a session-extreme reversal short rule:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;session_extreme_reversal_short__skip_open_1030_1500__max1__score_ge_50p0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The visible result was split sharply:&lt;/p&gt;























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Split&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Trades&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Net points&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Avg net points&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Train&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;169&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;-151.662&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;-0.897&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Validation&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;38&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;67.576&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;1.778&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;A gap this large should not be filed away as just “train/validation gap.” I needed to know whether it was regime change, entry-timing artifact, cost-model fragility, exit-path calculation error, or a contract-period effect. Otherwise, the next research step would be blind.&lt;/p&gt;
&lt;h2 id=&quot;the-full-trigger-family-was-bad-in-both-splits&quot;&gt;The Full Trigger Family Was Bad In Both Splits&lt;/h2&gt;
&lt;p&gt;The first step was to inspect what the selected sweep actually chose. The important finding was that the whole signal family was not good. If I looked at all qualifying triggers, both train and validation were negative.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;train all triggers: 4,317 rows, -4,786.866 net points
validation all triggers: 880 rows, -416.490 net points
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The selected sweep only takes the first qualifying trigger per day.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;train first trigger per day: 169 trades, -151.662 net points
validation first trigger per day: 38 trades, 67.576 net points
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So the validation result was not evidence that the whole session-extreme short family worked. It was evidence that a first-trigger timing rule worked in one validation period. That distinction matters. If all triggers had been positive, I could trust the family more. Here, entry timing was carrying the conclusion.&lt;/p&gt;
&lt;p&gt;There was another tempting detail. If I selected max-score or last triggers, both train and validation looked strong:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;train max-score trigger per day: 687.088 net points
validation max-score trigger per day: 296.326 net points
train last trigger per day: 606.838 net points
validation last trigger per day: 222.576 net points
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But that is diagnostic, not a candidate. Max-score and last-trigger selection can easily require knowing the rest of the day. If the entry rule cannot know it at entry time, it is not tradeable. Good-looking numbers did not justify promoting it.&lt;/p&gt;
&lt;h2 id=&quot;time-of-day-and-contract-periods&quot;&gt;Time Of Day And Contract Periods&lt;/h2&gt;
&lt;p&gt;Next I split the result by time of day and contract period. Train losses were especially concentrated in the &lt;code&gt;14:00 ET&lt;/code&gt; hour.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;train 14:00 ET: 45 trades, -98.910 net points
validation 14:00 ET: 5 trades, -18.240 net points
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Validation gains came mostly from &lt;code&gt;11:00 ET&lt;/code&gt; through &lt;code&gt;13:00 ET&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;validation 11:00 ET: 7 trades, 23.264 net points
validation 12:00 ET: 14 trades, 46.028 net points
validation 13:00 ET: 11 trades, 19.272 net points
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Contract periods also gave clues. Train losses clustered in specific MES contracts:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;MESU2: 24 trades, -65.952 net points
MESM4: 8 trades, -41.484 net points
MESH2: 24 trades, -29.702 net points
MESH3: 15 trades, -27.970 net points
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Validation was mostly &lt;code&gt;MESH5&lt;/code&gt; and &lt;code&gt;MESM5&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;MESH5: 14 trades, 25.778 net points
MESM5: 21 trades, 46.292 net points
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This does not prove the answer by itself. But it is enough to suspect that the validation period matched a narrow market condition. A good research system should turn that suspicion into a gate before turning it into a signal.&lt;/p&gt;
&lt;h2 id=&quot;hidden-issue-short-exit-paths-were-calculated-like-longs&quot;&gt;Hidden Issue: Short Exit Paths Were Calculated Like Longs&lt;/h2&gt;
&lt;p&gt;The deep dive also found a real bug. The source five-minute parameter-sweep trades already handled short direction correctly, so this bug did not explain the original &lt;code&gt;-151.662&lt;/code&gt; train result.&lt;/p&gt;
&lt;p&gt;But the alternate exit-path diagnostics in OHLCV robustness were calculating short time exits, MFE, MAE, and stop/target first-touch outcomes as if every lead were long. That could distort later exit-label sensitivity.&lt;/p&gt;
&lt;p&gt;After the fix, exit paths became direction-aware:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;short time exits: direction-aware
short MFE/MAE: direction-aware
first-touch stop/target: direction-aware
unit coverage: added for short-direction exits
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This kind of bug can inflate returns or kill a valid candidate. The point was not to find a bug to excuse the validation winner. It was to make the diagnostic surface trustworthy before candidate promotion.&lt;/p&gt;
&lt;h2 id=&quot;configured-positive-still-failed-cost-stress&quot;&gt;Configured-Positive Still Failed Cost Stress&lt;/h2&gt;
&lt;p&gt;After the direction-aware rebuild, a session-extreme short 15-minute exit variant briefly looked promising:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;variant:
parameter_session_extreme_reversal_short...time_exit_15m...session_early_or_middle

configured train: 27.230 net points over 115 trades
configured validation: 34.558 net points over 29 trades
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;At first glance, this seems to pass both train and validation. But the average train edge was too small.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;configured average train edge: 0.236783 points per trade
required dominant-active p99 extra stress: 0.250000 points per trade
extra stress cost: 115 * 0.25 = 28.750 points
stressed train net: 27.230 - 28.750 = -1.520 points
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The result is mathematically clear. The configured edge was smaller than one extra tick of spread stress. A candidate like that should not proceed to model training. Saying “train and validation are slightly positive” becomes much less meaningful when one extra tick turns train negative.&lt;/p&gt;
&lt;h2 id=&quot;filter-hardening-13650-rows-checked-0-passed&quot;&gt;Filter Hardening: 13,650 Rows Checked, 0 Passed&lt;/h2&gt;
&lt;p&gt;I did not stop immediately. Without buying more data, I tried adding one train-derived filter at a time to see whether the candidate could be hardened. Thresholds were computed from train only, and the candidate had to pass early, middle, and late train folds.&lt;/p&gt;
&lt;p&gt;The result was again &lt;code&gt;no_trade&lt;/code&gt;.&lt;/p&gt;

























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Metric&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Candidate filter rows checked&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;13,650&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Prefold pass rows&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;66&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;All-gate pass rows&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Selected filter&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;&lt;code&gt;no_trade&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Even the best-looking prefold filter broke in late train:&lt;/p&gt;





























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Split/Fold&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Dominant p99 net&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Train total&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;25.726&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Validation total&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;72.308&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Train early&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;63.074&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Train middle&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;8.826&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Train late&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;-46.174&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;This was fairly decisive. Early train and validation looked good, but late train was negative. The validation strength had not repeated across the whole train period.&lt;/p&gt;
&lt;h2 id=&quot;conclusion-temporal-instability-and-cost-fragility&quot;&gt;Conclusion: Temporal Instability And Cost Fragility&lt;/h2&gt;
&lt;p&gt;The conclusion of this deep dive was more specific than “this candidate does not work.”&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;The signal family has a conditional reversal edge that appears only in some market periods.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The original selected sweep was negative on train and positive only on validation. The full trigger family was negative in both splits. The direction-aware bug fix did not explain the original train failure. The configured-positive variant failed one tick of p99 cost stress. Filter hardening checked 13,650 rows and found no row that passed all inner train folds.&lt;/p&gt;
&lt;p&gt;So the decision stayed the same:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;selected policy: no_trade
train/test split: test untouched
model training: not started
paper trading: not started
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;What I liked about this phase was that the failure became clearer. It was no longer just “validation good, train bad.” It became “a fragile entry-timing reversal family that fails late train and cannot survive one tick of extra cost stress.”&lt;/p&gt;
&lt;p&gt;Once failure is explained that concretely, not training a model becomes much easier.&lt;/p&gt;
&lt;p&gt;The analysis made the candidate’s temporal instability concrete. But OHLCV bars could not say whether the extra tick used in p99 stress was conservative or optimistic in the actual market. The next step therefore used MBP-1 not as a feature source, but as an execution-calibration surface.&lt;/p&gt;
&lt;h2 id=&quot;ohlcv-backtests-do-not-prove-fills&quot;&gt;OHLCV Backtests Do Not Prove Fills&lt;/h2&gt;
&lt;p&gt;OHLCV backtests are fast and convenient. I can build signals from one-minute bars, use the close five minutes later as the label, subtract costs in points, and run research quickly. But that does not prove the trade could be executed. For MES, where tick size is small and the strategy horizon is short, top-of-book spread and latency can change the entire conclusion.&lt;/p&gt;
&lt;p&gt;The next stage of the quant trading system therefore used MBP-1 data as an execution-realism calibration surface. I deliberately say calibration surface, not feature source. The MBP coverage was mostly close to the canonical test period, and it was not a timestamp-matched train/validation fill dataset for older OHLCV trades. So I did not mix MBP into labels or model features.&lt;/p&gt;
&lt;p&gt;Instead, I changed the question:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;How realistic is the current top-of-book spread assumption?
Does a marketable order still satisfy that assumption after 0ms, 100ms, 500ms, or 1000ms latency?
How large is adverse mid movement over short horizons?
How different are dominant active MES/ES contracts from the all-symbol universe?
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This does not find a strategy. But it is very useful for killing candidates.&lt;/p&gt;
&lt;h2 id=&quot;first-the-mbp-cleaning-policy&quot;&gt;First, The MBP Cleaning Policy&lt;/h2&gt;
&lt;p&gt;I did not use MBP data directly because the raw anomaly report had already found crossed top-of-book rows and negative prices. The first MBP cleaning policy therefore defined a contract for clean outright ES/MES top-of-book rows.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;exclude spread symbols
exclude negative prices
drop missing or nonpositive top-of-book rows
drop locked books
drop crossed books
keep outright ES/MES rows only
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The result was:&lt;/p&gt;

















































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Metric&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Raw MBP rows&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;387,252,384&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Outright ES/MES target rows&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;386,559,005&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Clean outright top-of-book rows&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;386,556,485&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Spread-symbol rows excluded&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;693,379&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Negative-price rows flagged&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;78&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Nonpositive/missing top rows dropped&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;1,884&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Locked top rows dropped&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;69&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Crossed top rows dropped&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;859&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Event dates&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;29&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Symbols&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;25&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The important thing is not only that there are many clean rows. It is that each rejected row has a reason. If a later p99 spread report looks strange, I need to know whether the cause is a crossed book, stale contract, or real tail spread.&lt;/p&gt;
&lt;h2 id=&quot;static-spread-realism&quot;&gt;Static Spread Realism&lt;/h2&gt;
&lt;p&gt;The first execution-realism report measured spread and top-size distributions across clean top-of-book rows.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;clean top-of-book rows: 386,556,485
event dates: 28
symbols: 10
median spread: 1.0 tick
p90 spread: 2.0 ticks
p99 spread: 7.0 ticks
rows within assumed 1 spread tick: 0.875708
rows inside full 0.998 point round-turn cost: 0.943512
dominant MESM6 rows within assumed spread tick: 0.931609
dominant ESM6 rows within assumed spread tick: 0.931816
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The result cuts both ways. A median spread of one tick means the basic spread assumption is not absurd. But p90 is two ticks and p99 is seven ticks. Across the all-symbol universe, only about &lt;code&gt;87.6%&lt;/code&gt; of rows are inside the one-tick spread assumption.&lt;/p&gt;
&lt;p&gt;So fixed OHLCV costs may be fine as a baseline assumption, but they are not enough for candidate promotion. If a validation edge is small or exposed to inactive contracts, tail spread can erase it.&lt;/p&gt;
&lt;h2 id=&quot;latency-and-adverse-movement&quot;&gt;Latency And Adverse Movement&lt;/h2&gt;
&lt;p&gt;The MBP execution diagnostics went one step beyond static spread. It created 30-second anchors, then used as-of joins to inspect bid/ask snapshots after &lt;code&gt;0ms&lt;/code&gt;, &lt;code&gt;100ms&lt;/code&gt;, &lt;code&gt;500ms&lt;/code&gt;, and &lt;code&gt;1000ms&lt;/code&gt; latency. It then measured mid-price drift and adverse markout over &lt;code&gt;1s&lt;/code&gt;, &lt;code&gt;5s&lt;/code&gt;, &lt;code&gt;30s&lt;/code&gt;, and &lt;code&gt;60s&lt;/code&gt; horizons.&lt;/p&gt;
&lt;p&gt;The key is to inspect the quote when an order may actually arrive, not only the quote at the signal timestamp.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;LATENCY_OFFSETS_MS = (0, 100, 500, 1000)
HORIZON_OFFSETS_MS = (1000, 5000, 30000, 60000)
MAX_EVENT_STALENESS_MS = 2000

diagnostic[&quot;market_buy_entry_px&quot;] = diagnostic[&quot;latency_ask_px&quot;]
diagnostic[&quot;market_sell_entry_px&quot;] = diagnostic[&quot;latency_bid_px&quot;]
diagnostic[&quot;market_buy_adverse_after_fill_points&quot;] = (
    -diagnostic[&quot;market_buy_entry_markout_points&quot;]
).clip(lower=0.0)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The result was stricter than static spread:&lt;/p&gt;

















































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Metric&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Anchor rows&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;60,035&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Diagnostic rows&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;897,944&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Symbols&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;10&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Median latency spread&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;2.0 ticks&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;p90 latency spread&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;4.0 ticks&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;p99 latency spread&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;27.0 ticks&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Latency spread within one-tick assumption&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0.484892&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Avg absolute horizon mid drift&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0.638999 points&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Avg market-buy adverse mid movement&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0.313413 points&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Avg market-sell adverse mid movement&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0.325586 points&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Across the all-symbol universe, the one-tick assumption breaks often. But dominant active contracts look different. Contracts such as &lt;code&gt;MESM6&lt;/code&gt; and &lt;code&gt;ESM6&lt;/code&gt; have median and p90 spreads close to one tick, with roughly 96-97% of diagnostic rows inside the one-tick assumption.&lt;/p&gt;
&lt;p&gt;That distinction matters. Execution realism does not say “MES is always bad” or “MES is always fine.” It shows that cost realism depends heavily on the contract universe.&lt;/p&gt;
&lt;h2 id=&quot;mbp-microstructure-report&quot;&gt;MBP Microstructure Report&lt;/h2&gt;
&lt;p&gt;I also built an MBP microstructure report. It measures update rates, quote/trade activity, spread distribution, top-size collapse, imbalance, and microprice drift.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;clean seconds: 1,690,818
event dates: 22
symbols: 10
average event updates per second: 127.695576
average quote updates per second: 121.759004
average trades per second: 5.936572
all-symbol median spread: 3.0 ticks
all-symbol p90 spread: 4.0 ticks
top-size collapse share: 0.006857
dominant active MESM6 and ESM6 median/p90 spread: 1.0 tick
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This report was not meant to create model features immediately. It was closer to a lab notebook for future microstructure features. The all-symbol aggregate is much rougher because stale and inactive contracts remain in the universe. Active symbols are much cleaner.&lt;/p&gt;
&lt;h2 id=&quot;preparing-pathwise-stress-for-ohlcv-candidates&quot;&gt;Preparing Pathwise Stress For OHLCV Candidates&lt;/h2&gt;
&lt;p&gt;This execution layer later feeds into pathwise execution stress. Even if an OHLCV candidate is validation-positive, it must survive MBP-calibrated p90 spread and adverse-movement penalties.&lt;/p&gt;
&lt;p&gt;The important part was not overusing MBP. Because current MBP coverage is not full historical train/validation fill coverage, I did not attach timestamp-matched fills to every old OHLCV trade. Instead, I built scenarios such as active-symbol p90 stress and all-MES stress, then used them as candidate promotion gates.&lt;/p&gt;
&lt;p&gt;That choice is conservative and honest. Use the data as much as it can support, but do not pretend it proves more than it does.&lt;/p&gt;
&lt;h2 id=&quot;fixed-costs-are-only-the-beginning&quot;&gt;Fixed Costs Are Only The Beginning&lt;/h2&gt;
&lt;p&gt;After this phase, I trusted the fixed OHLCV cost assumption much less. A &lt;code&gt;0.998&lt;/code&gt; point round-turn cost is useful for research baselines, but not sufficient for promotion. A candidate must survive not only configured costs, but also spread tails, latency, adverse movement, and contract-activity differences.&lt;/p&gt;
&lt;p&gt;Later gates became stricter because of this. A validation-positive row can still be rejected if it fails pathwise active 1000ms stress. All-symbol or all-MES stress kills even more candidates. This is annoying, but it is the right kind of annoying for a trading system.&lt;/p&gt;
&lt;p&gt;OHLCV backtests are a good start. They do not prove execution. After adding MBP execution realism, I could see the distance between a good-looking number and a number that might actually be tradable.&lt;/p&gt;</content:encoded><language>en-US</language><category>ML</category><category>Quant Trading</category><category>Backtesting</category><category>Cost Stress</category><category>Execution</category><category>Market By Price</category><category>MES</category><category>Microstructure</category><category>Validation</category><author>ystc1247@gmail.com</author></item><item><title>[Quant Trading System 3/5] Why no_trade Baselines Made Positive Validation More Suspicious</title><link>https://theokimdev.com/en/blog/post-88-quant-trading-system-05-baselines-no-trade/</link><guid isPermaLink="true">https://theokimdev.com/en/blog/post-88-quant-trading-system-05-baselines-no-trade/</guid><description>How deterministic baselines, feature IC, and a strategy zoo selected no_trade, then train, concentration, and cost-stress gates rejected the first validation-positive candidates.</description><pubDate>Sat, 25 Jul 2026 02:20:00 GMT</pubDate><content:encoded>&lt;h2 id=&quot;the-purpose-of-the-first-baselines&quot;&gt;The Purpose Of The First Baselines&lt;/h2&gt;
&lt;p&gt;Once the training matrix exists, the next temptation is a model. In this quant trading system, I ran deterministic baselines first. The reason was simple: if human-readable rules all fail after costs, putting ML on top of the same surface can become more dangerous, not less.&lt;/p&gt;
&lt;p&gt;Baselines can search for profit, but their more important role is to provide a stop signal. If common rules fail, feature IC sends strange warnings, and candidate selection keeps choosing &lt;code&gt;no_trade&lt;/code&gt;, the conclusion should not be “train a more complex model.” It should be closer to “do not train yet.”&lt;/p&gt;
&lt;p&gt;The first baseline set was deliberately conservative.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;test split excluded
chronological train/validation only
no overlapping positions
max 5 trades per day
cost-adjusted net return
no_trade always available as a candidate
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The no-overlap and max-trades-per-day rules matter. A research signal may look active every minute, but real trading decisions are sequential. If a position is already open, the next trigger cannot be traded. Trading unlimited signals in a day is also unrealistic. So the baselines included some execution-like constraints from the beginning.&lt;/p&gt;
&lt;h2 id=&quot;walk-forward-baseline&quot;&gt;Walk-Forward Baseline&lt;/h2&gt;
&lt;p&gt;The first walk-forward baseline evaluated simple deterministic scores sequentially inside each day. The hold period was five minutes, each strategy could trade at most five times per day, and the test split was excluded.&lt;/p&gt;
&lt;p&gt;The result was cold:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;selected trade rows: 46,879
validation folds: 10
test trades: 0
maximum trades in one day: 5
top validation baseline: no_trade
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I liked this result because the harness did not force an active rule to be selected. Many backtesting scripts present the least bad active rule as if it were the best strategy. In a trading system, doing nothing may be the best choice. The baseline harness has to allow that choice naturally.&lt;/p&gt;
&lt;h2 id=&quot;candidate-policy-the-first-feature-only-failure&quot;&gt;Candidate Policy: The First Feature-Only Failure&lt;/h2&gt;
&lt;p&gt;The next step was a feature-only candidate policy. It used training-matrix features to create mean-reversion, momentum, and trend-pullback rules, then checked whether any policy was positive on both train and validation.&lt;/p&gt;
&lt;p&gt;The rules were simple, but the boundaries were strict. Policies must not reference label columns, must not load the test split, and must respect no-overlap plus the daily trade cap.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;POLICY_FEATURES = (
    &quot;mes_return_5m&quot;,
    &quot;mes_bb_20m_zscore&quot;,
    &quot;mes_rsi_14m&quot;,
    &quot;mes_vwap_session_diff_pct&quot;,
    &quot;es_return_5m&quot;,
    &quot;mes_es_return_5m_spread&quot;,
)

def validate_policy_features() -&amp;gt; None:
    label_overlap = sorted(set(POLICY_FEATURES) &amp;amp; set(LABEL_COLUMNS))
    if label_overlap:
        raise RuntimeError(f&quot;Candidate policies reference label columns: {label_overlap}&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The first candidate policy also selected &lt;code&gt;no_trade&lt;/code&gt;.&lt;/p&gt;

































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Metric&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Active policy rows scanned&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;95,856&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Test trades&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Max trades per day&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;5&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Overlap count&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Best active validation policy&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;&lt;code&gt;trend_pullback_score_2p5&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Best active validation net points&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;&lt;code&gt;-360.97&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;A result this poor is at least easy to interpret. The feature-only heuristic did not beat costs. If I trained a model at this point, it would probably find some noisy feature combination. But that would be closer to the start of overfitting than the start of a strategy.&lt;/p&gt;
&lt;h2 id=&quot;the-warning-from-feature-ic&quot;&gt;The Warning From Feature IC&lt;/h2&gt;
&lt;p&gt;I then built a feature IC report. In the spirit of Alphalens or Qlib, it looks at rank correlation, quantile spreads, turnover, and decay between features and forward returns. The test split stayed excluded.&lt;/p&gt;
&lt;p&gt;The numbers were interesting:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;input rows: 281,145 train/validation rows
features evaluated: 47
daily IC rows: 49,491
strongest validation mean absolute IC: mes_sma_5m, 0.214682
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But this should not be read as an alpha claim. The strongest features were mostly raw price, SMA, and VWAP-level features. That can be a normalization warning. Futures rolls, price levels, and session behavior can make rank correlations look useful without implying a tradable signal.&lt;/p&gt;
&lt;p&gt;So the feature IC conclusion was not “I found a good feature.” It was closer to “feature normalization and benchmark design need more suspicion before modeling.” The better a feature looks, the more I need to explain why.&lt;/p&gt;
&lt;h2 id=&quot;strategy-zoo-rebuilding-external-ideas-locally&quot;&gt;Strategy Zoo: Rebuilding External Ideas Locally&lt;/h2&gt;
&lt;p&gt;After that, I built a strategy zoo. The goal was to reimplement common GitHub strategy families on top of the local data contract: SMA/EMA crossovers, MACD, RSI reversals, Bollinger mean reversion and breakout, Donchian breakout, and opening-range breakout/failure.&lt;/p&gt;
&lt;p&gt;The important thing was not that these ideas came from outside. It was that every outside idea had to pass through the same local gate. GitHub stars, README backtests, and attractive charts are not evidence in this system. Everything must be rebuilt on local MES/ES data, costs, no-overlap rules, and train/validation splits.&lt;/p&gt;
&lt;p&gt;The strategy zoo remained conservative:&lt;/p&gt;









































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Metric&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Input rows&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;281,145&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Strategies evaluated&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;46&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Strategy trades scanned&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;141,528&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Validation strategy trades&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;24,409&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Test trades&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Selected strategy&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;&lt;code&gt;no_trade&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Best active validation strategy&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;&lt;code&gt;vol_expansion_breakout_short&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Best active validation net points&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;&lt;code&gt;-21.166&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The fact that the best active validation strategy was still negative is a useful stop signal. One could say, “Maybe XGBoost can still find it.” But when deterministic families all fail to beat costs, any model-found pattern needs much stronger evidence.&lt;/p&gt;
&lt;h2 id=&quot;the-lookahead-audit-passed-but-the-strategies-failed&quot;&gt;The Lookahead Audit Passed, But The Strategies Failed&lt;/h2&gt;
&lt;p&gt;An interesting detail is that the data leakage audit looked clean at this point. The lookahead audit recomputed selected feature families from raw OHLCV prefixes and compared them with stored feature values.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;audited features: 26
sampled rows: 216
feature observations: 5,616
mismatches: 0
max absolute difference: 4.96548580031e-10
forbidden future/label/rank/net feature-name tokens: 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This matters. When baselines fail, there are two possible explanations. Either the code/data is broken, or the signal is weak. The audit is not final proof, but it does show that the features were not accidentally looking into the future to produce attractive results. And the results were not attractive anyway.&lt;/p&gt;
&lt;p&gt;That combination leads to an honest conclusion: the data contract was getting cleaner, but a tradable edge was not visible yet.&lt;/p&gt;
&lt;h2 id=&quot;no_trade-is-not-a-lazy-answer&quot;&gt;no_trade Is Not A Lazy Answer&lt;/h2&gt;
&lt;p&gt;The results at this point all pointed in the same direction.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;walk-forward baseline: no_trade
candidate policy: no_trade
strategy zoo: no_trade
test split used: 0 trades
model training: not started
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;no_trade&lt;/code&gt; is not a lazy answer. It is closer to evidence that the baseline system is working correctly. The system should not be forced to pick something. If active rules cannot beat costs and validation, doing nothing should win.&lt;/p&gt;
&lt;p&gt;What I gained from this stage was not a strategy. I gained a standard. Any future candidate must at least beat this baseline set. And even if it looks good on validation, it still has to survive train, cost stress, concentration, and execution realism.&lt;/p&gt;
&lt;p&gt;The model still had to wait. When baselines return a negative conclusion, ignoring that conclusion and escaping into a more complex model is not research. It is wishful thinking.&lt;/p&gt;
&lt;p&gt;That clear stop signal did not end the research. It changed the job of the next experiments. Sensitivity analysis became less a search for something to promote and more a pressure test of whether the first positive validation rows could still be rejected under the rules already in place.&lt;/p&gt;
&lt;h2 id=&quot;when-the-first-good-looking-numbers-appeared&quot;&gt;When The First Good-Looking Numbers Appeared&lt;/h2&gt;
&lt;p&gt;This was the first point where attractive validation numbers appeared. A 30-minute horizon row produced &lt;code&gt;175.124&lt;/code&gt; validation net points, and exit-label sensitivity reached &lt;code&gt;256.048&lt;/code&gt; validation net points. A session-extreme short row from the parameter sweep also showed a good-looking profit factor.&lt;/p&gt;
&lt;p&gt;This is the dangerous moment. When a research loop that had been finding nothing finally prints a positive result, the human wants to explain it. Maybe the signal fits 30 minutes better than 5 minutes. Maybe the entry is fine and only the exit is wrong. Maybe a session filter can preserve the edge.&lt;/p&gt;
&lt;p&gt;But the point of this phase was not to find positive validation rows. It was to test whether the system could keep rejecting them through train, concentration, cost-stress, no-overlap, and test-locking rules.&lt;/p&gt;
&lt;h2 id=&quot;horizon-sensitivity-30-minutes-looked-better-but-train-collapsed&quot;&gt;Horizon Sensitivity: 30 Minutes Looked Better, But Train Collapsed&lt;/h2&gt;
&lt;p&gt;The first horizon sensitivity pass kept the current roll rule and recomputed labels at &lt;code&gt;5m&lt;/code&gt;, &lt;code&gt;10m&lt;/code&gt;, &lt;code&gt;15m&lt;/code&gt;, and &lt;code&gt;30m&lt;/code&gt;. It also checked that the recomputed five-minute labels matched the strict matrix labels exactly.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;horizon label rows: 1,082,461
strategy trade rows scanned: 160,845
test labels: 0
test trades: 0
5-minute label recomputation mismatches: 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The most visible result was a 30-minute ES/MES spread row.&lt;/p&gt;





















&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Metric&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Best active validation row&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;&lt;code&gt;30m mes_vs_es_spread_gt_0p0001&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Validation net points&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;&lt;code&gt;175.124&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Same row train net points&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;&lt;code&gt;-2,894.096&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;On validation alone, this is interesting. But with train failing that badly, it is not a candidate. A train loss of &lt;code&gt;-2,894.096&lt;/code&gt; points is not a small weakness. It is much closer to a signal that happened to survive in validation.&lt;/p&gt;
&lt;p&gt;The horizon sensitivity conclusion was therefore simple: the 30-minute horizon may be a research clue, but it is not ready for training or test evaluation.&lt;/p&gt;
&lt;h2 id=&quot;roll-rule-sensitivity-contract-selection-did-not-rescue-the-structure&quot;&gt;Roll-Rule Sensitivity: Contract Selection Did Not Rescue The Structure&lt;/h2&gt;
&lt;p&gt;The next pass was roll-rule sensitivity. It compared the previous-volume leader, front contract, and same-day-volume leader. Same-day-volume leader was marked as a lookahead diagnostic only, because it uses full-day volume.&lt;/p&gt;
&lt;p&gt;The result was even more conservative:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;roll-rule input rows: 1,014,039
strategy trade rows scanned: 121,315
test rows used: 0
front-contract differs from previous-volume leader: 53 trade dates
same-day-volume differs from previous-volume leader: 17 trade dates
best non-lookahead active validation net: -140.334
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Changing the roll rule did not make the five-minute baseline family work. Contract selection matters, but it is not a magic knob that turns a bad signal into a good one.&lt;/p&gt;
&lt;p&gt;This also blocked a tempting mistake. Same-day volume can look useful, but if the full day’s volume is used for that same day’s entry decision, it is lookahead. A tradeable rule must choose contracts using only previous information.&lt;/p&gt;
&lt;h2 id=&quot;parameter-sweep-the-good-looking-row-broke-on-train&quot;&gt;Parameter Sweep: The Good-Looking Row Broke On Train&lt;/h2&gt;
&lt;p&gt;The parameter sweep reused strategy-zoo signals and varied trading windows plus score floors. It made the rule stricter by allowing at most one trade per sweep variant per day, and the test split remained excluded.&lt;/p&gt;
&lt;p&gt;This produced the first plausible validation-positive row.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;selected sweep: no_trade
sweep variants: 192
sweep trades scanned: 114,967
validation sweep trades: 20,117
best active validation sweep:
  session_extreme_reversal_short__skip_open_1030_1500__max1__score_ge_50p0
best active validation net points: 67.576
best active validation profit factor: 2.408655
same sweep train net points: -151.662
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The validation result is tempting. Net points are positive and profit factor looks strong. But train is &lt;code&gt;-151.662&lt;/code&gt; points. This combination requires caution. A rule that fails on train and works only on validation may reflect regime change, but it may also be a selection artifact.&lt;/p&gt;
&lt;p&gt;I did not promote it at this stage. I sent it into a deeper diagnostic pass. The important thing was that the validation winner did not immediately become a candidate.&lt;/p&gt;
&lt;h2 id=&quot;exit-label-sensitivity-exit-changes-almost-looked-like-a-rescue&quot;&gt;Exit-Label Sensitivity: Exit Changes Almost Looked Like A Rescue&lt;/h2&gt;
&lt;p&gt;The most dangerous result came from exit-label sensitivity. For the existing OHLCV robustness leads, it tested fixed time exits, stop/target first-touch exits, and train-derived regime filters.&lt;/p&gt;
&lt;p&gt;The code deliberately forced train-derived thresholds:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;REGIME_FILTERS = (
    RegimeFilterSpec(&quot;no_filter&quot;, &quot;All source trades.&quot;),
    RegimeFilterSpec(&quot;session_early_or_middle&quot;, &quot;Session progress before late-third cutoff.&quot;),
    RegimeFilterSpec(
        &quot;train_high_mes_volume_20m_zscore&quot;,
        &quot;MES volume z-score is at or above the source-lead train 67th percentile.&quot;,
        &quot;mes_volume_20m_zscore&quot;,
        0.67,
    ),
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The result looked strong:&lt;/p&gt;









































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Metric&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Source exit rows&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;22,925&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Variant trade rows&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;98,903&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Active variants&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;147&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Best validation variant&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;&lt;code&gt;horizon_30m...time_exit_30m...train_high_mes_volume_20m_zscore&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Best validation net points&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;&lt;code&gt;256.048&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Best validation profit factor&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;&lt;code&gt;2.652178&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Best validation top-5 day share&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;&lt;code&gt;0.750012&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Same variant train net points&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;&lt;code&gt;-1,242.548&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;If I looked only at validation PnL, it would feel like a discovery. But a top-five day concentration of &lt;code&gt;0.750012&lt;/code&gt; means too much of the result came from a few dates. And with train at &lt;code&gt;-1,242.548&lt;/code&gt;, this is diagnostic evidence, not a candidate.&lt;/p&gt;
&lt;p&gt;Another variant was subtler. After a direction-aware rebuild, a session-extreme short 15-minute exit looked positive on both train and validation under configured costs.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;configured train net: 27.230
configured validation net: 34.558
dominant-active p99 stress train net: -1.520
all-clean p99 stress train net: -145.270
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The important number is not &lt;code&gt;27.230&lt;/code&gt;. It is &lt;code&gt;-1.520&lt;/code&gt;. The average train edge was so small that a p99 spread-stress adjustment pushed it negative. If a result cannot survive one tick of extra cost, it is not live-ready, and it is not even model-training-ready.&lt;/p&gt;
&lt;h2 id=&quot;validation-is-a-question-not-an-answer&quot;&gt;Validation Is A Question, Not An Answer&lt;/h2&gt;
&lt;p&gt;This phase changed how I read validation. Validation is not an answer. It is a question generator. Why did this work only here? Why did train fail? Did a few dates create the whole result? Does cost stress erase it? Is entry the problem or exit? Was this filter chosen after seeing validation?&lt;/p&gt;
&lt;p&gt;A good research system should ask those questions before promoting a validation-positive row. Most rows will not survive them.&lt;/p&gt;
&lt;p&gt;The final selection in this phase remained &lt;code&gt;no_trade&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;horizon sensitivity: no_trade
roll-rule sensitivity: no_trade
parameter sweep: no_trade
exit-label sensitivity: no_trade
test split touched: no
model training started: no
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;why-this-failure-was-useful&quot;&gt;Why This Failure Was Useful&lt;/h2&gt;
&lt;p&gt;These results may look like failures, but they made the next research direction clearer. The problem was no longer just “the baselines are bad.” I could now describe how they were bad.&lt;/p&gt;
&lt;p&gt;The 30-minute horizon works in validation but collapses on train. Changing the roll rule does not rescue the structure. Session-extreme short looks good on validation but is negative on train. Exit changes briefly improve results, but concentration and cost stress reject them.&lt;/p&gt;
&lt;p&gt;After decomposing the failures this far, not training a model becomes easier. ML is not a magic rescue tool for weak deterministic signals. If I train without understanding why a weak signal is weak, I risk wrapping a validation artifact in a more convincing function.&lt;/p&gt;
&lt;p&gt;So the conclusion stayed the same. Do not trade yet. Do not open the test split yet. Do not train yet. Build better questions.&lt;/p&gt;</content:encoded><language>en-US</language><category>ML</category><category>Quant Trading</category><category>Backtesting</category><category>Baseline</category><category>Feature IC</category><category>Sensitivity Analysis</category><category>Strategy Zoo</category><category>Validation</category><category>no_trade</category><author>ystc1247@gmail.com</author></item><item><title>[Quant Trading System 2/5] From an MES/ES Silver Dataset to a Strict Training Matrix</title><link>https://theokimdev.com/en/blog/post-86-quant-trading-system-03-silver-dataset/</link><guid isPermaLink="true">https://theokimdev.com/en/blog/post-86-quant-trading-system-03-silver-dataset/</guid><description>How past-only contract selection and session filtering produced an MES/ES silver dataset, then a strict matrix with physically separated features, labels, metadata, and splits.</description><pubDate>Sat, 25 Jul 2026 02:10:00 GMT</pubDate><content:encoded>&lt;h2 id=&quot;raw-futures-data-is-not-a-feature-matrix&quot;&gt;Raw Futures Data Is Not A Feature Matrix&lt;/h2&gt;
&lt;p&gt;After converting Databento GLBX data into bronze Parquet, the data was still not ready for baselines or models. Futures data requires more decisions than an equity daily-bar dataset. Even when the symbols are called &lt;code&gt;ES&lt;/code&gt; and &lt;code&gt;MES&lt;/code&gt;, the files contain many expiring contracts, and a parent-symbol request does not automatically create the continuous series I want.&lt;/p&gt;
&lt;p&gt;So the first real dataset task in the quant trading system was to build a silver OHLCV dataset. The goal was simple: treat MES as the tradable instrument, join same-minute ES as the reference instrument, and choose which MES/ES contracts to use on each trade date with an explicit rule.&lt;/p&gt;
&lt;p&gt;The important part was not handcrafting the most attractive continuous contract. The rule had to be past-only, auditable, and frozen before looking at validation or test results. The first rule I chose was the previous available regular-session volume leader.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Trading instrument: MES
Reference instrument: ES
Source schema: Databento ohlcv-1m
Contract selection: previous available regular-session volume leader
Session filter: 10:00 to 15:30 US/Eastern
Label: net forward 5-minute MES close-to-close return
Splits: chronological by trade date, 70% train, 15% validation, 15% test
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This rule is not perfect. But it satisfies the conditions for a good first rule. It does not use future volume, it can be reproduced from definition metadata and OHLCV data, and it leaves an artifact showing exactly which contract was selected on each date.&lt;/p&gt;
&lt;h2 id=&quot;making-the-dataset-contract-explicit-in-duckdb&quot;&gt;Making The Dataset Contract Explicit In DuckDB&lt;/h2&gt;
&lt;p&gt;The silver builder stacks several DuckDB views. It first keeps outright ES/MES futures from the definition data, then selects the latest definition row for each &lt;code&gt;instrument_id&lt;/code&gt;. After that, it joins definition metadata into OHLCV rows and computes trade dates and times in &lt;code&gt;America/New_York&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;con.execute(
    &quot;&quot;&quot;
    create or replace temp view rth_bars as
    select
      *,
      trade_date in (select trade_date from degraded_dates) as is_degraded_date
    from ohlcv_enriched
    where time_et &amp;gt;= TIME &apos;10:00:00&apos;
      and time_et &amp;lt;= TIME &apos;15:30:00&apos;
      and high &amp;gt;= open
      and high &amp;gt;= close
      and high &amp;gt;= low
      and low &amp;lt;= open
      and low &amp;lt;= close
      and low &amp;lt;= high
      and volume &amp;gt;= 0
    &quot;&quot;&quot;
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This code is not flashy, but it is one of the most important parts of the research pipeline. Before model features, there must be a dataset contract. The code has to say which time window is used, which rows are removed, and which quality checks are applied. Otherwise, when results improve later, I cannot separate a better signal from a changed data selection.&lt;/p&gt;
&lt;h2 id=&quot;why-i-started-with-a-narrow-regular-session&quot;&gt;Why I Started With A Narrow Regular Session&lt;/h2&gt;
&lt;p&gt;The first trading window is &lt;code&gt;10:00&lt;/code&gt; to &lt;code&gt;15:30&lt;/code&gt; US/Eastern. It avoids the first 30 minutes after the U.S. equity open and the last 30 minutes before the close.&lt;/p&gt;
&lt;p&gt;That choice is conservative. The open and close often have stronger volatility and more fragile execution assumptions. Of course, some strategies may find edge precisely there. But for the first dataset, stable measurement mattered more than signal hunting. I would rather validate the pipeline inside a narrower window than make the first result depend on the most unstable parts of the session.&lt;/p&gt;
&lt;p&gt;I also built a separate session calendar. The silver report distinguishes full regular sessions, exchange-holiday partial sessions, early closes, and vendor anomalies. The later strict training matrix keeps only complete regular sessions. That makes holiday and early-close handling an explicit filter instead of a hidden loss of data.&lt;/p&gt;
&lt;h2 id=&quot;first-silver-dataset-results&quot;&gt;First Silver Dataset Results&lt;/h2&gt;
&lt;p&gt;The first silver OHLCV dataset writes three main artifacts:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;ohlcv_1m_volume_leader_v1.parquet
contract_selection_v1.parquet
dataset_manifest_v1.json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The summary looked like this:&lt;/p&gt;













































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Metric&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Rows&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;418,644&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Trade dates&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;1,284&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;UTC range&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;2021-05-18 to 2026-05-15&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Degraded rows&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Missing ES reference rows&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;2&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Incomplete session rows&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;7,873&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Incomplete session dates&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;43&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Labeled rows&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;412,214&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Average net forward 5m return&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;-0.00019931&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Contract coverage was another important check:&lt;/p&gt;




















&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Root&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Selected trade dates&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Selected contracts&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;ES&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;1,284&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;21&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;MES&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;1,284&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;21&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;This result did not mean “the model can run now” as much as “I can now explain what the model would see.” I could identify which ES/MES contracts were selected, where the roll dates were, and why rows without forward labels were still retained.&lt;/p&gt;
&lt;h2 id=&quot;labels-are-experimental-conditions-not-truth&quot;&gt;Labels Are Experimental Conditions, Not Truth&lt;/h2&gt;
&lt;p&gt;The silver label is net forward five-minute MES close-to-close return. It records cost-adjusted net return alongside gross return, and the cost assumption is written into the report.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;mes_contract_multiplier_usd_per_point&quot;: 5.0,
  &quot;mes_fee_per_side_usd&quot;: 0.62,
  &quot;mes_tick_size_points&quot;: 0.25,
  &quot;slippage_ticks_round_turn&quot;: 2.0,
  &quot;spread_ticks_round_turn&quot;: 1.0,
  &quot;version&quot;: &quot;ibkr_mes_retail_v1&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This label is not truth. It is not an actual fill, broker state, or queue-position simulation. But at the research stage, it is a clear experimental condition. It lets deterministic rules and future models be compared under the same data and cost assumption.&lt;/p&gt;
&lt;p&gt;The silver dataset use notes therefore carry these principles:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Features use only current and earlier completed one-minute bars.
Labels use future five-minute MES closes and must not be used as features.
Holiday and early-close sessions are retained with explicit flags.
MBP fields are intentionally absent until the anomaly report is reviewed.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The last line mattered. MBP data already existed, but I did not force it into the silver OHLCV dataset. MBP needed anomaly review and a cleaning policy, and its coverage was not yet suitable for model features. Having data does not mean every field should become a feature.&lt;/p&gt;
&lt;h2 id=&quot;the-model-boundary-left-by-silver-data&quot;&gt;The Model Boundary Left By Silver Data&lt;/h2&gt;
&lt;p&gt;Building the silver dataset made one thing very clear: a large part of quant research happens before modeling. Which contract should be selected? Which session should be used? Which dates should be excluded? Which labels should be generated? Where should costs be subtracted? How long should the test split stay locked? These decisions define the meaning of model performance before a model exists.&lt;/p&gt;
&lt;p&gt;There was still no good strategy, but I could now explain what &lt;code&gt;MES&lt;/code&gt; and &lt;code&gt;ES&lt;/code&gt; meant inside the dataset: a past-volume-selected contract pair from parent-symbol data, one-minute bars inside the 10:00-15:30 ET window, degraded dates excluded, and incomplete sessions retained as explicit flags.&lt;/p&gt;
&lt;p&gt;That kind of boring definition does not guarantee a good result. It provides the tools to distrust one that looks too good. But an explainable silver row is not automatically a row a model should see. The next boundary was not running a model; it was locking the columns and rows the model must never be allowed to consume.&lt;/p&gt;
&lt;h2 id=&quot;the-work-before-model-training&quot;&gt;The Work Before Model Training&lt;/h2&gt;
&lt;p&gt;Once the silver OHLCV dataset existed, it was tempting to train a model immediately. MES and ES were aligned, forward-return labels existed, and train/validation/test splits were already assigned. It looked ready for XGBoost or LightGBM. But I deliberately stopped there.&lt;/p&gt;
&lt;p&gt;Before training a model, I needed to freeze the feature-matrix contract. Which columns are features? Which columns are labels? Which columns are metadata that must never enter the model? Where is split information stored, and how is the test split kept locked? Without those boundaries, a better model score could mean real signal, or it could mean that labels or future metadata accidentally leaked into the feature set.&lt;/p&gt;
&lt;p&gt;So the next step in the quant trading system was the strict training matrix. The name means that only model-ready rows survive, but the more important meaning is that columns not meant for the model are physically separated.&lt;/p&gt;
&lt;h2 id=&quot;physically-separating-features-labels-metadata-and-splits&quot;&gt;Physically Separating Features, Labels, Metadata, And Splits&lt;/h2&gt;
&lt;p&gt;The training matrix builder writes one wide matrix, but it also writes four separated artifacts.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;training_matrix_v1.parquet
features_v1.parquet
labels_v1.parquet
metadata_v1.parquet
splits_v1.parquet
training_matrix_manifest_v1.json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;row_id&lt;/code&gt; is the stable key used to join those files back together. This is a little inconvenient, but it reduces mistakes. If model-training code reads only &lt;code&gt;features_v1.parquet&lt;/code&gt;, it is much harder to accidentally include labels or metadata as inputs.&lt;/p&gt;
&lt;p&gt;The boundary is explicit in code:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;FEATURE_COLUMNS = (
    &quot;minute_of_day_et&quot;,
    &quot;session_progress&quot;,
    &quot;mes_return_1m&quot;,
    &quot;mes_return_5m&quot;,
    &quot;mes_return_15m&quot;,
    &quot;mes_return_30m&quot;,
    &quot;mes_rsi_14m&quot;,
    &quot;mes_atr_14m_points&quot;,
    &quot;mes_vwap_session_diff_points&quot;,
    &quot;es_return_5m&quot;,
    &quot;es_rsi_14m&quot;,
    &quot;mes_es_return_5m_spread&quot;,
)

LABEL_COLUMNS = (
    &quot;gross_forward_5m_points&quot;,
    &quot;net_forward_5m_points&quot;,
    &quot;daily_net_forward_5m_rank&quot;,
    &quot;is_top_5_daily_opportunity&quot;,
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The real feature list is longer. It includes Bollinger-style z-scores, RSI, ATR, stochastic oscillators, VWAP distance, realized volatility, rolling volume normalization, SMA distance, price-action shape, and ES-reference divergence. But the main point is not the number of features. It is that the feature/label boundary is closed in code.&lt;/p&gt;
&lt;h2 id=&quot;the-strict-filter&quot;&gt;The Strict Filter&lt;/h2&gt;
&lt;p&gt;Not every silver row enters the matrix. Rows are removed if they lack a forward label, lack an ES reference, fall on degraded dates, come from incomplete sessions, or contain null feature/label values.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;create or replace temp view strict_rows as
select
  row_number() over (order by ts_event_utc) as row_id,
  ...
from read_parquet(...)
where has_forward_5m_label
  and has_es_reference
  and not is_degraded_date
  and is_complete_regular_session
  and all feature columns are non-null
  and all label columns are non-null
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This filter is conservative. It reduces coverage and removes holidays and early closes. But in the first training matrix, clarity mattered more than coverage. I can study incomplete sessions separately later. I did not want the first model or baseline to silently mix those edge cases into the main experiment.&lt;/p&gt;
&lt;p&gt;The strict matrix ended up with:&lt;/p&gt;





































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Metric&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Rows&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;331,341&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Trade dates&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;1,241&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;MES contracts&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;21&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;ES contracts&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;21&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Train rows&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;232,017&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Validation rows&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;49,128&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Test rows&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;50,196&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The important thing is not just that test rows exist. It is that they are not used yet. The test split is present in the matrix, but baseline and diagnostic tooling exclude it by default. I wanted a structure where the test set exists but remains closed.&lt;/p&gt;
&lt;h2 id=&quot;opportunity-ranking-as-the-target-shape&quot;&gt;Opportunity Ranking As The Target Shape&lt;/h2&gt;
&lt;p&gt;I did not want the first model target to be a simple directional classifier. The more important question was whether a minute was one of the few moments worth trading that day.&lt;/p&gt;
&lt;p&gt;That is why the labels include daily ranking and top-k opportunity flags, not only forward return.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;gross_forward_5m_points
gross_forward_5m_return
net_forward_5m_points
net_forward_5m_return
clears_min_net_return
daily_net_forward_5m_rank
is_top_5_daily_opportunity
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This target design shaped the later candidate policy and walk-forward baseline. Even if a model scores every minute, the trading decision should happen only a few times per day. That makes “is this row one of today’s best opportunities?” more important than “is this row slightly positive?”&lt;/p&gt;
&lt;p&gt;This also matches real execution better. A dataset may contain many rows with slightly positive five-minute returns, but after costs and slippage most are not worth trading. A good strategy should not find many positive rows. It should reject almost everything and keep only a few sufficiently good rows.&lt;/p&gt;
&lt;h2 id=&quot;feature-engineering-versus-feature-audit&quot;&gt;Feature Engineering Versus Feature Audit&lt;/h2&gt;
&lt;p&gt;It is true that this stage added many features. But the goal was not to build an impressive alpha library. It was closer to creating a minimum feature surface that made baselines and audits possible.&lt;/p&gt;
&lt;p&gt;For example, the matrix includes MES short-term returns, RSI, ATR, Bollinger z-score, and ES-reference divergence. MES is the traded instrument, while ES is the larger reference market. To test whether the five-minute return spread or RSI spread matters, the first requirement is a stable same-minute join.&lt;/p&gt;
&lt;p&gt;But more features also mean more leakage risk. So the important work at this stage was not only generating features, but making past-only behavior auditable. A later lookahead audit recomputed selected feature families from raw OHLCV prefixes and compared them against stored full-run values. The sampled mismatch count was &lt;code&gt;0&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The training matrix became the foundation for that audit.&lt;/p&gt;
&lt;h2 id=&quot;why-i-still-did-not-train-a-model&quot;&gt;Why I Still Did Not Train A Model&lt;/h2&gt;
&lt;p&gt;At this point, I could have trained a model. The matrix existed, features existed, labels existed, and splits existed. But “can” is not the same as “should.” Deterministic baselines were not good yet, cost and execution realism had not been fully examined, and no validation-looking signal had passed train and robustness gates.&lt;/p&gt;
&lt;p&gt;So the first conclusion of the strict training matrix was not model training. It was deferring model training.&lt;/p&gt;
&lt;p&gt;I think that deferral matters. In ML projects, once a training pipeline exists, running the model feels natural. In trading, the model gives convincing answers too quickly. If features are numerous and labels are noisy, some combination will look good on validation. Before trusting that combination, the data contract and baseline gates need to exist.&lt;/p&gt;
&lt;p&gt;The strict training matrix was therefore both “data for the model” and “data that prevents premature modeling.” It made model training possible, while making the decision not to train yet more explicit.&lt;/p&gt;
&lt;h2 id=&quot;the-standard-this-phase-left-behind&quot;&gt;The Standard This Phase Left Behind&lt;/h2&gt;
&lt;p&gt;After this phase, every new feature or candidate in the quant trading system had a standard to satisfy. Features must be listed in &lt;code&gt;FEATURE_COLUMNS&lt;/code&gt;. Labels must not mix into features. Metadata is for explanation and audit, not model input. Splits are chronological by trade date. The test split exists but must not be used for selection or tuning.&lt;/p&gt;
&lt;p&gt;This adds code. It is more annoying than writing one wide matrix and selecting columns casually. But that annoyance makes the research faster later, because when a result looks strange, the search space for possible mistakes is smaller.&lt;/p&gt;
&lt;p&gt;Good trading ML does not begin with model architecture. At least in this project, it began by deciding which rows and columns the model must never be allowed to see.&lt;/p&gt;</content:encoded><language>en-US</language><category>ML</category><category>Quant Trading</category><category>Databento</category><category>DuckDB</category><category>Feature Engineering</category><category>Futures</category><category>Leakage</category><category>MES</category><category>ES</category><author>ystc1247@gmail.com</author></item><item><title>[Quant Trading System 1/5] From no_trade Discipline to Reproducible Databento Ingestion</title><link>https://theokimdev.com/en/blog/post-84-quant-trading-system-01-no-trade-as-research-discipline/</link><guid isPermaLink="true">https://theokimdev.com/en/blog/post-84-quant-trading-system-01-no-trade-as-research-discipline/</guid><description>How a refusal-first research philosophy became a concrete data boundary with preserved Databento jobs, hash verification, bronze conversion, and quality reports.</description><pubDate>Sat, 25 Jul 2026 02:00:00 GMT</pubDate><content:encoded>&lt;h2 id=&quot;a-research-system-not-a-trading-bot&quot;&gt;A Research System, Not A Trading Bot&lt;/h2&gt;
&lt;p&gt;When I started this quant trading system, the first idea I wanted to make explicit was simpler than it looked. Building a trading system does not mean the system should keep finding trades. If anything, a serious research system should be very good at deciding not to trade most of the time.&lt;/p&gt;
&lt;p&gt;Algorithmic trading is a domain where it is easy to fool oneself. A small positive equity curve, one parameter that looks good on validation, or a strategy name seen on Reddit or GitHub can make it feel as if an edge has been found. In practice, most signals collapse under cost, slippage, regime shift, leakage, or day concentration. So the starting question for this project was not “which model should I train?” It was closer to “which conditions must fail before I am allowed to not train a model?”&lt;/p&gt;
&lt;p&gt;The long-term goal can still be live trading. But live-ready does not simply mean that a broker API can submit orders. The version I wanted was slower and more uncomfortable. Preserve raw market data. Separate bronze and silver artifacts. Freeze train, validation, and test splits. Keep the test split locked until the end. Make deterministic baselines fail or survive first. Only after that should ML become a legitimate next step.&lt;/p&gt;
&lt;p&gt;That is why the most important default in this system is not &lt;code&gt;buy&lt;/code&gt; or &lt;code&gt;sell&lt;/code&gt;. It is &lt;code&gt;no_trade&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id=&quot;treating-no_trade-as-a-real-result&quot;&gt;Treating no_trade As A Real Result&lt;/h2&gt;
&lt;p&gt;When writing backtests, it is easy to make only active strategies look like outcomes. A rule triggers, the code calculates performance, the best-looking rule is selected, and then the weaknesses are explained afterward. I wanted the opposite order. Doing nothing should always remain inside the candidate set, and if an active candidate cannot beat doing nothing, it should not be selected.&lt;/p&gt;
&lt;p&gt;This idea kept repeating later in the candidate policy, strategy zoo, parameter sweep, and research gate dashboard. The names changed, but the structure remained similar. Generate candidates, split train and validation, subtract costs, then check whether the candidate is better than no-trade. If it does not pass, it may still be an interesting observation, but it is not a strategy.&lt;/p&gt;
&lt;p&gt;The cost assumption was separated into its own object for the same reason. A small number can change the whole conclusion when the instrument is MES and the horizon is short.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;@dataclass(frozen=True)
class CostAssumptions:
    version: str = &quot;ibkr_mes_retail_v1&quot;
    mes_fee_per_side_usd: float = 0.62
    mes_tick_size_points: float = 0.25
    mes_contract_multiplier_usd_per_point: float = 5.0
    spread_ticks_round_turn: float = 1.0
    slippage_ticks_round_turn: float = 2.0

    @property
    def round_turn_cost_points(self) -&amp;gt; float:
        return self.execution_cost_points + self.fee_cost_points
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Under the current research assumption, round-turn cost is &lt;code&gt;0.998&lt;/code&gt; MES points, or about &lt;code&gt;$4.99&lt;/code&gt; per contract. That is not a small number. If the average edge at a five-minute horizon is only a few ticks, a slightly more realistic execution assumption can erase the strategy. So this system makes me look at cost arithmetic before I look at attractive performance charts.&lt;/p&gt;
&lt;h2 id=&quot;a-structure-for-delaying-ml&quot;&gt;A Structure For Delaying ML&lt;/h2&gt;
&lt;p&gt;From the outside, this can look like an ML project. The repository now has a training matrix and many feature families. But the actual conclusion so far is closer to “do not train a model yet.” I do not consider that a failure. A good pipeline should block bad training before it becomes expensive and convincing.&lt;/p&gt;
&lt;p&gt;The first question I wanted the system to answer was:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;At this minute, is MES one of the best long opportunities available today?
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That question is stricter than a simple directional classifier. It does not ask the model to predict every minute. It asks the system to find only the moments worth trading inside a day. That is why trade count was capped. At most five trades per day, and zero trades is allowed. This small condition changes the character of the whole system. A model that can reject trades matters more than a model that always finds activity.&lt;/p&gt;
&lt;p&gt;The initial strategy decisions in the README carried the same shape:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Tradable instrument: MES
Reference data: ES plus MES context
Main horizon: 5-minute opportunities first
Trade count: 0 to 5 trades per day
Direction: Long-only first, both long and short later
Pace: Slow and steady: baselines before complex ML
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The important part is “slow and steady.” In trading research, the moment something looks quickly discovered is often the dangerous moment. When validation PnL turns positive inside a notebook, the human already wants to believe the conclusion. This quant trading system is deliberately shaped to delay that belief.&lt;/p&gt;
&lt;h2 id=&quot;the-first-success-is-not-trading&quot;&gt;The First Success Is Not Trading&lt;/h2&gt;
&lt;p&gt;The results so far have kept that principle intact. The first deterministic candidate policy selected &lt;code&gt;no_trade&lt;/code&gt;. The strategy zoo selected &lt;code&gt;no_trade&lt;/code&gt;. A parameter sweep produced a validation-positive row, but it failed on train. Exit-label sensitivity produced a better-looking validation row, but it failed train and cost stress. The market advantage scorecard and multi-horizon diagnostics eventually returned to the same conclusion.&lt;/p&gt;
&lt;p&gt;It can feel strange to write this in a blog post. Usually, the more attractive story is “the model improved,” “the signal worked,” or “the backtest found profit.” But in real research, the most valuable result is often the opposite: discovering that an idea is not yet strong enough to trade before touching the test split, before training ML, and before starting paper trading.&lt;/p&gt;
&lt;p&gt;The current state of the quant trading system can be summarized as:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;selected_candidate_id&quot;: &quot;no_trade&quot;,
  &quot;candidate_ready_for_test_or_ml&quot;: false
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I like this result. Not because it made money, but because the system can say it is not convinced. The first job of a research pipeline is not to find a good strategy. It is to prevent a bad strategy from looking good.&lt;/p&gt;
&lt;h2 id=&quot;the-attitude-a-quant-trading-system-needs&quot;&gt;The Attitude A Quant Trading System Needs&lt;/h2&gt;
&lt;p&gt;As I worked on this project, I kept thinking about where responsibility belongs. Trading code requires more caution than a normal toy project. The numbers connect to money, and a small leakage bug or wrong cost assumption can flip the conclusion completely. So I see this repository less as “code that makes profit” and more as “code that makes judgment conservative.”&lt;/p&gt;
&lt;p&gt;A good research system should calm the researcher down. If validation is positive but train is broken, stop. If train and validation look good but cost stress breaks, stop. If cost survives but day concentration is too high, stop. Until all of those gates pass, the test split should stay closed.&lt;/p&gt;
&lt;p&gt;There is no successful model-training story yet. The more important record is why I decided not to train, why the test split stayed locked, and how &lt;code&gt;no_trade&lt;/code&gt; became a legitimate result of research discipline.&lt;/p&gt;
&lt;p&gt;Declaring that default was not enough. The first implementation work had to freeze data provenance and integrity before any strategy code could become persuasive.&lt;/p&gt;
&lt;h2 id=&quot;data-fails-before-strategies-do&quot;&gt;Data Fails Before Strategies Do&lt;/h2&gt;
&lt;p&gt;The most tempting thing to do when starting trading research is to write a strategy immediately. SMA crossover, mean reversion, order-book imbalance, anything with a name feels more interesting. But the first serious work in this quant trading system was not signal design. It was moving, preserving, validating, and documenting the data well enough that later results could be trusted.&lt;/p&gt;
&lt;p&gt;The order matters. Once market data is mixed incorrectly, everything downstream becomes contaminated. If I forget where the raw files came from, which symbology was requested, whether a batch job was split by symbol, or how conversion was performed, then even a good-looking backtest becomes hard to interpret. I cannot tell whether a strategy failed or the data was wrong.&lt;/p&gt;
&lt;p&gt;So the quant trading system separated the Git repository from the market-data root from the start.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;/path/to/quant-trading-system
/path/to/quant-trading-market-data
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The repository holds code, documentation, and small manifests. Large DBN, DBN.zst, and Parquet artifacts stay outside Git. This was not only a storage decision. It was a boundary between lightweight research history and reproducible local data.&lt;/p&gt;
&lt;h2 id=&quot;preserving-databento-batches-by-job&quot;&gt;Preserving Databento Batches By Job&lt;/h2&gt;
&lt;p&gt;The first Databento GLBX downloads were parent-symbol requests. Because they used &lt;code&gt;stype_in=parent&lt;/code&gt;, &lt;code&gt;stype_out=instrument_id&lt;/code&gt;, and &lt;code&gt;split_symbols=false&lt;/code&gt;, splitting the downloaded files into ES/MES directories too early would have hidden the original request shape. The raw layout therefore preserved the Databento job id:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;raw/databento/GLBX.MDP3/jobs/&amp;lt;job_id&amp;gt;/&amp;lt;schema&amp;gt;/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That decision appears directly in the code. &lt;code&gt;JobInfo&lt;/code&gt; reads dataset, schema, symbols, symbology, time range, encoding, compression, and split settings from the download metadata, then calculates the canonical raw location.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;@dataclass(frozen=True)
class JobInfo:
    source_dir: Path
    job_id: str
    dataset: str
    schema: str
    symbols: tuple[str, ...]
    stype_in: str
    stype_out: str
    start_ns: int
    end_ns: int
    encoding: str
    compression: str
    split_symbols: bool

    @property
    def job_schema_dir(self) -&amp;gt; Path:
        return Path(&quot;raw&quot;) / &quot;databento&quot; / self.dataset / &quot;jobs&quot; / self.job_id / self.schema
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I like this structure because it reduces later ambiguity. Raw data should not look as if it has already been cleaned or interpreted. It should continue to show which batch job it came from, which schema it used, and how the vendor request was shaped.&lt;/p&gt;
&lt;h2 id=&quot;even-copying-needs-integrity-checks&quot;&gt;Even Copying Needs Integrity Checks&lt;/h2&gt;
&lt;p&gt;Copying data looks trivial, but in this project it is the first integrity gate. &lt;code&gt;databento_pipeline.py&lt;/code&gt; does not simply copy files. It fails if file sizes differ. If a manifest hash exists, it verifies SHA-256. Existing destination files are accepted only when they match the expected size and hash.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def copy_file(src: Path, dst: Path, expected_hash: str | None, verify_hashes: bool) -&amp;gt; str:
    dst.parent.mkdir(parents=True, exist_ok=True)
    if dst.exists():
        if dst.stat().st_size != src.stat().st_size:
            raise RuntimeError(f&quot;Existing destination has different size: {dst}&quot;)
        if verify_hashes and expected_hash and sha256_file(dst) != expected_hash:
            raise RuntimeError(f&quot;Existing destination hash mismatch: {dst}&quot;)
        return &quot;skipped-existing&quot;

    tmp = dst.with_suffix(dst.suffix + &quot;.tmp&quot;)
    shutil.copy2(src, tmp)
    if verify_hashes and expected_hash and sha256_file(tmp) != expected_hash:
        tmp.unlink(missing_ok=True)
        raise RuntimeError(f&quot;Copied hash mismatch: {src} -&amp;gt; {dst}&quot;)
    tmp.replace(dst)
    return &quot;copied&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This may feel excessive at first. But market data is not something I want to treat as casually replaceable. It costs money, the request conditions matter, and vendor condition metadata can change. So the pipeline records organize, convert, and evaluate steps separately.&lt;/p&gt;
&lt;p&gt;The first workflow was:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;python common/data/databento_pipeline.py organize ... --verify-hashes
python common/data/databento_pipeline.py convert
python common/data/databento_pipeline.py evaluate
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;organize&lt;/code&gt; preserves the raw job folders. &lt;code&gt;convert&lt;/code&gt; turns DBN/DBN.zst files into bronze Parquet. &lt;code&gt;evaluate&lt;/code&gt; records row counts, anomalies, degraded dates, and storage footprint. Separating those steps makes later “the data looks strange” conversations much more precise.&lt;/p&gt;
&lt;h2 id=&quot;the-first-inventory&quot;&gt;The First Inventory&lt;/h2&gt;
&lt;p&gt;The first evaluation result gave me more reasons to be careful than to be excited.&lt;/p&gt;





























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Schema&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Raw Files&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Parquet Files&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Rows&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;mbp-1&lt;/code&gt;&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;26&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;26&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;387,252,384&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;ohlcv-1m&lt;/code&gt;&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;1&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;1&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;5,484,713&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;definition&lt;/code&gt;&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;1,565&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;1,565&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;83,657&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The total market-data root was about &lt;code&gt;18G&lt;/code&gt;: roughly &lt;code&gt;7.4G&lt;/code&gt; raw and &lt;code&gt;11G&lt;/code&gt; processed bronze Parquet. This is not a tiny toy dataset, but it is still small enough to iterate locally.&lt;/p&gt;
&lt;p&gt;The OHLCV data passed the basic sanity checks:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;invalid OHLC rows: 0
negative-volume rows: 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The MBP data immediately showed why a cleaning policy would be needed:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;crossed top-of-book rows: 859
negative-price rows: 78
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Those numbers can look small compared with &lt;code&gt;387M&lt;/code&gt; rows. But when building execution realism or microstructure features, crossed books and negative prices can distort tail metrics. For that reason, MBP did not become a feature source immediately. It later became a separate cleaning and execution-calibration track.&lt;/p&gt;
&lt;h2 id=&quot;keeping-degraded-dates-visible&quot;&gt;Keeping Degraded Dates Visible&lt;/h2&gt;
&lt;p&gt;The Databento condition metadata also produced a degraded-date list:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;2021-12-05
2022-01-02
2025-09-17
2025-09-24
2025-11-28
2026-03-15
2026-03-16
2026-04-10
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This list became important later in the silver dataset and training matrix. Degraded dates can be dropped or retained with flags, but the choice must be explicit. In this system, the silver dataset excludes degraded rows and records that decision in the report.&lt;/p&gt;
&lt;p&gt;This kind of record is what determines research quality. Model performance can come later. But if provenance collapses, even good performance cannot be explained. On the other hand, if the data boundary is boringly clear from the beginning, later strategy failures become easier to diagnose.&lt;/p&gt;
&lt;h2 id=&quot;the-result-of-this-phase&quot;&gt;The Result Of This Phase&lt;/h2&gt;
&lt;p&gt;The Databento ingestion phase did not produce alpha. There was no signal, no model, and no trading decision. What it produced was more foundational: raw-to-bronze paths, manifests, conversion reports, evaluation reports, degraded-date lists, and the first MBP anomaly surface.&lt;/p&gt;
&lt;p&gt;That became the first real base of the quant trading system. Before writing a strategy, I needed to know the shape of the data. In futures data, parent symbols, instrument ids, contract rolls, and schema choices all change the meaning of a result. “I have ES/MES data” is not enough. I need to know which schema, which symbology, which period, and which quality state.&lt;/p&gt;
&lt;p&gt;At the end of this phase, I still did not trust any trade candidate. That was a good start. A trustworthy strategy can only be built on top of trustworthy data boundaries.&lt;/p&gt;</content:encoded><language>en-US</language><category>ML</category><category>Quant Trading</category><category>Algorithmic Trading</category><category>Databento</category><category>Market Data</category><category>MES</category><category>ES</category><category>Research Pipeline</category><author>ystc1247@gmail.com</author></item><item><title>[Borg-Orchestrator 4/4] Tuning, Comparing, and Bounding a Live Kubernetes Orchestrator</title><link>https://theokimdev.com/en/blog/post-80-borg-orchestrator-07-reward-tuning-with-optuna-ray-and-rllib/</link><guid isPermaLink="true">https://theokimdev.com/en/blog/post-80-borg-orchestrator-07-reward-tuning-with-optuna-ray-and-rllib/</guid><description>The final control-system arc: accountable reward tuning, dual-cluster comparison, bounded Kubernetes actions, semantic dashboard evidence, and honest energy limits.</description><pubDate>Sat, 25 Jul 2026 01:30:00 GMT</pubDate><content:encoded>&lt;p&gt;Once the live loop existed, the problem shifted from model performance to control behavior. A controller can be accurate in one narrow sense and still behave badly if the reward function teaches it the wrong priority.&lt;/p&gt;
&lt;p&gt;That is why Optuna and Ray/RLlib entered the project. I did not want tuning to remain invisible. If reward weights changed, the dashboard needed to show the trial. If RL was too expensive to keep in the fast loop, that decision also needed to be visible. The experiment had to preserve the reasoning trail, not only the final chosen parameters.&lt;/p&gt;
&lt;p&gt;This phase was about making optimization accountable. The question was not simply which configuration wins. It was whether the search process itself could be inspected well enough to trust the behavior it produced.&lt;/p&gt;
&lt;h2 id=&quot;the-reward-problem&quot;&gt;The reward problem&lt;/h2&gt;
&lt;p&gt;The orchestrator had three competing instincts: keep tasks alive, reduce waste, and protect the queue. I represented those as Agent A, Agent B, and Agent C rewards. The combined score used weights alpha, beta, and gamma. At first that felt too simple, but it was useful because I could see how changing the weights changed controller personality.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;@dataclass(slots=True)
class Score:
    raw_rewards: dict[str, float]
    alpha: float = 1.0
    beta: float = 1.0
    gamma: float = 1.0

    @property
    def total(self) -&amp;gt; float:
        return (
            self.alpha * self.raw_rewards.get(&quot;AgentA&quot;, 0.0)
            + self.beta * self.raw_rewards.get(&quot;AgentB&quot;, 0.0)
            + self.gamma * self.raw_rewards.get(&quot;AgentC&quot;, 0.0)
        )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I did not treat this as a perfect reward design. It was a practical controller surface. If Agent A dominated everything, the system became safety-heavy. If Agent B was too attractive, the controller could chase efficiency while queue health was poor. If Agent C was too strong, admission behavior could become overly defensive.&lt;/p&gt;
&lt;h2 id=&quot;optuna-as-a-visible-search-process&quot;&gt;Optuna as a visible search process&lt;/h2&gt;
&lt;p&gt;Optuna gave me a way to tune reward weights and policy parameters without pretending I had discovered perfect constants by hand. The important part was exporting trial history and reflecting it in the dashboard. I wanted to see completed trials, best values, and selected parameters as part of the runtime, not as a separate notebook I would forget to update.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;study = optuna.create_study(
    direction=&quot;maximize&quot;,
    storage=storage,
    study_name=study_name,
    load_if_exists=True,
)

state.optuna_history(
    study_name,
    history,
    best_value=study.best_value if study.best_trial else None,
    best_params=dict(study.best_params) if study.best_trial else {},
    status=&quot;running&quot;,
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The trial values were less important than the workflow. A controller experiment should make its tuning state visible. If I publish a dashboard screenshot where Optuna is disabled, the reader should know it was a fast run. If I publish one where Optuna completed trials, the dashboard should show the best parameters and history.&lt;/p&gt;
&lt;h2 id=&quot;rayrllib-was-useful-but-expensive-to-keep-in-the-loop&quot;&gt;Ray/RLlib was useful but expensive to keep in the loop&lt;/h2&gt;
&lt;p&gt;Ray/RLlib gave me a more formal multi-agent policy path. The environment exposed AgentA, AgentB, and AgentC as separate agents with shared observation vectors and separate action spaces. That matched the architecture well, but local development made me careful. PPO bootstrap can slow down iteration, so I often kept it disabled while debugging live Kubernetes behavior.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;self.possible_agents = [&quot;AgentA&quot;, &quot;AgentB&quot;, &quot;AgentC&quot;]

self.observation_spaces = {
    &quot;AgentA&quot;: Box(low=0.0, high=1.0, shape=(6,), dtype=float),
    &quot;AgentB&quot;: Box(low=0.0, high=1.0, shape=(6,), dtype=float),
    &quot;AgentC&quot;: Box(low=0.0, high=1.0, shape=(6,), dtype=float),
}

self.action_spaces = {
    &quot;AgentA&quot;: Discrete(POLICY_SPACES[&quot;AgentA&quot;].action_count),
    &quot;AgentB&quot;: Discrete(POLICY_SPACES[&quot;AgentB&quot;].action_count),
    &quot;AgentC&quot;: Discrete(POLICY_SPACES[&quot;AgentC&quot;].action_count),
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;why-the-dashboard-mattered-here&quot;&gt;Why the dashboard mattered here&lt;/h2&gt;
&lt;p&gt;Tuning without dashboard state felt like changing knobs in a dark room. The learning/reward view let me see whether reward history, current action, agent proposals, Optuna state, and Ray status agreed with the run mode. When something looked off, I could tell whether I had a tuning problem, a disabled-feature problem, or a live data problem.&lt;/p&gt;
&lt;p&gt;This phase also made me less interested in claiming one magic policy. The project was more interesting as a visible control experiment. Sometimes I wanted the deterministic agents and referee because they were readable. Sometimes I wanted Optuna search. Sometimes I wanted RLlib policy bootstrapping. The dashboard needed to show which one was active.&lt;/p&gt;
&lt;h2 id=&quot;from-reward-tuning-to-comparative-evidence&quot;&gt;From reward tuning to comparative evidence&lt;/h2&gt;
&lt;p&gt;The system could now expose reward trajectories, optimization state, and policy state alongside live controller decisions. Reward tuning forced every objective to compete with another objective and made the tradeoffs visible instead of hiding them inside a single score.&lt;/p&gt;
&lt;p&gt;That visibility made the controller easier to inspect, but the experimental side was still being evaluated in isolation. To turn optimization into evidence, I needed the same external pressure applied to a recognizable HPA plus Karpenter-like baseline, with a clear boundary around what the local comparison could claim.&lt;/p&gt;
&lt;h2 id=&quot;how-i-launched-the-comparison&quot;&gt;How I launched the comparison&lt;/h2&gt;
&lt;p&gt;I used two Kind clusters: borg-experimental and borg-baseline. The experimental side received the orchestrator loop and bounded Agent A/B/C remediation. The baseline side received HPA and a local warm-node activation controller that approximated Karpenter-like behavior inside Kind. I kept the interpretation boundary visible because local Kind warm-node activation is not AWS Karpenter.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;OPEN_BROWSER=0 ./orchestrator_stack/scripts/start_local_dual_cluster_stack.sh

PYTHONPATH=orchestrator_stack .venv/bin/python -m orchestrator.dashboard_server   --port 8765   --event-dir orchestrator_stack/runtime/visualization-experimental

PYTHONPATH=orchestrator_stack .venv/bin/python -m orchestrator.comparison_dashboard_server   --port 8876   --experimental-kubeconfig ~/Documents/borg_orchestrator_clusters/kubeconfig-experimental   --baseline-kubeconfig ~/Documents/borg_orchestrator_clusters/kubeconfig-baseline   --experimental-event-dir orchestrator_stack/runtime/visualization-experimental
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The comparison became much more meaningful after I applied the same style of pressure to both clusters. The captured dashboard came from a severe admission-cap style stimulus: 130 replicas, explicit CPU and memory requests, and an intentionally constrained scheduling situation. I wanted the dashboard to show backlog, not just happy-path autoscaling.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;EXPERIMENTAL_KUBECONFIG=$HOME/Documents/borg_orchestrator_clusters/kubeconfig-experimental BASELINE_KUBECONFIG=$HOME/Documents/borg_orchestrator_clusters/kubeconfig-baseline PHASE_INDEX=3 EXERCISE_RANDOMIZE=1 EXERCISE_SEED=27 ./orchestrator_stack/scripts/apply_comparison_stimulus.sh
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;interpreting-the-top-dashboard&quot;&gt;Interpreting the top dashboard&lt;/h2&gt;
&lt;p&gt;The top comparison view is dense, but that is why I like it. It shows the experimental latest decision, the baseline HPA state, local Karpenter active/warm nodes, and the direct pressure comparison. In the captured run the baseline HPA is maxed at 1/1 replicas with CPU far above the target, while the baseline still has a large Pending backlog. The experimental side is not magically free of pressure, but it is visibly handling the shared stimulus differently.&lt;/p&gt;
&lt;p&gt;The most important number in the screenshot is not a single reward score. It is the backlog gap: experimental pending 3 vs baseline pending 130. The dashboard labels this as experimental better by 127, which is much more readable than showing a raw delta and forcing me to remember which direction is good.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theokimdev.com/resources/blog/post-80-borg-orchestrator-07-reward-tuning-with-optuna-ray-and-rllib/image-02-89fdaa7d.png&quot; alt=&quot;06_comparison_objectives_timeline.png&quot;&gt;&lt;/p&gt;
&lt;h2 id=&quot;why-i-added-semantic-comparison-labels&quot;&gt;Why I added semantic comparison labels&lt;/h2&gt;
&lt;p&gt;At first the comparison dashboard was basically a metric pile. That was not enough. Lower pending pods is better. Lower restarts is better. Lower dynamic power is usually better under the same controlled stimulus. Higher ready workers can be better when schedulable capacity matters. A raw negative delta does not tell the reader any of that.&lt;/p&gt;
&lt;p&gt;So I made the dashboard speak in terms like experimental better, baseline ahead, matched behavior, and objective evidence. It still exposes the numbers, but the UI tells me the direction of the claim. That matters because the dashboard is part of the argument.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;specs = [
    (&quot;Ready workers&quot;, (&quot;ready_workers&quot;,), &quot;Higher means more schedulable worker capacity is available.&quot;),
    (&quot;Pending pods&quot;, (&quot;pending_pods&quot;,), &quot;Higher pending count means queue/backlog pressure.&quot;),
    (&quot;Restarts&quot;, (&quot;pod_summary&quot;, &quot;restarts&quot;), &quot;Container restarts indicate instability or churn.&quot;),
    (
        &quot;Controlled dynamic power W&quot;,
        (&quot;controlled_resource_totals&quot;, &quot;estimated_power_watts&quot;),
        &quot;Controller-relevant utilization-derived dynamic power, excluding node idle/control-plane noise.&quot;,
    ),
]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://theokimdev.com/resources/blog/post-80-borg-orchestrator-07-reward-tuning-with-optuna-ray-and-rllib/image-03-51b0d198.png&quot; alt=&quot;07_comparison_agent_goal_matrix.png&quot;&gt;&lt;/p&gt;
&lt;p&gt;The Agent Goal Matrix was my answer to a problem I kept running into: the experimental controller and the baseline are not optimizing the same internal objective. HPA is reactive scaling logic. Karpenter is capacity provisioning. Agent A/B/C is an explicit multi-objective controller. The dashboard needed to compare observable outcomes while still saying which objective each agent was responsible for.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theokimdev.com/resources/blog/post-80-borg-orchestrator-07-reward-tuning-with-optuna-ray-and-rllib/image-04-7ceb40b5.png&quot; alt=&quot;08_comparison_pressure_charts.png&quot;&gt;&lt;/p&gt;
&lt;h2 id=&quot;controlled-resource-totals&quot;&gt;Controlled resource totals&lt;/h2&gt;
&lt;p&gt;One repair I made here was narrowing the energy/resource comparison to controlled namespaces. If I included every bit of cluster noise, the dashboard could accidentally compare observability overhead or control-plane background work. That would make the energy story weak. The comparison dashboard therefore tracks controlled CPU, memory, requests, and utilization-derived dynamic watts for the shared comparison and exercise namespaces.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theokimdev.com/resources/blog/post-80-borg-orchestrator-07-reward-tuning-with-optuna-ray-and-rllib/image-05-6e5c966e.png&quot; alt=&quot;10_comparison_full_page.png&quot;&gt;&lt;/p&gt;
&lt;p&gt;Full comparison dashboard capture. I kept the full page because the top cards, objective evidence, timelines, agent matrix, controller narrative, and interpretation boundary are meant to be read together.&lt;/p&gt;
&lt;h2 id=&quot;what-the-comparison-provedand-the-gap-it-exposed&quot;&gt;What the comparison proved—and the gap it exposed&lt;/h2&gt;
&lt;p&gt;This local comparison did not prove that my controller beats production EKS HPA plus real AWS Karpenter. That would be a much bigger claim and would require real cloud runs. What it did prove for me was narrower and still useful: I could run two local clusters under shared pressure, collect live metrics from both, apply bounded experimental remediation on one side, and show the behavioral difference in a dashboard.&lt;/p&gt;
&lt;p&gt;That was the point where the project finally matched the frustration that started it. Pressure, controller decisions, and baseline behavior were visible at the same time instead of being reconstructed from HPA and Pending pods after the fact.&lt;/p&gt;
&lt;p&gt;Side-by-side behavior, semantic labels, and controlled resource totals replaced vague confidence with evidence. They also exposed the final trust gap: a recommendation in the event stream was not proof that Kubernetes had been changed. Closing the loop required a deliberately narrow executor and a dashboard that distinguished proposal, attempted mutation, and observed outcome.&lt;/p&gt;
&lt;h2 id=&quot;bounded-action-execution&quot;&gt;Bounded action execution&lt;/h2&gt;
&lt;p&gt;The executor records the command, return code, stdout, and stderr for every operation. I wanted this because silent mutation is dangerous. If the controller claims it scaled or capped something, I need the action trail in the decision payload.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def _record_operation(
    kubeconfig: str | Path,
    operations: list[dict[str, Any]],
    description: str,
    args: list[str],
) -&amp;gt; None:
    completed = _run_kubectl(kubeconfig, args)
    operations.append({
        &quot;description&quot;: description,
        &quot;command&quot;: &quot;kubectl &quot; + &quot; &quot;.join(args),
        &quot;returncode&quot;: completed.returncode,
        &quot;stdout&quot;: completed.stdout.strip(),
        &quot;stderr&quot;: completed.stderr.strip(),
    })
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Scaling and resource changes are similarly bounded. The executor discovers deployments only by the orchestrator exerciser label, then applies a small set of allowed changes. It can scale exercise deployments, cap a comparison load generator, or restart controlled work. It is not a general-purpose cluster automation tool, and I prefer it that way.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def execute_live_kubernetes_action(
    action: AgentAction,
    kubeconfig: str | Path,
    *,
    namespace: str = DEFAULT_EXERCISE_NAMESPACE,
    workload_namespace: str = DEFAULT_WORKLOAD_NAMESPACE,
) -&amp;gt; dict[str, Any]:
    names, discovery_error = _deployment_names(kubeconfig, namespace)
    operations: list[dict[str, Any]] = []
    result: dict[str, Any] = {
        &quot;status&quot;: &quot;observed&quot;,
        &quot;namespace&quot;: namespace,
        &quot;agent&quot;: action.agent_name,
        &quot;kind&quot;: action.kind.value,
        &quot;target&quot;: action.target,
        &quot;payload&quot;: dict(action.payload),
        &quot;matched_deployments&quot;: names,
        &quot;workload_namespace&quot;: workload_namespace,
        &quot;operations&quot;: operations,
    }
    if discovery_error:
        result[&quot;status&quot;] = &quot;error&quot;
        result[&quot;error&quot;] = discovery_error
        return result
    if not names or action.kind == ActionKind.NOOP:
        result[&quot;status&quot;] = &quot;no_targets&quot; if not names else &quot;noop&quot;
        return result
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;attaching-execution-to-the-dashboard-state&quot;&gt;Attaching execution to the dashboard state&lt;/h2&gt;
&lt;p&gt;The live loop attaches the execution result to the decision payload. This changed the meaning of the dashboard. A decision could now show not just AgentA:replicate, but also whether the bounded Kubernetes action was attempted and what kubectl returned.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;if exercise_cluster:
    decision_payload[&quot;kubernetes_execution&quot;] = execute_live_kubernetes_action(
        action,
        kubeconfig_path,
        namespace=exercise_namespace,
    )

state.decision(decision_payload)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This made the dashboard less clean, but more honest. Failed operations, no target matches, and no-op states are part of the experiment. I would rather show that mess than publish a controller story that hides whether anything happened.&lt;/p&gt;
&lt;h2 id=&quot;energy-limits&quot;&gt;Energy limits&lt;/h2&gt;
&lt;p&gt;Energy was the easiest metric to overclaim, so I kept the boundary explicit. The project uses a utilization-derived dynamic power estimate, not a physical wattmeter. It is useful for comparing controlled workload pressure under the same local model. It is not a claim about exact machine power consumption.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;@dataclass(frozen=True, slots=True)
class PowerCalibration:
    idle_watts: float = 80.0
    cpu_full_scale_watts: float = 120.0
    mem_full_scale_watts: float = 60.0
    source: str = &quot;default_utilization_model&quot;

def estimate_node_power_watts(
    cpu_util: float,
    mem_util: float,
    calibration: PowerCalibration | None = None,
) -&amp;gt; float:
    calibrated = calibration or DEFAULT_POWER_CALIBRATION
    return (
        calibrated.idle_watts
        + (calibrated.cpu_full_scale_watts * _bounded_ratio(cpu_util))
        + (calibrated.mem_full_scale_watts * _bounded_ratio(mem_util))
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The comparison dashboard also separates controlled dynamic power from whole-cluster background noise. That was important because otherwise I could accidentally make the experimental side look worse or better because of unrelated observability or control-plane activity.&lt;/p&gt;
&lt;h2 id=&quot;what-finally-remained&quot;&gt;What Finally Remained&lt;/h2&gt;
&lt;p&gt;The final local system has Borg-derived features, XGBoost risk and demand models, a six-layer orchestrator, Agent A/B/C proposals, a deterministic referee, optional Optuna and Ray/RLlib adaptation, live Kubernetes exercise loops, a dual-cluster comparison setup, and dashboards that show the moving parts. It is not a production autoscaler. It is a personal cloud-systems research rig that lets me ask more precise questions than I could ask by staring at HPA events alone.&lt;/p&gt;
&lt;p&gt;The most useful thing I got from the project was not a single metric. It was a workflow: create pressure, collect live state, let the controller propose, show the conflict, compare against a baseline, and keep the interpretation boundary visible. That workflow is why the dashboards became the main part of the project.&lt;/p&gt;
&lt;p&gt;If I continue this later, the next version should run longer repeated experiments, preserve more dashboard snapshots per run, and eventually move the comparison from Kind to EKS with real Karpenter. For now, the local version is complete enough for me to explain the whole path from Kubernetes frustration to a live orchestration experiment.&lt;/p&gt;
&lt;p&gt;This final phase made the project complete enough to explain without overclaiming. It showed where prediction ended, where decision began, where Kubernetes was actually touched, and where measurement had to stay humble. That boundary is the difference between a polished demo and an engineering artifact I can stand behind.&lt;/p&gt;</content:encoded><language>en-US</language><category>ML</category><category>Energy</category><category>HPA</category><category>karpenter</category><category>Kind</category><category>Kubernetes</category><category>Observability</category><category>Optuna</category><category>Ray</category><category>Reinforcement Learning</category><author>ystc1247@gmail.com</author></item><item><title>[Borg-Orchestrator 3/4] Building the Six-Layer Orchestrator and Validating It on Live Kubernetes</title><link>https://theokimdev.com/en/blog/post-78-borg-orchestrator-05-designing-the-six-layer-orchestrator-stack/</link><guid isPermaLink="true">https://theokimdev.com/en/blog/post-78-borg-orchestrator-05-designing-the-six-layer-orchestrator-stack/</guid><description>How model signals became separate agent proposals, conservative referee decisions, dashboard-visible state, and a live Kubernetes control loop.</description><pubDate>Sat, 25 Jul 2026 01:20:00 GMT</pubDate><content:encoded>&lt;p&gt;The six-layer orchestrator was the point where the project stopped being a model pipeline and became a control-plane experiment. That change was important because scores alone do not operate systems. Decisions do.&lt;/p&gt;
&lt;p&gt;A risk score is useful only if something knows how to consume it. A demand estimate matters only if it changes how capacity, admission, or safety tradeoffs are evaluated. Once I accepted that, the project needed boundaries: where raw observations enter, where models speak, where agents propose, where conflict is resolved, and where the UI explains the decision.&lt;/p&gt;
&lt;p&gt;This phase was my attempt to make that control flow explicit enough to criticize. If the system made a bad decision, I wanted to know which layer produced the mistake instead of blaming a vague black box.&lt;/p&gt;
&lt;h2 id=&quot;reading-this-dashboard&quot;&gt;Reading this dashboard&lt;/h2&gt;
&lt;p&gt;This screenshot is close to the mental picture I wanted from the beginning. At the top, the dashboard shows the active controller state instead of making me dig through logs. In this run the max risk is 0.950 and the selected decision is AgentA:replicate against borg-experimental-worker2. That means the safety agent won the referee decision because the risk path crossed the high threshold.&lt;/p&gt;
&lt;p&gt;The live orchestration flow is also important. It shows that the system is not one model call. The path is: Kubernetes cluster snapshot, workload exerciser stimulus, XGBoost risk/demand inference, Agent A/B/C proposals, referee selection, reward scoreboard, and event emission. I wanted this displayed because otherwise it is too easy to talk about the orchestrator as if it were a single black box.&lt;/p&gt;
&lt;h2 id=&quot;agent-responsibilities&quot;&gt;Agent responsibilities&lt;/h2&gt;
&lt;p&gt;The agent split was intentionally blunt. Agent A is safety. Agent B is efficiency. Agent C is admission and queue pressure. I did not want one giant policy object that quietly mixed all objectives. Separate agents made conflict visible, and visible conflict made the dashboard more honest.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;@dataclass(slots=True)
class AgentARiskMitigator:
    priority: int = 1

    def act(self, obs: Observation) -&amp;gt; AgentAction:
        if not obs.p_fail_scores:
            return AgentAction(&quot;AgentA&quot;, ActionKind.NOOP, score=0.0, priority=self.priority)
        node_id, score = max(obs.p_fail_scores.items(), key=lambda kv: kv[1])
        if score &amp;gt;= 0.83:
            return AgentAction(&quot;AgentA&quot;, ActionKind.REPLICATE, target=node_id, score=float(score), priority=self.priority)
        if score &amp;gt;= 0.7:
            return AgentAction(&quot;AgentA&quot;, ActionKind.MIGRATE, target=node_id, score=float(score), priority=self.priority)
        if score &amp;gt;= 0.5:
            return AgentAction(&quot;AgentA&quot;, ActionKind.THROTTLE, target=node_id, score=float(score), priority=self.priority)
        return AgentAction(&quot;AgentA&quot;, ActionKind.NOOP, score=float(score), priority=self.priority)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Agent B and Agent C were equally direct. Agent B looked for low demand and proposed power-state, DVFS, or memory balloon actions. Agent C looked at queue length, SLA pressure, and overloaded nodes, then proposed admission or resource caps. I liked this split because it matched the operational tension I had seen around Kubernetes: safety, efficiency, and admission often pull in different directions.&lt;/p&gt;
&lt;h2 id=&quot;the-referee-was-the-uncomfortable-part&quot;&gt;The referee was the uncomfortable part&lt;/h2&gt;
&lt;p&gt;Once the agents existed, I needed to decide what happened when they disagreed. I did not want silent winner-takes-all behavior. The referee had to produce a selected action and a rationale, plus an overridden map so the dashboard could show what lost and why.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;SAFETY_ACTIONS = {ActionKind.MIGRATE, ActionKind.REPLICATE, ActionKind.THROTTLE}
EFFICIENCY_ACTIONS = {ActionKind.POWER_STATE, ActionKind.DVFS, ActionKind.MEMORY_BALLOON}
PROTECTIVE_ADMISSION_DECISIONS = {&quot;queue&quot;, &quot;reject&quot;, &quot;deprioritize&quot;}

if agent_a_safety is not None:
    return RefereeDecision(
        action=agent_a_safety,
        rationale=f&quot;agent-a {safety_label} preempts lower-priority actions&quot;,
        overridden=overridden,
    )

if restrictive_admission is not None:
    return RefereeDecision(
        action=restrictive_admission,
        rationale=&quot;agent-c admission protection preempts efficiency actions&quot;,
        overridden=overridden,
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The referee policy is conservative on purpose. Safety can preempt efficiency. Admission protection can preempt non-safety actions. Efficiency gets a chance when the system is not screaming. This made the controller less flashy, but much easier to reason about.&lt;/p&gt;
&lt;h2 id=&quot;why-the-dashboard-became-central&quot;&gt;Why the dashboard became central&lt;/h2&gt;
&lt;p&gt;At this point I realized the dashboard was not a final reporting layer. It was part of the debugging process. If Agent A was selected, I needed to see the risk score and target. If Agent B lost, I needed to see the demand estimate. If Agent C proposed admission control, I needed to see queue length. If the active stage was complete but the cluster was still unhealthy, I needed the dashboard to show that too.&lt;/p&gt;
&lt;p&gt;That is why I kept adding state fields: current decision, proposal list, reward summary, stage progress, artifact list, Optuna history, Ray status, cluster snapshot, event stream. It made the UI dense, but the project needed density. A polished dashboard that hides the control path would not have served the experiment.&lt;/p&gt;
&lt;h2 id=&quot;an-inspectable-architecture-still-needed-reality&quot;&gt;An inspectable architecture still needed reality&lt;/h2&gt;
&lt;p&gt;The basic six-layer stack could train or load XGBoost brains, run agents, resolve conflicts, score rewards, and emit dashboard state. It finally had the shape I wanted: a cloud-systems experiment where the model had to live inside a visible controller.&lt;/p&gt;
&lt;p&gt;The stack made responsibility visible by separating prediction, proposal, arbitration, execution, and explanation into inspectable parts. That turned the project from a model demo into an engineering system, but it was still a controlled design. The next test was whether the same path could survive messy timing and incomplete signals from a live Kubernetes cluster.&lt;/p&gt;
&lt;h2 id=&quot;the-live-loop&quot;&gt;The live loop&lt;/h2&gt;
&lt;p&gt;The live loop is where the control-plane pieces came together. It collects a Kubernetes snapshot, creates an Observation, asks each agent for a proposal, resolves the proposals through the referee, emits the decision, steps the backend, updates reward state, and then writes the dashboard state files.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;proposals = [agent.act(obs) for agent in agents]
action = resolve(proposals)
reason = _decision_reason(snapshot, action.agent_name, action.kind.value)
action_label = _action_label(action)

decision_payload = {
    &quot;agent&quot;: action.agent_name,
    &quot;kind&quot;: action.kind.value,
    &quot;target&quot;: action.target,
    &quot;payload&quot;: dict(action.payload),
    &quot;action_label&quot;: action_label,
    &quot;score&quot;: float(action.score),
    &quot;proposal_count&quot;: len(proposals),
    &quot;proposals&quot;: [
        {&quot;agent&quot;: p.agent_name, &quot;kind&quot;: p.kind.value, &quot;target&quot;: p.target, &quot;score&quot;: float(p.score)}
        for p in proposals
    ],
}

state.decision(decision_payload)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The detail I cared about here was the proposal list. A dashboard that only shows the final action hides the conflict. When the final action is AgentA:replicate, I still want to know what AgentB and AgentC wanted. Otherwise I cannot tell whether the system was calmly aligned or whether safety just overrode everything.&lt;/p&gt;
&lt;h2 id=&quot;running-the-live-kubernetes-path&quot;&gt;Running the live Kubernetes path&lt;/h2&gt;
&lt;p&gt;The local run command became long because I wanted the loop to be explicit: which config, which event directory, which kubeconfig, how often to sample, whether to tune or skip tuning, and whether to apply exercise stimuli.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;PYTHONPATH=orchestrator_stack .venv/bin/python orchestrator_stack/run.py live-kubernetes-run   --config orchestrator_stack/config/orchestrator.example.json   --event-dir orchestrator_stack/runtime/visualization-experimental   --kubeconfig &quot;$HOME/Documents/borg_orchestrator_clusters/kubeconfig-experimental&quot;   --interval-seconds 3   --max-iterations 5   --namespace-prefixes borg-orchestrator-exercise,borg-comparison-workload,default,test-   --trace-out orchestrator_stack/runtime/visualization-experimental/live_kubernetes_trace.json   --trials 3   --prometheus-base-url http://127.0.0.1:19090   --no-policy --no-tune   --exercise-cluster   --exercise-namespace borg-orchestrator-exercise   --exercise-interval-iterations 1   --exercise-randomize --exercise-seed 31
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I often ran in fast mode with tuning disabled because I wanted to debug live behavior without waiting for policy training. That is why some dashboard captures show Ray and Optuna as disabled. That was not a bug in the dashboard. It was the run mode I selected to get lively Kubernetes state quickly.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theokimdev.com/resources/blog/post-78-borg-orchestrator-05-designing-the-six-layer-orchestrator-stack/image-02-d8deddcb.png&quot; alt=&quot;04_experimental_full_page.png&quot;&gt;&lt;/p&gt;
&lt;h2 id=&quot;what-the-full-dashboard-showed-me&quot;&gt;What the full dashboard showed me&lt;/h2&gt;
&lt;p&gt;The full dashboard helped me catch mismatches between the story I wanted to tell and the state the system was actually producing. If the active stage was complete but the event log had no cluster samples, something was wrong. If reward changed but decisions did not, I needed to inspect the backend. If the exerciser was active but queue pressure stayed flat, the Kubernetes stimulus was probably not doing what I thought.&lt;/p&gt;
&lt;p&gt;In the captured run, the event sequence is visibly not a single static recommendation. It moves through AgentA replicate, AgentB memory balloon proposals, AgentC admission/deprioritize behavior, and then another AgentA replicate as risk and SLA state change. That was the kind of liveness I wanted: not animation for its own sake, but traceable state changes.&lt;/p&gt;
&lt;h2 id=&quot;the-awkward-live-data-problems&quot;&gt;The awkward live-data problems&lt;/h2&gt;
&lt;p&gt;Live Kubernetes data introduced boring but real problems. Metrics Server can lag. Prometheus port-forwards can fail. A Kind cluster can behave differently from EKS. Pending pods can be caused by deliberate unschedulable node selectors, resource pressure, or controller choices. I had to make the dashboard expose enough detail to interpret those cases instead of flattening everything into a single score.&lt;/p&gt;
&lt;p&gt;That is why the dashboard kept both raw-ish cluster state and interpreted decision state. The raw state tells me what Kubernetes is showing. The decision state tells me what the orchestrator thinks it should do. The interesting debugging happens when those two disagree.&lt;/p&gt;
&lt;h2 id=&quot;what-live-validation-established&quot;&gt;What live validation established&lt;/h2&gt;
&lt;p&gt;The live experimental dashboard could keep up with the local Kubernetes loop. It was not yet a fair baseline comparison, but it was the first time the project felt like a control system instead of a batch experiment.&lt;/p&gt;
&lt;p&gt;The live path made the project harder to explain but easier to trust. It introduced messy timing, missing signals, and awkward state transitions, which is exactly why it mattered. Real systems rarely validate themselves under clean laboratory conditions.&lt;/p&gt;</content:encoded><language>en-US</language><category>ML</category><category>AIOpsLab</category><category>Borg</category><category>Kind</category><category>Kubernetes</category><category>Multi Agent Systems</category><category>Observability</category><category>Prometheus</category><author>ystc1247@gmail.com</author></item><item><title>[Borg-Orchestrator 2/4] Repairing Borg Data and Interpreting Multi-Horizon XGBoost Signals</title><link>https://theokimdev.com/en/blog/post-76-borg-orchestrator-03-schema-drift-broken-labels-and-the-repair-pipeline/</link><guid isPermaLink="true">https://theokimdev.com/en/blog/post-76-borg-orchestrator-03-schema-drift-broken-labels-and-the-repair-pipeline/</guid><description>How schema validation, temporal label repair, multi-horizon XGBoost models, and explicit action thresholds established a trustworthy control signal.</description><pubDate>Sat, 25 Jul 2026 01:10:00 GMT</pubDate><content:encoded>&lt;p&gt;The first time the pipeline looked stable, I did not trust it. That instinct was useful. Stable output is not the same thing as correct output, especially when the data is large enough to hide small mistakes.&lt;/p&gt;
&lt;p&gt;The dangerous failures were not the loud crashes. Loud crashes stop the run and force attention. The failures that worried me were quieter: nullable fields interpreted too casually, terminal events attached to the wrong temporal direction, schema changes that still produced parquet files, and labels that looked plausible while poisoning the training set.&lt;/p&gt;
&lt;p&gt;This phase was about treating data repair as part of the system, not as cleanup after the real work. If the project was going to make decisions from model output, label integrity had to become an explicit engineering concern.&lt;/p&gt;
&lt;h2 id=&quot;schema-drift-as-a-real-error&quot;&gt;Schema drift as a real error&lt;/h2&gt;
&lt;p&gt;The orchestrator side eventually had to ingest grouped traces, synthetic traces, and live Kubernetes snapshots. That made schema validation more important than I expected. I wrote validators that treated drift as a named problem instead of letting Python fail somewhere later with a vague KeyError or TypeError.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def _parse_int(value: Any, *, field: str, row_index: int) -&amp;gt; int:
    try:
        return int(value)
    except (TypeError, ValueError) as exc:
        raise ValueError(
            f&quot;Schema drift: row[{row_index}] field {field!r} must be integer-like, got {value!r}.&quot;
        ) from exc

def _validate_grouped_row(row: dict[str, Any], row_index: int) -&amp;gt; None:
    if &quot;timestamp&quot; not in row:
        raise ValueError(f&quot;Schema drift: grouped row[{row_index}] missing timestamp key.&quot;)
    _parse_int(row[&quot;timestamp&quot;], field=&quot;timestamp&quot;, row_index=row_index)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That style made the code a little less pretty, but it made failures readable. When I was running long local experiments, readable failure messages mattered more than elegance.&lt;/p&gt;
&lt;h2 id=&quot;the-label-bug-i-kept-checking-for&quot;&gt;The label bug I kept checking for&lt;/h2&gt;
&lt;p&gt;The most important label check was temporal. A row should not become positive because of a terminal event that had already happened before the usage window ended. That sounds obvious, but when the dataset is built from separate usage and event sources, it is easy to accidentally make future knowledge leak into the feature row.&lt;/p&gt;
&lt;p&gt;The repair was to keep the time-to-terminal calculation visible and to preserve a separate flag for terminal events that were already before the window end.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;(pl.col(&quot;is_failure_terminal_event&quot;)
 &amp;amp; pl.col(&quot;time_to_terminal_event_us&quot;).is_not_null()
 &amp;amp; (pl.col(&quot;time_to_terminal_event_us&quot;) &amp;gt;= 0)
 &amp;amp; (pl.col(&quot;time_to_terminal_event_us&quot;) &amp;lt;= horizon_us)
).alias(&quot;failure_within_horizon&quot;)

(pl.col(&quot;final_event_type&quot;).is_not_null()
 &amp;amp; (pl.col(&quot;last_event_time&quot;) &amp;lt; pl.col(&quot;end_time&quot;))
).alias(&quot;terminal_event_before_window_end&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I did not want to delete that second flag just because it was not the training target. It was useful during debugging because it gave me a way to see when terminal state was attached to a row in a suspicious way.&lt;/p&gt;
&lt;h2 id=&quot;repair-reports-not-just-repaired-code&quot;&gt;Repair reports, not just repaired code&lt;/h2&gt;
&lt;p&gt;Another thing I changed in this phase was how I recorded progress. If I only fixed scripts and moved on, I would forget why a repair existed. The reports in the repository became a lightweight lab notebook: what was broken, what was repaired, what remained risky, and which generated artifacts were expected after the next run.&lt;/p&gt;
&lt;p&gt;That habit helped later when the project split into several tracks: baseline forecaster, advanced XGBoost, orchestrator stack, Optuna/Ray tuning, local dual-cluster comparison, and dashboard work. Without progress reports, those tracks would have blurred together.&lt;/p&gt;
&lt;h2 id=&quot;the-repair-loop-was-slow-but-necessary&quot;&gt;The repair loop was slow but necessary&lt;/h2&gt;
&lt;p&gt;The least fun part was re-running work after a repair. A schema fix could force regenerated parquet. A label fix could force retraining. A feature change could make older metrics no longer comparable. It felt slow, but it was better than building a dashboard on top of a rotten label definition.&lt;/p&gt;
&lt;p&gt;I had a simple rule during this part: if I could not explain what a row meant, I did not want to train on it. That rule sounds a bit dramatic, but it kept the project grounded. Later, when I watched dashboards show risk, queue length, decisions, and reward traces, I knew those values came from contracts I had already fought with.&lt;/p&gt;
&lt;h2 id=&quot;the-repair-changed-the-trust-boundary&quot;&gt;The repair changed the trust boundary&lt;/h2&gt;
&lt;p&gt;After the repair pass, I was more comfortable moving to advanced modeling. The target label had a clearer temporal meaning, grouped trace ingestion had stricter validation, and artifact layout was less ambiguous. I still expected problems, but the project was no longer held together by hope and print statements.&lt;/p&gt;
&lt;p&gt;The project now felt less like a one-off classifier and more like a systems experiment. The data contract, not the model, had become the center of gravity. A successful run had to produce not only data, but evidence that the data deserved to be used.&lt;/p&gt;
&lt;p&gt;Only then did advanced modeling become worth attempting. The next risk was no longer silent corruption; it was overtrusting a model whose validation metric looked good but whose output had not yet acquired an operational meaning.&lt;/p&gt;
&lt;h2 id=&quot;multi-horizon-thinking&quot;&gt;Multi-horizon thinking&lt;/h2&gt;
&lt;p&gt;The first baseline target was basically: failure within the configured horizon. The advanced track expanded that idea. In a controller setting, the horizon matters. A risk that appears very near-term should not be interpreted the same way as a weaker, longer-horizon risk. I wanted the model artifacts and reports to keep that distinction visible.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;python scripts/build_advanced_xgboost_dataset.py --clusters b,c,d,e,f,g
python scripts/train_advanced_xgboost.py --clusters b,c,d,e,f,g
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The important part was not the command itself. The important part was isolating the advanced workspace so the generated features, tuned models, and reports could evolve without overwriting the baseline path.&lt;/p&gt;
&lt;h2 id=&quot;the-model-code-stayed-intentionally-plain&quot;&gt;The model code stayed intentionally plain&lt;/h2&gt;
&lt;p&gt;For the orchestrator-side XGBoost brains, I kept the training function compact. Risk was binary. Demand was regression. Both used histogram tree building and saved model files that the live loop could load later.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def train_safety_model(x: np.ndarray, y: np.ndarray, out_path: str | Path) -&amp;gt; Path:
    xgb = _require_xgboost()
    dtrain = xgb.DMatrix(x, label=y)
    params = {
        &quot;max_depth&quot;: 6,
        &quot;eta&quot;: 0.06,
        &quot;subsample&quot;: 0.9,
        &quot;colsample_bytree&quot;: 0.9,
        &quot;objective&quot;: &quot;binary:logistic&quot;,
        &quot;eval_metric&quot;: &quot;aucpr&quot;,
        &quot;tree_method&quot;: &quot;hist&quot;,
    }
    booster = xgb.train(params=params, dtrain=dtrain, num_boost_round=300)
    out = Path(out_path)
    out.parent.mkdir(parents=True, exist_ok=True)
    booster.save_model(str(out))
    return out
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This was not the most exotic part of the project, but it was one of the most important. I needed a model path that was easy to reproduce and easy to inspect. The orchestration system would already be complex enough. I did not want the model-loading layer to become another mystery.&lt;/p&gt;
&lt;h2 id=&quot;feature-importance-was-useful-but-not-enough&quot;&gt;Feature importance was useful, but not enough&lt;/h2&gt;
&lt;p&gt;Feature importance reports helped me check whether the model was paying attention to plausible signals: utilization, requests, rolling deltas, scheduling and priority fields, and machine-level context. But I was careful not to treat feature importance as proof. It is a debugging lens, not a guarantee that the model will behave well inside a controller.&lt;/p&gt;
&lt;p&gt;The more useful question became: can the model produce risk and demand values that are stable enough for an agent to reason about? A model can have an acceptable validation metric and still behave poorly inside a control loop if its output jumps too much or if the thresholds create constant action flipping.&lt;/p&gt;
&lt;h2 id=&quot;thresholds-became-part-of-the-architecture&quot;&gt;Thresholds became part of the architecture&lt;/h2&gt;
&lt;p&gt;The agent thresholds were simple at first, but they gave the risk model a behavioral meaning. A high risk score could become replicate. A medium risk score could become migrate or throttle. Low risk became no-op. That translation from probability to action was where the ML experiment started becoming an orchestration experiment.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;node_id, score = max(obs.p_fail_scores.items(), key=lambda kv: kv[1])
if score &amp;gt;= 0.83:
    return AgentAction(&quot;AgentA&quot;, ActionKind.REPLICATE, target=node_id, score=float(score))
if score &amp;gt;= 0.7:
    return AgentAction(&quot;AgentA&quot;, ActionKind.MIGRATE, target=node_id, score=float(score))
if score &amp;gt;= 0.5:
    return AgentAction(&quot;AgentA&quot;, ActionKind.THROTTLE, target=node_id, score=float(score))
return AgentAction(&quot;AgentA&quot;, ActionKind.NOOP, score=float(score))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I changed my mind several times about these thresholds. If they were too low, the controller looked dramatic and noisy. If they were too high, the risk model became decorative. I eventually treated them as controller parameters rather than sacred ML outputs.&lt;/p&gt;
&lt;h2 id=&quot;the-awkward-part-of-interpreting-results&quot;&gt;The awkward part of interpreting results&lt;/h2&gt;
&lt;p&gt;The advanced model reports were useful, but they did not answer the bigger systems question by themselves. AUCPR, precision at top-k, calibration bins, and feature importance all helped me decide whether the model was sane. They did not tell me whether a cluster would be better off when an agent used those scores.&lt;/p&gt;
&lt;p&gt;That realization pushed the project toward the six-layer orchestrator. I needed a place where model outputs became observations, observations became proposals, proposals conflicted, and the dashboard showed the conflict. Without that, I would only have a pile of model files and a weak story.&lt;/p&gt;
&lt;h2 id=&quot;from-model-scores-to-control-signals&quot;&gt;From model scores to control signals&lt;/h2&gt;
&lt;p&gt;The advanced XGBoost path produced enough model infrastructure to feed a controller. The risk model and demand model were no longer isolated experiments; they were ready to become the brains layer in a larger system.&lt;/p&gt;
&lt;p&gt;That larger system was where the project could become what I originally wanted while staring at Kubernetes: not just prediction, but visible control behavior.&lt;/p&gt;
&lt;p&gt;The important move was from model training toward model interpretation. A metric can be read in isolation; an engineer still has to explain what it should and should not be allowed to decide.&lt;/p&gt;</content:encoded><language>en-US</language><category>ML</category><category>Borg</category><category>Data Engineering</category><category>Failure Prediction</category><category>Kubernetes</category><category>polars</category><category>XGBoost</category><author>ystc1247@gmail.com</author></item><item><title>[Borg-Orchestrator 1/4] From Kubernetes Frustration to a Reproducible Failure Forecaster</title><link>https://theokimdev.com/en/blog/post-75-borg-orchestrator-01-from-kubernetes-frustration-to-a-borg-failure-forecaster/</link><guid isPermaLink="true">https://theokimdev.com/en/blog/post-75-borg-orchestrator-01-from-kubernetes-frustration-to-a-borg-failure-forecaster/</guid><description>How an operational Kubernetes frustration became a reproducible Borg data path, failure label, baseline forecaster, and inspectable XGBoost workspace.</description><pubDate>Sat, 25 Jul 2026 01:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Most infrastructure projects that are worth finishing begin as a vague discomfort before they become architecture. This one started with a very specific kind of frustration: I could watch Kubernetes react, but I could not always explain the timing before the damage became visible.&lt;/p&gt;
&lt;p&gt;At the company where I was working, I spent a lot of time around EKS, Kubernetes traces, GitHub changes, kubectl output, HPA behavior, and Karpenter behavior. From far away the cluster could look calm, but the operational story underneath was rarely calm. Workloads pushed CPU, HPA reacted later than I wanted, Karpenter added capacity on its own cadence, pods sat Pending, and the evidence lived across dashboards, terminal output, and small fragments of event history.&lt;/p&gt;
&lt;p&gt;The first version of this project was an attempt to turn that discomfort into a measurable system. Traces would go in, failure or demand risk would come out, agents would propose actions, a referee would select one, Kubernetes would react, and a dashboard would show the whole chain without hiding the uncomfortable parts.&lt;/p&gt;
&lt;h2 id=&quot;the-first-shape-of-the-idea&quot;&gt;The first shape of the idea&lt;/h2&gt;
&lt;p&gt;The earliest version of the project was much smaller than what it became. I wanted a failure forecaster. Given resource usage and terminal event information, could I score which tasks were likely to fail within a future window? That question was attractive because it was technical enough to be concrete, but close enough to Kubernetes operations to matter.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Borg traces would provide task and machine behavior over time.&lt;/li&gt;
&lt;li&gt;Feature windows would summarize CPU, memory, request, priority, scheduling class, and recent deltas.&lt;/li&gt;
&lt;li&gt;A target label would mark terminal failure within a fixed horizon.&lt;/li&gt;
&lt;li&gt;A baseline model would expose the first obvious risk score.&lt;/li&gt;
&lt;li&gt;Later, that risk score could become an input to an orchestrator instead of staying as a notebook metric.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The part I underestimated was the word later. A model alone is very easy to overvalue. If I only trained a classifier and printed AUCPR, the project would have stopped at a static ML experiment. The more I worked on it, the more I cared about the path from model output to control decision. That is why the project eventually became dashboard-heavy. The dashboard was not decoration. It was the only way I could keep myself honest about what the system was actually doing.&lt;/p&gt;
&lt;h2 id=&quot;directory-layout-before-modeling&quot;&gt;Directory layout before modeling&lt;/h2&gt;
&lt;p&gt;I kept the large data outside the repository from the beginning. The Borg data was too large and too mechanical to belong in git, and I did not want generated parquet files mixed into the source tree. The repo became the code and documentation layer. The external directories became the raw, processed, model, and report layer.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;mkdir -p ~/Documents/borg_data
mkdir -p ~/Documents/borg_processed
mkdir -p ~/Documents/borg_xgboost_workspace/{raw,processed,models,reports,runtime,config}

export BORG_DATA_DIR=&quot;$HOME/Documents/borg_data&quot;
export BORG_PROCESSED_DIR=&quot;$HOME/Documents/borg_processed&quot;
export BORG_XGBOOST_WORKSPACE=&quot;$HOME/Documents/borg_xgboost_workspace&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That split was a small decision, but it saved me later. Once I had baseline forecaster data, advanced XGBoost features, orchestrator runtime traces, Optuna reports, and dashboard screenshots, I needed to know which artifact belonged to which track. Otherwise every failed run would have left behind files with unclear meaning.&lt;/p&gt;
&lt;h2 id=&quot;why-borg-traces-instead-of-only-kubernetes-metrics&quot;&gt;Why Borg traces instead of only Kubernetes metrics&lt;/h2&gt;
&lt;p&gt;I could have started from live Kubernetes metrics only. That would have been more immediately familiar: pod CPU, memory, HPA desired replicas, Pending pods, node readiness. But I wanted more than a reactive demo. Borg traces gave me a dataset where machine/task behavior could be processed offline, repaired, and replayed. They also forced me to think in terms of terminal events and prediction horizons, which is exactly the thing I felt was missing when watching Kubernetes react after the fact.&lt;/p&gt;
&lt;p&gt;The first baseline command was intentionally boring. I wanted reproducible CLI entrypoints before I wanted clever architecture.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;python scripts/make_dataset.py
python scripts/make_forecaster_dataset.py
python scripts/train_forecaster_baseline.py
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;the-first-target-label&quot;&gt;The first target label&lt;/h2&gt;
&lt;p&gt;The first important label was simple on paper: mark a row positive if the task has a failure terminal event within the prediction horizon. The actual implementation was where the first real care was needed. It had to avoid terminal events that had already happened before the usage window ended, and it had to keep the time arithmetic explicit.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;dataset = pl.scan_parquet(dataset_file(cluster_id))

frame = (
    dataset
    .sort([&quot;collection_id&quot;, &quot;instance_index&quot;, &quot;end_time&quot;])
    .with_columns([
        (pl.col(&quot;last_event_time&quot;) - pl.col(&quot;end_time&quot;)).alias(&quot;time_to_terminal_event_us&quot;),
        pl.col(&quot;final_event_type&quot;).is_in(failure_event_types).alias(&quot;is_failure_terminal_event&quot;),
    ])
    .with_columns([
        (
            pl.col(&quot;is_failure_terminal_event&quot;)
            &amp;amp; pl.col(&quot;time_to_terminal_event_us&quot;).is_not_null()
            &amp;amp; (pl.col(&quot;time_to_terminal_event_us&quot;) &amp;gt;= 0)
            &amp;amp; (pl.col(&quot;time_to_terminal_event_us&quot;) &amp;lt;= horizon_us)
        ).alias(&quot;failure_within_horizon&quot;)
    ])
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the kind of code that looks plain, but it decides whether the entire project is meaningful. If the label leaks future state incorrectly, every later model and dashboard can look impressive while being nonsense. I had to keep reminding myself that the project was only as good as the boring label plumbing.&lt;/p&gt;
&lt;h2 id=&quot;from-prediction-to-a-visible-control-question&quot;&gt;From prediction to a visible control question&lt;/h2&gt;
&lt;p&gt;At this point I was not thinking in terms of a final dashboard yet. I was thinking in terms of an experiment that could eventually answer questions like: if risk rises before a visible failure, what should the controller do? If demand is low, can efficiency actions happen without stepping on safety? If queue health degrades, should admission control override power savings? And if all of that happens, can I see the actual reasoning and not just the final number?&lt;/p&gt;
&lt;p&gt;The Kubernetes operating friction came first. The model was my first attempt to make that frustration measurable. The dashboard came later because I eventually stopped trusting invisible experiments.&lt;/p&gt;
&lt;p&gt;That shift turned an operational feeling into an engineering question. The project was no longer about whether I could train a model on a public trace dataset. It was about whether prediction could become a visible, inspectable control signal.&lt;/p&gt;
&lt;p&gt;The forecaster idea became useful only after that trust boundary was implemented in the data path. With raw and generated artifacts separated, the next job was to join usage, event, and machine evidence without letting a successful Parquet write masquerade as correctness.&lt;/p&gt;
&lt;h2 id=&quot;joining-usage-events-and-machines&quot;&gt;Joining usage, events, and machines&lt;/h2&gt;
&lt;p&gt;The first real dataset was a joined table. Usage rows carried the time-window behavior. Events carried terminal state and task lifecycle evidence. Machines added context. I used Polars because I wanted streaming scans over parquet instead of loading everything into memory and hoping for the best.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;usage = pl.scan_parquet(str(usage_path))
events = pl.scan_parquet(str(events_path))
machines = pl.scan_parquet(str(machines_path))

dataset = (
    usage
    .join(events, on=[&quot;collection_id&quot;, &quot;instance_index&quot;], how=&quot;left&quot;, suffix=&quot;_event&quot;)
    .join(machines, on=&quot;machine_id&quot;, how=&quot;left&quot;, suffix=&quot;_machine&quot;)
    .collect(engine=&quot;streaming&quot;)
)

dataset.write_parquet(output_path)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This join was one of those points where I had to stay suspicious. A successful parquet write did not mean the dataset was correct. I checked row counts, positive label rates, missing machine ids, and whether terminal event timing still made sense after the join.&lt;/p&gt;
&lt;h2 id=&quot;why-i-did-not-jump-straight-to-advanced-features&quot;&gt;Why I did not jump straight to advanced features&lt;/h2&gt;
&lt;p&gt;I was tempted to build the complicated feature set immediately. Rolling windows, deltas, request ratios, priority encodings, cluster-level pressure, all of that was more interesting than verifying joins. But doing that too early made every later error harder to isolate. So I kept the first track intentionally plain: produce the joined dataset, produce a forecaster frame, train a baseline, inspect the predictions.&lt;/p&gt;
&lt;p&gt;The baseline was not meant to be the final model. It was a sanity instrument. If the baseline could not produce a plausible risk ranking, then the advanced model would only hide the failure behind more parameters.&lt;/p&gt;
&lt;h2 id=&quot;the-first-baseline-training-loop&quot;&gt;The first baseline training loop&lt;/h2&gt;
&lt;p&gt;The baseline training script standardized the forecaster frames, split validation data, trained a model, and wrote the outputs I knew I would want later: validation predictions and top risk alerts. I cared about those files because they let me inspect actual rows instead of only reading aggregate metrics.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;python scripts/train_forecaster_baseline.py   --clusters b,c,d,e,f,g   --feature-profile baseline
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The useful outputs were not just the model file. The validation predictions parquet gave me a ranked surface to inspect. The top-risk alerts parquet gave me the first version of what later became the control-plane idea: risk is more useful when it can be attached to an action candidate.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;validation_predictions.parquet: scored validation rows, sorted later by risk_score.&lt;/li&gt;
&lt;li&gt;top_risk_alerts.parquet: high-risk rows kept for inspection.&lt;/li&gt;
&lt;li&gt;cluster forecaster parquet files: per-cluster feature/label frames.&lt;/li&gt;
&lt;li&gt;metrics text and reports: enough context to compare later runs.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;the-first-friction-heavy-trial-and-error-loop&quot;&gt;The first friction-heavy trial-and-error loop&lt;/h2&gt;
&lt;p&gt;The difficult part was that every small pipeline change could invalidate something downstream. I would fix an optional column, then a cluster would produce different feature coverage. I would change the label horizon, then the positive rate would move. I would repair a schema issue, then model results would look better, but I still had to check whether the improvement was real or just caused by a different filtered dataset.&lt;/p&gt;
&lt;p&gt;This is where I started building the habit that carried through the rest of the project: every artifact needed to be inspectable. A model score alone was not enough. A dashboard alone was not enough. A parquet file without a local explanation was not enough. I wanted the project to be explicit enough that I could keep moving without fooling myself.&lt;/p&gt;
&lt;h2 id=&quot;the-first-reliable-measuring-tool&quot;&gt;The first reliable measuring tool&lt;/h2&gt;
&lt;p&gt;The completed baseline path could read the joined Borg-derived datasets, build forecaster frames with a 15-minute-style failure target, train a baseline forecaster, and write predictions I could inspect. It was still not the orchestrator. It was the first reliable measuring tool.&lt;/p&gt;
&lt;p&gt;That measuring tool also exposed the next problem: schema drift and broken labels. At that point the pipeline stopped being a clean sequence and became a repair project.&lt;/p&gt;
&lt;p&gt;The result was not a clever model. It was a workspace that made later experiments accountable—the kind of unglamorous foundation that quietly determines whether every later claim is defensible.&lt;/p&gt;</content:encoded><language>en-US</language><category>ML</category><category>Borg</category><category>Cloud Systems</category><category>Failure Prediction</category><category>Kubernetes</category><category>polars</category><category>XGBoost</category><author>ystc1247@gmail.com</author></item><item><title>[DevBerth 2/2] I Closed the Window. DevBerth Kept Using 69% CPU.</title><link>https://theokimdev.com/en/blog/post-97-devberth-04-activity-monitor-cpu-optimization/</link><guid isPermaLink="true">https://theokimdev.com/en/blog/post-97-devberth-04-activity-monitor-cpu-optimization/</guid><description>Activity Monitor showed DevBerth using 69.2% CPU with no window open. This is how I followed the work through Instruments and brought the same Release build down to 0.118%.</description><pubDate>Thu, 23 Jul 2026 06:10:00 GMT</pubDate><content:encoded>&lt;h2 id=&quot;i-closed-the-window-but-the-work-kept-going&quot;&gt;I closed the window, but the work kept going&lt;/h2&gt;
&lt;p&gt;DevBerth is supposed to stay alive in the menu bar when its main window closes. What it was not supposed to do was keep a large part of one CPU core busy.&lt;/p&gt;
&lt;p&gt;I noticed it in &lt;a href=&quot;https://support.apple.com/guide/activity-monitor/view-cpu-activity-actmntr43452/mac&quot;&gt;Activity Monitor&lt;/a&gt;. With the main window and menu popover closed, the Release build accumulated 623.86 CPU-seconds over 901 seconds: an average of 69.2% CPU. Nothing was visible and I was not interacting with the app.&lt;/p&gt;
&lt;p&gt;My first guess was &lt;code&gt;lsof&lt;/code&gt;. DevBerth does a lot of process discovery, so an expensive parser or classification pass seemed likely. Instruments told a more interesting story. The individual scans were not the whole problem; DevBerth was taking “nothing changed” and turning it into fresh UI and persistence work every two seconds.&lt;/p&gt;
&lt;p&gt;Before changing anything, I fixed the comparison so I would not accidentally optimize one scenario and publish numbers from another.&lt;/p&gt;





































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Item&lt;/th&gt;&lt;th&gt;Measurement condition&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Machine&lt;/td&gt;&lt;td&gt;MacBook Pro &lt;code&gt;Mac17,2&lt;/code&gt;, 10-core Apple M5, 24 GB&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;OS / toolchain&lt;/td&gt;&lt;td&gt;macOS 26.4, Xcode 26.4&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Runtime scale&lt;/td&gt;&lt;td&gt;Roughly 70 live host/Docker runtime rows, observed read-only&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Build&lt;/td&gt;&lt;td&gt;Application and helper installed from the same Release build&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Closed-window sample&lt;/td&gt;&lt;td&gt;Main window and menu popover closed for 901 seconds&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Profiler&lt;/td&gt;&lt;td&gt;Approximately 30 seconds of the same idle scenario in Time Profiler&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Safety&lt;/td&gt;&lt;td&gt;No unrelated process, service, or container was signaled&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The baseline commit was &lt;code&gt;8cc670e&lt;/code&gt;, and I recorded the optimized measurements on 2026-07-22. These are before-and-after numbers from one development Mac, not a benchmark for every Mac.&lt;/p&gt;
&lt;p&gt;Activity Monitor was great for spotting the problem. To find it, I used Apple &lt;a href=&quot;https://developer.apple.com/documentation/xcode/instruments&quot;&gt;Instruments&lt;/a&gt;, &lt;code&gt;os_signpost&lt;/code&gt; intervals, and small internal counters around discovery, enrichment, Docker, diffing, persistence, and SwiftUI publication.&lt;/p&gt;
&lt;p&gt;The baseline Time Profiler trace made the hidden cost visible. The main thread spent 14.657 sampled seconds flushing SwiftUI run-loop observers and 13.566 seconds in AttributeGraph updates. The window was gone, but a retained graph was still being told that everything had changed.&lt;/p&gt;
&lt;h2 id=&quot;the-fix-was-to-stop-treating-every-refresh-as-news&quot;&gt;The fix was to stop treating every refresh as news&lt;/h2&gt;
&lt;p&gt;The first bug was almost embarrassingly small. &lt;code&gt;ObservedListener&lt;/code&gt; stored &lt;code&gt;lastDetectedAt&lt;/code&gt;, and the process fingerprint stored &lt;code&gt;detectedAt&lt;/code&gt;. Those timestamps changed on every scan, so ordinary value equality said the whole snapshot was new.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;fixed two-second poll
  → fresh timestamps
  → whole snapshot unequal
  → SwiftUI publication
  → AttributeGraph update
  → lifecycle/history persistence
  → pruning and derived recomputation
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I still needed the timestamps because evidence freshness matters. The fix was to separate fresh evidence from a meaningful runtime transition. A changed protocol, address, port, process identity, project, owner, Docker relationship, or managed-service relationship can be real news. A newer timestamp by itself is not.&lt;/p&gt;
&lt;p&gt;That one distinction took deterministic unchanged-snapshot tests from 30 transactions and 3,460 persistent-history changes per minute to zero writes.&lt;/p&gt;
&lt;p&gt;The next problem was cadence. Two seconds feels fine while I am looking at the Runtime screen. It makes no sense after the app has been hidden and stable for several minutes. The monitor now has one actor, one cancellable loop, and at most one scan in flight. Extra refresh requests coalesce instead of starting parallel work.&lt;/p&gt;






























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Mode&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Cadence&lt;/th&gt;&lt;th&gt;When it applies&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Transition&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0.75 s&lt;/td&gt;&lt;td&gt;For 15 seconds after startup, wake, a real runtime change, or a known mutation&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Active&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;configured 2 s&lt;/td&gt;&lt;td&gt;Main window or menu popover is actually visible in the foreground&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Background&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;at least 10 s&lt;/td&gt;&lt;td&gt;No monitoring surface is visible&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Idle&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;at least 30 s&lt;/td&gt;&lt;td&gt;Hidden with no semantic change for 180 seconds&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Getting “actually visible” right took more work than expected. SwiftUI’s &lt;code&gt;onDisappear&lt;/code&gt; was not enough because AppKit backing windows could remain around after the surface closed. DevBerth now looks at real &lt;code&gt;NSWindow&lt;/code&gt; visibility, occlusion, minimize and key-window state, plus whether the app is active or hidden. A menu-bar backing window only counts when the popover is truly active.&lt;/p&gt;
&lt;p&gt;Then I reduced all the little child processes that had been multiplying each other. Process enrichment is cached only when a full native identity matches: PID, UID, start time, parent PID, executable path and file identity, argument digest, and working directory. The cache can save discovery work, but it can never authorize Stop or Restart; control still does a fresh identity and ownership check.&lt;/p&gt;
&lt;p&gt;Resource usage moved to one batched &lt;code&gt;ps&lt;/code&gt; with slower 1/5/30/60-second schedules as the surface moves from transition to idle. Docker uses one list and one batched inspect, a short success cache, and backoff when unavailable. Health checks have bounded concurrency. Logs drain continuously but only update the visible model in small batches when their revision changes.&lt;/p&gt;
&lt;p&gt;In a 141-second idle observation, the monitor created nine direct children, or 3.8 per minute. The deterministic pre-metadata baseline was at least 126 per minute, so the rate fell by about 97%. Very short &lt;code&gt;ps&lt;/code&gt; calls can slip between 200 ms samples, which is why I call that result a lower bound rather than zero work.&lt;/p&gt;
&lt;p&gt;One last surprise came from UDP. High-numbered, interface-bound UDP endpoints appeared and disappeared often enough to keep resetting the fast transition cadence. DevBerth still observes them, but while hidden it no longer treats that narrow kind of churn as a reason to poll at 0.75 seconds. TCP, lower UDP ports, and wildcard or loopback UDP changes still speed the monitor up immediately.&lt;/p&gt;
&lt;p&gt;Finally, SwiftUI publication became surface-aware. &lt;code&gt;AppModel&lt;/code&gt; always keeps the newest snapshot, but while hidden it does not wake SwiftUI for timestamp-only or resource-only refreshes. In the foreground it publishes meaningful listener changes, CPU moves of at least one percentage point, or memory moves above the larger of 1 MiB and 5%. When the first monitoring surface comes back, the newest hidden snapshot is published once.&lt;/p&gt;
&lt;h2 id=&quot;the-result-and-the-parts-i-did-not-hide&quot;&gt;The result, and the parts I did not hide&lt;/h2&gt;
&lt;p&gt;The matched Release comparison looked like this:&lt;/p&gt;









































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Metric&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Baseline Release&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Optimized Release&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Change&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Closed-window CPU&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;623.86 CPU-s / 901 s = &lt;strong&gt;69.2%&lt;/strong&gt;&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;1.06 CPU-s / 901 s = &lt;strong&gt;0.118%&lt;/strong&gt;&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;&lt;strong&gt;99.83% less&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Time Profiler CPU&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;24.4 CPU-s / ~30 s&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0.027 CPU-s / 30.742 s&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;&lt;strong&gt;99.89% less&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Main-thread sampled CPU&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;19.558 CPU-s&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0.001 CPU-s&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;&lt;strong&gt;99.995% less&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Direct monitoring children&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;at least 126/min&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;9 / 141 s = 3.8/min&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;about &lt;strong&gt;97.0% fewer&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Main-window foreground CPU&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;34.17 / 69 s = 49.5%&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;1.57 / 60 s = 2.62%&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;&lt;strong&gt;94.7% less&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The final idle Time Profiler trace contained 27 samples and 0.027 CPU-seconds over 30.742 seconds. The main thread had one 0.001-second sample, with no potential hang above 250 ms. The idle SwiftUI trace also reported no hitch or hang.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theokimdev.com/resources/blog/post-97-devberth-04-activity-monitor-cpu-optimization/devberth-performance-diagnostics.png&quot; alt=&quot;The installed DevBerth Performance Diagnostics sheet showing a current active cadence and bounded scan/cache aggregates&quot;&gt;&lt;/p&gt;
&lt;p&gt;The diagnostics screenshot is a live snapshot, not the source of the benchmark. It shows cadence, scan duration, cache hit rate, and enrichment totals without exposing commands, paths, environments, logs, or secrets. It only polls while the sheet is open.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theokimdev.com/resources/blog/post-97-devberth-04-activity-monitor-cpu-optimization/devberth-activity-monitor.png&quot; alt=&quot;An illustrative current Activity Monitor view filtered to DevBerth, showing the app and several STDIO helpers owned by active clients&quot;&gt;&lt;/p&gt;
&lt;p&gt;The Activity Monitor image is also just a spot check. Several &lt;code&gt;devberth-mcp&lt;/code&gt; rows can belong to different active Codex clients, so I did not add them together and compare that total with the single-app closed-window sample.&lt;/p&gt;
&lt;p&gt;I also kept a repeatable five-minute soak test. &lt;code&gt;run_performance_soak.sh&lt;/code&gt; launches an isolated Release app with in-memory persistence, fixed listener and resource fixtures, unavailable test Docker, and no production socket. It samples and terminates only the PID it launched.&lt;/p&gt;
&lt;p&gt;In the final 300-second run, CPU time rose by 0.11 seconds over the 293 seconds after startup, or about 0.038%. RSS settled between 132,992 and 165,568 KiB and ended at 133,040 KiB. Threads settled from 11 to five or six. Every sample saw zero direct children, and the application log contained no &lt;code&gt;error&lt;/code&gt;, &lt;code&gt;fatal&lt;/code&gt;, or &lt;code&gt;crash&lt;/code&gt; lines. The warnings-as-errors run passed all 210 tests at the time, and the broader soak gate passed twice.&lt;/p&gt;
&lt;p&gt;Not every loose end disappeared. The old timestamp bug had already left roughly 118 MB of persistent-history backlog in the production store. The optimized sample stopped that false growth, but I did not quietly vacuum or destructively compact existing user data as part of a CPU fix.&lt;/p&gt;
&lt;p&gt;I also did not invent measurements I could not get. Command-line Instruments did not expose Power Profiler on macOS 26.4. System Trace export never produced a usable artifact. There was no untouched-baseline kernel wakeup count, so there is no wakeup-reduction percentage here. &lt;code&gt;leaks -quiet&lt;/code&gt; found three 80-byte &lt;code&gt;CGRegion&lt;/code&gt; allocations, 240 bytes total, but no growing app-owned signature; that is not the same as claiming zero leaks.&lt;/p&gt;
&lt;p&gt;Polling still exists because the macOS 14 deployment baseline has no public event API that fully reports TCP and UDP listener ownership. Hidden evidence can be up to 30 seconds old, and a brief high-numbered interface UDP endpoint can disappear between scans. Foreground navigation also still produced two potential hangs at 279.19 and 319.81 ms. Those hitches are another profiling job, not something I wanted to bury under a good idle number.&lt;/p&gt;
&lt;p&gt;The biggest improvement was not a clever parser or a faster poll. It was teaching the app that a newer observation is not always a new event. DevBerth is built to notice runtime changes, and the best performance work was finally giving it a good way to say, “nothing happened.”&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Previous: &lt;a href=&quot;https://theokimdev.com/en/blog/post-94-devberth-01-why-i-built-it/&quot;&gt;I Wanted a Better Way to See What Was Running on My Mac&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content:encoded><language>en-US</language><category>Performance</category><category>DevBerth</category><category>Activity Monitor</category><category>Instruments</category><category>SwiftUI</category><category>macOS</category><author>ystc1247@gmail.com</author></item><item><title>[DevBerth 1/2] I Wanted a Better Way to See What Was Running on My Mac</title><link>https://theokimdev.com/en/blog/post-94-devberth-01-why-i-built-it/</link><guid isPermaLink="true">https://theokimdev.com/en/blog/post-94-devberth-01-why-i-built-it/</guid><description>DevBerth started as a port viewer. It turned into a native macOS runtime manager once I realized that seeing a process and safely controlling it were two very different problems.</description><pubDate>Thu, 23 Jul 2026 06:10:00 GMT</pubDate><content:encoded>&lt;h2 id=&quot;the-initiative-understand-the-whole-local-workspace&quot;&gt;The initiative: understand the whole local workspace&lt;/h2&gt;
&lt;p&gt;DevBerth began with a familiar local-development problem. I would start a frontend or API, discover that 5173 or 8080 was already occupied, and open a terminal to work out what was there. The routine was always similar: run &lt;code&gt;lsof&lt;/code&gt;, copy a PID, check &lt;code&gt;ps&lt;/code&gt;, look at Docker just in case, and try to remember whether I had started the process from a terminal, an IDE, or another tool that would bring it straight back after I killed it.&lt;/p&gt;
&lt;p&gt;Finding a PID was easy. Understanding what the PID represented was not.&lt;/p&gt;
&lt;p&gt;The first initiative was deliberately small: build a native macOS port viewer that connected TCP and useful UDP listeners to their processes. I wanted one place where I could answer “what is using this port?” without assembling the answer from several commands. A sortable table, a process inspector, and a Stop button seemed like enough.&lt;/p&gt;
&lt;p&gt;As soon as I tried to make Stop reliable, the project became much larger.&lt;/p&gt;
&lt;p&gt;A modern development workspace is rarely one process. It may contain a frontend dev server, an API, a database, a mobile bundler, a reverse proxy, a tunnel, a worker, and several containers. One process may own several listeners. A host port may really be published by Docker. A Compose service may have a container, a host process, a restart policy, and a project directory that all matter. Homebrew or launchd may recreate a process after it exits. An IDE task and a system process may share the same executable name. A PID can even be reused between inspection and execution.&lt;/p&gt;
&lt;p&gt;Traditional port tools are good at isolated facts. Process managers are good at processes they launched themselves. I wanted DevBerth to connect those two views without pretending they had the same level of trust.&lt;/p&gt;
&lt;p&gt;That led to the central product rule: seeing a process is not the same as having authority to control it.&lt;/p&gt;
&lt;p&gt;The rule becomes easier to reason about when it is split into three questions:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;What is running right now?&lt;/li&gt;
&lt;li&gt;What did I deliberately configure to run?&lt;/li&gt;
&lt;li&gt;What is DevBerth actually allowed to control right now?&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The first answer comes from operating-system evidence. &lt;code&gt;ObservedListener&lt;/code&gt; and &lt;code&gt;ObservedProcess&lt;/code&gt; are built from bounded &lt;code&gt;lsof&lt;/code&gt; and &lt;code&gt;ps&lt;/code&gt; collection plus structured Docker output. They describe the machine during one refresh. The information may be incomplete, and it begins to age as soon as it is collected.&lt;/p&gt;
&lt;p&gt;The second answer is durable user intent. A &lt;code&gt;ManagedServiceConfiguration&lt;/code&gt; records an executable or launch mechanism, separate arguments, a working directory, expected ports, dependencies, readiness and health checks, shutdown and restart policies, bounded log settings, and opaque Keychain references. A &lt;code&gt;RuntimeInstance&lt;/code&gt; represents one actual execution of that reviewed definition.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;ObservedListener / ObservedProcess
  what the machine looks like now

ManagedServiceConfiguration
  what I reviewed and intended to run

RuntimeInstance
  one actual run of that configuration
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The distinction matters because a process snapshot is not a launch recipe. It may omit shell expansion, environment variables, credentials, supervisor behavior, or the real working directory. A visible Docker process may only be an implementation detail of a Compose service. A shell command may be the middle of a longer chain.&lt;/p&gt;
&lt;p&gt;DevBerth can discover service candidates from project files, but discovery stops at suggestion. It never executes a candidate while scanning and never silently promotes an observed process into something restartable. I have to review the definition before the app can claim it knows how to launch it again.&lt;/p&gt;
&lt;p&gt;The third answer—current control authority—must be resolved at action time. A familiar command, matching port, inferred project, old PID, or similar container is useful context, but none of those alone authorizes a mutation.&lt;/p&gt;
&lt;p&gt;This separation became the foundation of the entire product:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Runtime shows what the machine currently exposes.&lt;/li&gt;
&lt;li&gt;Projects describe how reviewed services belong together.&lt;/li&gt;
&lt;li&gt;Sessions capture the workspace state I expect.&lt;/li&gt;
&lt;li&gt;Managed Services define what DevBerth may reliably launch.&lt;/li&gt;
&lt;li&gt;History records what actually changed.&lt;/li&gt;
&lt;li&gt;Docker adds container and Compose ownership when it can be proved.&lt;/li&gt;
&lt;li&gt;Settings exposes safe preferences, permissions, diagnostics, and integrations.&lt;/li&gt;
&lt;li&gt;MCP lets Codex use the same application-owned control plane instead of creating another one.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The app is native SwiftUI and local-only. It does not require an account, administrator privileges, or a cloud service. There is no analytics, telemetry, advertising SDK, cloud sync, crash-upload service, or observed-runtime upload endpoint. Process and resource values stay on the Mac. Secret-like environment values live in Keychain rather than SwiftData.&lt;/p&gt;
&lt;p&gt;On first launch, the welcome guide explains these boundaries before offering four useful starting points: review the current Runtime, import or create a Project, create and validate a Managed Service, or capture a Workspace Session. It also explains that macOS visibility may be limited, that discovery is not management, and that reliable restart requires a reviewed definition.&lt;/p&gt;
&lt;p&gt;DevBerth works without Full Disk Access, but macOS may hide some same-user process metadata. Settings can open the correct Privacy &amp;amp; Security page when broader visibility is useful; the app cannot and does not grant that permission to itself.&lt;/p&gt;
&lt;p&gt;The project is currently distributed from source, and the complete repository is public at &lt;a href=&quot;https://github.com/ysbc1247/portpilot-macos&quot;&gt;ysbc1247/portpilot-macos on GitHub&lt;/a&gt;. The stable build script creates the Release app, verifies the bundle, replaces &lt;code&gt;/Applications/DevBerth.app&lt;/code&gt; atomically, and installs the matching MCP helper under Application Support. There is not yet a Developer ID-notarized download or automatic updater, so I keep that limitation explicit rather than presenting the source build as a finished consumer distribution.&lt;/p&gt;
&lt;p&gt;That is the initiative in one sentence: make the whole local workspace understandable, then allow control only where the current evidence and reviewed intent actually support it.&lt;/p&gt;
&lt;h2 id=&quot;a-complete-tour-from-runtime-to-settings&quot;&gt;A complete tour from Runtime to Settings&lt;/h2&gt;
&lt;p&gt;The product uses one stable navigation order: Runtime, Projects, Sessions, Managed Services, History, Docker, and Settings. Each destination has a main collection view, while a contextual inspector or preview explains the selected item. I chose that structure because the app contains a lot of evidence, but I did not want a separate dashboard inventing a second version of the same state.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Runtime&lt;/strong&gt; is the live view of the machine. It discovers TCP listeners and meaningful bound UDP endpoints across IPv4 and IPv6, then connects them to the process metadata macOS makes available. The same snapshot can be shown as a customizable table or grouped by inferred project.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theokimdev.com/resources/blog/post-94-devberth-01-why-i-built-it/devberth-runtime-overview.png&quot; alt=&quot;The installed DevBerth Runtime view showing live TCP and UDP listeners, processes, projects, and ownership conclusions&quot;&gt;&lt;/p&gt;
&lt;p&gt;Search covers ports, protocols, addresses, process names, commands, and projects. Sorting covers port, process, project, runtime, and uptime. Saved views narrow the list to All Runtime, Managed, Unexpected, Unhealthy, Docker, or Externally Reachable. The table keeps the fields I check most often together: status and protocol, port, process and PID, project, ownership, restart trust, health, runtime, uptime, CPU, and resident memory.&lt;/p&gt;
&lt;p&gt;Native selection supports keyboard traversal and multiple rows. Multi-selection summarizes listener and process counts and resource totals, but it deliberately does not offer an ambiguous “stop everything selected” button. A group of rows may contain several owners and several safety levels, so bulk destruction would hide exactly the distinction the app is meant to preserve.&lt;/p&gt;
&lt;p&gt;Selecting one runtime opens the inspector. It explains the network listeners, process identity, observed command, working directory, parent evidence, project inference, managed-service relationship, restart trust, health, recent lifecycle events, Docker association, logs, and currently safe actions. “Why is this running?” is answered with bounded lineage and confidence-labeled ownership evidence. Missing or permission-limited data is shown as unavailable rather than guessed.&lt;/p&gt;
&lt;p&gt;Transient CPU and resident-memory usage come from one bounded batched reader. Docker metadata is attached when an exact container or Compose relationship is available. A listener can show active managed state, unexpected state, unhealthy state, or externally reachable scope without turning those classifications into control permission.&lt;/p&gt;
&lt;p&gt;Inspect and Stop remain close to each process because they are common tasks, but Stop is routed through the resolved owner. Root-owned and recognized system processes stay protected. An observed host process requires explicit confirmation and fresh fingerprint, listener-edge, user, protected-process, and owner-context checks.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theokimdev.com/resources/blog/post-94-devberth-01-why-i-built-it/devberth-runtime-inspector.png&quot; alt=&quot;The installed Runtime inspector showing process identity, ownership, project, and safe-action evidence&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Projects&lt;/strong&gt; move the unit of work from a port to a workspace. A project groups reviewed services, their expected ports, and dependency edges. The project view shows current observed or DevBerth-controlled activity, health and lifecycle state, Docker relationships, startup layers, incomplete dependencies, cycles, and recent failures.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theokimdev.com/resources/blog/post-94-devberth-01-why-i-built-it/devberth-projects.png&quot; alt=&quot;The installed DevBerth Projects page showing project-level service controls, dependency relationships, expected ports, and current activity&quot;&gt;&lt;/p&gt;
&lt;p&gt;Start All launches only stopped definitions that are currently verified. It preserves the complete dependency graph, so a database can become ready before the API that needs it and independent services can start in parallel where the graph allows. Stop All walks the graph in reverse, attempts every relevant target even if one fails, and keeps visible per-service progress and final results. Start, Stop, and Inspect are also available on each service row.&lt;/p&gt;
&lt;p&gt;Projects can be created manually or populated through Discover Services. Discovery reads supported files only inside a root I explicitly select. It recognizes JavaScript package managers, Gradle, Maven, Python, Go, Cargo, Docker Compose, Procfile, and Process Compose definitions. The scan is bounded and non-recursive, rejects unsafe paths, and never evaluates a project command.&lt;/p&gt;
&lt;p&gt;Discovered services remain unreviewed candidates. I can edit arguments, directories, expected ports, checks, and dependencies before importing them. Projects can also export a versioned &lt;code&gt;devberth-runtime.json&lt;/code&gt; manifest. The export deliberately excludes secret values and even Keychain reference identifiers, so it can describe runtime intent without becoming a credential bundle.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Sessions&lt;/strong&gt; capture a development workspace without pretending to capture a shell session. A Workspace Session stores selected projects and the expected running or stopped state of their managed services. It does not persist arbitrary process objects or assume that an old PID still matters.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theokimdev.com/resources/blog/post-94-devberth-01-why-i-built-it/devberth-sessions.png&quot; alt=&quot;The installed DevBerth Sessions page comparing a saved workspace with current managed-service state before restore&quot;&gt;&lt;/p&gt;
&lt;p&gt;Opening a session compares that expected state with the current machine and shows drift. Restore always begins with a fresh preview, including when it is launched from the command palette. The preview checks that definitions still exist, Keychain references resolve, restart trust remains current, expected ports are available, dependencies are complete, directories and executables still exist, and no conflict has appeared.&lt;/p&gt;
&lt;p&gt;The preview supports dry-run review, explicit confirmation of issues, expected-stopped handling, and optional rollback. Starts execute in dependency layers. If one layer fails, rollback can stop only the services started by that restore. It never stops an unmanaged process or a service that was already running as compensation. Restore history remains available so the result is inspectable after the workspace has changed again.&lt;/p&gt;
&lt;p&gt;This is useful for work that spans more than one repository or mixes host and Docker services. Instead of remembering which five commands I ran yesterday, I can describe the state I want, compare it with today’s runtime, and review the difference before DevBerth changes anything.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Managed Services&lt;/strong&gt; are the durable definitions behind reliable Start and Restart. A service can use a generic executable or command, npm, pnpm, Yarn, or Bun scripts, Gradle, Maven, an explicitly reviewed custom shell, a Docker container, or a verified Compose service. The definition records command boundaries rather than flattening everything into one shell string.&lt;/p&gt;
&lt;p&gt;It also records the working directory, expected ports, readiness and health checks, shutdown behavior, restart policy, dependencies, log policy, and secret references. Supported checks include TCP, HTTP, reviewed command, file, Docker, and dependency conditions. DevBerth treats process-running, listener-open, service-ready, and service-healthy as separate facts; one does not automatically imply the next.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theokimdev.com/resources/blog/post-94-devberth-01-why-i-built-it/devberth-managed-services.png&quot; alt=&quot;The installed DevBerth Managed Services page showing reviewed definitions, expected ports, restart trust, live status, and service controls&quot;&gt;&lt;/p&gt;
&lt;p&gt;New and edited services are not automatically restartable. Validation launches the exact definition in isolation, observes expected ports and reviewed readiness checks, and performs a controlled stop. If the sequence succeeds, the exact configuration digest becomes Verified restartable.&lt;/p&gt;
&lt;p&gt;Changing a safety-relevant field invalidates that trust. Editing the executable, arguments, working directory, expected ports, dependencies, checks, shutdown behavior, or secret references sends the service back to review. This can feel stricter than a typical process manager, but it keeps Start and Restart tied to the definition that was actually tested.&lt;/p&gt;
&lt;p&gt;Each service row keeps its own operation state. Stopping one service does not make every other button look busy. Progress, success, refusal, and failure remain visible until a newer operation replaces them. The same row can open logs and the working directory, copy the reviewed command, inspect evidence, stop a current runtime, or start and restart when the exact trust state permits it.&lt;/p&gt;
&lt;p&gt;Managed stdout and stderr are bounded in memory and on disk. Output passes through a streaming redactor before it reaches either place, including when a secret crosses chunk boundaries. Restart policies are attached to actual runtime instances and bounded by crash-loop limits rather than implemented as a UI toggle.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;History&lt;/strong&gt; answers “what changed while I was away?” It indexes bounded lifecycle evidence for filtering by time, severity, source, event type, service, summary, and result. The data includes launches, stops, exits, readiness transitions, health degradation and recovery, automatic-restart attempts, crash-loop limits, listener changes, Docker transitions, and session restore results.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theokimdev.com/resources/blog/post-94-devberth-01-why-i-built-it/devberth-history.png&quot; alt=&quot;The installed DevBerth History page showing recent lifecycle and listener evidence with time, severity, source, summary, and result&quot;&gt;&lt;/p&gt;
&lt;p&gt;History keeps events factual. Incident summaries are deterministic and built from ordered evidence rather than generated prose. A partial project stop stays partial. A service that is running but not listening is not rewritten as healthy. Related runtime, service, project, and session identifiers make it possible to move from the summary back to the underlying evidence.&lt;/p&gt;
&lt;p&gt;Retention is bounded and configurable. Logs, history exports, diagnostics, and audit details are already redacted and size-limited before they leave their original service. The goal is enough history to explain behavior without turning a local runtime manager into an unbounded data collector.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Docker&lt;/strong&gt; is optional. Runtime monitoring still works when Docker is not installed or its daemon is stopped. When it is available, DevBerth reads structured container data, published ports, state, health, restart policy, recent logs, and canonical Compose labels.&lt;/p&gt;
&lt;p&gt;Inspection can be broad, but mutation is intentionally narrow. A standalone container is addressed by its full current identity. A Compose mutation is available only after DevBerth verifies the exact project name, project directory, configuration and environment files, configuration hash, and current container membership. One-off containers never gain service-level authority, and DevBerth never falls back to signaling a container’s host PID.&lt;/p&gt;
&lt;p&gt;If Docker becomes unavailable, the app reports that state and backs off its checks without taking host listener monitoring down with it. That separation matters on laptops where Docker Desktop is not always running.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Settings&lt;/strong&gt; collects the safe configuration around the product. It exposes monitoring preferences, bounded history retention, appearance, permission guidance, the welcome guide, safe diagnostics, and the Codex/MCP integration. Full Disk Access opens the correct System Settings location rather than pretending the app can grant it.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theokimdev.com/resources/blog/post-94-devberth-01-why-i-built-it/devberth-mcp-settings.jpeg&quot; alt=&quot;The DevBerth Settings page showing the production Codex and MCP control-host status, protocol, tool inventory, and validation controls&quot;&gt;&lt;/p&gt;
&lt;p&gt;Performance Diagnostics shows bounded aggregates such as current monitoring mode and interval, scan duration, coalescing counts, cache size and hit rate, Docker duration, and recent warnings. It excludes commands, paths, process details, environments, logs, and secrets. The sheet reads its snapshot only while it is open.&lt;/p&gt;
&lt;p&gt;The Codex &amp;amp; MCP settings can install or repair the stable helper, preview a global or project Codex configuration change, test the connection, and run MCP validation. The editor preserves unrelated TOML, rejects duplicate DevBerth tables and symlinks, writes atomically, verifies the result, and keeps a timestamped backup.&lt;/p&gt;
&lt;p&gt;The main window is not the only way to use these features. The &lt;strong&gt;menu bar&lt;/strong&gt; shows counts for active managed services, unexpected listeners, and unhealthy services. It offers listener search, favorite service control, recent project Start and Stop, session capture, monitoring controls, and a route back to the main window.&lt;/p&gt;
&lt;p&gt;Pressing &lt;strong&gt;⌘K&lt;/strong&gt; opens the command palette. It searches ports, PIDs, processes, commands, projects, managed services, and sessions. It can open every destination, refresh or toggle monitoring, start or stop projects, capture a session, open a restore preview, open or copy managed-service data, and start, stop, or restart a service. Restart appears only when the exact current digest is Verified restartable.&lt;/p&gt;
&lt;p&gt;The menu bar and palette are shortcuts, not privileged surfaces. They call the same application actions as the main window and cannot bypass ownership, validation, confirmation, or restart-trust rules. Native labels, text alongside critical color states, keyboard navigation, accessibility descriptions, light and dark appearance, and localization-ready user strings are part of the same product surface.&lt;/p&gt;
&lt;h2 id=&quot;one-control-plane-including-codex&quot;&gt;One control plane, including Codex&lt;/h2&gt;
&lt;p&gt;All those features would be much less useful if each one invented a different definition of safety. DevBerth therefore has one monitor, one current runtime snapshot, and application-owned domain services for ownership, lifecycle, projects, sessions, persistence, and secrets. SwiftUI depends on service protocols; views do not call &lt;code&gt;Process&lt;/code&gt;, &lt;code&gt;lsof&lt;/code&gt;, &lt;code&gt;ps&lt;/code&gt;, &lt;code&gt;kill&lt;/code&gt;, Docker, or a shell directly.&lt;/p&gt;
&lt;p&gt;The same boundary applies to Codex.&lt;/p&gt;
&lt;p&gt;The easiest MCP implementation would have let a helper run its own discovery and shell commands. It also would have created a second runtime authority with its own cache, ownership logic, persistence, and failure modes. Instead, &lt;code&gt;devberth-mcp&lt;/code&gt; is a thin STDIO adapter. It validates typed MCP input, communicates with the already-running app over a current-user Unix socket, and returns structured results.&lt;/p&gt;
&lt;p&gt;Production currently exposes 82 tools plus resources and prompts for runtime inspection, ports, projects, services, dependencies, sessions, Docker, logs, history, settings, diagnostics, destructive previews, and coordinated change sets. The helper does not run its own &lt;code&gt;lsof&lt;/code&gt;, &lt;code&gt;ps&lt;/code&gt;, Docker, shell, database, or Keychain queries. The GUI and Codex are looking at the same snapshot and asking the same app services to act.&lt;/p&gt;
&lt;p&gt;Production has no TCP control port. The socket lives under DevBerth’s Application Support directory with a private parent, restrictive file mode, matching-UID peer checks, bounded frames, deadlines, and protocol negotiation. Production and development use separate sockets and handshakes.&lt;/p&gt;
&lt;p&gt;The identity of DevBerth itself is also checked. During development, a Debug control host and the installed Release app can share a display name and bundle identifier. Production activation therefore validates the exact &lt;code&gt;/Applications/DevBerth.app&lt;/code&gt;, executable, Release marker, production socket, and persistence mode. Development activation requires an explicit app path and Debug marker. A familiar label is not strong identity, whether the target is a user process or DevBerth itself.&lt;/p&gt;
&lt;p&gt;For host processes, a strong &lt;code&gt;ProcessFingerprint&lt;/code&gt; combines PID, UID, executable path and file identity when available, start time, command digest, parent PID, and observation time. DevBerth also keeps the exact listener-to-process edge and resolves the lifecycle owner.&lt;/p&gt;
&lt;p&gt;Immediately before a signal, it resolves the target again. A graceful stop waits for the configured timeout. Force escalation performs another fresh resolution instead of reusing the earlier decision. A changed fingerprint, listener edge, owner route, user, protected status, or controller context is a refusal.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;observe
  → identify the process and listener
  → resolve the lifecycle owner
  → show the exact action preview
  → obtain confirmation
  → resolve and validate again
  → signal the exact scope
  → validate once more before force escalation
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The rules differ by runtime state:&lt;/p&gt;



































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Runtime state&lt;/th&gt;&lt;th align=&quot;center&quot;&gt;Inspect&lt;/th&gt;&lt;th align=&quot;center&quot;&gt;Stop&lt;/th&gt;&lt;th align=&quot;center&quot;&gt;Start or Restart&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Observed listener or process&lt;/td&gt;&lt;td align=&quot;center&quot;&gt;Yes&lt;/td&gt;&lt;td align=&quot;center&quot;&gt;Explicit confirmation plus fresh exact-owner revalidation&lt;/td&gt;&lt;td align=&quot;center&quot;&gt;Observation grants no launch authority&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Live DevBerth-managed runtime&lt;/td&gt;&lt;td align=&quot;center&quot;&gt;Yes&lt;/td&gt;&lt;td align=&quot;center&quot;&gt;Through its registered policy and revalidated process group&lt;/td&gt;&lt;td align=&quot;center&quot;&gt;Only from the exact verified definition&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Verified Docker or Compose owner&lt;/td&gt;&lt;td align=&quot;center&quot;&gt;Yes&lt;/td&gt;&lt;td align=&quot;center&quot;&gt;Through the verified controller context&lt;/td&gt;&lt;td align=&quot;center&quot;&gt;Only while that exact context remains valid&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Protected or unverifiable process&lt;/td&gt;&lt;td align=&quot;center&quot;&gt;Limited evidence&lt;/td&gt;&lt;td align=&quot;center&quot;&gt;Refused&lt;/td&gt;&lt;td align=&quot;center&quot;&gt;Refused&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;MCP destructive actions add a two-step authorization contract. &lt;code&gt;operation_preview&lt;/code&gt; captures stable targets, revisions, process fingerprints, listener edges, owner routes, expected effects, risks, and compensation. The token expires after five minutes and can be used once. &lt;code&gt;operation_execute&lt;/code&gt; consumes it only after resolving the current target again.&lt;/p&gt;
&lt;p&gt;Five minutes is not a promise that the host will stay unchanged. It is only the maximum lifetime of the authorization. Changed revisions, replay, stale identity, different ownership, protected targets, or missing controller context still fail. Configuration batches use an equivalent change-set preview and execution flow, with compensation for already-applied configuration steps when a later step fails.&lt;/p&gt;
&lt;p&gt;Secrets never cross this control boundary. SwiftData stores opaque Keychain references, not values. MCP can say whether a secret name is configured and currently resolvable, but it cannot retrieve the value or reference UUID. Profile edits stage Keychain changes and roll them back if validation or persistence fails. Duplicated services receive independent references rather than sharing secret lifecycle.&lt;/p&gt;
&lt;p&gt;The local-only privacy model is equally important. DevBerth does not upload observed runtimes. Diagnostics omit commands, paths, environments, logs, and secrets. Manifests omit secret values and Keychain identifiers. Resource data and live process objects are transient. Tests use in-memory stores, no production socket, and static or application-owned fixtures; they do not enumerate or signal unrelated user processes or containers.&lt;/p&gt;
&lt;p&gt;The current implementation covers the complete sidebar, menu bar, command palette, onboarding, service discovery, orchestration, sessions, lifecycle and health evidence, Docker and Compose routing, privacy controls, and MCP. The latest warnings-as-errors run passed all 216 tests across unit, persistence, harmless owned-process integration, UI, MCP, and application-identity coverage.&lt;/p&gt;
&lt;p&gt;There are still limits. DevBerth cannot reconstruct an arbitrary process’s original shell session or complete environment. Root-owned and recognized system processes remain protected because there is no privilege-escalation helper. UDP does not have a universal TCP-style listening state. Project inference walks only bounded parents of a verified working directory instead of scanning the disk. The editor currently exposes one dependency selector even though the domain model supports larger graphs. Distribution remains source-first.&lt;/p&gt;
&lt;p&gt;Those limits are intentional parts of the explanation, not details to hide after the feature list. DevBerth is useful because it connects a lot of local runtime information, but it is trustworthy only when it also says what it cannot prove.&lt;/p&gt;
&lt;p&gt;What began as “what is using port 5173?” became a native runtime explorer, guarded service controller, workspace restorer, lifecycle record, Docker inspector, menu-bar tool, and MCP control plane. Every feature returns to the same initiative: understand the local workspace without guessing, and make control follow the evidence rather than the other way around.&lt;/p&gt;
&lt;p&gt;The next article covers the performance problem that appeared after all of this was working. The monitor was collecting the right evidence, but it treated every refresh as meaningful change and used 69.2% CPU with the window closed.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Next: &lt;a href=&quot;https://theokimdev.com/en/blog/post-97-devberth-04-activity-monitor-cpu-optimization/&quot;&gt;I Closed the Window. DevBerth Kept Using 69% CPU.&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content:encoded><language>en-US</language><category>macOS</category><category>DevBerth</category><category>SwiftUI</category><category>Runtime Management</category><category>Developer Tools</category><category>MCP</category><author>ystc1247@gmail.com</author></item><item><title>Springfox Swagger Parse Error in Spring Boot: Reproducing the Failure and Migrating to springdoc</title><link>https://theokimdev.com/en/blog/post-50-swagger/</link><guid isPermaLink="true">https://theokimdev.com/en/blog/post-50-swagger/</guid><description>A reproducible Spring Boot lab showing why Springfox fails around PathPatternParser, how a Boot 2.7 compatibility patch restores /v2/api-docs, and why springdoc is the safer Boot 3 migration path.</description><pubDate>Fri, 10 Jul 2026 02:35:00 GMT</pubDate><content:encoded>&lt;h2 id=&quot;this-was-not-just-a-swagger-ui-problem&quot;&gt;This Was Not Just A Swagger UI Problem&lt;/h2&gt;
&lt;p&gt;Springfox can fail before Swagger UI has a chance to render. The useful debugging boundary is the request-mapping metadata that Springfox scans while the application starts, not the browser page that never loads.&lt;/p&gt;
&lt;p&gt;The runnable lab in &lt;code&gt;labs/swagger-springfox-migration-lab/&lt;/code&gt; exercises the same tiny &lt;code&gt;/orders/{orderId}&lt;/code&gt; API through three cases:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Spring Boot 2.7.18 + Springfox 3.0.0 + &lt;code&gt;PathPatternParser&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Spring Boot 2.7.18 + Springfox 3.0.0 + &lt;code&gt;AntPathMatcher&lt;/code&gt; plus a handler-mapping filter.&lt;/li&gt;
&lt;li&gt;Spring Boot 3.4.7 + &lt;code&gt;springdoc-openapi-starter-webmvc-ui&lt;/code&gt; 2.8.17, grouped docs, and bearer-token OpenAPI metadata.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The goal is not to argue that one documentation library is always better. The goal is to make a version-boundary failure observable, then choose the smallest responsible fix for each application generation.&lt;/p&gt;
&lt;h2 id=&quot;the-failure&quot;&gt;The Failure&lt;/h2&gt;
&lt;p&gt;The failure looked like a Swagger parse error, but the important line was lower in the stack trace:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Failed to start bean &apos;documentationPluginsBootstrapper&apos;;
nested exception is java.lang.NullPointerException:
Cannot invoke &quot;org.springframework.web.servlet.mvc.condition.PatternsRequestCondition.getPatterns()&quot;
because &quot;this.condition&quot; is null

at springfox.documentation.spring.web.WebMvcPatternsRequestConditionWrapper.getPatterns
at springfox.documentation.RequestHandler.sortedPaths
at springfox.documentation.spring.web.plugins.WebMvcRequestHandlerProvider.requestHandlers
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That tells us Springfox is not merely failing to render UI resources. It is failing while scanning Spring MVC request mappings and sorting discovered paths before the docs endpoint can be served.&lt;/p&gt;
&lt;p&gt;The reproduced evidence is saved in &lt;code&gt;/resources/blog/post-50-swagger/springfox-default-path-pattern-failure.txt&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id=&quot;why-path-matching-matters&quot;&gt;Why Path Matching Matters&lt;/h2&gt;
&lt;p&gt;Spring MVC has two path-matching models that matter here:&lt;/p&gt;




















&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Model&lt;/th&gt;&lt;th&gt;Rough shape&lt;/th&gt;&lt;th&gt;Why it matters for Springfox&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;AntPathMatcher&lt;/code&gt;&lt;/td&gt;&lt;td&gt;String-pattern based matching&lt;/td&gt;&lt;td&gt;Older libraries often expect &lt;code&gt;PatternsRequestCondition&lt;/code&gt; to be populated&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;PathPatternParser&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Parsed path-pattern matching&lt;/td&gt;&lt;td&gt;Request mapping metadata can be represented without the old condition object Springfox expects&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Spring Framework’s &lt;a href=&quot;https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-servlet/handlermapping-path.html&quot;&gt;path matching documentation&lt;/a&gt; describes &lt;code&gt;PathPatternParser&lt;/code&gt; as the parsed-pattern alternative to &lt;code&gt;AntPathMatcher&lt;/code&gt;. Spring Boot’s &lt;a href=&quot;https://docs.spring.io/spring-boot/docs/2.7.12/reference/html/web.html#web.servlet.spring-mvc.pathmatch&quot;&gt;2.7 web documentation&lt;/a&gt; explains the matching-strategy property. A Spring Boot &lt;a href=&quot;https://github.com/spring-projects/spring-boot/issues/28936&quot;&gt;2.6 documentation issue&lt;/a&gt; also records that the MVC auto-configuration default was actually &lt;code&gt;path-pattern-parser&lt;/code&gt; in that line.&lt;/p&gt;
&lt;p&gt;Springfox 3.0.0 was built around older Spring MVC assumptions. Its own &lt;a href=&quot;https://github.com/springfox/springfox#spring-boot-applications&quot;&gt;Spring Boot application notes&lt;/a&gt; mainly describe dependency and annotation changes for Springfox 3.x. They do not turn Springfox into a modern Spring Boot 3 documentation stack.&lt;/p&gt;
&lt;p&gt;That is why “Swagger broke” is too vague. The actual bug is:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Springfox’s request-handler scanner expects mapping metadata that is not available in the same shape when Spring MVC uses parsed path patterns.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id=&quot;lab-design&quot;&gt;Lab Design&lt;/h2&gt;
&lt;p&gt;The lab uses Maven inside a JDK 17 Docker container:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;services:
  maven:
    image: maven:3.9.9-eclipse-temurin-17
    working_dir: /workspace
    volumes:
      - .:/workspace
      - maven-cache:/root/.m2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There are two modules:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;springfox-boot27
springdoc-boot3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Both expose the same API:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@RestController
class OrderController {
    @GetMapping(&quot;/orders/{orderId}&quot;)
    OrderResponse getOrder(@PathVariable String orderId) {
        return new OrderResponse(orderId, &quot;PAID&quot;, 42000);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Run it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cd labs/swagger-springfox-migration-lab
./scripts/run-matrix.sh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The successful rerun used ID &lt;code&gt;20260710T024421Z&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id=&quot;observed-matrix&quot;&gt;Observed Matrix&lt;/h2&gt;
&lt;p&gt;The clean run produced this result:&lt;/p&gt;

























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Scenario&lt;/th&gt;&lt;th&gt;Endpoint&lt;/th&gt;&lt;th&gt;Observed outcome&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Boot 2.7.18 + Springfox 3.0.0 + &lt;code&gt;PathPatternParser&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;/v2/api-docs&lt;/code&gt;&lt;/td&gt;&lt;td&gt;application startup failed before the endpoint could be served&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Boot 2.7.18 + Springfox 3.0.0 + &lt;code&gt;AntPathMatcher&lt;/code&gt; + handler-mapping filter&lt;/td&gt;&lt;td&gt;&lt;code&gt;/v2/api-docs&lt;/code&gt;&lt;/td&gt;&lt;td&gt;returned a Swagger 2 document&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Boot 3.4.7 + springdoc-openapi 2.8.17&lt;/td&gt;&lt;td&gt;&lt;code&gt;/v3/api-docs&lt;/code&gt; and &lt;code&gt;/v3/api-docs/orders&lt;/code&gt;&lt;/td&gt;&lt;td&gt;returned OpenAPI 3.1 documents with bearer auth metadata&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The generated summary is saved at &lt;code&gt;/resources/blog/post-50-swagger/matrix-summary.md&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The positive Springfox case returned:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Scenario: Spring Boot 2.7.18 + Springfox 3.0.0 + AntPathMatcher + handler-mapping filter
Outcome: /v2/api-docs returned a Swagger 2 document
Contains swagger 2 marker: true
Contains /orders/{orderId}: true
Contains OrderResponse schema: true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The springdoc migration case returned:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Scenario: Spring Boot 3.4.7 + springdoc-openapi-starter-webmvc-ui 2.8.17
Outcome: /v3/api-docs returned an OpenAPI document
HTTP status: 200 OK
Contains openapi marker: true
Contains /orders/{orderId}: true
Contains OrderResponse schema: true
Contains bearerAuth security scheme: true
Grouped endpoint /v3/api-docs/orders status: 200 OK
Unauthenticated GET /orders/demo-1 status: 403 FORBIDDEN
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Those two lines are more important than a Swagger UI screenshot. They prove that the generated machine-readable contract exists and includes the expected operation and schema.&lt;/p&gt;
&lt;h2 id=&quot;boot-27-if-you-cannot-migrate-immediately&quot;&gt;Boot 2.7: If You Cannot Migrate Immediately&lt;/h2&gt;
&lt;p&gt;For an existing Spring Boot 2.7 service that still uses Springfox, the compatibility path in the lab is explicit:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-properties&quot;&gt;spring.mvc.pathmatch.matching-strategy=ant-path-matcher
lab.springfox.compatibility-filter=true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The handler-mapping filter is intentionally isolated behind a property:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Configuration
@ConditionalOnProperty(name = &quot;lab.springfox.compatibility-filter&quot;, havingValue = &quot;true&quot;)
class SpringfoxCompatibilityConfig {
    @Bean
    static BeanPostProcessor springfoxHandlerMappingFilter() {
        return new BeanPostProcessor() {
            @Override
            public Object postProcessAfterInitialization(Object bean, String beanName) {
                if (&quot;springfox.documentation.spring.web.plugins.WebMvcRequestHandlerProvider&quot;
                    .equals(bean.getClass().getName())) {
                    filterParsedPatternMappings(bean);
                }
                return bean;
            }
        };
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The real implementation in the lab reflects into Springfox’s &lt;code&gt;handlerMappings&lt;/code&gt; list and removes mappings whose &lt;code&gt;getPatternParser()&lt;/code&gt; is not &lt;code&gt;null&lt;/code&gt;. This is a compatibility patch, not a clean architecture pattern. It depends on Springfox internals and should be covered by an integration test that actually calls &lt;code&gt;/v2/api-docs&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;My rule after reproducing this is:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;If the application is staying on Boot 2 for a while, keep the workaround narrow and tested.&lt;/li&gt;
&lt;li&gt;If the application is moving to Boot 3, do not carry Springfox forward.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;boot-3-migrate-the-documentation-stack&quot;&gt;Boot 3: Migrate The Documentation Stack&lt;/h2&gt;
&lt;p&gt;Spring Boot 3 moved to Jakarta namespaces and a newer Spring Framework baseline. Treating Springfox as something to patch through that migration is the wrong default.&lt;/p&gt;
&lt;p&gt;The springdoc path in the lab uses:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;&amp;lt;dependency&amp;gt;
    &amp;lt;groupId&amp;gt;org.springdoc&amp;lt;/groupId&amp;gt;
    &amp;lt;artifactId&amp;gt;springdoc-openapi-starter-webmvc-ui&amp;lt;/artifactId&amp;gt;
    &amp;lt;version&amp;gt;2.8.17&amp;lt;/version&amp;gt;
&amp;lt;/dependency&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The migrated module also adds &lt;code&gt;spring-boot-starter-security&lt;/code&gt;, a &lt;code&gt;SecurityFilterChain&lt;/code&gt;, a grouped OpenAPI bean, and a bearer-token security scheme:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Configuration
@SecurityScheme(
    name = &quot;bearerAuth&quot;,
    type = SecuritySchemeType.HTTP,
    scheme = &quot;bearer&quot;,
    bearerFormat = &quot;JWT&quot;
)
class OpenApiConfig {
    @Bean
    GroupedOpenApi ordersApi() {
        return GroupedOpenApi.builder()
            .group(&quot;orders&quot;)
            .pathsToMatch(&quot;/orders/**&quot;)
            .build();
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The security filter keeps generated docs reachable while protecting the API endpoint:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    return http
        .csrf(AbstractHttpConfigurer::disable)
        .authorizeHttpRequests(auth -&amp;gt; auth
            .requestMatchers(&quot;/v3/api-docs/**&quot;, &quot;/swagger-ui/**&quot;, &quot;/swagger-ui.html&quot;).permitAll()
            .anyRequest().authenticated()
        )
        .build();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;a href=&quot;https://springdoc.org/&quot;&gt;springdoc documentation&lt;/a&gt; says this starter exposes Swagger UI and the OpenAPI JSON endpoint for Spring Boot applications. In the lab, &lt;code&gt;/v3/api-docs&lt;/code&gt; returned:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;openapi&quot;: &quot;3.1.0&quot;,
  &quot;paths&quot;: {
    &quot;/orders/{orderId}&quot;: {
      &quot;get&quot;: {
        &quot;summary&quot;: &quot;Find one order&quot;
      }
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The important migration difference is not just endpoint naming:&lt;/p&gt;





























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Springfox path&lt;/th&gt;&lt;th&gt;springdoc path&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;/v2/api-docs&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;/v3/api-docs&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Swagger 2 model by default&lt;/td&gt;&lt;td&gt;OpenAPI 3.x model&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;Docket&lt;/code&gt; configuration&lt;/td&gt;&lt;td&gt;starter auto-configuration plus &lt;code&gt;@Operation&lt;/code&gt;, &lt;code&gt;@Tag&lt;/code&gt;, &lt;code&gt;GroupedOpenApi&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Boot 2 compatibility workaround may be needed&lt;/td&gt;&lt;td&gt;Boot 3-compatible library line&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Ad hoc security snippets&lt;/td&gt;&lt;td&gt;explicit docs allowlist plus OpenAPI &lt;code&gt;bearerAuth&lt;/code&gt; scheme&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;If the frontend, QA tooling, or API gateway still expects &lt;code&gt;/v2/api-docs&lt;/code&gt;, the migration must include that consumer contract. Do not silently change the docs endpoint and call the backend migration done.&lt;/p&gt;
&lt;h2 id=&quot;security-configuration-is-a-separate-test&quot;&gt;Security Configuration Is A Separate Test&lt;/h2&gt;
&lt;p&gt;Swagger endpoints also need an explicit Spring Security decision. A Boot 2-era allowlist often looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;.antMatchers(&quot;/swagger-ui/**&quot;, &quot;/v3/api-docs&quot;, &quot;/v2/api-docs&quot;).permitAll()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That line is version-sensitive:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Boot 2 / Spring Security 5 commonly used &lt;code&gt;antMatchers&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Boot 3 / Spring Security 6 uses the newer &lt;code&gt;authorizeHttpRequests&lt;/code&gt; style and &lt;code&gt;requestMatchers&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;More importantly, a docs endpoint should not automatically be public in every environment. For internal admin APIs, it may be better to expose docs only behind VPN, SSO, basic auth, or a non-production profile.&lt;/p&gt;
&lt;p&gt;The lab now turns that into a regression test:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;GET /v3/api-docs as an unauthenticated user
expected: 200 OK

GET /orders/demo-1 as an unauthenticated user
expected: 403 FORBIDDEN
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That test tells you more than a copied allowlist snippet.&lt;/p&gt;
&lt;h2 id=&quot;debugging-checklist&quot;&gt;Debugging Checklist&lt;/h2&gt;
&lt;p&gt;When Swagger/OpenAPI generation breaks after a Spring Boot upgrade, check it in this order:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;Identify the docs library and version.&lt;/p&gt;
&lt;p&gt;Springfox, springdoc, and raw Swagger UI are not interchangeable. The failure mode depends on which library is scanning Spring MVC mappings.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Find the endpoint that should exist.&lt;/p&gt;
&lt;p&gt;For Springfox it is usually &lt;code&gt;/v2/api-docs&lt;/code&gt;. For springdoc it is usually &lt;code&gt;/v3/api-docs&lt;/code&gt;. Test the JSON endpoint before debugging the UI.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Read the stack trace at the scanner layer.&lt;/p&gt;
&lt;p&gt;If the stack contains &lt;code&gt;documentationPluginsBootstrapper&lt;/code&gt;, &lt;code&gt;WebMvcRequestHandlerProvider&lt;/code&gt;, or &lt;code&gt;WebMvcPatternsRequestConditionWrapper&lt;/code&gt;, suspect Springfox and MVC path matching.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Check Spring MVC path matching.&lt;/p&gt;
&lt;p&gt;If you are on Boot 2.6 or 2.7 and still using Springfox, compare &lt;code&gt;path-pattern-parser&lt;/code&gt; and &lt;code&gt;ant-path-matcher&lt;/code&gt; with an integration test.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Separate compatibility from migration.&lt;/p&gt;
&lt;p&gt;A Boot 2 compatibility patch may be acceptable as a temporary hold. It should not become the Boot 3 migration strategy.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Verify security and deployment behavior.&lt;/p&gt;
&lt;p&gt;The JSON docs endpoint, the Swagger UI static assets, reverse-proxy path rewriting, and auth filters can fail independently.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;what-this-proves-in-an-interview&quot;&gt;What This Proves In An Interview&lt;/h2&gt;
&lt;p&gt;This lab is small, but it demonstrates a better debugging habit than “I followed a Swagger setup tutorial.”&lt;/p&gt;
&lt;p&gt;It proves that I can:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Reproduce a framework compatibility failure with a minimal app.&lt;/li&gt;
&lt;li&gt;Turn a stack trace into a hypothesis about request-mapping internals.&lt;/li&gt;
&lt;li&gt;Separate UI rendering from generated API contract availability.&lt;/li&gt;
&lt;li&gt;Keep a risky workaround contained and tested.&lt;/li&gt;
&lt;li&gt;Define a migration path that accounts for consumers of &lt;code&gt;/v2/api-docs&lt;/code&gt; and &lt;code&gt;/v3/api-docs&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The practical conclusion is direct:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;For existing Boot 2.7 services, patch Springfox only as a tested bridge. For Boot 3 services, migrate to springdoc and verify &lt;code&gt;/v3/api-docs&lt;/code&gt; as part of the upgrade.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That is the engineering decision hidden behind what first looked like a Swagger parse error.&lt;/p&gt;</content:encoded><language>en-US</language><category>API</category><category>springfox</category><category>springdoc</category><category>spring boot</category><category>openapi</category><category>swagger</category><author>ystc1247@gmail.com</author></item><item><title>Reducing Kubernetes Load Balancer Cost With Istio Gateway: A Local Routing Lab</title><link>https://theokimdev.com/en/blog/post-72-istio-gateway/</link><guid isPermaLink="true">https://theokimdev.com/en/blog/post-72-istio-gateway/</guid><description>A reproducible kind and Istio Helm lab showing how four hostnames can share one ingress gateway, with an AWS load-balancer cost-floor model and the operational trade-offs.</description><pubDate>Fri, 10 Jul 2026 02:16:37 GMT</pubDate><content:encoded>&lt;h2 id=&quot;the-cost-problem-is-real-but-the-fix-is-not-free&quot;&gt;The Cost Problem Is Real, But The Fix Is Not Free&lt;/h2&gt;
&lt;p&gt;A familiar EKS cost problem appears when every module gets its own ALB-backed Ingress and the number of idle load balancers grows with the number of exposed services. AWS Elastic Load Balancing charges for each hour or partial hour that an Application Load Balancer or Network Load Balancer is running, then adds capacity-unit and related charges on top. If four low-traffic services each get their own ALB, the base hourly floor alone is already four times larger than a single shared edge.&lt;/p&gt;
&lt;p&gt;But “use Istio Gateway” is not a complete answer. It moves complexity from cloud load balancer count into gateway operations, certificate ownership, routing correctness, blast radius, and quota management.&lt;/p&gt;
&lt;p&gt;The local lab in &lt;code&gt;labs/istio-gateway-cost-lab/&lt;/code&gt; does not create AWS resources or claim to reproduce an AWS bill. It proves the routing shape locally with kind and Istio:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Four public hostnames.&lt;/li&gt;
&lt;li&gt;Four backend Kubernetes Services.&lt;/li&gt;
&lt;li&gt;One Istio ingress gateway Service.&lt;/li&gt;
&lt;li&gt;One Istio &lt;code&gt;Gateway&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Four &lt;code&gt;VirtualService&lt;/code&gt; objects attached to the shared gateway.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Then it calculates the base load-balancer hourly floor separately from AWS’s current pricing examples.&lt;/p&gt;
&lt;h2 id=&quot;the-baseline-one-alb-style-ingress-per-module&quot;&gt;The Baseline: One ALB-Style Ingress Per Module&lt;/h2&gt;
&lt;p&gt;The baseline is easy to understand and easy to operate at small scale:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: api-alb
  namespace: apps
  annotations:
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/target-type: ip
    alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:region:account:certificate/api-placeholder
    alb.ingress.kubernetes.io/ssl-redirect: &quot;443&quot;
    alb.ingress.kubernetes.io/listen-ports: &apos;[{&quot;HTTP&quot;: 80}, {&quot;HTTPS&quot;:443}]&apos;
spec:
  ingressClassName: alb
  rules:
    - host: api.local.test
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: api
                port:
                  number: 8080
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each service owns its own edge resource. That is attractive because the blast radius is small: if the &lt;code&gt;api&lt;/code&gt; ingress is wrong, &lt;code&gt;admin&lt;/code&gt; does not necessarily break. DNS, certificate, WAF, and access log ownership are also easier to reason about per module.&lt;/p&gt;
&lt;p&gt;The cost downside is the number of load balancers. In the lab’s baseline manifest, four hostnames produce four ALB-style Ingress objects:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;api.local.test
admin.local.test
billing.local.test
backoffice.local.test
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That is a reasonable choice for high-value production services. It becomes wasteful for low-traffic development, release, admin, or internal-facing modules that still need stable HTTPS exposure.&lt;/p&gt;
&lt;h2 id=&quot;the-shared-gateway-model&quot;&gt;The Shared Gateway Model&lt;/h2&gt;
&lt;p&gt;The shared model puts the external edge behind one gateway Service. In EKS, that Service is commonly backed by one NLB. Istio’s ingress gateway then handles host-based routing through &lt;code&gt;Gateway&lt;/code&gt; and &lt;code&gt;VirtualService&lt;/code&gt; resources.&lt;/p&gt;
&lt;p&gt;The lab’s Gateway is intentionally small:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;apiVersion: networking.istio.io/v1
kind: Gateway
metadata:
  name: shared-edge-gateway
  namespace: istio-system
spec:
  selector:
    istio: ingressgateway
  servers:
    - port:
        number: 80
        name: http
        protocol: HTTP
      hosts:
        - api.local.test
        - admin.local.test
        - billing.local.test
        - backoffice.local.test
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each application gets a route:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
  name: billing-route
  namespace: apps
spec:
  hosts:
    - billing.local.test
  gateways:
    - istio-system/shared-edge-gateway
  http:
    - route:
        - destination:
            host: billing.apps.svc.cluster.local
            port:
              number: 8080
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The key difference is ownership. The cloud load balancer is no longer the only routing object. It becomes the entry point to an Envoy gateway, and the actual host routing lives in Istio configuration.&lt;/p&gt;
&lt;h2 id=&quot;local-lab&quot;&gt;Local Lab&lt;/h2&gt;
&lt;p&gt;Run the lab:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cd labs/istio-gateway-cost-lab
./scripts/run-lab.sh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The successful rerun used ID &lt;code&gt;20260710T023921Z&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Tooling:&lt;/p&gt;

























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Tool&lt;/th&gt;&lt;th&gt;Version from the run&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Docker&lt;/td&gt;&lt;td&gt;29.3.0&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;kind&lt;/td&gt;&lt;td&gt;v0.31.0&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;kubectl client&lt;/td&gt;&lt;td&gt;v1.34.1&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Helm&lt;/td&gt;&lt;td&gt;v4.1.4&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The script creates a kind cluster, installs Istio with Helm, deploys four &lt;code&gt;hashicorp/http-echo&lt;/code&gt; services, creates a local self-signed TLS secret, applies the shared Gateway, VirtualServices, DestinationRule, PDB, and HPA manifests, scales the ingress gateway to two replicas, port-forwards ports 80 and 443, and then runs HTTP, HTTPS, load-loop, failure, and rollback checks.&lt;/p&gt;
&lt;p&gt;The gateway Service looked like this in the local cluster:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;NAME                   TYPE           CLUSTER-IP     EXTERNAL-IP   PORT(S)                                      AGE   SELECTOR
istio-ingressgateway   LoadBalancer   10.96.173.72   &amp;lt;pending&amp;gt;     15021:32402/TCP,80:30705/TCP,443:30744/TCP   20s   app=istio-ingressgateway,istio=ingressgateway
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;&amp;lt;pending&amp;gt;&lt;/code&gt; external IP is expected in a plain kind cluster. The lab uses &lt;code&gt;kubectl port-forward&lt;/code&gt; to exercise the gateway locally.&lt;/p&gt;
&lt;p&gt;Istio resources:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;NAMESPACE      NAME                  AGE
istio-system   shared-edge-gateway   1s

NAMESPACE   NAME               GATEWAYS                               HOSTS
apps        admin-route        [&quot;istio-system/shared-edge-gateway&quot;]   [&quot;admin.local.test&quot;]
apps        api-route          [&quot;istio-system/shared-edge-gateway&quot;]   [&quot;api.local.test&quot;]
apps        backoffice-route   [&quot;istio-system/shared-edge-gateway&quot;]   [&quot;backoffice.local.test&quot;]
apps        billing-route      [&quot;istio-system/shared-edge-gateway&quot;]   [&quot;billing.local.test&quot;]

NAMESPACE   NAME            HOST
apps        echo-services   *.apps.svc.cluster.local
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Route checks:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;### api.local.test
api service via shared istio gateway

### admin.local.test
admin service via shared istio gateway

### billing.local.test
billing service via shared istio gateway

### backoffice.local.test
backoffice service via shared istio gateway
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The full evidence is saved under &lt;code&gt;/resources/blog/post-72-istio-gateway/&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The rerun also verified HTTPS routing through the Istio gateway. The script generated a one-day self-signed certificate for the four &lt;code&gt;*.local.test&lt;/code&gt; hostnames and stored it as a Kubernetes TLS secret named &lt;code&gt;shared-local-tls&lt;/code&gt;. Then it port-forwarded &lt;code&gt;18443:443&lt;/code&gt; and called each hostname with &lt;code&gt;curl --resolve&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;### api.local.test
api service via shared istio gateway

### admin.local.test
admin service via shared istio gateway

### billing.local.test
billing service via shared istio gateway

### backoffice.local.test
backoffice service via shared istio gateway
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That is not a production certificate workflow, but it proves the Istio-side TLS termination path used by the manifest. In production, I would replace this with cert-manager, ACM-backed termination at the NLB, or a centrally managed secret rotation process.&lt;/p&gt;
&lt;p&gt;The load loop was intentionally small, but it keeps the route checks from being a single lucky request:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;api.local.test ok_requests=20
admin.local.test ok_requests=20
billing.local.test ok_requests=20
backoffice.local.test ok_requests=20
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For failure and rollback, the lab patched the &lt;code&gt;billing-route&lt;/code&gt; VirtualService to a missing backend host, observed a failed route, then reapplied the known-good manifest:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;### before failure
billing service via shared istio gateway

### after bad route patch
http_code=503
body=

### after rollback
billing service via shared istio gateway
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That is the operational check I care about. A shared gateway design must have a boring rollback path, not only a happy-path route.&lt;/p&gt;
&lt;p&gt;The gateway scale and availability objects were also captured:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;NAME                   READY   UP-TO-DATE   AVAILABLE
istio-ingressgateway   2/2     2            2

poddisruptionbudget.policy/istio-ingressgateway   MIN AVAILABLE 1
horizontalpodautoscaler.autoscaling/istio-ingressgateway   MINPODS 2   MAXPODS 4
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The HPA shows unknown CPU in kind because the lab does not install metrics-server. That is expected. The manifest still documents the autoscaling contract, while the actual clean run proves that two gateway replicas can be rolled out in the local cluster.&lt;/p&gt;
&lt;h2 id=&quot;cost-floor-model&quot;&gt;Cost-Floor Model&lt;/h2&gt;
&lt;p&gt;The lab’s cost script compares the controlled manifests:&lt;/p&gt;





























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Model&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Public hostnames&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Load-balancer-shaped resources&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;730-hour monthly floor&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Per-module ALB Ingress&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;4&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;4&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;$65.70&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Shared Istio Gateway/NLB&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;4&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;1&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;$16.43&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Difference&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;-3&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;-$49.28&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The formula is deliberately limited:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;load_balancer_count * 0.0225 USD/hour * 730 hours
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;0.0225 USD/hour&lt;/code&gt; value comes from the AWS ELB pricing examples for US-East-1, checked on 2026-07-10. AWS also charges for LCU/NLCU usage, data transfer, public IPv4 addresses, and other services that may sit in front of or behind the load balancer. NAT Gateway cost is not part of this public ingress path unless the architecture sends egress or private-subnet flows through NAT; the model lists it separately as excluded rather than pretending it is zero everywhere.&lt;/p&gt;
&lt;p&gt;The script also prints a simplified traffic-dependent estimate. It uses processed bytes only, so it is not a bill and it does not include Envoy worker-node cost:&lt;/p&gt;





































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Profile&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Processed bytes&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Capacity units used for estimate&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;4 ALB model&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;1 NLB + Istio model&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Difference&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;low&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0.3 MB/s&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;1.08&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;$72.01&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;$21.16&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;-$50.85&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;medium&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;5.0 MB/s&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;18.00&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;$170.82&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;$95.27&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;-$75.56&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;high&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;25.0 MB/s&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;90.00&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;$591.30&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;$410.63&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;-$180.68&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;So the claim is not “Istio saves exactly $49.28.” The claim is:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Consolidating four low-traffic public edges into one shared gateway removes three running load-balancer hourly floors before traffic-dependent charges.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That is a narrower and more defensible statement. At higher traffic, the answer depends on bytes, new connections, active connections, rule evaluations, cross-zone behavior, gateway pod size, and whether ALB L7 features such as WAF integration are replacing logic that would otherwise move into Envoy.&lt;/p&gt;
&lt;h2 id=&quot;tls-and-certificates&quot;&gt;TLS And Certificates&lt;/h2&gt;
&lt;p&gt;A shared NLB path can terminate TLS with attached ACM certificate ARNs, but that is not the only design.&lt;/p&gt;
&lt;p&gt;There are two common options:&lt;/p&gt;




















&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;TLS model&lt;/th&gt;&lt;th&gt;Where TLS terminates&lt;/th&gt;&lt;th&gt;What to check&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;NLB TLS listener&lt;/td&gt;&lt;td&gt;AWS NLB terminates TLS and forwards HTTP/TCP to the gateway&lt;/td&gt;&lt;td&gt;ACM certificate list, listener config, AWS Load Balancer Controller annotations, certificate quota&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Istio gateway TLS&lt;/td&gt;&lt;td&gt;NLB passes traffic to Envoy and Istio terminates TLS with Kubernetes secrets&lt;/td&gt;&lt;td&gt;Secret ownership, SDS behavior, gateway TLS config, namespace boundaries, cert-manager workflow&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The local lab exercises the second option with a self-signed secret:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;apiVersion: networking.istio.io/v1
kind: Gateway
spec:
  servers:
    - port:
        number: 443
        name: https
        protocol: HTTPS
      tls:
        mode: SIMPLE
        credentialName: shared-local-tls
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The sample patch in the lab shows the NLB-annotation style:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;metadata&quot;: {
    &quot;annotations&quot;: {
      &quot;service.beta.kubernetes.io/aws-load-balancer-type&quot;: &quot;external&quot;,
      &quot;service.beta.kubernetes.io/aws-load-balancer-nlb-target-type&quot;: &quot;ip&quot;,
      &quot;service.beta.kubernetes.io/aws-load-balancer-scheme&quot;: &quot;internet-facing&quot;,
      &quot;service.beta.kubernetes.io/aws-load-balancer-ssl-cert&quot;: &quot;arn:aws:acm:region:account:certificate/api-placeholder,arn:aws:acm:region:account:certificate/admin-placeholder&quot;,
      &quot;service.beta.kubernetes.io/aws-load-balancer-backend-protocol&quot;: &quot;http&quot;,
      &quot;service.beta.kubernetes.io/aws-load-balancer-ssl-ports&quot;: &quot;443&quot;
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In a real cluster, I would not apply that blindly. I would first decide whether TLS belongs at the AWS edge or in Istio, then verify certificate count, rotation ownership, access logs, and whether the application needs client certificate or SNI-specific behavior.&lt;/p&gt;
&lt;h2 id=&quot;quotas-are-part-of-the-design&quot;&gt;Quotas Are Part Of The Design&lt;/h2&gt;
&lt;p&gt;AWS quota pages matter more as soon as one gateway represents many applications.&lt;/p&gt;
&lt;p&gt;The NLB quota page lists limits such as certificates per NLB, listeners per NLB, NLBs per Region, and targets per Availability Zone per NLB. The ALB quota page lists its own certificate, rule, target, and load-balancer quotas.&lt;/p&gt;
&lt;p&gt;The engineering check is simple:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;number of hosts
number of certificates
number of listeners
number of backend Services
number of gateway pods
number of target registrations per AZ
expected connections and bytes
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If one shared gateway approaches those limits, the cost-saving design becomes a scaling risk. At that point, split by environment, traffic class, domain ownership, or service criticality.&lt;/p&gt;
&lt;h2 id=&quot;operational-trade-offs&quot;&gt;Operational Trade-Offs&lt;/h2&gt;
&lt;p&gt;The shared gateway pattern is useful when the services are low traffic, operationally related, and owned by a team that can operate the ingress plane well.&lt;/p&gt;
&lt;p&gt;It is risky when unrelated systems are put behind the same edge only because the bill looks cleaner.&lt;/p&gt;
&lt;p&gt;The main trade-offs:&lt;/p&gt;








































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Concern&lt;/th&gt;&lt;th&gt;Per-module ALB&lt;/th&gt;&lt;th&gt;Shared Istio Gateway&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Load-balancer hourly floor&lt;/td&gt;&lt;td&gt;Higher as modules increase&lt;/td&gt;&lt;td&gt;Lower&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Failure blast radius&lt;/td&gt;&lt;td&gt;Smaller&lt;/td&gt;&lt;td&gt;Larger&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Routing flexibility&lt;/td&gt;&lt;td&gt;AWS ALB rules and controller behavior&lt;/td&gt;&lt;td&gt;Istio routing model&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Certificate ownership&lt;/td&gt;&lt;td&gt;Per ingress or group&lt;/td&gt;&lt;td&gt;Centralized or gateway-managed&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Debug path&lt;/td&gt;&lt;td&gt;AWS LB -&amp;gt; target group -&amp;gt; Service&lt;/td&gt;&lt;td&gt;AWS LB -&amp;gt; Envoy gateway -&amp;gt; VirtualService -&amp;gt; Service&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Required platform skill&lt;/td&gt;&lt;td&gt;Kubernetes Ingress and AWS LB controller&lt;/td&gt;&lt;td&gt;Istio, Envoy, gateway operations, mesh observability&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;For production, I would add at least:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Two or more ingress gateway replicas.&lt;/li&gt;
&lt;li&gt;PodDisruptionBudget for the gateway.&lt;/li&gt;
&lt;li&gt;HPA or fixed capacity sized from connection and CPU data.&lt;/li&gt;
&lt;li&gt;Readiness checks and synthetic route checks per hostname.&lt;/li&gt;
&lt;li&gt;Access logs and Envoy metrics.&lt;/li&gt;
&lt;li&gt;Alerting on 5xx, route misses, and gateway saturation.&lt;/li&gt;
&lt;li&gt;A documented certificate rotation path.&lt;/li&gt;
&lt;li&gt;A clear split rule for when a service must get its own edge.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The lab now includes the first three as manifests or execution steps: &lt;code&gt;gateway-ha-overlays.yaml&lt;/code&gt; defines a PDB, HPA, and DestinationRule; &lt;code&gt;run-lab.sh&lt;/code&gt; scales the gateway deployment to two replicas and records the result. It does not prove multi-AZ behavior because kind is a single local cluster, so cross-zone target registration and zonal failure handling remain cloud-environment validation items.&lt;/p&gt;
&lt;p&gt;Layer 4 versus Layer 7 is the core architecture decision. An NLB in front of Istio keeps the cloud edge simple and can preserve source IP depending on target type and configuration, but HTTP routing, retries, mTLS policy, and per-host behavior move into Envoy. An ALB Ingress keeps AWS-native L7 routing, health checks, and WAF attachment closer to the edge, but multiplying ALBs increases fixed hourly cost and rule ownership. HTTP/2 and gRPC make this choice sharper: verify protocol negotiation, idle timeouts, and whether TLS terminates at the NLB, ALB, or Istio gateway.&lt;/p&gt;
&lt;h2 id=&quot;when-i-would-use-it&quot;&gt;When I Would Use It&lt;/h2&gt;
&lt;p&gt;I would use a shared Istio Gateway for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Development and release environments.&lt;/li&gt;
&lt;li&gt;Low-traffic admin tools.&lt;/li&gt;
&lt;li&gt;Internal dashboards that still need stable DNS and HTTPS.&lt;/li&gt;
&lt;li&gt;Services owned by the same platform or product team.&lt;/li&gt;
&lt;li&gt;Hostnames whose certificate and DNS ownership is already centralized.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I would avoid it, or split the gateway, for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;High-traffic customer-facing paths.&lt;/li&gt;
&lt;li&gt;Services with separate compliance boundaries.&lt;/li&gt;
&lt;li&gt;Services with incompatible TLS requirements.&lt;/li&gt;
&lt;li&gt;Teams that cannot safely operate Istio routing.&lt;/li&gt;
&lt;li&gt;Any route where a shared gateway outage would be unacceptable.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;sources-and-reproduction&quot;&gt;Sources And Reproduction&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Lab code: &lt;code&gt;labs/istio-gateway-cost-lab/&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Cost summary: &lt;code&gt;/resources/blog/post-72-istio-gateway/cost-summary.md&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Route checks: &lt;code&gt;/resources/blog/post-72-istio-gateway/route-checks.txt&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;TLS route checks: &lt;code&gt;/resources/blog/post-72-istio-gateway/tls-route-checks.txt&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Load loop: &lt;code&gt;/resources/blog/post-72-istio-gateway/load-loop.txt&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Failure and rollback output: &lt;code&gt;/resources/blog/post-72-istio-gateway/failure-rollback.txt&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Gateway HA objects: &lt;code&gt;/resources/blog/post-72-istio-gateway/gateway-ha-objects.txt&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Gateway and VirtualService output: &lt;code&gt;/resources/blog/post-72-istio-gateway/gateway-virtualservices.txt&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;AWS ELB pricing: &lt;a href=&quot;https://aws.amazon.com/elasticloadbalancing/pricing/&quot;&gt;Elastic Load Balancing pricing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;AWS NLB quotas: &lt;a href=&quot;https://docs.aws.amazon.com/elasticloadbalancing/latest/network/load-balancer-limits.html&quot;&gt;Quotas for your Network Load Balancers&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;AWS ALB quotas: &lt;a href=&quot;https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-limits.html&quot;&gt;Quotas for your Application Load Balancers&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Istio Gateway reference: &lt;a href=&quot;https://istio.io/latest/docs/reference/config/networking/gateway/&quot;&gt;Gateway&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Istio VirtualService reference: &lt;a href=&quot;https://istio.io/latest/docs/reference/config/networking/virtual-service/&quot;&gt;VirtualService&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Istio Helm install: &lt;a href=&quot;https://istio.io/latest/docs/setup/install/helm/&quot;&gt;Install with Helm&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content:encoded><language>en-US</language><category>Kubernetes</category><category>Istio</category><category>Gateway</category><category>VirtualService</category><category>EKS</category><category>NLB</category><category>Cost</category><author>ystc1247@gmail.com</author></item><item><title>Hibernate Slow Query Logging in Spring Boot: YAML, .properties, and Provider Settings</title><link>https://theokimdev.com/en/blog/post-66-hibernate-slow-query-logging-yaml-properties/</link><guid isPermaLink="true">https://theokimdev.com/en/blog/post-66-hibernate-slow-query-logging-yaml-properties/</guid><description>A reproducible Spring Boot and Hibernate lab showing which slow-query logging properties actually reach Hibernate across Boot 2/Hibernate 5 and Boot 3/Hibernate 6.</description><pubDate>Fri, 10 Jul 2026 02:03:44 GMT</pubDate><content:encoded>&lt;h2 id=&quot;the-real-bug-was-not-yaml&quot;&gt;The Real Bug Was Not YAML&lt;/h2&gt;
&lt;p&gt;“Hibernate slow query logging works in &lt;code&gt;hibernate.properties&lt;/code&gt;, but not in YAML” is an imprecise diagnosis. The useful question is narrower:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Did the Hibernate slow-query threshold enter Hibernate’s provider settings, or did it only exist somewhere in Spring’s external configuration?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Those are different states. A value can appear in &lt;code&gt;application.yml&lt;/code&gt; and still not be forwarded to the JPA provider. A value can also be overridden by another config file before Hibernate ever sees it.&lt;/p&gt;
&lt;p&gt;The runnable lab in &lt;code&gt;labs/hibernate-slow-query-lab/&lt;/code&gt; compares Spring Boot 2.7.18 with Hibernate 5.6.15.Final and Spring Boot 3.3.6 with Hibernate 6.5.3.Final, using PostgreSQL 16 in Docker and a deterministic slow SQL probe:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;select 1;
select 1 from pg_sleep(0.02);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The threshold is 1 ms in the positive cases. The slow query sleeps for about 20 ms, so a correctly delivered threshold should emit an &lt;code&gt;org.hibernate.SQL_SLOW&lt;/code&gt; log.&lt;/p&gt;
&lt;h2 id=&quot;short-answer&quot;&gt;Short Answer&lt;/h2&gt;
&lt;p&gt;For Spring Boot JPA applications, put Hibernate-native settings under &lt;code&gt;spring.jpa.properties&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;For Hibernate 5.6:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;spring:
  jpa:
    properties:
      hibernate:
        session:
          events:
            log:
              LOG_QUERIES_SLOWER_THAN_MS: 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-properties&quot;&gt;spring.jpa.properties.hibernate.session.events.log.LOG_QUERIES_SLOWER_THAN_MS=1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For Hibernate 6.5, this shorter provider key also worked in the lab:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-properties&quot;&gt;spring.jpa.properties.hibernate.log_slow_query=1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Do not put the provider key at the top level and expect Spring Boot JPA auto-configuration to pass it to Hibernate:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;hibernate:
  session:
    events:
      log:
        LOG_QUERIES_SLOWER_THAN_MS: 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In this lab, that top-level form produced no slow-query log in both Boot 2/Hibernate 5 and Boot 3/Hibernate 6.&lt;/p&gt;
&lt;h2 id=&quot;why-this-is-easy-to-misconfigure&quot;&gt;Why This Is Easy To Misconfigure&lt;/h2&gt;
&lt;p&gt;There are three independent layers that are easy to collapse into one mental model:&lt;/p&gt;

























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Layer&lt;/th&gt;&lt;th&gt;What it owns&lt;/th&gt;&lt;th&gt;Failure mode&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Spring external configuration&lt;/td&gt;&lt;td&gt;Loads &lt;code&gt;application.yml&lt;/code&gt;, &lt;code&gt;application.properties&lt;/code&gt;, environment variables, command-line args, and profile files&lt;/td&gt;&lt;td&gt;The value is overridden or loaded in a different order than expected&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Spring Boot JPA auto-configuration&lt;/td&gt;&lt;td&gt;Builds the JPA &lt;code&gt;EntityManagerFactory&lt;/code&gt; and provider property map&lt;/td&gt;&lt;td&gt;The value exists in Spring config but is not forwarded to Hibernate&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Hibernate&lt;/td&gt;&lt;td&gt;Interprets provider-native settings and emits SQL logs&lt;/td&gt;&lt;td&gt;The key name is wrong for the Hibernate version or logger level is too low&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Spring Boot’s &lt;a href=&quot;https://docs.spring.io/spring-boot/reference/features/external-config.html&quot;&gt;externalized configuration documentation&lt;/a&gt; describes the property-source order and explicitly recommends using one file format for the application. It also says that when &lt;code&gt;.properties&lt;/code&gt; and YAML config files are in the same location, &lt;code&gt;.properties&lt;/code&gt; wins.&lt;/p&gt;
&lt;p&gt;Spring Boot’s application properties appendix lists &lt;code&gt;spring.jpa.properties.*&lt;/code&gt; as the namespace for additional native JPA provider properties. That is the bridge this post is about. Hibernate’s own key is not the full Spring Boot key; Spring Boot’s &lt;code&gt;spring.jpa.properties.&lt;/code&gt; prefix is how the native Hibernate key gets placed into the provider map.&lt;/p&gt;
&lt;h2 id=&quot;lab-design&quot;&gt;Lab Design&lt;/h2&gt;
&lt;p&gt;The lab uses Docker Compose rather than an in-memory database:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;services:
  postgres:
    image: postgres:16-alpine
    ports:
      - &quot;55433:5432&quot;
    environment:
      POSTGRES_DB: hibernate_slow_query
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres

  gradle:
    image: gradle:8.10.2-jdk17
    depends_on:
      postgres:
        condition: service_healthy
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I used PostgreSQL because &lt;code&gt;pg_sleep(0.02)&lt;/code&gt; creates an actual slow database statement. The test is not measuring controller latency, Java sleeps, or result processing time. It is testing whether Hibernate’s JDBC statement logger sees a query execution that crosses the configured threshold.&lt;/p&gt;
&lt;p&gt;The base Spring Boot test configuration intentionally keeps Hibernate statistics disabled:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;spring:
  jpa:
    properties:
      hibernate:
        format_sql: false
        generate_statistics: false

logging:
  level:
    org.hibernate.SQL: off
    org.hibernate.SQL_SLOW: info
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That matters because slow-query logging should not require turning on every Hibernate statistic. The lab listens to &lt;code&gt;org.hibernate.SQL_SLOW&lt;/code&gt;, not to the broad &lt;code&gt;org.hibernate&lt;/code&gt; logger.&lt;/p&gt;
&lt;p&gt;Run the lab:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cd labs/hibernate-slow-query-lab
./scripts/run-matrix.sh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The successful rerun used ID &lt;code&gt;20260710T024828Z&lt;/code&gt;. Its generated matrix is saved at &lt;code&gt;/resources/blog/post-66-hibernate-slow-query-logging-yaml-properties/matrix-summary.md&lt;/code&gt;, and a short log excerpt is saved at &lt;code&gt;/resources/blog/post-66-hibernate-slow-query-logging-yaml-properties/slow-query-lines.txt&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id=&quot;how-the-test-avoids-false-positives&quot;&gt;How The Test Avoids False Positives&lt;/h2&gt;
&lt;p&gt;My first version used Spring Boot’s output-capture test extension and searched the entire captured console output for &lt;code&gt;SlowQuery&lt;/code&gt;. That was too loose. A multi-context Gradle test process can include earlier log lines, and Hibernate 5 and Hibernate 6 do not use exactly the same slow-query message text.&lt;/p&gt;
&lt;p&gt;The final test helper attaches a short-lived Logback appender directly around the probe queries:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;static boolean observesSlowQuery(EntityManager entityManager) {
    Logger slowQueryLogger = (Logger) LoggerFactory.getLogger(&quot;org.hibernate.SQL_SLOW&quot;);
    ListAppender&amp;lt;ILoggingEvent&amp;gt; appender = new ListAppender&amp;lt;&amp;gt;();
    appender.start();
    slowQueryLogger.addAppender(appender);

    try {
        entityManager.createNativeQuery(&quot;select 1&quot;).getSingleResult();
        entityManager.createNativeQuery(&quot;select 1 from pg_sleep(0.02)&quot;).getSingleResult();
    } finally {
        slowQueryLogger.detachAppender(appender);
        appender.stop();
    }

    return appender.list.stream()
        .map(ILoggingEvent::getFormattedMessage)
        .anyMatch(message -&amp;gt; message.contains(&quot;SlowQuery&quot;) || message.contains(&quot;Slow query&quot;));
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That does two useful things:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;It checks only log events produced during the probe queries.&lt;/li&gt;
&lt;li&gt;It accepts both Hibernate 5’s &lt;code&gt;SlowQuery:&lt;/code&gt; format and Hibernate 6’s &lt;code&gt;Slow query took ...&lt;/code&gt; format.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;observed-results&quot;&gt;Observed Results&lt;/h2&gt;
&lt;p&gt;The clean run produced this matrix:&lt;/p&gt;





















































































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Boot&lt;/th&gt;&lt;th&gt;Hibernate&lt;/th&gt;&lt;th&gt;Config case&lt;/th&gt;&lt;th&gt;Format&lt;/th&gt;&lt;th&gt;Property path&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Observed log&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;2.7.18&lt;/td&gt;&lt;td&gt;5.6.15.Final&lt;/td&gt;&lt;td&gt;spring jpa properties&lt;/td&gt;&lt;td&gt;properties&lt;/td&gt;&lt;td&gt;&lt;code&gt;spring.jpa.properties.hibernate.session.events.log.LOG_QUERIES_SLOWER_THAN_MS&lt;/code&gt;&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;true&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;2.7.18&lt;/td&gt;&lt;td&gt;5.6.15.Final&lt;/td&gt;&lt;td&gt;spring jpa properties&lt;/td&gt;&lt;td&gt;yaml&lt;/td&gt;&lt;td&gt;&lt;code&gt;spring.jpa.properties.hibernate.session.events.log.LOG_QUERIES_SLOWER_THAN_MS&lt;/code&gt;&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;true&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;2.7.18&lt;/td&gt;&lt;td&gt;5.6.15.Final&lt;/td&gt;&lt;td&gt;properties overrides yaml&lt;/td&gt;&lt;td&gt;yaml+properties&lt;/td&gt;&lt;td&gt;&lt;code&gt;spring.jpa.properties.hibernate.session.events.log.LOG_QUERIES_SLOWER_THAN_MS&lt;/code&gt;&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;false&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;2.7.18&lt;/td&gt;&lt;td&gt;5.6.15.Final&lt;/td&gt;&lt;td&gt;top-level hibernate property&lt;/td&gt;&lt;td&gt;yaml&lt;/td&gt;&lt;td&gt;&lt;code&gt;hibernate.session.events.log.LOG_QUERIES_SLOWER_THAN_MS&lt;/code&gt;&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;false&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;3.3.6&lt;/td&gt;&lt;td&gt;6.5.3.Final&lt;/td&gt;&lt;td&gt;spring jpa properties&lt;/td&gt;&lt;td&gt;properties&lt;/td&gt;&lt;td&gt;&lt;code&gt;spring.jpa.properties.hibernate.session.events.log.LOG_QUERIES_SLOWER_THAN_MS&lt;/code&gt;&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;true&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;3.3.6&lt;/td&gt;&lt;td&gt;6.5.3.Final&lt;/td&gt;&lt;td&gt;spring jpa properties&lt;/td&gt;&lt;td&gt;yaml&lt;/td&gt;&lt;td&gt;&lt;code&gt;spring.jpa.properties.hibernate.session.events.log.LOG_QUERIES_SLOWER_THAN_MS&lt;/code&gt;&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;true&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;3.3.6&lt;/td&gt;&lt;td&gt;6.5.3.Final&lt;/td&gt;&lt;td&gt;hibernate 6 short alias&lt;/td&gt;&lt;td&gt;properties&lt;/td&gt;&lt;td&gt;&lt;code&gt;spring.jpa.properties.hibernate.log_slow_query&lt;/code&gt;&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;true&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;3.3.6&lt;/td&gt;&lt;td&gt;6.5.3.Final&lt;/td&gt;&lt;td&gt;properties overrides yaml&lt;/td&gt;&lt;td&gt;yaml+properties&lt;/td&gt;&lt;td&gt;&lt;code&gt;spring.jpa.properties.hibernate.session.events.log.LOG_QUERIES_SLOWER_THAN_MS&lt;/code&gt;&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;false&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;3.3.6&lt;/td&gt;&lt;td&gt;6.5.3.Final&lt;/td&gt;&lt;td&gt;top-level hibernate property&lt;/td&gt;&lt;td&gt;yaml&lt;/td&gt;&lt;td&gt;&lt;code&gt;hibernate.session.events.log.LOG_QUERIES_SLOWER_THAN_MS&lt;/code&gt;&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;false&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The positive cases produced the expected logger output:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Hibernate 5.6.15.Final:
SlowQuery: 21 milliseconds. SQL: &apos;... select 1 from pg_sleep(0.02)&apos;

Hibernate 6.5.3.Final:
Slow query took 23 milliseconds [select 1 from pg_sleep(0.02)]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the important conclusion: YAML was not the problem. YAML worked when the property was nested under &lt;code&gt;spring.jpa.properties&lt;/code&gt;. The top-level &lt;code&gt;hibernate.*&lt;/code&gt; key was the problem because Spring Boot did not forward that value as a JPA provider property in this setup.&lt;/p&gt;
&lt;h2 id=&quot;why-properties-changed-the-result&quot;&gt;Why &lt;code&gt;.properties&lt;/code&gt; Changed The Result&lt;/h2&gt;
&lt;p&gt;The precedence case deliberately puts two contradictory thresholds in the same profile:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;spring:
  jpa:
    properties:
      hibernate:
        session:
          events:
            log:
              LOG_QUERIES_SLOWER_THAN_MS: 1
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-properties&quot;&gt;spring.jpa.properties.hibernate.session.events.log.LOG_QUERIES_SLOWER_THAN_MS=100000
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The 20 ms &lt;code&gt;pg_sleep()&lt;/code&gt; query should log if the threshold is 1 ms. It should not log if the threshold is 100,000 ms. The matrix observed &lt;code&gt;false&lt;/code&gt;, which means the &lt;code&gt;.properties&lt;/code&gt; value won in that same-location mixed-format scenario.&lt;/p&gt;
&lt;p&gt;That matches Spring Boot’s documented guidance: do not mix YAML and &lt;code&gt;.properties&lt;/code&gt; for the same configuration surface unless you are intentionally relying on precedence. For operational config, “the value exists somewhere” is not enough. The winning value is what matters.&lt;/p&gt;
&lt;h2 id=&quot;hibernate-5-vs-hibernate-6-key-names&quot;&gt;Hibernate 5 vs Hibernate 6 Key Names&lt;/h2&gt;
&lt;p&gt;Hibernate 5.6 exposes the slow-query setting as:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;hibernate.session.events.log.LOG_QUERIES_SLOWER_THAN_MS
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The Hibernate 5.6 constant values page lists that string under &lt;code&gt;LOG_SLOW_QUERY&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Hibernate 6.5’s &lt;code&gt;JdbcSettings.LOG_SLOW_QUERY&lt;/code&gt; documents the shorter key:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;hibernate.log_slow_query
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The lab also observed that the longer key still worked with Spring Boot 3.3.6 and Hibernate 6.5.3.Final when it was passed through &lt;code&gt;spring.jpa.properties&lt;/code&gt;. I would not build future production configuration around that observation alone. For Hibernate 6, prefer the documented Hibernate 6 key unless you have a compatibility reason to keep the older-looking one.&lt;/p&gt;
&lt;p&gt;The configuration path is therefore explicit: Spring loads the winning external value, Spring Boot copies &lt;code&gt;spring.jpa.properties.*&lt;/code&gt; into the provider map, and Hibernate interprets the native key and emits the log.&lt;/p&gt;
&lt;h2 id=&quot;production-checklist&quot;&gt;Production Checklist&lt;/h2&gt;
&lt;p&gt;When slow-query logging does not appear, debug it in this order:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;Confirm the provider path.&lt;/p&gt;
&lt;p&gt;The key should be under &lt;code&gt;spring.jpa.properties&lt;/code&gt;. If the native Hibernate key starts at the top level, Spring Boot may load it as environment config but still not put it into Hibernate’s provider settings.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Confirm the active profile and winning file.&lt;/p&gt;
&lt;p&gt;Check whether &lt;code&gt;application-prod.properties&lt;/code&gt; overrides &lt;code&gt;application-prod.yml&lt;/code&gt;, whether command-line args override both, and whether an environment variable is setting a different threshold.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Confirm the Hibernate version.&lt;/p&gt;
&lt;p&gt;Hibernate 5.6 and Hibernate 6.5 have different documented key names and different log message shapes.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Confirm the logger.&lt;/p&gt;
&lt;p&gt;For this signal, enable &lt;code&gt;org.hibernate.SQL_SLOW&lt;/code&gt;. Turning on broad SQL logging is noisier and does not prove the threshold is working.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Confirm the query is actually slow at the JDBC statement boundary.&lt;/p&gt;
&lt;p&gt;The lab uses &lt;code&gt;pg_sleep(0.02)&lt;/code&gt; because it forces database-side execution time. A slow API response can be caused by connection acquisition, transaction waits, serialization, remote calls, or application code that Hibernate’s slow-query logger will not see.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;what-i-would-put-in-a-real-service&quot;&gt;What I Would Put In A Real Service&lt;/h2&gt;
&lt;p&gt;For a Spring Boot 3 and Hibernate 6 service, I would start with:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;spring:
  jpa:
    properties:
      hibernate:
        log_slow_query: 200

logging:
  level:
    org.hibernate.SQL_SLOW: info
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The threshold is workload-dependent. &lt;code&gt;200 ms&lt;/code&gt; might be too noisy for an OLAP-heavy internal tool and too relaxed for a low-latency transactional endpoint. I would set it after looking at request SLOs, database latency percentiles, and log volume.&lt;/p&gt;
&lt;p&gt;For Spring Boot 2 and Hibernate 5:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;spring:
  jpa:
    properties:
      hibernate:
        session:
          events:
            log:
              LOG_QUERIES_SLOWER_THAN_MS: 200

logging:
  level:
    org.hibernate.SQL_SLOW: info
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I would also keep one format per environment file. If the project uses YAML, keep the JPA/Hibernate config in YAML. If it uses &lt;code&gt;.properties&lt;/code&gt;, keep it there. Mixing both can be useful for a test matrix, but it is a bad default for production readability.&lt;/p&gt;
&lt;h2 id=&quot;sources-and-reproduction&quot;&gt;Sources And Reproduction&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Lab code: &lt;code&gt;labs/hibernate-slow-query-lab/&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Generated matrix: &lt;code&gt;/resources/blog/post-66-hibernate-slow-query-logging-yaml-properties/matrix-summary.md&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Slow-query log excerpt: &lt;code&gt;/resources/blog/post-66-hibernate-slow-query-logging-yaml-properties/slow-query-lines.txt&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Spring Boot external configuration: &lt;a href=&quot;https://docs.spring.io/spring-boot/reference/features/external-config.html&quot;&gt;Externalized Configuration&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Spring Boot provider properties: &lt;a href=&quot;https://docs.spring.io/spring-boot/appendix/application-properties/index.html&quot;&gt;Common Application Properties&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Hibernate 5.6 constant values: &lt;a href=&quot;https://docs.hibernate.org/orm/5.6/javadocs/constant-values.html&quot;&gt;LOG_SLOW_QUERY&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Hibernate 6.5 JDBC settings: &lt;a href=&quot;https://docs.hibernate.org/orm/6.5/javadocs/org/hibernate/cfg/JdbcSettings.html&quot;&gt;JdbcSettings.LOG_SLOW_QUERY&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content:encoded><language>en-US</language><category>Spring Boot</category><category>hibernate</category><category>spring boot</category><category>properties</category><category>yaml</category><category>observability</category><author>ystc1247@gmail.com</author></item><item><title>OLTP vs. OLAP Benchmarking With HammerDB, PostgreSQL, and Query Plans</title><link>https://theokimdev.com/en/blog/post-54-benchmarking-types-oltp-olap/</link><guid isPermaLink="true">https://theokimdev.com/en/blog/post-54-benchmarking-types-oltp-olap/</guid><description>A reproducible PostgreSQL benchmark lab comparing HammerDB TPROC-C, HammerDB TPROC-H, and an analytical EXPLAIN probe where a targeted index changed the plan and improved one local query.</description><pubDate>Fri, 10 Jul 2026 01:40:01 GMT</pubDate><content:encoded>&lt;h2 id=&quot;the-misleading-simplicity-of-oltp-vs-olap&quot;&gt;The Misleading Simplicity Of OLTP vs. OLAP&lt;/h2&gt;
&lt;p&gt;OLTP handles many small transactions, while OLAP handles analytical queries. That distinction is correct, but it is not enough for engineering work. A benchmark becomes useful only when the workload, dataset, measurement window, and interpretation boundary are explicit.&lt;/p&gt;
&lt;p&gt;The local benchmark lab in &lt;code&gt;labs/oltp-olap-benchmark/&lt;/code&gt; uses PostgreSQL in Docker, HammerDB 5.0 for TPROC-C and TPROC-H workloads, and a PostgreSQL &lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/code&gt; probe for a targeted analytical query.&lt;/p&gt;
&lt;p&gt;The most useful result was not that one side was “faster.” The useful result was this:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;A date index changed the analytical query from a parallel sequential scan to a parallel index-only scan, reduced read buffers, and cut execution time from 213.717 ms to 134.184 ms in this local run.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That is the benchmark lesson I want to keep. OLTP and OLAP are not labels to memorize. They are different ways of creating pressure on the database.&lt;/p&gt;
&lt;h2 id=&quot;table-of-contents&quot;&gt;Table Of Contents&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Test environment&lt;/li&gt;
&lt;li&gt;Local architecture&lt;/li&gt;
&lt;li&gt;Building the benchmark database&lt;/li&gt;
&lt;li&gt;OLTP workload with TPROC-C&lt;/li&gt;
&lt;li&gt;OLAP workload with TPROC-H&lt;/li&gt;
&lt;li&gt;Analytical query-plan probe&lt;/li&gt;
&lt;li&gt;What the index result proves&lt;/li&gt;
&lt;li&gt;What the numbers mean&lt;/li&gt;
&lt;li&gt;Production rules&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;test-environment&quot;&gt;Test Environment&lt;/h2&gt;
&lt;p&gt;This was a local Docker benchmark, not an official TPC result and not a production capacity claim.&lt;/p&gt;

















































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Item&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Run date&lt;/td&gt;&lt;td&gt;2026-07-10&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Host&lt;/td&gt;&lt;td&gt;macOS/Darwin on Apple Silicon&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Docker&lt;/td&gt;&lt;td&gt;29.3.0&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Docker Compose&lt;/td&gt;&lt;td&gt;v5.1.0&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;PostgreSQL image&lt;/td&gt;&lt;td&gt;&lt;code&gt;postgres:16-alpine&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;PostgreSQL version observed&lt;/td&gt;&lt;td&gt;PostgreSQL 16.14&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;HammerDB image&lt;/td&gt;&lt;td&gt;&lt;code&gt;tpcorg/hammerdb:latest&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;HammerDB CLI version observed&lt;/td&gt;&lt;td&gt;5.0&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;HammerDB platform&lt;/td&gt;&lt;td&gt;&lt;code&gt;linux/amd64&lt;/code&gt; under Docker Desktop&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Lab path&lt;/td&gt;&lt;td&gt;&lt;code&gt;labs/oltp-olap-benchmark/&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The HammerDB image ran as &lt;code&gt;linux/amd64&lt;/code&gt; on an ARM64 laptop. That matters. Docker Desktop virtualization and architecture emulation can distort absolute numbers, especially for CPU-bound and I/O-heavy work. The results are still useful for comparing behavior inside the same local environment, but they should not be treated as universal PostgreSQL performance values.&lt;/p&gt;
&lt;p&gt;The clean rerun used ID &lt;code&gt;20260710T025057Z&lt;/code&gt;. Its generated summary is committed at &lt;code&gt;/resources/blog/post-54-benchmarking-types-oltp-olap/benchmark-summary.md&lt;/code&gt;, and the raw HammerDB and &lt;code&gt;EXPLAIN&lt;/code&gt; excerpts are linked below.&lt;/p&gt;
&lt;p&gt;The full run command is intentionally boring:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cd labs/oltp-olap-benchmark
./scripts/run-all.sh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Cleanup is also explicit:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;./scripts/cleanup.sh
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;local-architecture&quot;&gt;Local Architecture&lt;/h2&gt;





























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Stage&lt;/th&gt;&lt;th&gt;Role&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;run-all.sh&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Starts from a clean state, runs both workloads, and collects logs and plans&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Docker Compose&lt;/td&gt;&lt;td&gt;Keeps PostgreSQL and HammerDB in isolated containers&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;PostgreSQL 16&lt;/td&gt;&lt;td&gt;Hosts the TPROC-C and TPROC-H databases&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;HammerDB 5.0&lt;/td&gt;&lt;td&gt;Runs the transactional and analytical workloads&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;SQL probes&lt;/td&gt;&lt;td&gt;Captures dataset checks and &lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/code&gt; evidence&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The Compose file keeps the database and benchmark runner separate:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;services:
  postgres:
    image: postgres:16-alpine
    ports:
      - &quot;55432:5432&quot;
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: postgres

  hammerdb:
    image: tpcorg/hammerdb:latest
    platform: linux/amd64
    working_dir: /home/HammerDB-5.0
    volumes:
      - .:/workspace
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The important design choice is that all code shown in this post lives in the lab. The article does not use isolated snippets that cannot be run.&lt;/p&gt;
&lt;h2 id=&quot;building-the-benchmark-database&quot;&gt;Building The Benchmark Database&lt;/h2&gt;
&lt;p&gt;The lab builds two separate databases:&lt;/p&gt;




















&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Database&lt;/th&gt;&lt;th&gt;Workload&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Main dataset evidence&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;tpcc&lt;/code&gt;&lt;/td&gt;&lt;td&gt;HammerDB TPROC-C&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;2 warehouses, 60,000 customers, 306,070 orders, 3,058,117 order lines&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;tpch&lt;/code&gt;&lt;/td&gt;&lt;td&gt;HammerDB TPROC-H&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;scale factor 1, 150,000 customers, 1,500,000 orders, 5,999,168 line items&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The saved dataset summary is available at &lt;code&gt;/resources/blog/post-54-benchmarking-types-oltp-olap/dataset-summary.txt&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The local database sizes after the run were:&lt;/p&gt;

















&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Database&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Size&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;tpcc&lt;/code&gt;&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;654 MB&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;tpch&lt;/code&gt;&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;2,064 MB&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;HammerDB calls its built-in workloads TPROC-C and TPROC-H. HammerDB documents these as workloads derived from TPC-C and TPC-H, not official audited TPC results. That distinction matters when writing or publishing benchmark claims.&lt;/p&gt;
&lt;h2 id=&quot;oltp-workload-with-tproc-c&quot;&gt;OLTP Workload With TPROC-C&lt;/h2&gt;
&lt;p&gt;The TPROC-C script uses a small local configuration:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tcl&quot;&gt;dbset db pg
dbset bm TPC-C

diset connection pg_host postgres
diset connection pg_port 5432

diset tpcc pg_count_ware 2
diset tpcc pg_num_vu 2
diset tpcc pg_rampup 1
diset tpcc pg_duration 1
diset tpcc pg_storedprocs true
diset tpcc pg_allwarehouse true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The benchmark used 2 active virtual users, a 1-minute ramp-up, and a 1-minute measured duration. That is deliberately short. I wanted a reproducible article lab, not a long capacity test.&lt;/p&gt;
&lt;p&gt;HammerDB reported:&lt;/p&gt;

































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Metric&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Active virtual users&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;2&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Warehouses&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;2&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Ramp-up&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;1 minute&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Measured duration&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;1 minute&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;PostgreSQL TPM&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;219,223&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;NOPM&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;93,918&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The transaction timing breakdown is more useful than the headline number:&lt;/p&gt;





















































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Transaction&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Calls&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;p50&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;p95&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;p99&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Average&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;New Order&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;492,140&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0.336 ms&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0.453 ms&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0.717 ms&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0.368 ms&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Payment&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;489,294&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0.222 ms&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0.311 ms&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0.479 ms&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0.247 ms&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Stock Level&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;48,766&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0.378 ms&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;1.013 ms&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;56.618 ms&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;2.430 ms&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Delivery&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;49,168&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0.673 ms&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0.990 ms&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;1.505 ms&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0.729 ms&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Order Status&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;49,064&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0.137 ms&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0.201 ms&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0.268 ms&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0.141 ms&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The stock-level transaction had a much fatter p99 than payment or order-status, even though its p95 was close to 1 ms. That is the kind of signal OLTP benchmarking should surface. A single TPM value does not tell me which operation carries the tail-latency risk.&lt;/p&gt;
&lt;p&gt;The Docker stats snapshot after TPROC-C showed:&lt;/p&gt;

























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Metric&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Snapshot&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;PostgreSQL CPU&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;16.45%&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;PostgreSQL memory&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;203.2 MiB / 7.653 GiB&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Network I/O&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;313 MB / 506 MB&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Block I/O&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;45.1 kB / 11.5 GB&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Again, this is a snapshot, not a full resource profile. It is still enough to remind me that OLTP pressure is not only query latency. It also creates write volume, lock pressure, WAL activity, and checkpoint/vacuum work.&lt;/p&gt;
&lt;p&gt;The raw HammerDB result is saved at &lt;code&gt;/resources/blog/post-54-benchmarking-types-oltp-olap/hammerdb-tprocc-result.txt&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id=&quot;olap-workload-with-tproc-h&quot;&gt;OLAP Workload With TPROC-H&lt;/h2&gt;
&lt;p&gt;For the analytical side, the lab builds HammerDB’s PostgreSQL TPROC-H schema at scale factor 1:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tcl&quot;&gt;dbset db pg
dbset bm TPC-H

diset connection pg_host postgres
diset connection pg_port 5432

diset tpch pg_scale_fact 1
diset tpch pg_num_tpch_threads 2
diset tpch pg_total_querysets 1
diset tpch pg_degree_of_parallel 2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The TPROC-H run executed one query set: 22 analytical queries.&lt;/p&gt;





























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Metric&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Scale factor&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;1&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Query sets&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;1&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Queries&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;22&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Total query-set time&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;9 seconds&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Geometric mean of returning query times&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0.26616 seconds&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Some query times from the run:&lt;/p&gt;

































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Query&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Time&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Query 18&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;2.209 s&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Query 17&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0.959 s&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Query 1&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0.890 s&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Query 15&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0.866 s&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Query 9&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0.527 s&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Query 13&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0.413 s&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;This workload feels different from TPROC-C even before looking at results. TPROC-C repeatedly mutates small transactional state. TPROC-H loads a larger analytical schema and spends its time scanning, joining, grouping, and aggregating.&lt;/p&gt;
&lt;p&gt;The raw HammerDB result is saved at &lt;code&gt;/resources/blog/post-54-benchmarking-types-oltp-olap/hammerdb-tproch-result.txt&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id=&quot;analytical-query-plan-probe&quot;&gt;Analytical Query-Plan Probe&lt;/h2&gt;
&lt;p&gt;HammerDB gives workload-level evidence, but I also wanted one query-plan example I could reason about directly. The lab runs this analytical query against &lt;code&gt;lineitem&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;explain (analyze, buffers, timing)
select
  l_shipdate,
  count(*) as line_count,
  sum(l_extendedprice * (1 - l_discount)) as discounted_revenue
from lineitem
where l_shipdate &amp;gt;= date &apos;1995-01-01&apos;
  and l_shipdate &amp;lt; date &apos;1995-04-01&apos;
group by l_shipdate
order by l_shipdate;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then it creates a plausible covering index:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;create index idx_lineitem_shipdate_revenue_probe
  on lineitem (l_shipdate)
  include (l_extendedprice, l_discount);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The expectation was simple: filtering by &lt;code&gt;l_shipdate&lt;/code&gt; and reading &lt;code&gt;l_extendedprice&lt;/code&gt; plus &lt;code&gt;l_discount&lt;/code&gt; should benefit from the index. The actual result was more interesting.&lt;/p&gt;























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Probe&lt;/th&gt;&lt;th&gt;Plan shape&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Execution time&lt;/th&gt;&lt;th&gt;Buffer evidence&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Before index&lt;/td&gt;&lt;td&gt;parallel sequential scan + partial hash aggregate&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;213.717 ms&lt;/td&gt;&lt;td&gt;shared hit 3,179 / read 126,193&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;After index&lt;/td&gt;&lt;td&gt;parallel index-only scan + group aggregate&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;134.184 ms&lt;/td&gt;&lt;td&gt;shared hit 23,469 / read 11,303 / heap fetches 33,813&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The index reduced read buffers and improved this query in the clean run. That result is useful, but it is still not a rule that “uses an index” means “better.” It is one measured outcome for this dataset, predicate, visibility state, and Docker environment.&lt;/p&gt;
&lt;p&gt;The full plan output is saved at &lt;code&gt;/resources/blog/post-54-benchmarking-types-oltp-olap/olap-index-probe.txt&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id=&quot;what-the-index-result-proves&quot;&gt;What The Index Result Proves&lt;/h2&gt;
&lt;p&gt;The indexed plan was not magic. PostgreSQL used a parallel index-only scan:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Parallel Index Only Scan using idx_lineitem_shipdate_revenue_probe on lineitem
  Index Cond: ((l_shipdate &amp;gt;= &apos;1995-01-01&apos;::date)
    AND (l_shipdate &amp;lt; &apos;1995-04-01&apos;::date))
  Heap Fetches: 33813
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The baseline plan used a parallel sequential scan:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Parallel Seq Scan on lineitem
  Rows Removed by Filter: 1924843
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The query selected about 224,637 rows after filtering. That is not a tiny lookup, but it was selective enough for the targeted index to reduce table reads substantially in this run. The optimized plan still performed 33,813 heap fetches, and creating the index took 2.163 seconds in the lab. The benefit is therefore conditional: it helped this read query, but it also adds write overhead, storage, vacuum interaction, and maintenance cost.&lt;/p&gt;
&lt;p&gt;This is why &lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/code&gt; matters. PostgreSQL’s documentation is explicit that &lt;code&gt;ANALYZE&lt;/code&gt; executes the statement and adds actual timing and row statistics, while &lt;code&gt;BUFFERS&lt;/code&gt; shows shared/local/temp block activity. Without those actuals, I would only have a plausible explanation.&lt;/p&gt;
&lt;h2 id=&quot;what-the-numbers-actually-mean&quot;&gt;What The Numbers Actually Mean&lt;/h2&gt;
&lt;p&gt;The benchmark supports a narrow set of claims:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;HammerDB 5.0 ran PostgreSQL TPROC-C and TPROC-H workloads locally.&lt;/li&gt;
&lt;li&gt;The TPROC-C run produced 219,223 PostgreSQL TPM and 93,918 NOPM under a 2-warehouse, 2-VU, 1-minute measured configuration.&lt;/li&gt;
&lt;li&gt;The TPROC-H scale-factor-1 run completed one 22-query set in 9 seconds with a 0.26616-second geometric mean for returning query times.&lt;/li&gt;
&lt;li&gt;The custom analytical query improved with the targeted index in this local run: 213.717 ms before index, 134.184 ms after index.&lt;/li&gt;
&lt;li&gt;The indexed plan reduced read buffers and changed the query to a parallel index-only scan, but still required 33,813 heap fetches.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The benchmark does not support these claims:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;PostgreSQL can handle 219,223 TPM in production.&lt;/li&gt;
&lt;li&gt;A laptop Docker result predicts managed database performance.&lt;/li&gt;
&lt;li&gt;This index will always improve this query in every environment.&lt;/li&gt;
&lt;li&gt;Indexes are always good for OLAP queries.&lt;/li&gt;
&lt;li&gt;HammerDB local output is an official TPC result.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Those unsupported claims would be overreach.&lt;/p&gt;
&lt;h2 id=&quot;production-implications&quot;&gt;Production Implications&lt;/h2&gt;
&lt;p&gt;My final rule is:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Name the workload before choosing the metric, and inspect the plan before trusting the optimization.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;For OLTP, I care about:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;transaction mix;&lt;/li&gt;
&lt;li&gt;p95/p99 latency by transaction type;&lt;/li&gt;
&lt;li&gt;lock waits and blocking;&lt;/li&gt;
&lt;li&gt;write amplification;&lt;/li&gt;
&lt;li&gt;error rate;&lt;/li&gt;
&lt;li&gt;checkpoint, vacuum, and WAL pressure;&lt;/li&gt;
&lt;li&gt;throughput after warm-up, not just peak samples.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For OLAP, I care about:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;row counts and dataset shape;&lt;/li&gt;
&lt;li&gt;scan volume;&lt;/li&gt;
&lt;li&gt;join and aggregate strategy;&lt;/li&gt;
&lt;li&gt;memory and temp files;&lt;/li&gt;
&lt;li&gt;buffer reads and hits;&lt;/li&gt;
&lt;li&gt;parallelism;&lt;/li&gt;
&lt;li&gt;whether an index helps the real query, not the imagined query.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The index probe is the lesson I would carry into production code review. A developer might propose &lt;code&gt;lineitem(l_shipdate) include (...)&lt;/code&gt; and the idea would sound reasonable. The review should not stop there. The next questions are:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;How selective is the date range?&lt;/li&gt;
&lt;li&gt;Does the query still need many heap fetches?&lt;/li&gt;
&lt;li&gt;Does the index disable a better parallel plan?&lt;/li&gt;
&lt;li&gt;How much write overhead does the extra index add?&lt;/li&gt;
&lt;li&gt;Is this query frequent enough to justify the storage and maintenance cost?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Benchmarking is useful only when it makes those questions answerable.&lt;/p&gt;
&lt;h2 id=&quot;references&quot;&gt;References&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://hammerdb.com/docs/index.html&quot;&gt;HammerDB documentation&lt;/a&gt;, checked on 2026-07-10.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.hammerdb.com/blog/uncategorized/hammerdb-v4-0-new-features-pt1-tprocc-tproch/&quot;&gt;HammerDB TPROC-C and TPROC-H naming note&lt;/a&gt;, checked on 2026-07-10.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.postgresql.org/docs/current/sql-explain.html&quot;&gt;PostgreSQL &lt;code&gt;EXPLAIN&lt;/code&gt; documentation&lt;/a&gt;, checked on 2026-07-10.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.postgresql.org/docs/current/using-explain.html&quot;&gt;PostgreSQL guide to using &lt;code&gt;EXPLAIN&lt;/code&gt;&lt;/a&gt;, checked on 2026-07-10.&lt;/li&gt;
&lt;/ul&gt;</content:encoded><language>en-US</language><category>Database</category><category>Benchmarks</category><category>PostgreSQL</category><category>HammerDB</category><category>OLTP</category><category>OLAP</category><author>ystc1247@gmail.com</author></item><item><title>Thoughts on What Developers Should Do in an Age Where AI Writes Code</title><link>https://theokimdev.com/en/blog/post-83-what-developers-do-when-ai-writes-code/</link><guid isPermaLink="true">https://theokimdev.com/en/blog/post-83-what-developers-do-when-ai-writes-code/</guid><description>How coding agents move developer leverage from typing code to defining systems, constraints, review, and responsibility.</description><pubDate>Thu, 02 Jul 2026 22:19:52 GMT</pubDate><content:encoded>&lt;h2 id=&quot;from-code-writing-to-work-design&quot;&gt;From Code Writing To Work Design&lt;/h2&gt;
&lt;p&gt;I think back to when I first joined the company in early 2024. If I remember correctly, it was right before GPT-4o came out. Even then, LLMs were quite helpful for writing code. But the nature of that help was very different from what it is now.&lt;/p&gt;
&lt;p&gt;For example, suppose I had to build an API in Spring Boot. I would first complete one example API myself, flowing from controller to facade, service, and repository. Then I would copy the entire code and ask the model to create another API with the same convention and the same logic. In other words, the LLM was closer to a replication tool than a designer. If a human had already laid the path, it could follow that path to some extent.&lt;/p&gt;
&lt;p&gt;Of course, I sometimes asked questions when I got stuck. But hallucinations were severe, and once the complexity of the task increased even slightly, syntax errors and logical flaws appeared throughout the code. At the time, the natural procedure in development was still to search through official documentation, inspect library code, read error logs, and debug directly. The LLM was an auxiliary tool, not something one could entrust work to.&lt;/p&gt;
&lt;p&gt;After that, model performance continued to rise. At some point, the amount of time I spent relying on answers slowly began to exceed the time I spent looking things up myself. Then coding agents appeared. The first one I properly used was from the Gemini line, and to be honest, its performance was terrible. The fact that it could directly modify local files was interesting, but it was much slower than when I worked by hand, and the results were not stable. From that point on, I tried my own kind of harness engineering. I split tasks into smaller units, specified rules, forced tests, and limited the scope of modification. Even so, it could not match the quality of code written directly by a human.&lt;/p&gt;
&lt;p&gt;The same problems repeated even in repositories where the conventions had already been established. It modified files without understanding the structure, forced together pieces of code that merely looked similar, and almost always caused problems during compilation. In the end, a developer still had to read and fix the code again. The agent may have written the code, but the responsibility still belonged entirely to the developer.&lt;/p&gt;
&lt;p&gt;Then, around the time I left the company in early 2026, I started using newer Codex-line models, and the difference became completely tangible. The code became more precise. It performed compilation checks after the work. It began to handle not only simple implementation, but also revision and verification as a single flow. Now I could leave one task to an agent and work on something else myself. With some exaggeration, I could produce two or three times my previous productivity.&lt;/p&gt;
&lt;p&gt;After returning to school and working on several projects, this change accelerated even further. New agents and models were released roughly once every month or two, and their performance and speed visibly improved. The parts that developers had to fix after a task was completed decreased, and the code became increasingly refined. It reached the point where, on a monthly basis, or even weekly, I felt that I would fall behind if I did not keep up. Now writing code without a coding agent feels strange.&lt;/p&gt;
&lt;p&gt;The way I work has also changed completely. I specify branch conventions and Git flow, then run several tasks in parallel. Even PR reviews are no longer something I read entirely by hand. I built a custom dashboard to help review other people’s code, or more precisely, code written by other people’s agents. With the rise of MCP, I can connect Notion, Figma, browsers, and various other connectors, compressing into a single flow tasks that previously required moving back and forth between windows.&lt;/p&gt;
&lt;h2 id=&quot;where-developer-judgment-still-matters&quot;&gt;Where Developer Judgment Still Matters&lt;/h2&gt;
&lt;p&gt;Server incident response is the same. In the past, when a problem occurred, I directly wrote Prometheus queries, checked Grafana dashboards, dug through CloudWatch metrics, and followed JVM flame graphs or API traces in Datadog APM to find the cause. When a database issue occurred, I would spend the whole day attached to the query console, checking processes, finding expensive queries, reading execution plans, and fixing indexes and query structures. Now, a significant portion of this process can be delegated to an agent. Through an MCP server, I can have it read metrics, open a tunnel into an infrastructure server, perform Java profile capture, and assist with analyzing the result. A large portion of the repetitive exploration and text analysis humans had to perform can now be completed in an overwhelmingly shorter amount of time.&lt;/p&gt;
&lt;p&gt;This progress was astonishing. But at the same time, it felt hollow.&lt;/p&gt;
&lt;p&gt;It seemed as though the tasks I had been good at could now be done by anyone. The time I spent reading code, digging into libraries, spending entire days debugging, and accumulating knowledge through official documentation felt almost meaningless. In the past, that was competitiveness. The ability to understand problems faster than others, fix them more accurately, and implement them in a more stable structure. But AI began to imitate that ability rapidly. It felt as though the scarcity of the skills I had built was disappearing.&lt;/p&gt;
&lt;p&gt;But while collaborating with several developers, my thinking slowly changed. AI was not reducing the gap between developers. It was revealing that gap even more clearly.&lt;/p&gt;
&lt;p&gt;Many people talk about the importance of prompts. I feel it painfully as well. But the prompt I am talking about here is not the ability to write pretty sentences. It is the ability to understand the essence of a task, know the points that must be decided, understand the context of the system in which the code will live, and set conditions so that the agent cannot make careless assumptions. In the end, a good prompt comes from good development knowledge.&lt;/p&gt;
&lt;p&gt;Suppose we are making a simple login page. If you ask for “a login page view and backend logic,” including both frontend and backend, today’s AI can produce a working login feature in under five minutes. The problem is precisely that it works. It works so convincingly that, unless you know what is missing, you simply move on.&lt;/p&gt;
&lt;p&gt;But login is not merely a feature where a user enters an email and password, sends a POST request to the server, and is redirected to the main page on success. One must decide how sessions will be managed, how the lifecycle of access tokens and refresh tokens will be handled if JWT is used, how HttpOnly, Secure, and SameSite policies will be configured if cookies are used, how passwords will be hashed and stored, where server-side authentication and authorization checks will be performed, how to respond to credential stuffing and brute force attacks, how login failure counts and rate limits will be handled, how access privileges will be separated by user role, how logout and token invalidation will be guaranteed, and how much error information will be exposed between security and usability.&lt;/p&gt;
&lt;h2 id=&quot;working-with-agents-without-losing-thought&quot;&gt;Working With Agents Without Losing Thought&lt;/h2&gt;
&lt;p&gt;These are not things that easily come to mind if one has never built a login page directly. AI can produce an answer. But the developer must know what to ask. A person who does not know what must be decided will accept the assumptions filled in by AI as they are. At that moment, the code has not been “completed.” It has entered the repository carrying invisible debt.&lt;/p&gt;
&lt;p&gt;That is why I find it strange to treat “AI contribution” as an independent value. If we assume the contribution to code quality is 100, some people say that in the past, developer ability accounted for 90 and LLM assistance added 10, while now AI accounts for 90 and the developer handles only 10. But I do not think so. The quality of the 90 that AI produces does not fall from the sky as a separate value. It still stands on the developer’s prior knowledge, understanding of the system, ability to decompose requirements, and ability to use AI well. Without that foundation, the 90 produced by AI is not quality. It is a convincing bundle of assumptions.&lt;/p&gt;
&lt;p&gt;In fact, the gap may grow even wider. In the past, the difference between an excellent developer and an average developer appeared in coding speed and debugging ability. Now that difference appears in the way they handle agents. One person tells AI, “Make a login.” Another person organizes authentication policy, security requirements, error handling, test scope, and integration with the existing architecture before assigning the task. Both use AI. But the results are completely different.&lt;/p&gt;
&lt;p&gt;The hiring market is similar. These days, people often say that the productivity of existing developers has increased, so new developers are no longer needed, and that is why the market has become difficult. Of course, there is some truth to that. If a skilled developer uses agents well, one person can handle far more work than before. The need for people who only perform simple implementation is bound to decrease.&lt;/p&gt;
&lt;p&gt;But from what I hear from people in the field, the issue does not seem to be simply that companies are hiring fewer juniors. It also feels strongly as though there are fewer people worth hiring. Development looks too easy now. Output appears even without digging deeply into code. One can complete a project without having implemented things directly. But if engineering thinking is not properly trained in that process, one does not understand why the code was written that way. When an error occurs, one cannot narrow down the cause. When structures collide, one does not know where to begin unraveling them. The problem is not that one cannot write code without AI. The problem is being unable to properly read even the code AI has written.&lt;/p&gt;
&lt;p&gt;Of course, studying code itself has also become much harder. One can say that the ideal approach is to write code without AI and understand logic from the bottom up, as people did before. I also believe that is important. But when there is a tool right in front of me that can perform a task I cannot do at ten times the speed, how could I ignore it? Saying we should not use AI is not realistic. What matters now is not whether we use AI, but how we use AI without losing thought.&lt;/p&gt;
&lt;p&gt;Before AI, learning development was mostly a bottom-up process. You learned syntax, then data structures, networks, and databases. You built small features by hand, then slowly climbed toward more complex systems. That was not necessarily because bottom-up learning was always the best methodology. It was because there were few alternatives. Without the fundamentals, you could not write high-level code, especially code that carried complex responsibilities and abstractions.&lt;/p&gt;
&lt;p&gt;With AI, a top-down approach has become practical. You can first have an agent produce higher-level code, then analyze why the code is shaped that way, and dig backward into the parts you do not yet understand. From a learner’s point of view, this can compress study time dramatically. Andrej Karpathy, whose recent LLM observations helped popularize the practice of writing files such as &lt;code&gt;CLAUDE.md&lt;/code&gt; to improve Claude Code’s performance, tweeted a similar idea in 2020. His advice was to take on concrete projects, learn deeply on demand, and summarize what you learned in your own words. At the time, that sounded ideal but not always practical. In the AI era, it has become a much more realistic learning strategy.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theokimdev.com/resources/blog/post-83-what-developers-do-when-ai-writes-code/karpathy-learning-on-demand.png&quot; alt=&quot;Andrej Karpathy tweet about learning on demand through concrete projects&quot;&gt;&lt;/p&gt;
&lt;p&gt;There is one method I at least try to practice. Before assigning a task, I write the requirements in as much detail as possible. Then, at the end, I always add this question.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;What do I need to decide in advance in order to perform this task? Do not assume any workflow or logic. Have me review every process in detail.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;This question matters because AI does not leave blanks empty. Any instruction a developer does not include in the prompt becomes something the model has to infer. It will proceed with the implementation according to what looks most appropriate from its point of view. But every project has its own development direction. Once data modeling, authentication style, deployment structure, incident response, and future extensibility are involved, the “right” direction cannot be decided by general best practice alone. That judgment ultimately belongs to the developer responsible for the system.&lt;/p&gt;
&lt;p&gt;A good prompt is not an attempt to use AI less. It is closer to separating the execution area where AI should be powerful from the design area the developer must decide in advance. With a weak prompt, the human decides 10% and the remaining 90% becomes AI assumptions. With a precise prompt, the developer provides 90% of the context, constraints, forbidden paths, and review criteria, while AI fills only the remaining 10% with tactical judgment. In both cases, AI writes code. But the nature of the output is completely different.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theokimdev.com/resources/blog/post-83-what-developers-do-when-ai-writes-code/prompt-assumption-balance-en.svg&quot; alt=&quot;How prompt quality changes the ratio between explicit requirements and AI assumptions&quot;&gt;&lt;/p&gt;
&lt;h2 id=&quot;responsibility-moves-upstream&quot;&gt;Responsibility Moves Upstream&lt;/h2&gt;
&lt;p&gt;Just adding this one question changes the result significantly. No matter how detailed I think the prompt is, something I had forgotten always comes out. Sometimes, an entire procedure I had never considered returns as a question. I must then study those questions again and make decisions. Only then do I avoid making the agent redo the same work twice, three times, or ten times.&lt;/p&gt;
&lt;p&gt;Understanding the written code is also important. Code written by an agent is not code one can skip reading. Rather, it is code one must read even more carefully. The first task always starts off looking plausible. The first feature in a new repository is clean. But if one continues assigning follow-up tasks without understanding that structure, spaghetti code eventually accumulates. Logic collides, responsibilities blur, exception handling is duplicated, and the same concept spreads under multiple names. At first, it seems fast. Later, it collapses beyond control.&lt;/p&gt;
&lt;p&gt;In the age of AI, developers may write less code. But that does not mean they can think less. If anything, they must think more. The time spent typing directly decreases, but one must judge what to build, what structure is appropriate, which decisions must not be deferred, and which assumptions must be prevented. Implementation labor decreases, but design, review, and responsibility become more distinct.&lt;/p&gt;
&lt;p&gt;That is why the difference between a developer and a coder now seems even clearer.&lt;/p&gt;
&lt;p&gt;A coder receives code written by AI. A developer decides what kind of code AI should write. A coder thinks it is over when it works. A developer looks at why it works, where it can break, and what kind of structure can survive when the next feature is added. A coder writes prompts as commands. A developer writes prompts like design documents.&lt;/p&gt;
&lt;p&gt;The age in which AI writes code has arrived. This does not mean developers are no longer needed. It means, rather, that what a developer knows has become more important. In the past, we learned what we did not know by struggling through it ourselves. Now AI covers up what we do not know too quickly. That is why we must dig into it more consciously. We should not merely look at the answer AI filled in. We must check what AI assumed.&lt;/p&gt;
&lt;p&gt;In the end, competitiveness has not disappeared. Its position has merely changed. It has moved from how much code one can type to how accurately one can define what kind of code should be written. And this change is colder than it first appears. AI is open to everyone, but not everyone obtains the same result.&lt;/p&gt;</content:encoded><language>en-US</language><category>AI</category><category>Coding Agent</category><category>LLM</category><category>Software Engineering</category><author>ystc1247@gmail.com</author></item><item><title>The False Sense of Safety Around PriorityClass and PodDisruptionBudget</title><link>https://theokimdev.com/en/blog/post-73-priorityclass-poddisruptionbudget/</link><guid isPermaLink="true">https://theokimdev.com/en/blog/post-73-priorityclass-poddisruptionbudget/</guid><description>An operational explanation of why PodDisruptionBudget is not an absolute guarantee during PriorityClass preemption or drain behavior.</description><pubDate>Sun, 21 Sep 2025 05:08:10 GMT</pubDate><content:encoded>&lt;h2 id=&quot;where-the-problem-started&quot;&gt;Where the Problem Started&lt;/h2&gt;
&lt;p&gt;A PodDisruptionBudget can sound like a hard guarantee. But Kubernetes can still evict lower-priority pods to make room for more important ones, and drain behavior can produce results that surprise operators. This post examines the false sense of safety that appears when PriorityClass and PDB are used together.&lt;/p&gt;
&lt;p&gt;When using Kubernetes, it is common to configure PriorityClass and PodDisruptionBudget(PDB) together. PriorityClass is a mechanism that says, “this pod is important,” by assigning priority, and PDB is a constraint that says, “at least this many must stay alive.”&lt;/p&gt;
&lt;p&gt;At first, it is easy to think this way.&lt;/p&gt;
&lt;p&gt;“Since I configured a PDB, the minimum count should be guaranteed, right?”&lt;/p&gt;
&lt;p&gt;But in real operations, pods can disappear even when a PDB exists. Especially in scheduler preemption situations or node drain operations, PDB is not absolutely guaranteed. In this post, I will summarize why this happens, how it is handled in the scheduler architecture and at the code level, and how to verify it with actual examples.&lt;/p&gt;
&lt;h2 id=&quot;the-roles-of-priorityclass-and-pdb&quot;&gt;The Roles of PriorityClass and PDB&lt;/h2&gt;
&lt;h3 id=&quot;priorityclass&quot;&gt;PriorityClass&lt;/h3&gt;
&lt;p&gt;Pods with a high PriorityClass value are treated as more important by the scheduler. If cluster resources are insufficient, the scheduler forcibly evicts lower PriorityClass pods in order to run higher PriorityClass pods. This process is called Preemption.&lt;/p&gt;
&lt;h3 id=&quot;pdb-poddisruptionbudget&quot;&gt;PDB (PodDisruptionBudget)&lt;/h3&gt;
&lt;p&gt;It prevents too many pods from going down at the same time during voluntary disruptions such as drain or rolling updates. For example, if minAvailable: 2 is set, at least two pods must stay alive. &lt;strong&gt;However, situations like node failure or preemption are not guaranteed.&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id=&quot;how-pdb-handling-looks-inside-the-scheduler-code&quot;&gt;How PDB Handling Looks Inside the Scheduler Code&lt;/h2&gt;
&lt;p&gt;The scheduler does not actually ignore PDB completely. If you look at pkg/scheduler/framework/plugins/defaultpreemption/default_preemption.go, there is code that checks PDBs when selecting victims:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;// SelectVictimsOnNode finds minimum set of pods on the given node that should be preempted in order to make enough room
// for &quot;pod&quot; to be scheduled.
func (pl *DefaultPreemption) SelectVictimsOnNode(
	ctx context.Context,
	state fwk.CycleState,
	pod *v1.Pod,
	nodeInfo fwk.NodeInfo,
	pdbs []*policy.PodDisruptionBudget) ([]*v1.Pod, int, *fwk.Status) {
	logger := klog.FromContext(ctx)
	var potentialVictims []fwk.PodInfo
	removePod := func(rpi fwk.PodInfo) error {
		if err := nodeInfo.RemovePod(logger, rpi.GetPod()); err != nil {
			return err
		}
		status := pl.fh.RunPreFilterExtensionRemovePod(ctx, state, pod, rpi, nodeInfo)
		if !status.IsSuccess() {
			return status.AsError()
		}
		return nil
	}
	addPod := func(api fwk.PodInfo) error {
		nodeInfo.AddPodInfo(api)
		status := pl.fh.RunPreFilterExtensionAddPod(ctx, state, pod, api, nodeInfo)
		if !status.IsSuccess() {
			return status.AsError()
		}
		return nil
	}
	// As the first step, remove all pods eligible for preemption from the node and
	// check if the given pod can be scheduled without them present.
	for _, pi := range nodeInfo.GetPods() {
		if pl.isPreemptionAllowed(nodeInfo, pi, pod) {
			potentialVictims = append(potentialVictims, pi)
			if err := removePod(pi); err != nil {
				return nil, 0, fwk.AsStatus(err)
			}
		}
	}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In other words, the scheduler respects PDBs when it can. But if every viable path requires breaking a PDB, Kubernetes can still choose preemption and evict the lower-priority pod.&lt;/p&gt;
&lt;p&gt;The official documentation also says this:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;PodDisruptionBudget is supported, but not guaranteed. The scheduler tries not to violate PDB when possible, but if it cannot find an alternative victim, it removes lower-priority pods even if that breaks PDB.&lt;/p&gt;
&lt;p&gt;Kubernetes Docs&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id=&quot;example-where-pdb-is-broken-during-preemption&quot;&gt;Example Where PDB Is Broken During Preemption&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;Create PriorityClass&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
  name: low-priority
value: 1000
globalDefault: false
description: &quot;낮은 우선순위 파드&quot;

---
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
  name: high-priority
value: 100000
globalDefault: false
description: &quot;높은 우선순위 파드&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;ol start=&quot;2&quot;&gt;
&lt;li&gt;Create Deployment + PDB&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;apiVersion: apps/v1
kind: Deployment
metadata:
  name: demo-deploy
spec:
  replicas: 3
  selector:
    matchLabels:
      app: demo
  template:
    metadata:
      labels:
        app: demo
    spec:
      priorityClassName: low-priority
      containers:
      - name: nginx
        image: nginx

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: demo-pdb
spec:
  minAvailable: 2
  selector:
    matchLabels:
      app: demo
&lt;/code&gt;&lt;/pre&gt;
&lt;ol start=&quot;3&quot;&gt;
&lt;li&gt;Add a high PriorityClass pod&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;apiVersion: v1
kind: Pod
metadata:
  name: important-pod
spec:
  priorityClassName: high-priority
  containers:
  - name: busy
    image: busybox
    command: [&quot;sh&quot;, &quot;-c&quot;, &quot;sleep 3600&quot;]
    resources:
      requests:
        cpu: &quot;500m&quot;
        memory: &quot;512Mi&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If cluster resources are insufficient, the scheduler sacrifices one of the &lt;code&gt;demo-deploy&lt;/code&gt; pods to run &lt;code&gt;important-pod&lt;/code&gt;. During this process, the &lt;code&gt;PDB(minAvailable: 2)&lt;/code&gt; condition can be broken.&lt;/p&gt;
&lt;p&gt;If you run &lt;code&gt;kubectl describe pdb demo-pdb&lt;/code&gt;, &lt;code&gt;CurrentHealthy&lt;/code&gt; may have dropped to &lt;code&gt;1&lt;/code&gt; instead of &lt;code&gt;2&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id=&quot;example-where-pdb-is-broken-during-drain&quot;&gt;Example Where PDB Is Broken During Drain&lt;/h2&gt;
&lt;p&gt;Now test the same assumption during &lt;code&gt;drain&lt;/code&gt;.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;drain-demo Deployment and PDB&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;apiVersion: apps/v1
kind: Deployment
metadata:
  name: drain-demo
spec:
  replicas: 3
  selector:
    matchLabels:
      app: drain-demo
  template:
    metadata:
      labels:
        app: drain-demo
    spec:
      priorityClassName: low-priority
      containers:
      - name: nginx
        image: nginx

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: drain-demo-pdb
spec:
  minAvailable: 2
  selector:
    matchLabels:
      app: drain-demo
&lt;/code&gt;&lt;/pre&gt;
&lt;ol start=&quot;2&quot;&gt;
&lt;li&gt;Run node drain&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;kubectl drain &amp;lt;node-name&amp;gt; --ignore-daemonsets --delete-emptydir-data
&lt;/code&gt;&lt;/pre&gt;
&lt;ol start=&quot;3&quot;&gt;
&lt;li&gt;Check the result&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;If you run &lt;code&gt;kubectl get pods&lt;/code&gt;, you may see that only one pod is left.&lt;/p&gt;
&lt;p&gt;Result of &lt;code&gt;kubectl describe pdb drain-demo-pdb&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Status:
  Current Healthy:   1
  Desired Healthy:   2
  Disruptions Allowed: 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In other words, during the drain process, the PDB condition is broken and pods disappear.&lt;/p&gt;
&lt;h2 id=&quot;why-does-this-happen&quot;&gt;Why Does This Happen?&lt;/h2&gt;
&lt;h3 id=&quot;pdb-is-a-drainupdate-protection-mechanism-but-not-an-absolute-guarantee&quot;&gt;PDB is a drain/update protection mechanism, but not an absolute guarantee&lt;/h3&gt;
&lt;p&gt;Even though drain itself is a voluntary disruption, there are cases where the PDB condition cannot be fully honored while emptying a node.&lt;/p&gt;
&lt;h3 id=&quot;asynchronous-controller-structure&quot;&gt;Asynchronous controller structure&lt;/h3&gt;
&lt;p&gt;Because the PDB controller and scheduler run separately, if the timing of status updates is off, the scheduler can make decisions based on stale information.&lt;/p&gt;
&lt;h3 id=&quot;a-philosophical-choice&quot;&gt;A philosophical choice&lt;/h3&gt;
&lt;p&gt;Kubernetes follows the philosophy of “while preserving service availability as much as possible, running the more important pod comes first.” That is why PDB violations are allowed.&lt;/p&gt;
&lt;h2 id=&quot;things-to-watch-in-operations&quot;&gt;Things To Watch In Operations&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;PDB is not an absolute shield.&lt;/strong&gt; For truly important workloads, you should raise PriorityClass and use PDB as a supporting mechanism.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;A drain strategy is necessary.&lt;/strong&gt; In production environments, you need to design deployment/update strategy on the assumption that &lt;code&gt;kubectl drain&lt;/code&gt; may ignore PDB.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Monitoring is mandatory.&lt;/strong&gt; You should periodically check &lt;code&gt;disruptionsAllowed&lt;/code&gt; and &lt;code&gt;currentHealthy&lt;/code&gt; with &lt;code&gt;kubectl get pdb&lt;/code&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Practice beforehand.&lt;/strong&gt; Intentionally trigger preemption and drain in a controlled environment before relying on the policy in production.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;summary&quot;&gt;Summary&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;PriorityClass determines pod priority and removes lower PriorityClass pods when needed.&lt;/li&gt;
&lt;li&gt;PDB limits voluntary disruptions, but it is not guaranteed in situations such as preemption or drain.&lt;/li&gt;
&lt;li&gt;Kubernetes’ basic philosophy is “run important pods first,” and breaking PDB is also allowed in that process.&lt;/li&gt;
&lt;li&gt;For pods you really want to protect, design them with a PriorityClass + PDB combination and think through the drain strategy as well.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;availability-takeaway&quot;&gt;Availability Takeaway&lt;/h2&gt;
&lt;p&gt;PDB is an important safety mechanism, but not an absolute shield. Scheduling, preemption, drain, and controller update timing all affect the real availability story. For important workloads, PDB needs to be designed together with priority, replica placement, rollout strategy, and monitoring.&lt;/p&gt;</content:encoded><language>en-US</language><category>Kubernetes</category><author>ystc1247@gmail.com</author></item><item><title>Why an ORM Query Missed a VARCHAR Index in SQL Server</title><link>https://theokimdev.com/en/blog/post-70-orm-varchar-sql-server/</link><guid isPermaLink="true">https://theokimdev.com/en/blog/post-70-orm-varchar-sql-server/</guid><description>A debugging note on why SQL Server skipped a VARCHAR index when ORM-generated Unicode parameters triggered implicit type conversion.</description><pubDate>Sat, 25 Jan 2025 16:25:18 GMT</pubDate><content:encoded>&lt;h2 id=&quot;where-the-problem-started&quot;&gt;Where the Problem Started&lt;/h2&gt;
&lt;p&gt;When an index exists but is not used, intuition usually fails until the execution plan is inspected. With an ORM, a condition that looks harmless in application code can become a different plan once JDBC parameter types and database type conversion are involved. This post follows a SQL Server case where a VARCHAR index was skipped.&lt;/p&gt;
&lt;p&gt;I observed that when an ORM uses a string value in a query, it sends the query using the NVARCHAR type. So when an index contains columns declared as VARCHAR, and the query tries to use that index, a type mismatch causes the index not to be used.&lt;/p&gt;
&lt;p&gt;For example, consider &lt;code&gt;PK__CUST_INFO__7AA72534 (CARE_ORG_ID char(8), CUST_NO varchar(20))&lt;/code&gt;, a simple two-column index.&lt;/p&gt;
&lt;p&gt;I also suspected that statistics might have had an error and prevented the index from being used smoothly, so I ran a query that ignores statistics through option(recompile).&lt;/p&gt;
&lt;h2 id=&quot;implementation-path&quot;&gt;Implementation Path&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;select * from cust_info where CARE_ORG_ID = &apos;15887361&apos; and CUST_NO = &apos;#1030197&apos; option(recompile)
select * from cust_info where CARE_ORG_ID = N&apos;15887361&apos; and CUST_NO = N&apos;#1030197&apos; option(recompile)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;how-i-verified-it&quot;&gt;How I Verified It&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://theokimdev.com/resources/blog/post-70-orm-varchar-sql-server/cover-43cdd860.png&quot; alt=&quot;ace1b95f-ebf8-4a59-ae76-ee902a8491b5.png&quot;&gt;&lt;/p&gt;
&lt;p&gt;This is Query Analysis in DataGrip. The first query used the intended index, but the second query, which uses &lt;code&gt;nvarchar&lt;/code&gt; parameters, unexpectedly performed an &lt;code&gt;INDEX_CUST_INFO_CELL_PHONE&lt;/code&gt; index seek before using the PK index.&lt;/p&gt;
&lt;p&gt;Because the &lt;code&gt;CELL_PHONE&lt;/code&gt; index is non-clustered, it does not contain every column. SQL Server appears to fetch candidate rows from that index and then join/look up the remaining row data through the PK.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theokimdev.com/resources/blog/post-70-orm-varchar-sql-server/image-02-0aafec50.png&quot; alt=&quot;34ee05af-9026-4d5f-8b2e-25b94bb10e63.png&quot;&gt;&lt;/p&gt;
&lt;p&gt;This is the result of dropping the CELL_PHONE index from a copied CUST_INFO table and running the query with nvarchar params.&lt;/p&gt;
&lt;p&gt;The inner join in a single-table query looks odd at first. My inference is that because the parameters were bound as &lt;code&gt;nvarchar&lt;/code&gt;, SQL Server had to convert &lt;code&gt;CARE_ORG_ID&lt;/code&gt; and &lt;code&gt;CUST_NO&lt;/code&gt; values to &lt;code&gt;nvarchar&lt;/code&gt; during plan execution, which broke the expected direct index usage.&lt;/p&gt;
&lt;p&gt;I then changed both &lt;code&gt;CARE_ORG_ID&lt;/code&gt; and &lt;code&gt;CUST_NO&lt;/code&gt; to &lt;code&gt;nvarchar&lt;/code&gt; and reran the same two queries.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theokimdev.com/resources/blog/post-70-orm-varchar-sql-server/image-03-05204c4e.png&quot; alt=&quot;4ae3b4d1-8bb3-42ac-81aa-9c44bcd2c30c.png&quot;&gt;&lt;/p&gt;
&lt;h2 id=&quot;performance-takeaway&quot;&gt;Performance Takeaway&lt;/h2&gt;
&lt;p&gt;An ORM hides SQL generation, but it does not hide database behavior. Parameter type, implicit conversion, collation, and index column type can make a good index useless. Performance debugging has to read application code and the actual database execution plan together.&lt;/p&gt;
&lt;p&gt;When the column types are also &lt;code&gt;nvarchar&lt;/code&gt;, the index is used regardless of whether the parameter is sent as &lt;code&gt;nvarchar&lt;/code&gt; or &lt;code&gt;varchar&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Why did the previous table column type conversion happen? This can be checked at &lt;a href=&quot;https://learn.microsoft.com/en-us/sql/t-sql/data-types/data-type-precedence-transact-sql?view=sql-server-ver16&quot;&gt;https://learn.microsoft.com/en-us/sql/t-sql/data-types/data-type-precedence-transact-sql?view=sql-server-ver16&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;In short, when querying a varchar type column with nvarchar, nvarchar has higher precedence, so type conversion occurs on every row of the entire table. But when querying an nvarchar column with varchar, conversion occurs only on the query parameter before the query runs, so the same query plan occurs.&lt;/p&gt;
&lt;p&gt;The fact that even the simplest form of index failed to be used made it clear that all queries executed by the ORM, where there are often joins across as many as 10 or more tables, were not using the intended indexes at all. Three possible solutions come to mind.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Convert every column type used by indexes to nvarchar.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Because nvarchar supports Unicode, unlike varchar/char, which uses 1 byte per character, it consumes 2 bytes per character. This increases not only the overall database size but also the index size.&lt;/p&gt;
&lt;p&gt;Connected to the above, because the data value becomes larger, I/O work takes more time and memory usage also increases.&lt;/p&gt;
&lt;p&gt;Changing a column’s type requires dropping the index, changing the type, and recreating the index if that column is used in an index.&lt;/p&gt;
&lt;p&gt;We confirmed above that even when using nvarchar columns, a varchar parameter can still use the index, but there may be unknown side effects, especially in programs that use native query.&lt;/p&gt;
&lt;ol start=&quot;2&quot;&gt;
&lt;li&gt;Keep string parameters in the ORM as nvarchar by default, but exceptionally send queries as varchar/char for columns that need to use indexes.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This is the most ideal solution, but applying it in a Spring Boot project is strangely painful. I will write a separate document for it.&lt;/p&gt;
&lt;ol start=&quot;3&quot;&gt;
&lt;li&gt;Conversely, make all string parameters varchar, and handle some columns declared as nvarchar.&lt;/li&gt;
&lt;/ol&gt;</content:encoded><language>en-US</language><category>Database</category><category>index</category><category>MSSQL</category><category>nvarchar</category><category>spring boot</category><category>varchar</category><author>ystc1247@gmail.com</author></item><item><title>Tracing SQL Server JDBC Unicode Parameter Behavior Inside the Library</title><link>https://theokimdev.com/en/blog/post-71-sql-server-jdbc-setsendstringparametersasunicodefalse/</link><guid isPermaLink="true">https://theokimdev.com/en/blog/post-71-sql-server-jdbc-setsendstringparametersasunicodefalse/</guid><description>A deep debugging note on why setSendStringParametersAsUnicode=false may not behave as expected, traced through SQL Server JDBC driver internals.</description><pubDate>Sat, 25 Jan 2025 16:17:26 GMT</pubDate><content:encoded>&lt;h2 id=&quot;where-the-problem-started&quot;&gt;Where the Problem Started&lt;/h2&gt;
&lt;p&gt;The existence of a configuration option does not guarantee that it is applied at the point one expects. When parameter binding is decided inside a database driver, there can be a gap between documented behavior and the actual call path. This post traces why &lt;code&gt;setSendStringParametersAsUnicode=false&lt;/code&gt; did not behave as expected inside the SQL Server JDBC driver.&lt;/p&gt;
&lt;p&gt;As a follow-up to the SQL Server VARCHAR index issue, I needed a way to leave parameters as the existing nvarchar values while querying parameters that use an index as varchar/char.&lt;/p&gt;
&lt;p&gt;One option is to send all string parameters as &lt;code&gt;varchar&lt;/code&gt; by adding &lt;code&gt;setSendStringParametersAsUnicode=false&lt;/code&gt; to the JDBC URL.&lt;/p&gt;
&lt;p&gt;When that is set, parameters that used to be sent as nvarchar are all sent as varchar. But a few columns in some DBs are exceptionally declared as nvarchar. To handle those, I used the @Nationalized annotation. With that in place, even if the JDBC URL declares that parameters should be sent as varchar, the attribute’s isNationalized value is set to true, so an nvarchar type query is generated.&lt;/p&gt;
&lt;p&gt;The problem here is that when saving, the query is sent as nvarchar, so all unicode special characters are saved properly. But when reading this column value from some DBs, the driver raises Could not extract column x from JDBC ResultSet - The conversion from varchar to NCHAR is unsupported.&lt;/p&gt;
&lt;h2 id=&quot;implementation-path&quot;&gt;Implementation Path&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;public void contributeTypes(TypeContributions typeContributions, ServiceRegistry serviceRegistry) {
    // by default, not much to do...
    registerColumnTypes( typeContributions, serviceRegistry );
    final NationalizationSupport nationalizationSupport = getNationalizationSupport();
    final JdbcTypeRegistry jdbcTypeRegistry = typeContributions.getTypeConfiguration().getJdbcTypeRegistry();
    if ( nationalizationSupport == NationalizationSupport.EXPLICIT ) {
       jdbcTypeRegistry.addDescriptor( NCharJdbcType.INSTANCE );
       jdbcTypeRegistry.addDescriptor( NVarcharJdbcType.INSTANCE );
       jdbcTypeRegistry.addDescriptor( LongNVarcharJdbcType.INSTANCE );
       jdbcTypeRegistry.addDescriptor( NClobJdbcType.DEFAULT );
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When @Nationalized is declared, NVarcharJdbcType is added to the jdbcTypeRegistry in the contributeTypes method of SQLServerDialect.java. Then, when Hibernate tries to map db values → Java objects, the extract function in BasicExtractor.java, which is used for that mapping,&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Override
public J extract(ResultSet rs, int paramIndex, WrapperOptions options) throws SQLException {
    final J value = doExtract( rs, paramIndex, options );
    if ( value == null || rs.wasNull() ) {
       if ( JdbcExtractingLogging.LOGGER.isTraceEnabled() ) {
          JdbcExtractingLogging.logNullExtracted(
                paramIndex,
                getJdbcType().getDefaultSqlTypeCode()
          );
       }
       return null;
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;takes the abstract function doExtract from NVarcharJdbcType.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Override
public &amp;lt;X&amp;gt; ValueExtractor&amp;lt;X&amp;gt; getExtractor(final JavaType&amp;lt;X&amp;gt; javaType) {
    return new BasicExtractor&amp;lt;&amp;gt;( javaType, this ) {
       @Override
       protected X doExtract(ResultSet rs, int paramIndex, WrapperOptions options) throws SQLException {
          return javaType.wrap( rs.getNString( paramIndex ), options );
       }
       @Override
       protected X doExtract(CallableStatement statement, int index, WrapperOptions options) throws SQLException {
          return javaType.wrap( statement.getNString( index ), options );
       }
       @Override
       protected X doExtract(CallableStatement statement, String name, WrapperOptions options) throws SQLException {
          return javaType.wrap( statement.getNString( name ), options );
       }
    };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Looking at the getNString function,&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Override
public String getNString(int columnIndex) throws SQLException {
    loggerExternal.entering(getClassNameLogging(), &quot;getNString&quot;, columnIndex);
    checkClosed();
    String value = (String) getValue(columnIndex, JDBCType.NCHAR);
    loggerExternal.exiting(getClassNameLogging(), &quot;getNString&quot;, value);
    return value;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;the driver tries to retrieve the stored value as &lt;code&gt;NCHAR&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;I added the @Nationalized annotation to handle columns that were declared as NVARCHAR at the request of some clients. But when most database columns that are actually stored as VARCHAR are converted this way, The conversion from varchar to NCHAR is unsupported. gets raised.&lt;/p&gt;
&lt;p&gt;If we dig into where the error is ultimately thrown,&lt;/p&gt;
&lt;p&gt;dtv.java&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;Object getValue(DTV dtv, JDBCType jdbcType, int scale, InputStreamGetterArgs streamGetterArgs, Calendar cal,
        TypeInfo typeInfo, CryptoMetadata cryptoMetadata, TDSReader tdsReader, SQLServerStatement statement) throws SQLServerException {
    SQLServerConnection con = tdsReader.getConnection();
    Object convertedValue = null;
    byte[] decryptedValue;
    boolean encrypted = false;
    SSType baseSSType = typeInfo.getSSType();
    // If column encryption is not enabled on connection or on statement, cryptoMeta will be null.
    if (null != cryptoMetadata) {
        assert (SSType.VARBINARY == typeInfo.getSSType()) || (SSType.VARBINARYMAX == typeInfo.getSSType());
        baseSSType = cryptoMetadata.baseTypeInfo.getSSType();
        encrypted = true;
        if (aeLogger.isLoggable(java.util.logging.Level.FINE)) {
            aeLogger.fine(&quot;Data is encrypted, SQL Server Data Type: &quot; + baseSSType + &quot;, Encryption Type: &quot;
                    + cryptoMetadata.getEncryptionType());
        }
    }
    // Note that the value should be prepped
    // only for columns whose values can be read of the wire.
    // If valueMark == null and isNull, it implies that
    // the column is null according to NBCROW and that
    // there is nothing to be read from the wire.
    if (null == valueMark &amp;amp;&amp;amp; (!isNull))
        getValuePrep(typeInfo, tdsReader);
    // either there should be a valueMark
    // or valueMark should be null and isNull should be set to true(NBCROW case)
    assert ((valueMark != null) || (valueMark == null &amp;amp;&amp;amp; isNull));
    if (null != streamGetterArgs) {
        if (!streamGetterArgs.streamType.convertsFrom(typeInfo))
            DataTypes.throwConversionError(typeInfo.getSSType().toString(), streamGetterArgs.streamType.toString());
    } else {
        if (!baseSSType.convertsTo(jdbcType) &amp;amp;&amp;amp; !isNull) {
            // if the baseSSType is Character or NCharacter and jdbcType is Longvarbinary,
            // does not throw type conversion error, which allows getObject() on Long Character types.
            if (encrypted) {
                if (!Util.isBinaryType(jdbcType.getIntValue())) {
                    DataTypes.throwConversionError(baseSSType.toString(), jdbcType.toString());
                }
            } else {
                DataTypes.throwConversionError(baseSSType.toString(), jdbcType.toString());
            }
        }
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;boolean convertsTo(JDBCType jdbcType) {
    return GetterConversion.converts(this, jdbcType);
}

static final boolean converts(SSType fromSSType, JDBCType toJDBCType) {
    return conversionMap.get(fromSSType.category).contains(toJDBCType.category);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The current situation is that fromSSType(DB declared type) is varchar and toJDBCType(Application declared type) is NCHAR. In this case, SSTYPE becomes CHARACTER, and because NCHAR is not in the map, the error occurs.&lt;/p&gt;
&lt;h2 id=&quot;how-i-verified-it&quot;&gt;How I Verified It&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://theokimdev.com/resources/blog/post-71-sql-server-jdbc-setsendstringparametersasunicodefalse/cover-5c50242c.png&quot; alt=&quot;86fca1b8-8937-474c-8435-e2047f1d2c0a.png&quot;&gt;&lt;/p&gt;
&lt;p&gt;The possible solutions I considered were all unsatisfying:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;Remove &lt;code&gt;@Nationalized&lt;/code&gt;, accept that some special characters cannot be saved/read correctly, and treat everything as &lt;code&gt;varchar&lt;/code&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;At runtime, when executing a select query, inspect the column type and forcibly change the descriptor in jdbcTypeRegistry&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Keep the columns declared as nvarchar in a separate table and do step 2&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Remove the @Nationalized annotation and use native query in CUD query to force type casting to nvarchar (of course, since reads are done as varchar, there will be defects for some special characters in columns declared as nvarchar)&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Clone the mssql-jdbc library and create v2
If I were to modify the driver, adding &lt;code&gt;SSType.Category.NCHAR&lt;/code&gt; to &lt;code&gt;CHARACTER&lt;/code&gt; in &lt;a href=&quot;http://m.microsoft.sqlserver.jdbc.SSType.GetterConversion&quot;&gt;http://m.microsoft.sqlserver.jdbc.SSType.GetterConversion&lt;/a&gt; looked like the relevant point.
Looking at &lt;a href=&quot;http://m.microsoft.sqlserver.jdbc.JDBCType.UpdaterConversion&quot;&gt;http://m.microsoft.sqlserver.jdbc.JDBCType.UpdaterConversion&lt;/a&gt;, &lt;code&gt;CHARACTER&lt;/code&gt; already contains &lt;code&gt;SSType.Category.NCHAR&lt;/code&gt;, so the write path appears to avoid the same failure. That makes the read-side gap feel especially unfortunate.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Below is source code that forcibly adds &lt;code&gt;NCHARACTER&lt;/code&gt; to the &lt;code&gt;CHARACTER&lt;/code&gt; enum map of &lt;code&gt;GetterConversion&lt;/code&gt;. Since it directly mutates library internals, it absolutely needs strict review before use.&lt;/p&gt;
&lt;p&gt;Reflection, &lt;code&gt;Unsafe&lt;/code&gt;, immutable enum internals, and error suppression all make this unpleasant. If there is a cleaner approach, I would much rather use that.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;import jakarta.annotation.PostConstruct
import org.springframework.context.annotation.Configuration
import sun.misc.Unsafe
import java.lang.reflect.Field
import java.lang.reflect.Modifier
import java.util.Collections
import java.util.EnumSet

@Configuration
class SSTypeConfiguration {

    @PostConstruct
    fun modifyCharacterEnumSet() {
        try {
            val getterConversionClass = Class.forName(&quot;com.microsoft.sqlserver.jdbc.SSType\$GetterConversion&quot;)
            val characterField: Field = getterConversionClass.getDeclaredField(&quot;CHARACTER&quot;)
            characterField.isAccessible = true

            val characterEnum = characterField.get(null)

            val categoriesField: Field = characterEnum.javaClass.getDeclaredField(&quot;to&quot;)
            categoriesField.isAccessible = true

            val unsafeField = Unsafe::class.java.getDeclaredField(&quot;theUnsafe&quot;)
            unsafeField.isAccessible = true
            val unsafe = unsafeField.get(null) as Unsafe

            val fieldOffset = unsafe.objectFieldOffset(categoriesField)

            @Suppress(&quot;UNCHECKED_CAST&quot;)
            val enumSet = categoriesField.get(characterEnum) as EnumSet&amp;lt;*&amp;gt;

            val nCharacterCategory = Class.forName(&quot;com.microsoft.sqlserver.jdbc.JDBCType\$Category&quot;)
                .enumConstants.first { (it as Enum&amp;lt;*&amp;gt;).name == &quot;NCHARACTER&quot; }

            @Suppress(&quot;UNCHECKED_CAST&quot;)
            (enumSet as MutableSet&amp;lt;Any&amp;gt;).add(nCharacterCategory)

            val immutableEnumSet = Collections.unmodifiableSet(enumSet)

            unsafe.putObject(characterEnum, fieldOffset, immutableEnumSet)

            val conversionMapField = getterConversionClass.getDeclaredField(&quot;conversionMap&quot;)
            conversionMapField.isAccessible = true
            @Suppress(&quot;UNCHECKED_CAST&quot;)
            val conversionMap = conversionMapField.get(null) as MutableMap&amp;lt;Any, EnumSet&amp;lt;*&amp;gt;&amp;gt;

            val sstypeCategoryClass = Class.forName(&quot;com.microsoft.sqlserver.jdbc.SSType\$Category&quot;)
            val characterCategory = sstypeCategoryClass.enumConstants.first { (it as Enum&amp;lt;*&amp;gt;).name == &quot;CHARACTER&quot; }

            @Suppress(&quot;UNCHECKED_CAST&quot;)
            (conversionMap[characterCategory] as MutableSet&amp;lt;Any&amp;gt;?)?.add(nCharacterCategory)
           } catch (e: Exception) {
            // runtime에 터뜨리고 롤백과정을 밟는 것이 이상적일듯함
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;driver-boundary-takeaway&quot;&gt;Driver Boundary Takeaway&lt;/h2&gt;
&lt;p&gt;Problems like this do not end at application configuration. The driver’s parameter-binding behavior has to be understood, and sometimes that means reading library source. Production performance issues often become most complex at the boundary between framework, driver, and database engine.&lt;/p&gt;</content:encoded><language>en-US</language><category>Spring Boot</category><author>ystc1247@gmail.com</author></item><item><title>What to Know When Scaling Pods Down to Zero with KEDA</title><link>https://theokimdev.com/en/blog/post-69-keda-pod-replica-0-scale-in/</link><guid isPermaLink="true">https://theokimdev.com/en/blog/post-69-keda-pod-replica-0-scale-in/</guid><description>A source-code-backed look at how KEDA and HPA divide responsibilities around minReplicas and scale-to-zero behavior.</description><pubDate>Wed, 09 Oct 2024 09:32:03 GMT</pubDate><content:encoded>&lt;h2 id=&quot;where-the-problem-started&quot;&gt;Where the Problem Started&lt;/h2&gt;
&lt;p&gt;Scale-to-zero looks attractive because it reduces cost, but the behavior is not fully explained by the default Kubernetes HPA alone. Once replicas reach zero, metric collection and scaling triggers become a different responsibility. This post follows KEDA and HPA through the source code to understand that division.&lt;/p&gt;
&lt;p&gt;Kubernetes has HPA(Horizontal Pod Autoscaler) as its basic scaler. It exists for scale out and scale in, and adjusts the pod replica count.&lt;/p&gt;
&lt;p&gt;The basis for this adjustment is whether the resource metric(CPU, memory, etc.) specified through yaml or api is above or below the target utilization.&lt;/p&gt;
&lt;p&gt;Start with a simple HPA YAML.&lt;/p&gt;
&lt;h2 id=&quot;implementation-path&quot;&gt;Implementation Path&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: example-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: example-deployment
  minReplicas: 2
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 80
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With this HPA, Kubernetes increases replicas when average CPU utilization goes above 80% and scales in when it falls below the target.&lt;/p&gt;
&lt;p&gt;The interesting part is how that CPU calculation works when multiple pods are involved.&lt;/p&gt;
&lt;p&gt;HPA uses a formula like this.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-properties&quot;&gt;desiredReplicas = ceil[currentReplicas * (currentMetricValue / targetMetricValue)]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For example, assume there are currently 4 replicas, averageUtilization is declared as 80%, and the current average cpu of the pods is 120%.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-properties&quot;&gt;desiredReplicas = ceil[4 * (120 / 80)] = ceil[6] = 6 replicas
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Applying the formula above shows that it should scale out from 4 replicas to 6 replicas.&lt;/p&gt;
&lt;p&gt;While using HPA like this, I suddenly thought that it would be economical to reduce the replica count to 0 on development/release servers at dawn, when there is no resource usage at all. So I set minReplica to 0 and applied it, but the replica count never dropped below 1.&lt;/p&gt;
&lt;p&gt;From a developer’s perspective, the biggest advantage of an open source project is that you can open up the source code, so I looked through the k8s HPA code.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func startHPAControllerWithMetricsClient(ctx context.Context, controllerContext ControllerContext, metricsClient metrics.MetricsClient) (controller.Interface, bool, error) {

	hpaClient := controllerContext.ClientBuilder.ClientOrDie(&quot;horizontal-pod-autoscaler&quot;)
	hpaClientConfig := controllerContext.ClientBuilder.ConfigOrDie(&quot;horizontal-pod-autoscaler&quot;)

	// we don&apos;t use cached discovery because DiscoveryScaleKindResolver does its own caching,
	// so we want to re-fetch every time when we actually ask for it
	scaleKindResolver := scale.NewDiscoveryScaleKindResolver(hpaClient.Discovery())
	scaleClient, err := scale.NewForConfig(hpaClientConfig, controllerContext.RESTMapper, dynamic.LegacyAPIPathResolverFunc, scaleKindResolver)
	if err != nil {
		return nil, false, err
	}

	go podautoscaler.NewHorizontalController(
		ctx,
		hpaClient.CoreV1(),
		scaleClient,
		hpaClient.AutoscalingV2(),
		controllerContext.RESTMapper,
		metricsClient,
		controllerContext.InformerFactory.Autoscaling().V2().HorizontalPodAutoscalers(),
		controllerContext.InformerFactory.Core().V1().Pods(),
		controllerContext.ComponentConfig.HPAController.HorizontalPodAutoscalerSyncPeriod.Duration,
		controllerContext.ComponentConfig.HPAController.HorizontalPodAutoscalerDownscaleStabilizationWindow.Duration,
		controllerContext.ComponentConfig.HPAController.HorizontalPodAutoscalerTolerance,
		controllerContext.ComponentConfig.HPAController.HorizontalPodAutoscalerCPUInitializationPeriod.Duration,
		controllerContext.ComponentConfig.HPAController.HorizontalPodAutoscalerInitialReadinessDelay.Duration,
	).Run(ctx, int(controllerContext.ComponentConfig.HPAController.ConcurrentHorizontalPodAutoscalerSyncs))
	return nil, true, nil
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The controller setup creates the HPA controller. Following that path into &lt;code&gt;NewHorizontalController&lt;/code&gt;,&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;replicaCalc := NewReplicaCalculator(
    metricsClient,
    hpaController.podLister,
    tolerance,
    cpuInitializationPeriod,
    delayOfInitialReadinessStatus,
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;the replica calculator stands out. The fields are roughly:&lt;/p&gt;
&lt;p&gt;metricsClient : Client for fetching and aggregating CPU or memory util&lt;/p&gt;
&lt;p&gt;podLister : Client for fetching actual pod metrics such as current readiness and health&lt;/p&gt;
&lt;p&gt;tolerance : Scaling only happens when the replica count calculation exceeds this number&lt;/p&gt;
&lt;p&gt;cpuInitializationPeriod : Since cpu is unstable when a pod is first created, it is excluded from calculation during this period&lt;/p&gt;
&lt;p&gt;delayOfInitialReadinessStatus : This does not exclude the pod; it is just the period during which HPA will not perform replica scaling&lt;/p&gt;
&lt;p&gt;Inside &lt;code&gt;GetResourceReplicas&lt;/code&gt; in &lt;code&gt;replica_calculator.go&lt;/code&gt;, the final replica count calculation becomes visible.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;// re-run the utilization calculation with our new numbers
newUsageRatio, _, _, err := metricsclient.GetResourceUtilizationRatio(metrics, requests, targetUtilization)
if err != nil {
    return 0, utilization, rawUtilization, time.Time{}, err
}

if math.Abs(1.0-newUsageRatio) &amp;lt;= c.tolerance || (usageRatio &amp;lt; 1.0 &amp;amp;&amp;amp; newUsageRatio &amp;gt; 1.0) || (usageRatio &amp;gt; 1.0 &amp;amp;&amp;amp; newUsageRatio &amp;lt; 1.0) {
    // return the current replicas if the change would be too small,
    // or if the new usage ratio would cause a change in scale direction
    return currentReplicas, utilization, rawUtilization, timestamp, nil
}

newReplicas := int32(math.Ceil(newUsageRatio * float64(len(metrics))))
if (newUsageRatio &amp;lt; 1.0 &amp;amp;&amp;amp; newReplicas &amp;gt; currentReplicas) || (newUsageRatio &amp;gt; 1.0 &amp;amp;&amp;amp; newReplicas &amp;lt; currentReplicas) {
    // return the current replicas if the change of metrics length would cause a change in scale direction
    return currentReplicas, utilization, rawUtilization, timestamp, nil
}

// return the result, where the number of replicas considered is
// however many replicas factored into our calculation
return newReplicas, utilization, rawUtilization, timestamp, nil
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But here, I cannot find any validation saying newReplicas must not be 0. It was the same when I looked through the private functions that appear in between.&lt;/p&gt;
&lt;p&gt;So I went back to horizontal.go.&lt;/p&gt;
&lt;p&gt;The relevant snippet looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;var minReplicas int32

if hpa.Spec.MinReplicas != nil {
    minReplicas = *hpa.Spec.MinReplicas
} else {
    // Default value
    minReplicas = 1
}

rescale := true
logger := klog.FromContext(ctx)

if currentReplicas == 0 &amp;amp;&amp;amp; minReplicas != 0 {
    // Autoscaling is disabled for this resource
    desiredReplicas = 0
    rescale = false
    setCondition(hpa, autoscalingv2.ScalingActive, v1.ConditionFalse, &quot;ScalingDisabled&quot;, &quot;scaling is disabled since the replica count of the target is zero&quot;)
} else if currentReplicas &amp;gt; hpa.Spec.MaxReplicas {
    rescaleReason = &quot;Current number of replicas above Spec.MaxReplicas&quot;
    desiredReplicas = hpa.Spec.MaxReplicas
} else if currentReplicas &amp;lt; minReplicas {
    rescaleReason = &quot;Current number of replicas below Spec.MinReplicas&quot;
    desiredReplicas = minReplicas
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;minReplicas&lt;/code&gt; is a user-declared value, and in the YAML above it is set to 2. No matter how low the resource metric becomes, HPA must keep at least that many replicas. If it is not declared, the default becomes 1, and &lt;code&gt;Spec.MinReplicas&lt;/code&gt; cannot normally be set to 0.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;// minReplicas is the lower limit for the number of replicas to which the autoscaler
// can scale down.  It defaults to 1 pod.  minReplicas is allowed to be 0 if the
// alpha feature gate HPAScaleToZero is enabled and at least one Object or External
// metric is configured.  Scaling is active as long as at least one metric value is
// available.
// +optional
MinReplicas *int32 `json:&quot;minReplicas,omitempty&quot; protobuf:&quot;varint,2,opt,name=minReplicas&quot;`
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The comment explains the rule: unless the alpha feature gate is enabled and Object/External metrics are configured, &lt;code&gt;minReplicas&lt;/code&gt; cannot be 0. &lt;code&gt;validate.go&lt;/code&gt; confirms the same constraint in code.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func validateMetrics(metrics []autoscaling.MetricSpec, fldPath *field.Path, minReplicas *int32) field.ErrorList {
    allErrs := field.ErrorList{}
    hasObjectMetrics := false
    hasExternalMetrics := false

    for i, metricSpec := range metrics {
       idxPath := fldPath.Index(i)
       if targetErrs := validateMetricSpec(metricSpec, idxPath); len(targetErrs) &amp;gt; 0 {
          allErrs = append(allErrs, targetErrs...)
       }
       if metricSpec.Type == autoscaling.ObjectMetricSourceType {
          hasObjectMetrics = true
       }
       if metricSpec.Type == autoscaling.ExternalMetricSourceType {
          hasExternalMetrics = true
       }
    }

    if minReplicas != nil &amp;amp;&amp;amp; *minReplicas == 0 {
       if !hasObjectMetrics &amp;amp;&amp;amp; !hasExternalMetrics {
          allErrs = append(allErrs, field.Forbidden(fldPath, &quot;must specify at least one Object or External metric to support scaling to zero replicas&quot;))
       }
    }

    return allErrs
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;However, alpha feature gates are settings for unstable experimental features that are not yet ready for general use. In the end, this means you cannot reduce minReplicas to 0 when using only k8s in production.&lt;/p&gt;
&lt;p&gt;This is where Keda comes in. Like k8s, it is a CNCF graduate project, and it is a scaler widely used as an alternative to HPA.&lt;/p&gt;
&lt;p&gt;As you can tell from “alternative”, you cannot declare HPA and Keda at the same time for the same target.&lt;/p&gt;
&lt;h2 id=&quot;how-i-verified-it&quot;&gt;How I Verified It&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://theokimdev.com/resources/blog/post-69-keda-pod-replica-0-scale-in/cover-df56b086.png&quot; alt=&quot;Scaling in to pod replica 0 with Keda screenshot 01&quot;&gt;&lt;/p&gt;
&lt;p&gt;This is the key flow image for understanding Keda. The most important part is that Keda is responsible for adjusting the replica count from 0-&amp;gt;1 and 1-&amp;gt;0, while HPA handles the rest. You can find the details in the Keda docs.&lt;/p&gt;
&lt;p&gt;The following is a yaml file that can make the pod replica count 0.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;{{- if or (eq .Values.env &quot;develop&quot;) (eq .Values.env &quot;release&quot;) }}
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: some-application-{{ .Values.env }}-scaledobject
  namespace: some-application
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: some-application-{{ .Values.env }}
  cooldownPeriod: 300
  minReplicaCount: 0
  maxReplicaCount: 10
  triggers:
    - type: cron
      metadata:
        timezone: Asia/Seoul
        start: &quot;30 08 * * *&quot;
        end: &quot;30 19 * * *&quot;
        desiredReplicas: &quot;1&quot;
    - type: memory
      metricType: Utilization
      metadata:
        value: &quot;90&quot;
{{- end }}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;operational-takeaway&quot;&gt;Operational Takeaway&lt;/h2&gt;
&lt;p&gt;It is not enough to say that KEDA enables replicas to reach zero. To operate it safely, one needs to know which scaler detects events, when HPA takes over again, and where minReplicas is enforced. Cost optimization has to be understood together with runtime semantics.&lt;/p&gt;
&lt;p&gt;Because only development/release pods should scale to zero, it is convenient to declare the environment in templated values and branch from there.&lt;/p&gt;
&lt;p&gt;The familiar HPA values appear here with slightly different names: &lt;code&gt;minReplicaCount&lt;/code&gt; and &lt;code&gt;maxReplicaCount&lt;/code&gt;. &lt;code&gt;cooldownPeriod&lt;/code&gt; defines how long KEDA waits before scaling down after activity drops, giving the workload time to stabilize after sudden traffic or metric changes.&lt;/p&gt;
&lt;p&gt;The trigger is the core of KEDA. KEDA supports many trigger types: CloudWatch log or metric signals, Prometheus custom metrics, cron schedules, queue depth, and more.&lt;/p&gt;
&lt;p&gt;Since the initial goal was to scale the pod count to 0 at a specific time, I created a cron type trigger, set the pod count to 1 from 08:30 to 19:30, and did not declare it for other times.&lt;/p&gt;
&lt;p&gt;I also added a memory type trigger. When multiple triggers exist like this, replicas are created according to the maximum value from each trigger calculation. (&lt;a href=&quot;https://github.com/kedacore/keda/issues/5078&quot;&gt;https://github.com/kedacore/keda/issues/5078&lt;/a&gt;)&lt;/p&gt;</content:encoded><language>en-US</language><category>Kubernetes</category><category>0 replica</category><category>keda</category><category>minreplica</category><author>ystc1247@gmail.com</author></item><item><title>Running Kafka and Debezium on Kubernetes with Strimzi</title><link>https://theokimdev.com/en/blog/post-67-strimzi-kafka-debezium/</link><guid isPermaLink="true">https://theokimdev.com/en/blog/post-67-strimzi-kafka-debezium/</guid><description>A production-minded walkthrough of deploying Kafka, KafkaConnect, and Debezium on Kubernetes with Strimzi, including CDC, TLS, offset recovery, and operational caveats.</description><pubDate>Wed, 24 Jul 2024 09:57:27 GMT</pubDate><content:encoded>&lt;h2 id=&quot;where-the-problem-started&quot;&gt;Where the Problem Started&lt;/h2&gt;
&lt;p&gt;The hard part of introducing CDC is not only starting a connector. It is deciding how database changes become an event pipeline, how Kafka and Debezium should run inside the application cluster, and how to recover when offsets break. This post records the Strimzi setup and operational points I ran into while running Kafka and Debezium on Kubernetes.&lt;/p&gt;
&lt;p&gt;Debezium is a common CDC(Change Data Capture) tool.&lt;/p&gt;
&lt;p&gt;The reason for using CDC is to quickly detect database changes, emit events, and run work from them.&lt;/p&gt;
&lt;p&gt;To briefly explain the use case that led to introducing it,&lt;/p&gt;
&lt;p&gt;A POST API request enters the application -&amp;gt; information is stored in database A -&amp;gt; an external API is called -&amp;gt; if the external call succeeds, information is stored in database B -&amp;gt; follow-up service methods run&lt;/p&gt;
&lt;p&gt;In that flow, database A could be updated even when the external API failed, so the whole process was not protected by one transaction boundary.&lt;/p&gt;
&lt;p&gt;Adding &lt;code&gt;@Transactional&lt;/code&gt; and rolling back on failure sounds like the obvious answer, but the external API call was being sent asynchronously, so that did not solve the consistency problem.&lt;/p&gt;
&lt;p&gt;The target design was to call the external API first, then persist a detectable row only after success. Debezium could then capture that change, emit an event, and trigger the remaining workflow.&lt;/p&gt;
&lt;p&gt;Several pub/sub systems or message queues can handle that event flow. This post focuses on Kafka, and more specifically on running Kafka and Debezium as pods inside the Kubernetes cluster instead of operating them as separate instances.&lt;/p&gt;
&lt;p&gt;Strimzi is one practical way to deploy Kafka on Kubernetes. A Kafka cluster, Kafka Connect, and connector resources can be managed with a small set of YAML files, so I will organize the setup around that path.&lt;/p&gt;
&lt;p&gt;Define the namespace first.&lt;/p&gt;
&lt;h2 id=&quot;implementation-path&quot;&gt;Implementation Path&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;kubectl create ns strimzi-debezium
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Additionally, if you are using helm:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;helm install strimzi-operator strimzi/strimzi-kafka-operator -n strimzi-debezium
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Create the Kafka cluster resource next.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;apiVersion: kafka.strimzi.io/v1beta2
kind: Kafka
metadata:
  name: my-cluster
  namespace: strimzi-debezium
spec:
  kafka:
    version: 3.7.0
    replicas: 3
    listeners:
      - name: plain
        port: 9092
        type: internal
        tls: false
      - name: tls
        port: 9093
        type: internal
        tls: true
    storage:
      type: jbod
      volumes:
        - id: 0
          type: persistent-claim
          size: 10Gi
          deleteClaim: false
    config:
      offsets.topic.replication.factor: 3
      transaction.state.log.replication.factor: 3
      transaction.state.log.min.isr: 2
      default.replication.factor: 3
      min.insync.replicas: 2
      ssl.endpoint.identification.algorithm: &quot;&quot;
      log4j.logger.org.apache.kafka: DEBUG
      log4j.logger.kafka: DEBUG
    resources:
      requests:
        memory: 1Gi
        cpu: &quot;0.5&quot;
      limits:
        memory: 2Gi
        cpu: &quot;1&quot;
    template:
      kafkaContainer:
        env:
          - name: KAFKA_HEAP_OPTS
            value: &quot;-Xms1G -Xmx1G&quot;
  zookeeper:
    replicas: 3
    storage:
      type: persistent-claim
      size: 10Gi
      deleteClaim: false
    resources:
      requests:
        memory: 512Mi
        cpu: &quot;0.5&quot;
      limits:
        memory: 1Gi
        cpu: &quot;1&quot;
    config:
      autopurge.snapRetainCount: 3
      autopurge.purgeInterval: 1
      clientCnxnSocket: org.apache.zookeeper.ClientCnxnSocketNetty
      serverCnxnFactory: org.apache.zookeeper.server.NettyServerCnxnFactory
      quorum.ssl.enabled: true
      ssl.quorum.protocol: TLSv1.2
      tickTime: 2000
      initLimit: 15
      syncLimit: 10
    logging:
      type: inline
      loggers:
        zookeeper.root.logger: &quot;INFO,CONSOLE&quot;
    template:
      pod:
        securityContext:
          runAsUser: 1001
          fsGroup: 1001
  entityOperator:
    topicOperator: {}
    userOperator: {}
  clusterCa:
    generateCertificateAuthority: true
  clientsCa:
    generateCertificateAuthority: true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here, Kafka and ZooKeeper are each created with three replicas, and CPU/memory are allocated fairly generously. The replica count should follow the use case, but these resource values were close to the minimum I felt comfortable with for reliable database-change delivery.&lt;/p&gt;
&lt;p&gt;For apiVersion, you can use versions like v1alpha in addition to v1beta2, but some conf properties are missing depending on the version, so do not mix them casually.&lt;/p&gt;
&lt;p&gt;Kafka is set to 3.7.0, which was the latest version at the time. When I pinned the Strimzi version and brought the stack up together, Kafka versions around 3.2 or lower were not compatible.&lt;/p&gt;
&lt;p&gt;I exposed two Kafka listeners: one plaintext port and one TLS port. While experimenting with the settings, I saw connection failures when a client tried to connect without the expected TLS certificate, so this part is worth setting carefully from the beginning.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;ssl.endpoint.identification.algorithm: &quot;&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This part disables hostname verification for SSL connection, which is not recommended in a prod environment.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;template:
  kafkaContainer:
    env:
      - name: KAFKA_HEAP_OPTS
        value: &quot;-Xms1G -Xmx1G&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This config sets the Kafka heap allocation. Without it, the pod can run into memory pressure during startup.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;clusterCa:
  generateCertificateAuthority: true
clientsCa:
  generateCertificateAuthority: true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This part is optional. You can also create and use the CA and pem yourself.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes -subj &quot;/CN=kafka-connect&quot;
kubectl create secret generic kafka-connect-tls --from-file=tls.key=key.pem --from-file=tls.crt=cert.pem -n strimzi-debezium
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the manual certificate creation path. The remaining details follow the standard Kubernetes secret and Strimzi certificate flow.&lt;/p&gt;
&lt;p&gt;Next is kafka-connect.yaml.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;apiVersion: kafka.strimzi.io/v1beta2
kind: KafkaConnect
metadata:
  name: my-connect-cluster
  namespace: strimzi-debezium
  annotations:
    strimzi.io/use-connector-resources: &quot;true&quot;
spec:
  image: ystc1247/kafka-strimzi:0.0.3
  replicas: 1
  bootstrapServers: my-cluster-kafka-bootstrap:9093
  tls:
    trustedCertificates:
      - secretName: my-cluster-cluster-ca-cert
        certificate: ca.crt
  config:
    offset.storage.topic: connect-cluster-offsets
    config.storage.topic: connect-cluster-configs
    status.storage.topic: connect-cluster-status
    config.storage.replication.factor: 1
    offset.storage.replication.factor: 1
    status.storage.replication.factor: 1
    config.providers: file
    config.providers.file.class: org.apache.kafka.common.config.provider.FileConfigProvider
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;annotations:
  strimzi.io/use-connector-resources: &quot;true&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This annotation is important because it allows connector resources to be managed properly. For the image, I used one I built and pushed to DockerHub with Strimzi and the MySQL connector included. A different image is fine as long as it contains the required connector.&lt;/p&gt;
&lt;p&gt;I am attaching the Dockerfile.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dockerfile&quot;&gt;FROM strimzi/kafka:0.20.1-kafka-2.5.0
USER root:root
RUN mkdir -p /opt/kafka/plugins/debezium
COPY ./debezium-connector-mysql/ /opt/kafka/plugins/debezium/
USER 1001
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The MySQL connector I used:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;curl -L https://repo1.maven.org/maven2/io/debezium/debezium-connector-mysql/2.7.0.Final/debezium-connector-mysql-2.7.0.Final-plugin.tar.gz | tar -xz
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;bootstrapServers: my-cluster-kafka-bootstrap:9093
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You can use 9092, which was configured without tls, for this part, but authentication issues may occur.&lt;/p&gt;
&lt;p&gt;Next is kafka-connector.yaml.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;apiVersion: kafka.strimzi.io/v1beta2
kind: KafkaConnector
metadata:
  name: inventory-connector
  namespace: strimzi-debezium
  labels:
    strimzi.io/cluster: my-connect-cluster
spec:
  class: io.debezium.connector.mysql.MySqlConnector
  tasksMax: 1
  config:
    topic.prefix: ${원하는 topic prefix}
    database.hostname: dbhost
    database.port: dbport
    database.user: 유저
    database.password: 비번
    database.dbname: db이름
    database.server.id: 185239 (고유한 id를 사용)
    database.server.name: db server
    table.include.list: 중요! 변화를 감지하고자 하는 table
    schema.history.internal.kafka.bootstrap.servers: &quot;my-cluster-kafka-bootstrap:9092&quot;
    schema.history.internal.kafka.topic: &quot;schema-changes.inventory&quot;
    database.history.kafka.bootstrap.servers: &quot;my-cluster-kafka-bootstrap:9092&quot;
    database.history.kafka.topic: &quot;database-changes.inventory&quot;
    include.schema.changes: &quot;true&quot;
    database.history.kafka.reset.offset: &quot;true&quot;
    snapshot.mode: &quot;when_needed&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;snapshot.mode is important because Debezium provides many different snapshot behaviors. Choose the mode that matches the recovery behavior you need. The detailed options are in the &lt;a href=&quot;https://debezium.io/documentation/reference/stable/connectors/mysql.html&quot;&gt;Debezium MySQL connector documentation&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;If pod creation and deletion repeat and Debezium misses an offset, a mismatch can occur between the current snapshot and the available snapshot.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;│ INFO: Connected to {} at mysql-bin-changelog.400734/891 (sid:185054, cid:15583019)                                                                          │
│ 2024-07-24 06:42:19,297 INFO [inventory-connector|task-0] Connected to binlog at dev.data.yeogiya.io:3306, starting at BinlogOffsetContext{sourceInfoSchema=Schema{io.debezium.co │
│ 2024-07-24 06:42:19,297 INFO [inventory-connector|task-0] Waiting for keepalive thread to start (io.debezium.connector.binlog.BinlogStreamingChangeEventSource) [debezium-mysqlco │
│ 2024-07-24 06:42:19,297 INFO [inventory-connector|task-0] Creating thread debezium-mysqlconnector-{}-binlog-client (io.debezium.util.Threads) [blc-{} │
│ 2024-07-24 06:42:19,297 ERROR [inventory-connector|task-0] Error during binlog processing. Last offset stored = null, binlog reader near position = mysql-bin-changelog.400734/89 │
│ 2024-07-24 06:42:19,298 ERROR [inventory-connector|task-0] Producer failure (io.debezium.pipeline.ErrorHandler) [blc-{}:3306]                                    │
│ io.debezium.DebeziumException: Could not find first log file name in binary log index file Error code: 1236; SQLSTATE: HY000.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This error usually means Debezium is trying to read a binary log file that no longer exists, such as &lt;code&gt;mysql-bin-changelog.400734&lt;/code&gt;. A MySQL version change can also create related offset/snapshot mismatches.&lt;/p&gt;
&lt;p&gt;For the missing-binlog case, start here.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;SHOW VARIABLES LIKE &apos;log_bin&apos;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Check that binary logging is enabled.&lt;/p&gt;
&lt;p&gt;Next:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;mysql&amp;gt; SHOW BINARY LOGS;
+----------------------------+-----------+-----------+
| Log_name                   | File_size | Encrypted |
+----------------------------+-----------+-----------+
| mysql-bin-changelog.400803 |      2436 | No        |
| mysql-bin-changelog.400804 |      2436 | No        |
| mysql-bin-changelog.400805 |       891 | No        |
+----------------------------+-----------+-----------+
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Compare the version Debezium is currently reading with the current latest version.&lt;/p&gt;
&lt;p&gt;Additionally, there could be problems with global variables like expire_logs_days and binlog_expire_logs_seconds, but if you have not changed them and they are still at the default, they probably are not the issue.&lt;/p&gt;
&lt;p&gt;Then force Kafka/Debezium to move the consumer offset to a valid position.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;apiVersion: v1
kind: Pod
metadata:
  name: kafka-client
  namespace: default
spec:
  containers:
    - name: kafka
      image: confluentinc/cp-kafka:latest
      command:
        - sleep
        - &quot;3600&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Bring up this helper pod first. If the Kafka shell tools are already available elsewhere, this step can be skipped.&lt;/p&gt;
&lt;p&gt;After that:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;kubectl exec -it kafka-client -n default -- /bin/sh
cd /usr/bin
./kafka-consumer-groups --bootstrap-server my-cluster-kafka-bootstrap.strimzi-debezium.svc.cluster.local:9092 --group inventory-connector --reset-offsets --to-latest --all-topics --execute
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This resets the connector group’s offsets for all topics to the latest position. Any database changes between the old offset and the new one will not be captured, so this is a recovery operation that must be used carefully.&lt;/p&gt;
&lt;p&gt;Here are the results.&lt;/p&gt;
&lt;h2 id=&quot;how-i-verified-it&quot;&gt;How I Verified It&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://theokimdev.com/resources/blog/post-67-strimzi-kafka-debezium/cover-61ce01c6.png&quot; alt=&quot;Running Kafka and Debezium on Kubernetes with Strimzi screenshot 01&quot;&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;kubectl exec -it my-cluster-kafka-0 -n strimzi-debezium -- bin/kafka-console-consumer.sh --bootstrap-server my-cluster-kafka-bootstrap:9092 --topic {topic-name} --from-beginning
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;operational-takeaway&quot;&gt;Operational Takeaway&lt;/h2&gt;
&lt;p&gt;Strimzi lifts Kafka operation into Kubernetes objects, but it does not remove operational responsibility. TLS, heap sizing, connector images, topic replication, binlog retention, and offset reset all connect directly to real failure modes. Once a CDC pipeline is attached, it becomes part of the data flow, so failure and recovery need to be designed from the beginning.&lt;/p&gt;
&lt;p&gt;Running this shows the messages Kafka is producing.&lt;/p&gt;</content:encoded><language>en-US</language><category>kafka</category><category>Debezium</category><category>eks</category><category>Helm</category><category>Kafka</category><category>Strimzi</category><author>ystc1247@gmail.com</author></item><item><title>Calling APIs from Database Triggers: Usage and Risk</title><link>https://theokimdev.com/en/blog/post-65-trigger-database-api/</link><guid isPermaLink="true">https://theokimdev.com/en/blog/post-65-trigger-database-api/</guid><description>An implementation note on calling external APIs from database triggers, with attention to transaction boundaries, failure propagation, and coupling risk.</description><pubDate>Sat, 01 Jun 2024 22:59:15 GMT</pubDate><content:encoded>&lt;h2 id=&quot;where-the-problem-started&quot;&gt;Where the Problem Started&lt;/h2&gt;
&lt;p&gt;Database triggers are powerful, but dangerous. They run outside the application layer, which makes flow harder to see, and calling an external API from that point complicates failure propagation and transaction boundaries. This post records how the approach works and why it should be handled carefully.&lt;/p&gt;
&lt;p&gt;In databases, triggers are often used for business needs such as maintaining data integrity and validation. When an operation such as INSERT or DELETE happens on a specific table, row, or column, the trigger is bound to the same transaction and performs additional work on top of the original operation.&lt;/p&gt;
&lt;p&gt;But you can also attach an API call, something normally handled in the server application layer, to that flow.&lt;/p&gt;
&lt;p&gt;The first thing to emphasize upfront is that &lt;em&gt;&lt;strong&gt;this is absolutely not recommended.&lt;/strong&gt;&lt;/em&gt; As mentioned earlier, the natural procedure is to call APIs from the controller and service layers alongside database operations executed by the application. There is no reason to create unpredictable database load by calling APIs from sensitive database work that needs to preserve ACID properties.&lt;/p&gt;
&lt;p&gt;With that warning in mind, this post looks at how an API call can be attached to a database trigger, and what kind of load and coupling that creates. This belongs in the category of last-resort engineering: useful only when business constraints leave no cleaner option.&lt;/p&gt;
&lt;p&gt;For the database, I used plain SQL Server 2019, and for analysis I used Datagrip.&lt;/p&gt;
&lt;p&gt;Start with a table that has only a simple &lt;code&gt;name&lt;/code&gt; column.&lt;/p&gt;
&lt;h2 id=&quot;implementation-path&quot;&gt;Implementation Path&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE TABLE BasicTable (
    id INT IDENTITY(1,1) PRIMARY KEY,
    name NVARCHAR(100) NOT NULL
);
GO
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For the HTTP call, I used a public API that predicts age from a &lt;code&gt;name&lt;/code&gt; parameter. When I passed in my own name, it returned a predicted age of 66.&lt;/p&gt;
&lt;h2 id=&quot;how-i-verified-it&quot;&gt;How I Verified It&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://theokimdev.com/resources/blog/post-65-trigger-database-api/image-02-e2b2d250.png&quot; alt=&quot;Calling an API from the Database with a Trigger screenshot 01&quot;&gt;&lt;/p&gt;
&lt;p&gt;SQL Server requires an additional option before a trigger can call an external API directly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;EXEC sp_configure &apos;show advanced options&apos;, 1;
RECONFIGURE;
EXEC sp_configure &apos;Ole Automation Procedures&apos;, 1;
RECONFIGURE;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This option is for using OLE Automation Object inside Microsoft’s COM(Component Object Model), which is used only in MSSQL. Specifically, because we will use MSXML2.ServerXMLHTTP among the COM components for the current HTTP request, enabling this option is required. If you are wondering what OLE is, read &lt;a href=&quot;https://en.wikipedia.org/wiki/Object_Linking_and_Embedding&quot;&gt;https://en.wikipedia.org/wiki/Object_Linking_and_Embedding&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The rough procedures used to make the HTTP call and receive the response are as follows.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sp_OACreate: Creates an instance of an OLE Automation object.
sp_OADestroy: Destroys an instance of an OLE Automation object.
sp_OAGetProperty: Gets a property value of an OLE Automation object.
sp_OASetProperty: Sets a property value of an OLE Automation object.
sp_OAMethod: Calls a method of an OLE Automation object.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Create a separate table to log what the trigger does.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE TABLE ApiResponseLog (
    id INT IDENTITY(1,1) PRIMARY KEY,
    operation_type NVARCHAR(10),
    name NVARCHAR(100),
    response NVARCHAR(1000),
    timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
);
GO
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I will insert the operation_type (UPDATE, SELECT, INSERT, DELETE), the name to be passed to the API, and the corresponding response.&lt;/p&gt;
&lt;p&gt;But for performance testing, I also wanted to record the API call time, DB insert time, and so on.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;ALTER TABLE ApiResponseLog
ADD api_call_start_time DATETIME2,
    api_call_end_time DATETIME2,
    api_call_duration_ms INT,
    insert_start_time DATETIME2,
    insert_end_time DATETIME2,
    insert_duration_ms INT;
GO
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The trigger itself looks like this.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE TRIGGER trgAfterAllOperations ON BasicTable
AFTER INSERT, UPDATE, DELETE
AS
BEGIN
    DECLARE @name NVARCHAR(100);
    DECLARE @url NVARCHAR(200);
    DECLARE @response NVARCHAR(1000);
    DECLARE @object INT;
    DECLARE @status INT;
    DECLARE @operation_type NVARCHAR(10);
    DECLARE @api_call_start_time DATETIME2;
    DECLARE @api_call_end_time DATETIME2;
    DECLARE @insert_start_time DATETIME2;
    DECLARE @insert_end_time DATETIME2;

    -- operation type 결정
    -- update 의 경우 delete/insert pseudotable 모두에 행이 생성됨
    IF EXISTS (SELECT * FROM inserted) AND EXISTS (SELECT * FROM deleted)
        SET @operation_type = &apos;UPDATE&apos;;
    ELSE IF EXISTS (SELECT * FROM inserted)
        SET @operation_type = &apos;INSERT&apos;;
    ELSE IF EXISTS (SELECT * FROM deleted)
        SET @operation_type = &apos;DELETE&apos;;

    IF @operation_type = &apos;INSERT&apos; OR @operation_type = &apos;UPDATE&apos;
    BEGIN
        SELECT TOP 1 @name = name FROM inserted;
    END
    ELSE IF @operation_type = &apos;DELETE&apos;
    BEGIN
        SELECT TOP 1 @name = name FROM deleted;
    END

    -- API URL 지정
    SET @url = &apos;https://api.agify.io?name=&apos; + @name;

    -- API call 시작시간 기록
    SET @api_call_start_time = SYSDATETIME();

    -- API call!!
    EXEC @status = sp_OACreate &apos;MSXML2.ServerXMLHTTP&apos;, @object OUT;
    IF @status = 0 EXEC @status = sp_OAMethod @object, &apos;open&apos;, NULL, &apos;GET&apos;, @url, &apos;false&apos;;
    IF @status = 0 EXEC @status = sp_OAMethod @object, &apos;send&apos;;
    IF @status = 0 EXEC @status = sp_OAGetProperty @object, &apos;responseText&apos;, @response OUT;
    IF @status = 0 EXEC sp_OADestroy @object;

    -- API call 끝난시간 기록
    SET @api_call_end_time = SYSDATETIME();

    -- insert 시작시간 기록
    SET @insert_start_time = SYSDATETIME();

    -- 트리거 로깅
    INSERT INTO ApiResponseLog (
        operation_type,
        name,
        response,
        api_call_start_time,
        api_call_end_time,
        api_call_duration_ms,
        insert_start_time,
        insert_end_time,
        insert_duration_ms
    )
    VALUES (
        @operation_type,
        @name,
        @response,
        @api_call_start_time,
        @api_call_end_time,
        DATEDIFF(MILLISECOND, @api_call_start_time, @api_call_end_time),
        @insert_start_time,
        SYSDATETIME(), -- insert 끝난시간 기록
        DATEDIFF(MILLISECOND, @insert_start_time, SYSDATETIME())
    );
END;
GO
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The snippet is long, but the important part is that the HTTP request is executed inside the trigger’s transaction path.&lt;/p&gt;
&lt;p&gt;After creating the trigger, modify &lt;code&gt;BasicTable&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theokimdev.com/resources/blog/post-65-trigger-database-api/image-03-91cf3825.png&quot; alt=&quot;Calling an API from the Database with a Trigger screenshot 02&quot;&gt;&lt;/p&gt;
&lt;p&gt;Execute the change.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theokimdev.com/resources/blog/post-65-trigger-database-api/image-04-276f0fc5.png&quot; alt=&quot;Calling an API from the Database with a Trigger screenshot 03&quot;&gt;&lt;/p&gt;
&lt;p&gt;The result confirms that the API call ran and that the response was saved.&lt;/p&gt;
&lt;p&gt;The key column is &lt;code&gt;api_call_duration_ms&lt;/code&gt;. It records 222ms, which is ordinary for a server-to-server API call. The problem is that the time is now inside a database transaction. In contrast, &lt;code&gt;insert_duration_ms&lt;/code&gt; is recorded at a sub-millisecond scale.&lt;/p&gt;
&lt;p&gt;A single operation that should remain integral now has meaningful time consumption because the trigger makes an API call.&lt;/p&gt;
&lt;p&gt;Then I checked the database load.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- Insert 20 records
INSERT INTO BasicTable (name) VALUES (&apos;Name1&apos;);
INSERT INTO BasicTable (name) VALUES (&apos;Name2&apos;);
INSERT INTO BasicTable (name) VALUES (&apos;Name3&apos;);
INSERT INTO BasicTable (name) VALUES (&apos;Name4&apos;);
INSERT INTO BasicTable (name) VALUES (&apos;Name5&apos;);
INSERT INTO BasicTable (name) VALUES (&apos;Name6&apos;);
INSERT INTO BasicTable (name) VALUES (&apos;Name7&apos;);
INSERT INTO BasicTable (name) VALUES (&apos;Name8&apos;);
INSERT INTO BasicTable (name) VALUES (&apos;Name9&apos;);
INSERT INTO BasicTable (name) VALUES (&apos;Name10&apos;);
INSERT INTO BasicTable (name) VALUES (&apos;Name11&apos;);
INSERT INTO BasicTable (name) VALUES (&apos;Name12&apos;);
INSERT INTO BasicTable (name) VALUES (&apos;Name13&apos;);
INSERT INTO BasicTable (name) VALUES (&apos;Name14&apos;);
INSERT INTO BasicTable (name) VALUES (&apos;Name15&apos;);
INSERT INTO BasicTable (name) VALUES (&apos;Name16&apos;);
INSERT INTO BasicTable (name) VALUES (&apos;Name17&apos;);
INSERT INTO BasicTable (name) VALUES (&apos;Name18&apos;);
INSERT INTO BasicTable (name) VALUES (&apos;Name19&apos;);
INSERT INTO BasicTable (name) VALUES (&apos;Name20&apos;);

-- Update 15 records
UPDATE BasicTable SET name = &apos;UpdatedName1&apos; WHERE id = 1;
UPDATE BasicTable SET name = &apos;UpdatedName2&apos; WHERE id = 2;
UPDATE BasicTable SET name = &apos;UpdatedName3&apos; WHERE id = 3;
UPDATE BasicTable SET name = &apos;UpdatedName4&apos; WHERE id = 4;
UPDATE BasicTable SET name = &apos;UpdatedName5&apos; WHERE id = 5;
UPDATE BasicTable SET name = &apos;UpdatedName6&apos; WHERE id = 6;
UPDATE BasicTable SET name = &apos;UpdatedName7&apos; WHERE id = 7;
UPDATE BasicTable SET name = &apos;UpdatedName8&apos; WHERE id = 8;
UPDATE BasicTable SET name = &apos;UpdatedName9&apos; WHERE id = 9;
UPDATE BasicTable SET name = &apos;UpdatedName10&apos; WHERE id = 10;
UPDATE BasicTable SET name = &apos;UpdatedName11&apos; WHERE id = 11;
UPDATE BasicTable SET name = &apos;UpdatedName12&apos; WHERE id = 12;
UPDATE BasicTable SET name = &apos;UpdatedName13&apos; WHERE id = 13;
UPDATE BasicTable SET name = &apos;UpdatedName14&apos; WHERE id = 14;
UPDATE BasicTable SET name = &apos;UpdatedName15&apos; WHERE id = 15;

-- Delete 15 records
DELETE FROM BasicTable WHERE id = 1;
DELETE FROM BasicTable WHERE id = 2;
DELETE FROM BasicTable WHERE id = 3;
DELETE FROM BasicTable WHERE id = 4;
DELETE FROM BasicTable WHERE id = 5;
DELETE FROM BasicTable WHERE id = 6;
DELETE FROM BasicTable WHERE id = 7;
DELETE FROM BasicTable WHERE id = 8;
DELETE FROM BasicTable WHERE id = 9;
DELETE FROM BasicTable WHERE id = 10;
DELETE FROM BasicTable WHERE id = 11;
DELETE FROM BasicTable WHERE id = 12;
DELETE FROM BasicTable WHERE id = 13;
DELETE FROM BasicTable WHERE id = 14;
DELETE FROM BasicTable WHERE id = 15;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;First, remove the trigger and run the test without it.&lt;/p&gt;
&lt;p&gt;[2024-06-02 07:23:53] 50 rows affected in 26 ms&lt;/p&gt;
&lt;p&gt;The run completed 20 inserts, 15 updates, and 15 deletes in about 0.02 seconds. Looking more closely:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theokimdev.com/resources/blog/post-65-trigger-database-api/image-05-589ca389.png&quot; alt=&quot;Calling an API from the Database with a Trigger screenshot 04&quot;&gt;&lt;/p&gt;
&lt;p&gt;For insert, the plan shows the cost and actual execution time of the clustered index insert.&lt;/p&gt;
&lt;p&gt;Then enable the trigger and run the benchmark again.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;        INSERT INTO BasicTable (name) VALUES (&apos;Name28&apos;);
        INSERT INTO BasicTable (name) VALUES (&apos;Name29&apos;);
        INSERT INTO BasicTable (name) VALUES (&apos;Name30&apos;);
        INSERT INTO BasicTable (name) VALUES (&apos;Name31&apos;);
        INSERT INTO BasicTable (name) VALUES (&apos;Name32&apos;);
        INSERT INTO BasicTable (name) VALUES (&apos;Name33&apos;);
        INSERT INTO BasicTable (name) VALUES (&apos;Name34&apos;);
        INSERT INTO BasicTable (name) VALUES (&apos;Name35&apos;);
        INSERT INTO BasicTable (name) VALUES (&apos;Name36&apos;);
        INSERT INTO BasicTable (name) VALUES (&apos;Name37&apos;);
        INSERT INTO BasicTable (name) VALUES (&apos;Name38&apos;);
        INSERT INTO BasicTable (name) VALUES (&apos;Name39&apos;);
        INSERT INTO BasicTable (name) VALUES (&apos;Name40&apos;);
        INSERT INTO BasicTable (name) VALUES (&apos;Name41&apos;);
        INSERT INTO BasicTable (name) VALUES (&apos;Name42&apos;);
        INSERT INTO BasicTable (name) VALUES (&apos;Name43&apos;);
        INSERT INTO BasicTable (name) VALUES (&apos;Name44&apos;);
        INSERT INTO BasicTable (name) VALUES (&apos;Name45&apos;);
        INSERT INTO BasicTable (name) VALUES (&apos;Name46&apos;);
        INSERT INTO BasicTable (name) VALUES (&apos;Name47&apos;);
[2024-06-02 07:33:20] 20 rows affected in 4 s 894 ms
master&amp;gt; -- Update 15 records
        UPDATE BasicTable SET name = &apos;UpdatedName28&apos; WHERE id = 28;
        UPDATE BasicTable SET name = &apos;UpdatedName29&apos; WHERE id = 29;
        UPDATE BasicTable SET name = &apos;UpdatedName30&apos; WHERE id = 30;
        UPDATE BasicTable SET name = &apos;UpdatedName31&apos; WHERE id = 31;
        UPDATE BasicTable SET name = &apos;UpdatedName32&apos; WHERE id = 32;
        UPDATE BasicTable SET name = &apos;UpdatedName33&apos; WHERE id = 33;
        UPDATE BasicTable SET name = &apos;UpdatedName34&apos; WHERE id = 34;
        UPDATE BasicTable SET name = &apos;UpdatedName35&apos; WHERE id = 35;
        UPDATE BasicTable SET name = &apos;UpdatedName36&apos; WHERE id = 36;
        UPDATE BasicTable SET name = &apos;UpdatedName37&apos; WHERE id = 37;
        UPDATE BasicTable SET name = &apos;UpdatedName38&apos; WHERE id = 38;
        UPDATE BasicTable SET name = &apos;UpdatedName39&apos; WHERE id = 39;
        UPDATE BasicTable SET name = &apos;UpdatedName40&apos; WHERE id = 40;
        UPDATE BasicTable SET name = &apos;UpdatedName41&apos; WHERE id = 41;
        UPDATE BasicTable SET name = &apos;UpdatedName42&apos; WHERE id = 42;
[2024-06-02 07:33:23] 15 rows affected in 3 s 642 ms
master&amp;gt; -- Delete 15 records
        DELETE FROM BasicTable WHERE id = 28;
        DELETE FROM BasicTable WHERE id = 29;
        DELETE FROM BasicTable WHERE id = 30;
        DELETE FROM BasicTable WHERE id = 31;
        DELETE FROM BasicTable WHERE id = 32;
        DELETE FROM BasicTable WHERE id = 33;
        DELETE FROM BasicTable WHERE id = 34;
        DELETE FROM BasicTable WHERE id = 35;
        DELETE FROM BasicTable WHERE id = 36;
        DELETE FROM BasicTable WHERE id = 37;
        DELETE FROM BasicTable WHERE id = 38;
        DELETE FROM BasicTable WHERE id = 39;
        DELETE FROM BasicTable WHERE id = 40;
        DELETE FROM BasicTable WHERE id = 41;
        DELETE FROM BasicTable WHERE id = 42;
[2024-06-02 07:33:26] 15 rows affected in 3 s 91 ms
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The trigger-backed run took 4.892s for 20 INSERTs, 3.642s for 15 UPDATEs, and 3.091s for 15 DELETEs, for a total of around 11 seconds. That result is exactly what should be expected once API calls are tied to trigger execution.&lt;/p&gt;
&lt;p&gt;Then were the API calls and inserts into the ApiResponseLog table performed correctly?&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theokimdev.com/resources/blog/post-65-trigger-database-api/image-06-8e661708.png&quot; alt=&quot;Calling an API from the Database with a Trigger screenshot 05&quot;&gt;&lt;/p&gt;
&lt;p&gt;Unfortunately, no.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theokimdev.com/resources/blog/post-65-trigger-database-api/image-07-cffa3894.png&quot; alt=&quot;Calling an API from the Database with a Trigger screenshot 06&quot;&gt;&lt;/p&gt;
&lt;p&gt;After going into SSMS, I observed that the SQL trigger was consuming CPU time. As this accumulates, concurrency seems to spike, and some objects apparently fail to be delivered properly.&lt;/p&gt;
&lt;p&gt;Not only that, but if the server does not deliver the API response in a normal amount of time, the transaction can also blow up after reaching the Trigger execution limit. This can create bottlenecks or locks, and even if the transaction passes through with a silent throw, the desired API response was never received, so the logic still failed to achieve its goal.&lt;/p&gt;
&lt;p&gt;In conclusion, do not ruin the operation that should be the lightest by attaching API calls to triggers in a database where you have worked so hard to reduce latency. Even if the API call is light enough to ignore, the server making the API call and the server running the database are different, so a dependency is inevitable.&lt;/p&gt;
&lt;p&gt;API calls inside triggers also damage the atomicity of the transaction. External calls cannot become part of database transaction management.&lt;/p&gt;
&lt;p&gt;Also, if an error occurs, debugging hell will open up. You might not be able to debug it at all.&lt;/p&gt;
&lt;p&gt;Unless it is really, really, really necessary for the business logic, do not use it. Just do not use it.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theokimdev.com/resources/blog/post-65-trigger-database-api/cover-de31447c.png&quot; alt=&quot;blob&quot;&gt;&lt;/p&gt;
&lt;h2 id=&quot;design-conclusion&quot;&gt;Design Conclusion&lt;/h2&gt;
&lt;p&gt;Trigger-based API calls can look like a fast workaround. But once ownership moves into the database, observability, retry behavior, rollback semantics, and testability all become harder. It can be used, but only when there is a clear reason not to use application events or an outbox-style design instead.&lt;/p&gt;</content:encoded><language>en-US</language><category>Database</category><category>API</category><category>MSSQL</category><category>Trigger</category><author>ystc1247@gmail.com</author></item><item><title>Understanding Salting and Hashing as a Security Boundary</title><link>https://theokimdev.com/en/blog/post-64-salting-hashing/</link><guid isPermaLink="true">https://theokimdev.com/en/blog/post-64-salting-hashing/</guid><description>A security-focused explanation of hashing, salting, password storage, rainbow-table resistance, and verification flow.</description><pubDate>Fri, 03 May 2024 15:25:19 GMT</pubDate><content:encoded>&lt;h2 id=&quot;where-the-problem-started&quot;&gt;Where the Problem Started&lt;/h2&gt;
&lt;p&gt;Password storage is not simply encrypting a string. Even if two users choose the same password, the stored values should differ, and a database leak should not make the original password recoverable. This post organizes hashing and salting as a security boundary rather than a vocabulary exercise.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;When building a user information system, the most important element is protecting user passwords.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The technique used here is called &lt;strong&gt;salting&lt;/strong&gt;, and its etymology is supposedly &lt;a href=&quot;https://stackoverflow.com/questions/244903/why-is-a-password-salt-called-a-salt&quot;&gt;https://stackoverflow.com/questions/244903/why-is-a-password-salt-called-a-salt&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;There is no especially satisfying answer, but the origins of common placeholder terms like &lt;code&gt;foo&lt;/code&gt; and &lt;code&gt;bar&lt;/code&gt; are often more arbitrary than they look.&lt;/p&gt;
&lt;p&gt;Start with hashing. In short, hashing is a one-way process that transforms a string into another string. One-way means the hashed value cannot be restored to the original input.&lt;/p&gt;
&lt;p&gt;By contrast, in two-way encryption, there is a method shared by both encryption and decryption, such as a key, that can infer the original string and the changed string.&lt;/p&gt;
&lt;p&gt;Various algorithms are used for hashing. There are &lt;strong&gt;Phpass, libsodium, sha256, sha512&lt;/strong&gt;, and so on, but look up which algorithms are safe and officially recognized.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;MD5, SHA1, SHA2, and similar algorithms are based on the Merkle-Damgard construction and can be exposed to length extension attacks, so they should not be used for password storage.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The password authentication flow is easier to reason about step by step.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The user creates an account.&lt;/li&gt;
&lt;li&gt;The password is stored in the DB after hashing.&lt;/li&gt;
&lt;li&gt;When the user tries to log in, the password entered by the user is encrypted using the hashing algorithm and compared with the password in the DB.&lt;/li&gt;
&lt;li&gt;If the hashes match, authentication passes.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;In most login systems, when step 4 fails, the service does not reveal whether the ID or password was wrong. That is not just a frustrating UX choice; it is a small security measure that makes username-aware brute force attacks harder.&lt;/p&gt;
&lt;p&gt;Is security that has gone through hashing safe? Of course not.&lt;/p&gt;
&lt;p&gt;If the hashing algorithm used by a service is identified, then after the user database is breached, inferring the password is not necessarily difficult.&lt;/p&gt;
&lt;p&gt;Even if the hashing algorithm is not known at first, an attacker can still brute force likely algorithms and candidate passwords. With enough preparation, they may also use lookup tables or rainbow tables.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://en.wikipedia.org/wiki/Rainbow_table#:~:text=A%20rainbow%20table%20is%20a,form%2C%20but%20as%20hash%20values.&quot;&gt;https://en.wikipedia.org/wiki/Rainbow_table#:~:text=A%20rainbow%20table%20is%20a,form%2C%20but%20as%20hash%20values.&lt;/a&gt;&lt;/p&gt;
&lt;h2 id=&quot;how-i-verified-it&quot;&gt;How I Verified It&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://theokimdev.com/resources/blog/post-64-salting-hashing/image-02-802c0879.png&quot; alt=&quot;Understanding and Caveats Around Salting and Hashing screenshot 01&quot;&gt;&lt;/p&gt;
&lt;p&gt;In short, it is a key-value style table that maps every string hashed by a hashing algorithm back to its original string, so naturally the table size is huge.&lt;/p&gt;
&lt;p&gt;This is because complex hashing algorithms do not replace each character with some other string. They perform a complex form of encryption intertwined across the characters, so the encryption of “abcdef” and “abcdee” is completely different.&lt;/p&gt;
&lt;p&gt;That is where salt appears.&lt;/p&gt;
&lt;p&gt;The operating principle of lookup tables and rainbow tables is that one string is replaced with exactly one other string.&lt;/p&gt;
&lt;p&gt;For example, suppose there is a password called “password.”&lt;/p&gt;
&lt;p&gt;When one service encrypts “password,” no matter how complex the hashing algorithm is, every encrypted “password” corresponds to “password.” That is why a special string is appended to “password” before hashing, and that string is called salt.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://theokimdev.com/resources/blog/post-64-salting-hashing/cover-fe0e8e82.png&quot; alt=&quot;Understanding and Caveats Around Salting and Hashing screenshot 02&quot;&gt;&lt;/p&gt;
&lt;p&gt;There are several caveats when using salt.&lt;/p&gt;
&lt;p&gt;First is reusing salt. The moment you harm salt uniqueness, there is little difference from the security state before adding salt to the string. After the attacker figures out what the salt is, they can simply append the salt to the password and use a reverse lookup table.&lt;/p&gt;
&lt;p&gt;Salt must be different for each user. You could use a language-provided random function to generate each character, but use a safer CSPRNG instead.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://en.wikipedia.org/wiki/Cryptographically_secure_pseudorandom_number_generator&quot;&gt;https://en.wikipedia.org/wiki/Cryptographically_secure_pseudorandom_number_generator&lt;/a&gt; I do not really know the principle, but it is a cryptographically safer random function. CSPRNG libraries also exist in most languages. For example, Java’s java.security.SecureRandom and Python’s secrets.&lt;/p&gt;
&lt;p&gt;Next is a small salt string size. For example, if a salt uses only 4 ASCII characters, 95^4 = 81450625 salts can be generated. It may look like a lot, but given the speed and storage capacity of modern computers, it is nowhere near enough.&lt;/p&gt;
&lt;p&gt;Experts say the salt should be at least as large as the hashed string after salt is added. For example, a string hashed with SHA256 has a size of 256bits == 32bytes == 64 characters. If you use SHA512, the minimum salt size is 128 char.&lt;/p&gt;
&lt;p&gt;Then hash the salted string.&lt;/p&gt;
&lt;p&gt;There are fast and safe algorithms, or algorithms once considered safe, such as SHA256, SHA512, and WHIRLPOOL. There are also algorithms such as PBKDF2, bcrypt, and scrypt, which are a bit slower but aligned with modern security.&lt;/p&gt;
&lt;p&gt;It may be overengineering, so choose appropriately based on the scale and capability of the service.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://cs.opensource.google/go/x/crypto/+/refs/tags/v0.22.0:scrypt/scrypt.go&quot;&gt;https://cs.opensource.google/go/x/crypto/+/refs/tags/v0.22.0:scrypt/scrypt.go&lt;/a&gt; This is the Go library source code for scrypt, and seeing how extremely complex it is, it certainly looks safe.&lt;/p&gt;
&lt;p&gt;The following is a short Python function from creating salt to password hashing. As expected, short functions? Python.&lt;/p&gt;
&lt;h2 id=&quot;implementation-path&quot;&gt;Implementation Path&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;class EncryptPassword:

    @staticmethod

    def salt_password(password):

        salt = os.urandom(64) # salt 생성

        salt_hex = salt.hex() # 저장을 위한 salt &amp;gt; hex 로 변환

		# PBKDF2 (SHA-512 의 100,000 반복) 으로 해싱
        dk = hashlib.pbkdf2_hmac(&apos;sha512&apos;, password.encode(), salt, 100000, dklen=64)

        hashed_password_hex = dk.hex() # 저장을 위한 비밀번호 &amp;gt; hex 로 변환

        return hashed_password_hex, salt_hex
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;security-takeaway&quot;&gt;Security Takeaway&lt;/h2&gt;
&lt;p&gt;Knowing the words hash and salt is different from designing safe password storage. Algorithm choice, salt generation, work factor, verification flow, and migration strategy all matter. Security features should be judged not just by whether they work, but by what information an attacker can still obtain.&lt;/p&gt;</content:encoded><language>en-US</language><category>Security</category><category>Hash</category><category>hashing</category><category>salt</category><category>Salting</category><category>해싱</category><author>ystc1247@gmail.com</author></item><item><title>Propagating TraceId Context Across Async Work</title><link>https://theokimdev.com/en/blog/post-61-async-thread-traceid/</link><guid isPermaLink="true">https://theokimdev.com/en/blog/post-61-async-thread-traceid/</guid><description>A debugging note on why traceId disappears across Spring Boot async work, and how TaskDecorator/context propagation restores traceable logs.</description><pubDate>Sat, 09 Mar 2024 11:07:44 GMT</pubDate><content:encoded>&lt;h2 id=&quot;where-the-problem-started&quot;&gt;Where the Problem Started&lt;/h2&gt;
&lt;p&gt;The frustrating part of tracing is not having too few logs; it is having many logs that cannot be tied back to one request. Async work changes threads, and context can disappear at that boundary. When traceId is lost, incident analysis becomes much harder. This post records how I restored traceId propagation in a Spring Boot async flow.&lt;/p&gt;
&lt;p&gt;In a Spring Boot service, when server logic fails, traceId is often the fastest way to find the related logs. For example, you can search for &lt;code&gt;&quot;${traceId}&quot;&lt;/code&gt; in an AWS CloudWatch log group.&lt;/p&gt;
&lt;p&gt;That makes consistent traceId logging important. In single-threaded synchronous work, it is usually straightforward.&lt;/p&gt;
&lt;p&gt;But when you want asynchronous work, the logic may continue on another thread while it runs. In those cases, the traceId from the original thread (parent thread) can fail to propagate to the worker thread (child thread). This needs to propagate so that, if a problem occurs in the worker thread, you can follow the traceId and investigate the error efficiently.&lt;/p&gt;
&lt;p&gt;In Spring Boot 2.x, Spring Sleuth handled traceId propagation smoothly. After moving to Spring Boot 3.x, Sleuth was deprecated and the tracing path moved toward Micrometer. Micrometer is commonly used for metrics and tracing integration, but the async traceId propagation I relied on before no longer happened automatically. This became one of several migration issues that appeared while moving existing project logic to Spring Boot 3.x.&lt;/p&gt;
&lt;h2 id=&quot;implementation-path&quot;&gt;Implementation Path&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;implementation(&quot;io.micrometer&quot;, &quot;micrometer-observation&quot;)
implementation(&quot;io.micrometer&quot;, &quot;micrometer-tracing&quot;)
implementation(&quot;io.micrometer&quot;, &quot;micrometer-tracing-bridge-brave&quot;)
implementation(&quot;io.micrometer&quot;, &quot;context-propagation&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Add the tracing and context propagation dependencies. The context propagation project is available here: &lt;a href=&quot;https://github.com/micrometer-metrics/context-propagation&quot;&gt;https://github.com/micrometer-metrics/context-propagation&lt;/a&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;@Configuration
class AsynchronousConfiguration: AsyncConfigurer {
    @Bean
    fun threadPoolTaskExecutor(): ThreadPoolTaskExecutor = ThreadPoolTaskExecutor().apply {
        corePoolSize = CORE_POOL_SIZE
        maxPoolSize = MAX_POOL_SIZE
        queueCapacity = QUEUE_CAPACITY
        setThreadNamePrefix(&quot;async-&quot;)
    }

    @Bean(ASYNC_TASK_EXECUTOR_BEAN_NAME)
    fun asyncTaskExecutor(delegate: ThreadPoolTaskExecutor): DelegatingSecurityContextAsyncTaskExecutor {
        return DelegatingSecurityContextAsyncTaskExecutor(delegate)
    }

    companion object {
        const val ASYNC_TASK_EXECUTOR_BEAN_NAME = &quot;asyncTaskExecutor&quot;
        const val CORE_POOL_SIZE = 10
        const val MAX_POOL_SIZE = 50
        const val QUEUE_CAPACITY = 50
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Assume a standard asynchronous configuration class. Declaring &lt;code&gt;threadPoolTaskExecutor&lt;/code&gt; as a bean lets us configure thread pool settings such as core size, max size, and queue capacity.&lt;/p&gt;
&lt;p&gt;After that, asynchronous methods can refer to this executor by name.&lt;/p&gt;
&lt;p&gt;I used a small service method to observe how the async thread behaves.&lt;/p&gt;
&lt;p&gt;One async method calls another, creating a parent/child-style async flow that makes trace propagation easier to inspect.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;@Async(AsynchronousConfiguration.ASYNC_TASK_EXECUTOR_BEAN_NAME)
    fun startParentTask(traceId: String) {

        val parentThreadName = Thread.currentThread().name
        log.info(&quot;Parent task started with traceId: $traceId on thread: $parentThreadName&quot;)

        childService.startChildTask(traceId)

        Thread.sleep(1000)

        log.info(&quot;Parent task completed with traceId: $traceId on thread: $parentThreadName&quot;)
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;First is the parent method. For thread tracing in the logs, it receives a randomly generated traceId as an argument along with the current thread name, then logs with slf4j.&lt;/p&gt;
&lt;p&gt;I add a sleep to the Thread to artificially force the logic to take some time while it runs.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;@Async(AsynchronousConfiguration.ASYNC_TASK_EXECUTOR_BEAN_NAME)
    fun startChildTask(traceId: String) {
        val childThreadName = Thread.currentThread().name
        val traceId = Thread.currentThread().stackTrace
        log.info(&quot;Child task started with traceId: $traceId on thread: $childThreadName&quot;)

        Thread.sleep(1000)

        log.info(&quot;Child task completed with traceId: $traceId on thread: $childThreadName&quot;)
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The child method follows the same shape.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;@GetMapping(&quot;/test-trace&quot;)
    fun testTraceId(): String {
        val traceId = java.util.UUID.randomUUID().toString()
        log.info(&quot;Starting asynchronous job with traceId: $traceId&quot;)
        parentService.startParentTask(traceId)
        return &quot;Asynchronous job started with traceId: $traceId&quot;
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This controller is only for testing. It creates a random traceId, logs the request entry point, and starts the async flow so multiple requests can be sent in quick succession.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;logging:
  pattern:
    dateformat: &quot;yyyy-MM-dd HH:mm:ss.SSS&quot;
    level: &quot;%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Configure the log pattern so &lt;code&gt;traceId&lt;/code&gt; and &lt;code&gt;spanId&lt;/code&gt; are visible in every line.&lt;/p&gt;
&lt;p&gt;Create a propagation bean through &lt;code&gt;TaskDecorator&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt; @Bean
    fun taskDecorator(): TaskDecorator {
        return TaskDecorator { runnable: Runnable? -&amp;gt;
            ContextSnapshot.captureAll(*arrayOfNulls(0)).wrap(
                runnable!!,
            )
        }
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;a href=&quot;https://blog.gangnamunni.com/post/mdc-context-task-decorator/&quot;&gt;https://blog.gangnamunni.com/post/mdc-context-task-decorator/&lt;/a&gt; This is a somewhat old post from Gangnam Unni’s tech blog, but it solves an issue in a similar situation.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/core/task/TaskDecorator.html&quot;&gt;https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/core/task/TaskDecorator.html&lt;/a&gt; This is the official &lt;code&gt;TaskDecorator&lt;/code&gt; documentation. As the name suggests, it wraps a task before execution, which makes it a useful hook for monitoring, metrics, and context propagation.&lt;/p&gt;
&lt;p&gt;After creating the decorator, attach it to the &lt;code&gt;threadPoolTaskExecutor&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;@Bean
    fun threadPoolTaskExecutor(): ThreadPoolTaskExecutor = ThreadPoolTaskExecutor().apply {
        corePoolSize = CORE_POOL_SIZE
        maxPoolSize = MAX_POOL_SIZE
        queueCapacity = QUEUE_CAPACITY
        setThreadNamePrefix(&quot;async-&quot;)
        setTaskDecorator(taskDecorator()) &amp;lt;&amp;lt; 이거 추가
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Send several requests and check whether the logs keep the same trace context across async execution.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;2024-03-09 19:51:03.182  INFO [,65ec3f17e6d38686445bf9e99014b824,445bf9e99014b824] 25486 --- [nio-8080-exec-1] kr.projects.trace.TraceController        : Starting asynchronous job with traceId: 3f0f0f1e-c0b3-4cef-99e3-7cbfd809e164
2024-03-09 19:51:03.183  INFO [,65ec3f17e6d38686445bf9e99014b824,445bf9e99014b824] 25486 --- [nio-8080-exec-1] kr.projects.trace.ParentService          : Parent task started with traceId: 3f0f0f1e-c0b3-4cef-99e3-7cbfd809e164 on thread: http-nio-8080-exec-1
2024-03-09 19:51:03.183  INFO [,65ec3f17e6d38686445bf9e99014b824,445bf9e99014b824] 25486 --- [nio-8080-exec-1] kr.projects.trace.ChildService           : Child task started with traceId: [Ljava.lang.StackTraceElement;@6223d89e on thread: http-nio-8080-exec-1
2024-03-09 19:51:04.189  INFO [,65ec3f17e6d38686445bf9e99014b824,445bf9e99014b824] 25486 --- [nio-8080-exec-1] kr.projects.trace.ChildService           : Child task completed with traceId: [Ljava.lang.StackTraceElement;@6223d89e on thread: http-nio-8080-exec-1
2024-03-09 19:51:05.191  INFO [,65ec3f17e6d38686445bf9e99014b824,445bf9e99014b824] 25486 --- [nio-8080-exec-1] kr.projects.trace.ParentService          : Parent task completed with traceId: 3f0f0f1e-c0b3-4cef-99e3-7cbfd809e164 on thread: http-nio-8080-exec-1
2024-03-09 19:51:44.421  INFO [,65ec3f40987e7c543582282a039a134d,3582282a039a134d] 25486 --- [nio-8080-exec-2] kr.projects.trace.TraceController        : Starting asynchronous job with traceId: dfebee98-f8e6-450e-be36-bd9703a46923
2024-03-09 19:51:44.434  INFO [,65ec3f40987e7c543582282a039a134d,3582282a039a134d] 25486 --- [nio-8080-exec-2] kr.projects.trace.ParentService          : Parent task started with traceId: dfebee98-f8e6-450e-be36-bd9703a46923 on thread: http-nio-8080-exec-2
2024-03-09 19:51:44.436  INFO [,65ec3f40987e7c543582282a039a134d,3582282a039a134d] 25486 --- [nio-8080-exec-2] kr.projects.trace.ChildService           : Child task started with traceId: [Ljava.lang.StackTraceElement;@7cf9c5a5 on thread: http-nio-8080-exec-2
2024-03-09 19:51:44.620  INFO [,65ec3f405ae8bf117fd3ec3d607efd2d,7fd3ec3d607efd2d] 25486 --- [nio-8080-exec-3] kr.projects.trace.TraceController        : Starting asynchronous job with traceId: 3e7b5aaf-0ee9-477b-9db9-0a8d98746945
2024-03-09 19:51:44.637  INFO [,65ec3f405ae8bf117fd3ec3d607efd2d,7fd3ec3d607efd2d] 25486 --- [nio-8080-exec-3] kr.projects.trace.ParentService          : Parent task started with traceId: 3e7b5aaf-0ee9-477b-9db9-0a8d98746945 on thread: http-nio-8080-exec-3
2024-03-09 19:51:44.637  INFO [,65ec3f405ae8bf117fd3ec3d607efd2d,7fd3ec3d607efd2d] 25486 --- [nio-8080-exec-3] kr.projects.trace.ChildService           : Child task started with traceId: [Ljava.lang.StackTraceElement;@7ebc7ff2 on thread: http-nio-8080-exec-3
2024-03-09 19:51:44.824  INFO [,65ec3f4064b2ba57b991da1e9a58f904,b991da1e9a58f904] 25486 --- [nio-8080-exec-4] kr.projects.trace.TraceController        : Starting asynchronous job with traceId: db6dee41-5500-4f49-a902-d4ed52dfdf1a
2024-03-09 19:51:44.825  INFO [,65ec3f4064b2ba57b991da1e9a58f904,b991da1e9a58f904] 25486 --- [nio-8080-exec-4] kr.projects.trace.ParentService          : Parent task started with traceId: db6dee41-5500-4f49-a902-d4ed52dfdf1a on thread: http-nio-8080-exec-4
2024-03-09 19:51:44.825  INFO [,65ec3f4064b2ba57b991da1e9a58f904,b991da1e9a58f904] 25486 --- [nio-8080-exec-4] kr.projects.trace.ChildService           : Child task started with traceId: [Ljava.lang.StackTraceElement;@2c146429 on thread: http-nio-8080-exec-4
2024-03-09 19:51:45.023  INFO [,65ec3f4105c42d6e1380816172851f52,1380816172851f52] 25486 --- [nio-8080-exec-5] kr.projects.trace.TraceController        : Starting asynchronous job with traceId: 08d2f0c0-b588-4a84-a65c-8d6c1a06993f
2024-03-09 19:51:45.024  INFO [,65ec3f4105c42d6e1380816172851f52,1380816172851f52] 25486 --- [nio-8080-exec-5] kr.projects.trace.ParentService          : Parent task started with traceId: 08d2f0c0-b588-4a84-a65c-8d6c1a06993f on thread: http-nio-8080-exec-5
2024-03-09 19:51:45.024  INFO [,65ec3f4105c42d6e1380816172851f52,1380816172851f52] 25486 --- [nio-8080-exec-5] kr.projects.trace.ChildService           : Child task started with traceId: [Ljava.lang.StackTraceElement;@27562cf1 on thread: http-nio-8080-exec-5
2024-03-09 19:51:45.220  INFO [,65ec3f41b88d12392f0aed8c54e60a0a,2f0aed8c54e60a0a] 25486 --- [nio-8080-exec-6] kr.projects.trace.TraceController        : Starting asynchronous job with traceId: 26adfe5e-2730-4a31-97eb-49c0eacb9a28
2024-03-09 19:51:45.220  INFO [,65ec3f41b88d12392f0aed8c54e60a0a,2f0aed8c54e60a0a] 25486 --- [nio-8080-exec-6] kr.projects.trace.ParentService          : Parent task started with traceId: 26adfe5e-2730-4a31-97eb-49c0eacb9a28 on thread: http-nio-8080-exec-6
2024-03-09 19:51:45.220  INFO [,65ec3f41b88d12392f0aed8c54e60a0a,2f0aed8c54e60a0a] 25486 --- [nio-8080-exec-6] kr.projects.trace.ChildService           : Child task started with traceId: [Ljava.lang.StackTraceElement;@6b7c68eb on thread: http-nio-8080-exec-6
2024-03-09 19:51:45.438  INFO [,65ec3f40987e7c543582282a039a134d,3582282a039a134d] 25486 --- [nio-8080-exec-2] kr.projects.trace.ChildService           : Child task completed with traceId: [Ljava.lang.StackTraceElement;@7cf9c5a5 on thread: http-nio-8080-exec-2
2024-03-09 19:51:45.638  INFO [,65ec3f405ae8bf117fd3ec3d607efd2d,7fd3ec3d607efd2d] 25486 --- [nio-8080-exec-3] kr.projects.trace.ChildService           : Child task completed with traceId: [Ljava.lang.StackTraceElement;@7ebc7ff2 on thread: http-nio-8080-exec-3
2024-03-09 19:51:45.829  INFO [,65ec3f4064b2ba57b991da1e9a58f904,b991da1e9a58f904] 25486 --- [nio-8080-exec-4] kr.projects.trace.ChildService           : Child task completed with traceId: [Ljava.lang.StackTraceElement;@2c146429 on thread: http-nio-8080-exec-4
2024-03-09 19:51:46.026  INFO [,65ec3f4105c42d6e1380816172851f52,1380816172851f52] 25486 --- [nio-8080-exec-5] kr.projects.trace.ChildService           : Child task completed with traceId: [Ljava.lang.StackTraceElement;@27562cf1 on thread: http-nio-8080-exec-5
2024-03-09 19:51:46.224  INFO [,65ec3f41b88d12392f0aed8c54e60a0a,2f0aed8c54e60a0a] 25486 --- [nio-8080-exec-6] kr.projects.trace.ChildService           : Child task completed with traceId: [Ljava.lang.StackTraceElement;@6b7c68eb on thread: http-nio-8080-exec-6
2024-03-09 19:51:46.442  INFO [,65ec3f40987e7c543582282a039a134d,3582282a039a134d] 25486 --- [nio-8080-exec-2] kr.projects.trace.ParentService          : Parent task completed with traceId: dfebee98-f8e6-450e-be36-bd9703a46923 on thread: http-nio-8080-exec-2
2024-03-09 19:51:46.639  INFO [,65ec3f405ae8bf117fd3ec3d607efd2d,7fd3ec3d607efd2d] 25486 --- [nio-8080-exec-3] kr.projects.trace.ParentService          : Parent task completed with traceId: 3e7b5aaf-0ee9-477b-9db9-0a8d98746945 on thread: http-nio-8080-exec-3
2024-03-09 19:51:46.830  INFO [,65ec3f4064b2ba57b991da1e9a58f904,b991da1e9a58f904] 25486 --- [nio-8080-exec-4] kr.projects.trace.ParentService          : Parent task completed with traceId: db6dee41-5500-4f49-a902-d4ed52dfdf1a on thread: http-nio-8080-exec-4
2024-03-09 19:51:47.032  INFO [,65ec3f4105c42d6e1380816172851f52,1380816172851f52] 25486 --- [nio-8080-exec-5] kr.projects.trace.ParentService          : Parent task completed with traceId: 08d2f0c0-b588-4a84-a65c-8d6c1a06993f on thread: http-nio-8080-exec-5
2024-03-09 19:51:47.226  INFO [,65ec3f41b88d12392f0aed8c54e60a0a,2f0aed8c54e60a0a] 25486 --- [nio-8080-exec-6] kr.projects.trace.ParentService          : Parent task completed with traceId: 26adfe5e-2730-4a31-97eb-49c0eacb9a28 on thread: http-nio-8080-exec-6
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;traceability-takeaway&quot;&gt;Traceability Takeaway&lt;/h2&gt;
&lt;p&gt;TraceId propagation is not about making logs look neat. It is about preserving the ability to reconstruct a request in production. Whenever a thread boundary appears, context propagation has to be handled explicitly, and migrations must check whether automatic behavior from older tracing libraries has disappeared.&lt;/p&gt;
&lt;p&gt;In each line, the second and third values inside the brackets after &lt;code&gt;INFO&lt;/code&gt; are the &lt;code&gt;traceId&lt;/code&gt; and &lt;code&gt;spanId&lt;/code&gt;. Even when execution crosses an async boundary, the original trace context is preserved.&lt;/p&gt;</content:encoded><language>en-US</language><category>Spring Boot</category><category>micrometer</category><category>propogation</category><category>traceId</category><author>ystc1247@gmail.com</author></item><item><title>Database Benchmarking Criteria and Using HammerDB</title><link>https://theokimdev.com/en/blog/post-53-database-hammerdb/</link><guid isPermaLink="true">https://theokimdev.com/en/blog/post-53-database-hammerdb/</guid><description>A practical note on designing database benchmarks with OLTP/OLAP context, schema shape, client count, read/write ratio, and HammerDB execution.</description><pubDate>Tue, 24 Oct 2023 17:56:27 GMT</pubDate><content:encoded>&lt;h2 id=&quot;where-the-problem-started&quot;&gt;Where the Problem Started&lt;/h2&gt;
&lt;p&gt;Database benchmarks are easy to misread when they become only a comparison of numbers. A high TPS value matters less than the schema and workload that produced it. This post records how I used HammerDB while thinking through which benchmark conditions make the result meaningful.&lt;/p&gt;
&lt;p&gt;Choosing a database system has long-term consequences.&lt;/p&gt;
&lt;p&gt;If you need to replace the database during operation after making the choice, migration is not easy, so you need to anticipate and detect problems early in development.&lt;/p&gt;
&lt;p&gt;Things to think about during the selection process include the following.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;Schema and record size&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Number of clients&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Query format and access patterns&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Ratio of read and write queries&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Range of variation in the variables above&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;After that, there is a process of building a test cluster and simulating workloads, which can be done with tools such as YCSB(serving benchmark) and hammerDB.&lt;/p&gt;
&lt;p&gt;The following is the hammerDB configuration.&lt;/p&gt;
&lt;p&gt;Create a hammerDB Dockerfile in the directory you want.&lt;/p&gt;
&lt;h2 id=&quot;implementation-path&quot;&gt;Implementation Path&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cd &amp;lt;directory&amp;gt;
touch Dockerfile
vi Dockerfile
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-dockerfile&quot;&gt;FROM  docker.io/tpcorg/hammerdb:oracle as oracle

FROM  docker.io/tpcorg/hammerdb:mssqls

# Install and configure IBM Db2 client libraries,
# You will need to pre-download IDB Db2 client libraries and place in the local folder
# RUN mkdir -p db2_cli_odbc_driver/odbc_cli
# ADD odbc_cli db2_cli_odbc_driver/odbc_cli/
# RUN apt update &amp;amp;&amp;amp; \
#    echo &apos;debconf debconf/frontend select Noninteractive&apos; | debconf-set-selections &amp;amp;&amp;amp; \
#    apt -y install libxml2 &amp;amp;&amp;amp; \
#    echo &apos;export DB2_CLI_DRIVER_INSTALL_PATH=&quot;/home/db2_cli_odbc_driver/odbc_cli/clidriver&quot;&apos; &amp;gt;&amp;gt; ~/.bashrc &amp;amp;&amp;amp; \
#    echo &apos;export LD_LIBRARY_PATH=&quot;$LD_LIBRARY_PATH:/home/db2_cli_odbc_driver/odbc_cli/clidriver/lib&quot;&apos; &amp;gt;&amp;gt; ~/.bashrc &amp;amp;&amp;amp; \
#    echo &apos;export LIBPATH=&quot;/home/db2_cli_odbc_driver/odbc_cli/clidriver/lib&quot;&apos; &amp;gt;&amp;gt; ~/.bashrc &amp;amp;&amp;amp; \
#    echo &apos;export PATH=&quot;$PATH:/home/db2_cli_odbc_driver/odbc_cli/clidriver/bin&quot;&apos; &amp;gt;&amp;gt; ~/.bashrc &amp;amp;&amp;amp; \
#    echo &apos;export PATH=&quot;$PATH:/home/db2_cli_odbc_driver/odbc_cli/clidriver/adm&quot;&apos; &amp;gt;&amp;gt;  ~/.bashrc

COPY --from=oracle /home/instantclient_21_5 /home/instantclient_21_5
ENV ORACLE_LIBRARY=/home/instantclient_21_5/libclntsh.so
RUN echo &apos;export LD_LIBRARY_PATH=/home/instantclient_21_5/:$LD_LIBRARY_PATH&apos;  &amp;gt;&amp;gt; ~/.bashrc

RUN apt-get update &amp;amp;&amp;amp; \
    DEBIAN_FRONTEND=noninteractive apt-get install -y libmariadb3 libpq-dev libmysqlclient21 &amp;amp;&amp;amp; \
    rm -rf /var/lib/apt/lists/*
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Build the image.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;docker build -t hammerdb .
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you want to create a container,&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;docker run -it --name &amp;lt;container name&amp;gt; hammerdb bash
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Database benchmarking starts from the HammerDB CLI. First, locate the CLI path.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;docker exec -it hammerdb /bin/bash
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Go in and find the path, then run&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;docker exec -it &amp;lt;container name&amp;gt; &amp;lt;path name&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and you will enter the cli.&lt;/p&gt;
&lt;p&gt;Choose the database type for the benchmark.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ora mssqls db2 mysql pg maria
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The options are oracle, MsSQL, IBM Db2, MySQL, PostgreSQL, and MariaDB.&lt;/p&gt;
&lt;p&gt;For this run, I selected MySQL.&lt;/p&gt;
&lt;p&gt;Choose the benchmark type according to the workload you want to model. HammerDB documents the available benchmark families here: &lt;a href=&quot;https://www.hammerdb.com/benchmarks.html&quot;&gt;https://www.hammerdb.com/benchmarks.html&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;For now, I will test with the TCP-C type.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;hammerdb&amp;gt; dbset bm TPC-C
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You need to check the dict(dictionary). This can differ depending on the hammerDB version, database type, and benchmark type, so the settings can vary. For example, in some situations you can set the host and other values with just the set keyword, while in others you may need to use diset to change the dictionary itself.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;hammerdb&amp;gt;print dict
Dictionary Settings for MySQL
connection {
 mysql_host               = 127.0.0.1
 mysql_port               = 3306
 mysql_socket             = /tmp/mysql.sock
 mysql_ssl                = false
 mysql_ssl_two_way        = false
 mysql_ssl_linux_capath   = /etc/mysql/certs
 mysql_ssl_windows_capath = C:\mysql\certs
 mysql_ssl_ca             = ca-cert.pem
 mysql_ssl_cert           = client-cert.pem
 mysql_ssl_key            = client-key.pem
 mysql_ssl_cipher         = server
}
tpcc       {
 mysql_count_ware       = 1
 mysql_num_vu           = 1
 mysql_user             = root
 mysql_pass             = mysql
 mysql_dbase            = tpcc
 mysql_storage_engine   = innodb
 mysql_partition        = false
 mysql_prepared         = false
 mysql_total_iterations = 10000000
 mysql_raiseerror       = false
 mysql_keyandthink      = false
 mysql_driver           = timed
 mysql_rampup           = 2
 mysql_duration         = 5
 mysql_allwarehouse     = false
 mysql_timeprofile      = false
 mysql_async_scale      = false
 mysql_async_client     = 10
 mysql_async_verbose    = false
 mysql_async_delay      = 1000
 mysql_connect_pool     = false
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the current dict structure. If you want to change the host, port, user, pass, and so on,&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;diset connection mysql_port &amp;lt;your port&amp;gt;
diset connection mysql_host &amp;lt;your host&amp;gt;
diset tpcc mysql_user &amp;lt;your user&amp;gt;
diset tpcc mysql_pass &amp;lt;your pass&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;run these commands.&lt;/p&gt;
&lt;p&gt;If you are trying to load an AWS RDS database, you need to open the IP in the Security Group.&lt;/p&gt;
&lt;p&gt;Load the benchmark schema next.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;vudestroy
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You can think of this as a kind of clean step.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;buildschema
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the command for loading the schema. If the settings were completed correctly, the host and port will appear, and there will be some waiting time.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;Vuser 1 created - WAIT IDLE
Vuser 1:RUNNING
Vuser 1:CREATING TPCC SCHEMA
Vuser 1:Ssl_cipher
Vuser 1:CREATING DATABASE tpcc
Vuser 1:CREATING TPCC TABLES
Vuser 1:Loading Item
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Refer to &lt;a href=&quot;https://stackoverflow.com/questions/73624954/how-long-it-takes-to-build-schema-or-my-hammerdb-not-working&quot;&gt;https://stackoverflow.com/questions/73624954/how-long-it-takes-to-build-schema-or-my-hammerdb-not-working&lt;/a&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;Vuser 1:Orders Done
Vuser 1:End:Tue Oct 24 17:43:06 UTC 2023
Vuser 1:CREATING TPCC STORED PROCEDURES
Vuser 1:GATHERING SCHEMA STATISTICS
Vuser 1:TPCC SCHEMA COMPLETE
Vuser 1:FINISHED SUCCESS
ALL VIRTUAL USERS COMPLETE
Schema Build jobid=6537FC1D608703E253535343
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After the load finishes, HammerDB shows the completed schema state.&lt;/p&gt;
&lt;p&gt;From there, run the benchmark and inspect the performance metrics. On RDS, CloudWatch can provide additional database-level monitoring. Before executing the run, configure the virtual user count to match the scenario.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;vurun
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;benchmarking-takeaway&quot;&gt;Benchmarking Takeaway&lt;/h2&gt;
&lt;p&gt;A benchmark never fully reproduces production. Still, when schema, client count, query mix, and read/write ratio are explicit, it becomes a comparison that can be reasoned about. The important part is not merely obtaining a number, but being able to explain the conditions behind it.&lt;/p&gt;</content:encoded><language>en-US</language><category>Database</category><category>hammerDB</category><category>Testing</category><category>Workload</category><category>데이터베이스</category><author>ystc1247@gmail.com</author></item><item><title>SWMaestro 14th Cohort Acceptance Retrospective</title><link>https://theokimdev.com/en/blog/post-6-swmaestro-14/</link><guid isPermaLink="true">https://theokimdev.com/en/blog/post-6-swmaestro-14/</guid><description>A practical retrospective on preparing for the SWMaestro 14th cohort, from coding tests to project interviews, CS fundamentals, and collaboration questions.</description><pubDate>Thu, 13 Apr 2023 17:56:52 GMT</pubDate><content:encoded>&lt;p&gt;SWMaestro, along with BOB, was a talent development program I had heard about from seniors since my freshman year. During the previous cohort’s application period, both my algorithm skills and project quality were vague, so I remember stopping midway through the application and deciding to aim for the 14th cohort instead. This time, I felt I could explain my projects properly and bring my coding-test performance up to a stable minimum.&lt;/p&gt;
&lt;h2 id=&quot;overall-preparation&quot;&gt;Overall Preparation&lt;/h2&gt;
&lt;p&gt;Acceptance retrospectives can easily become only a record of the result. What mattered more in this process was not how many keywords I memorized, but how clearly I could explain the projects I had built. This post captures how I prepared separately for the coding tests and interviews, and what kinds of questions I expected to defend.&lt;/p&gt;
&lt;p&gt;I organized my preparation into three tracks.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;For the coding tests, I focused less on solving every problem and more on securing the problems I could implement without mistakes.&lt;/li&gt;
&lt;li&gt;For the interview, I prepared to explain my projects from structure, technology choices, failure modes, and possible improvements.&lt;/li&gt;
&lt;li&gt;For CS and backend fundamentals, the goal was not keyword memorization. I wanted to be able to keep answering when follow-up questions came in.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Looking back, the most important thing was not flashy knowledge but organized explanation. Especially in the project interview, “I used this technology” mattered less than “why I used it, and where I would look if something broke.”&lt;/p&gt;
&lt;h2 id=&quot;coding-tests&quot;&gt;Coding Tests&lt;/h2&gt;
&lt;p&gt;The tests were held on Programmers for two hours. The format was 4 algorithm problems and 1 SQL problem. The first round was invalidated once because of a server issue, and then it was held again in the same format.&lt;/p&gt;
&lt;h3 id=&quot;what-i-felt-from-round-1&quot;&gt;What I Felt From Round 1&lt;/h3&gt;
&lt;p&gt;Round 1 had a strong implementation and simulation flavor. There were simple implementation, coordinate-plane traversal, combination search, DFS-like search, and a SQL problem with string-processing characteristics. The ideas themselves were not extremely novel. The hard part was implementing them within the time limit without missing conditions.&lt;/p&gt;
&lt;p&gt;These are the types I felt I needed to secure.&lt;/p&gt;

























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Type&lt;/th&gt;&lt;th&gt;Preparation point&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Implementation / simulation&lt;/td&gt;&lt;td&gt;Split conditions into explicit states and write edge cases first&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Brute force / combinations&lt;/td&gt;&lt;td&gt;Quickly judge whether the input size allows brute force&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;DFS / backtracking&lt;/td&gt;&lt;td&gt;Define state and visited conditions clearly&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;SQL&lt;/td&gt;&lt;td&gt;Review string functions in addition to SELECT, JOIN, and GROUP BY&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;For implementation problems in particular, even if you know the direction, mistakes appear once the code gets long. So if I were preparing for SWMaestro again, I would spend more time solving implementation-heavy problems with a timer than digging into new advanced algorithms.&lt;/p&gt;
&lt;h3 id=&quot;what-i-felt-from-round-2&quot;&gt;What I Felt From Round 2&lt;/h3&gt;
&lt;p&gt;Round 2 also had 4 algorithm problems and 1 SQL problem. The set included implementation, DP, queue-based simulation, and a graph problem mixing Dijkstra and DP. Personally, handling edge cases in the DP problem was harder than the graph problem.&lt;/p&gt;
&lt;p&gt;Both rounds felt tight on time. Roughly two solved problems felt like the stable line, and I heard from a mentor that many trainees in Round 2 only solved Problem 1 and SQL clearly. If you have steadily studied the essential types for job coding tests, the test itself is not unfamiliar. But if your implementation stamina is weak, it can shake you more than expected.&lt;/p&gt;
&lt;p&gt;If I prepared again, I would prioritize this order.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Solve implementation and simulation problems regularly, even if only briefly&lt;/li&gt;
&lt;li&gt;For DP, focus on state definition and edge cases more than only the recurrence&lt;/li&gt;
&lt;li&gt;Keep BFS, DFS, and Dijkstra templates comfortable enough to write quickly&lt;/li&gt;
&lt;li&gt;Solve at least one SQL problem each using JOIN, GROUP BY, UNION, and string functions&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;interview-preparation&quot;&gt;Interview Preparation&lt;/h2&gt;
&lt;p&gt;At first, I spread the interview topics too widely, which made the notes messy. Later, I grouped them like this. It was much more useful.&lt;/p&gt;
&lt;h3 id=&quot;1-project-explanation&quot;&gt;1. Project Explanation&lt;/h3&gt;
&lt;p&gt;The biggest preparation area was my own projects. I expected the interviewers to dig deeply into the projects I presented.&lt;/p&gt;
&lt;p&gt;For each project, I prepared answers to these questions.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;What problem was this project trying to solve?&lt;/li&gt;
&lt;li&gt;Which part did I own?&lt;/li&gt;
&lt;li&gt;Why did I choose that technology stack?&lt;/li&gt;
&lt;li&gt;What did the DB schema, API, and deployment structure look like?&lt;/li&gt;
&lt;li&gt;If an incident happened, what would I check first?&lt;/li&gt;
&lt;li&gt;If I built it again now, what would I change?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For example, for a Redis-based project, I prepared the caching target, DB fallback when Redis fails, Base62 short URL generation, MariaDB character set configuration, Docker Compose &lt;code&gt;depends_on&lt;/code&gt;, and the CSV parsing flow as one connected explanation. It was not enough to say “I used Redis.” I needed to explain why Redis was necessary and what would happen if Redis went down.&lt;/p&gt;
&lt;p&gt;For a GPT-based project, I prepared not only implementation details but also business model, cost structure, and user acquisition. The SWMaestro interview did not feel like a pure technical interview. It also checked whether a person could carry a team project to the end.&lt;/p&gt;
&lt;h3 id=&quot;2-backend-fundamentals&quot;&gt;2. Backend Fundamentals&lt;/h3&gt;
&lt;p&gt;For backend fundamentals, I did not try to go infinitely deep. I focused on the range connected to the technologies I had actually used.&lt;/p&gt;





































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Area&lt;/th&gt;&lt;th&gt;What I reviewed&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Spring / Spring Boot&lt;/td&gt;&lt;td&gt;Dependency management, embedded server, initial setup, Spring Security and CORS&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;REST API&lt;/td&gt;&lt;td&gt;URI design, CRUD, JSON response, common frontend/backend integration issues&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Testing&lt;/td&gt;&lt;td&gt;When to use JUnit5, Spock, and mocking&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Database&lt;/td&gt;&lt;td&gt;Normalization, RDB vs NoSQL, MySQL/MariaDB/PostgreSQL/Redis characteristics&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Cache&lt;/td&gt;&lt;td&gt;Redis caching targets, session/API response caching, fallback strategy&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Docker / deployment&lt;/td&gt;&lt;td&gt;Container communication, Docker Compose, EC2 deployment, environment configuration&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Git / collaboration&lt;/td&gt;&lt;td&gt;Branch rules, commit units, pull/review habits&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;I used to try to memorize this kind of material as a list of keywords, but in interviews that becomes obvious quickly. Questions do not always stop at “what is normalization?” They can continue into “why was this schema acceptable for your project?” Connecting fundamentals back to projects was much better.&lt;/p&gt;
&lt;h3 id=&quot;3-collaboration-and-leadership&quot;&gt;3. Collaboration and Leadership&lt;/h3&gt;
&lt;p&gt;Collaboration questions also felt important. The expected questions were roughly like these.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;What would you do if a teammate disagreed with you?&lt;/li&gt;
&lt;li&gt;What would you do if the initial plan could no longer be executed?&lt;/li&gt;
&lt;li&gt;How would you communicate with a teammate using a different technology stack?&lt;/li&gt;
&lt;li&gt;If you were the leader, how would you manage schedule and backlog?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I prepared answers around making the initial plan detailed, while reflecting changed requirements into the backlog and timeline during implementation. When teammates used different stacks, I planned to document contact points such as API contract, data format, CORS, and security, and check likely frontend/backend merge issues early.&lt;/p&gt;
&lt;h2 id=&quot;in-depth-interview&quot;&gt;In-depth Interview&lt;/h2&gt;
&lt;p&gt;I heard that the atmosphere and question direction differed depending on the division and interviewers. Division 1, where I interviewed, had a balanced mix of technical and personality questions. Personality questions were often common questions given to all five candidates.&lt;/p&gt;
&lt;p&gt;The most memorable question came near the end. They asked which candidate in the room I would want to team up with, and who seemed suitable as a leader. It was difficult to answer unless you had listened to the other candidates carefully. Some people answered vaguely, while others chose a specific candidate and gave a reason.&lt;/p&gt;
&lt;p&gt;The technical interview went deep around the project I had presented. If a project looked weak, they seemed to ask more persistently about planning intent and technology choices. For the coding test, printed copies of the Round 1 and Round 2 problems were handed out, but only a few coding-test questions came up across all candidates. Still, the interviewers knew the Programmers submission history, submitted code, and partial scores, so it was not something you could hand-wave away.&lt;/p&gt;
&lt;p&gt;In conclusion, if you thoroughly understand the projects you built and prepare for follow-up questions on the technologies you used, the interview is not especially hard to answer. After passing both coding tests, the remaining question felt closer to how clearly I could explain my own experience.&lt;/p&gt;
&lt;h2 id=&quot;interview-takeaway&quot;&gt;Interview Takeaway&lt;/h2&gt;
&lt;p&gt;In the end, preparing for SWMaestro was not about collecting impressive technology names. It was about reaching the point where I could explain what I had actually built, including its structure, failure modes, and what I would redesign today. Once that was clear, the interview became much less intimidating.&lt;/p&gt;</content:encoded><language>en-US</language><category>SWMaestro</category><category>Software maestro</category><category>SW Maestro</category><category>SW 마에스트로</category><category>소프트웨어 마에스트로</category><author>ystc1247@gmail.com</author></item></channel></rss>