From 0c89fad713b65db6b5b167c980ab77c4e60d57bf Mon Sep 17 00:00:00 2001 From: sjdonado Date: Sun, 2 Nov 2025 08:48:10 +0100 Subject: [PATCH] refactor: improve click buffer performance --- README.md | 6 +- app/controllers/click.cr | 114 +++-------------- docs/SETUP.md | 256 ++++++++++++++++++++++++++++++++------- scripts/cli.cr | 2 +- shard.yml | 4 +- 5 files changed, 236 insertions(+), 146 deletions(-) diff --git a/README.md b/README.md index 081f97a..549168c 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ [![Docker Stars](https://img.shields.io/docker/stars/sjdonado/bit.svg)](https://hub.docker.com/r/sjdonado/bit) [![Docker Image Size](https://img.shields.io/docker/image-size/sjdonado/bit/latest)](https://hub.docker.com/r/sjdonado/bit) -Lightweight URL shortener (API-only) with minimal resource requirements. Avg memory consumption **30MiB**, avg CPU load 20%. +Lightweight URL shortener (API-only) with minimal resource requirements. -Highly performant: **7.9k req/sec**, latency 15.8ms (100k requests using 125 connections, [benchmark](docs/SETUP.md#benchmark)). +Highly performant: **11k req/sec**, latency 11ms, 40MiB avg memory usage (100k requests using 125 connections, [benchmark](docs/SETUP.md#benchmark)). Self-hosted: [Dokku](docs/SETUP.md#dokku), [Docker Compose](docs/SETUP.md#docker-compose). @@ -20,7 +20,7 @@ It is feature-complete by design: simple and reliable without unnecessary bloat. ## Recommented requirements - 100MB disk space -- 70MiB RAM +- 100MiB RAM - x86_64 or ARM64 ## Documentation diff --git a/app/controllers/click.cr b/app/controllers/click.cr index b855510..1c17425 100644 --- a/app/controllers/click.cr +++ b/app/controllers/click.cr @@ -4,90 +4,6 @@ module App::Controllers include App::Lib include App::Services - # Buffered channel to hold click data - @@click_channel = Channel(NamedTuple( - link_id: Int64, - remote_address: String, - user_agent: String?, - referer: String - )).new(1024) - - @@processor_started = begin - spawn do - batch = [] of NamedTuple( - link_id: Int64, - remote_address: String, - user_agent: String?, - referer: String - ) - - batch_size = 32 - min_batch = 32 - max_batch = 512 - scaling_factor = 1.5 - - loop do - select - when click_data = @@click_channel.receive - batch << click_data - - if batch.size >= batch_size - process_click_batch(batch) - batch.clear - # Increase batch size when reaching capacity - batch_size = (batch_size * scaling_factor).to_i.clamp(min_batch, max_batch) - end - when timeout(1.seconds) - unless batch.empty? - process_click_batch(batch) - batch.clear - batch_size = (batch_size / scaling_factor).to_i.clamp(min_batch, max_batch) - else - # Reset to default if idle - batch_size = min_batch unless batch_size == min_batch - end - end - end - end - true - end - - private def self.process_click_batch(batch) - clicks = [] of App::Models::Click - - batch.each do |click_data| - begin - client_ip = IpLookup.ip_from_address(click_data[:remote_address]) - family, _, _, os = UserAgent.parse(click_data[:user_agent] || "") - - click = App::Models::Click.new - click.link_id = click_data[:link_id] - click.country = client_ip ? IpLookup.country(client_ip) : nil - click.user_agent = click_data[:user_agent] - click.browser = family - click.os = os.try &.[0] # OS family - click.referer = click_data[:referer] - - clicks << click - rescue ex - Log.error { "Click data processing error: #{ex.message}" } - end - end - - # Batch insert clicks if any were successfully processed - unless clicks.empty? - begin - multi = Crecto::Multi.new - clicks.each do |click| - multi.insert(click) - end - Database.transaction(multi) - rescue ex - Log.error { "Batch click insertion error: #{ex.message}" } - end - end - end - def self.redirect_handler ->(env : HTTP::Server::Context) { link_id, url = Database.raw_query("SELECT id, url FROM links WHERE slug = (?) LIMIT 1", env.params.url["slug"]) do |result| @@ -96,21 +12,29 @@ module App::Controllers remote_address = env.request.headers["Cf-Connecting-Ip"]? || env.request.remote_address.to_s + # Send redirect immediately env.response.status_code = 301 env.response.headers.add("Location", url) env.response.headers.add("X-Forwarded-For", remote_address) - begin - @@click_channel.send({ - link_id: link_id, - remote_address: remote_address, - user_agent: env.request.headers["User-Agent"]?, - referer: env.request.headers["Referer"]?.try { |r| URI.parse(r).host rescue r } || env.params.query["utm_source"]? || "Direct" - }) - rescue Channel::ClosedError - Log.error { "Click channel closed" } - rescue ex - Log.error { "Error queuing click: #{ex.message}" } + # non-blocking click proccessing + spawn do + begin + client_ip = IpLookup.ip_from_address(remote_address) + family, _, _, os = UserAgent.parse(env.request.headers["User-Agent"]? || "") + + click = App::Models::Click.new + click.link_id = link_id + click.country = client_ip ? IpLookup.country(client_ip) : nil + click.user_agent = env.request.headers["User-Agent"]? + click.browser = family + click.os = os.try &.[0] + click.referer = env.request.headers["Referer"]?.try { |r| URI.parse(r).host rescue r } || env.params.query["utm_source"]? || "Direct" + + Database.insert(click) + rescue ex + Log.error { "Click tracking error: #{ex.message}" } + end end } end diff --git a/docs/SETUP.md b/docs/SETUP.md index 0ca1e25..3743d2d 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -73,7 +73,7 @@ Recommended for lower latency communication (no host network traversal) ## Local Development ### Requirements -- Crystal 1.12+ +- Crystal 1.18+ - Shards package manager - SQLite3 @@ -109,59 +109,225 @@ ENV=test crystal spec ## Benchmark -- Colima: cpu 1, mem 1 -- SoC: Apple M3 Pro +### Run ``` -~/p/bit> colima start --cpu 1 --memory 1 -INFO[0000] starting colima -INFO[0000] runtime: docker -INFO[0001] starting ... context=vm -INFO[0076] provisioning ... context=docker -INFO[0077] starting ... context=docker -INFO[0077] done -~/p/bit> ./benchmark.cr -Setting up... -Waiting for the application to be ready... -Seeding the database... -Checking seed results... -Fetching all created links from /api/links... -Selected link for benchmarking: http://localhost:4000/slug2576 -Starting benchmark with Bombardier... -Bombarding http://localhost:4000/slug2576 with 100000 request(s) using 125 connection(s) - 100000 / 100000 [===================================================================================================================================================================] 100.00% 7795/s 12s +shards build --release --no-debug --progress --stats +shards run benchmark +``` + +### Output + +Chip: Apple M4 Pro. Memory: 24GB + +- Dry run + +``` +1762068811 ~/p/bit> shards build --release --no-debug --progress --stats +Dependencies are satisfied +Building: bit +Parse: 00:00:00.000041417 ( 1.17MB) +Semantic (top level): 00:00:00.407816208 ( 163.45MB) +Semantic (new): 00:00:00.001814125 ( 163.45MB) +Semantic (type declarations): 00:00:00.019943333 ( 179.45MB) +Semantic (abstract def check): 00:00:00.007606542 ( 195.45MB) +Semantic (restrictions augmenter): 00:00:00.006390917 ( 195.45MB) +Semantic (ivars initializers): 00:00:00.012892709 ( 211.45MB) +Semantic (cvars initializers): 00:00:00.101427125 ( 211.50MB) +Semantic (main): 00:00:00.639027292 ( 499.88MB) +Semantic (cleanup): 00:00:00.000513000 ( 499.88MB) +Semantic (recursive struct check): 00:00:00.000625542 ( 499.88MB) +Codegen (crystal): 00:00:00.514694833 ( 532.38MB) +Codegen (bc+obj): 00:00:14.734378459 ( 532.38MB) +Codegen (linking): 00:00:00.308837000 ( 532.38MB) + +Macro runs: + - /opt/homebrew/Cellar/crystal/1.18.2/share/crystal/src/ecr/process.cr: reused previous compilation (00:00:00.003361958) + +Codegen (bc+obj): + - no previous .o files were reused +Building: cli +Parse: 00:00:00.000057625 ( 1.17MB) +Semantic (top level): 00:00:00.378950750 ( 163.45MB) +Semantic (new): 00:00:00.001392542 ( 163.45MB) +Semantic (type declarations): 00:00:00.017725458 ( 179.45MB) +Semantic (abstract def check): 00:00:00.007331291 ( 195.45MB) +Semantic (restrictions augmenter): 00:00:00.006174250 ( 195.45MB) +Semantic (ivars initializers): 00:00:00.012456209 ( 211.45MB) +Semantic (cvars initializers): 00:00:00.101925250 ( 211.50MB) +Semantic (main): 00:00:00.283259791 ( 371.62MB) +Semantic (cleanup): 00:00:00.000385375 ( 371.62MB) +Semantic (recursive struct check): 00:00:00.000574250 ( 371.62MB) +Codegen (crystal): 00:00:00.318639083 ( 387.88MB) +Codegen (bc+obj): 00:00:00.090703209 ( 387.88MB) +Codegen (linking): 00:00:00.100725000 ( 387.88MB) + +Codegen (bc+obj): + - all previous .o files were reused +Building: benchmark +Parse: 00:00:00.000210708 ( 1.17MB) +Semantic (top level): 00:00:00.259058375 ( 147.78MB) +Semantic (new): 00:00:00.000878709 ( 147.78MB) +Semantic (type declarations): 00:00:00.012123625 ( 147.78MB) +Semantic (abstract def check): 00:00:00.032016792 ( 147.78MB) +Semantic (restrictions augmenter): 00:00:00.004018334 ( 147.78MB) +Semantic (ivars initializers): 00:00:00.006835041 ( 147.78MB) +Semantic (cvars initializers): 00:00:00.031038959 ( 195.78MB) +Semantic (main): 00:00:00.157428625 ( 243.83MB) +Semantic (cleanup): 00:00:00.000264416 ( 243.83MB) +Semantic (recursive struct check): 00:00:00.000380166 ( 243.83MB) +Codegen (crystal): 00:00:00.079188459 ( 259.83MB) +Codegen (bc+obj): 00:00:04.807389083 ( 259.83MB) +Codegen (linking): 00:00:00.098161042 ( 259.83MB) + +Codegen (bc+obj): + - no previous .o files were reused +1762068874 ~/p/bit> shards run benchmark +Dependencies are satisfied +Building: benchmark +Executing: benchmark +Starting application: ./bit... +Application output will be saved to: app_output.log +Application started with PID: 12693 +Checking if server is ready at http://localhost:4000... +.Server is ready! +Seeding database... +Database seeded successfully. +Fetching links from API... +Selected link: http://localhost:4000/slug9623 + +Starting benchmark with 100000 requests... +Bombarding http://localhost:4000/slug9623 with 100000 request(s) using 125 connection(s) + 100000 / 100000 [===============================================================================================================================================================================================] 100.00% 11079/s 9s Done! Statistics Avg Stdev Max - Reqs/sec 7900.70 7570.15 29263.59 - Latency 15.89ms 10.22ms 67.32ms + Reqs/sec 11361.35 8907.62 28610.94 + Latency 11.09ms 6.66ms 52.76ms Latency Distribution - 50% 4.82ms - 75% 9.24ms - 90% 51.61ms - 95% 52.74ms - 99% 55.07ms + 50% 1.93ms + 75% 3.12ms + 90% 39.47ms + 95% 40.07ms + 99% 42.60ms HTTP codes: 1xx - 0, 2xx - 0, 3xx - 100000, 4xx - 0, 5xx - 0 others - 0 - Throughput: 2.14MB/s + Throughput: 3.06MB/s + Benchmark completed successfully. -Analyzing resource usage... -Timestamp CPU(%) Memory(MiB) -1742763202 0.01 44.83 -1742763204 0.01 44.78 -1742763206 91.53 68.23 -1742763208 92.03 68.17 -1742763210 91.0 68.09 -1742763212 92.73 68.38 -1742763214 92.17 67.66 -1742763216 91.1 67.69 -1742763218 2.93 67.04 **** Resource Usage Statistics **** - Measurements: 9 - Average CPU Usage: 61.5% - Average Memory Usage: 62.76 MiB - Peak CPU Usage: 92.73% - Peak Memory Usage: 68.38 MiB -Cleanup completed. Resource usage data saved in resource_usage.txt + Measurements: 12 + Average CPU Usage: 73.94% + Average Memory Usage: 42.45 MiB + Peak CPU Usage: 100.0% + Peak Memory Usage: 56.02 MiB + +**** Files Generated **** + Resource stats: resource_usage.log + Application log: app_output.log + +Stopping application... +Application stopped. +``` + +- Second run + +``` +1762068874 ~/p/bit> shards run benchmark +Dependencies are satisfied +Building: benchmark +Executing: benchmark +Starting application: ./bit... +Application output will be saved to: app_output.log +Application started with PID: 12693 +Checking if server is ready at http://localhost:4000... +.Server is ready! +Seeding database... +Database seeded successfully. +Fetching links from API... +Selected link: http://localhost:4000/slug9623 + +Starting benchmark with 100000 requests... +Bombarding http://localhost:4000/slug9623 with 100000 request(s) using 125 connection(s) + 100000 / 100000 [===============================================================================================================================================================================================] 100.00% 11079/s 9s +Done! +Statistics Avg Stdev Max + Reqs/sec 11361.35 8907.62 28610.94 + Latency 11.09ms 6.66ms 52.76ms + Latency Distribution + 50% 1.93ms + 75% 3.12ms + 90% 39.47ms + 95% 40.07ms + 99% 42.60ms + HTTP codes: + 1xx - 0, 2xx - 0, 3xx - 100000, 4xx - 0, 5xx - 0 + others - 0 + Throughput: 3.06MB/s + +Benchmark completed successfully. + +**** Resource Usage Statistics **** + Measurements: 12 + Average CPU Usage: 73.94% + Average Memory Usage: 42.45 MiB + Peak CPU Usage: 100.0% + Peak Memory Usage: 56.02 MiB + +**** Files Generated **** + Resource stats: resource_usage.log + Application log: app_output.log + +Stopping application... +Application stopped. +1762068900 ~/p/bit> shards run benchmark +Dependencies are satisfied +Building: benchmark +Executing: benchmark +Starting application: ./bit... +Application output will be saved to: app_output.log +Application started with PID: 18421 +Checking if server is ready at http://localhost:4000... +.Server is ready! +Seeding database... +Runtime error near line 1: UNIQUE constraint failed: users.api_key (19) +Runtime error near line 7: UNIQUE constraint failed: links.slug (19) +Warning: Database seeding failed. Continuing anyway... +Fetching links from API... +Selected link: http://localhost:4000/slug5911 + +Starting benchmark with 100000 requests... +Bombarding http://localhost:4000/slug5911 with 100000 request(s) using 125 connection(s) + 100000 / 100000 [===============================================================================================================================================================================================] 100.00% 11080/s 9s +Done! +Statistics Avg Stdev Max + Reqs/sec 11213.27 8842.12 29594.78 + Latency 11.24ms 6.87ms 43.66ms + Latency Distribution + 50% 1.96ms + 75% 3.01ms + 90% 40.02ms + 95% 41.06ms + 99% 42.58ms + HTTP codes: + 1xx - 0, 2xx - 0, 3xx - 100000, 4xx - 0, 5xx - 0 + others - 0 + Throughput: 3.02MB/s + +Benchmark completed successfully. + +**** Resource Usage Statistics **** + Measurements: 12 + Average CPU Usage: 74.28% + Average Memory Usage: 32.18 MiB + Peak CPU Usage: 100.0% + Peak Memory Usage: 40.88 MiB + +**** Files Generated **** + Resource stats: resource_usage.log + Application log: app_output.log + +Stopping application... +Application stopped. ``` diff --git a/scripts/cli.cr b/scripts/cli.cr index f49d0bc..a38e32f 100644 --- a/scripts/cli.cr +++ b/scripts/cli.cr @@ -21,7 +21,7 @@ OptionParser.parse do |parser| parser.on("--update-parsers", "Download UA regexes and/or GeoLite2 database") do puts "=== Starting data files update ===" App::Services::Cli.update_uap_regexes - App::Services::Cli.download_geolite_db + App::Services::Cli.update_geolite_db puts "=== Data files updated successfully ===" exit end diff --git a/shard.yml b/shard.yml index 4a59fd0..e6f26be 100644 --- a/shard.yml +++ b/shard.yml @@ -1,5 +1,5 @@ name: bit -version: 1.5.3 +version: 1.6.0 authors: - Juan Rodriguez Ronado <@sjdonado> @@ -32,6 +32,6 @@ development_dependencies: spec-kemal: github: kemalcr/spec-kemal -crystal: ">= 1.12.1" +crystal: ">= 1.18.2" license: MIT