perf(static-sites): parallelize Bunny transfers
This commit is contained in:
parent
d9ea6cf9b7
commit
10132c10e3
4 changed files with 143 additions and 7 deletions
|
|
@ -62,6 +62,10 @@ python3 static_site.py bunny dist --dry-run
|
||||||
python3 static_site.py bunny dist
|
python3 static_site.py bunny dist
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Changed files are transferred with 16 workers by default while preserving the
|
||||||
|
asset-before-HTML and manifest-last publication order. Set
|
||||||
|
`BUNNY_TRANSFER_WORKERS` or pass `--workers` to tune the limit.
|
||||||
|
|
||||||
Set `BUNNY_STORAGE_ENDPOINT` when the Storage Zone is outside Frankfurt.
|
Set `BUNNY_STORAGE_ENDPOINT` when the Storage Zone is outside Frankfurt.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,10 @@ inputs:
|
||||||
description: Report changes without uploading or deleting files.
|
description: Report changes without uploading or deleting files.
|
||||||
required: false
|
required: false
|
||||||
default: "false"
|
default: "false"
|
||||||
|
workers:
|
||||||
|
description: Maximum concurrent Bunny Storage transfers.
|
||||||
|
required: false
|
||||||
|
default: "16"
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: composite
|
using: composite
|
||||||
|
|
@ -33,9 +37,10 @@ runs:
|
||||||
BUNNY_STORAGE_ENDPOINT: ${{ inputs.storage-endpoint }}
|
BUNNY_STORAGE_ENDPOINT: ${{ inputs.storage-endpoint }}
|
||||||
BUNNY_PURGE_ENDPOINT: ${{ inputs.purge-endpoint }}
|
BUNNY_PURGE_ENDPOINT: ${{ inputs.purge-endpoint }}
|
||||||
STATIC_SITE_DRY_RUN: ${{ inputs.dry-run }}
|
STATIC_SITE_DRY_RUN: ${{ inputs.dry-run }}
|
||||||
|
STATIC_SITE_WORKERS: ${{ inputs.workers }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
args=(bunny "$STATIC_SITE_DIRECTORY")
|
args=(bunny "$STATIC_SITE_DIRECTORY" --workers "$STATIC_SITE_WORKERS")
|
||||||
if [[ "$STATIC_SITE_DRY_RUN" == "true" ]]; then
|
if [[ "$STATIC_SITE_DRY_RUN" == "true" ]]; then
|
||||||
args+=(--dry-run)
|
args+=(--dry-run)
|
||||||
elif [[ "$STATIC_SITE_DRY_RUN" != "false" ]]; then
|
elif [[ "$STATIC_SITE_DRY_RUN" != "false" ]]; then
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
from collections.abc import Callable
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
|
@ -17,19 +19,32 @@ import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, TypeVar
|
||||||
|
|
||||||
|
|
||||||
MANIFEST_NAME = ".static-site-manifest.json"
|
MANIFEST_NAME = ".static-site-manifest.json"
|
||||||
MANIFEST_VERSION = 1
|
MANIFEST_VERSION = 1
|
||||||
BUNNY_RETRY_DELAYS = (1, 2, 4, 8, 16)
|
BUNNY_RETRY_DELAYS = (1, 2, 4, 8, 16)
|
||||||
BUNNY_TRANSIENT_STATUS_CODES = {401, 408, 429, 500, 502, 503, 504}
|
BUNNY_TRANSIENT_STATUS_CODES = {401, 408, 429, 500, 502, 503, 504}
|
||||||
|
DEFAULT_BUNNY_TRANSFER_WORKERS = 16
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
class DeployError(RuntimeError):
|
class DeployError(RuntimeError):
|
||||||
"""A static-site deployment could not be completed safely."""
|
"""A static-site deployment could not be completed safely."""
|
||||||
|
|
||||||
|
|
||||||
|
def positive_int(value: str) -> int:
|
||||||
|
try:
|
||||||
|
parsed = int(value)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise argparse.ArgumentTypeError("must be an integer") from exc
|
||||||
|
if parsed < 1:
|
||||||
|
raise argparse.ArgumentTypeError("must be at least 1")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class LocalFile:
|
class LocalFile:
|
||||||
path: str
|
path: str
|
||||||
|
|
@ -140,6 +155,9 @@ class BunnyStorage:
|
||||||
f"Bunny Storage {method} {path} failed: HTTP {exc.code}: {detail}"
|
f"Bunny Storage {method} {path} failed: HTTP {exc.code}: {detail}"
|
||||||
) from exc
|
) from exc
|
||||||
except urllib.error.URLError as exc:
|
except urllib.error.URLError as exc:
|
||||||
|
if attempt < len(BUNNY_RETRY_DELAYS):
|
||||||
|
time.sleep(BUNNY_RETRY_DELAYS[attempt])
|
||||||
|
continue
|
||||||
raise DeployError(
|
raise DeployError(
|
||||||
f"Bunny Storage {method} {path} failed: {exc.reason}"
|
f"Bunny Storage {method} {path} failed: {exc.reason}"
|
||||||
) from exc
|
) from exc
|
||||||
|
|
@ -192,6 +210,39 @@ def bunny_plan(
|
||||||
return upload, delete
|
return upload, delete
|
||||||
|
|
||||||
|
|
||||||
|
def run_concurrently(
|
||||||
|
items: list[T], action: Callable[[T], None], workers: int
|
||||||
|
) -> None:
|
||||||
|
if workers < 1:
|
||||||
|
raise DeployError("Bunny transfer workers must be at least 1")
|
||||||
|
if not items:
|
||||||
|
return
|
||||||
|
with ThreadPoolExecutor(max_workers=min(workers, len(items))) as executor:
|
||||||
|
futures = [executor.submit(action, item) for item in items]
|
||||||
|
for future in as_completed(futures):
|
||||||
|
future.result()
|
||||||
|
|
||||||
|
|
||||||
|
def apply_bunny_plan(
|
||||||
|
storage: BunnyStorage,
|
||||||
|
files: dict[str, LocalFile],
|
||||||
|
upload: list[str],
|
||||||
|
delete: list[str],
|
||||||
|
workers: int = DEFAULT_BUNNY_TRANSFER_WORKERS,
|
||||||
|
) -> None:
|
||||||
|
assets = [
|
||||||
|
path for path in upload if not path.lower().endswith((".html", ".htm"))
|
||||||
|
]
|
||||||
|
documents = [
|
||||||
|
path for path in upload if path.lower().endswith((".html", ".htm"))
|
||||||
|
]
|
||||||
|
|
||||||
|
run_concurrently(assets, lambda path: storage.upload(files[path]), workers)
|
||||||
|
run_concurrently(documents, lambda path: storage.upload(files[path]), workers)
|
||||||
|
run_concurrently(delete, storage.delete, workers)
|
||||||
|
storage.upload_manifest(manifest_for(files))
|
||||||
|
|
||||||
|
|
||||||
def purge_bunny_pull_zone(pull_zone_id: str, api_key: str) -> None:
|
def purge_bunny_pull_zone(pull_zone_id: str, api_key: str) -> None:
|
||||||
if not api_key:
|
if not api_key:
|
||||||
raise DeployError("BUNNY_API_KEY is required when BUNNY_PULL_ZONE_ID is set")
|
raise DeployError("BUNNY_API_KEY is required when BUNNY_PULL_ZONE_ID is set")
|
||||||
|
|
@ -264,11 +315,7 @@ def deploy_bunny(args: argparse.Namespace) -> dict[str, Any]:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
if result["changed"]:
|
if result["changed"]:
|
||||||
for path in upload:
|
apply_bunny_plan(storage, files, upload, delete, args.workers)
|
||||||
storage.upload(files[path])
|
|
||||||
for path in delete:
|
|
||||||
storage.delete(path)
|
|
||||||
storage.upload_manifest(manifest_for(files))
|
|
||||||
if args.purge_endpoint:
|
if args.purge_endpoint:
|
||||||
purge_bunny_gateway(args.purge_endpoint, args.zone, args.password)
|
purge_bunny_gateway(args.purge_endpoint, args.zone, args.password)
|
||||||
result["purged"] = True
|
result["purged"] = True
|
||||||
|
|
@ -335,6 +382,14 @@ def parser() -> argparse.ArgumentParser:
|
||||||
bunny.add_argument(
|
bunny.add_argument(
|
||||||
"--purge-endpoint", default=os.environ.get("BUNNY_PURGE_ENDPOINT", "")
|
"--purge-endpoint", default=os.environ.get("BUNNY_PURGE_ENDPOINT", "")
|
||||||
)
|
)
|
||||||
|
bunny.add_argument(
|
||||||
|
"--workers",
|
||||||
|
type=positive_int,
|
||||||
|
default=os.environ.get(
|
||||||
|
"BUNNY_TRANSFER_WORKERS", str(DEFAULT_BUNNY_TRANSFER_WORKERS)
|
||||||
|
),
|
||||||
|
help="Maximum concurrent Bunny Storage transfers.",
|
||||||
|
)
|
||||||
bunny.add_argument("--dry-run", action="store_true")
|
bunny.add_argument("--dry-run", action="store_true")
|
||||||
bunny.set_defaults(
|
bunny.set_defaults(
|
||||||
handler=deploy_bunny,
|
handler=deploy_bunny,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import threading
|
||||||
import unittest
|
import unittest
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
|
@ -15,11 +16,13 @@ sys.modules[SPEC.name] = static_site
|
||||||
SPEC.loader.exec_module(static_site)
|
SPEC.loader.exec_module(static_site)
|
||||||
|
|
||||||
DeployError = static_site.DeployError
|
DeployError = static_site.DeployError
|
||||||
|
apply_bunny_plan = static_site.apply_bunny_plan
|
||||||
bunny_plan = static_site.bunny_plan
|
bunny_plan = static_site.bunny_plan
|
||||||
collect_files = static_site.collect_files
|
collect_files = static_site.collect_files
|
||||||
manifest_for = static_site.manifest_for
|
manifest_for = static_site.manifest_for
|
||||||
parse_manifest = static_site.parse_manifest
|
parse_manifest = static_site.parse_manifest
|
||||||
purge_bunny_gateway = static_site.purge_bunny_gateway
|
purge_bunny_gateway = static_site.purge_bunny_gateway
|
||||||
|
run_concurrently = static_site.run_concurrently
|
||||||
|
|
||||||
|
|
||||||
def write_site(root: Path) -> None:
|
def write_site(root: Path) -> None:
|
||||||
|
|
@ -80,6 +83,56 @@ class StaticSiteTests(unittest.TestCase):
|
||||||
remote = manifest_for(files)["files"]
|
remote = manifest_for(files)["files"]
|
||||||
self.assertEqual(bunny_plan(files, remote), ([], []))
|
self.assertEqual(bunny_plan(files, remote), ([], []))
|
||||||
|
|
||||||
|
def test_bunny_transfers_use_multiple_workers(self) -> None:
|
||||||
|
barrier = threading.Barrier(4)
|
||||||
|
completed: list[int] = []
|
||||||
|
|
||||||
|
def transfer(item: int) -> None:
|
||||||
|
barrier.wait(timeout=2)
|
||||||
|
completed.append(item)
|
||||||
|
|
||||||
|
run_concurrently([1, 2, 3, 4], transfer, workers=4)
|
||||||
|
|
||||||
|
self.assertCountEqual(completed, [1, 2, 3, 4])
|
||||||
|
|
||||||
|
def test_bunny_transfer_order_keeps_documents_and_manifest_safe(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
write_site(root)
|
||||||
|
(root / "about.html").write_text("<h1>About</h1>")
|
||||||
|
files = collect_files(root)
|
||||||
|
events: list[tuple[str, str]] = []
|
||||||
|
storage = mock.Mock()
|
||||||
|
storage.upload.side_effect = lambda item: events.append(
|
||||||
|
("upload", item.path)
|
||||||
|
)
|
||||||
|
storage.delete.side_effect = lambda path: events.append(("delete", path))
|
||||||
|
storage.upload_manifest.side_effect = lambda _manifest: events.append(
|
||||||
|
("manifest", static_site.MANIFEST_NAME)
|
||||||
|
)
|
||||||
|
|
||||||
|
apply_bunny_plan(
|
||||||
|
storage,
|
||||||
|
files,
|
||||||
|
["assets/app.js", "about.html", "index.html"],
|
||||||
|
["stale.css"],
|
||||||
|
workers=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
asset_position = events.index(("upload", "assets/app.js"))
|
||||||
|
document_positions = {
|
||||||
|
events.index(("upload", "about.html")),
|
||||||
|
events.index(("upload", "index.html")),
|
||||||
|
}
|
||||||
|
delete_position = events.index(("delete", "stale.css"))
|
||||||
|
self.assertTrue(
|
||||||
|
all(asset_position < position for position in document_positions)
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
all(position < delete_position for position in document_positions)
|
||||||
|
)
|
||||||
|
self.assertEqual(events[-1], ("manifest", static_site.MANIFEST_NAME))
|
||||||
|
|
||||||
def test_gateway_requires_a_plain_https_origin(self) -> None:
|
def test_gateway_requires_a_plain_https_origin(self) -> None:
|
||||||
invalid = (
|
invalid = (
|
||||||
"http://purge.example",
|
"http://purge.example",
|
||||||
|
|
@ -115,6 +168,25 @@ class StaticSiteTests(unittest.TestCase):
|
||||||
sleep.assert_called_once_with(1)
|
sleep.assert_called_once_with(1)
|
||||||
self.assertEqual(urlopen.call_count, 2)
|
self.assertEqual(urlopen.call_count, 2)
|
||||||
|
|
||||||
|
@mock.patch.object(static_site.time, "sleep")
|
||||||
|
@mock.patch.object(static_site.urllib.request, "urlopen")
|
||||||
|
def test_storage_retries_transient_connection_errors(
|
||||||
|
self, urlopen: mock.Mock, sleep: mock.Mock
|
||||||
|
) -> None:
|
||||||
|
response = mock.MagicMock()
|
||||||
|
response.__enter__.return_value.read.return_value = b"manifest"
|
||||||
|
urlopen.side_effect = [
|
||||||
|
static_site.urllib.error.URLError("connection reset"),
|
||||||
|
response,
|
||||||
|
]
|
||||||
|
storage = static_site.BunnyStorage(
|
||||||
|
"example", "storage-password", "storage.example"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(storage.get_manifest(), b"manifest")
|
||||||
|
sleep.assert_called_once_with(1)
|
||||||
|
self.assertEqual(urlopen.call_count, 2)
|
||||||
|
|
||||||
@mock.patch.object(static_site.urllib.request, "urlopen")
|
@mock.patch.object(static_site.urllib.request, "urlopen")
|
||||||
def test_gateway_reuses_storage_password(self, urlopen: mock.Mock) -> None:
|
def test_gateway_reuses_storage_password(self, urlopen: mock.Mock) -> None:
|
||||||
urlopen.return_value.__enter__.return_value = object()
|
urlopen.return_value.__enter__.return_value = object()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue