Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 30 additions & 34 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
name: CI

on:
workflow_call:
push:
branches: [main]
pull_request:
Expand All @@ -15,68 +16,61 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6

- uses: cachix/install-nix-action@v31
- uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31

- uses: erlef/setup-beam@v1
id: beam
- name: Restore dependencies cache
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
elixir-version: 1.20.2
otp-version: 29.0.3
path: deps
key: ${{ runner.os }}-mix-deps-${{ hashFiles('mix.lock') }}

- name: Restore dependencies cache
uses: actions/cache@v5
- name: Restore build cache
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: |
deps
_build
key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }}
restore-keys: ${{ runner.os }}-mix-
path: _build
key: ${{ runner.os }}-mix-build-${{ hashFiles('flake.lock', 'mix.lock') }}

- name: Restore PLT cache
uses: actions/cache@v5
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: priv/plts
key: ${{ runner.os }}-plt-otp${{ steps.beam.outputs.otp-version }}-${{ hashFiles('**/mix.lock') }}-${{ github.sha }}
restore-keys: |
${{ runner.os }}-plt-otp${{ steps.beam.outputs.otp-version }}-${{ hashFiles('**/mix.lock') }}-
${{ runner.os }}-plt-otp${{ steps.beam.outputs.otp-version }}-
key: ${{ runner.os }}-plt-${{ hashFiles('flake.lock', 'mix.lock') }}

- name: Install dependencies
run: mix deps.get
run: nix develop --command mix deps.get --check-locked

- name: Compile
run: mix compile --warnings-as-errors
run: nix develop --command mix compile --warnings-as-errors

- name: Check formatting
run: mix format --check-formatted
run: nix develop --command mix format --check-formatted

- name: Check unused dependencies
run: mix deps.unlock --check-unused
run: nix develop --command mix deps.unlock --check-unused

- name: Check dependency cycles
run: mix xref graph --format cycles --fail-above 0
run: nix develop --command mix xref graph --format cycles --fail-above 0

- name: Run unit tests without soffice
run: mix test
run: nix develop --command mix test --exclude integration
env:
URP_INTEGRATION: "0"

- name: Build documentation
run: mix docs --warnings-as-errors
run: nix develop --command mix docs --warnings-as-errors

- name: Audit Hex dependencies
run: mix hex.audit

- name: Check Nix flake
run: nix flake check
run: nix develop --command mix hex.audit

- name: Run dialyzer
run: mix dialyzer
run: nix develop --command mix dialyzer

- uses: docker/setup-buildx-action@v4
- uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4

- name: Build soffice image
uses: docker/build-push-action@v7
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
context: benchmarks
file: benchmarks/Dockerfile.soffice-debian
Expand All @@ -88,10 +82,12 @@ jobs:
- name: Start soffice
run: |
docker run --detach --name soffice \
--publish 2002:2002 \
--publish 127.0.0.1:2002:2002 \
--volume /tmp:/tmp \
soffice
timeout 30 bash -c 'until nc -z localhost 2002; do sleep 1; done'

- name: Run integration tests
run: mix test --include lo26
run: nix develop --command mix test --include integration --include lo26
env:
URP_INTEGRATION: "1"
36 changes: 22 additions & 14 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,35 +6,43 @@ on:
- "v*"

permissions:
contents: write
contents: read

jobs:
validate:
name: Validate release commit
uses: ./.github/workflows/ci.yml

publish:
name: Publish to Hex
needs: validate
runs-on: ubuntu-latest
permissions:
contents: write

steps:
- uses: actions/checkout@v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6

- uses: erlef/setup-beam@v1
with:
elixir-version: "1.20.2"
otp-version: "29.0.3"
- uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31

- name: Restore dependencies cache
uses: actions/cache@v5
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: |
deps
_build
key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }}
restore-keys: ${{ runner.os }}-mix-
path: deps
key: ${{ runner.os }}-mix-deps-${{ hashFiles('mix.lock') }}

- name: Install dependencies
run: mix deps.get
run: nix develop --command mix deps.get --check-locked

- name: Verify tag, package version, and changelog
run: |
version="$(tr -d '[:space:]' < VERSION)"
test "${GITHUB_REF_NAME}" = "v${version}"
grep --fixed-strings --quiet "## [v${version}]" CHANGELOG.md
nix develop --command mix hex.build

- name: Publish to Hex
run: mix hex.publish --yes
run: nix develop --command mix hex.publish --yes
env:
HEX_API_KEY: ${{ secrets.HEX_API_KEY }}

Expand Down
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,31 @@
# Changelog

## [Unreleased]

### Changed

- Emit complete telemetry spans with start, stop, and exception events.
- Use the Nix flake as the Elixir/OTP source of truth in CI and release validation.
- Make LibreOffice integration tests an explicit opt-in instead of probing an ambient port.
- Clarify that multiple connections to one soffice process are supported but share state
and generally do not provide useful conversion parallelism.

### Fixed

- Return errors from non-bang APIs when pool checkout fails and discard broken diagnostic workers.
- Scope URP TID and OID caches to individual connections and decode cached interface references.
- Preserve streamed input resources through export and bound enumerable input with demand-driven flow.
- Propagate stream export and output-sink failures instead of returning partial success.
- Run best-effort document and remote-temp cleanup after failed conversions.
- Restore per-call timeout and frame-size overrides before returning workers to the pool.
- Validate public conversion options before dispatching test stubs or protocol work.
- Harden release validation and make benchmarks reject failed or non-PDF results.

### Security

- Use unpredictable temporary names and run the benchmark LibreOffice image as a non-root user.
- Bind documented benchmark service ports to loopback by default.

## [v0.10.1] - 2026-07-14

### Changed
Expand Down
41 changes: 23 additions & 18 deletions PERFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ a popular LibreOffice-based conversion service.

```sh
docker compose --file benchmarks/docker-compose.yml up --detach --wait
mix run benchmarks/bench.exs
nix develop --command mix run benchmarks/bench.exs
```

The results below were recorded on July 14, 2026, on Apple M3 Max with
Expand All @@ -24,47 +24,52 @@ The fixture uses Liberation fonts only — regenerate with

```
Name ips average deviation median 99th %
URP 1.37 0.73 s ±4.74% 0.72 s 0.82 s
Gotenberg 0.84 1.19 s ±5.46% 1.16 s 1.37 s
URP → Debian glibc 1.31 0.76 s ±14.66% 0.72 s 1.09 s
URP → Alpine musl 0.88 1.14 s ±8.36% 1.16 s 1.21 s
Gotenberg (HTTP) 0.85 1.18 s ±5.55% 1.16 s 1.36 s
```

**15.5 MB input → 62 MB PDF:**

```
Name ips average deviation median 99th %
URP 0.196 5.10 s ±2.12% 5.05 s 5.22 s
Gotenberg 0.135 7.39 s ±0.88% 7.36 s 7.46 s
URP → Debian glibc 0.194 5.16 s ±1.86% 5.18 s 5.24 s
URP → Alpine musl 0.143 6.98 s ±13.31% 6.74 s 8.01 s
Gotenberg (HTTP) 0.137 7.30 s ±2.32% 7.23 s 7.49 s
```

URP had **39% lower average latency** for the small document and **31%
lower average latency** for the large document. The absolute advantage
grew from 0.46 s to 2.29 s. These measurements compare the complete
stacks, including their slightly different LibreOffice patch versions;
they do not isolate dependency or runtime upgrades individually.
The Debian URP stack had **36% lower average latency** for the small
document and **29% lower average latency** for the large document than
Gotenberg. The absolute advantage grew from 0.42 s to 2.14 s. Alpine URP
was much closer to Gotenberg and had noticeably higher variance on the
large fixture. These measurements compare complete stacks with different
LibreOffice builds, C libraries, and container packaging; they do not
isolate dependency or runtime upgrades individually.

### Process overhead sanity check

`benchmarks/convert.exs` compares the persistent URP connection with a
cold `soffice --convert-to` process and Gotenberg using the 33 KB
`sample3.docx` fixture. Across five timed iterations, the averages were:
`sample3.docx` fixture. Across ten timed iterations, the results were:

| Method | Average | Range |
|--------|---------|-------|
| URP | 46 ms | 43–49 ms |
| Gotenberg | 153 ms | 146–160 ms |
| LibreOffice CLI | 280 ms | 267–302 ms |
| Method | Average | Median | Range |
|--------|---------|--------|-------|
| URP | 45 ms | 45 ms | 42–49 ms |
| Gotenberg | 174 ms | 154 ms | 147–323 ms |
| LibreOffice CLI | 285 ms | 286 ms | 276–302 ms |

This is a process-overhead check, not an apples-to-apples transport
benchmark: URP reuses a live office process, while the CLI measurement
starts a new process for every conversion.
starts a new process for every conversion. One Gotenberg request was a
323 ms outlier; the median remained close to the previous run.

## I/O strategies

URP supports two I/O transfer strategies via the `:io` option, benchmarked
with `benchmarks/io_bench.exs`:

```sh
mix run benchmarks/io_bench.exs
nix develop --command mix run benchmarks/io_bench.exs
```

**File I/O** (`:file`, default) writes temp files on soffice's filesystem
Expand Down
26 changes: 17 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,13 @@ config :urp, :default,

> #### pool_size {: .warning}
>
> Each connection needs its own soffice instance. With `pool_size: 3`,
> run 3 soffice containers — one per connection. Concurrent operations
> on a single soffice process are not safe.
> A single URP connection handles one operation at a time. LibreOffice accepts
> multiple connections to one soffice process, but they share process-wide
> state and generally do not improve conversion throughput. Keep `pool_size: 1`
> unless you have tested your workload. For predictable parallelism and fault
> isolation, use separate soffice processes with distinct profiles. The current
> pool sends every worker to the configured host and port; distributing workers
> across containers requires separate named pools or an external TCP balancer.

### Testing

Expand All @@ -69,18 +73,20 @@ URP.Test.stub(fn _input, _opts -> {:ok, "/tmp/fake.pdf"} end)
assert {:ok, _} = MyApp.generate_invoice(order)
```

When soffice is unavailable, tests tagged `:integration` are excluded automatically.
Run the complete suite, including the LibreOffice 26.2+ coverage, with:
`mix test` always runs the deterministic unit suite without probing local ports.
Run the complete suite, including the LibreOffice 26.2+ coverage, explicitly with:

```sh
docker compose --file benchmarks/docker-compose.yml up --detach --wait soffice
mix test --include lo26
URP_INTEGRATION=1 nix develop --command mix test --include lo26
```

### Telemetry

Every operation emits `[:urp, :call, :stop]` with queue, service, and
total time measurements. See `URP.Telemetry`.
Every operation emits a `[:urp, :call, :start]` event followed by either
`[:urp, :call, :stop]` or `[:urp, :call, :exception]`. Stop events include
queue, service, backoff, and total time. Connection retries emit
`[:urp, :connection, :retry]`. See `URP.Telemetry`.

## Performance

Expand All @@ -96,7 +102,9 @@ See [PERFORMANCE.md](PERFORMANCE.md) for benchmarks and container image recommen

```sh
./release.sh patch # or minor, major
git push origin main --tags
git push origin main
# Wait for main CI, then:
git push origin "v$(cat VERSION)"
```

## License
Expand Down
13 changes: 11 additions & 2 deletions benchmarks/Dockerfile.soffice-debian
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,21 @@ RUN export DEBIAN_FRONTEND=noninteractive && \
apt-get install -y --no-install-recommends -t trixie-backports \
libreoffice-core-nogui libreoffice-writer-nogui libreoffice-calc-nogui libreoffice-impress-nogui && \
apt-get install -y --no-install-recommends \
fonts-liberation fonts-crosextra-carlito && \
adduser fonts-liberation fonts-crosextra-carlito netcat-openbsd && \
adduser --uid 10001 --disabled-password --gecos "" soffice && \
mkdir -p /tmp/soffice-profile && \
chown soffice:soffice /tmp/soffice-profile && \
apt-get clean && rm -rf /var/lib/apt/lists/*

ENV HOST=0.0.0.0
ENV PORT=2002
ENV HOME=/home/soffice

EXPOSE ${PORT}

CMD ["sh", "-c", "exec soffice --headless --norestore --accept=\"socket,host=${HOST},port=${PORT},tcpNoDelay=1;urp;\""]
USER soffice

HEALTHCHECK --start-period=10s --interval=2s --timeout=1s --retries=15 \
CMD nc -z 127.0.0.1 "$PORT" || exit 1

CMD ["sh", "-c", "exec soffice --headless --norestore -env:UserInstallation=file:///tmp/soffice-profile --accept=\"socket,host=${HOST},port=${PORT},tcpNoDelay=1;urp;\""]
Loading