From 68b84d32845c27edfecea135cf85d3e3c4b961e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Stru=CC=88bing?= Date: Tue, 9 Jun 2026 10:03:36 +0200 Subject: [PATCH 1/7] Optimize XML response output Add profiling scripts and baseline measurements for XML paging while reducing overhead in the XML response writer path. --- .../fhir-structure/src/blaze/fhir/spec.clj | 108 +++++++- .../src/blaze/fhir/spec_spec.clj | 4 + .../src/blaze/fhir/writing_context.clj | 257 +++++++++++++++++- .../test/blaze/fhir/spec_test.clj | 37 +++ .../src/blaze/middleware/fhir/output.clj | 25 +- .../Dockerfile.baseline-jdk-profile | 25 ++ profiling/xml-paging/Dockerfile.jdk-profile | 23 ++ profiling/xml-paging/README.md | 47 ++++ profiling/xml-paging/benchmark-paging.sh | 80 ++++++ profiling/xml-paging/benchmark-single-page.sh | 40 +++ profiling/xml-paging/results-baseline.csv | 11 + .../xml-paging/results-single-baseline.csv | 41 +++ .../results-single-xml-buffered.csv | 41 +++ .../results-single-xml-fast-emit.csv | 21 ++ .../results-xml-buffered-count5000.csv | 7 + profiling/xml-paging/results-xml-buffered.csv | 11 + .../results-xml-direct-context-count5000.csv | 7 + .../xml-paging/results-xml-direct-context.csv | 11 + .../results-xml-fast-emit-count5000.csv | 4 + .../xml-paging/results-xml-fast-emit.csv | 11 + profiling/xml-paging/run-blaze.sh | 43 +++ profiling/xml-paging/run-standalone.sh | 64 +++++ profiling/xml-paging/upload-test-data.sh | 14 + 23 files changed, 910 insertions(+), 22 deletions(-) create mode 100644 profiling/xml-paging/Dockerfile.baseline-jdk-profile create mode 100644 profiling/xml-paging/Dockerfile.jdk-profile create mode 100644 profiling/xml-paging/README.md create mode 100755 profiling/xml-paging/benchmark-paging.sh create mode 100755 profiling/xml-paging/benchmark-single-page.sh create mode 100644 profiling/xml-paging/results-baseline.csv create mode 100644 profiling/xml-paging/results-single-baseline.csv create mode 100644 profiling/xml-paging/results-single-xml-buffered.csv create mode 100644 profiling/xml-paging/results-single-xml-fast-emit.csv create mode 100644 profiling/xml-paging/results-xml-buffered-count5000.csv create mode 100644 profiling/xml-paging/results-xml-buffered.csv create mode 100644 profiling/xml-paging/results-xml-direct-context-count5000.csv create mode 100644 profiling/xml-paging/results-xml-direct-context.csv create mode 100644 profiling/xml-paging/results-xml-fast-emit-count5000.csv create mode 100644 profiling/xml-paging/results-xml-fast-emit.csv create mode 100755 profiling/xml-paging/run-blaze.sh create mode 100755 profiling/xml-paging/run-standalone.sh create mode 100755 profiling/xml-paging/upload-test-data.sh diff --git a/modules/fhir-structure/src/blaze/fhir/spec.clj b/modules/fhir-structure/src/blaze/fhir/spec.clj index ee04fc9ca..9a556eb73 100644 --- a/modules/fhir-structure/src/blaze/fhir/spec.clj +++ b/modules/fhir-structure/src/blaze/fhir/spec.clj @@ -14,7 +14,8 @@ [cognitect.anomalies :as anom]) (:import [blaze.fhir.spec.type Primitive] - [java.io ByteArrayOutputStream] + [clojure.data.xml.node Element] + [java.io BufferedWriter ByteArrayOutputStream OutputStream OutputStreamWriter Writer] [java.nio.charset StandardCharsets] [java.util.regex Pattern])) @@ -67,6 +68,111 @@ (when-ok [bytes (write-json-as-bytes context value)] (String. ^bytes bytes StandardCharsets/UTF_8))) +(declare unform-xml) + +(defn- valid-xml-char? [^long c] + (or (== 0x09 c) (== 0x0A c) (== 0x0D c) + (and (<= 0x20 c) (<= c 0xD7FF)) + (and (<= 0xE000 c) (<= c 0xFFFD)))) + +(defn- write-xml-str [^Writer writer s] + (let [^String s (str s) + len (.length s)] + (loop [i 0 + start 0] + (if (< i len) + (let [c (.charAt s i)] + (case c + \& (do + (when (< start i) + (.write writer s start (- i start))) + (.write writer "&") + (recur (inc i) (inc i))) + \< (do + (when (< start i) + (.write writer s start (- i start))) + (.write writer "<") + (recur (inc i) (inc i))) + \> (do + (when (< start i) + (.write writer s start (- i start))) + (.write writer ">") + (recur (inc i) (inc i))) + \" (do + (when (< start i) + (.write writer s start (- i start))) + (.write writer """) + (recur (inc i) (inc i))) + (cond + (Character/isHighSurrogate c) + (let [i' (inc i)] + (if (and (< i' len) (Character/isLowSurrogate (.charAt s i'))) + (recur (inc i') start) + (do + (when (< start i) + (.write writer s start (- i start))) + (.write writer "?") + (recur i' i')))) + + (or (Character/isLowSurrogate c) (not (valid-xml-char? (int c)))) + (do + (when (< start i) + (.write writer s start (- i start))) + (.write writer "?") + (recur (inc i) (inc i))) + + :else + (recur (inc i) start)))) + (when (< start len) + (.write writer s start (- len start))))))) + +(defn- write-xml-attr [^Writer writer k v] + (.write writer " ") + (.write writer (name k)) + (.write writer "=\"") + (write-xml-str writer v) + (.write writer "\"")) + +(defn- write-xml-start-tag [^Writer writer tag attrs] + (.write writer "<") + (.write writer (name tag)) + (run! (fn [[k v]] (write-xml-attr writer k v)) attrs)) + +(declare write-xml-element) + +(defn- write-xml-content [^Writer writer content] + (run! + (fn [x] + (if (instance? Element x) + (write-xml-element writer x) + (write-xml-str writer x))) + content)) + +(defn- write-xml-element [^Writer writer {:keys [tag attrs content]}] + (write-xml-start-tag writer tag attrs) + (if (seq content) + (do + (.write writer ">") + (write-xml-content writer content) + (.write writer "")) + (.write writer "/>"))) + +(defn write-xml + "Writes `value` to output stream `out` closing it if done." + [context out value] + (with-open [writer (BufferedWriter. + (OutputStreamWriter. ^OutputStream out StandardCharsets/UTF_8) + 65536)] + (.write writer "") + (if-some [xml-handlers (:xml-handlers (meta context))] + (if-some [handler (xml-handlers (:fhir/type value))] + (handler xml-handlers writer value) + (write-xml-element writer (unform-xml value))) + (write-xml-element writer (unform-xml value))) + nil)) + (defn write-cbor [context x] (let [out (ByteArrayOutputStream.)] diff --git a/modules/fhir-structure/src/blaze/fhir/spec_spec.clj b/modules/fhir-structure/src/blaze/fhir/spec_spec.clj index 4e8766237..38935592b 100644 --- a/modules/fhir-structure/src/blaze/fhir/spec_spec.clj +++ b/modules/fhir-structure/src/blaze/fhir/spec_spec.clj @@ -46,6 +46,10 @@ :args (s/cat :context :blaze.fhir/writing-context :value :fhir/value) :ret (s/or :result string? :anomaly ::anom/anomaly)) +(s/fdef fhir-spec/write-xml + :args (s/cat :context :blaze.fhir/writing-context + :out #(instance? OutputStream %) :value :fhir/value)) + (s/fdef fhir-spec/write-cbor :args (s/cat :context :blaze.fhir/writing-context :value :fhir/value) :ret (s/or :result bytes? :anomaly ::anom/anomaly)) diff --git a/modules/fhir-structure/src/blaze/fhir/writing_context.clj b/modules/fhir-structure/src/blaze/fhir/writing_context.clj index b41a3f1f8..1a944c3ce 100644 --- a/modules/fhir-structure/src/blaze/fhir/writing_context.clj +++ b/modules/fhir-structure/src/blaze/fhir/writing_context.clj @@ -3,6 +3,7 @@ (:require [blaze.anomaly :as ba] [blaze.fhir.spec.resource :as res] + [blaze.fhir.spec.type :as type] [blaze.fhir.spec.type.string-util :as su] [blaze.fhir.structure-definition-repo :as sdr] [blaze.fhir.structure-definition-repo.spec] @@ -15,11 +16,15 @@ [taoensso.timbre :as log]) (:import [blaze ReducibleArray] - [blaze.fhir.spec.type Base Complex FieldName Primitive] - [com.fasterxml.jackson.core JsonGenerator SerializableString])) + [blaze.fhir.spec.type Base Complex FieldName Primitive Xhtml] + [clojure.data.xml.node Element] + [com.fasterxml.jackson.core JsonGenerator SerializableString] + [java.io Writer])) (set! *warn-on-reflection* true) +(def ^:const fhir-namespace "http://hl7.org/fhir") + (defn- fhir-type-keyword [type] (let [parts (cons "fhir" (seq (str/split type #"\.")))] (keyword (str/join "." (butlast parts)) (last parts)))) @@ -164,12 +169,250 @@ {} (res/separate-element-definitions type more))) -(defn- build-context [complex-types resources] - (reduce - (fn [r {:keys [kind] {elements :element} :snapshot}] - (into r (create-type-handlers (keyword kind) elements))) +(defn- valid-xml-char? [^long c] + (or (== 0x09 c) (== 0x0A c) (== 0x0D c) + (and (<= 0x20 c) (<= c 0xD7FF)) + (and (<= 0xE000 c) (<= c 0xFFFD)))) + +(defn- write-xml-str [^Writer writer s] + (let [^String s (str s) + len (.length s)] + (loop [i 0 + start 0] + (if (< i len) + (let [c (.charAt s i)] + (case c + \& (do + (when (< start i) + (.write writer s start (- i start))) + (.write writer "&") + (recur (inc i) (inc i))) + \< (do + (when (< start i) + (.write writer s start (- i start))) + (.write writer "<") + (recur (inc i) (inc i))) + \> (do + (when (< start i) + (.write writer s start (- i start))) + (.write writer ">") + (recur (inc i) (inc i))) + \" (do + (when (< start i) + (.write writer s start (- i start))) + (.write writer """) + (recur (inc i) (inc i))) + (cond + (Character/isHighSurrogate c) + (let [i' (inc i)] + (if (and (< i' len) (Character/isLowSurrogate (.charAt s i'))) + (recur (inc i') start) + (do + (when (< start i) + (.write writer s start (- i start))) + (.write writer "?") + (recur i' i')))) + + (or (Character/isLowSurrogate c) (not (valid-xml-char? (int c)))) + (do + (when (< start i) + (.write writer s start (- i start))) + (.write writer "?") + (recur (inc i) (inc i))) + + :else + (recur (inc i) start)))) + (when (< start len) + (.write writer s start (- len start))))))) + +(defn- write-xml-attr [^Writer writer k v] + (.write writer " ") + (.write writer (name k)) + (.write writer "=\"") + (write-xml-str writer v) + (.write writer "\"")) + +(defn- write-start-tag [^Writer writer tag attrs] + (.write writer "<") + (.write writer ^String tag) + (run! (fn [[k v]] (write-xml-attr writer k v)) attrs)) + +(declare write-element-tree!) + +(defn- write-element-tree-content! [^Writer writer content] + (run! + (fn [x] + (if (instance? Element x) + (write-element-tree! writer x) + (write-xml-str writer x))) + content)) + +(defn- write-element-tree! [^Writer writer {:keys [tag attrs content]}] + (write-start-tag writer (name tag) attrs) + (if (seq content) + (do + (.write writer ">") + (write-element-tree-content! writer content) + (.write writer "")) + (.write writer "/>"))) + +(defn- write-end-tag [^Writer writer tag] + (.write writer "")) + +(defn- write-primitive-attrs! [^Writer writer ^Primitive value] + (when-some [id (.id value)] + (write-xml-attr writer :id id)) + (when-some [value (.valueAsString value)] + (write-xml-attr writer :value value))) + +(declare write-value!) + +(defn- write-primitive! [xml-handlers ^Writer writer tag ^Primitive value] + (if (instance? Xhtml value) + (write-element-tree! writer (type/xhtml-to-xml value)) + (let [extensions (.extension value)] + (write-start-tag writer tag nil) + (write-primitive-attrs! writer value) + (if (seq extensions) + (do + (.write writer ">") + (run! #(write-value! xml-handlers writer "extension" %) extensions) + (write-end-tag writer tag)) + (.write writer "/>"))))) + +(defn- write-system-string! [^Writer writer tag value] + (write-start-tag writer tag {:value value}) + (.write writer "/>")) + +(defn- write-resource-wrapper! [xml-handlers ^Writer writer value] + (write-start-tag writer "resource" nil) + (.write writer ">") + (write-value! xml-handlers writer nil value) + (write-end-tag writer "resource")) + +(defn- xml-type-tag [base-tag value] + (str base-tag (su/capital (name (:fhir/type value))))) + +(deftype XmlPropertyHandler [key tag polymorphic type resource-wrapper]) + +(defn- xml-type-keyword [path code] + (cond + (= "http://hl7.org/fhirpath/System.String" code) + :system/string + + (#{"Element" "BackboneElement"} code) + (fhir-type-keyword path) + + (Character/isUpperCase ^char (first code)) + (keyword "fhir" code) + + :else + (keyword "fhir" code))) + +(defn- xml-property-handler-definition + [parent-type {:keys [path] content-reference :contentReference element-types :type}] + (let [polymorphic (< 1 (count element-types)) + base-tag (res/base-field-name parent-type path polymorphic) + first-type-code (:code (first element-types))] + (XmlPropertyHandler. + (keyword base-tag) + base-tag + polymorphic + (if content-reference + (fhir-type-keyword (subs content-reference 1)) + (when (= 1 (count element-types)) + (xml-type-keyword path first-type-code))) + (= "Resource" first-type-code)))) + +(defn- create-xml-property-handlers [type element-definitions] + (ReducibleArray. (map (partial xml-property-handler-definition type) element-definitions))) + +(defn- write-xml-field-1! [xml-handlers writer ^XmlPropertyHandler property-handler value] + (cond + (.-resource-wrapper property-handler) + (write-resource-wrapper! xml-handlers writer value) + + (.-polymorphic property-handler) + (write-value! xml-handlers writer (xml-type-tag (.-tag property-handler) value) value) + + :else + (write-value! xml-handlers writer (.-tag property-handler) value))) + +(defn- write-xml-field! [xml-handlers writer ^XmlPropertyHandler property-handler value] + (if (sequential? value) + (run! #(write-xml-field-1! xml-handlers writer property-handler %) value) + (write-xml-field-1! xml-handlers writer property-handler value))) + +(defn- write-xml-fields! [xml-handlers writer property-handlers m] + (run! + (fn [property-handler] + (when-some [value (get m (.-key ^XmlPropertyHandler property-handler))] + (write-xml-field! xml-handlers writer property-handler value))) + property-handlers)) + +(defn- write-complex! [xml-handlers ^Writer writer tag attrs property-handlers value] + (write-start-tag writer tag attrs) + (if (seq value) + (do + (.write writer ">") + (write-xml-fields! xml-handlers writer property-handlers value) + (write-end-tag writer tag)) + (.write writer "/>"))) + +(defn- create-xml-type-handler [kind type element-definitions] + (let [property-handlers (create-xml-property-handlers type element-definitions)] + (case kind + :resource + (fn resource-xml-handler [xml-handlers ^Writer writer value] + (write-complex! xml-handlers writer type {:xmlns fhir-namespace} property-handlers value)) + :complex-type + (fn complex-xml-handler [xml-handlers ^Writer writer tag value] + (write-complex! xml-handlers writer tag nil property-handlers value))))) + +(defn- create-xml-type-handlers + "Creates a map of keyword type names to XML type-handlers." + [kind [{type :path} & more]] + (reduce-kv + (fn [res type element-definitions] + (let [kind (if (str/includes? type ".") :complex-type kind)] + (assoc res (fhir-type-keyword type) (create-xml-type-handler kind type element-definitions)))) {} - (into complex-types resources))) + (res/separate-element-definitions type more))) + +(defn- write-value! [xml-handlers writer tag value] + (cond + (instance? Primitive value) + (write-primitive! xml-handlers writer tag value) + + (string? value) + (write-system-string! writer tag value) + + :else + (if-some [handler (xml-handlers (:fhir/type value))] + (if tag + (handler xml-handlers writer tag value) + (handler xml-handlers writer value)) + (throw (IllegalArgumentException. (format "Value `%s` is no supported FHIR XML type." value)))))) + +(defn- build-context [complex-types resources] + (let [structure-definitions (into complex-types resources) + json-handlers + (reduce + (fn [r {:keys [kind] {elements :element} :snapshot}] + (into r (create-type-handlers (keyword kind) elements))) + {} + structure-definitions) + xml-handlers + (reduce + (fn [r {:keys [kind] {elements :element} :snapshot}] + (into r (create-xml-type-handlers (keyword kind) elements))) + {} + structure-definitions)] + (with-meta json-handlers {:xml-handlers xml-handlers}))) (defmethod m/pre-init-spec :blaze.fhir/writing-context [_] (s/keys :req-un [:blaze.fhir/structure-definition-repo])) diff --git a/modules/fhir-structure/test/blaze/fhir/spec_test.clj b/modules/fhir-structure/test/blaze/fhir/spec_test.clj index a8043a769..dbe81f8dc 100644 --- a/modules/fhir-structure/test/blaze/fhir/spec_test.clj +++ b/modules/fhir-structure/test/blaze/fhir/spec_test.clj @@ -36,6 +36,7 @@ [blaze.fhir.spec.type.system DateTime Times] [com.fasterxml.jackson.dataformat.cbor CBORFactory] [com.google.common.hash Hashing] + [java.io ByteArrayInputStream ByteArrayOutputStream] [java.nio.charset StandardCharsets])) (xml-name/alias-uri 'f "http://hl7.org/fhir") @@ -59,6 +60,10 @@ (defn- read-cbor [source] (j/read-value source cbor-object-mapper)) +(defn- read-xml [source] + (with-open [in (ByteArrayInputStream. source)] + (fhir-spec/conform-xml (xml/parse in)))) + (def ^:private parsing-context (ig/init-key :blaze.fhir/parsing-context @@ -89,6 +94,38 @@ :blaze.fhir/writing-context {:structure-definition-repo structure-definition-repo})) +(deftest write-xml-test + (let [out (ByteArrayOutputStream.) + patient {:fhir/type :fhir/Patient :id "0"}] + (is (nil? (fhir-spec/write-xml writing-context out patient))) + (is (= patient (read-xml (.toByteArray out)))))) + +(deftest write-xml-escaping-test + (let [out (ByteArrayOutputStream.) + patient {:fhir/type :fhir/Patient + :id "0" + :name [{:fhir/type :fhir/HumanName + :family #fhir/string "A&B \"D\"\u001E"}]}] + (is (nil? (fhir-spec/write-xml writing-context out patient))) + (is (= (str "" + "" + "" + "" + "") + (String. (.toByteArray out) StandardCharsets/UTF_8))))) + +(deftest write-xml-xhtml-test + (let [out (ByteArrayOutputStream.) + patient (fhir-spec/conform-xml + (prxml/sexp-as-element + [::f/Patient {:xmlns "http://hl7.org/fhir"} + [::f/id {:value "0"}] + [::f/text + [::xhtml/div {:xmlns "http://www.w3.org/1999/xhtml"} + [::xhtml/p "FHIR is cool."]]]]))] + (is (nil? (fhir-spec/write-xml writing-context out patient))) + (is (= patient (read-xml (.toByteArray out)))))) + (defn- write-json [x] (fhir-spec/write-json-as-bytes writing-context x)) diff --git a/modules/rest-util/src/blaze/middleware/fhir/output.clj b/modules/rest-util/src/blaze/middleware/fhir/output.clj index 12117b330..da2b6a987 100644 --- a/modules/rest-util/src/blaze/middleware/fhir/output.clj +++ b/modules/rest-util/src/blaze/middleware/fhir/output.clj @@ -8,8 +8,6 @@ [blaze.anomaly :as ba] [blaze.fhir.spec :as fhir-spec] [blaze.handler.util :as handler-util] - [clojure.data.xml :as xml] - [clojure.java.io :as io] [muuntaja.parse :as parse] [prometheus.alpha :as prom] [ring.core.protocols :as rp] @@ -44,16 +42,15 @@ (catch Throwable e (log/error "Error while outputting JSON:" e))))))) -(defn- generate-xml [body] +(defn- generate-xml [writing-context body] (reify rp/StreamableResponseBody (write-body-to-stream [_body _response output-stream] (log/trace "generate XML") (with-open [_ (prom/timer generate-duration-seconds "xml")] - (with-open [writer (io/writer output-stream)] - (try - (xml/emit (fhir-spec/unform-xml body) writer) - (catch Throwable e - (log/error "Error while outputting XML:" e)))))))) + (try + (fhir-spec/write-xml writing-context output-stream body) + (catch Throwable e + (log/error "Error while outputting XML:" e))))))) (defn- generate-binary** [{{data :value} :data}] (when data @@ -81,8 +78,8 @@ (cond-> response body (-> (update :body (partial generate-json writing-context)) (ring/content-type content-type)))) -(defn- encode-response-xml [{:keys [body] :as response} content-type] - (cond-> response body (-> (update :body generate-xml) +(defn- encode-response-xml [writing-context {:keys [body] :as response} content-type] + (cond-> response body (-> (update :body (partial generate-xml writing-context)) (ring/content-type content-type)))) (defn- encode-response-binary [writing-context {:keys [body] :as response}] @@ -113,11 +110,11 @@ (defn handle-response [writing-context opts request response] (case (request-format request) :fhir+json (encode-response-json writing-context response "application/fhir+json;charset=utf-8") - :fhir+xml (encode-response-xml response "application/fhir+xml;charset=utf-8") + :fhir+xml (encode-response-xml writing-context response "application/fhir+xml;charset=utf-8") :json (encode-response-json writing-context response "application/json;charset=utf-8") - :xml (encode-response-xml response "application/xml;charset=utf-8") + :xml (encode-response-xml writing-context response "application/xml;charset=utf-8") :text-json (encode-response-json writing-context response "text/json;charset=utf-8") - :text-xml (encode-response-xml response "text/xml;charset=utf-8") + :text-xml (encode-response-xml writing-context response "text/xml;charset=utf-8") (when (:accept-all? opts) (dissoc response :body)))) (defn wrap-output @@ -131,7 +128,7 @@ (defn handle-binary-response [writing-context request response] (case (request-format request) :fhir+json (encode-response-json writing-context response "application/fhir+json;charset=utf-8") - :fhir+xml (encode-response-xml response "application/fhir+xml;charset=utf-8") + :fhir+xml (encode-response-xml writing-context response "application/fhir+xml;charset=utf-8") (encode-response-binary writing-context response))) (defn wrap-binary-output diff --git a/profiling/xml-paging/Dockerfile.baseline-jdk-profile b/profiling/xml-paging/Dockerfile.baseline-jdk-profile new file mode 100644 index 000000000..b64dc7a20 --- /dev/null +++ b/profiling/xml-paging/Dockerfile.baseline-jdk-profile @@ -0,0 +1,25 @@ +FROM samply/blaze:latest AS baseline + +FROM eclipse-temurin:25.0.2_10-jdk-noble + +RUN set -eux; \ + apt-get update; \ + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends curl libjemalloc2 wget; \ + rm -rf /var/lib/apt/lists/ + +RUN mkdir -p /app/data && chown 1001:1001 /app/data +COPY --from=baseline /app/blaze-1.7.0-standalone.jar /app/ + +WORKDIR /app +USER 1001 + +ENV LD_PRELOAD="libjemalloc.so.2" +ENV BASE_JAVA_TOOL_OPTIONS="-XX:+UseCompactObjectHeaders --enable-native-access=ALL-UNNAMED --sun-misc-unsafe-memory-access=allow" +ENV STORAGE="standalone" +ENV INDEX_DB_DIR="/app/data/index" +ENV TRANSACTION_DB_DIR="/app/data/transaction" +ENV RESOURCE_DB_DIR="/app/data/resource" +ENV ADMIN_INDEX_DB_DIR="/app/data/admin-index" +ENV ADMIN_TRANSACTION_DB_DIR="/app/data/admin-transaction" + +CMD ["sh", "-c", "JAVA_TOOL_OPTIONS=\"${BASE_JAVA_TOOL_OPTIONS} ${JAVA_TOOL_OPTIONS}\" exec java -jar blaze-1.7.0-standalone.jar"] diff --git a/profiling/xml-paging/Dockerfile.jdk-profile b/profiling/xml-paging/Dockerfile.jdk-profile new file mode 100644 index 000000000..63ca0873b --- /dev/null +++ b/profiling/xml-paging/Dockerfile.jdk-profile @@ -0,0 +1,23 @@ +FROM eclipse-temurin:25.0.2_10-jdk-noble + +RUN set -eux; \ + apt-get update; \ + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends curl libjemalloc2 wget; \ + rm -rf /var/lib/apt/lists/ + +RUN mkdir -p /app/data && chown 1001:1001 /app/data +COPY target/blaze-1.7.0-standalone.jar /app/ + +WORKDIR /app +USER 1001 + +ENV LD_PRELOAD="libjemalloc.so.2" +ENV BASE_JAVA_TOOL_OPTIONS="-XX:+UseCompactObjectHeaders --enable-native-access=ALL-UNNAMED --sun-misc-unsafe-memory-access=allow" +ENV STORAGE="standalone" +ENV INDEX_DB_DIR="/app/data/index" +ENV TRANSACTION_DB_DIR="/app/data/transaction" +ENV RESOURCE_DB_DIR="/app/data/resource" +ENV ADMIN_INDEX_DB_DIR="/app/data/admin-index" +ENV ADMIN_TRANSACTION_DB_DIR="/app/data/admin-transaction" + +CMD ["sh", "-c", "JAVA_TOOL_OPTIONS=\"${BASE_JAVA_TOOL_OPTIONS} ${JAVA_TOOL_OPTIONS}\" exec java -jar blaze-1.7.0-standalone.jar"] diff --git a/profiling/xml-paging/README.md b/profiling/xml-paging/README.md new file mode 100644 index 000000000..b84a16e5b --- /dev/null +++ b/profiling/xml-paging/README.md @@ -0,0 +1,47 @@ +# XML Paging Benchmark + +This directory contains local profiling helpers for comparing Blaze JSON and +XML paging performance with a persistent Docker volume. + +## Start Blaze + +With a standalone jar: + +```sh +make uberjar +profiling/xml-paging/run-standalone.sh target/blaze-1.7.0-standalone.jar +``` + +The script stores data below `profiling/xml-paging/runtime/data` by default. +Reusing that directory keeps the loaded resources across jar changes. + +With Docker: + +```sh +profiling/xml-paging/run-blaze.sh blaze:latest +``` + +The script uses the Docker volume `blaze-paging-data` by default. Reusing the +volume keeps the loaded resources across image changes. + +## Upload Test Data + +```sh +profiling/xml-paging/upload-test-data.sh /Users/axs/Projekte/kerndatensatz-testdaten/Test_Data +``` + +## Benchmark Paging + +```sh +profiling/xml-paging/benchmark-paging.sh Encounter '_count=1000' json 5 +profiling/xml-paging/benchmark-paging.sh Encounter '_count=1000' xml 5 +``` + +Results are appended to `profiling/xml-paging/results.csv`. + +For server-side response timing of one page without following next links: + +```sh +profiling/xml-paging/benchmark-single-page.sh Encounter '_count=1000' json 20 +profiling/xml-paging/benchmark-single-page.sh Encounter '_count=1000' xml 20 +``` diff --git a/profiling/xml-paging/benchmark-paging.sh b/profiling/xml-paging/benchmark-paging.sh new file mode 100755 index 000000000..a3031407e --- /dev/null +++ b/profiling/xml-paging/benchmark-paging.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash + +set -euo pipefail + +server="${BLAZE_SERVER:-http://localhost:8080/fhir}" +type="${1:-Encounter}" +query="${2:-_count=1000}" +format="${3:-xml}" +runs="${4:-5}" +out="${5:-profiling/xml-paging/results.csv}" + +case "$format" in + json) + accept="application/fhir+json" + ;; + xml) + accept="application/fhir+xml" + ;; + *) + echo "Format must be either json or xml, got: $format" >&2 + exit 1 + ;; +esac + +mkdir -p "$(dirname "$out")" +if [[ ! -f "$out" ]]; then + echo "timestamp,format,type,query,run,pages,entries,bytes,seconds" > "$out" +fi + +extract_json_next() { + jq -r '.link[]? | select(.relation == "next") | .url' "$1" | head -1 +} + +extract_xml_next() { + xmllint --xpath "string(/*[local-name()='Bundle']/*[local-name()='link'][*[local-name()='relation']/@value='next']/*[local-name()='url']/@value)" "$1" 2>/dev/null || true +} + +count_json_entries() { + jq -r '.entry | length // 0' "$1" +} + +count_xml_entries() { + xmllint --xpath "count(/*[local-name()='Bundle']/*[local-name()='entry'])" "$1" 2>/dev/null || echo 0 +} + +url="${server}/${type}?${query}" + +for run in $(seq 1 "$runs"); do + tmp_dir="$(mktemp -d)" + page_file="${tmp_dir}/page" + pages=0 + entries=0 + bytes=0 + next="$url" + start_ns="$(date +%s%N)" + + while [[ -n "$next" ]]; do + curl -fsS -H "Accept: ${accept}" "$next" -o "$page_file" + page_bytes="$(wc -c < "$page_file" | tr -d ' ')" + bytes=$((bytes + page_bytes)) + pages=$((pages + 1)) + + if [[ "$format" == "json" ]]; then + page_entries="$(count_json_entries "$page_file")" + next="$(extract_json_next "$page_file")" + else + page_entries="$(count_xml_entries "$page_file")" + next="$(extract_xml_next "$page_file")" + fi + + entries=$((entries + page_entries)) + done + + end_ns="$(date +%s%N)" + seconds="$(awk -v start="$start_ns" -v end="$end_ns" 'BEGIN { printf "%.3f", (end - start) / 1000000000 }')" + timestamp="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + printf '%s,%s,%s,%s,%s,%s,%s,%s,%s\n' \ + "$timestamp" "$format" "$type" "$query" "$run" "$pages" "$entries" "$bytes" "$seconds" | tee -a "$out" + rm -rf "$tmp_dir" +done diff --git a/profiling/xml-paging/benchmark-single-page.sh b/profiling/xml-paging/benchmark-single-page.sh new file mode 100755 index 000000000..b15c8f42f --- /dev/null +++ b/profiling/xml-paging/benchmark-single-page.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash + +set -euo pipefail + +server="${BLAZE_SERVER:-http://localhost:8080/fhir}" +type="${1:-Encounter}" +query="${2:-_count=1000}" +format="${3:-xml}" +runs="${4:-20}" +out="${5:-profiling/xml-paging/results-single-page.csv}" + +case "$format" in + json) + accept="application/fhir+json" + ;; + xml) + accept="application/fhir+xml" + ;; + *) + echo "Format must be either json or xml, got: $format" >&2 + exit 1 + ;; +esac + +mkdir -p "$(dirname "$out")" +if [[ ! -f "$out" ]]; then + echo "timestamp,format,type,query,run,bytes,seconds" > "$out" +fi + +url="${server}/${type}?${query}" + +for run in $(seq 1 "$runs"); do + tmp_file="$(mktemp)" + seconds="$(curl -fsS -H "Accept: ${accept}" -o "$tmp_file" -w '%{time_total}' "$url")" + bytes="$(wc -c < "$tmp_file" | tr -d ' ')" + timestamp="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + printf '%s,%s,%s,%s,%s,%s,%.3f\n' \ + "$timestamp" "$format" "$type" "$query" "$run" "$bytes" "$seconds" | tee -a "$out" + rm -f "$tmp_file" +done diff --git a/profiling/xml-paging/results-baseline.csv b/profiling/xml-paging/results-baseline.csv new file mode 100644 index 000000000..52c01214f --- /dev/null +++ b/profiling/xml-paging/results-baseline.csv @@ -0,0 +1,11 @@ +timestamp,format,type,query,run,pages,entries,bytes,seconds +2026-06-02T20:26:09Z,json,Encounter,_count=1000,1,59,58271,68219526,3.872 +2026-06-02T20:26:13Z,json,Encounter,_count=1000,2,59,58271,68219526,3.872 +2026-06-02T20:26:14Z,xml,Encounter,_count=1000,1,59,58271,82716918,10.707 +2026-06-02T20:26:17Z,json,Encounter,_count=1000,3,59,58271,68219526,3.801 +2026-06-02T20:26:21Z,json,Encounter,_count=1000,4,59,58271,68219526,3.845 +2026-06-02T20:26:24Z,xml,Encounter,_count=1000,2,59,58271,82716918,10.277 +2026-06-02T20:26:25Z,json,Encounter,_count=1000,5,59,58271,68219526,3.823 +2026-06-02T20:26:34Z,xml,Encounter,_count=1000,3,59,58271,82716918,10.069 +2026-06-02T20:26:45Z,xml,Encounter,_count=1000,4,59,58271,82716918,10.117 +2026-06-02T20:26:55Z,xml,Encounter,_count=1000,5,59,58271,82716918,10.187 diff --git a/profiling/xml-paging/results-single-baseline.csv b/profiling/xml-paging/results-single-baseline.csv new file mode 100644 index 000000000..5041c756c --- /dev/null +++ b/profiling/xml-paging/results-single-baseline.csv @@ -0,0 +1,41 @@ +timestamp,format,type,query,run,bytes,seconds +2026-06-02T20:31:36Z,xml,Encounter,_count=1000,1,1451950,0.416 +2026-06-02T20:31:37Z,xml,Encounter,_count=1000,2,1451950,0.147 +2026-06-02T20:31:37Z,xml,Encounter,_count=1000,3,1451950,0.159 +2026-06-02T20:31:37Z,json,Encounter,_count=1000,1,1195007,0.038 +2026-06-02T20:31:37Z,json,Encounter,_count=1000,2,1195007,0.016 +2026-06-02T20:31:37Z,json,Encounter,_count=1000,3,1195007,0.015 +2026-06-02T20:31:37Z,json,Encounter,_count=1000,4,1195007,0.014 +2026-06-02T20:31:37Z,json,Encounter,_count=1000,5,1195007,0.012 +2026-06-02T20:31:37Z,xml,Encounter,_count=1000,4,1451950,0.143 +2026-06-02T20:31:37Z,json,Encounter,_count=1000,6,1195007,0.011 +2026-06-02T20:31:37Z,json,Encounter,_count=1000,7,1195007,0.010 +2026-06-02T20:31:37Z,json,Encounter,_count=1000,8,1195007,0.009 +2026-06-02T20:31:37Z,json,Encounter,_count=1000,9,1195007,0.009 +2026-06-02T20:31:37Z,json,Encounter,_count=1000,10,1195007,0.014 +2026-06-02T20:31:37Z,xml,Encounter,_count=1000,5,1451950,0.148 +2026-06-02T20:31:37Z,json,Encounter,_count=1000,11,1195007,0.019 +2026-06-02T20:31:37Z,json,Encounter,_count=1000,12,1195007,0.009 +2026-06-02T20:31:37Z,json,Encounter,_count=1000,13,1195007,0.008 +2026-06-02T20:31:37Z,json,Encounter,_count=1000,14,1195007,0.008 +2026-06-02T20:31:37Z,json,Encounter,_count=1000,15,1195007,0.008 +2026-06-02T20:31:37Z,xml,Encounter,_count=1000,6,1451950,0.144 +2026-06-02T20:31:37Z,json,Encounter,_count=1000,16,1195007,0.008 +2026-06-02T20:31:37Z,json,Encounter,_count=1000,17,1195007,0.008 +2026-06-02T20:31:37Z,json,Encounter,_count=1000,18,1195007,0.007 +2026-06-02T20:31:37Z,json,Encounter,_count=1000,19,1195007,0.008 +2026-06-02T20:31:37Z,json,Encounter,_count=1000,20,1195007,0.008 +2026-06-02T20:31:38Z,xml,Encounter,_count=1000,7,1451950,0.143 +2026-06-02T20:31:38Z,xml,Encounter,_count=1000,8,1451950,0.135 +2026-06-02T20:31:38Z,xml,Encounter,_count=1000,9,1451950,0.140 +2026-06-02T20:31:38Z,xml,Encounter,_count=1000,10,1451950,0.144 +2026-06-02T20:31:38Z,xml,Encounter,_count=1000,11,1451950,0.144 +2026-06-02T20:31:38Z,xml,Encounter,_count=1000,12,1451950,0.152 +2026-06-02T20:31:39Z,xml,Encounter,_count=1000,13,1451950,0.144 +2026-06-02T20:31:39Z,xml,Encounter,_count=1000,14,1451950,0.140 +2026-06-02T20:31:39Z,xml,Encounter,_count=1000,15,1451950,0.134 +2026-06-02T20:31:39Z,xml,Encounter,_count=1000,16,1451950,0.136 +2026-06-02T20:31:39Z,xml,Encounter,_count=1000,17,1451950,0.129 +2026-06-02T20:31:39Z,xml,Encounter,_count=1000,18,1451950,0.139 +2026-06-02T20:31:39Z,xml,Encounter,_count=1000,19,1451950,0.133 +2026-06-02T20:31:40Z,xml,Encounter,_count=1000,20,1451950,0.130 diff --git a/profiling/xml-paging/results-single-xml-buffered.csv b/profiling/xml-paging/results-single-xml-buffered.csv new file mode 100644 index 000000000..b1b2e7c4e --- /dev/null +++ b/profiling/xml-paging/results-single-xml-buffered.csv @@ -0,0 +1,41 @@ +timestamp,format,type,query,run,bytes,seconds +2026-06-02T20:30:48Z,xml,Encounter,_count=1000,1,1451950,0.146 +2026-06-02T20:30:48Z,xml,Encounter,_count=1000,2,1451950,0.129 +2026-06-02T20:30:49Z,xml,Encounter,_count=1000,3,1451950,0.136 +2026-06-02T20:30:49Z,xml,Encounter,_count=1000,4,1451950,0.141 +2026-06-02T20:30:49Z,json,Encounter,_count=1000,1,1195007,0.009 +2026-06-02T20:30:49Z,json,Encounter,_count=1000,2,1195007,0.008 +2026-06-02T20:30:49Z,json,Encounter,_count=1000,3,1195007,0.007 +2026-06-02T20:30:49Z,xml,Encounter,_count=1000,5,1451950,0.135 +2026-06-02T20:30:49Z,json,Encounter,_count=1000,4,1195007,0.010 +2026-06-02T20:30:49Z,json,Encounter,_count=1000,5,1195007,0.007 +2026-06-02T20:30:49Z,json,Encounter,_count=1000,6,1195007,0.007 +2026-06-02T20:30:49Z,json,Encounter,_count=1000,7,1195007,0.008 +2026-06-02T20:30:49Z,json,Encounter,_count=1000,8,1195007,0.007 +2026-06-02T20:30:49Z,xml,Encounter,_count=1000,6,1451950,0.131 +2026-06-02T20:30:49Z,json,Encounter,_count=1000,9,1195007,0.007 +2026-06-02T20:30:49Z,json,Encounter,_count=1000,10,1195007,0.007 +2026-06-02T20:30:49Z,json,Encounter,_count=1000,11,1195007,0.006 +2026-06-02T20:30:49Z,json,Encounter,_count=1000,12,1195007,0.007 +2026-06-02T20:30:49Z,json,Encounter,_count=1000,13,1195007,0.007 +2026-06-02T20:30:49Z,xml,Encounter,_count=1000,7,1451950,0.127 +2026-06-02T20:30:49Z,json,Encounter,_count=1000,14,1195007,0.007 +2026-06-02T20:30:49Z,json,Encounter,_count=1000,15,1195007,0.007 +2026-06-02T20:30:49Z,json,Encounter,_count=1000,16,1195007,0.007 +2026-06-02T20:30:49Z,json,Encounter,_count=1000,17,1195007,0.007 +2026-06-02T20:30:49Z,xml,Encounter,_count=1000,8,1451950,0.131 +2026-06-02T20:30:49Z,json,Encounter,_count=1000,18,1195007,0.006 +2026-06-02T20:30:49Z,json,Encounter,_count=1000,19,1195007,0.008 +2026-06-02T20:30:49Z,json,Encounter,_count=1000,20,1195007,0.008 +2026-06-02T20:30:50Z,xml,Encounter,_count=1000,9,1451950,0.137 +2026-06-02T20:30:50Z,xml,Encounter,_count=1000,10,1451950,0.134 +2026-06-02T20:30:50Z,xml,Encounter,_count=1000,11,1451950,0.144 +2026-06-02T20:30:50Z,xml,Encounter,_count=1000,12,1451950,0.137 +2026-06-02T20:30:50Z,xml,Encounter,_count=1000,13,1451950,0.133 +2026-06-02T20:30:50Z,xml,Encounter,_count=1000,14,1451950,0.132 +2026-06-02T20:30:50Z,xml,Encounter,_count=1000,15,1451950,0.125 +2026-06-02T20:30:51Z,xml,Encounter,_count=1000,16,1451950,0.124 +2026-06-02T20:30:51Z,xml,Encounter,_count=1000,17,1451950,0.122 +2026-06-02T20:30:51Z,xml,Encounter,_count=1000,18,1451950,0.127 +2026-06-02T20:30:51Z,xml,Encounter,_count=1000,19,1451950,0.135 +2026-06-02T20:30:51Z,xml,Encounter,_count=1000,20,1451950,0.125 diff --git a/profiling/xml-paging/results-single-xml-fast-emit.csv b/profiling/xml-paging/results-single-xml-fast-emit.csv new file mode 100644 index 000000000..b5f10834c --- /dev/null +++ b/profiling/xml-paging/results-single-xml-fast-emit.csv @@ -0,0 +1,21 @@ +timestamp,format,type,query,run,bytes,seconds +2026-06-02T20:38:47Z,xml,Encounter,_count=1000,1,1479950,0.302 +2026-06-02T20:38:47Z,xml,Encounter,_count=1000,2,1479950,0.069 +2026-06-02T20:38:48Z,xml,Encounter,_count=1000,3,1479950,0.063 +2026-06-02T20:38:48Z,xml,Encounter,_count=1000,4,1479950,0.060 +2026-06-02T20:38:48Z,xml,Encounter,_count=1000,5,1479950,0.060 +2026-06-02T20:38:48Z,xml,Encounter,_count=1000,6,1479950,0.061 +2026-06-02T20:38:48Z,xml,Encounter,_count=1000,7,1479950,0.059 +2026-06-02T20:38:48Z,xml,Encounter,_count=1000,8,1479950,0.058 +2026-06-02T20:38:48Z,xml,Encounter,_count=1000,9,1479950,0.052 +2026-06-02T20:38:48Z,xml,Encounter,_count=1000,10,1479950,0.051 +2026-06-02T20:38:48Z,xml,Encounter,_count=1000,11,1479950,0.051 +2026-06-02T20:38:48Z,xml,Encounter,_count=1000,12,1479950,0.050 +2026-06-02T20:38:48Z,xml,Encounter,_count=1000,13,1479950,0.050 +2026-06-02T20:38:48Z,xml,Encounter,_count=1000,14,1479950,0.053 +2026-06-02T20:38:49Z,xml,Encounter,_count=1000,15,1479950,0.066 +2026-06-02T20:38:49Z,xml,Encounter,_count=1000,16,1479950,0.061 +2026-06-02T20:38:49Z,xml,Encounter,_count=1000,17,1479950,0.056 +2026-06-02T20:38:49Z,xml,Encounter,_count=1000,18,1479950,0.055 +2026-06-02T20:38:49Z,xml,Encounter,_count=1000,19,1479950,0.063 +2026-06-02T20:38:49Z,xml,Encounter,_count=1000,20,1479950,0.060 diff --git a/profiling/xml-paging/results-xml-buffered-count5000.csv b/profiling/xml-paging/results-xml-buffered-count5000.csv new file mode 100644 index 000000000..5a5788242 --- /dev/null +++ b/profiling/xml-paging/results-xml-buffered-count5000.csv @@ -0,0 +1,7 @@ +timestamp,format,type,query,run,pages,entries,bytes,seconds +2026-06-02T20:29:37Z,json,Encounter,_count=5000,1,12,58271,68198845,2.549 +2026-06-02T20:29:40Z,json,Encounter,_count=5000,2,12,58271,68198845,2.501 +2026-06-02T20:29:42Z,json,Encounter,_count=5000,3,12,58271,68198845,2.549 +2026-06-02T20:29:43Z,xml,Encounter,_count=5000,1,12,58271,82691302,9.218 +2026-06-02T20:29:52Z,xml,Encounter,_count=5000,2,12,58271,82691302,8.960 +2026-06-02T20:30:01Z,xml,Encounter,_count=5000,3,12,58271,82691302,8.986 diff --git a/profiling/xml-paging/results-xml-buffered.csv b/profiling/xml-paging/results-xml-buffered.csv new file mode 100644 index 000000000..e460d82c9 --- /dev/null +++ b/profiling/xml-paging/results-xml-buffered.csv @@ -0,0 +1,11 @@ +timestamp,format,type,query,run,pages,entries,bytes,seconds +2026-06-02T20:28:30Z,json,Encounter,_count=1000,1,59,58271,68219526,3.891 +2026-06-02T20:28:34Z,json,Encounter,_count=1000,2,59,58271,68219526,3.699 +2026-06-02T20:28:35Z,xml,Encounter,_count=1000,1,59,58271,82716918,10.997 +2026-06-02T20:28:38Z,json,Encounter,_count=1000,3,59,58271,68219526,3.629 +2026-06-02T20:28:41Z,json,Encounter,_count=1000,4,59,58271,68219526,3.631 +2026-06-02T20:28:45Z,json,Encounter,_count=1000,5,59,58271,68219526,3.615 +2026-06-02T20:28:45Z,xml,Encounter,_count=1000,2,59,58271,82716918,10.206 +2026-06-02T20:28:56Z,xml,Encounter,_count=1000,3,59,58271,82716918,10.111 +2026-06-02T20:29:06Z,xml,Encounter,_count=1000,4,59,58271,82716918,10.287 +2026-06-02T20:29:16Z,xml,Encounter,_count=1000,5,59,58271,82716918,10.081 diff --git a/profiling/xml-paging/results-xml-direct-context-count5000.csv b/profiling/xml-paging/results-xml-direct-context-count5000.csv new file mode 100644 index 000000000..f9f9817d2 --- /dev/null +++ b/profiling/xml-paging/results-xml-direct-context-count5000.csv @@ -0,0 +1,7 @@ +timestamp,format,type,query,run,pages,entries,bytes,seconds +2026-06-02T21:05:41Z,json,Encounter,_count=5000,1,12,58271,68198845,2.618 +2026-06-02T21:05:43Z,xml,Encounter,_count=5000,1,12,58271,84615914,3.633 +2026-06-02T21:05:44Z,json,Encounter,_count=5000,2,12,58271,68198845,2.520 +2026-06-02T21:05:46Z,json,Encounter,_count=5000,3,12,58271,68198845,2.497 +2026-06-02T21:05:47Z,xml,Encounter,_count=5000,2,12,58271,84615914,3.648 +2026-06-02T21:05:50Z,xml,Encounter,_count=5000,3,12,58271,84615914,3.589 diff --git a/profiling/xml-paging/results-xml-direct-context.csv b/profiling/xml-paging/results-xml-direct-context.csv new file mode 100644 index 000000000..5306d9cfe --- /dev/null +++ b/profiling/xml-paging/results-xml-direct-context.csv @@ -0,0 +1,11 @@ +timestamp,format,type,query,run,pages,entries,bytes,seconds +2026-06-02T21:04:54Z,json,Encounter,_count=1000,1,59,58271,68219526,4.088 +2026-06-02T21:04:55Z,xml,Encounter,_count=1000,1,59,58271,84641530,4.826 +2026-06-02T21:04:57Z,json,Encounter,_count=1000,2,59,58271,68219526,3.601 +2026-06-02T21:05:00Z,xml,Encounter,_count=1000,2,59,58271,84641530,4.703 +2026-06-02T21:05:01Z,json,Encounter,_count=1000,3,59,58271,68219526,3.672 +2026-06-02T21:05:05Z,json,Encounter,_count=1000,4,59,58271,68219526,3.546 +2026-06-02T21:05:05Z,xml,Encounter,_count=1000,3,59,58271,84641530,4.625 +2026-06-02T21:05:08Z,json,Encounter,_count=1000,5,59,58271,68219526,3.561 +2026-06-02T21:05:09Z,xml,Encounter,_count=1000,4,59,58271,84641530,4.625 +2026-06-02T21:05:14Z,xml,Encounter,_count=1000,5,59,58271,84641530,4.623 diff --git a/profiling/xml-paging/results-xml-fast-emit-count5000.csv b/profiling/xml-paging/results-xml-fast-emit-count5000.csv new file mode 100644 index 000000000..c35d338da --- /dev/null +++ b/profiling/xml-paging/results-xml-fast-emit-count5000.csv @@ -0,0 +1,4 @@ +timestamp,format,type,query,run,pages,entries,bytes,seconds +2026-06-02T20:48:26Z,xml,Encounter,_count=5000,1,12,58271,84322890,4.753 +2026-06-02T20:48:30Z,xml,Encounter,_count=5000,2,12,58271,84322890,4.498 +2026-06-02T20:48:35Z,xml,Encounter,_count=5000,3,12,58271,84322890,4.552 diff --git a/profiling/xml-paging/results-xml-fast-emit.csv b/profiling/xml-paging/results-xml-fast-emit.csv new file mode 100644 index 000000000..295977768 --- /dev/null +++ b/profiling/xml-paging/results-xml-fast-emit.csv @@ -0,0 +1,11 @@ +timestamp,format,type,query,run,pages,entries,bytes,seconds +2026-06-02T20:38:54Z,xml,Encounter,_count=1000,1,59,58271,84348506,5.978 +2026-06-02T20:39:00Z,xml,Encounter,_count=1000,2,59,58271,84348506,5.938 +2026-06-02T20:39:06Z,xml,Encounter,_count=1000,3,59,58271,84348506,5.732 +2026-06-02T20:39:12Z,xml,Encounter,_count=1000,4,59,58271,84348506,5.631 +2026-06-02T20:39:17Z,xml,Encounter,_count=1000,5,59,58271,84348506,5.665 +2026-06-02T20:48:24Z,json,Encounter,_count=1000,1,59,58271,68219526,3.892 +2026-06-02T20:48:27Z,json,Encounter,_count=1000,2,59,58271,68219526,3.698 +2026-06-02T20:48:31Z,json,Encounter,_count=1000,3,59,58271,68219526,3.580 +2026-06-02T20:48:34Z,json,Encounter,_count=1000,4,59,58271,68219526,3.579 +2026-06-02T20:48:38Z,json,Encounter,_count=1000,5,59,58271,68219526,3.559 diff --git a/profiling/xml-paging/run-blaze.sh b/profiling/xml-paging/run-blaze.sh new file mode 100755 index 000000000..a8180ed67 --- /dev/null +++ b/profiling/xml-paging/run-blaze.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash + +set -euo pipefail + +image="${1:-blaze:latest}" +container="${BLAZE_CONTAINER:-blaze-paging}" +volume="${BLAZE_DATA_VOLUME:-blaze-paging-data}" +port="${BLAZE_PORT:-8080}" +admin_port="${BLAZE_ADMIN_PORT:-8081}" +heap="${BLAZE_HEAP:-4g}" + +if docker ps -a --format '{{.Names}}' | grep -qx "$container"; then + docker rm -f "$container" >/dev/null +fi + +docker volume create "$volume" >/dev/null + +docker run \ + --name "$container" \ + -d \ + -e "JAVA_TOOL_OPTIONS=-Xmx${heap}" \ + -e ENABLE_ADMIN_API=true \ + -p "${port}:8080" \ + -p "${admin_port}:8081" \ + -v "${volume}:/app/data" \ + "$image" >/dev/null + +url="http://localhost:${port}/health" +printf 'Waiting for %s' "$url" +for _ in $(seq 1 120); do + if curl -fsS "$url" >/dev/null 2>&1; then + printf '\n' + echo "Blaze is ready: http://localhost:${port}/fhir" + exit 0 + fi + printf '.' + sleep 1 +done + +printf '\n' >&2 +docker logs "$container" >&2 || true +echo "Blaze didn't become healthy in time." >&2 +exit 1 diff --git a/profiling/xml-paging/run-standalone.sh b/profiling/xml-paging/run-standalone.sh new file mode 100755 index 000000000..7213a9eb3 --- /dev/null +++ b/profiling/xml-paging/run-standalone.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash + +set -euo pipefail + +jar="${1:-target/blaze-1.7.0-standalone.jar}" +runtime_dir="${BLAZE_RUNTIME_DIR:-profiling/xml-paging/runtime}" +data_dir="${BLAZE_DATA_DIR:-${runtime_dir}/data}" +port="${BLAZE_PORT:-8080}" +admin_port="${BLAZE_ADMIN_PORT:-8081}" +heap="${BLAZE_HEAP:-4g}" +pid_file="${runtime_dir}/blaze.pid" +log_file="${runtime_dir}/blaze.log" + +if [[ ! -f "$jar" ]]; then + echo "Jar not found: $jar" >&2 + echo "Build it first with: make uberjar" >&2 + exit 1 +fi + +mkdir -p "$runtime_dir" "$data_dir" + +if [[ -f "$pid_file" ]]; then + old_pid="$(cat "$pid_file")" + if kill -0 "$old_pid" 2>/dev/null; then + kill "$old_pid" + while kill -0 "$old_pid" 2>/dev/null; do + sleep 1 + done + fi + rm -f "$pid_file" +fi + +export STORAGE=standalone +export BASE_URL="http://localhost:${port}/fhir" +export SERVER_PORT="$port" +export ADMIN_SERVER_PORT="$admin_port" +export ENABLE_ADMIN_API=true +export INDEX_DB_DIR="${data_dir}/index" +export TRANSACTION_DB_DIR="${data_dir}/transaction" +export RESOURCE_DB_DIR="${data_dir}/resource" +export ADMIN_INDEX_DB_DIR="${data_dir}/admin-index" +export ADMIN_TRANSACTION_DB_DIR="${data_dir}/admin-transaction" +export JAVA_TOOL_OPTIONS="-Xmx${heap} ${JAVA_TOOL_OPTIONS:-}" + +nohup java -jar "$jar" >"$log_file" 2>&1 & +echo "$!" > "$pid_file" + +url="http://localhost:${port}/health" +printf 'Waiting for %s' "$url" +for _ in $(seq 1 120); do + if curl -fsS "$url" >/dev/null 2>&1; then + printf '\n' + echo "Blaze is ready: http://localhost:${port}/fhir" + echo "Log: $log_file" + exit 0 + fi + printf '.' + sleep 1 +done + +printf '\n' >&2 +tail -100 "$log_file" >&2 || true +echo "Blaze didn't become healthy in time." >&2 +exit 1 diff --git a/profiling/xml-paging/upload-test-data.sh b/profiling/xml-paging/upload-test-data.sh new file mode 100755 index 000000000..a3ea21e32 --- /dev/null +++ b/profiling/xml-paging/upload-test-data.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash + +set -euo pipefail + +server="${BLAZE_SERVER:-http://localhost:8080/fhir}" +data_dir="${1:-/Users/axs/Projekte/kerndatensatz-testdaten/Test_Data}" + +if [[ ! -d "$data_dir" ]]; then + echo "Data directory not found: $data_dir" >&2 + exit 1 +fi + +blazectl --no-progress --server "$server" upload "$data_dir" +blazectl --server "$server" count-resources From 37484fb58775e1670e4c6a37f89ab613d9933a28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Stru=CC=88bing?= Date: Tue, 9 Jun 2026 10:04:01 +0200 Subject: [PATCH 2/7] Reduce generic XML writer overhead Precompute tag fragments, streamline repeated field writing, avoid unnecessary sequence allocation and use a faster XML escaping helper in the hot path. --- .../java/blaze/ReducibleArray.java | 4 + .../java/blaze/fhir/XmlUtil.java | 63 ++++++ .../src/blaze/fhir/writing_context.clj | 187 +++++++++++------- .../test/blaze/fhir/writing_context_test.clj | 14 +- 4 files changed, 191 insertions(+), 77 deletions(-) create mode 100644 modules/fhir-structure/java/blaze/fhir/XmlUtil.java diff --git a/modules/fhir-structure/java/blaze/ReducibleArray.java b/modules/fhir-structure/java/blaze/ReducibleArray.java index 2cb36e176..3c917afd4 100644 --- a/modules/fhir-structure/java/blaze/ReducibleArray.java +++ b/modules/fhir-structure/java/blaze/ReducibleArray.java @@ -15,6 +15,10 @@ public ReducibleArray(List list) { this.items = list.toArray(); } + public Object[] array() { + return items; + } + public Object reduce(IFn f, Object init) { for (Object item : items) { init = f.invoke(init, item); diff --git a/modules/fhir-structure/java/blaze/fhir/XmlUtil.java b/modules/fhir-structure/java/blaze/fhir/XmlUtil.java new file mode 100644 index 000000000..7f5ab7913 --- /dev/null +++ b/modules/fhir-structure/java/blaze/fhir/XmlUtil.java @@ -0,0 +1,63 @@ +package blaze.fhir; + +import java.io.IOException; +import java.io.Writer; + +public final class XmlUtil { + + private XmlUtil() { + } + + private static boolean validXmlChar(char c) { + return c == 0x09 || c == 0x0A || c == 0x0D || + (0x20 <= c && c <= 0xD7FF) || + (0xE000 <= c && c <= 0xFFFD); + } + + public static void writeEscaped(Writer writer, String s) throws IOException { + int len = s.length(); + int start = 0; + for (int i = 0; i < len; i++) { + char c = s.charAt(i); + switch (c) { + case '&' -> { + if (start < i) writer.write(s, start, i - start); + writer.write("&"); + start = i + 1; + } + case '<' -> { + if (start < i) writer.write(s, start, i - start); + writer.write("<"); + start = i + 1; + } + case '>' -> { + if (start < i) writer.write(s, start, i - start); + writer.write(">"); + start = i + 1; + } + case '"' -> { + if (start < i) writer.write(s, start, i - start); + writer.write("""); + start = i + 1; + } + default -> { + if (Character.isHighSurrogate(c)) { + int j = i + 1; + if (j < len && Character.isLowSurrogate(s.charAt(j))) { + i = j; + } else { + if (start < i) writer.write(s, start, i - start); + writer.write("?"); + start = j; + } + } else if (Character.isLowSurrogate(c) || !validXmlChar(c)) { + if (start < i) writer.write(s, start, i - start); + writer.write("?"); + start = i + 1; + } + } + } + } + if (start < len) writer.write(s, start, len - start); + } +} diff --git a/modules/fhir-structure/src/blaze/fhir/writing_context.clj b/modules/fhir-structure/src/blaze/fhir/writing_context.clj index 1a944c3ce..2b19e9613 100644 --- a/modules/fhir-structure/src/blaze/fhir/writing_context.clj +++ b/modules/fhir-structure/src/blaze/fhir/writing_context.clj @@ -16,8 +16,10 @@ [taoensso.timbre :as log]) (:import [blaze ReducibleArray] + [blaze.fhir XmlUtil] [blaze.fhir.spec.type Base Complex FieldName Primitive Xhtml] [clojure.data.xml.node Element] + [clojure.lang IPersistentMap Indexed] [com.fasterxml.jackson.core JsonGenerator SerializableString] [java.io Writer])) @@ -169,61 +171,8 @@ {} (res/separate-element-definitions type more))) -(defn- valid-xml-char? [^long c] - (or (== 0x09 c) (== 0x0A c) (== 0x0D c) - (and (<= 0x20 c) (<= c 0xD7FF)) - (and (<= 0xE000 c) (<= c 0xFFFD)))) - (defn- write-xml-str [^Writer writer s] - (let [^String s (str s) - len (.length s)] - (loop [i 0 - start 0] - (if (< i len) - (let [c (.charAt s i)] - (case c - \& (do - (when (< start i) - (.write writer s start (- i start))) - (.write writer "&") - (recur (inc i) (inc i))) - \< (do - (when (< start i) - (.write writer s start (- i start))) - (.write writer "<") - (recur (inc i) (inc i))) - \> (do - (when (< start i) - (.write writer s start (- i start))) - (.write writer ">") - (recur (inc i) (inc i))) - \" (do - (when (< start i) - (.write writer s start (- i start))) - (.write writer """) - (recur (inc i) (inc i))) - (cond - (Character/isHighSurrogate c) - (let [i' (inc i)] - (if (and (< i' len) (Character/isLowSurrogate (.charAt s i'))) - (recur (inc i') start) - (do - (when (< start i) - (.write writer s start (- i start))) - (.write writer "?") - (recur i' i')))) - - (or (Character/isLowSurrogate c) (not (valid-xml-char? (int c)))) - (do - (when (< start i) - (.write writer s start (- i start))) - (.write writer "?") - (recur (inc i) (inc i))) - - :else - (recur (inc i) start)))) - (when (< start len) - (.write writer s start (- len start))))))) + (XmlUtil/writeEscaped writer (str s))) (defn- write-xml-attr [^Writer writer k v] (.write writer " ") @@ -235,7 +184,15 @@ (defn- write-start-tag [^Writer writer tag attrs] (.write writer "<") (.write writer ^String tag) - (run! (fn [[k v]] (write-xml-attr writer k v)) attrs)) + (when attrs + (run! (fn [[k v]] (write-xml-attr writer k v)) attrs))) + +(defn- write-resource-start-tag [^Writer writer tag] + (.write writer "<") + (.write writer ^String tag) + (.write writer " xmlns=\"") + (.write writer fhir-namespace) + (.write writer "\"")) (declare write-element-tree!) @@ -284,20 +241,55 @@ (write-end-tag writer tag)) (.write writer "/>"))))) +(defn- write-primitive-field! + [xml-handlers ^Writer writer ^String start-tag ^String end-tag ^String value-start-tag ^Primitive value] + (if (instance? Xhtml value) + (write-element-tree! writer (type/xhtml-to-xml value)) + (let [id (.id value) + string-value (.valueAsString value) + extensions (.extension value)] + (if (and (nil? id) string-value (empty? extensions)) + (do + (.write writer value-start-tag) + (write-xml-str writer string-value) + (.write writer "\"/>")) + (do + (.write writer start-tag) + (when id + (write-xml-attr writer :id id)) + (when string-value + (write-xml-attr writer :value string-value)) + (if (seq extensions) + (do + (.write writer ">") + (run! #(write-value! xml-handlers writer "extension" %) extensions) + (.write writer end-tag)) + (.write writer "/>"))))))) + (defn- write-system-string! [^Writer writer tag value] (write-start-tag writer tag {:value value}) (.write writer "/>")) +(defn- write-system-string-field! [^Writer writer ^String value-start-tag value] + (.write writer value-start-tag) + (write-xml-str writer value) + (.write writer "\"/>")) + (defn- write-resource-wrapper! [xml-handlers ^Writer writer value] (write-start-tag writer "resource" nil) (.write writer ">") (write-value! xml-handlers writer nil value) (write-end-tag writer "resource")) +(defn- fhir-type [value] + (when (instance? IPersistentMap value) + (.valAt ^IPersistentMap value Base/FHIR_TYPE_KEY))) + (defn- xml-type-tag [base-tag value] - (str base-tag (su/capital (name (:fhir/type value))))) + (str base-tag (su/capital (name (fhir-type value))))) -(deftype XmlPropertyHandler [key tag polymorphic type resource-wrapper]) +(deftype XmlPropertyHandler + [key tag polymorphic-tags type resource-wrapper start-tag end-tag value-start-tag]) (defn- xml-type-keyword [path code] (cond @@ -321,12 +313,22 @@ (XmlPropertyHandler. (keyword base-tag) base-tag - polymorphic + (when polymorphic + (into + {} + (map + (fn [{:keys [code]}] + [(xml-type-keyword path code) + (str base-tag (su/capital code))])) + element-types)) (if content-reference (fhir-type-keyword (subs content-reference 1)) (when (= 1 (count element-types)) (xml-type-keyword path first-type-code))) - (= "Resource" first-type-code)))) + (= "Resource" first-type-code) + (str "<" base-tag) + (str "") + (str "<" base-tag " value=\"")))) (defn- create-xml-property-handlers [type element-definitions] (ReducibleArray. (map (partial xml-property-handler-definition type) element-definitions))) @@ -336,31 +338,66 @@ (.-resource-wrapper property-handler) (write-resource-wrapper! xml-handlers writer value) - (.-polymorphic property-handler) - (write-value! xml-handlers writer (xml-type-tag (.-tag property-handler) value) value) + (.-polymorphic-tags property-handler) + (write-value! + xml-handlers writer + (or ((.-polymorphic-tags property-handler) (fhir-type value)) + (xml-type-tag (.-tag property-handler) value)) + value) + + (instance? Primitive value) + (write-primitive-field! + xml-handlers writer + (.-start-tag property-handler) + (.-end-tag property-handler) + (.-value-start-tag property-handler) + value) + + (string? value) + (write-system-string-field! writer (.-value-start-tag property-handler) value) :else (write-value! xml-handlers writer (.-tag property-handler) value))) +(defn- write-xml-field-values! [xml-handlers writer ^XmlPropertyHandler property-handler value] + (if (instance? Indexed value) + (let [len (count value)] + (loop [i 0] + (when (< i len) + (write-xml-field-1! xml-handlers writer property-handler (nth value i)) + (recur (inc i))))) + (run! #(write-xml-field-1! xml-handlers writer property-handler %) value))) + (defn- write-xml-field! [xml-handlers writer ^XmlPropertyHandler property-handler value] (if (sequential? value) - (run! #(write-xml-field-1! xml-handlers writer property-handler %) value) + (write-xml-field-values! xml-handlers writer property-handler value) (write-xml-field-1! xml-handlers writer property-handler value))) -(defn- write-xml-fields! [xml-handlers writer property-handlers m] - (run! - (fn [property-handler] - (when-some [value (get m (.-key ^XmlPropertyHandler property-handler))] - (write-xml-field! xml-handlers writer property-handler value))) - property-handlers)) +(defn- write-xml-fields! [xml-handlers ^Writer writer property-handlers m] + (let [property-handlers (.array ^ReducibleArray property-handlers) + len (alength property-handlers)] + (loop [i 0 wrote? false] + (if (< i len) + (let [property-handler (aget property-handlers i)] + (if-some [value (.valAt ^IPersistentMap m (.-key ^XmlPropertyHandler property-handler))] + (do + (when-not wrote? + (.write writer ">")) + (write-xml-field! xml-handlers writer property-handler value) + (recur (inc i) true)) + (recur (inc i) wrote?))) + wrote?)))) (defn- write-complex! [xml-handlers ^Writer writer tag attrs property-handlers value] (write-start-tag writer tag attrs) - (if (seq value) - (do - (.write writer ">") - (write-xml-fields! xml-handlers writer property-handlers value) - (write-end-tag writer tag)) + (if (write-xml-fields! xml-handlers writer property-handlers value) + (write-end-tag writer tag) + (.write writer "/>"))) + +(defn- write-resource! [xml-handlers ^Writer writer tag property-handlers value] + (write-resource-start-tag writer tag) + (if (write-xml-fields! xml-handlers writer property-handlers value) + (write-end-tag writer tag) (.write writer "/>"))) (defn- create-xml-type-handler [kind type element-definitions] @@ -368,7 +405,7 @@ (case kind :resource (fn resource-xml-handler [xml-handlers ^Writer writer value] - (write-complex! xml-handlers writer type {:xmlns fhir-namespace} property-handlers value)) + (write-resource! xml-handlers writer type property-handlers value)) :complex-type (fn complex-xml-handler [xml-handlers ^Writer writer tag value] (write-complex! xml-handlers writer tag nil property-handlers value))))) @@ -392,7 +429,7 @@ (write-system-string! writer tag value) :else - (if-some [handler (xml-handlers (:fhir/type value))] + (if-some [handler (xml-handlers (fhir-type value))] (if tag (handler xml-handlers writer tag value) (handler xml-handlers writer value)) diff --git a/modules/fhir-structure/test/blaze/fhir/writing_context_test.clj b/modules/fhir-structure/test/blaze/fhir/writing_context_test.clj index 31be5003e..23d3b286b 100644 --- a/modules/fhir-structure/test/blaze/fhir/writing_context_test.clj +++ b/modules/fhir-structure/test/blaze/fhir/writing_context_test.clj @@ -5,15 +5,25 @@ [blaze.test-util :as tu] [clojure.spec.alpha :as s] [clojure.spec.test.alpha :as st] - [clojure.test :as test :refer [deftest testing]] + [clojure.test :as test :refer [deftest is testing]] [integrant.core :as ig] - [taoensso.timbre :as log])) + [taoensso.timbre :as log]) + (:import + [blaze ReducibleArray] + [java.util ArrayList])) (st/instrument) +(set! *warn-on-reflection* true) (log/set-min-level! :trace) (test/use-fixtures :each tu/fixture) +(deftest reducible-array-test + (let [list (doto (ArrayList.) + (.add :a) + (.add :b))] + (is (= [:a :b] (vec (.array (ReducibleArray. list))))))) + (deftest init-test (testing "nil config" (given-failed-system {:blaze.fhir/writing-context nil} From cddb538152d50e8e51f6d3fdbea894729ac80a6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Stru=CC=88bing?= Date: Tue, 9 Jun 2026 10:04:13 +0200 Subject: [PATCH 3/7] Specialize XML search bundle entries Add a fast path for common search Bundle.entry shapes to avoid generic XML field iteration for fullUrl, resource and search metadata. --- .../src/blaze/fhir/writing_context.clj | 60 ++++++++++++++++--- .../test/blaze/fhir/spec_test.clj | 54 +++++++++++++++++ 2 files changed, 106 insertions(+), 8 deletions(-) diff --git a/modules/fhir-structure/src/blaze/fhir/writing_context.clj b/modules/fhir-structure/src/blaze/fhir/writing_context.clj index 2b19e9613..8bad1757b 100644 --- a/modules/fhir-structure/src/blaze/fhir/writing_context.clj +++ b/modules/fhir-structure/src/blaze/fhir/writing_context.clj @@ -17,7 +17,7 @@ (:import [blaze ReducibleArray] [blaze.fhir XmlUtil] - [blaze.fhir.spec.type Base Complex FieldName Primitive Xhtml] + [blaze.fhir.spec.type Base BundleEntrySearch Complex FieldName Primitive Xhtml] [clojure.data.xml.node Element] [clojure.lang IPersistentMap Indexed] [com.fasterxml.jackson.core JsonGenerator SerializableString] @@ -281,6 +281,45 @@ (write-value! xml-handlers writer nil value) (write-end-tag writer "resource")) +(defn- write-bundle-entry-search! [xml-handlers ^Writer writer ^BundleEntrySearch search] + (when (and (nil? (.id search)) (empty? (.extension search))) + (.write writer "") + (when-some [mode (.mode search)] + (write-primitive-field! xml-handlers writer "" "" "")) + (.write writer "/>")) + true)) + +(defn- write-search-bundle-entry! [xml-handlers ^Writer writer value] + (let [m ^IPersistentMap value + full-url (.valAt m :fullUrl) + resource (.valAt m :resource) + search (.valAt m :search)] + (when (and (nil? (.valAt m :id)) + (nil? (.valAt m :extension)) + (nil? (.valAt m :modifierExtension)) + (nil? (.valAt m :link)) + (nil? (.valAt m :request)) + (nil? (.valAt m :response)) + (or (nil? search) (instance? BundleEntrySearch search)) + (or full-url resource search)) + (.write writer "") + (when full-url + (write-primitive-field! xml-handlers writer "" "") + true))) + (defn- fhir-type [value] (when (instance? IPersistentMap value) (.valAt ^IPersistentMap value Base/FHIR_TYPE_KEY))) @@ -402,13 +441,18 @@ (defn- create-xml-type-handler [kind type element-definitions] (let [property-handlers (create-xml-property-handlers type element-definitions)] - (case kind - :resource - (fn resource-xml-handler [xml-handlers ^Writer writer value] - (write-resource! xml-handlers writer type property-handlers value)) - :complex-type - (fn complex-xml-handler [xml-handlers ^Writer writer tag value] - (write-complex! xml-handlers writer tag nil property-handlers value))))) + (if (= "Bundle.entry" type) + (fn bundle-entry-xml-handler [xml-handlers ^Writer writer tag value] + (if (and (= "entry" tag) (write-search-bundle-entry! xml-handlers writer value)) + nil + (write-complex! xml-handlers writer tag nil property-handlers value))) + (case kind + :resource + (fn resource-xml-handler [xml-handlers ^Writer writer value] + (write-resource! xml-handlers writer type property-handlers value)) + :complex-type + (fn complex-xml-handler [xml-handlers ^Writer writer tag value] + (write-complex! xml-handlers writer tag nil property-handlers value)))))) (defn- create-xml-type-handlers "Creates a map of keyword type names to XML type-handlers." diff --git a/modules/fhir-structure/test/blaze/fhir/spec_test.clj b/modules/fhir-structure/test/blaze/fhir/spec_test.clj index dbe81f8dc..74e6eb0e8 100644 --- a/modules/fhir-structure/test/blaze/fhir/spec_test.clj +++ b/modules/fhir-structure/test/blaze/fhir/spec_test.clj @@ -94,6 +94,11 @@ :blaze.fhir/writing-context {:structure-definition-repo structure-definition-repo})) +(defn- write-xml-as-string [x] + (let [out (ByteArrayOutputStream.)] + (fhir-spec/write-xml writing-context out x) + (String. (.toByteArray out) StandardCharsets/UTF_8))) + (deftest write-xml-test (let [out (ByteArrayOutputStream.) patient {:fhir/type :fhir/Patient :id "0"}] @@ -126,6 +131,55 @@ (is (nil? (fhir-spec/write-xml writing-context out patient))) (is (= patient (read-xml (.toByteArray out)))))) +(deftest write-xml-bundle-entry-test + (testing "search entry" + (is (= (str "" + "" + "" + "" + "" + "" + "" + "") + (write-xml-as-string + {:fhir/type :fhir/Bundle + :entry [{:fhir/type :fhir.Bundle/entry + :fullUrl #fhir/uri "http://example.com/fhir/Patient/0" + :resource {:fhir/type :fhir/Patient :id "0"} + :search #fhir.Bundle.entry/search{}}]})))) + + (testing "search mode and score" + (is (= (str "" + "" + "" + "" + "" + "" + "" + "" + "") + (write-xml-as-string + {:fhir/type :fhir/Bundle + :entry [{:fhir/type :fhir.Bundle/entry + :search #fhir.Bundle.entry/search{:mode #fhir/code "match" + :score #fhir/decimal 1.1M}}]})))) + + (testing "search with extensions falls back to the general writer" + (let [bundle {:fhir/type :fhir/Bundle + :entry [{:fhir/type :fhir.Bundle/entry + :search #fhir.Bundle.entry/search + {:extension [#fhir/Extension + {:url "http://example.com/fhir/StructureDefinition/foo"}]}}]}] + (is (= (str "" + "" + "" + "" + "" + "" + "" + "") + (write-xml-as-string bundle)))))) + (defn- write-json [x] (fhir-spec/write-json-as-bytes writing-context x)) From 53928a04a4133f70fe50c5a1ba8bc14ced2779b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Stru=CC=88bing?= Date: Tue, 9 Jun 2026 10:04:38 +0200 Subject: [PATCH 4/7] Add direct XML writers for common complex types Introduce XmlDirectWriter and dispatch selected FHIR complex types through direct XML serialization with regression coverage for Period values. --- .../blaze/fhir/spec/type/XmlDirectWriter.java | 146 ++++++++++++++++++ .../src/blaze/fhir/writing_context.clj | 33 +++- .../test/blaze/fhir/spec_test.clj | 142 ++++++++++++++++- 3 files changed, 318 insertions(+), 3 deletions(-) create mode 100644 modules/fhir-structure/java/blaze/fhir/spec/type/XmlDirectWriter.java diff --git a/modules/fhir-structure/java/blaze/fhir/spec/type/XmlDirectWriter.java b/modules/fhir-structure/java/blaze/fhir/spec/type/XmlDirectWriter.java new file mode 100644 index 000000000..089ddd2e9 --- /dev/null +++ b/modules/fhir-structure/java/blaze/fhir/spec/type/XmlDirectWriter.java @@ -0,0 +1,146 @@ +package blaze.fhir.spec.type; + +import blaze.fhir.XmlUtil; + +import java.io.IOException; +import java.io.Writer; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; + +public final class XmlDirectWriter { + + private XmlDirectWriter() { + } + + private static boolean canWritePrimitive(Primitive value) { + return value == null || value.id() == null && value.extension().isEmpty(); + } + + private static boolean canWriteCoding(Coding value) { + return value.id() == null && value.extension().isEmpty() && + canWritePrimitive(value.system()) && + canWritePrimitive(value.version()) && + canWritePrimitive(value.code()) && + canWritePrimitive(value.display()) && + canWritePrimitive(value.userSelected()); + } + + private static boolean canWriteCodeableConcept(CodeableConcept value) { + if (value.id() != null || !value.extension().isEmpty() || !canWritePrimitive(value.text())) { + return false; + } + for (Coding coding : value.coding()) { + if (!canWriteCoding(coding)) { + return false; + } + } + return true; + } + + private static boolean canWritePeriod(Period value) { + return value.id() == null && value.extension().isEmpty() && + canWritePrimitive(value.start()) && + canWritePrimitive(value.end()); + } + + private static void writePrimitiveField(Writer writer, java.lang.String tag, Primitive value) throws IOException { + if (value == null) return; + var stringValue = value.valueAsString(); + if (stringValue == null) { + writer.write("<"); + writer.write(tag); + writer.write("/>"); + } else { + writer.write("<"); + writer.write(tag); + writer.write(" value=\""); + XmlUtil.writeEscaped(writer, stringValue); + writer.write("\"/>"); + } + } + + private static void writeDateTimeField(Writer writer, java.lang.String tag, DateTime value) throws IOException { + if (value == null) return; + var dateTime = value.value(); + if (dateTime == null) { + writer.write("<"); + writer.write(tag); + writer.write("/>"); + } else { + writer.write("<"); + writer.write(tag); + writer.write(" value=\""); + switch (dateTime) { + case LocalDateTime localDateTime -> + blaze.fhir.spec.type.system.DateTime.LOCAL_DATE_TIME.formatTo(localDateTime, writer); + case OffsetDateTime offsetDateTime -> + blaze.fhir.spec.type.system.DateTime.DATE_TIME.formatTo(offsetDateTime, writer); + default -> writer.write(dateTime.toString()); + } + writer.write("\"/>"); + } + } + + public static boolean writeCoding(Writer writer, java.lang.String tag, Coding value) throws IOException { + if (!canWriteCoding(value)) { + return false; + } + writer.write("<"); + writer.write(tag); + if (value.system() == null && value.version() == null && value.code() == null && + value.display() == null && value.userSelected() == null) { + writer.write("/>"); + } else { + writer.write(">"); + writePrimitiveField(writer, "system", value.system()); + writePrimitiveField(writer, "version", value.version()); + writePrimitiveField(writer, "code", value.code()); + writePrimitiveField(writer, "display", value.display()); + writePrimitiveField(writer, "userSelected", value.userSelected()); + writer.write(""); + } + return true; + } + + public static boolean writeCodeableConcept(Writer writer, java.lang.String tag, CodeableConcept value) throws IOException { + if (!canWriteCodeableConcept(value)) { + return false; + } + writer.write("<"); + writer.write(tag); + if (value.coding().isEmpty() && value.text() == null) { + writer.write("/>"); + } else { + writer.write(">"); + for (Coding coding : value.coding()) { + writeCoding(writer, "coding", coding); + } + writePrimitiveField(writer, "text", value.text()); + writer.write(""); + } + return true; + } + + public static boolean writePeriod(Writer writer, java.lang.String tag, Period value) throws IOException { + if (!canWritePeriod(value)) { + return false; + } + writer.write("<"); + writer.write(tag); + if (value.start() == null && value.end() == null) { + writer.write("/>"); + } else { + writer.write(">"); + writeDateTimeField(writer, "start", value.start()); + writeDateTimeField(writer, "end", value.end()); + writer.write(""); + } + return true; + } +} diff --git a/modules/fhir-structure/src/blaze/fhir/writing_context.clj b/modules/fhir-structure/src/blaze/fhir/writing_context.clj index 8bad1757b..332d56ba1 100644 --- a/modules/fhir-structure/src/blaze/fhir/writing_context.clj +++ b/modules/fhir-structure/src/blaze/fhir/writing_context.clj @@ -17,7 +17,8 @@ (:import [blaze ReducibleArray] [blaze.fhir XmlUtil] - [blaze.fhir.spec.type Base BundleEntrySearch Complex FieldName Primitive Xhtml] + [blaze.fhir.spec.type Base BundleEntrySearch CodeableConcept Coding Complex FieldName + Period Primitive Xhtml XmlDirectWriter] [clojure.data.xml.node Element] [clojure.lang IPersistentMap Indexed] [com.fasterxml.jackson.core JsonGenerator SerializableString] @@ -395,6 +396,18 @@ (string? value) (write-system-string-field! writer (.-value-start-tag property-handler) value) + (identical? :fhir/CodeableConcept (.-type property-handler)) + (when-not (XmlDirectWriter/writeCodeableConcept writer (.-tag property-handler) ^CodeableConcept value) + (write-value! xml-handlers writer (.-tag property-handler) value)) + + (identical? :fhir/Coding (.-type property-handler)) + (when-not (XmlDirectWriter/writeCoding writer (.-tag property-handler) ^Coding value) + (write-value! xml-handlers writer (.-tag property-handler) value)) + + (identical? :fhir/Period (.-type property-handler)) + (when-not (XmlDirectWriter/writePeriod writer (.-tag property-handler) ^Period value) + (write-value! xml-handlers writer (.-tag property-handler) value)) + :else (write-value! xml-handlers writer (.-tag property-handler) value))) @@ -472,6 +485,24 @@ (string? value) (write-system-string! writer tag value) + (and tag (instance? CodeableConcept value)) + (when-not (XmlDirectWriter/writeCodeableConcept writer tag value) + (if-some [handler (xml-handlers (fhir-type value))] + (handler xml-handlers writer tag value) + (throw (IllegalArgumentException. (format "Value `%s` is no supported FHIR XML type." value))))) + + (and tag (instance? Coding value)) + (when-not (XmlDirectWriter/writeCoding writer tag value) + (if-some [handler (xml-handlers (fhir-type value))] + (handler xml-handlers writer tag value) + (throw (IllegalArgumentException. (format "Value `%s` is no supported FHIR XML type." value))))) + + (and tag (instance? Period value)) + (when-not (XmlDirectWriter/writePeriod writer tag value) + (if-some [handler (xml-handlers (fhir-type value))] + (handler xml-handlers writer tag value) + (throw (IllegalArgumentException. (format "Value `%s` is no supported FHIR XML type." value))))) + :else (if-some [handler (xml-handlers (fhir-type value))] (if tag diff --git a/modules/fhir-structure/test/blaze/fhir/spec_test.clj b/modules/fhir-structure/test/blaze/fhir/spec_test.clj index 74e6eb0e8..f1145f25c 100644 --- a/modules/fhir-structure/test/blaze/fhir/spec_test.clj +++ b/modules/fhir-structure/test/blaze/fhir/spec_test.clj @@ -32,11 +32,11 @@ [jsonista.core :as j] [juxt.iota :refer [given]]) (:import - [blaze.fhir.spec.type Base] + [blaze.fhir.spec.type Base XmlDirectWriter] [blaze.fhir.spec.type.system DateTime Times] [com.fasterxml.jackson.dataformat.cbor CBORFactory] [com.google.common.hash Hashing] - [java.io ByteArrayInputStream ByteArrayOutputStream] + [java.io ByteArrayInputStream ByteArrayOutputStream StringWriter] [java.nio.charset StandardCharsets])) (xml-name/alias-uri 'f "http://hl7.org/fhir") @@ -180,6 +180,144 @@ "") (write-xml-as-string bundle)))))) +(deftest write-xml-direct-complex-type-fields-test + (testing "non-polymorphic fields" + (let [out (ByteArrayOutputStream.) + patient {:fhir/type :fhir/Patient + :meta #fhir/Meta + {:security + [#fhir/Coding + {:system #fhir/uri "http://terminology.hl7.org/CodeSystem/v3-ActReason" + :code #fhir/code "HTEST"}]} + :name [#fhir/HumanName + {:period #fhir/Period + {:start #fhir/dateTime #system/date-time "2019-01-01T00:00:00+01:00" + :end #fhir/dateTime #system/date-time "2048-12-31T00:00:00+01:00"}}]}] + (is (nil? (fhir-spec/write-xml writing-context out patient))) + (is (= patient (read-xml (.toByteArray out)))))) + + (testing "polymorphic Period field" + (let [out (ByteArrayOutputStream.) + observation {:fhir/type :fhir/Observation + :status #fhir/code "final" + :code #fhir/CodeableConcept + {:coding + [#fhir/Coding + {:system #fhir/uri "http://loinc.org" + :code #fhir/code "718-7"}]} + :effective #fhir/Period + {:start #fhir/dateTime #system/date-time "2019-01-01T00:00:00+01:00" + :end #fhir/dateTime #system/date-time "2048-12-31T00:00:00+01:00"}}] + (is (nil? (fhir-spec/write-xml writing-context out observation))) + (is (= observation (read-xml (.toByteArray out))))))) + +(deftest write-xml-direct-complex-type-fallback-test + (testing "non-polymorphic fields with extensions" + (let [patient {:fhir/type :fhir/Patient + :meta #fhir/Meta + {:security + [#fhir/Coding + {:extension [#fhir/Extension + {:url "http://example.com/fhir/StructureDefinition/coding"}] + :system #fhir/uri "http://terminology.hl7.org/CodeSystem/v3-ActReason" + :code #fhir/code "HTEST"}]} + :name [#fhir/HumanName + {:period #fhir/Period + {:extension [#fhir/Extension + {:url "http://example.com/fhir/StructureDefinition/period"}] + :start #fhir/dateTime #system/date-time "2019-01-01T00:00:00+01:00"}}]} + ^String xml (write-xml-as-string patient)] + (is (.contains xml "")) + (is (.contains xml "")))) + + (testing "CodeableConcept and polymorphic Period with extensions" + (let [observation {:fhir/type :fhir/Observation + :status #fhir/code "final" + :code #fhir/CodeableConcept + {:extension [#fhir/Extension + {:url "http://example.com/fhir/StructureDefinition/code"}] + :coding + [#fhir/Coding + {:system #fhir/uri "http://loinc.org" + :code #fhir/code "718-7"}]} + :effective #fhir/Period + {:extension [#fhir/Extension + {:url "http://example.com/fhir/StructureDefinition/effective"}] + :start #fhir/dateTime #system/date-time "2019-01-01T00:00:00+01:00"}} + ^String xml (write-xml-as-string observation)] + (is (.contains xml "")) + (is (.contains xml ""))))) + +(deftest write-xml-codeable-concept-direct-test + (testing "simple CodeableConcept" + (let [writer (StringWriter.)] + (is (true? (XmlDirectWriter/writeCodeableConcept + writer + "code" + #fhir/CodeableConcept + {:coding + [#fhir/Coding + {:system #fhir/uri "http://loinc.org" + :code #fhir/code "718-7" + :display #fhir/string "Hemoglobin [Mass/volume] & friends"}] + :text #fhir/string "Hemoglobin "}))) + (is (= (str "" + "" + "" + "" + "" + "" + "" + "") + (str writer))))) + + (testing "extended CodeableConcept falls back before writing" + (let [writer (StringWriter.)] + (is (false? (XmlDirectWriter/writeCodeableConcept + writer + "code" + #fhir/CodeableConcept + {:extension [#fhir/Extension + {:url "http://example.com/fhir/StructureDefinition/foo"}]}))) + (is (= "" (str writer)))))) + +(deftest write-xml-period-direct-test + (testing "simple Period" + (let [writer (StringWriter.)] + (is (true? (XmlDirectWriter/writePeriod + writer + "period" + #fhir/Period + {:start #fhir/dateTime #system/date-time "2019-01-01T00:00:00+01:00" + :end #fhir/dateTime #system/date-time "2048-12-31T00:00:00+01:00"}))) + (is (= (str "" + "" + "" + "") + (str writer))))) + + (testing "Period with only start" + (let [writer (StringWriter.)] + (is (true? (XmlDirectWriter/writePeriod + writer + "period" + #fhir/Period + {:start #fhir/dateTime #system/date-time "2019-01-01T00:00:00+01:00"}))) + (is (= (str "" + "" + "") + (str writer))))) + + (testing "extended Period falls back before writing" + (let [writer (StringWriter.)] + (is (false? (XmlDirectWriter/writePeriod + writer + "period" + #fhir/Period + {:extension [#fhir/Extension + {:url "http://example.com/fhir/StructureDefinition/foo"}]}))) + (is (= "" (str writer)))))) + (defn- write-json [x] (fhir-spec/write-json-as-bytes writing-context x)) From 2f81c10bfc2be523334e108a1a2574a34d72f286 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Stru=CC=88bing?= Date: Tue, 9 Jun 2026 10:05:03 +0200 Subject: [PATCH 5/7] Write XML through a UTF-8 writer Add XmlUtf8Writer and route XML serialization through it so primitive XML output can avoid extra character encoding work. --- .../java/blaze/fhir/XmlUtf8Writer.java | 227 ++++++++++++++++++ .../java/blaze/fhir/XmlUtil.java | 4 + .../fhir-structure/src/blaze/fhir/spec.clj | 7 +- .../test/blaze/fhir/spec_test.clj | 17 ++ 4 files changed, 251 insertions(+), 4 deletions(-) create mode 100644 modules/fhir-structure/java/blaze/fhir/XmlUtf8Writer.java diff --git a/modules/fhir-structure/java/blaze/fhir/XmlUtf8Writer.java b/modules/fhir-structure/java/blaze/fhir/XmlUtf8Writer.java new file mode 100644 index 000000000..326869575 --- /dev/null +++ b/modules/fhir-structure/java/blaze/fhir/XmlUtf8Writer.java @@ -0,0 +1,227 @@ +package blaze.fhir; + +import java.io.IOException; +import java.io.OutputStream; +import java.io.Writer; + +public final class XmlUtf8Writer extends Writer { + + private final OutputStream out; + private final byte[] buffer; + private int pos; + + public XmlUtf8Writer(OutputStream out, int bufferSize) { + this.out = out; + this.buffer = new byte[bufferSize]; + } + + private void ensureOpen() throws IOException { + if (out == null) { + throw new IOException("Stream closed"); + } + } + + private void ensureCapacity(int n) throws IOException { + if (buffer.length - pos < n) { + flushBuffer(); + } + } + + private void flushBuffer() throws IOException { + if (pos > 0) { + out.write(buffer, 0, pos); + pos = 0; + } + } + + private void writeByte(int b) throws IOException { + ensureCapacity(1); + buffer[pos++] = (byte) b; + } + + private void writeCodePoint(int codePoint) throws IOException { + if (codePoint < 0x80) { + writeByte(codePoint); + } else if (codePoint < 0x800) { + ensureCapacity(2); + buffer[pos++] = (byte) (0xC0 | (codePoint >> 6)); + buffer[pos++] = (byte) (0x80 | (codePoint & 0x3F)); + } else if (codePoint < 0x10000) { + ensureCapacity(3); + buffer[pos++] = (byte) (0xE0 | (codePoint >> 12)); + buffer[pos++] = (byte) (0x80 | ((codePoint >> 6) & 0x3F)); + buffer[pos++] = (byte) (0x80 | (codePoint & 0x3F)); + } else { + ensureCapacity(4); + buffer[pos++] = (byte) (0xF0 | (codePoint >> 18)); + buffer[pos++] = (byte) (0x80 | ((codePoint >> 12) & 0x3F)); + buffer[pos++] = (byte) (0x80 | ((codePoint >> 6) & 0x3F)); + buffer[pos++] = (byte) (0x80 | (codePoint & 0x3F)); + } + } + + private void writeAscii(String str, int off, int end) throws IOException { + while (off < end) { + ensureCapacity(1); + int n = Math.min(buffer.length - pos, end - off); + for (int i = 0; i < n; i++) { + buffer[pos + i] = (byte) str.charAt(off + i); + } + pos += n; + off += n; + } + } + + private void writeAscii(char[] cbuf, int off, int end) throws IOException { + while (off < end) { + ensureCapacity(1); + int n = Math.min(buffer.length - pos, end - off); + for (int i = 0; i < n; i++) { + buffer[pos + i] = (byte) cbuf[off + i]; + } + pos += n; + off += n; + } + } + + private static boolean validXmlChar(char c) { + return c == 0x09 || c == 0x0A || c == 0x0D || + (0x20 <= c && c <= 0xD7FF) || + (0xE000 <= c && c <= 0xFFFD); + } + + public void writeEscaped(String str) throws IOException { + ensureOpen(); + int len = str.length(); + int start = 0; + for (int i = 0; i < len; i++) { + char c = str.charAt(i); + switch (c) { + case '&' -> { + if (start < i) writeAscii(str, start, i); + writeAscii("&", 0, 5); + start = i + 1; + } + case '<' -> { + if (start < i) writeAscii(str, start, i); + writeAscii("<", 0, 4); + start = i + 1; + } + case '>' -> { + if (start < i) writeAscii(str, start, i); + writeAscii(">", 0, 4); + start = i + 1; + } + case '"' -> { + if (start < i) writeAscii(str, start, i); + writeAscii(""", 0, 6); + start = i + 1; + } + default -> { + if (c >= 0x80) { + if (start < i) writeAscii(str, start, i); + if (Character.isHighSurrogate(c)) { + int j = i + 1; + if (j < len && Character.isLowSurrogate(str.charAt(j))) { + writeCodePoint(Character.toCodePoint(c, str.charAt(j))); + i = j; + start = j + 1; + } else { + writeByte('?'); + start = j; + } + } else if (Character.isLowSurrogate(c) || !validXmlChar(c)) { + writeByte('?'); + start = i + 1; + } else { + writeCodePoint(c); + start = i + 1; + } + } else if (!validXmlChar(c)) { + if (start < i) writeAscii(str, start, i); + writeByte('?'); + start = i + 1; + } + } + } + } + if (start < len) writeAscii(str, start, len); + } + + @Override + public void write(int c) throws IOException { + ensureOpen(); + writeCodePoint(Character.isSurrogate((char) c) ? '?' : c); + } + + @Override + public void write(char[] cbuf, int off, int len) throws IOException { + ensureOpen(); + int end = off + len; + for (int i = off; i < end; i++) { + char c = cbuf[i]; + if (c < 0x80) { + int start = i++; + while (i < end && cbuf[i] < 0x80) { + i++; + } + writeAscii(cbuf, start, i); + i--; + } else if (Character.isHighSurrogate(c)) { + int j = i + 1; + if (j < end && Character.isLowSurrogate(cbuf[j])) { + writeCodePoint(Character.toCodePoint(c, cbuf[j])); + i = j; + } else { + writeByte('?'); + } + } else if (Character.isLowSurrogate(c)) { + writeByte('?'); + } else { + writeCodePoint(c); + } + } + } + + @Override + public void write(String str, int off, int len) throws IOException { + ensureOpen(); + int end = off + len; + for (int i = off; i < end; i++) { + char c = str.charAt(i); + if (c < 0x80) { + int start = i++; + while (i < end && str.charAt(i) < 0x80) { + i++; + } + writeAscii(str, start, i); + i--; + } else if (Character.isHighSurrogate(c)) { + int j = i + 1; + if (j < end && Character.isLowSurrogate(str.charAt(j))) { + writeCodePoint(Character.toCodePoint(c, str.charAt(j))); + i = j; + } else { + writeByte('?'); + } + } else if (Character.isLowSurrogate(c)) { + writeByte('?'); + } else { + writeCodePoint(c); + } + } + } + + @Override + public void flush() throws IOException { + ensureOpen(); + flushBuffer(); + out.flush(); + } + + @Override + public void close() throws IOException { + flush(); + out.close(); + } +} diff --git a/modules/fhir-structure/java/blaze/fhir/XmlUtil.java b/modules/fhir-structure/java/blaze/fhir/XmlUtil.java index 7f5ab7913..be08fab36 100644 --- a/modules/fhir-structure/java/blaze/fhir/XmlUtil.java +++ b/modules/fhir-structure/java/blaze/fhir/XmlUtil.java @@ -15,6 +15,10 @@ private static boolean validXmlChar(char c) { } public static void writeEscaped(Writer writer, String s) throws IOException { + if (writer instanceof XmlUtf8Writer xmlWriter) { + xmlWriter.writeEscaped(s); + return; + } int len = s.length(); int start = 0; for (int i = 0; i < len; i++) { diff --git a/modules/fhir-structure/src/blaze/fhir/spec.clj b/modules/fhir-structure/src/blaze/fhir/spec.clj index 9a556eb73..fd89ee451 100644 --- a/modules/fhir-structure/src/blaze/fhir/spec.clj +++ b/modules/fhir-structure/src/blaze/fhir/spec.clj @@ -13,9 +13,10 @@ [clojure.walk :as walk] [cognitect.anomalies :as anom]) (:import + [blaze.fhir XmlUtf8Writer] [blaze.fhir.spec.type Primitive] [clojure.data.xml.node Element] - [java.io BufferedWriter ByteArrayOutputStream OutputStream OutputStreamWriter Writer] + [java.io ByteArrayOutputStream OutputStream Writer] [java.nio.charset StandardCharsets] [java.util.regex Pattern])) @@ -162,9 +163,7 @@ (defn write-xml "Writes `value` to output stream `out` closing it if done." [context out value] - (with-open [writer (BufferedWriter. - (OutputStreamWriter. ^OutputStream out StandardCharsets/UTF_8) - 65536)] + (with-open [writer (XmlUtf8Writer. ^OutputStream out 65536)] (.write writer "") (if-some [xml-handlers (:xml-handlers (meta context))] (if-some [handler (xml-handlers (:fhir/type value))] diff --git a/modules/fhir-structure/test/blaze/fhir/spec_test.clj b/modules/fhir-structure/test/blaze/fhir/spec_test.clj index f1145f25c..1efb723cb 100644 --- a/modules/fhir-structure/test/blaze/fhir/spec_test.clj +++ b/modules/fhir-structure/test/blaze/fhir/spec_test.clj @@ -32,6 +32,7 @@ [jsonista.core :as j] [juxt.iota :refer [given]]) (:import + [blaze.fhir XmlUtf8Writer] [blaze.fhir.spec.type Base XmlDirectWriter] [blaze.fhir.spec.type.system DateTime Times] [com.fasterxml.jackson.dataformat.cbor CBORFactory] @@ -99,6 +100,22 @@ (fhir-spec/write-xml writing-context out x) (String. (.toByteArray out) StandardCharsets/UTF_8))) +(deftest xml-utf8-writer-test + (testing "UTF-8 encoding" + (let [out (ByteArrayOutputStream.)] + (with-open [writer (XmlUtf8Writer. out 16)] + (.write writer "Aä") + (.write writer "😀") + (.write writer (char-array [\B \C]) 0 2)) + (is (= "Aä😀BC" (String. (.toByteArray out) StandardCharsets/UTF_8))))) + + (testing "escaping" + (let [out (ByteArrayOutputStream.)] + (with-open [writer (XmlUtf8Writer. out 16)] + (.writeEscaped writer "A&B \"D\" ä 😀 \u001E")) + (is (= "A&B <C> "D" ä 😀 ?" + (String. (.toByteArray out) StandardCharsets/UTF_8)))))) + (deftest write-xml-test (let [out (ByteArrayOutputStream.) patient {:fhir/type :fhir/Patient :id "0"}] From 3970432db30c2a6b7db3d6cb585b8db66e2a6b26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Stru=CC=88bing?= Date: Tue, 9 Jun 2026 10:05:33 +0200 Subject: [PATCH 6/7] Expand direct XML writing for common values Extend direct XML serialization to additional Period, Coding, Identifier and Meta cases, and optimize the UTF-8 writer for common ASCII output paths. --- .../java/blaze/fhir/XmlUtf8Writer.java | 91 +++--- .../blaze/fhir/spec/type/XmlDirectWriter.java | 259 +++++++++++++++++- .../src/blaze/fhir/writing_context.clj | 22 +- .../test/blaze/fhir/spec_test.clj | 107 +++++++- 4 files changed, 411 insertions(+), 68 deletions(-) diff --git a/modules/fhir-structure/java/blaze/fhir/XmlUtf8Writer.java b/modules/fhir-structure/java/blaze/fhir/XmlUtf8Writer.java index 326869575..79bbd4daf 100644 --- a/modules/fhir-structure/java/blaze/fhir/XmlUtf8Writer.java +++ b/modules/fhir-structure/java/blaze/fhir/XmlUtf8Writer.java @@ -93,59 +93,44 @@ private static boolean validXmlChar(char c) { public void writeEscaped(String str) throws IOException { ensureOpen(); int len = str.length(); - int start = 0; for (int i = 0; i < len; i++) { char c = str.charAt(i); - switch (c) { - case '&' -> { - if (start < i) writeAscii(str, start, i); - writeAscii("&", 0, 5); - start = i + 1; - } - case '<' -> { - if (start < i) writeAscii(str, start, i); - writeAscii("<", 0, 4); - start = i + 1; - } - case '>' -> { - if (start < i) writeAscii(str, start, i); - writeAscii(">", 0, 4); - start = i + 1; - } - case '"' -> { - if (start < i) writeAscii(str, start, i); - writeAscii(""", 0, 6); - start = i + 1; - } - default -> { - if (c >= 0x80) { - if (start < i) writeAscii(str, start, i); - if (Character.isHighSurrogate(c)) { - int j = i + 1; - if (j < len && Character.isLowSurrogate(str.charAt(j))) { - writeCodePoint(Character.toCodePoint(c, str.charAt(j))); - i = j; - start = j + 1; - } else { - writeByte('?'); - start = j; + if (c < 0x80) { + if (c >= 0x20) { + switch (c) { + case '&' -> writeAscii("&", 0, 5); + case '<' -> writeAscii("<", 0, 4); + case '>' -> writeAscii(">", 0, 4); + case '"' -> writeAscii(""", 0, 6); + default -> { + if (pos == buffer.length) { + flushBuffer(); } - } else if (Character.isLowSurrogate(c) || !validXmlChar(c)) { - writeByte('?'); - start = i + 1; - } else { - writeCodePoint(c); - start = i + 1; + buffer[pos++] = (byte) c; } - } else if (!validXmlChar(c)) { - if (start < i) writeAscii(str, start, i); - writeByte('?'); - start = i + 1; } + } else if (c == 0x09 || c == 0x0A || c == 0x0D) { + if (pos == buffer.length) { + flushBuffer(); + } + buffer[pos++] = (byte) c; + } else { + writeByte('?'); + } + } else if (Character.isHighSurrogate(c)) { + int j = i + 1; + if (j < len && Character.isLowSurrogate(str.charAt(j))) { + writeCodePoint(Character.toCodePoint(c, str.charAt(j))); + i = j; + } else { + writeByte('?'); } + } else if (Character.isLowSurrogate(c) || !validXmlChar(c)) { + writeByte('?'); + } else { + writeCodePoint(c); } } - if (start < len) writeAscii(str, start, len); } @Override @@ -161,12 +146,10 @@ public void write(char[] cbuf, int off, int len) throws IOException { for (int i = off; i < end; i++) { char c = cbuf[i]; if (c < 0x80) { - int start = i++; - while (i < end && cbuf[i] < 0x80) { - i++; + if (pos == buffer.length) { + flushBuffer(); } - writeAscii(cbuf, start, i); - i--; + buffer[pos++] = (byte) c; } else if (Character.isHighSurrogate(c)) { int j = i + 1; if (j < end && Character.isLowSurrogate(cbuf[j])) { @@ -190,12 +173,10 @@ public void write(String str, int off, int len) throws IOException { for (int i = off; i < end; i++) { char c = str.charAt(i); if (c < 0x80) { - int start = i++; - while (i < end && str.charAt(i) < 0x80) { - i++; + if (pos == buffer.length) { + flushBuffer(); } - writeAscii(str, start, i); - i--; + buffer[pos++] = (byte) c; } else if (Character.isHighSurrogate(c)) { int j = i + 1; if (j < end && Character.isLowSurrogate(str.charAt(j))) { diff --git a/modules/fhir-structure/java/blaze/fhir/spec/type/XmlDirectWriter.java b/modules/fhir-structure/java/blaze/fhir/spec/type/XmlDirectWriter.java index 089ddd2e9..022bedfef 100644 --- a/modules/fhir-structure/java/blaze/fhir/spec/type/XmlDirectWriter.java +++ b/modules/fhir-structure/java/blaze/fhir/spec/type/XmlDirectWriter.java @@ -6,6 +6,7 @@ import java.io.Writer; import java.time.LocalDateTime; import java.time.OffsetDateTime; +import java.util.List; public final class XmlDirectWriter { @@ -43,10 +44,52 @@ private static boolean canWritePeriod(Period value) { canWritePrimitive(value.end()); } + private static boolean canWriteIdentifier(Identifier value) { + return value.id() == null && value.extension().isEmpty() && + canWritePrimitive(value.use()) && + (value.type() == null || canWriteCodeableConcept(value.type())) && + canWritePrimitive(value.system()) && + canWritePrimitive(value.value()) && + (value.period() == null || canWritePeriod(value.period())) && + value.assigner() == null; + } + + private static boolean canWriteCodings(List codings) { + for (Coding coding : codings) { + if (!canWriteCoding(coding)) { + return false; + } + } + return true; + } + + private static boolean canWritePrimitives(List primitives) { + for (Primitive primitive : primitives) { + if (!canWritePrimitive(primitive)) { + return false; + } + } + return true; + } + + private static boolean canWriteMeta(Meta value) { + return value.id() == null && value.extension().isEmpty() && + canWritePrimitive(value.versionId()) && + canWritePrimitive(value.lastUpdated()) && + canWritePrimitive(value.source()) && + canWritePrimitives(value.profile()) && + canWriteCodings(value.security()) && + canWriteCodings(value.tag()); + } + private static void writePrimitiveField(Writer writer, java.lang.String tag, Primitive value) throws IOException { if (value == null) return; var stringValue = value.valueAsString(); - if (stringValue == null) { + writeStringValueField(writer, tag, stringValue); + } + + private static void writeStringValueField(Writer writer, java.lang.String tag, java.lang.String value) throws IOException { + if (value == null) { writer.write("<"); writer.write(tag); writer.write("/>"); @@ -54,11 +97,118 @@ private static void writePrimitiveField(Writer writer, java.lang.String tag, Pri writer.write("<"); writer.write(tag); writer.write(" value=\""); - XmlUtil.writeEscaped(writer, stringValue); + XmlUtil.writeEscaped(writer, value); writer.write("\"/>"); } } + private static void writeStringField(Writer writer, java.lang.String tag, Uri value) throws IOException { + if (value == null) return; + writeStringValueField(writer, tag, value.value()); + } + + private static void writeStringField(Writer writer, java.lang.String tag, Id value) throws IOException { + if (value == null) return; + writeStringValueField(writer, tag, value.value()); + } + + private static void writeStringField(Writer writer, java.lang.String tag, Canonical value) throws IOException { + if (value == null) return; + writeStringValueField(writer, tag, value.value()); + } + + private static void writeStringField(Writer writer, java.lang.String tag, String value) throws IOException { + if (value == null) return; + writeStringValueField(writer, tag, value.value()); + } + + private static void writeStringField(Writer writer, java.lang.String tag, Code value) throws IOException { + if (value == null) return; + writeStringValueField(writer, tag, value.value()); + } + + private static void writeBooleanField(Writer writer, java.lang.String tag, Boolean value) throws IOException { + if (value == null) return; + var booleanValue = value.value(); + if (booleanValue == null) { + writer.write("<"); + writer.write(tag); + writer.write("/>"); + } else { + writer.write("<"); + writer.write(tag); + writer.write(booleanValue ? " value=\"true\"/>" : " value=\"false\"/>"); + } + } + + private static void write2(Writer writer, int value) throws IOException { + writer.write('0' + value / 10); + writer.write('0' + value % 10); + } + + private static void write4(Writer writer, int value) throws IOException { + writer.write('0' + value / 1000); + writer.write('0' + value / 100 % 10); + writer.write('0' + value / 10 % 10); + writer.write('0' + value % 10); + } + + private static boolean canWriteYear(int year) { + return 0 <= year && year <= 9999; + } + + private static void writeLocalDateTime(Writer writer, LocalDateTime value) throws IOException { + write4(writer, value.getYear()); + writer.write('-'); + write2(writer, value.getMonthValue()); + writer.write('-'); + write2(writer, value.getDayOfMonth()); + writer.write('T'); + write2(writer, value.getHour()); + writer.write(':'); + write2(writer, value.getMinute()); + writer.write(':'); + write2(writer, value.getSecond()); + writeFraction(writer, value.getNano()); + } + + private static void writeFraction(Writer writer, int nano) throws IOException { + if (nano == 0) return; + writer.write('.'); + int divisor = 100000000; + while (nano % 10 == 0) { + nano /= 10; + divisor /= 10; + } + while (divisor > 0) { + writer.write('0' + nano / divisor); + nano %= divisor; + divisor /= 10; + } + } + + private static void writeOffset(Writer writer, OffsetDateTime value) throws IOException { + int totalSeconds = value.getOffset().getTotalSeconds(); + if (totalSeconds == 0) { + writer.write('Z'); + return; + } + if (totalSeconds < 0) { + writer.write('-'); + totalSeconds = -totalSeconds; + } else { + writer.write('+'); + } + write2(writer, totalSeconds / 3600); + writer.write(':'); + write2(writer, totalSeconds / 60 % 60); + int seconds = totalSeconds % 60; + if (seconds != 0) { + writer.write(':'); + write2(writer, seconds); + } + } + private static void writeDateTimeField(Writer writer, java.lang.String tag, DateTime value) throws IOException { if (value == null) return; var dateTime = value.value(); @@ -71,16 +221,48 @@ private static void writeDateTimeField(Writer writer, java.lang.String tag, Date writer.write(tag); writer.write(" value=\""); switch (dateTime) { - case LocalDateTime localDateTime -> + case LocalDateTime localDateTime -> { + if (canWriteYear(localDateTime.getYear())) { + writeLocalDateTime(writer, localDateTime); + } else { blaze.fhir.spec.type.system.DateTime.LOCAL_DATE_TIME.formatTo(localDateTime, writer); - case OffsetDateTime offsetDateTime -> + } + } + case OffsetDateTime offsetDateTime -> { + if (canWriteYear(offsetDateTime.getYear())) { + writeLocalDateTime(writer, offsetDateTime.toLocalDateTime()); + writeOffset(writer, offsetDateTime); + } else { blaze.fhir.spec.type.system.DateTime.DATE_TIME.formatTo(offsetDateTime, writer); + } + } default -> writer.write(dateTime.toString()); } writer.write("\"/>"); } } + private static void writeInstantField(Writer writer, java.lang.String tag, Instant value) throws IOException { + if (value == null) return; + var instant = value.value(); + if (instant == null) { + writer.write("<"); + writer.write(tag); + writer.write("/>"); + } else { + writer.write("<"); + writer.write(tag); + writer.write(" value=\""); + if (canWriteYear(instant.getYear())) { + writeLocalDateTime(writer, instant.toLocalDateTime()); + writeOffset(writer, instant); + } else { + blaze.fhir.spec.type.system.DateTime.DATE_TIME.formatTo(instant, writer); + } + writer.write("\"/>"); + } + } + public static boolean writeCoding(Writer writer, java.lang.String tag, Coding value) throws IOException { if (!canWriteCoding(value)) { return false; @@ -92,11 +274,11 @@ public static boolean writeCoding(Writer writer, java.lang.String tag, Coding va writer.write("/>"); } else { writer.write(">"); - writePrimitiveField(writer, "system", value.system()); - writePrimitiveField(writer, "version", value.version()); - writePrimitiveField(writer, "code", value.code()); - writePrimitiveField(writer, "display", value.display()); - writePrimitiveField(writer, "userSelected", value.userSelected()); + writeStringField(writer, "system", value.system()); + writeStringField(writer, "version", value.version()); + writeStringField(writer, "code", value.code()); + writeStringField(writer, "display", value.display()); + writeBooleanField(writer, "userSelected", value.userSelected()); writer.write(""); @@ -117,7 +299,7 @@ public static boolean writeCodeableConcept(Writer writer, java.lang.String tag, for (Coding coding : value.coding()) { writeCoding(writer, "coding", coding); } - writePrimitiveField(writer, "text", value.text()); + writeStringField(writer, "text", value.text()); writer.write(""); @@ -143,4 +325,61 @@ public static boolean writePeriod(Writer writer, java.lang.String tag, Period va } return true; } + + public static boolean writeIdentifier(Writer writer, java.lang.String tag, Identifier value) throws IOException { + if (!canWriteIdentifier(value)) { + return false; + } + writer.write("<"); + writer.write(tag); + if (value.use() == null && value.type() == null && value.system() == null && + value.value() == null && value.period() == null) { + writer.write("/>"); + } else { + writer.write(">"); + writeStringField(writer, "use", value.use()); + if (value.type() != null) { + writeCodeableConcept(writer, "type", value.type()); + } + writeStringField(writer, "system", value.system()); + writeStringField(writer, "value", value.value()); + if (value.period() != null) { + writePeriod(writer, "period", value.period()); + } + writer.write(""); + } + return true; + } + + public static boolean writeMeta(Writer writer, java.lang.String tag, Meta value) throws IOException { + if (!canWriteMeta(value)) { + return false; + } + writer.write("<"); + writer.write(tag); + if (value.versionId() == null && value.lastUpdated() == null && value.source() == null && + value.profile().isEmpty() && value.security().isEmpty() && value.tag().isEmpty()) { + writer.write("/>"); + } else { + writer.write(">"); + writeStringField(writer, "versionId", value.versionId()); + writeInstantField(writer, "lastUpdated", value.lastUpdated()); + writeStringField(writer, "source", value.source()); + for (Canonical profile : value.profile()) { + writeStringField(writer, "profile", profile); + } + for (Coding security : value.security()) { + writeCoding(writer, "security", security); + } + for (Coding tagCoding : value.tag()) { + writeCoding(writer, "tag", tagCoding); + } + writer.write(""); + } + return true; + } } diff --git a/modules/fhir-structure/src/blaze/fhir/writing_context.clj b/modules/fhir-structure/src/blaze/fhir/writing_context.clj index 332d56ba1..d6aac2a2d 100644 --- a/modules/fhir-structure/src/blaze/fhir/writing_context.clj +++ b/modules/fhir-structure/src/blaze/fhir/writing_context.clj @@ -18,7 +18,7 @@ [blaze ReducibleArray] [blaze.fhir XmlUtil] [blaze.fhir.spec.type Base BundleEntrySearch CodeableConcept Coding Complex FieldName - Period Primitive Xhtml XmlDirectWriter] + Identifier Meta Period Primitive Xhtml XmlDirectWriter] [clojure.data.xml.node Element] [clojure.lang IPersistentMap Indexed] [com.fasterxml.jackson.core JsonGenerator SerializableString] @@ -408,6 +408,14 @@ (when-not (XmlDirectWriter/writePeriod writer (.-tag property-handler) ^Period value) (write-value! xml-handlers writer (.-tag property-handler) value)) + (identical? :fhir/Identifier (.-type property-handler)) + (when-not (XmlDirectWriter/writeIdentifier writer (.-tag property-handler) ^Identifier value) + (write-value! xml-handlers writer (.-tag property-handler) value)) + + (identical? :fhir/Meta (.-type property-handler)) + (when-not (XmlDirectWriter/writeMeta writer (.-tag property-handler) ^Meta value) + (write-value! xml-handlers writer (.-tag property-handler) value)) + :else (write-value! xml-handlers writer (.-tag property-handler) value))) @@ -503,6 +511,18 @@ (handler xml-handlers writer tag value) (throw (IllegalArgumentException. (format "Value `%s` is no supported FHIR XML type." value))))) + (and tag (instance? Identifier value)) + (when-not (XmlDirectWriter/writeIdentifier writer tag value) + (if-some [handler (xml-handlers (fhir-type value))] + (handler xml-handlers writer tag value) + (throw (IllegalArgumentException. (format "Value `%s` is no supported FHIR XML type." value))))) + + (and tag (instance? Meta value)) + (when-not (XmlDirectWriter/writeMeta writer tag value) + (if-some [handler (xml-handlers (fhir-type value))] + (handler xml-handlers writer tag value) + (throw (IllegalArgumentException. (format "Value `%s` is no supported FHIR XML type." value))))) + :else (if-some [handler (xml-handlers (fhir-type value))] (if tag diff --git a/modules/fhir-structure/test/blaze/fhir/spec_test.clj b/modules/fhir-structure/test/blaze/fhir/spec_test.clj index 1efb723cb..0afaa5d44 100644 --- a/modules/fhir-structure/test/blaze/fhir/spec_test.clj +++ b/modules/fhir-structure/test/blaze/fhir/spec_test.clj @@ -38,7 +38,8 @@ [com.fasterxml.jackson.dataformat.cbor CBORFactory] [com.google.common.hash Hashing] [java.io ByteArrayInputStream ByteArrayOutputStream StringWriter] - [java.nio.charset StandardCharsets])) + [java.nio.charset StandardCharsets] + [java.time LocalDateTime])) (xml-name/alias-uri 'f "http://hl7.org/fhir") (xml-name/alias-uri 'xhtml "http://www.w3.org/1999/xhtml") @@ -276,13 +277,15 @@ [#fhir/Coding {:system #fhir/uri "http://loinc.org" :code #fhir/code "718-7" - :display #fhir/string "Hemoglobin [Mass/volume] & friends"}] + :display #fhir/string "Hemoglobin [Mass/volume] & friends" + :userSelected #fhir/boolean true}] :text #fhir/string "Hemoglobin "}))) (is (= (str "" "" "" "" "" + "" "" "" "") @@ -325,6 +328,30 @@ "") (str writer))))) + (testing "Period with fractional seconds and UTC" + (let [writer (StringWriter.)] + (is (true? (XmlDirectWriter/writePeriod + writer + "period" + #fhir/Period + {:start #fhir/dateTime #system/date-time "2019-01-01T00:00:00.12Z"}))) + (is (= (str "" + "" + "") + (str writer))))) + + (testing "Period with year outside the direct writer range falls back" + (let [writer (StringWriter.)] + (is (true? (XmlDirectWriter/writePeriod + writer + "period" + (type/period + {:start (type/dateTime (LocalDateTime/of 10000 1 1 0 0))})))) + (is (= (str "" + "" + "") + (str writer))))) + (testing "extended Period falls back before writing" (let [writer (StringWriter.)] (is (false? (XmlDirectWriter/writePeriod @@ -335,6 +362,82 @@ {:url "http://example.com/fhir/StructureDefinition/foo"}]}))) (is (= "" (str writer)))))) +(deftest write-xml-identifier-direct-test + (testing "simple Identifier" + (let [writer (StringWriter.)] + (is (true? (XmlDirectWriter/writeIdentifier + writer + "identifier" + #fhir/Identifier + {:use #fhir/code "usual" + :type #fhir/CodeableConcept + {:coding + [#fhir/Coding + {:system #fhir/uri "http://terminology.hl7.org/CodeSystem/v2-0203" + :code #fhir/code "MR"}]} + :system #fhir/uri "http://example.com/mrn" + :value #fhir/string "123 & 456" + :period #fhir/Period + {:start #fhir/dateTime #system/date-time "2019-01-01T00:00:00+01:00"}}))) + (is (= (str "" + "" + "" + "" + "" + "" + "" + "" + "" + "") + (str writer))))) + + (testing "extended Identifier falls back before writing" + (let [writer (StringWriter.)] + (is (false? (XmlDirectWriter/writeIdentifier + writer + "identifier" + #fhir/Identifier + {:extension [#fhir/Extension + {:url "http://example.com/fhir/StructureDefinition/foo"}]}))) + (is (= "" (str writer)))))) + +(deftest write-xml-meta-direct-test + (testing "simple Meta" + (let [writer (StringWriter.)] + (is (true? (XmlDirectWriter/writeMeta + writer + "meta" + #fhir/Meta + {:versionId #fhir/id "42" + :lastUpdated #fhir/instant #system/date-time "2026-06-08T08:00:00Z" + :source #fhir/uri "http://example.com/source" + :profile [#fhir/canonical "http://example.com/profile"] + :security + [#fhir/Coding + {:system #fhir/uri "http://terminology.hl7.org/CodeSystem/v3-ActReason" + :code #fhir/code "HTEST"}]}))) + (is (= (str "" + "" + "" + "" + "" + "" + "" + "" + "" + "") + (str writer))))) + + (testing "extended Meta falls back before writing" + (let [writer (StringWriter.)] + (is (false? (XmlDirectWriter/writeMeta + writer + "meta" + #fhir/Meta + {:extension [#fhir/Extension + {:url "http://example.com/fhir/StructureDefinition/foo"}]}))) + (is (= "" (str writer)))))) + (defn- write-json [x] (fhir-spec/write-json-as-bytes writing-context x)) From 993f31e7dc16db4fae03559c3bf586ec8319ab1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Stru=CC=88bing?= Date: Tue, 9 Jun 2026 10:05:47 +0200 Subject: [PATCH 7/7] Document XML optimization handover Capture the optimization context, verification results, benchmark observations and discarded experiments for follow-up review. --- .../blaze-xml-optimization-handover.md | 855 ++++++++++++++++++ 1 file changed, 855 insertions(+) create mode 100644 docs/performance/blaze-xml-optimization-handover.md diff --git a/docs/performance/blaze-xml-optimization-handover.md b/docs/performance/blaze-xml-optimization-handover.md new file mode 100644 index 000000000..39dfbd01f --- /dev/null +++ b/docs/performance/blaze-xml-optimization-handover.md @@ -0,0 +1,855 @@ +# Blaze XML Paging Optimization Handover + +Stand: 2026-06-08 +Repo: /Users/axs/Projekte/blaze +Branch: codex/xml-direct-writer-wip + +## Review-Branch-Zuschnitt, 2026-06-09 + +Die urspruengliche WIP-Serie wurde im Fork `astruebi/blaze` zusaetzlich als Branch `codex/xml-direct-writer-review` auf den damaligen aktuellen `origin/main` neu zugeschnitten. Der Review-Branch enthaelt dieselben finalen Aenderungen, aber in groesseren, logischeren Commits statt der kleinteiligen WIP-Historie: + +1. `68b84d32 Optimize XML response output` + - Erste Optimierung des XML-Response-Pfads sowie Profiling-/Benchmark-Setup unter `profiling/xml-paging`. +2. `37484fb5 Reduce generic XML writer overhead` + - Reduziert generischen XML-Writer-Overhead durch guenstigere Felditeration, vor berechnete Tag-Fragmente, weniger Seq-Allokationen, schnelleres Escaping und optimierten FHIR-Type-Lookup. +3. `cddb5381 Specialize XML search bundle entries` + - Fuegt den Fast Path fuer typische Search-`Bundle.entry`-Elemente hinzu. +4. `53928a04 Add direct XML writers for common complex types` + - Fuehrt `XmlDirectWriter` ein und deckt erste haeufige komplexe Typen inklusive Period-Regressionstests ab. +5. `2f81c10b Write XML through a UTF-8 writer` + - Fuehrt `XmlUtf8Writer` ein und leitet XML-Ausgabe ueber den UTF-8-Schreibpfad. +6. `3970432d Expand direct XML writing for common values` + - Erweitert direkte XML-Ausgabe fuer Period-, Coding-, Identifier- und Meta-Faelle und optimiert ASCII-Pfade im UTF-8-Writer. +7. `9a54ce3c Document XML optimization handover` + - Uebernimmt diese Handover-Datei als historischen Kontext. + +Der folgende Inhalt wurde bewusst weitgehend unveraendert gelassen. Er beschreibt den urspruenglichen Optimierungsthread mit WIP-Commits, lokalen Benchmark-Kontexten, verworfenen Experimenten und Zwischenstaenden. + +## Aktueller Kurzstand, 2026-06-08 + +- 8080 läuft aktuell mit `blaze:xml-direct-meta-jdk` auf Container `blaze`, Volume `blaze_blaze-data`. +- 8081 läuft weiterhin als alter Vergleichs-Blaze `blaze-old` mit `blaze:baseline-jdk-profile`, Volume `blaze_old-data`. +- Letzter gesicherter Commit vor dem aktuellen Meta-Schritt: + - `2c6abeedb WIP write Identifier XML directly` +- Aktueller zu sichernder Schritt: + - `XmlDirectWriter.writeMeta` plus Dispatch für `:fhir/Meta` in `writing_context.clj`. + - Direkter Meta-Pfad für einfache `versionId`, `lastUpdated`, `source`, `profile`, `security`, `tag`; Fallback bei `id`/`extension` oder nicht direkt schreibbaren Kindern. + - Test: `write-xml-meta-direct-test`. +- Verification für Meta: + - `make -C modules/fhir-structure fmt`: grün. + - `make -C modules/fhir-structure lint`: grün. + - `make -C modules/fhir-structure test`: grün (`220 tests, 4551 assertions`). + - `make -C modules/fhir-structure test-coverage`: grün (`ALL FILES 95,15% Forms`, `94,59% Lines`). + - `make -C modules/fhir-structure clean`, `prep`, `make uberjar`: grün. + - `javap`: `writeIdentifier` und `writeMeta` im Bytecode vorhanden. +- Warme lokale Benchmarks mit `_count=5000`, 8080 gleiche DB: + - Consent XML meist `104-114 ms`, JSON meist `115-133 ms`: XML hier schneller. + - Encounter XML meist `27-29 ms`, JSON meist `23-24 ms`: JSON noch knapp schneller. + - Condition XML meist `16-18 ms`, JSON meist `16-17 ms`: nahe an JSON, teils gleichauf. + - Patient XML meist `20-21 ms`, JSON grob `16-24 ms`: nahe, abhängig vom Lauf. + - Observation XML meist `32-34 ms`, JSON meist `23-29 ms`: JSON weiterhin schneller. +- Interpretation: + - Meta ist ein allgemeiner Writer-Hebel, weil `meta` auf vielen Ressourcen häufig vorkommt. + - Gewinn gegenüber Identifier-only: Patient/Condition/Encounter/Observation sichtbar besser; Consent stabil/schnell. + - Ziel "XML überall schneller als JSON" ist noch nicht erreicht, aber Consent ist mittlerweile klar der erste Ressourcentyp, bei dem XML lokal schneller als JSON läuft. +- Danach probierter, verworfener Quantity-Versuch: + - Idee: `XmlDirectWriter.writeQuantity` für `Observation.valueQuantity`, mit direktem Decimal/String/Code/Uri-Schreiben. + - Tests/Checks waren grün, Coverage blieb über 95%. + - Breiter typbasierter Dispatch für `:fhir/Quantity` war klar zu teuer: Encounter/Condition/Patient wurden schlechter. + - Entschärfte Variante nur für polymorphe `Quantity` war auch nicht überzeugend: Observation höchstens neutral/leicht besser, andere Ressourcen weiterhin zu oft schlechter oder zu instabil. + - Ergebnis: komplett verworfen, nicht committet. 8080 wurde wieder auf `blaze:xml-direct-meta-jdk` zurückgesetzt. + +## Ziel + +FHIR XML Paging/Downloads in Blaze schneller machen, idealerweise schneller als JSON, weil fhircrackR aktuell XML von Blaze lädt. Lokal dient curl gegen Blaze als Proxy-Benchmark; fhircrackR kann später separat geprüft werden. + +## Laufende Blaze-Instanzen + +- Optimierter Blaze: http://localhost:8080/fhir + - Container: blaze + - Image-Tag aktuell zuletzt getestet: blaze:xml-coding-values-jdk + - Volume: blaze_blaze-data + - Ports: 8080:8080, 8082:8081 + - JAVA_TOOL_OPTIONS=-Xmx4g + - Stand: Branch `codex/xml-direct-writer-wip`; lokale WIP-Commits plus aktueller noch zu committender Coding-Primitive-Kandidat +- Alter Vergleichs-Blaze: http://localhost:8081/fhir + - Container: blaze-old + - Image: blaze:baseline-jdk-profile + - Volume: blaze_old-data + +Beide sind mit demselben Testdatensatz beladen: +- Patient: 29021 +- Encounter: 58271 +- Observation: 19187 +- Condition: 46217 +- Consent: 19887 + +## Wichtige lokale Dateien + +- Benchmark-Skript, untracked: profiling/xml-paging/curl-single-page.sh +- Laufzeitdaten, untracked: profiling/xml-paging/runtime/ +- Handover-Datei: /private/tmp/blaze-xml-optimization-handover.md + +Untracked bewusst nicht committen: +- .calva/ +- axs_testdata/ +- blazectl-1.0.0-darwin-arm64.tar.gz +- profiling/xml-paging/curl-single-page.sh +- profiling/xml-paging/runtime/ + +## Commit-Stand + +Aktueller lokaler WIP-Commit vor dem jetzigen Kandidaten: +- 8b46cb488 WIP write Period dateTime XML values manually + +Vorherige relevante WIP-Commits in dieser Optimierungsserie: +- f9d4e89dc WIP escape XML directly in UTF-8 writer +- 5c92ced8f WIP write XML directly as UTF-8 +- 3adc225f9 WIP cover Period XML with missing end +- 0e41c80e2 WIP write Period dateTime XML values directly +- 83618c85a WIP add direct XML writers for common complex types +- 204a472bd WIP specialize XML search bundle entries +- 20fdb1f1a WIP optimize XML type lookup +- 7ea970e1e WIP optimize XML escaping +- fc6c13c3f WIP avoid XML seq allocation +- eea29c54f WIP optimize XML repeated field writing +- d7ab9a63b WIP precompute XML field tag fragments +- 2bf1987b7 WIP specialize XML resource start tag +- d2c5c0f04 WIP reduce XML field iteration overhead +- f258c703c WIP profile XML paging hot spots +- 4d2f33762 WIP optimize XML paging output +- upstream 39a83f7e6 Update jackson to v2.22.0 + +Späterer finaler PR soll wahrscheinlich ein sauberer einzelner Commit auf Issue-Branch werden; lokale WIP-Commits sind nur Arbeitsstände. Benchmarking-Kram nicht ins Repo übernehmen. + +## Build-Falle bei Java-Änderungen + +Wichtig: Bei Änderungen in `modules/fhir-structure/java` kann `make uberjar` sonst stale Klassen aus `modules/fhir-structure/target/classes` paketieren. Das ist einmal passiert: Der Jar enthielt noch eine entfernte `writeReference`-Methode und frühe Messungen liefen gegen alten Bytecode. + +Pflichtablauf für Java-Änderungen: + +1. `make -C modules/fhir-structure clean` +2. `make -C modules/fhir-structure prep` +3. `make uberjar` +4. `javap -classpath target/blaze-1.7.0-standalone.jar -private blaze.fhir.spec.type.XmlDirectWriter` + +Jar-Kontrolle für den aktuellen Kandidaten: +- Erwartet: `writeStringValueField`, `writeStringField(Uri)`, `writeStringField(String)`, `writeStringField(Code)`, `writeBooleanField(Boolean)`. +- Erwartet nicht mehr: `writeReference`. + +## Bisher erfolgreiche Optimierungen + +- XML wird direkter geschrieben, weniger clojure.data.xml/emit-Overhead. +- Java-Helfer XmlUtil.writeEscaped(Writer, String) für schnelleres Escaping. +- Precomputed XML start/end/value-start tag strings in XmlPropertyHandler. +- Verbesserte Schleifen über Felder und wiederholte Werte. +- Spezialfall für Resource-Start-Tag mit FHIR Namespace. +- Polymorphe XML-Tags vorbereitet. +- Direkter fhir type lookup: + - helper fhir-type nutzt Base/FHIR_TYPE_KEY über IPersistentMap.valAt. + - vermeidet Keyword-Aufruf (:fhir/type value) im XML-Hotpath. +- Spezialisierter Bundle.entry XML Search-Entry Writer: + - greift für typische Paging/Search-Entries mit fullUrl/resource/search und ohne id/extension/modifierExtension/link/request/response. + - schreibt fullUrl, resource wrapper und search.mode/search.score direkt. + - fällt bei komplexeren Entries oder Search mit id/extensions auf den generischen Writer zurück. + +## Verifikation für aktuellen Commit + +Für 20fdb1f1a: +- make -C modules/fhir-structure fmt: ok +- make -C modules/fhir-structure lint: ok +- make -C modules/fhir-structure test: 212 tests, 4516 assertions, 0 failures +- make -C modules/fhir-structure test-coverage: ALL FILES 95.29% Forms, 94.51% Lines + +Für Bundle.entry Fast Path: +- make -C modules/fhir-structure fmt: ok +- make -C modules/fhir-structure lint: ok +- make -C modules/fhir-structure test: 213 tests, 4519 assertions, 0 failures +- make -C modules/fhir-structure test-coverage: ALL FILES 95.35% Forms, 94.73% Lines + +## Benchmark-Lage + +Baseline alt war grob sehr langsam: +- Encounter _count=1000 XML ca. 130-150 ms, JSON ca. 7-15 ms in frühen Single-Page-Messungen. +- Paging komplett war ebenfalls deutlich langsamer. + +Aktueller optimierter Stand ist viel näher dran: +- Encounter _count=1000 warm oft ca. 11-14 ms, JSON ca. 7-9 ms in warmen Läufen. +- Encounter _count=5000 warm oft ca. 43-50 ms, JSON ca. 25-35 ms je nach Warmup. +- Observation _count=1000 warm ca. 12-15 ms, JSON oft ca. 7-10 ms. +- Observation _count=5000 warm ca. 45-52 ms, JSON ca. 25-30 ms. +- Consent bleibt besonders XML-lastig: _count=5000 ca. 219-229 ms, JSON ca. 109-119 ms. + +Der Abstand ist kleiner, aber XML ist bei großen Seiten weiterhin klar hinter JSON. + +Nach Bundle.entry Fast Path gab es den ersten klar positiven invasiveren Schritt: +- Encounter _count=1000 warm oft ca. 9.5-10.7 ms nach Warmup, JSON ca. 7.0-7.3 ms. +- Encounter _count=5000 warm stabil ca. 39-41 ms, JSON ca. 25-26 ms. +- Observation _count=1000 warm ca. 10.8-12.8 ms, JSON ca. 7.1-7.6 ms. +- Observation _count=5000 warm ca. 42-44 ms, JSON ca. 25-32 ms. +- Condition _count=1000 ca. 7.7-9.0 ms, JSON ca. 6.9-7.2 ms; _count=5000 ca. 26-29 ms, JSON ca. 19-22 ms. +- Consent bleibt sehr xml-lastig: _count=1000 ca. 51-53 ms vs JSON ca. 31-38 ms; _count=5000 ca. 205-244 ms vs JSON ca. 116-128 ms. + +Interpretation: +- Fast Path behalten; er verbessert Search-Bundle-Seiten messbar, aber XML ist weiterhin nicht generell schneller als JSON. +- Das Ziel "in jedem Fall schneller als JSON" ist noch nicht erreicht. + +## Aktuelles Profilbild + +JFR nach 20fdb1f1a, Datei: /tmp/xmltype.jfr + +Hot methods: +- blaze.fhir.XmlUtil.writeEscaped(Writer, String): ca. 8% +- blaze.fhir.spec.type.Base.valAt(Object): ca. 8% +- java.io.BufferedWriter.write(String, int, int): ca. 5% +- blaze.fhir.spec.type.AbstractElement.valAt(Object, Object): ca. 4% +- clojure.lang.Util.dohasheq / equiv: relevant +- Primitive.valueAsString and DateTime formatting: relevant +- write_xml_field_1! / write_xml_fields!: still visible + +Allocations werden inzwischen stark durch DB/Resource-read-Pfad dominiert, nicht nur XML: +- resource_as_of/new_entry +- CopyOnWriteArrayList.iterator +- HashMap$KeySet.iterator +- iter_pool.State +- OffsetDateTime.ofInstant +- kleinere XML/date/string allocations + +Interpretation: +- Kleine generische XML-Writer-Optimierungen sind weitgehend ausgeschöpft. +- Restkosten kommen aus Escaping/Writer, generischen FHIR-Feldzugriffen, DateTime-Formatierung und größerer XML-Payload. +- Nach Bundle.entry Fast Path sollte neu profiliert werden, weil Entry-Overhead reduziert wurde und sich Hotspots verschoben haben können. + +## Verworfene Experimente + +Nicht committen, da Benchmark nicht besser oder schlechter: + +1. Custom Java Utf8Writer/direct OutputStream UTF-8 + - Naiver Austausch war langsamer. + - Wenn erneut, dann als kompletter Byte-XML-Writer ohne viele Zwischen-Strings, nicht nur Writer ersetzen. + +2. XML-safe primitive skip in Clojure + - Mixed/worse. + +3. java.util.List size/get loop statt aktuellem Pfad + - Mixed/worse. + +4. Primitive.writeXmlValue(Writer) für DateTime/Instant/Time + - Tests grün, Benchmarks mixed/worse. + +5. KeywordLookupSite cache pro XmlPropertyHandler + - Tests grün nach object-array fix, Benchmarks mixed/worse. + +6. Größerer Versuch: Java Base/Complex Felditeration statt StructureDefinition-Scan + - Idee: Für Java-FHIR-Typen über ihre vorhandene geordnete Iterator-Feldliste laufen und XmlPropertyHandler per key map suchen. + - Tests grün, keine Reflection nach Type-Hint-Fix. + - Benchmark nicht besser, Observation/large pages schlechter. + - Wieder entfernt, nicht committed. + +7. Bundle-Root XML Search-Bundle Fast Path + - Idee: Bundle mit id/type/total/link/entry direkt schreiben und Entry-Fast-Path wiederverwenden. + - Tests grün: 213 tests, 4520 assertions, 0 failures; fmt/lint grün. + - Benchmark nach Warmup höchstens neutral: + - Encounter 1000 ca. 10-12 ms, 5000 ca. 39-41 ms. + - Observation 1000 ca. 12-14 ms, 5000 ca. 43-46 ms. + - Zusatzcode war relativ groß und nicht klar schneller; wieder entfernt. + - 8080 wurde danach wieder mit dem committeten Stand 204a472bd gebaut und gestartet. + +8. Coding XML Fast Path in Clojure + - Idee: `Coding` als häufigen Java-Complex-Type direkt über Accessors schreiben, ohne generischen `valAt`-Feldscan. + - Tests grün: 214 tests, 4520 assertions, 0 failures; fmt/lint grün. + - Benchmark klar schlechter bei Observation: + - Observation 1000 warm ca. 12-14 ms, nicht besser. + - Observation 5000 warm ca. 50-56 ms statt ca. 42-46 ms. + - Vermutung: zusätzlicher Spezial-Dispatch/Codegröße/JIT-Verhalten schlägt die eingesparten Feldzugriffe. + - Wieder entfernt; 8080 danach wieder mit dem committeten Stand 204a472bd bauen/starten. + +9. BufferedWriter Buffer 65536 -> 8192 + - Idee: JFR zeigte `BufferedWriter.` als Allocation-Site; kleinerer Buffer reduziert pro Response Allocation. + - Tests grün: 213 tests, 4519 assertions, 0 failures; fmt/lint grün. + - Benchmark: + - Encounter warm teils neutral/minimal besser, aber nicht stabil genug. + - Observation 5000 schlechter: ca. 48-52 ms statt ca. 42-46 ms. + - Wieder entfernt; 64k Buffer bleibt sinnvoll für große XML-Seiten. + +10. `write-xml-str` ohne `(str ...)` für vorhandene Strings + - Experiment: + - `write-xml-str` änderte `(XmlUtil/writeEscaped writer (str s))` zu + `(XmlUtil/writeEscaped writer (if (string? s) s (str s)))`. + - Verdacht: JFR zeigte `XmlUtil.writeEscaped` und String/StringBuilder-Kosten; vielleicht unnötige Coercion vermeiden. + - Tests: + - `make -C modules/fhir-structure fmt`: grün. + - `make -C modules/fhir-structure lint`: grün. + - `make -C modules/fhir-structure test`: 213 tests, 4519 assertions, 0 failures. + - Benchmark auf 8080 mit bestehender DB: + - Encounter 1000 warm: ca. 10-13 ms XML vs ca. 7-8 ms JSON. + - Encounter 5000 warm: ca. 40-42 ms XML vs ca. 24-30 ms JSON. + - Observation 1000 warm: ca. 12-17 ms XML vs ca. 8-17 ms JSON. + - Observation 5000 warm: ca. 54-58 ms XML vs ca. 29-35 ms JSON. + - Ergebnis: + - Kein stabiler Gewinn; Observation 5000 deutlich schlechter als der letzte gute Stand. + - Änderung wieder entfernt. + - 8080 neu gebaut und neu gestartet auf dem guten Bundle.entry-Stand (`204a472bd`). + +## Aktueller gehaltener Kandidat nach `204a472bd` + +11. Java `XmlDirectWriter` für häufige Complex Types + - Neue Datei: + - `modules/fhir-structure/java/blaze/fhir/spec/type/XmlDirectWriter.java` + - Unterstützt aktuell: + - `Coding` + - `CodeableConcept` + - `Period` + - Die schnellen Writer schreiben nur einfache Werte ohne `id`/`extension` an Elementen und ohne Extensions an Primitive-Kindern. + - Sobald so ein Sonderfall erkannt wird, geben sie `false` zurück, bevor etwas geschrieben wurde; der Clojure-Writer fällt dann auf den generischen Pfad zurück. + - Integration: + - `write-xml-field-1!` nutzt den Property-Handler-Typ für direkte Felder, damit nicht bei jedem Complex Value mehrere `instance?`-Checks laufen. + - `write-value!` nutzt `instance?` nur für polymorphe/getaggte Werte mit vorhandenem XML-Tag. + - Tests: + - Roundtrip für direkte Felder: `Patient.meta.security` (`Coding`) und `HumanName.period` (`Period`). + - Roundtrip für polymorphe Felder: `Observation.code` (`CodeableConcept`) und `Observation.effectivePeriod`. + - Fallback-Tests mit Extensions prüfen, dass Extensions erhalten bleiben. + - Direkte Unit-Tests auf `XmlDirectWriter/writeCodeableConcept` und `writePeriod` prüfen XML-Ausgabe und fallback-before-write. + - Verification: + - `make -C modules/fhir-structure fmt`: grün. + - `make -C modules/fhir-structure lint`: grün. + - `make -C modules/fhir-structure test`: grün (`217 tests, 4535 assertions`). + - `make -C modules/fhir-structure test-coverage`: grün (`ALL FILES 95,39% Forms`, `94,82% Lines`). + - Benchmarks: + - CodeableConcept/Coding brachte bei Encounter/Observation/Condition stabil Gewinn gegenüber dem Bundle.entry-only Stand. + - Period brachte Consent sichtbar nach vorne, aber nur mit typbasiertem Dispatch ohne breite `instance?`-Checks in `write-xml-field-1!`. + - Breiter Period-`instance?`-Dispatch hatte Observation verschlechtert und wurde deshalb verworfen. + - Aktueller Kandidat bleibt bei lokalen Tests weiterhin langsamer als JSON, reduziert aber einen messbaren Teil der XML-Lücke. + - JFR auf dem Java-Fastpath-Kandidaten: + - `XmlUtil.writeEscaped` und `BufferedWriter.write` dominieren jetzt deutlich. + - Interpretation: Der nächste große Hebel liegt wahrscheinlich weniger in weiteren kleinen Clojure-Dispatch-Spezialisierungen und stärker in weniger Writer-Calls/einem kohärenteren XML-Byte-Writer oder generierten XML-Writer-Pfaden. + - Frischer A/B-Vergleich gegen vorherigen Commit `204a472bd`, gleiche 8080-DB, Median der warmen Läufe 2-7: + - Encounter `_count=1000`: 19,5 ms -> 10,9 ms, ca. 44% schneller. + - Encounter `_count=5000`: 47,9 ms -> 39,9 ms, ca. 17% schneller. + - Observation `_count=1000`: 12,2 ms -> 13,0 ms, ca. 6% langsamer/neutral im Rauschen. + - Observation `_count=5000`: 54,2 ms -> 47,9 ms, ca. 12% schneller. + - Consent `_count=1000`: 52,4 ms -> 40,2 ms, ca. 23% schneller. + - Consent `_count=5000`: 205,5 ms -> 186,8 ms, ca. 9% schneller. + - Fazit dieses Commits: + - Bringt realen Zusatzgewinn, aber nicht mehr den riesigen Sprung des Bundle.entry-Fastpaths. + - Effekt ist typenabhängig: stark bei Consent/Encounter 1000, moderat bei großen Pages, Observation 1000 neutral bis minimal schlechter. + +## Neuer Stand nach `8b46cb488` + +12. Java `XmlDirectWriter`: Period DateTime manuell schreiben + - Commit: `8b46cb488 WIP write Period dateTime XML values manually`. + - Ersetzt `DateTime.valueAsString()`/`DateTimeFormatter` im schnellen Period-Pfad durch manuelles Schreiben für den üblichen Jahresbereich `0000..9999`; außerhalb wird sauber auf den generischen Writer zurückgefallen. + - Nach der stale-class-Korrektur neu sauber gebaut und gemessen. + - Clean Period-only Benchmark: + - Consent `_count=5000`: XML warm ca. `126-141 ms`, JSON ca. `118-138 ms`. + - Observation `_count=5000`: XML warm ca. `40-46 ms`, JSON ca. `26-32 ms`. + - Encounter `_count=5000`: XML warm ca. `33-36 ms`, JSON ca. `24-27 ms`. + - Interpretation: + - Consent ist damit viel näher an JSON als in älteren Messungen. + - `DateTimeFormatter` ist im Profil fast weg; danach dominieren Escaping/Writer/String-Pfad. + +13. Aktueller noch zu committender Kandidat: Coding/CodeableConcept primitive Werte direkt schreiben + - Dateien: + - `modules/fhir-structure/java/blaze/fhir/spec/type/XmlDirectWriter.java` + - `modules/fhir-structure/test/blaze/fhir/spec_test.clj` + - Idee: + - In `writeCoding` nicht mehr `Primitive.valueAsString()` nutzen, sondern `Uri.value()`, `String.value()`, `Code.value()` und `Boolean.value()` direkt. + - Auch `CodeableConcept.text` direkt schreiben. + - Semantik beachten: Primitive-Objekt vorhanden aber Wert `nil` muss weiterhin `` ergeben; primitives Objekt `nil` wird nicht geschrieben. + - Test ergänzt: + - `write-xml-codeable-concept-direct-test` prüft jetzt zusätzlich `Coding.userSelected`. + - Verification vor Commit: + - `make -C modules/fhir-structure fmt`: grün. + - `make -C modules/fhir-structure lint`: grün. + - `make -C modules/fhir-structure test`: grün (`218 tests, 4543 assertions`). + - `make -C modules/fhir-structure test-coverage`: grün (`ALL FILES 95,39% Forms`, `94,81% Lines`). + - `make -C modules/fhir-structure clean`: grün. + - `make -C modules/fhir-structure prep`: grün. + - `make uberjar`: grün. + - `javap`: neue String/Boolean-Helfer im Jar, kein `writeReference`. + - Benchmark `blaze:xml-coding-values-jdk`: + - Consent `_count=5000`: XML warm nach Warmup ca. `110-128 ms`, JSON ca. `111-125 ms`; mehrere Läufe XML gleichauf oder leicht schneller. + - Observation `_count=5000` seriell: XML ca. `38-41 ms` meist, JSON ca. `23-27 ms`. + - Encounter `_count=5000`: XML ca. `34-36 ms` meist, JSON ca. `24-27 ms`. + - Condition `_count=5000`: XML ca. `22-26 ms`, JSON ca. `18-21 ms`. + - Interpretation: + - Consent erreicht lokale JSON-Parität bzw. einzelne schnellere Läufe. + - Observation/Encounter/Condition profitieren höchstens leicht und bleiben hinter JSON. + - Der verbleibende Haupthebel ist vermutlich weniger `valueAsString()` in Coding, sondern XML-Payload/escaping/writer-call count und ggf. breitere direkte Writer-Pfade für häufige verschachtelte Typen. + +14. Aktueller Kandidat: `XmlUtf8Writer` schreibt ASCII in einem Durchlauf + - Datei: + - `modules/fhir-structure/java/blaze/fhir/XmlUtf8Writer.java` + - Motivation: + - XML-only JFR auf `d5e664381` mit Consent `_count=5000` zeigte klare Writer-Dominanz: + - `XmlUtf8Writer.writeAscii(String, int, int)`: ca. `26%`. + - `XmlUtf8Writer.writeEscaped(String)`: ca. `25%`. + - `XmlUtf8Writer.write(String, int, int)`: ca. `11%`. + - Das alte Muster scannte sichere ASCII-Strings häufig einmal in `writeEscaped`/`write` und kopierte danach in `writeAscii` nochmal. + - Änderung: + - `writeEscaped(String)` schreibt den häufigen sicheren ASCII-Fall direkt in den internen Byte-Buffer. + - `write(String, int, int)` und `write(char[], int, int)` schreiben ASCII ebenfalls direkt. + - Escaping-Semantik bleibt gleich: `&`, `<`, `>`, `"` werden ersetzt; ungültige XML-Zeichen werden in `writeEscaped` zu `?`; Surrogate werden wie vorher behandelt. + - Verification: + - `make -C modules/fhir-structure fmt`: grün. + - `make -C modules/fhir-structure lint`: grün. + - `make -C modules/fhir-structure test`: grün (`218 tests, 4543 assertions`). + - `make -C modules/fhir-structure clean`: grün. + - `make -C modules/fhir-structure prep`: grün. + - `make uberjar`: grün. + - `javap`: `XmlDirectWriter` weiterhin korrekt, kein `writeReference`. + - Docker-Image: `blaze:xml-utf8-onepass-jdk`, 8080 läuft damit auf unverändertem `blaze_blaze-data`. + - `make -C modules/fhir-structure test-coverage`: grün (`ALL FILES 95,36% Forms`, `94,81% Lines`). + - Benchmarks auf 8080, gleiche DB: + - Consent `_count=5000`, 12 Läufe: + - XML nach Warmup oft `111-125 ms`; beste warme Läufe `111-113 ms`. + - JSON warm grob `116-139 ms`. + - Ergebnis: XML jetzt lokal bei Consent häufig gleichauf oder schneller als JSON. + - Observation `_count=5000`, serieller Repeat: + - XML warm meist `33-39 ms`. + - JSON meist `23-30 ms`. + - Ergebnis: kein Regress; eher besser als der letzte Stand (`38-41 ms`). + - Encounter `_count=5000`: + - XML warm meist `35-38 ms`. + - JSON meist `27-40 ms`. + - Ergebnis: neutral bis leicht besser, aber nicht stabil schneller als JSON. + - Condition `_count=5000`: + - XML warm `21-24 ms`. + - JSON `15-19 ms`. + - Ergebnis: stabil, aber JSON bleibt schneller. + - Entscheidung: + - Behalten und lokal committen. Das ist ein allgemeiner Writer-Hebel, nicht datensatzspezifisch. + +15. Aktueller Kandidat nach `538ae8b77`: schnellerer ASCII-Zweig in `writeEscaped` + - Datei: + - `modules/fhir-structure/java/blaze/fhir/XmlUtf8Writer.java` + - Motivation: + - JFR nach `538ae8b77` zeigte `writeAscii` nicht mehr als Hotspot, aber `XmlUtf8Writer.writeEscaped(String)` blieb dominant: + - `writeEscaped`: ca. `35%`. + - `XmlDirectWriter.writeStringValueField`: ca. `8%`. + - `write(String, int, int)`: nur noch ca. `2%`. + - Änderung: + - `writeEscaped` hat jetzt zuerst einen expliziten ASCII-Fall. + - Druckbare ASCII-Zeichen, die keine XML-Sonderzeichen sind, werden direkt in den Buffer geschrieben. + - `&`, `<`, `>`, `"` gehen weiterhin in die Entity-Zweige. + - ASCII-Control-Zeichen bleiben wie vorher: Tab/LF/CR erlaubt, andere werden `?`. + - Nicht-ASCII/Surrogates bleiben semantisch unverändert. + - Verification: + - `make -C modules/fhir-structure test`: grün (`218 tests, 4543 assertions`). + - `make -C modules/fhir-structure clean`: grün. + - `make -C modules/fhir-structure prep`: grün. + - `make uberjar`: grün. + - Docker-Image: `blaze:xml-utf8-ascii-branch-jdk`, 8080 läuft damit auf unverändertem `blaze_blaze-data`. + - `make -C modules/fhir-structure fmt`: grün. + - `make -C modules/fhir-structure lint`: grün. + - `make -C modules/fhir-structure test-coverage`: grün (`ALL FILES 95,39% Forms`, `94,81% Lines`). + - Benchmarks auf 8080, gleiche DB: + - Consent `_count=5000`, seriell nach Warmup: + - XML fast durchgehend `111-121 ms`. + - JSON meist `126-146 ms`. + - Ergebnis: XML klar schneller als JSON in diesem Lauf. + - Observation `_count=5000`, seriell: + - XML stabil `33-35 ms`. + - JSON `24-29 ms`. + - Ergebnis: kein Regress, sehr guter XML-Wert. + - Encounter `_count=5000`: + - XML meist `33-35 ms`, ein Ausreißer `47 ms`. + - JSON meist `25-32 ms`. + - Ergebnis: neutral/leicht besser, JSON bleibt im Median schneller. + - Condition `_count=5000`: + - XML meist `21-23 ms`, ein Ausreißer `28 ms`. + - JSON meist `17-19 ms`. + - Ergebnis: stabil, JSON bleibt schneller. + - Entscheidung: + - Behalten und lokal committen. Es ist ein allgemeiner, kleiner Writer-Hebel und hat keine beobachtete Regression in den Zielressourcen. + +12. Period DateTime ohne Zwischenstring schreiben + - Profiling nach Commit `83618c85a`: + - `XmlUtil.writeEscaped(Writer, String)`: ca. 28% Samples. + - `BufferedWriter.write(String, int, int)`: ca. 24%. + - `Primitive.valueAsString()`: ca. 5,8%. + - `DateTimeFormatter...format`: ca. 5,5%. + - Änderung: + - In `XmlDirectWriter.writePeriod` werden `start`/`end` jetzt über `writeDateTimeField` geschrieben. + - Für `LocalDateTime`/`OffsetDateTime` nutzt das direkt `DateTimeFormatter.formatTo(..., writer)`. + - Dadurch entfällt im direkten Period-Pfad der Zwischenstring aus `DateTime.valueAsString()` und das anschließende XML-Escaping des DateTime-Strings. + - Fallback bei `id`/`extension` bleibt unverändert, weil `writePeriod` weiterhin vorher `canWritePeriod` prüft. + - Verification: + - `clojure -T:build compile` in `modules/fhir-structure`: grün. + - `make -C modules/fhir-structure fmt`: grün. + - `make -C modules/fhir-structure lint`: grün. + - `make -C modules/fhir-structure test`: grün (`217 tests, 4535 assertions`). + - `make -C modules/fhir-structure test-coverage`: grün (`ALL FILES 95,36% Forms`, `94,82% Lines`). + - Benchmarks auf 8080 mit gleicher DB, warme Läufe: + - Consent `_count=1000`: ca. 38-52 ms, Median warm ca. 42 ms; vorher grob ca. 40 ms, also neutral bis leicht besser. + - Consent `_count=5000`: ca. 143-178 ms, Median warm ca. 160 ms; vorher grob ca. 187 ms, also klar schneller. + - Observation `_count=1000`: ca. 9,7-14,6 ms, keine Regression. + - Observation `_count=5000`: ca. 43-56 ms, keine Regression gegenüber vorher grob ca. 48 ms. + - Encounter/Condition blieben unauffällig/neutral bis leicht besser im lokalen Lauf. + - Fazit: + - Kleiner, messbarer Gewinn vor allem bei periodlastigem Consent. + - Behalten und lokal committen. + +13. Verworfener Versuch: direkter `dateTime`/`instant` Primitive-Fastpath + - Motivation: + - Nach Commit `0e41c80e2` zeigte JFR weiterhin `Primitive.valueAsString()` und `DateTimeFormatter` als relevante Kosten. + - Idee: `:fhir/dateTime` und `:fhir/instant` im generischen XML-Field-Dispatch direkt schreiben, ohne `valueAsString()` und ohne XML-Escaping des sicheren DateTime-Strings. + - Tests zuerst: + - Direkte Tests für `XmlDirectWriter/writeDateTime` und `writeInstant` wurden geschrieben und scheiterten erwartbar, weil Methoden fehlten. + - Implementiert: + - `XmlDirectWriter/writeDateTime` + - `XmlDirectWriter/writeInstant` + - Dispatch in `write-xml-field-1!` für `:fhir/dateTime`/`:fhir/instant`. + - `write-value!`-Zweige für polymorphe DateTime/Instant-Werte. + - Gefundener Fehler: + - `Encounter` XML bei `_count=1000/5000` wurde plötzlich nur ca. 21 KB groß. + - Logs zeigten NPE beim XML-Output. + - Ursache: direkter `writeDateTime` behandelte `nil` nicht wie der bisherige Primitive-Pfad; `Period` mit nur `start` oder nur `end` rief den Writer mit `nil` für das fehlende Kind auf. + - Regressionstest ergänzt: `write-xml-period-direct-test` mit "Period with only start". + - Fix: + - `writeDateTime`/`writeInstant` wurden für `nil` gefixt; Tests danach grün. + - Benchmark nach Fix: + - `Encounter` Payload wieder korrekt, aber nicht schneller; eher schlechter als der letzte gute Stand. + - `Consent 5000` ungefähr im Bereich des letzten Commits, nicht klar besser. + - `Observation` teils besser, aber nicht genug, um die Regression/Komplexität zu rechtfertigen. + - Entscheidung: + - Breiter `dateTime`/`instant`-Fastpath entfernt. + - Regressionstest für `Period` mit nur `start` behalten. + - 8080 neu gebaut und wieder auf den guten Stand ohne diesen Versuch zurückgesetzt. + +14. Experiment: eigener UTF-8 Writer für XML Output + - Stand danach uncommitted, 2026-06-07: + - Neue Java-Klasse `blaze.fhir.XmlUtf8Writer`. + - `blaze.fhir.spec/write-xml` nutzt `XmlUtf8Writer` statt `BufferedWriter(OutputStreamWriter(... UTF_8), 65536)`. + - Writer schreibt direkt in einen byte buffer auf den `OutputStream`. + - ASCII-Runs werden blockweise in den Buffer kopiert; Nicht-ASCII wird als UTF-8 codiert; Surrogat-Paare werden korrekt behandelt. + - Test `xml-utf8-writer-test` in `spec_test.clj` deckt `Aä😀BC` ab. + - TDD: + - Test zuerst hinzugefügt, lief rot mit `ClassNotFoundException: blaze.fhir.XmlUtf8Writer`. + - Danach Java-Klasse implementiert und `write-xml` umgestellt. + - Korrektheit/Checks: + - `clojure -T:build compile` grün. + - `make -C modules/fhir-structure fmt` grün. + - `make -C modules/fhir-structure lint` grün. + - `make -C modules/fhir-structure test` grün, 218 Tests, 4538 Assertions. + - `make -C modules/fhir-structure test-coverage` grün, `ALL FILES 95,39% Forms`. + - 8080 neu gebaut mit Image `blaze:xml-utf8-writer-jdk`; DB-Volume `blaze_blaze-data` blieb erhalten. + - Payload sanity: + - `Encounter?_count=1000` XML: `1480534` bytes. + - `Consent?_count=1000` XML: `8790820` bytes. + - Benchmarks gegen 8080, Warmup jeweils kritisch: + - Erste naive Writer-Version: + - `Encounter 5000` warm ca. `43.9-47.4 ms`, nicht klar besser. + - `Consent 5000` warm ca. `146.4-155.2 ms`, leicht besser als bisher grob `~160 ms`. + - `Observation 5000` warm ca. `44.3-51.7 ms`, neutral bis leicht besser. + - Optimierte ASCII-Run-Version: + - `Consent 5000`: `152.1-181.2 ms`, Median etwa im Bereich `160 ms`, also neutral/leicht besser, aber mit Ausreißer. + - `Observation 5000`: `43.6-45.9 ms`, gut/stabil. + - `Encounter 5000` erste Runde nach Restart: `43.1-56.7 ms`; zweite warme Runde: `35.6-42.3 ms`. + - `Encounter 1000` zweite warme Runde: `9.1-12.1 ms`, aber JSON dort `~7-8 ms`. + - Interpretation: + - Writer ist allgemein und korrekt. + - Effekt ist kleiner als die direkten Complex-Type-Fastpaths, aber warm offenbar positiv oder neutral. + - Nicht überbewerten: Messungen bleiben JIT/Warmup-sensibel, besonders direkt nach Neustart. + - Nächster sinnvoller Schritt: JFR nach Writer, um zu sehen, ob `OutputStreamWriter`/`BufferedWriter` aus den Hot Methods verschwunden ist und ob nun `XmlUtf8Writer.writeAscii`/`writeEscaped`/FHIR field dispatch dominieren. + +15. Experiment: direktes XML-Escaping im `XmlUtf8Writer` + - JFR nach dem Writer zeigte bei XML-only `Consent?_count=5000`: + - `XmlUtil.writeEscaped(Writer, String)` 18,64% + - `XmlUtf8Writer.writeAscii(String, int, int)` 18,05% + - `XmlUtf8Writer.write(String, int, int)` 10,95% + - `Primitive.valueAsString()` 7,10% + - `DateTimeFormatter...format` weiterhin sichtbar. + - Interpretation: + - Der alte `XmlUtil.writeEscaped` scannt den String und ruft dann `writer.write(...)`. + - `XmlUtf8Writer.write(...)` scannt dieselben ASCII-Abschnitte erneut. + - Änderung: + - `XmlUtf8Writer.writeEscaped(String)` ergänzt. + - `XmlUtil.writeEscaped` delegiert bei `writer instanceof XmlUtf8Writer` direkt dorthin. + - Test `xml-utf8-writer-test` erweitert um Escaping inklusive `&`, `<`, `>`, `"`, Nicht-ASCII, Surrogat-Paar und invalidem XML-Control-Char. + - TDD: + - Test zuerst rot mit `No matching method writeEscaped`. + - Danach Implementierung und `XmlUtil`-Brücke. + - Checks: + - `clojure -T:build compile` grün. + - `make -C modules/fhir-structure fmt` grün. + - `make -C modules/fhir-structure lint` grün. + - `make -C modules/fhir-structure test` grün, 218 Tests, 4539 Assertions. + - `make -C modules/fhir-structure test-coverage` grün, `ALL FILES 95,39% Forms`. + - 8080 neu gebaut mit `blaze:xml-utf8-writer-jdk`, DB-Volume blieb erhalten. + - Benchmarks nach Escape-Fastpath: + - `Consent 5000`: warm `142.6-160.0 ms`, meist ca. `145-156 ms`; vorher im Writer-Stand eher `~160-170 ms`, nach Period-Fastpath grob `~160 ms`. + - `Encounter 5000`: warm `32.9-34.7 ms`, bisher meist `35-42 ms`. + - `Observation 5000`: warm `41.0-47.8 ms`, neutral bis leicht besser. + - Interpretation: + - Das ist ein allgemeiner Gewinn im XML-Outputpfad, weil alle XML-Escapes über `XmlUtil.writeEscaped` laufen. + - Keine sichtbare Regression bei Encounter/Observation. + - Nächster sinnvoller Schritt: nochmal XML-only-JFR auf diesem Stand; wahrscheinlich bleiben `Primitive.valueAsString`, DateTime formatting, `PersistentArrayMap.indexOf`, `write_xml_fields_BANG_` als nächste Kandidaten. + +16. Verworfener Versuch: allgemeiner Primitive-Fastpath ohne `valueAsString` + - Idee: + - `XmlDirectWriter/writePrimitiveField` öffentlich machen und in `writing_context.clj` vor dem bisherigen Primitive-Pfad aufrufen. + - Kontrollierte Werte (`boolean`, `integer`, `dateTime`, `instant`) direkt schreiben. + - Strings/URIs/Codes zunächst breit über Java-Switch/default laufen lassen, danach auf eine engere Variante ohne String-Default reduziert. + - TDD: + - Test `write-xml-primitive-field-direct-test` hinzugefügt und rot gesehen. + - Java/Clojure-Implementierung danach grün. + - Ergebnis: + - Modultests grün. + - Breite Variante war nicht stabil schneller; `Consent 5000` wurde eher schlechter. + - Engere Variante war ebenfalls nicht überzeugend: + - Seriell warm `Encounter 5000` etwa `35-38 ms`, okay aber nicht besser als Escape-Stand. + - Seriell warm `Consent 5000` etwa `159-166 ms`, schlechter als die guten Escape-Stand-Läufe (`~145-156 ms`). + - Entscheidung: + - Versuch vollständig entfernt, nicht committed. + - 8080 neu gebaut und wieder auf letzten guten Stand (`5c92ced8f`, direkter Escape-Pfad) gesetzt. + - Erkenntnis: + - `valueAsString` breit zu vermeiden ist nicht automatisch ein Gewinn, weil ein generischer Hook/Switch alle String-lastigen Primitive trifft. + - Der nächste Ansatz sollte konkreter sein: JFR-getrieben pro heißem FHIR-Typ/Feld oder generierter Writer, nicht ein universeller Primitive-Interceptor. + +## Wahrscheinliche nächste invasive Varianten + +Variante A: Spezialisierte XML-Serialisierung in Java-FHIR-Typen +- Analog zur existierenden JSON-Spezialisierung. +- Primitive/Complex/Resource-Typen bekommen serializeXmlField/serializeXmlValue oder ähnliches. +- Ziel: weniger generischer Clojure-Dispatch, weniger valAt-Scans, weniger tag lookup. +- Risiko: viele Java-Typen betroffen oder Generator/Codegen nötig. +- Vermutlich stärkster Hebel. + +Variante B: Generierter XML-Writer pro FHIR-Typ +- Aus StructureDefinitions einmal Writer-Funktionen/classes generieren. +- Pro Typ direkte Feldzugriffe/Handler, keine generische Feldliste zur Laufzeit. +- Könnte in Clojure oder Java laufen; Java wahrscheinlich schneller, Clojure einfacher. +- Risiko: größerer Architekturumbau. + +Variante C: Vollständiger UTF-8 Byte XML Writer +- Erster Schritt ist jetzt als `XmlUtf8Writer` umgesetzt. +- Weitere Ausbaustufe wäre konsequenter: Tags und häufige Attribute als UTF-8 bytes precomputen, Escaping direkt bytes schreiben und ggf. Writer-API im XML-Pfad schrittweise ersetzen. +- Nur sinnvoll, wenn JFR nach dem Writer noch Writer/Escaping als klare Hotspots zeigt. + +Variante D: Date/Time XML formatting spezialisieren +- DateTime/Instant/Time valueAsString erzeugt aktuell Strings. +- Eigenes writeXmlValue kann theoretisch direkt schreiben. +- Frühere kleine Version war nicht gut genug, aber im Rahmen von A oder C wieder sinnvoll. + +Variante E: Payload/Compression +- gzip hilft vor allem bei Netzwerk/IO, lokal weniger bei CPU-Pfad. +- Könnte abhängig von Latenz/Bandbreite/Größe aktiviert werden, ist aber ein anderer Hebel als reine Server-CPU. + +## Nächster sinnvoller Schritt + +Nach Commit des aktuellen Java-Complex-Type-Fastpaths neu profilieren und dann mit den invasiveren Varianten weitergehen, jeweils als Experiment mit schneller Rückfallmöglichkeit: + +1. Optional JFR auf aktuellem Bundle.entry Stand: + docker exec blaze jcmd 1 JFR.start name=bundleentry settings=profile filename=/tmp/bundleentry.jfr duration=45s + profiling/xml-paging/curl-single-page.sh Encounter '1000' 80 + docker cp blaze:/tmp/bundleentry.jfr /tmp/bundleentry.jfr + jfr view hot-methods /tmp/bundleentry.jfr + jfr view allocation-by-site /tmp/bundleentry.jfr +2. Weitere kleine Typ-Fastpaths nur noch dann wählen, wenn JFR/Benchmark einen klaren Hotspot zeigt. `Reference`/`Identifier` wären Kandidaten, aber der aktuelle JFR deutet eher auf Writer/Escaping hin. +3. Test zuerst über bestehende XML Roundtrip/Writer-Tests plus gezielten Test falls öffentliches Verhalten sichtbar wird. +4. Benchmark gegen 8080-Datensatz mit Encounter/Observation/Condition/Consent bei _count=1000 und 5000. +5. Nur behalten, wenn stabil besser; sonst entfernen wie die anderen Experimente. + +## Nützliche Befehle + +Health: +curl -fsS http://localhost:8080/health + +Benchmark: +profiling/xml-paging/curl-single-page.sh Encounter '1000 5000' 7 +profiling/xml-paging/curl-single-page.sh Observation '1000 5000' 7 +profiling/xml-paging/curl-single-page.sh Condition '1000 5000' 5 +profiling/xml-paging/curl-single-page.sh Consent '1000 5000' 5 + +Build/restart 8080 preserving DB: +make uberjar +docker build -f profiling/xml-paging/Dockerfile.jdk-profile -t blaze:xml-direct-context-jdk . +docker rm -f blaze +docker run -d --name blaze -p 8080:8080 -p 8082:8081 -e BASE_URL=http://localhost:8080 -e JAVA_TOOL_OPTIONS=-Xmx4g -v blaze_blaze-data:/app/data blaze:xml-direct-context-jdk + +JFR: +docker exec blaze jcmd 1 JFR.start name=xml settings=profile filename=/tmp/xml.jfr duration=45s +profiling/xml-paging/curl-single-page.sh Encounter '1000' 80 +docker cp blaze:/tmp/xml.jfr /tmp/xml.jfr +jfr view hot-methods /tmp/xml.jfr +jfr view allocation-by-site /tmp/xml.jfr + +Verification: +make -C modules/fhir-structure fmt +make -C modules/fhir-structure lint +make -C modules/fhir-structure test +make -C modules/fhir-structure test-coverage + +## Update 2026-06-07: Reference verworfen, Period-DateTime direkt geschrieben + +### Verworfener Versuch: `Reference` Fastpath + +- Idee: + - Simple `Reference` direkt in `XmlDirectWriter` schreiben, falls `id`, `extension` und `identifier` fehlen. + - Ziel war weniger Clojure-Dispatch bei häufigen Referenzfeldern. +- Ergebnis: + - Tests waren grün, aber Benchmarks waren nicht überzeugend. + - `Encounter` blieb etwa im bekannten Bereich, `Consent` wurde eher schlechter (`5000` warm grob 160-176 ms statt zuvor eher 145-160 ms). +- Entscheidung: + - Vollständig entfernt, nicht committed. + - Erkenntnis: ein weiterer komplexer Typ-Fastpath lohnt nur, wenn JFR ihn klar zeigt; zusätzliche Dispatch-Prüfungen können bei vielen Feldern selbst kosten. + +### Behaltene Änderung: direkter Period-DateTime-Writer + +- JFR nach `5c92ced8f` zeigte weiterhin: + - `XmlUtf8Writer.writeAscii` ~21% + - `XmlUtf8Writer.writeEscaped` ~14% + - `DateTimeFormatterBuilder$CompositePrinterParser.format` ~9% + - `Primitive.valueAsString` ~7% + - Allocation: `DateTimeFormatter.formatTo` ~20%, `AbstractStringBuilder` ~20%, `Arrays.copyOfRange` ~19% +- Änderung: + - `XmlDirectWriter.writePeriod` nutzt für `LocalDateTime` und `OffsetDateTime` keinen `DateTimeFormatter.formatTo` mehr. + - Jahr/Monat/Tag/Uhrzeit/Fraction/Offset werden direkt in den `Writer` geschrieben. + - Zusätzlicher Test für Fractional Seconds + UTC: `2019-01-01T00:00:00.12Z`. + - Zusätzlicher Fallback-Test für programmgesteuerte Jahre außerhalb des direkten Bereichs, z.B. `+10000-01-01T00:00:00`. +- Benchmarks mit `blaze:xml-period-datetime-jdk`, bestehendem Datenvolume: + - `Consent?_count=5000`: warm bis ca. 144-153 ms; vorher guter Bereich eher ca. 145-160 ms, Vergleichslauf letzter Stand nach Warmup ca. 157-160 ms. + - `Observation?_count=5000`: warm ca. 38-40 ms; vorher eher ca. 41-48 ms. + - `Encounter?_count=5000`: seriell warm ca. 35-38 ms; keine klare Verbesserung, aber auch keine belastbare Regression. + - Kleine Pages `_count=1000`: XML bleibt knapp hinter JSON, keine auffällige Regression. +- Verification: + - `make -C modules/fhir-structure fmt` OK + - `make -C modules/fhir-structure lint` OK + - `make -C modules/fhir-structure test` OK: 218 tests, 4543 assertions + - `make -C modules/fhir-structure test-coverage` OK: ALL FILES 95.36% forms +- Aktueller 8080: + - Läuft mit Image `blaze:xml-period-datetime-jdk`. + - 8081 alter Baseline-Blaze bleibt unverändert. + +## Update 2026-06-08: Type-Cache verworfen, Identifier direkt geschrieben + +### Verworfener Versuch: bekannten FHIR-Typ in `write-value!` weiterreichen + +- Idee: + - Bei polymorphen XML-Feldern wird `:fhir/type` zur Tag-Bestimmung gelesen und direkt danach in `write-value!` nochmal für Handler-Dispatch. + - Versuch: `write-value!` um eine interne Arity mit bereits bekanntem Typ erweitern. +- Ergebnis: + - Erster Versuch hatte einen Bug für polymorphe Primitive (`DateTime`), wurde gefixt. + - Korrekte Variante war benchmarkseitig kein Gewinn; Consent blieb grob auf bisherigem Niveau, andere Ressourcen teils neutral bis leicht schlechter. +- Entscheidung: + - Vollständig entfernt, nicht behalten. + +### Behaltene Änderung: direkter `Identifier`-Writer + +- Änderung: + - `XmlDirectWriter.writeIdentifier` schreibt einfache Identifier direkt. + - Erlaubt sind Identifier ohne `id`, ohne `extension`, ohne `assigner`; `use`, `type`, `system`, `value`, `period` dürfen vorhanden sein, solange die enthaltenen Werte ebenfalls direkt schreibbar sind. + - Hook in `writing_context.clj` für Felder vom Typ `:fhir/Identifier` und direkte Werte vom Java-Typ `Identifier`. + - Neuer Test `write-xml-identifier-direct-test` prüft einfache Ausgabe und Fallback vor dem Schreiben bei Extension. +- Verification: + - Rotlauf vor Implementierung: erwarteter Compile-Fehler, `XmlDirectWriter/writeIdentifier` fehlt. + - `make -C modules/fhir-structure test` OK: 219 tests, 4547 assertions. + - `make -C modules/fhir-structure fmt` OK. + - `make -C modules/fhir-structure lint` OK. + - `make -C modules/fhir-structure test-coverage` OK: ALL FILES 95.17% forms. + - `javap` zeigt `writeIdentifier(java.io.Writer, java.lang.String, blaze.fhir.spec.type.Identifier)`. +- Benchmark mit `blaze:xml-direct-identifier-jdk`, bestehendem 8080-Datenvolume, warm `_count=5000`: + - Consent XML ca. 105-110 ms, JSON meist ca. 116-120 ms. + - Encounter XML ca. 29-31 ms, JSON meist ca. 24-30 ms. + - Condition XML ca. 19-20 ms, JSON meist ca. 16-17 ms. + - Patient XML ca. 22-23 ms, JSON meist ca. 17-19 ms. + - Observation XML ca. 34-36 ms, JSON meist ca. 24-25 ms; etwa neutral gegenüber vorher. +- Aktueller 8080: + - Läuft mit Image `blaze:xml-direct-identifier-jdk`. + - 8081 alter Baseline-Blaze bleibt unverändert. + +## Update 2026-06-08: Meta direkt geschrieben, Quantity und Bundle-Entry-Shortcut verworfen + +### Behaltene Änderung: direkter `Meta`-Writer + +- Aktueller Commit: `1fcaa9011 WIP write Meta XML directly`. +- Änderung: + - `XmlDirectWriter.writeMeta` schreibt einfache `Meta` direkt. + - Direkter Pfad für `versionId`, `lastUpdated`, `source`, `profile`, `security`, `tag`, solange keine `id`/`extension` und nur direkt schreibbare Primitive/Codings vorhanden sind. + - Hook in `writing_context.clj` für `:fhir/Meta` und direkte Werte vom Java-Typ `Meta`. +- Verification: + - `make -C modules/fhir-structure fmt` OK. + - `make -C modules/fhir-structure lint` OK. + - `make -C modules/fhir-structure test` OK: 220 tests, 4551 assertions. + - `make -C modules/fhir-structure test-coverage` OK: ALL FILES 95.15% forms. + - Clean/prep/uberjar OK; `javap` zeigte `writeIdentifier` und `writeMeta`. +- Benchmark mit `blaze:xml-direct-meta-jdk`, bestehendem 8080-Datenvolume, warm `_count=5000`: + - Consent XML ca. 104-114 ms, JSON ca. 115-133 ms; XML ist hier schneller. + - Encounter XML ca. 27-29 ms, JSON ca. 23-24 ms. + - Condition XML ca. 16-18 ms, JSON ca. 16-17 ms. + - Patient XML ca. 20-21 ms, JSON ca. 16-24 ms. + - Observation XML ca. 32-34 ms, JSON ca. 23-29 ms. + +### Verworfener Versuch: direkter `Quantity`-Writer + +- Idee: + - `Observation.valueQuantity` bzw. Quantity-Felder direkt schreiben. +- Ergebnis: + - Breiter type-basierter Hook war zu teuer und regressierte Encounter/Condition/Patient. + - Enger polymorpher Hook war für Observation neutral bis leicht besser, aber andere Ressourcen waren unstabil bzw. teils schlechter. +- Entscheidung: + - Vollständig entfernt, nicht committed. + +### Verworfener Versuch: kompakter `Bundle.entry`-Shortcut + +- Profil nach Meta zeigte Hotspots in `write-xml-fields!`, `Base.valAt`, Hash/Equiv und `write-search-bundle-entry!`. +- Idee: + - Häufige Such-Bundle-Entries enthalten nur `:fhir/type`, `:fullUrl`, `:resource`, `:search`. + - Für diese kompakte Form sollten sechs negative `.valAt`-Lookups auf `:id`, `:extension`, `:modifierExtension`, `:link`, `:request`, `:response` übersprungen werden. +- Test/Build: + - Zusätzlicher Schutztest für Entry mit `request` wurde temporär ergänzt. + - `make -C modules/fhir-structure test` OK mit dem Versuch. + - Testimage `blaze:xml-entry-compact-jdk` gebaut. +- Benchmark warm `_count=5000`, zweiter Lauf, Median ohne Run 1: + - Consent XML 108.5 ms vs JSON 129.4 ms. + - Condition XML 18.5 ms vs JSON 18.6 ms. + - Encounter XML 29.2 ms vs JSON 26.8 ms. + - Observation XML 34.3 ms vs JSON 26.4 ms. + - Patient XML 22.2 ms vs JSON 19.3 ms. +- Entscheidung: + - Nicht behalten. Consent/Condition sehen gut aus, aber der Gesamtgewinn ist nicht robust genug und bringt Encounter/Observation/Patient nicht näher an JSON. + - Änderungen vollständig entfernt; getrackter Arbeitsbaum wieder sauber. + +### Aktueller Zustand + +- 8080 läuft wieder mit Image `blaze:xml-direct-meta-jdk` und Volume `blaze_blaze-data`. +- 8081 läuft weiter mit altem Baseline-Image `blaze:baseline-jdk-profile` und Volume `blaze_old-data`. +- Getrackter Arbeitsbaum ist sauber; nur lokale/untracked Benchmark-/Hilfsdateien sind vorhanden. + +## Update 2026-06-08: Generischer Resource-Sparse-Writer verworfen + +### Kontext + +- Es wurde explizit festgelegt, dass kein Patch akzeptabel ist, der auf eine bestimmte FHIR-Version, konkrete Resource-Felder oder Profile hardcodiert ist. +- Ein kurz angesetzter Encounter-spezifischer Spike wurde deshalb sofort wieder entfernt, bevor er getestet oder behalten wurde. + +### Verworfener versionsneutraler Versuch + +- Idee: + - Für Resource-Handler zusätzlich aus den vorhandenen `element-definitions` eine `key -> XmlPropertyHandler + FHIR-order`-Tabelle erzeugen. + - Dünn besetzte Resources sollten nur ihre tatsächlich vorhandenen Keys einsammeln, nach StructureDefinition-Reihenfolge sortieren und schreiben. + - Bei unbekannten vorhandenen Keys sollte der alte generische Writer fallbacken. +- Eigenschaften: + - Keine hardcodierten Resource-Namen. + - Keine hardcodierten Feldlisten. + - Feldreihenfolge und Handler blieben aus denselben StructureDefinitions abgeleitet wie bisher. +- Verification während des Spikes: + - `make -C modules/fhir-structure fmt` OK. + - `make -C modules/fhir-structure lint` OK. + - `make -C modules/fhir-structure test` OK: 220 tests, 4551 assertions. +- Benchmark mit `blaze:xml-sparse-resource-jdk`, bestehendem 8080-Datenvolume, warm `_count=5000`, zweiter Lauf, Median ohne Run 1: + - Consent XML 113.5 ms vs JSON 149.1 ms. + - Encounter XML 28.9 ms vs JSON 24.9 ms. + - Condition XML 19.0 ms vs JSON 17.2 ms. + - Observation XML 34.3 ms vs JSON 29.7 ms. + - Patient XML 24.7 ms vs JSON 18.9 ms. +- Entscheidung: + - Nicht behalten. Gegen `xml-direct-meta` ist der Clojure-Sparse-Pfad nicht robust schneller und verschlechtert Patient/Condition. + - Vermutung: Array-Erzeugung, Map-Seq und Sortierung fressen die eingesparten `.valAt`-Lookups auf. + - Änderungen vollständig entfernt; getrackter Arbeitsbaum wieder sauber. + +### Aktueller Zustand nach diesem Versuch + +- 8080 läuft wieder mit Image `blaze:xml-direct-meta-jdk`. +- 8081 läuft weiter mit altem Baseline-Image `blaze:baseline-jdk-profile`.