refactor: improve click buffer performance
This commit is contained in:
@@ -2,9 +2,9 @@
|
||||
[](https://hub.docker.com/r/sjdonado/bit)
|
||||
[](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
|
||||
|
||||
+19
-95
@@ -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
|
||||
|
||||
+211
-45
@@ -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.
|
||||
```
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user