Skip to content

Commit 9e5d364

Browse files
committed
initial commit
0 parents  commit 9e5d364

25 files changed

+9753
-0
lines changed

.dockerignore

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/.vscode/
2+
.idea
3+
.DS_Store
4+
5+
# plaintask todo files
6+
*.todo
7+
8+
# generated markdown previews
9+
*.html
10+
11+
# ignore generated pb_data
12+
pb_data/
13+
14+
# binaries, deployment tools and configs
15+
app
16+
hop
17+
hop.yml
18+
fly
19+
fly.toml

.gitignore

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/.vscode/
2+
.idea
3+
.DS_Store
4+
5+
# plaintask todo files
6+
*.todo
7+
8+
# generated markdown previews
9+
*.html
10+
11+
# ignore generated pb_data
12+
pb_data/
13+
14+
# binaries, deployment tools and configs
15+
app
16+
hop
17+
hop.yml
18+
fly
19+
fly.toml

Dockerfile

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
FROM golang:1.20-alpine
2+
3+
WORKDIR /pb
4+
5+
COPY . .
6+
7+
RUN go mod tidy
8+
9+
RUN CGO_ENABLED=0 go build -o /pb/pocketbase
10+
11+
EXPOSE 8090
12+
13+
ADD docker_cmd.sh /docker_cmd.sh
14+
15+
CMD /docker_cmd.sh

LICENSE.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
The MIT License (MIT)
2+
Copyright (c) 2023 - present, Gani Georgiev
3+
4+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software
5+
and associated documentation files (the "Software"), to deal in the Software without restriction,
6+
including without limitation the rights to use, copy, modify, merge, publish, distribute,
7+
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software
8+
is furnished to do so, subject to the following conditions:
9+
10+
The above copyright notice and this permission notice shall be included in all copies or
11+
substantial portions of the Software.
12+
13+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
14+
BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
15+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
16+
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
17+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

README.md

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
PocketBase benchmarks
2+
======================================================================
3+
4+
This is a test PocketBase application with various benchmarks to serve
5+
as a general idea of what you could expect from a basic PocketBase instance
6+
without extra performance tuning or deployment on expensive server.
7+
8+
- [Results](#results)
9+
- [Takeaways and things we'll have to improve](#takeaways-and-things-well-have-to-improve)
10+
- [About the benchmarks](#about-the-benchmarks)
11+
12+
13+
## Results
14+
15+
- **Hetzner CAX11 (2vCPU ARM64, 4GB RAM, €3.79)**
16+
- [Pure Go results (default)](results/hetzner_cax11.md)
17+
- [CGO results](results/hetzner_cax11_cgo.md)
18+
19+
- **Hetzner CAX41 (16vCPU ARM64, 32GB RAM, €24.49)**
20+
- [Pure Go results (default)](results/hetzner_cax41.md)
21+
- [CGO results](results/hetzner_cax41_cgo.md)
22+
23+
- **Hop.io (1vCPU, 512MB RAM, _free tier_)**
24+
- [Pure Go results (default)](results/hop_free_tier.md)
25+
_(fails the mixed `posts50k` tests most likely a result of some resources abuse protection)_
26+
27+
- **Fly.io (1vCPU, 256MB RAM, _free tier_)**
28+
- [Pure Go results (default)](results/hop_free_tier.md)
29+
_(it requires swap to be configured to prevent OOM errors and restarts; fails the heavier `posts100k` tests most likely a result of some resources abuse protection)_
30+
31+
_Keep in mind that the tests are performed on the same machine where the PocketBase instance is deployed so the app performance can be slightly affected by the benchmarks execution itself (most hosts providers have several protections in place and at the moment I don't have the time to create proper setup to run the tests from more than one IP._
32+
33+
_There are several optimizations that can be done and the benchmarks will change in the future so that the tests can run as part of the development workflow to track regressions, but for now improving the overall PocketBase dev experience remains with higher priority._
34+
35+
36+
#### Takeaways and things we'll have to improve
37+
38+
- The Go and JS (_will be available with v0.17.0+_) custom routes and app hooks perform almost the same for low and medium concurrency (this is because by default we have 100 prewarmed `goja.Runtime`).
39+
40+
- At the moment there is no much difference in terms of query execution between the lower and higher spec Hetzner VMs (_probably because most of the operations are I/O bound_).
41+
42+
- We could experiment with different SQLite `PRAGMA` options to see if there will be any significant difference since we currently use mostly the defaults (_eg. increasing `page_size`, `cache_size`, heap limits, etc._).
43+
44+
- The default data DB connections limit (max:120, idle:20) could be changed to be dynamic based on the running hardware to improve the overall performance and reduce the memory usage.
45+
46+
- With higher concurrency individual query performance degrades (_probably because the runtime has to do more work, there is context switching involved, locks, etc._) but still the overall requests completion is better for most of the times.
47+
48+
- Basic API rules don't have significant impact on the performance. The performance starts to degrade when the rule condition requires a join with a large table (especially in case of a nested relation fields lookup). Joins over large datasets are slow even for simple queries like:
49+
50+
```sql
51+
SELECT COUNT(DISTINCT posts10k.rowid)
52+
FROM posts10k
53+
LEFT JOIN posts100k ON posts100k.author = posts10k.author
54+
```
55+
Creating indexes for the relation fields can help speeding up the queries but further optimization techniques will have to be researched.
56+
57+
- To prevent unnecessary `JOIN` statements we can implement internally a special condition that will treat single `relation` field statements like `rel.id = @request.auth.id` the same as `rel = @request.auth.id`.
58+
59+
- The existing json column normalizations (eg. `CASE WHEN json_valid ...`) have some impact on the performance (~10%) and can be further optimized by removing them entirely and ensuring that the field values are always stored in the correct format before create/update persistence (either on app level or triggers).
60+
61+
- The CGO driver for some queries in _large datasets_ can be ~1.5x-4x times faster.
62+
Building with `CGO_ENABLED=1` is the easiest way to boost the query performance without changing your db structure or hardware but keep in mind that it complicates the cross-compilation.
63+
64+
65+
## About the benchmarks
66+
67+
The application uses the `develop` branch of PocketBase.
68+
69+
The test database is ~180MB and has the following collections structure:
70+
71+
![db_erd](https://i.imgur.com/gYC8Qci.png)
72+
73+
In order to emulate real usage scenarios as close as possible, the tests are grouped in several categories:
74+
75+
- **create** - Tests the write (_insert_) HTTP API performance. It is also responsible for populating the test database.
76+
77+
- **search** - Tests the list/search (aka. `getList()`) HTTP API performance.
78+
It also contains scenarios with mixed list and update tests to observe how mixed read/write operations affects each other.
79+
80+
- **custom** - Tests for custom Go and JS code (routes, middlewares, hooks, etc.).
81+
82+
- **delete** - Test the delete HTTP API performance (including cascade delete).
83+
84+
85+
## Run the benchmarks
86+
87+
To run the benchmarks locally or on your server, you can:
88+
89+
0. _[Install Go 1.18+](https://go.dev/doc/install) (if you haven't already)_
90+
1. Clone/download the repo
91+
2. Run `GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build` (_https://go.dev/doc/install/source#environment_)
92+
3. Start the created executable by running `./app serve`.
93+
4. Navigate to `http:localhost:8090/benchmarks`
94+
95+
_By default all tests are executed in the previously mentioned test categories order, but you can also
96+
specify which exact tests to run using the `/benchmarks?run=custom,delete` query parameter._
97+
98+
The above will start the benchmarks in a new goroutine and once completed it will print its result to the terminal and in the `benchmarks` collection
99+
(_note that some of the tests are slow and it may take some time to complete; we write the test result as a collection record to workaround various host providers DDoS protections and restrictions like persistent connections limit, read/write timeouts, etc._).

benchmarks/bench.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package benchmarks
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"time"
7+
8+
"golang.org/x/sync/errgroup"
9+
)
10+
11+
type BenchResult struct {
12+
Best time.Duration
13+
Worst time.Duration
14+
Completed time.Duration
15+
Errors []error
16+
}
17+
18+
func (r BenchResult) String() string {
19+
return fmt.Sprintf(
20+
"```\n┌─ Best: %s\n├─ Worst: %s\n├─ Completed: %s\n└─ Errors: %d\n```",
21+
r.Best,
22+
r.Worst,
23+
r.Completed,
24+
len(r.Errors),
25+
)
26+
}
27+
28+
// A negative concurrency indicates no limit
29+
// (aka. a go routine will be fired for each iteration).
30+
func bench(action func(i int) error, iterations int, concurrency int) (*BenchResult, error) {
31+
if iterations < 1 {
32+
return nil, errors.New("iterations must be >= 1")
33+
}
34+
35+
totalStart := time.Now()
36+
37+
var errors []error
38+
39+
times := make([]time.Duration, iterations)
40+
41+
g := errgroup.Group{}
42+
g.SetLimit(concurrency)
43+
44+
for i := 0; i < iterations; i++ {
45+
i := i
46+
g.Go(func() error {
47+
start := time.Now()
48+
49+
if err := action(i); err != nil {
50+
errors = append(errors, err)
51+
}
52+
53+
times[i] = time.Since(start)
54+
55+
return nil
56+
})
57+
}
58+
59+
if err := g.Wait(); err != nil {
60+
return nil, err
61+
}
62+
63+
result := &BenchResult{
64+
Best: times[0],
65+
Worst: times[0],
66+
Completed: time.Since(totalStart),
67+
Errors: errors,
68+
}
69+
70+
// find the best and worst time
71+
for _, t := range times {
72+
if t < result.Best {
73+
result.Best = t
74+
}
75+
if t > result.Worst {
76+
result.Worst = t
77+
}
78+
}
79+
80+
return result, nil
81+
}

benchmarks/request.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package benchmarks
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net"
9+
"net/http"
10+
"time"
11+
)
12+
13+
type Request struct {
14+
Method string
15+
Url string
16+
Headers map[string]string
17+
Body io.Reader
18+
Context context.Context // default to context.Background()
19+
}
20+
21+
// If destBody is non-nil, it will read and unmarshal the request
22+
// response body into the specified variable.
23+
func (c *Request) Send(destBody any) error {
24+
if c.Context == nil {
25+
c.Context = context.Background()
26+
}
27+
28+
req, err := http.NewRequestWithContext(c.Context, c.Method, c.Url, c.Body)
29+
if err != nil {
30+
return err
31+
}
32+
33+
for k, v := range c.Headers {
34+
req.Header.Add(k, v)
35+
}
36+
37+
// set default content-type header (if missing)
38+
if req.Header.Get("content-type") == "" {
39+
req.Header.Set("content-type", "application/json")
40+
}
41+
42+
dialer := &net.Dialer{
43+
Timeout: 30 * time.Second,
44+
KeepAlive: 30 * time.Second,
45+
}
46+
47+
client := http.Client{
48+
Transport: &http.Transport{
49+
Proxy: http.ProxyFromEnvironment,
50+
DialContext: dialer.DialContext,
51+
MaxIdleConns: 0,
52+
IdleConnTimeout: 90 * time.Second,
53+
TLSHandshakeTimeout: 10 * time.Second,
54+
ExpectContinueTimeout: 1 * time.Second,
55+
},
56+
}
57+
58+
res, err := client.Do(req)
59+
if err != nil {
60+
return err
61+
}
62+
defer res.Body.Close()
63+
64+
if res.StatusCode >= 400 {
65+
return fmt.Errorf("request failed with status %d", res.StatusCode)
66+
}
67+
68+
if destBody != nil {
69+
bodyRaw, err := io.ReadAll(res.Body)
70+
if err != nil {
71+
return err
72+
}
73+
74+
if err := json.Unmarshal(bodyRaw, destBody); err != nil {
75+
return err
76+
}
77+
}
78+
79+
return nil
80+
}

0 commit comments

Comments
 (0)