<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
    <title>nullreference</title>
    <subtitle>Articles, technical papers, and story drafts.</subtitle>
    <link rel="self" type="application/atom+xml" href="https://nullreference.xyz/atom.xml"/>
    <link rel="alternate" type="text/html" href="https://nullreference.xyz"/>
    <generator uri="https://www.getzola.org/">Zola</generator>
    <updated>2026-09-26T00:00:00+00:00</updated>
    <id>https://nullreference.xyz/atom.xml</id>
    <entry xml:lang="en">
        <title>Placing Compute Along the Database Request Path</title>
        <published>2026-09-26T00:00:00+00:00</published>
        <updated>2026-09-26T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://nullreference.xyz/articles/database-affinity-placement/"/>
        <id>https://nullreference.xyz/articles/database-affinity-placement/</id>
        
        <content type="html" xml:base="https://nullreference.xyz/articles/database-affinity-placement/">&lt;p&gt;Where should a web request run when the host closest to the client is not the host closest to its data?
Conventional routing can minimize client-to-compute delay. A database-backed request can also pay for each
serial network wait between compute and the database. This paper tests whether a request-aware placement rule
can reduce total response time.&lt;/p&gt;
&lt;p&gt;The test runs one Rails service on two application compute nodes and uses a separate PostgreSQL node. It supports a narrow claim:
for this service, requests with more serial database calls benefited from database-aware placement over
user-first routing. The experiment does not test a general scheduler or other dependency types.&lt;/p&gt;
&lt;p&gt;For a candidate compute host &lt;code&gt;c&lt;/code&gt;, the measured score is:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;estimated_latency(c) = client_to_compute_path(c) + N_serial × PostgreSQL_RTT(c) + route_residual(c)&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;N_serial&lt;/code&gt; counts database calls that depend on earlier calls. Parallel calls do not each add a full RTT to
the critical path.&lt;/p&gt;
&lt;p&gt;The network-only policy omits &lt;code&gt;route_residual&lt;/code&gt;. The profiled policy estimates it from the route&#39;s measured
zero-query cost. This is a practical model of the measured path. It is not a general model of CPU cost, queues,
or every service dependency. The experiment found no measurable placement benefit from profiling over the
simpler network-only policy.&lt;/p&gt;
&lt;p&gt;I calibrated a route-aware cost model on Compute A and Compute B. I then changed the client-to-compute
network path and tested the model on new requests. It predicted four direct-node medians within 0.08 to 0.33 ms. The
three-query route was faster on Compute A. The 12-query N+1 route was faster on Compute B.&lt;/p&gt;
&lt;p&gt;The routing proxy reduced the N+1 route median from 6.356 ms with user-first routing to 5.690 ms with the
profiled policy. This is a 10.5% reduction across 500 requests per policy. The 95% bootstrap interval for the
median change was 0.522 to 0.780 ms lower. The profiled policy and simpler network-only policy chose the same
hosts. Their median difference was not measurable in this run.&lt;/p&gt;
&lt;p&gt;The &lt;a href=&quot;/papers/database-affinity-data-guide/&quot;&gt;data guide&lt;/a&gt; defines the public role labels and measurement files.&lt;/p&gt;
&lt;h2 id=&quot;prototype-and-cost-model&quot;&gt;Prototype and cost model&lt;/h2&gt;
&lt;p&gt;The prototype has a Python HTTP proxy and a placement planner. The same Rails service ran on both compute
nodes. PostgreSQL ran on a separate database node. The app and database did not share a node. Both policies
used the same strong-read primary. The proxy tested compute selection, not replica placement.&lt;/p&gt;
&lt;p&gt;The network-only policy estimates response time as:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;user_to_compute_RTT + sequential_query_count × SQL_round_trip_time&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;The profiled policy adds a route-specific residual for each compute host:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;profiled_cost = user_request_path + host_route_residual + sequential_query_count × SQL_round_trip_time&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;I measured each host&#39;s &lt;code&gt;/health&lt;/code&gt; request path and each Rails route. I measured the median time for &lt;code&gt;SELECT 1&lt;/code&gt;
from each app host. The route residual is the direct route median minus the health request median and the
sequential query count multiplied by the &lt;code&gt;SELECT 1&lt;/code&gt; median. This residual captures route work that the
network-only score does not model. It does not isolate CPU time.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;/feed&lt;/code&gt; route makes three sequential SQL calls. The &lt;code&gt;/feed-naive&lt;/code&gt; route makes one account call, one article
call, and ten feedback calls. The seeded account has ten articles, so the N+1 route makes twelve calls.&lt;/p&gt;
&lt;h2 id=&quot;calibration-and-held-out-test&quot;&gt;Calibration and held-out test&lt;/h2&gt;
&lt;p&gt;I calibrated the model without added user-path delay. For each host and route, I warmed the endpoint 20 times
and measured 500 requests. I measured 500 &lt;code&gt;SELECT 1&lt;/code&gt; calls after 20 warmups. The median SQL call times were
0.355 ms from Compute A to the database node primary and 0.231 ms from Compute B to the primary.&lt;/p&gt;
&lt;p&gt;The test added 0.25 ms of one-way egress delay on the path from Compute B to Compute A. The new
client-to-compute TCP connect medians were 0.088 ms for Compute A and 0.601 ms for Compute B. The &lt;code&gt;/health&lt;/code&gt;
request medians were 0.741 ms and 1.480 ms.
I then measured 500 new requests for each route on each host. The &lt;a href=&quot;/papers/database-affinity-calibration.csv&quot;&gt;calibration data&lt;/a&gt;
and &lt;a href=&quot;/papers/database-affinity-heldout.csv&quot;&gt;held-out direct measurements&lt;/a&gt; include all samples.&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Route&lt;/th&gt;&lt;th&gt;Compute node&lt;/th&gt;&lt;th style=&quot;text-align: right&quot;&gt;Profiled estimate&lt;/th&gt;&lt;th style=&quot;text-align: right&quot;&gt;Held-out p50&lt;/th&gt;&lt;th style=&quot;text-align: right&quot;&gt;Error&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;/feed&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Compute A&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;2.261 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;2.338 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.077 ms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;/feed&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Compute B&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;2.451 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;2.571 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.120 ms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;/feed-naive&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Compute A&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;5.650 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;5.978 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.328 ms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;/feed-naive&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Compute B&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;4.449 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;4.744 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;0.295 ms&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The profile chose the compute node with the lower held-out p50 for each route. These estimates use a single calibration
run. They do not show that the profile will remain accurate under load or across other machines.&lt;/p&gt;
&lt;h2 id=&quot;routed-results&quot;&gt;Routed results&lt;/h2&gt;
&lt;p&gt;The proxy opened a new upstream connection for each request. It warmed each policy and route 15 times. It then
sent 500 requests for each of three policies and two routes. The run interleaved and randomized requests. All
3,000 requests returned HTTP 200.&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Route&lt;/th&gt;&lt;th style=&quot;text-align: right&quot;&gt;User-first p50&lt;/th&gt;&lt;th style=&quot;text-align: right&quot;&gt;Network-only p50&lt;/th&gt;&lt;th style=&quot;text-align: right&quot;&gt;Profiled p50&lt;/th&gt;&lt;th&gt;Selected host&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Three-query &lt;code&gt;/feed&lt;/code&gt;&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;2.834 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;2.816 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;2.829 ms&lt;/td&gt;&lt;td&gt;Compute A&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Twelve-call &lt;code&gt;/feed-naive&lt;/code&gt;&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;6.356 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;5.742 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;5.690 ms&lt;/td&gt;&lt;td&gt;Compute B&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Both database-aware policies kept &lt;code&gt;/feed&lt;/code&gt; on Compute A. Neither changed its median by a measurable amount. For
&lt;code&gt;/feed-naive&lt;/code&gt;, network-only routing lowered p50 by 0.614 ms relative to user-first routing. Profiled routing
lowered p50 by 0.665 ms. The 95% bootstrap interval for the profiled change was 0.522 to 0.780 ms lower.&lt;/p&gt;
&lt;p&gt;The profile did not improve the placement choice over the network-only policy. Both policies selected the same
host for each route. Their &lt;code&gt;/feed-naive&lt;/code&gt; p50 difference was 0.052 ms, with a 95% bootstrap interval from 0.191
ms lower to 0.137 ms higher. This test shows that route and host profiles can predict end-to-end cost. It does
not show that the added profile logic improves routing when the simpler policy already chooses well.&lt;/p&gt;
&lt;p&gt;The first proxy run used only the network score. It predicted a larger gain than the measured result. The
&lt;a href=&quot;/papers/database-affinity-initial-run.csv&quot;&gt;initial run data&lt;/a&gt; records that result. The later profile reduced
prediction error, but did not produce a measurable gain over network-only routing.&lt;/p&gt;
&lt;h2 id=&quot;a-deliberate-policy-disagreement&quot;&gt;A deliberate policy disagreement&lt;/h2&gt;
&lt;p&gt;The first proxy experiment did not make the network-only and profiled policies choose different hosts. I added
a controlled Rails route that runs N serial &lt;code&gt;SELECT 1&lt;/code&gt; calls. Compute A and Compute B are the two
application nodes. PostgreSQL stayed on the separate database node. The same client sent requests to both
application nodes.&lt;/p&gt;
&lt;p&gt;I swept N = 0, 1, 3, 6, 12, 24, and 48. The network model used the measured &lt;code&gt;/health&lt;/code&gt; path plus N times the
per-query database time. The profiled model used the measured zero-query route plus N times the per-query
database time. On the first calibration, the network model&#39;s predicted crossover was after 3.55 calls. The
profiled model predicted it after 1.62 calls. The
&lt;a href=&quot;/papers/database-affinity-crossover-primary-calibration.json&quot;&gt;primary calibration&lt;/a&gt; records the inputs.&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th style=&quot;text-align: right&quot;&gt;Serial calls&lt;/th&gt;&lt;th style=&quot;text-align: right&quot;&gt;Compute A p50&lt;/th&gt;&lt;th style=&quot;text-align: right&quot;&gt;Compute B p50&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td style=&quot;text-align: right&quot;&gt;0&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;1.044 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;1.499 ms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td style=&quot;text-align: right&quot;&gt;1&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;1.668 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;1.844 ms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td style=&quot;text-align: right&quot;&gt;3&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;2.603 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;2.443 ms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td style=&quot;text-align: right&quot;&gt;6&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;3.956 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;3.225 ms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td style=&quot;text-align: right&quot;&gt;12&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;6.668 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;4.555 ms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td style=&quot;text-align: right&quot;&gt;24&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;12.039 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;7.405 ms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td style=&quot;text-align: right&quot;&gt;48&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;22.435 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;12.743 ms&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The network model chose Compute A at zero, one, and three calls, then chose Compute B from six calls on. The profiled model
switched to Compute B at three calls.&lt;/p&gt;
&lt;p&gt;At three calls, Compute B was 0.161 ms faster than Compute A. The 95% bootstrap interval for Compute A p50 minus Compute B p50
was 0.046 to 0.260 ms. At one call, Compute A was faster by 0.176 ms, with an interval from -0.246 to -0.115 ms.
The &lt;a href=&quot;/papers/database-affinity-crossover-primary.csv&quot;&gt;raw sweep&lt;/a&gt; and
&lt;a href=&quot;/papers/database-affinity-crossover-primary-summary.csv&quot;&gt;summary&lt;/a&gt; include all samples. A 500-sample
follow-up at two calls found no clear winner. Compute A was faster by 0.006 ms, with a 95% interval from -0.075 to
0.048 ms. The &lt;a href=&quot;/papers/database-affinity-crossover-n2-confirmation.csv&quot;&gt;two-call follow-up&lt;/a&gt; is available.
The data place the measured crossover between one and three calls, but do not locate it more precisely.&lt;/p&gt;
&lt;p&gt;I repeated the direct comparison with a fresh calibration. The new network and profiled models both chose Compute A
at one call and Compute B at three and twelve calls. Compute B was faster at three calls by 0.243 ms, with a 95%
interval from 0.178 to 0.314 ms. This repeat supports a workload-dependent placement change. It does not show
that the profiled model improves on the simpler network model. The measured host paths varied enough to move
the predicted crossover. The &lt;a href=&quot;/papers/database-affinity-crossover-repeat-calibration.json&quot;&gt;repeat calibration&lt;/a&gt;,
&lt;a href=&quot;/papers/database-affinity-crossover-repeat.csv&quot;&gt;measurements&lt;/a&gt;, and
&lt;a href=&quot;/papers/database-affinity-crossover-repeat-summary.csv&quot;&gt;summary&lt;/a&gt; are available.&lt;/p&gt;
&lt;p&gt;I then sent requests through all three local proxies, using the fresh calibration. The same client sent
one-call, three-call, and twelve-call requests. User-first routing kept all requests on Compute A. The two database-
aware policies sent the one-call request to Compute A and the longer requests to Compute B.&lt;/p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th style=&quot;text-align: right&quot;&gt;Calls&lt;/th&gt;&lt;th style=&quot;text-align: right&quot;&gt;User-first p50&lt;/th&gt;&lt;th style=&quot;text-align: right&quot;&gt;Profiled p50&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td style=&quot;text-align: right&quot;&gt;1&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;1.978 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;1.966 ms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td style=&quot;text-align: right&quot;&gt;3&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;2.949 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;2.865 ms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td style=&quot;text-align: right&quot;&gt;12&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;6.776 ms&lt;/td&gt;&lt;td style=&quot;text-align: right&quot;&gt;4.918 ms&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Profiled routing reduced the three-call p50 by 0.083 ms relative to user-first routing. Its 95% interval was
0.010 to 0.187 ms. At twelve calls, it reduced p50 by 1.858 ms, with an interval from 1.616 to 2.050 ms.
Profiled routing did not measurably beat network-only routing. Their median difference was 0.022 ms at three
calls, with an interval from -0.056 to 0.100 ms, and 0.059 ms at twelve calls, with an interval from -0.230
to 0.289 ms. The &lt;a href=&quot;/papers/database-affinity-crossover-proxy.csv&quot;&gt;proxy samples&lt;/a&gt; and
&lt;a href=&quot;/papers/database-affinity-crossover-proxy-summary.csv&quot;&gt;summary&lt;/a&gt; contain these measurements.
Network-only p50 was 1.973 ms, 2.887 ms, and 4.977 ms at one, three, and twelve calls. The two database-aware
policies chose the same host at each count in this repeated run.&lt;/p&gt;
&lt;p&gt;This test supports route-dependent database-aware placement over user-first placement for this Rails service.
It does not establish a benefit from profiling over a simpler policy that combines measured client-path and
database costs. The &lt;a href=&quot;/papers/database_affinity_crossover_sweep.py&quot;&gt;sweep&lt;/a&gt;,
&lt;a href=&quot;/papers/database_affinity_crossover_proxy.py&quot;&gt;proxy&lt;/a&gt;,
&lt;a href=&quot;/papers/database_affinity_crossover_measure.py&quot;&gt;measurement script&lt;/a&gt;, and
&lt;a href=&quot;/papers/database_affinity_crossover_feeds_controller.rb&quot;&gt;Rails route&lt;/a&gt; are published with the data.&lt;/p&gt;
&lt;h2 id=&quot;isolation-and-limits&quot;&gt;Isolation and limits&lt;/h2&gt;
&lt;p&gt;The application and database ran on separate nodes. The experiment did not
test hostile tenants, a hardened sandbox, or failure isolation. It used one database primary, so it did not test
compute and replica selection together.&lt;/p&gt;
&lt;p&gt;This test used one Rails service, two application nodes, one database node, one client path, and one controlled delay. It did not
model concurrent arrivals, queueing, failures, migration, or production traffic. The measured route split
supports selective compute placement for this workload. It does not establish a production scheduler benefit.&lt;/p&gt;
&lt;p&gt;The general scheduling question extends beyond PostgreSQL. A request can depend on several services, and each
serial wait can affect the best compute location. This experiment did not measure Redis, object storage, or
service-to-service calls. Future work can test a placement score that includes those dependencies and host
compute cost.&lt;/p&gt;
&lt;p&gt;Latency-aware placement and compute-data colocation have substantial prior work. &lt;a rel=&quot;external&quot; href=&quot;https://www.cl.cam.ac.uk/~dap53/pubs/popescu-ieee-network-2022.pdf&quot;&gt;NoMora&lt;/a&gt; uses measured
application performance and network delay to place or move tasks. &lt;a rel=&quot;external&quot; href=&quot;https://doi.org/10.3390/software5010005&quot;&gt;LambdaStore&lt;/a&gt; colocates serverless functions
with storage. &lt;a rel=&quot;external&quot; href=&quot;https://www.sciencedirect.com/science/article/pii/S1389128624002767&quot;&gt;User-aware edge placement&lt;/a&gt; includes user latency in service placement. &lt;a rel=&quot;external&quot; href=&quot;https://www.microsoft.com/en-us/research/publication/network-aware-scheduling-for-data-parallel-jobs-plan-when-you-can/&quot;&gt;Corral&lt;/a&gt; jointly places
compute and data while isolating jobs. This prototype does not claim that these broad ideas are new.&lt;/p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Sub-Millisecond Database RTT in Web Applications</title>
        <published>2026-09-26T00:00:00+00:00</published>
        <updated>2026-09-26T00:00:00+00:00</updated>
        
        <author>
          <name>Unknown</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://nullreference.xyz/articles/database-latency-submillisecond/"/>
        <id>https://nullreference.xyz/articles/database-latency-submillisecond/</id>
        
        <content type="html" xml:base="https://nullreference.xyz/articles/database-latency-submillisecond/">&lt;p&gt;At a configured 0.75 ms database round-trip time (RTT), four ordinary three-query routes gained
