perlsky is a Perl 5 implementation of an AT Protocol Personal Data Server.
13
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add performance benchmark and dashboard examples

alice e7a84fac 81bcb641

+928 -1
+52 -1
docs/DEPLOYMENT.md
··· 318 318 https://pds.example.com/metrics 319 319 ``` 320 320 321 - See `docs/METRICS.md` for the metric surface. 321 + Checked-in Prometheus and Grafana examples live under: 322 + 323 + - [ops/prometheus/perlsky.yml](../ops/prometheus/perlsky.yml) 324 + - [ops/grafana/prometheus-datasource.yml](../ops/grafana/prometheus-datasource.yml) 325 + - [ops/grafana/perlsky-dashboard-provider.yml](../ops/grafana/perlsky-dashboard-provider.yml) 326 + - [ops/grafana/perlsky-dashboard.json](../ops/grafana/perlsky-dashboard.json) 327 + 328 + See [METRICS.md](./METRICS.md) for the metric surface and dashboard notes. 329 + 330 + ## Prometheus 331 + 332 + Merge [ops/prometheus/perlsky.yml](../ops/prometheus/perlsky.yml) into your Prometheus config and replace the placeholder bearer token with `metrics_token` from `/etc/perlsky/perlsky.json`. 333 + 334 + One minimal local scrape job looks like: 335 + 336 + ```yaml 337 + - job_name: perlsky 338 + scrape_interval: 15s 339 + scrape_timeout: 5s 340 + metrics_path: /metrics 341 + scheme: http 342 + authorization: 343 + credentials: REPLACE_WITH_PERLSKY_METRICS_TOKEN 344 + static_configs: 345 + - targets: ['127.0.0.1:7755'] 346 + labels: 347 + service: perlsky 348 + ``` 349 + 350 + Validate and reload: 351 + 352 + ```sh 353 + promtool check config /etc/prometheus/prometheus.yml 354 + systemctl reload prometheus || systemctl restart prometheus 355 + curl -fsS 'http://127.0.0.1:9090/api/v1/query?query=up%7Bjob%3D%22perlsky%22%7D' 356 + ``` 357 + 358 + ## Grafana 359 + 360 + Provision the Prometheus data source and dashboard provider with the checked-in examples, then copy the dashboard JSON into the watched directory: 361 + 362 + ```sh 363 + install -d /etc/grafana/provisioning/datasources 364 + install -d /etc/grafana/provisioning/dashboards 365 + install -d /var/lib/grafana/dashboards 366 + cp /opt/perlsky/app/ops/grafana/prometheus-datasource.yml /etc/grafana/provisioning/datasources/perlsky-prometheus.yml 367 + cp /opt/perlsky/app/ops/grafana/perlsky-dashboard-provider.yml /etc/grafana/provisioning/dashboards/perlsky.yml 368 + cp /opt/perlsky/app/ops/grafana/perlsky-dashboard.json /var/lib/grafana/dashboards/perlsky-overview.json 369 + systemctl restart grafana-server || systemctl restart grafana 370 + ``` 371 + 372 + The example data source uses the stable UID `prometheus`. Keep that UID or update the dashboard file to match your local Prometheus data source UID. 322 373 323 374 ## Upgrades 324 375
+35
docs/METRICS.md
··· 38 38 Counts instrumented SQLite-backed store operations by operation and status. 39 39 - `perlsky_store_operation_duration_seconds` 40 40 Histogram of instrumented store operation duration. 41 + - `perlsky_service_proxy_requests_total` 42 + Counts local and upstream `app.bsky.*` proxy requests by NSID, source, and status. 43 + - `perlsky_service_proxy_request_duration_seconds` 44 + Histogram for service-proxy request latency with the same labels. 45 + - `perlsky_service_proxy_local_post_index_cache_access_total` 46 + Counts request-local hits, process-cache hits, and rebuilds for the local post index. 47 + - `perlsky_service_proxy_local_post_index_rebuild_duration_seconds` 48 + Histogram of local post-index rebuild time. 49 + - `perlsky_service_proxy_local_post_index_entries` 50 + Gauge of local post-index entry counts by kind. 51 + - `perlsky_service_proxy_local_post_resolution_total` 52 + Counts how local post lookups were resolved: request cache, shared index, store, or non-local bypass. 53 + - `perlsky_service_proxy_profile_record_cache_total` 54 + Counts local profile record cache hits and misses. 55 + - `perlsky_repo_resolution_total` 56 + Counts repo/DID resolution paths, including request-cache reuse versus fallback scans. 41 57 - `perlsky_build_info` 42 58 Static build/service info gauge. 43 59 ··· 63 79 - crawler errors from `perlsky_crawler_requests_total{result="error"}` 64 80 - large ingress with low egress or vice versa on blob byte counters 65 81 - persistent growth in store latency histograms 82 + - sustained `result="rebuild"` growth in `perlsky_service_proxy_local_post_index_cache_access_total` 83 + - high `p95` in `perlsky_service_proxy_local_post_index_rebuild_duration_seconds` 84 + - unexpected growth in `source="list_scan"` for `perlsky_repo_resolution_total` 85 + 86 + ## Prometheus 87 + 88 + The repo includes a checked-in example scrape job at [ops/prometheus/perlsky.yml](../ops/prometheus/perlsky.yml). 89 + 90 + On the live VPS we scrape every `15s` instead of more aggressively to avoid adding pressure while Prometheus is already remote-writing to Grafana Cloud. 91 + 92 + ## Grafana 93 + 94 + The repo includes: 95 + 96 + - [ops/grafana/perlsky-dashboard.json](../ops/grafana/perlsky-dashboard.json): overview dashboard for XRPC, service-proxy, store, subscription, and blob metrics 97 + - [ops/grafana/prometheus-datasource.yml](../ops/grafana/prometheus-datasource.yml): example provisioned Prometheus data source 98 + - [ops/grafana/perlsky-dashboard-provider.yml](../ops/grafana/perlsky-dashboard-provider.yml): example dashboard provider that watches a dashboard directory 99 + 100 + The dashboard expects a Prometheus data source. When provisioning, either keep the checked-in `uid` from the example data source or update the dashboard's `${DS_PROMETHEUS}` mapping during import. 66 101 67 102 ## Example Scrape 68 103
+69
docs/PERFORMANCE.md
··· 1 + # Performance 2 + 3 + This document covers two practical tools for `perlsky` performance work: 4 + 5 + - Prometheus metrics for live visibility 6 + - `script/benchmark-local-appview` for repeatable local endpoint timing 7 + 8 + ## Benchmark Script 9 + 10 + `script/benchmark-local-appview` spins up an ephemeral local `perlsky` daemon, seeds a small repo with posts and a reply chain, then benchmarks the hottest local appview endpoints over real HTTP: 11 + 12 + - `app.bsky.actor.getProfile` 13 + - `app.bsky.feed.getAuthorFeed` 14 + - `app.bsky.feed.getPostThread` 15 + 16 + Example: 17 + 18 + ```sh 19 + script/benchmark-local-appview --iterations 75 --warmup 15 --posts 100 --replies 12 20 + ``` 21 + 22 + JSON output: 23 + 24 + ```sh 25 + script/benchmark-local-appview --format json > data/local-appview-benchmark.json 26 + ``` 27 + 28 + The benchmark is intentionally small and deterministic. It is best for comparing one local appview change against another, not for claiming cluster-scale throughput. 29 + 30 + Useful flags: 31 + 32 + - `--iterations N`: measured requests per endpoint, default `50` 33 + - `--warmup N`: unmeasured warmup requests per endpoint, default `10` 34 + - `--posts N`: number of posts to seed for the author feed, default `50` 35 + - `--replies N`: length of the reply chain for the thread benchmark, default `8` 36 + - `--feed-limit N`: `getAuthorFeed` page size, default `25` 37 + - `--keep-tmp`: keep the temporary benchmark dataset on disk for inspection 38 + 39 + The script also prints a metrics excerpt so it is easy to sanity-check whether local appview cache counters moved during the run. 40 + 41 + ## Recommended Workflow 42 + 43 + For repeatable tuning: 44 + 45 + 1. Run the benchmark script before a perf change and save the output. 46 + 2. Apply the change. 47 + 3. Run the same benchmark again with the same arguments. 48 + 4. Compare: 49 + - `p50` 50 + - `p95` 51 + - `max` 52 + - derived `req/s` 53 + 5. Check `/metrics` to confirm that cache hit/rebuild counters changed the way you expected. 54 + 6. If the change looks promising, compare the same Prometheus panels in Grafana after deployment. 55 + 56 + ## Current Hot Metrics 57 + 58 + The most relevant local appview metrics are: 59 + 60 + - `perlsky_service_proxy_requests_total` 61 + - `perlsky_service_proxy_request_duration_seconds` 62 + - `perlsky_service_proxy_local_post_index_cache_access_total` 63 + - `perlsky_service_proxy_local_post_index_rebuild_duration_seconds` 64 + - `perlsky_service_proxy_local_post_index_entries` 65 + - `perlsky_service_proxy_local_post_resolution_total` 66 + - `perlsky_service_proxy_profile_record_cache_total` 67 + - `perlsky_repo_resolution_total` 68 + 69 + See [METRICS.md](./METRICS.md) for Prometheus and Grafana queries.
+11
ops/grafana/perlsky-dashboard-provider.yml
··· 1 + apiVersion: 1 2 + 3 + providers: 4 + - name: perlsky 5 + orgId: 1 6 + folder: perlsky 7 + type: file 8 + disableDeletion: false 9 + updateIntervalSeconds: 30 10 + options: 11 + path: /var/lib/grafana/dashboards
+390
ops/grafana/perlsky-dashboard.json
··· 1 + { 2 + "annotations": { 3 + "list": [ 4 + { 5 + "builtIn": 1, 6 + "datasource": { 7 + "type": "grafana", 8 + "uid": "-- Grafana --" 9 + }, 10 + "enable": true, 11 + "hide": true, 12 + "iconColor": "rgba(0, 211, 255, 1)", 13 + "name": "Annotations & Alerts", 14 + "type": "dashboard" 15 + } 16 + ] 17 + }, 18 + "editable": true, 19 + "fiscalYearStartMonth": 0, 20 + "graphTooltip": 0, 21 + "links": [], 22 + "panels": [ 23 + { 24 + "datasource": { 25 + "type": "prometheus", 26 + "uid": "prometheus" 27 + }, 28 + "fieldConfig": { 29 + "defaults": { 30 + "unit": "reqps" 31 + }, 32 + "overrides": [] 33 + }, 34 + "gridPos": { 35 + "h": 8, 36 + "w": 12, 37 + "x": 0, 38 + "y": 0 39 + }, 40 + "id": 1, 41 + "targets": [ 42 + { 43 + "expr": "sum by (nsid, endpoint_type) (rate(perlsky_xrpc_requests_total[5m]))", 44 + "legendFormat": "{{nsid}} {{endpoint_type}}", 45 + "refId": "A" 46 + } 47 + ], 48 + "title": "XRPC RPS", 49 + "type": "timeseries" 50 + }, 51 + { 52 + "datasource": { 53 + "type": "prometheus", 54 + "uid": "prometheus" 55 + }, 56 + "fieldConfig": { 57 + "defaults": { 58 + "unit": "percentunit" 59 + }, 60 + "overrides": [] 61 + }, 62 + "gridPos": { 63 + "h": 8, 64 + "w": 12, 65 + "x": 12, 66 + "y": 0 67 + }, 68 + "id": 2, 69 + "targets": [ 70 + { 71 + "expr": "sum(rate(perlsky_xrpc_requests_total{status=~\"4..|5..\"}[5m])) / clamp_min(sum(rate(perlsky_xrpc_requests_total[5m])), 1)", 72 + "legendFormat": "error ratio", 73 + "refId": "A" 74 + } 75 + ], 76 + "title": "XRPC Error Ratio", 77 + "type": "timeseries" 78 + }, 79 + { 80 + "datasource": { 81 + "type": "prometheus", 82 + "uid": "prometheus" 83 + }, 84 + "fieldConfig": { 85 + "defaults": { 86 + "unit": "s" 87 + }, 88 + "overrides": [] 89 + }, 90 + "gridPos": { 91 + "h": 8, 92 + "w": 12, 93 + "x": 0, 94 + "y": 8 95 + }, 96 + "id": 3, 97 + "targets": [ 98 + { 99 + "expr": "histogram_quantile(0.95, sum by (le, nsid, endpoint_type) (rate(perlsky_xrpc_request_duration_seconds_bucket[5m])))", 100 + "legendFormat": "{{nsid}} {{endpoint_type}}", 101 + "refId": "A" 102 + } 103 + ], 104 + "title": "P95 XRPC Latency", 105 + "type": "timeseries" 106 + }, 107 + { 108 + "datasource": { 109 + "type": "prometheus", 110 + "uid": "prometheus" 111 + }, 112 + "fieldConfig": { 113 + "defaults": { 114 + "unit": "reqps" 115 + }, 116 + "overrides": [] 117 + }, 118 + "gridPos": { 119 + "h": 8, 120 + "w": 12, 121 + "x": 12, 122 + "y": 8 123 + }, 124 + "id": 4, 125 + "targets": [ 126 + { 127 + "expr": "sum by (nsid, source, status) (rate(perlsky_service_proxy_requests_total[5m]))", 128 + "legendFormat": "{{nsid}} {{source}} {{status}}", 129 + "refId": "A" 130 + } 131 + ], 132 + "title": "Service Proxy Request Rate", 133 + "type": "timeseries" 134 + }, 135 + { 136 + "datasource": { 137 + "type": "prometheus", 138 + "uid": "prometheus" 139 + }, 140 + "fieldConfig": { 141 + "defaults": { 142 + "unit": "s" 143 + }, 144 + "overrides": [] 145 + }, 146 + "gridPos": { 147 + "h": 8, 148 + "w": 12, 149 + "x": 0, 150 + "y": 16 151 + }, 152 + "id": 5, 153 + "targets": [ 154 + { 155 + "expr": "histogram_quantile(0.95, sum by (le, nsid, source, status) (rate(perlsky_service_proxy_request_duration_seconds_bucket[5m])))", 156 + "legendFormat": "{{nsid}} {{source}} {{status}}", 157 + "refId": "A" 158 + } 159 + ], 160 + "title": "P95 Service Proxy Latency", 161 + "type": "timeseries" 162 + }, 163 + { 164 + "datasource": { 165 + "type": "prometheus", 166 + "uid": "prometheus" 167 + }, 168 + "fieldConfig": { 169 + "defaults": { 170 + "unit": "ops" 171 + }, 172 + "overrides": [] 173 + }, 174 + "gridPos": { 175 + "h": 8, 176 + "w": 12, 177 + "x": 12, 178 + "y": 16 179 + }, 180 + "id": 6, 181 + "targets": [ 182 + { 183 + "expr": "sum by (result) (rate(perlsky_service_proxy_local_post_index_cache_access_total[5m]))", 184 + "legendFormat": "{{result}}", 185 + "refId": "A" 186 + } 187 + ], 188 + "title": "Local Post Index Cache Access", 189 + "type": "timeseries" 190 + }, 191 + { 192 + "datasource": { 193 + "type": "prometheus", 194 + "uid": "prometheus" 195 + }, 196 + "fieldConfig": { 197 + "defaults": { 198 + "unit": "s" 199 + }, 200 + "overrides": [] 201 + }, 202 + "gridPos": { 203 + "h": 8, 204 + "w": 12, 205 + "x": 0, 206 + "y": 24 207 + }, 208 + "id": 7, 209 + "targets": [ 210 + { 211 + "expr": "histogram_quantile(0.95, sum by (le) (rate(perlsky_service_proxy_local_post_index_rebuild_duration_seconds_bucket[5m])))", 212 + "legendFormat": "p95 rebuild", 213 + "refId": "A" 214 + } 215 + ], 216 + "title": "Local Post Index Rebuild P95", 217 + "type": "timeseries" 218 + }, 219 + { 220 + "datasource": { 221 + "type": "prometheus", 222 + "uid": "prometheus" 223 + }, 224 + "fieldConfig": { 225 + "defaults": { 226 + "unit": "short" 227 + }, 228 + "overrides": [] 229 + }, 230 + "gridPos": { 231 + "h": 8, 232 + "w": 12, 233 + "x": 12, 234 + "y": 24 235 + }, 236 + "id": 8, 237 + "targets": [ 238 + { 239 + "expr": "perlsky_service_proxy_local_post_index_entries", 240 + "legendFormat": "{{kind}}", 241 + "refId": "A" 242 + } 243 + ], 244 + "title": "Local Post Index Size", 245 + "type": "timeseries" 246 + }, 247 + { 248 + "datasource": { 249 + "type": "prometheus", 250 + "uid": "prometheus" 251 + }, 252 + "fieldConfig": { 253 + "defaults": { 254 + "unit": "s" 255 + }, 256 + "overrides": [] 257 + }, 258 + "gridPos": { 259 + "h": 8, 260 + "w": 12, 261 + "x": 0, 262 + "y": 32 263 + }, 264 + "id": 9, 265 + "targets": [ 266 + { 267 + "expr": "histogram_quantile(0.95, sum by (le, operation) (rate(perlsky_store_operation_duration_seconds_bucket[5m])))", 268 + "legendFormat": "{{operation}}", 269 + "refId": "A" 270 + } 271 + ], 272 + "title": "P95 SQLite Operation Latency", 273 + "type": "timeseries" 274 + }, 275 + { 276 + "datasource": { 277 + "type": "prometheus", 278 + "uid": "prometheus" 279 + }, 280 + "fieldConfig": { 281 + "defaults": { 282 + "unit": "ops" 283 + }, 284 + "overrides": [] 285 + }, 286 + "gridPos": { 287 + "h": 8, 288 + "w": 12, 289 + "x": 12, 290 + "y": 32 291 + }, 292 + "id": 10, 293 + "targets": [ 294 + { 295 + "expr": "sum by (operation, status) (rate(perlsky_store_operations_total[5m]))", 296 + "legendFormat": "{{operation}} {{status}}", 297 + "refId": "A" 298 + } 299 + ], 300 + "title": "SQLite Operation Rate", 301 + "type": "timeseries" 302 + }, 303 + { 304 + "datasource": { 305 + "type": "prometheus", 306 + "uid": "prometheus" 307 + }, 308 + "fieldConfig": { 309 + "defaults": { 310 + "unit": "short" 311 + }, 312 + "overrides": [] 313 + }, 314 + "gridPos": { 315 + "h": 8, 316 + "w": 12, 317 + "x": 0, 318 + "y": 40 319 + }, 320 + "id": 11, 321 + "targets": [ 322 + { 323 + "expr": "sum by (nsid) (perlsky_subscription_active)", 324 + "legendFormat": "{{nsid}}", 325 + "refId": "A" 326 + }, 327 + { 328 + "expr": "sum by (nsid, frame_type) (rate(perlsky_subscription_frames_total[5m]))", 329 + "legendFormat": "{{nsid}} {{frame_type}}", 330 + "refId": "B" 331 + } 332 + ], 333 + "title": "Subscription Health", 334 + "type": "timeseries" 335 + }, 336 + { 337 + "datasource": { 338 + "type": "prometheus", 339 + "uid": "prometheus" 340 + }, 341 + "fieldConfig": { 342 + "defaults": { 343 + "unit": "Bps" 344 + }, 345 + "overrides": [] 346 + }, 347 + "gridPos": { 348 + "h": 8, 349 + "w": 12, 350 + "x": 12, 351 + "y": 40 352 + }, 353 + "id": 12, 354 + "targets": [ 355 + { 356 + "expr": "sum(rate(perlsky_blob_ingress_bytes_total[5m]))", 357 + "legendFormat": "ingress", 358 + "refId": "A" 359 + }, 360 + { 361 + "expr": "sum(rate(perlsky_blob_egress_bytes_total[5m]))", 362 + "legendFormat": "egress", 363 + "refId": "B" 364 + } 365 + ], 366 + "title": "Blob Traffic", 367 + "type": "timeseries" 368 + } 369 + ], 370 + "refresh": "30s", 371 + "schemaVersion": 39, 372 + "style": "dark", 373 + "tags": [ 374 + "perlsky", 375 + "prometheus", 376 + "pds" 377 + ], 378 + "templating": { 379 + "list": [] 380 + }, 381 + "time": { 382 + "from": "now-6h", 383 + "to": "now" 384 + }, 385 + "timepicker": {}, 386 + "timezone": "", 387 + "title": "perlsky Overview", 388 + "uid": "perlsky-overview", 389 + "version": 1 390 + }
+10
ops/grafana/prometheus-datasource.yml
··· 1 + apiVersion: 1 2 + 3 + datasources: 4 + - name: Prometheus 5 + type: prometheus 6 + access: proxy 7 + uid: prometheus 8 + url: http://127.0.0.1:9090 9 + isDefault: true 10 + editable: false
+16
ops/prometheus/perlsky.yml
··· 1 + # Example Prometheus scrape job for perlsky. 2 + # 3 + # Replace the bearer token with the value from `metrics_token` in 4 + # `/etc/perlsky/perlsky.json`. 5 + 6 + - job_name: perlsky 7 + scrape_interval: 15s 8 + scrape_timeout: 5s 9 + metrics_path: /metrics 10 + scheme: http 11 + authorization: 12 + credentials: REPLACE_WITH_PERLSKY_METRICS_TOKEN 13 + static_configs: 14 + - targets: ['127.0.0.1:7755'] 15 + labels: 16 + service: perlsky
+345
script/benchmark-local-appview
··· 1 + #!/usr/bin/env perl 2 + 3 + use v5.34; 4 + use warnings; 5 + 6 + use Config (); 7 + use File::Spec; 8 + use File::Temp qw(tempdir); 9 + use FindBin qw($Bin); 10 + use Getopt::Long qw(GetOptions); 11 + use IO::Socket::INET; 12 + use JSON::PP qw(encode_json decode_json); 13 + use Mojo::Server::Daemon; 14 + use Mojo::UserAgent; 15 + use POSIX qw(strftime); 16 + use Time::HiRes qw(time sleep); 17 + 18 + BEGIN { 19 + require lib; 20 + my $root = File::Spec->rel2abs(File::Spec->catdir($Bin, '..')); 21 + lib->import( 22 + File::Spec->catdir($root, 'lib'), 23 + File::Spec->catdir($root, 'local', 'lib', 'perl5'), 24 + File::Spec->catdir($root, 'local', 'lib', 'perl5', $Config::Config{archname}), 25 + ); 26 + } 27 + 28 + use ATProto::PDS; 29 + 30 + my %opt = ( 31 + iterations => 50, 32 + warmup => 10, 33 + posts => 50, 34 + replies => 8, 35 + feed_limit => 25, 36 + format => 'text', 37 + keep_tmp => 0, 38 + ); 39 + 40 + GetOptions( 41 + 'iterations=i' => \$opt{iterations}, 42 + 'warmup=i' => \$opt{warmup}, 43 + 'posts=i' => \$opt{posts}, 44 + 'replies=i' => \$opt{replies}, 45 + 'feed-limit=i' => \$opt{feed_limit}, 46 + 'format=s' => \$opt{format}, 47 + 'keep-tmp!' => \$opt{keep_tmp}, 48 + ) or die "usage: $0 [--iterations N] [--warmup N] [--posts N] [--replies N] [--feed-limit N] [--format text|json] [--keep-tmp]\n"; 49 + 50 + die "--iterations must be >= 1\n" unless $opt{iterations} >= 1; 51 + die "--warmup must be >= 0\n" unless $opt{warmup} >= 0; 52 + die "--posts must be >= 1\n" unless $opt{posts} >= 1; 53 + die "--replies must be >= 0\n" unless $opt{replies} >= 0; 54 + die "--feed-limit must be >= 1\n" unless $opt{feed_limit} >= 1; 55 + die "--format must be text or json\n" unless $opt{format} eq 'text' || $opt{format} eq 'json'; 56 + 57 + my $root = File::Spec->rel2abs(File::Spec->catdir($Bin, '..')); 58 + my $tmp = tempdir('perlsky-bench-XXXXXX', TMPDIR => 1, CLEANUP => !$opt{keep_tmp}); 59 + my $data_dir = File::Spec->catdir($tmp, 'data'); 60 + my $db_path = File::Spec->catfile($tmp, 'perlsky.sqlite'); 61 + my $port = _find_free_port(); 62 + my $base_url = "http://127.0.0.1:$port"; 63 + my $metrics_token = 'benchmark-metrics-token'; 64 + my @server_pids; 65 + 66 + END { 67 + my $status = $?; 68 + kill 'TERM', @server_pids if @server_pids; 69 + waitpid($_, 0) for @server_pids; 70 + $? = $status; 71 + } 72 + 73 + my $app = ATProto::PDS->new( 74 + project_root => $root, 75 + settings => { 76 + host => '127.0.0.1', 77 + port => $port, 78 + base_url => $base_url, 79 + hostname => '127.0.0.1', 80 + service_did_method => 'did:web', 81 + service_handle_domain => 'benchmark.test', 82 + jwt_secret => 'benchmark-secret', 83 + admin_password => 'benchmark-admin-secret', 84 + metrics_token => $metrics_token, 85 + data_dir => $data_dir, 86 + db_path => $db_path, 87 + }, 88 + ); 89 + $app->log->level('fatal'); 90 + 91 + _start_server($app, $port); 92 + 93 + my $ua = Mojo::UserAgent->new(max_redirects => 0); 94 + $ua->request_timeout(30); 95 + $ua->inactivity_timeout(30); 96 + 97 + my $account = _post_json($ua, "$base_url/xrpc/com.atproto.server.createAccount", {}, { 98 + handle => 'alice.benchmark.test', 99 + email => 'alice@benchmark.test', 100 + password => 'benchmark-password', 101 + }); 102 + 103 + my $access = $account->{accessJwt} or die "createAccount did not return accessJwt\n"; 104 + my $did = $account->{did} or die "createAccount did not return did\n"; 105 + 106 + my $root_post = _post_record( 107 + $ua, 108 + $base_url, 109 + $access, 110 + $did, 111 + 'bench-root', 112 + { 113 + '$type' => 'app.bsky.feed.post', 114 + text => 'benchmark root', 115 + createdAt => '2026-03-11T19:00:00Z', 116 + }, 117 + ); 118 + 119 + for my $index (1 .. $opt{posts} - 1) { 120 + _post_record( 121 + $ua, 122 + $base_url, 123 + $access, 124 + $did, 125 + sprintf('bench-post-%03d', $index), 126 + { 127 + '$type' => 'app.bsky.feed.post', 128 + text => "benchmark post $index", 129 + createdAt => sprintf('2026-03-11T19:%02d:%02dZ', int($index / 60), $index % 60), 130 + }, 131 + ); 132 + } 133 + 134 + my $parent = $root_post; 135 + for my $index (1 .. $opt{replies}) { 136 + $parent = _post_record( 137 + $ua, 138 + $base_url, 139 + $access, 140 + $did, 141 + sprintf('bench-reply-%03d', $index), 142 + { 143 + '$type' => 'app.bsky.feed.post', 144 + text => "benchmark reply $index", 145 + reply => { 146 + root => { uri => $root_post->{uri}, cid => $root_post->{cid} }, 147 + parent => { uri => $parent->{uri}, cid => $parent->{cid} }, 148 + }, 149 + createdAt => sprintf('2026-03-11T20:%02d:%02dZ', int($index / 60), $index % 60), 150 + }, 151 + ); 152 + } 153 + 154 + my %headers = (Authorization => "Bearer $access"); 155 + my %scenarios = ( 156 + get_profile => { 157 + url => "$base_url/xrpc/app.bsky.actor.getProfile?actor=" . _uri_escape($did), 158 + }, 159 + get_author_feed => { 160 + url => "$base_url/xrpc/app.bsky.feed.getAuthorFeed?actor=" 161 + . _uri_escape($did) 162 + . '&limit=' . $opt{feed_limit}, 163 + }, 164 + get_post_thread => { 165 + url => "$base_url/xrpc/app.bsky.feed.getPostThread?uri=" . _uri_escape($parent->{uri}), 166 + }, 167 + ); 168 + 169 + for my $scenario (values %scenarios) { 170 + _exercise_endpoint($ua, $scenario->{url}, \%headers) for 1 .. $opt{warmup}; 171 + } 172 + 173 + my %results; 174 + for my $name (sort keys %scenarios) { 175 + my @samples; 176 + for (1 .. $opt{iterations}) { 177 + push @samples, _exercise_endpoint($ua, $scenarios{$name}{url}, \%headers); 178 + } 179 + $results{$name} = _summarize_samples(\@samples); 180 + } 181 + 182 + my $metrics_body = _get_text( 183 + $ua, 184 + "$base_url/metrics", 185 + { Authorization => "Bearer $metrics_token" }, 186 + ); 187 + my @metrics_excerpt = grep { 188 + /^perlsky_service_proxy_(?:request|requests|local_post_index|local_post_resolution|profile_record_cache)/ 189 + || /^perlsky_repo_resolution_total/ 190 + } split /\n/, $metrics_body; 191 + 192 + my $report = { 193 + generated_at => strftime('%Y-%m-%dT%H:%M:%SZ', gmtime), 194 + base_url => $base_url, 195 + temp_dir => $tmp, 196 + scenario => { 197 + posts => $opt{posts}, 198 + replies => $opt{replies}, 199 + iterations => $opt{iterations}, 200 + warmup => $opt{warmup}, 201 + feed_limit => $opt{feed_limit}, 202 + }, 203 + results => \%results, 204 + metrics_excerpt => \@metrics_excerpt, 205 + }; 206 + 207 + if ($opt{format} eq 'json') { 208 + print encode_json($report), "\n"; 209 + exit 0; 210 + } 211 + 212 + print "perlsky local appview benchmark\n"; 213 + print "base_url: $base_url\n"; 214 + print "temp_dir: $tmp\n"; 215 + print "scenario: posts=$opt{posts} replies=$opt{replies} iterations=$opt{iterations} warmup=$opt{warmup} feed_limit=$opt{feed_limit}\n"; 216 + for my $name (sort keys %results) { 217 + my $summary = $results{$name}; 218 + printf "%s: avg=%.4fs p50=%.4fs p95=%.4fs max=%.4fs req/s=%.1f\n", 219 + $name, 220 + @{$summary}{qw(avg p50 p95 max requests_per_second)}; 221 + } 222 + print "\nmetrics excerpt:\n"; 223 + print "$_\n" for @metrics_excerpt; 224 + 225 + sub _post_record { 226 + my ($ua, $base_url, $access, $did, $rkey, $record) = @_; 227 + return _post_json( 228 + $ua, 229 + "$base_url/xrpc/com.atproto.repo.createRecord", 230 + { Authorization => "Bearer $access" }, 231 + { 232 + repo => $did, 233 + collection => 'app.bsky.feed.post', 234 + rkey => $rkey, 235 + record => $record, 236 + }, 237 + ); 238 + } 239 + 240 + sub _exercise_endpoint { 241 + my ($ua, $url, $headers) = @_; 242 + my $started = time; 243 + my $tx = $ua->get($url => $headers); 244 + my $res = $tx->result; 245 + die "GET $url failed: " . ($res->code // 'unknown') . ' ' . ($res->body // q()) 246 + unless ($res->code // 0) == 200; 247 + decode_json($res->body // '{}'); 248 + return time - $started; 249 + } 250 + 251 + sub _post_json { 252 + my ($ua, $url, $headers, $payload) = @_; 253 + my $tx = $ua->post($url => $headers => json => $payload); 254 + my $res = $tx->result; 255 + die "POST $url failed: " . ($res->code // 'unknown') . ' ' . ($res->body // q()) 256 + unless ($res->code // 0) == 200; 257 + return decode_json($res->body // '{}'); 258 + } 259 + 260 + sub _get_text { 261 + my ($ua, $url, $headers) = @_; 262 + my $tx = $ua->get($url => $headers); 263 + my $res = $tx->result; 264 + die "GET $url failed: " . ($res->code // 'unknown') . ' ' . ($res->body // q()) 265 + unless ($res->code // 0) == 200; 266 + return $res->body // q(); 267 + } 268 + 269 + sub _summarize_samples { 270 + my ($samples) = @_; 271 + my @sorted = sort { $a <=> $b } @$samples; 272 + my $count = @sorted; 273 + my $sum = 0; 274 + $sum += $_ for @sorted; 275 + my $avg = $sum / $count; 276 + return { 277 + count => $count, 278 + min => $sorted[0], 279 + max => $sorted[-1], 280 + avg => $avg, 281 + p50 => _quantile(\@sorted, 0.50), 282 + p95 => _quantile(\@sorted, 0.95), 283 + requests_per_second => $avg > 0 ? (1 / $avg) : 0, 284 + }; 285 + } 286 + 287 + sub _quantile { 288 + my ($samples, $quantile) = @_; 289 + return 0 unless @$samples; 290 + my $index = int((@$samples - 1) * $quantile); 291 + return $samples->[$index]; 292 + } 293 + 294 + sub _start_server { 295 + my ($app, $port) = @_; 296 + my $pid = fork(); 297 + die 'fork failed' unless defined $pid; 298 + 299 + if ($pid == 0) { 300 + my $daemon = Mojo::Server::Daemon->new( 301 + app => $app, 302 + listen => ["http://127.0.0.1:$port"], 303 + silent => 1, 304 + ); 305 + $daemon->run; 306 + exit 0; 307 + } 308 + 309 + push @server_pids, $pid; 310 + _wait_for_ready("http://127.0.0.1:$port/_health"); 311 + } 312 + 313 + sub _wait_for_ready { 314 + my ($health_url) = @_; 315 + my $ua = Mojo::UserAgent->new(max_redirects => 0); 316 + for (1 .. 100) { 317 + my $ok = eval { 318 + my $tx = $ua->get($health_url); 319 + my $res = $tx->result; 320 + return ($res->code // 0) == 200; 321 + }; 322 + return 1 if $ok; 323 + sleep 0.05; 324 + } 325 + die "timed out waiting for $health_url\n"; 326 + } 327 + 328 + sub _find_free_port { 329 + my $sock = IO::Socket::INET->new( 330 + LocalAddr => '127.0.0.1', 331 + LocalPort => 0, 332 + Proto => 'tcp', 333 + Listen => 1, 334 + ReuseAddr => 1, 335 + ) or die "unable to allocate port: $!"; 336 + my $port = $sock->sockport; 337 + close $sock; 338 + return $port; 339 + } 340 + 341 + sub _uri_escape { 342 + my ($value) = @_; 343 + $value =~ s/([^A-Za-z0-9._~-])/sprintf('%%%02X', ord($1))/ge; 344 + return $value; 345 + }