Skip to content

Commit cba716b

Browse files
authored
perf(ci): shorten unstructured-api test and publishing runs (#587)
## What Standalone API CI avoids repeated inference work and redundant image transfers while retaining distinct smoke-test scenarios. | Measurement | Before | After | Saved | | --- | ---: | ---: | ---: | | Total CI | 39m18s historical median | 26m15s | 13m03s (33.2%) | | Smoke tests | 16m05s | 11m37s | 4m28s (27.8%) | | Mode comparisons | ~14m35s | 5m07s | ~9m28s (64.9%) | [Successful PR run](https://github.com/Unstructured-IO/unstructured-api/actions/runs/34426279723). Total baseline is nine historical runs; test-stage baselines come from August 27. This is not a controlled comparison: runner variability and faster individual inference calls contribute to the difference. ## Why Some option checks unintentionally repeated default inference, gzip parametrization duplicated seven cases, and the smoke-test client installed unused dependencies. The test harness now sends the intended options and avoids duplicated work. Release publishing also retags images in the registry instead of pulling them only to retag them. The 37 distinct smoke cases and seven mode comparisons remain covered. Heavy PDF inference remains the largest test cost. ## Relationship to other work Independent of Plugins, platform-libs, and shared-workflow #434/#437. Core-product has its own vendored API and separate CI improvement in #1660; its savings are not an additional stage of this pipeline. Publishing speed has not been measured: that workflow runs only on main. The timing table covers CI, not release publishing. ## Validation Current GitHub checks pass, including the Docker build, 37 smoke tests, seven mode comparisons, core tests, coverage, and lint. Six shell regression tests and registry-retagging dry runs passed; publishing itself still requires validation on main.
1 parent 76bf7bf commit cba716b

8 files changed

Lines changed: 167 additions & 92 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ on:
66
pull_request:
77
branches: [ main ]
88

9+
concurrency:
10+
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
11+
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
12+
913
env:
1014
PIPELINE_FAMILY: "general"
1115

@@ -21,6 +25,7 @@ jobs:
2125
with:
2226
enable-cache: true
2327
cache-dependency-glob: "uv.lock"
28+
cache-suffix: lint
2429
- name: Set up Python ${{ env.PYTHON_VERSION }}
2530
run: uv python install ${{ env.PYTHON_VERSION }}
2631
- name: Install lint dependencies
@@ -47,19 +52,24 @@ jobs:
4752
with:
4853
enable-cache: true
4954
cache-dependency-glob: "uv.lock"
55+
cache-suffix: test
5056
- name: Set up Python ${{ env.PYTHON_VERSION }}
5157
run: uv python install ${{ env.PYTHON_VERSION }}
52-
- name: Install dependencies and run core tests
58+
- name: Install system dependencies
5359
run: |
5460
sudo apt-get update && sudo apt-get install --yes poppler-utils libreoffice
55-
uv sync --group test --locked
5661
make install-pandoc
57-
make install-nlp-models
5862
sudo add-apt-repository -y ppa:alex-p/tesseract-ocr5
5963
sudo apt-get install -y tesseract-ocr tesseract-ocr-kor
6064
tesseract --version
61-
make test
62-
make check-coverage
65+
- name: Install test dependencies
66+
run: uv sync --group test --locked
67+
- name: Install NLP models
68+
run: make install-nlp-models
69+
- name: Run core tests
70+
run: make test
71+
- name: Check coverage
72+
run: make check-coverage
6373

6474
changelog:
6575
runs-on: opensource-linux-8core
@@ -77,7 +87,6 @@ jobs:
7787
- if: steps.changes.outputs.src == 'true' && github.ref != 'refs/heads/main'
7888
uses: dangoslen/changelog-enforcer@v3
7989

80-
# TODO - figure out best practice for caching docker images
8190
test_dockerfile:
8291
runs-on: opensource-linux-8core
8392
needs: lint
@@ -90,6 +99,7 @@ jobs:
9099
with:
91100
enable-cache: true
92101
cache-dependency-glob: "uv.lock"
102+
cache-suffix: smoke
93103
- name: Set up Python ${{ env.PYTHON_VERSION }}
94104
run: uv python install ${{ env.PYTHON_VERSION }}
95105
- name: Free up disk space
@@ -112,8 +122,9 @@ jobs:
112122
113123
echo "Disk usage after cleanup:"
114124
df -h
125+
- name: Install smoke test dependencies
126+
run: uv sync --only-group smoke --locked
127+
- name: Build Dockerfile
128+
run: make docker-build
115129
- name: Test Dockerfile
116-
run: |
117-
uv sync --group test --locked
118-
make docker-build
119-
make docker-test
130+
run: make docker-test

‎.github/workflows/docker-publish.yml‎

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -80,10 +80,11 @@ jobs:
8080
with:
8181
enable-cache: true
8282
cache-dependency-glob: "uv.lock"
83+
cache-suffix: smoke
8384
- name: Set up Python ${{ env.PYTHON_VERSION }}
8485
run: uv python install ${{ env.PYTHON_VERSION }}
8586
- name: Install test dependencies
86-
run: uv sync --group test --locked
87+
run: uv sync --only-group smoke --locked
8788
- name: Test image
8889
run: |
8990
export DOCKER_IMAGE="$DOCKER_BUILD_REPOSITORY:${{ matrix.arch }}-$SHORT_SHA"
@@ -100,33 +101,28 @@ jobs:
100101
steps:
101102
- name: Checkout code
102103
uses: actions/checkout@v5
103-
- name: Set SHORT_SHA
104-
run: echo "SHORT_SHA=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
104+
- name: Set up Docker Buildx
105+
uses: docker/setup-buildx-action@v4
105106
- name: Login to Quay.io
106107
uses: docker/login-action@v4
107108
with:
108109
registry: quay.io
109110
username: ${{ secrets.QUAY_IO_ROBOT_USERNAME }}
110111
password: ${{ secrets.QUAY_IO_ROBOT_TOKEN }}
111-
- name: Pull AMD image
112-
run: |
113-
docker pull $DOCKER_BUILD_REPOSITORY:amd64-$SHORT_SHA
114-
- name: Pull ARM image
115-
run: |
116-
docker pull $DOCKER_BUILD_REPOSITORY:arm64-$SHORT_SHA
117-
- name: Push AMD and ARM tags
112+
- name: Update architecture cache tags
118113
run: |
119-
# these are used to construct the final manifest but also cache-from in subsequent runs
120-
docker tag $DOCKER_BUILD_REPOSITORY:amd64-$SHORT_SHA $DOCKER_BUILD_REPOSITORY:amd64
121-
docker push $DOCKER_BUILD_REPOSITORY:amd64
122-
docker tag $DOCKER_BUILD_REPOSITORY:arm64-$SHORT_SHA $DOCKER_BUILD_REPOSITORY:arm64
123-
docker push $DOCKER_BUILD_REPOSITORY:arm64
114+
docker buildx imagetools create --prefer-index=false \
115+
-t $DOCKER_BUILD_REPOSITORY:amd64 \
116+
$DOCKER_BUILD_REPOSITORY:amd64-$SHORT_SHA
117+
docker buildx imagetools create --prefer-index=false \
118+
-t $DOCKER_BUILD_REPOSITORY:arm64 \
119+
$DOCKER_BUILD_REPOSITORY:arm64-$SHORT_SHA
124120
- name: Push multiarch manifest
125121
run: |
126122
VERSION=$(grep -oP '(?<=__version__ = ")[^"]+' prepline_general/api/__version__.py)
127123
docker buildx imagetools create \
128124
-t ${DOCKER_REPOSITORY}:latest \
129125
-t ${DOCKER_REPOSITORY}:$SHORT_SHA \
130126
-t ${DOCKER_REPOSITORY}:$VERSION \
131-
$DOCKER_BUILD_REPOSITORY:amd64 \
132-
$DOCKER_BUILD_REPOSITORY:arm64
127+
$DOCKER_BUILD_REPOSITORY:amd64-$SHORT_SHA \
128+
$DOCKER_BUILD_REPOSITORY:arm64-$SHORT_SHA

‎pyproject.toml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ dependencies = [
1616
]
1717

1818
[dependency-groups]
19+
smoke = [
20+
"pytest >=9.0.2, <10.0.0",
21+
"pandas >=3.0.0, <4.0.0",
22+
"requests >=2.32.0, <3.0.0",
23+
]
1924
lint = [
2025
"ruff >=0.15.0, <1.0.0",
2126
"mypy >=1.19.1, <2.0.0",

‎scripts/docker-smoke-test.sh‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ await_server_ready 8000
8080
# Smoke Tests
8181
#######################
8282
echo Running smoke tests with SKIP_INFERENCE_TESTS: "$SKIP_INFERENCE_TESTS"
83-
PYTHONPATH=. SKIP_INFERENCE_TESTS=$SKIP_INFERENCE_TESTS uv run pytest -vv scripts/smoketest.py
83+
PYTHONPATH=. SKIP_INFERENCE_TESTS=$SKIP_INFERENCE_TESTS uv run --no-sync pytest --durations=20 -vv scripts/smoketest.py
8484

8585
#######################
8686
# Test parallel vs single mode

‎scripts/parallel-mode-test.sh‎

Lines changed: 29 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,73 +1,40 @@
11
#!/usr/bin/env bash
22

3-
# parallel-mode-test.sh
4-
# Iterate a list of curl commands, and run each one against two instances of the api
5-
# The smoke test will start one container with parallel mode and one without, and
6-
# diff the two outputs to make sure parallel mode does not alter the response.
7-
# Note the filepaths assume you ran this from the top level
8-
9-
# shellcheck disable=SC2317,SC2086 # SC2317: trap functions appear unreachable, SC2086: curl params require word splitting
3+
set -euo pipefail
104

115
base_url_1=$1
126
base_url_2=$2
13-
MAX_RETRIES=3
7+
results_dir=$(mktemp -d)
8+
trap 'rm -rf "$results_dir"' EXIT
149

15-
curl_with_retry() {
16-
local curl_command=$1
10+
request_elements() {
11+
local base_url=$1
1712
local output_file=$2
13+
shift 2
1814

19-
for attempt in $(seq 1 $MAX_RETRIES); do
20-
$curl_command 2> /dev/null | jq -S 'del(..|.parent_id?)' > "$output_file"
21-
if [ -s "$output_file" ]; then
22-
return 0
23-
fi
24-
echo " Attempt $attempt/$MAX_RETRIES failed, retrying in 5s..."
25-
sleep 5
26-
done
27-
28-
echo " All $MAX_RETRIES attempts failed!"
29-
$curl_command
30-
return 1
15+
curl --fail --silent --show-error --retry 2 --max-time 300 \
16+
"$base_url/general/v0/general" \
17+
-F files=@sample-docs/layout-parser-paper.pdf \
18+
"$@" > "$output_file"
19+
jq -e 'type == "array" and length > 0' "$output_file" > /dev/null
3120
}
3221

33-
declare -a curl_params=(
34-
"-F files=@sample-docs/layout-parser-paper.pdf -F 'strategy=fast'"
35-
"-F files=@sample-docs/layout-parser-paper.pdf -F 'strategy=auto"
36-
"-F files=@sample-docs/layout-parser-paper.pdf -F 'strategy=hi_res'"
37-
"-F files=@sample-docs/layout-parser-paper.pdf -F 'coordinates=true'"
38-
"-F files=@sample-docs/layout-parser-paper.pdf -F 'encoding=utf-8'"
39-
"-F files=@sample-docs/layout-parser-paper.pdf -F 'include_page_breaks=true'"
40-
"-F files=@sample-docs/layout-parser-paper.pdf -F 'hi_res_model_name=yolox'"
41-
)
42-
43-
for params in "${curl_params[@]}"
44-
do
45-
curl_command="curl $base_url_1/general/v0/general $params"
46-
echo Testing: "$curl_command"
47-
48-
# Run in single mode
49-
# Note(austin): Parallel mode screws up hierarchy! While we deal with that,
50-
# let's ignore parent_id fields in the results
51-
if ! curl_with_retry "$curl_command" output.json; then
52-
echo Command failed!
53-
exit 1
54-
fi
55-
original_length=$(jq 'length' output.json)
56-
57-
# Run in parallel mode
58-
curl_command="curl $base_url_2/general/v0/general $params"
59-
if ! curl_with_retry "$curl_command" parallel_output.json; then
60-
echo Command failed!
61-
exit 1
62-
fi
63-
parallel_length=$(jq 'length' parallel_output.json)
64-
65-
if ! [[ "$original_length" == "$parallel_length" ]]; then
66-
echo Parallel mode returned a different number of elements!
67-
echo Params: "$params"
68-
exit 1
69-
fi
70-
71-
rm -f output.json parallel_output.json
72-
echo
22+
for option in strategy=fast strategy=auto strategy=hi_res coordinates=true encoding=utf-8 include_page_breaks=true hi_res_model_name=yolox; do
23+
# Exercise forwarding options without repeating inference for each one.
24+
case "$option" in
25+
strategy=*) form_args=(-F "$option") ;;
26+
hi_res_model_name=*) form_args=(-F strategy=hi_res -F "$option") ;;
27+
*) form_args=(-F strategy=fast -F "$option") ;;
28+
esac
29+
30+
echo "Testing: $option"
31+
request_elements "$base_url_1" "$results_dir/single.json" "${form_args[@]}"
32+
request_elements "$base_url_2" "$results_dir/parallel.json" "${form_args[@]}"
33+
34+
original_length=$(jq 'length' "$results_dir/single.json")
35+
parallel_length=$(jq 'length' "$results_dir/parallel.json")
36+
if [[ "$original_length" != "$parallel_length" ]]; then
37+
echo "Parallel mode returned a different number of elements for $option"
38+
exit 1
39+
fi
7340
done