2.2 to 2.6 ms. A 12-query Rails route gained 9.6 ms. A Redmine API workload gained 32.2 ms, and an
Aimeos storefront workload gained 34.4 ms.&lt;/p&gt;
&lt;p&gt;The effect remained visible below 1 ms. Its size depended on the database work in each request.
These tests ran in local containers on one DGX GB10 host. They did not use a cloud.&lt;/p&gt;
&lt;h2 id=&quot;framework-routes&quot;&gt;Framework routes&lt;/h2&gt;
&lt;p&gt;Each framework used the same feed route. It looked up a reader, selected articles, and counted
feedback. Each query used the result from the previous query, so the route made three sequential
database calls. The raw Axum route prepared each query on each request. The cached Axum route reused
prepared statements.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Go with &lt;code&gt;database/sql&lt;/code&gt; and &lt;code&gt;pgx&lt;/code&gt;: 0.91 ms at 0 ms, 3.40 ms at 0.75 ms. Change: +2.49 ms
[2.29, 2.63].&lt;/li&gt;
&lt;li&gt;Node.js with &lt;code&gt;pg&lt;/code&gt;: 1.45 ms at 0 ms, 3.64 ms at 0.75 ms. Change: +2.19 ms [2.06, 2.52].&lt;/li&gt;
&lt;li&gt;Rails with Active Record: 1.90 ms at 0 ms, 4.16 ms at 0.75 ms. Change: +2.26 ms
[1.97, 2.47].&lt;/li&gt;
&lt;li&gt;Axum with cached statements: 0.83 ms at 0 ms, 3.40 ms at 0.75 ms. Change: +2.57 ms
[2.22, 2.70].&lt;/li&gt;
&lt;li&gt;Axum with raw query strings: 1.21 ms at 0 ms, 6.32 ms at 0.75 ms. Change: +5.11 ms
[4.82, 5.48].&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;/db-latency-subms-stacks.svg&quot; alt=&quot;Framework route p50 from 0 to 1 ms of configured database RTT.&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The three-query routes gained about the 2.25 ms predicted by three sequential waits at 0.75 ms
each. The raw Axum route gained more because it also prepared statements. The cached route stayed
near the other three-query routes. This compares complete runtime and driver stacks. It does not
isolate language speed.&lt;/p&gt;
&lt;p&gt;The normal Rails route used three calls. A second route used one feedback count for each of ten
articles. It made 12 sequential calls in total.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Grouped feedback counts, 3 calls: 1.90 ms at 0 ms, 4.16 ms at 0.75 ms. Change: +2.26 ms
[1.97, 2.47].&lt;/li&gt;
&lt;li&gt;One feedback count per article, 12 calls: 3.54 ms at 0 ms, 13.09 ms at 0.75 ms. Change:
+9.55 ms [9.26, 10.40].&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The 12-call route gained close to the 9 ms predicted by 12 sequential waits at 0.75 ms each. This
is a query-pattern result. It is not a framework ranking.&lt;/p&gt;
&lt;h2 id=&quot;open-source-applications&quot;&gt;Open-source applications&lt;/h2&gt;
&lt;p&gt;The Redmine workload sent authenticated reads to the Redmine 6.1.4 REST API. Its mix used 60%
paginated issue lists, 30% issue details with journals, and 10% project lists. The test seeded 12
projects and 360 issues.&lt;/p&gt;
&lt;p&gt;The Aimeos workload used a Laravel storefront and its demo catalog. It sent 60% product searches
and 40% product detail requests. The catalog contained 17 products.&lt;/p&gt;
&lt;p&gt;Redmine 6.1.4 ran with Rails 7.2.3.2. Aimeos used Laravel 13.33.0 and PHP 8.4. Both apps used
PostgreSQL 18.6.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;0 ms: Redmine 35.78 ms. Aimeos 65.74 ms.&lt;/li&gt;
&lt;li&gt;0.05 ms: Redmine 45.11 ms, change +9.33 ms [8.10, 10.56]. Aimeos 64.47 ms, change
-1.27 ms [-4.64, 1.85].&lt;/li&gt;
&lt;li&gt;0.1 ms: Redmine 47.22 ms, change +11.43 ms [10.71, 12.63]. Aimeos 71.23 ms, change
+5.48 ms [1.88, 7.17].&lt;/li&gt;
&lt;li&gt;0.2 ms: Redmine 44.82 ms, change +9.03 ms [7.27, 10.60]. Aimeos 76.08 ms, change
+10.33 ms [7.97, 11.80].&lt;/li&gt;
&lt;li&gt;0.5 ms: Redmine 58.45 ms, change +22.67 ms [19.75, 23.84]. Aimeos 89.26 ms, change
+23.51 ms [21.21, 25.29].&lt;/li&gt;
&lt;li&gt;0.75 ms: Redmine 68.02 ms, change +32.23 ms [30.39, 34.80]. Aimeos 100.12 ms, change
+34.37 ms [30.71, 35.80].&lt;/li&gt;
&lt;li&gt;1 ms: Redmine 82.42 ms, change +46.63 ms [44.15, 51.09]. Aimeos 111.55 ms, change
+45.80 ms [43.03, 48.55].&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;/redmine-database-latency-subms.svg&quot; alt=&quot;Redmine request p50 from 0 to 1 ms of configured database RTT.&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/aimeos-database-latency-subms.svg&quot; alt=&quot;Aimeos request p50 from 0 to 1 ms of configured database RTT.&quot; /&gt;&lt;/p&gt;
&lt;p&gt;At 0.75 ms, the Redmine median increased by 90%. The Aimeos median increased by 52%. Each app
showed about 32 to 34 ms of added request time from less than one millisecond of configured RTT.
The ratio of the request increase to configured RTT was about 43 for Redmine and 46 for Aimeos.
These ratios describe this workload and test setup. They are not database query counts.&lt;/p&gt;
&lt;p&gt;The smallest Redmine settings did not follow a smooth progression. The 0.05 and 0.1 ms settings
added more request time than a one-event-per-round-trip model predicts. At 0.05 ms, an uncached
&lt;code&gt;SELECT 1&lt;/code&gt; call gained 0.077 ms at the median. Forty-one such waits would add about 3.2 ms, while
the Redmine request median gained 9.3 ms. At 0.1 ms, the same model predicts 4.6 ms, while the
request median gained 11.4 ms. The trace does not explain these differences.&lt;/p&gt;
&lt;h3 id=&quot;redmine-sql-trace&quot;&gt;Redmine SQL trace&lt;/h3&gt;
&lt;p&gt;A separate trace counted uncached Active Record SQL notification events for 100 requests. It did
not run with the timing measurements. The trace saw 41 events per issue-list request at the median,
20 per issue-detail request, and 8 per project-list request.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Issue list: 60 requests, 41 events per request at the median, range 41 to 68.&lt;/li&gt;
&lt;li&gt;Issue detail: 30 requests, 20 events at the median, range 20 to 30.&lt;/li&gt;
&lt;li&gt;Project list: 10 requests, 8 events at the median, range 8 to 10.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The issue-list count is close to the measured amplification ratio. An SQL event count does not show
whether each event caused a separate network round trip. A protocol trace would be needed to make
that claim.&lt;/p&gt;
&lt;h2 id=&quot;measurement-method&quot;&gt;Measurement method&lt;/h2&gt;
&lt;p&gt;The app, database, and probe ran in separate Docker containers on the local DGX GB10 host. The test
used one client. Linux &lt;code&gt;tc netem&lt;/code&gt; added half of each target delay to the app container and half to
the database container. The tested settings were 0, 0.05, 0.1, 0.2, 0.3, 0.4, 0.5, 0.75, and 1 ms.
The task order was shuffled.&lt;/p&gt;
&lt;p&gt;Each framework condition ran 20 times with 100 measured requests and 20 warmup requests per run.
Redmine used the same run and request counts. Aimeos ran 10 times with 50 measured requests and 20
warmup requests per condition. No measured requests failed.&lt;/p&gt;
&lt;p&gt;Each run produced a p50. The reported p50 is the median across run-level p50 values. The 95% CIs
use 10,000 bootstrap samples of run-level p50 values. The CIs for changes resampled the baseline
and target runs independently, then subtracted their medians.&lt;/p&gt;
&lt;p&gt;A separate network check sent 50 ICMP packets between two local containers at each setting. The
check used the same &lt;code&gt;netem&lt;/code&gt; queue, including at 0 ms. It measured a mean RTT of 0.063 ms with no
injected delay, 0.806 ms at a configured 0.75 ms, and 1.008 ms at a configured 1 ms. This checks
the container network path. It does not measure a PostgreSQL query.&lt;/p&gt;
&lt;p&gt;A second probe ran 200 uncached &lt;code&gt;SELECT 1&lt;/code&gt; calls through the Redmine Rails adapter in each of five
runs per setting. At configured RTTs of 0.75 and 1 ms, median query times increased by 0.756 ms
[0.736, 1.007] and 1.007 ms [0.988, 1.503]. This probe includes the Rails adapter and local host
scheduling. Its medians varied at some lower settings.&lt;/p&gt;
&lt;h2 id=&quot;limits-and-next-question&quot;&gt;Limits and next question&lt;/h2&gt;
&lt;p&gt;These were synthetic requests to real applications. They were not captured production traffic.
The tests used one local host and one client. They do not measure cloud placement or predict latency
for a managed database. The Redmine trace counted SQL notification events, not network exchanges.
The smallest RTT settings need more investigation because the request deltas did not always match
the standalone query probe.&lt;/p&gt;
&lt;p&gt;The results motivate a follow-up systems question. Can a platform identify handlers that wait for
many database results in sequence, then place only those handlers near the database primary?
This experiment does not test such a scheduler.&lt;/p&gt;
</content>
        
    </entry>
</feed>