‎scripts/smoketest.py‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,6 @@ def test_happy_path_all_types(extension, example_filename: str, content_type: st
146146
assert len(df) == len(json_response.json())
147147

148148

149-
@pytest.mark.parametrize("output_format", ["application/json", "text/csv"])
150149
@pytest.mark.parametrize(
151150
"filenames_to_gzip, filenames_verbatim, uncompressed_content_type",
152151
[
@@ -173,7 +172,6 @@ def test_happy_path_all_types(extension, example_filename: str, content_type: st
173172
],
174173
)
175174
def test_gzip_sending(
176-
output_format: str,
177175
filenames_to_gzip: List[str],
178176
filenames_verbatim: List[str],
179177
uncompressed_content_type: str,
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import json
2+
import os
3+
import subprocess
4+
import sys
5+
from pathlib import Path
6+
7+
import pytest
8+
9+
SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "parallel-mode-test.sh"
10+
11+
12+
@pytest.fixture
13+
def run_comparisons(tmp_path):
14+
curl = tmp_path / "curl"
15+
curl.write_text(
16+
f"#!{sys.executable}\n"
17+
"import json, os, sys\n"
18+
"with open(os.environ['REQUEST_LOG'], 'a') as log:\n"
19+
" log.write(json.dumps(sys.argv[1:]) + '\\n')\n"
20+
"if os.environ.get('HTTP_FAILURE'):\n"
21+
" sys.exit(22)\n"
22+
"body = os.environ.get('RESPONSE_BODY', '[{\"text\": \"content\"}]')\n"
23+
"if os.environ.get('MISMATCH') and any('parallel/' in arg for arg in sys.argv):\n"
24+
" body = '[{}, {}]'\n"
25+
"print(body)\n"
26+
)
27+
curl.chmod(0o755)
28+
log = tmp_path / "requests.jsonl"
29+
30+
def run(**overrides):
31+
result = subprocess.run(
32+
["bash", str(SCRIPT), "http://single", "http://parallel"],
33+
env={
34+
**os.environ,
35+
"PATH": f"{tmp_path}:{os.environ['PATH']}",
36+
"REQUEST_LOG": str(log),
37+
**overrides,
38+
},
39+
capture_output=True,
40+
text=True,
41+
check=False,
42+
)
43+
requests = [json.loads(line) for line in log.read_text().splitlines()]
44+
return result, requests
45+
46+
return run
47+
48+
49+
def test_comparisons_send_valid_options_to_both_servers(run_comparisons):
50+
result, requests = run_comparisons()
51+
assert result.returncode == 0, result.stderr
52+
assert len(requests) == 14
53+
expected = [
54+
{"strategy": "fast"},
55+
{"strategy": "auto"},
56+
{"strategy": "hi_res"},
57+
{"strategy": "fast", "coordinates": "true"},
58+
{"strategy": "fast", "encoding": "utf-8"},
59+
{"strategy": "fast", "include_page_breaks": "true"},
60+
{"strategy": "hi_res", "hi_res_model_name": "yolox"},
61+
]
62+
for index, args in enumerate(requests):
63+
fields = dict(args[i + 1].split("=", 1) for i, arg in enumerate(args) if arg == "-F")
64+
assert fields.pop("files") == "@sample-docs/layout-parser-paper.pdf"
65+
assert fields == expected[index // 2]
66+
server = "single" if index % 2 == 0 else "parallel"
67+
assert f"http://{server}/general/v0/general" in args
68+
assert "--fail" in args
69+
70+
71+
@pytest.mark.parametrize("body", ['{"detail": "server error"}', "[]", "invalid JSON"])
72+
def test_comparisons_reject_invalid_responses(run_comparisons, body):
73+
result, requests = run_comparisons(RESPONSE_BODY=body)
74+
assert result.returncode != 0
75+
assert len(requests) == 1
76+
77+
78+
def test_comparisons_reject_http_failure(run_comparisons):
79+
result, requests = run_comparisons(HTTP_FAILURE="1")
80+
assert result.returncode != 0
81+
assert len(requests) == 1
82+
83+
84+
def test_comparisons_reject_different_element_counts(run_comparisons):
85+
result, requests = run_comparisons(MISMATCH="1")
86+
assert result.returncode != 0
87+
assert len(requests) == 2
88+
assert "different number of elements" in result.stdout

‎uv.lock‎

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)