mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-25 03:42:06 +00:00
7cda0d7896
Migrates from xarmian/pad to PerpetualSoftware/pad across the entire
repo and updates the product subtitle to "Collaborate with your AI
agents".
Go module rename
- go.mod: github.com/xarmian/pad → github.com/PerpetualSoftware/pad
- All Go imports updated across cmd/pad, internal/{cli,server,store,
models,collections,items,events,metrics,webhooks} (~130 files)
- Test fixtures with the literal repo slug ("xarmian/pad" in JSON
shapes, SSH/HTTPS git URL strings, workspace_context fixtures)
also updated, including the secondary repo entry
(xarmian/pad-web → PerpetualSoftware/pad-web — pad-web was also
moved to the org per branch context)
Docs / config
- README badges, install instructions, brew tap, Docker image, source
build path, sponsor link (sponsor link kept as personal @xarmian)
- Subtitle: "Project management for developers and AI agents." →
"Collaborate with your AI agents." (README, manifests, web layout
meta, .goreleaser homebrew description)
- CONTRIBUTING.md, SECURITY.md, skills/INSTALL.md
- .goreleaser.yaml: homebrew_casks owner, GHCR image, release github
owner, cosign cert-identity regex, comments
- .github/workflows/release.yml: tap/release comments
- deploy/k8s/deployment.yaml: container image
- docs/deployment.md: clone URL
- web/static/{site.webmanifest,manifest.json}: description
- web/src/routes/+layout.svelte: meta description + og:description
Brew tap path is PerpetualSoftware/tap/pad (CamelCase, matches
GitHub user case). GHCR image is ghcr.io/perpetualsoftware/pad
(lowercased per GHCR's URL normalization). CODEOWNERS @xarmian and
FUNDING.yml github: xarmian intentionally retained — those are the
personal maintainer / sponsor account, separate from the org repo.
Verification: go build ./..., go test ./... (all pkgs pass), web
build, and make install all clean (TASK-844, TASK-845).
257 lines
6.7 KiB
Go
257 lines
6.7 KiB
Go
package store
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// TestConcurrentWritePerformance measures how each backend handles concurrent
|
|
// writers. SQLite serializes writes (SQLITE_BUSY under contention); PostgreSQL
|
|
// supports true concurrent writes. This test quantifies the difference.
|
|
//
|
|
// Runs against both SQLite and PostgreSQL (when PAD_TEST_POSTGRES_URL is set).
|
|
func TestConcurrentWritePerformance(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping load test in short mode")
|
|
}
|
|
|
|
pgURL := os.Getenv("PAD_TEST_POSTGRES_URL")
|
|
|
|
// Always test SQLite.
|
|
t.Run("SQLite", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
s, err := New(filepath.Join(dir, "test.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer s.Close()
|
|
runConcurrentWriteTest(t, s, "SQLite")
|
|
})
|
|
|
|
// Test PostgreSQL if available.
|
|
if pgURL != "" {
|
|
t.Run("PostgreSQL", func(t *testing.T) {
|
|
s := testStorePostgres(t, pgURL)
|
|
runConcurrentWriteTest(t, s, "PostgreSQL")
|
|
})
|
|
}
|
|
}
|
|
|
|
func runConcurrentWriteTest(t *testing.T, s *Store, label string) {
|
|
t.Helper()
|
|
|
|
// Seed a workspace + collection.
|
|
ws, err := s.CreateWorkspace(models.WorkspaceCreate{Name: "loadtest"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
coll, err := s.CreateCollection(ws.ID, models.CollectionCreate{
|
|
Name: "Tasks",
|
|
Schema: `{"fields":[{"key":"status","label":"Status","type":"select","options":["open","done"],"default":"open","required":true}]}`,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
concurrencyLevels := []int{1, 5, 10, 25, 50}
|
|
opsPerWorker := 10
|
|
|
|
t.Logf("\n%s — Concurrent Write Performance", label)
|
|
t.Logf("%-12s | %-8s | %-10s | %-10s | %-10s | %s", "Workers", "Total", "Errors", "Throughput", "Avg", "p95")
|
|
t.Logf("%-12s-+%-10s+%-12s+%-12s+%-12s+%s", "------------", "----------", "------------", "------------", "------------", "----------")
|
|
|
|
for _, workers := range concurrencyLevels {
|
|
var (
|
|
wg sync.WaitGroup
|
|
errors atomic.Int64
|
|
latencies = make([]time.Duration, 0, workers*opsPerWorker)
|
|
mu sync.Mutex
|
|
)
|
|
|
|
start := time.Now()
|
|
|
|
for w := 0; w < workers; w++ {
|
|
wg.Add(1)
|
|
go func(workerID int) {
|
|
defer wg.Done()
|
|
for i := 0; i < opsPerWorker; i++ {
|
|
opStart := time.Now()
|
|
title := fmt.Sprintf("Worker %d Item %d", workerID, i)
|
|
_, err := s.CreateItem(ws.ID, coll.ID, models.ItemCreate{
|
|
Title: title,
|
|
Content: fmt.Sprintf("Content for %s — testing concurrent writes.", title),
|
|
Fields: `{"status":"open"}`,
|
|
})
|
|
elapsed := time.Since(opStart)
|
|
|
|
mu.Lock()
|
|
latencies = append(latencies, elapsed)
|
|
mu.Unlock()
|
|
|
|
if err != nil {
|
|
errors.Add(1)
|
|
mu.Lock()
|
|
if errors.Load() <= 3 {
|
|
t.Logf(" [worker %d] error: %v", workerID, err)
|
|
}
|
|
mu.Unlock()
|
|
}
|
|
}
|
|
}(w)
|
|
}
|
|
|
|
wg.Wait()
|
|
totalTime := time.Since(start)
|
|
totalOps := workers * opsPerWorker
|
|
errCount := errors.Load()
|
|
throughput := float64(totalOps) / totalTime.Seconds()
|
|
|
|
// Calculate avg and p95.
|
|
avg := avgDuration(latencies)
|
|
p95 := percentileDuration(latencies, 0.95)
|
|
|
|
t.Logf("%-12d | %-8d | %-10d | %-10.1f/s | %-10s | %s",
|
|
workers, totalOps, errCount, throughput, avg.Truncate(time.Microsecond), p95.Truncate(time.Microsecond))
|
|
|
|
if errCount > 0 && label == "PostgreSQL" {
|
|
t.Errorf("PostgreSQL had %d errors with %d concurrent writers — should handle this cleanly", errCount, workers)
|
|
}
|
|
}
|
|
}
|
|
|
|
func avgDuration(durations []time.Duration) time.Duration {
|
|
if len(durations) == 0 {
|
|
return 0
|
|
}
|
|
var total time.Duration
|
|
for _, d := range durations {
|
|
total += d
|
|
}
|
|
return total / time.Duration(len(durations))
|
|
}
|
|
|
|
func percentileDuration(durations []time.Duration, p float64) time.Duration {
|
|
if len(durations) == 0 {
|
|
return 0
|
|
}
|
|
// Simple sort-based percentile (good enough for test sizes).
|
|
sorted := make([]time.Duration, len(durations))
|
|
copy(sorted, durations)
|
|
// Insertion sort — small slices.
|
|
for i := 1; i < len(sorted); i++ {
|
|
key := sorted[i]
|
|
j := i - 1
|
|
for j >= 0 && sorted[j] > key {
|
|
sorted[j+1] = sorted[j]
|
|
j--
|
|
}
|
|
sorted[j+1] = key
|
|
}
|
|
idx := int(float64(len(sorted)-1) * p)
|
|
return sorted[idx]
|
|
}
|
|
|
|
// TestConcurrentMixedReadWrite simulates a realistic workload: mostly reads with
|
|
// some writes, to check for contention under typical usage patterns.
|
|
func TestConcurrentMixedReadWrite(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping load test in short mode")
|
|
}
|
|
|
|
s := testStore(t)
|
|
|
|
// Seed initial data.
|
|
ws, err := s.CreateWorkspace(models.WorkspaceCreate{Name: "mixed-load"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
coll, err := s.CreateCollection(ws.ID, models.CollectionCreate{
|
|
Name: "Tasks",
|
|
Schema: `{"fields":[{"key":"status","label":"Status","type":"select","options":["open","done"],"default":"open","required":true}]}`,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Pre-seed 50 items.
|
|
for i := 0; i < 50; i++ {
|
|
s.CreateItem(ws.ID, coll.ID, models.ItemCreate{
|
|
Title: fmt.Sprintf("Seed Item %d", i),
|
|
Content: "Seeded content for mixed workload test.",
|
|
Fields: `{"status":"open"}`,
|
|
})
|
|
}
|
|
|
|
const (
|
|
readers = 20
|
|
writers = 5
|
|
opsPerUser = 20
|
|
duration = 3 * time.Second
|
|
)
|
|
|
|
var (
|
|
readOps, writeOps atomic.Int64
|
|
readErrs, writeErrs atomic.Int64
|
|
wg sync.WaitGroup
|
|
)
|
|
|
|
deadline := time.Now().Add(duration)
|
|
|
|
// Readers: list items.
|
|
for r := 0; r < readers; r++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for time.Now().Before(deadline) {
|
|
_, err := s.ListItems(ws.ID, models.ItemListParams{Limit: 20})
|
|
readOps.Add(1)
|
|
if err != nil {
|
|
readErrs.Add(1)
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// Writers: create items.
|
|
for w := 0; w < writers; w++ {
|
|
wg.Add(1)
|
|
go func(id int) {
|
|
defer wg.Done()
|
|
i := 0
|
|
for time.Now().Before(deadline) {
|
|
_, err := s.CreateItem(ws.ID, coll.ID, models.ItemCreate{
|
|
Title: fmt.Sprintf("Mixed Writer %d Item %d", id, i),
|
|
Content: "Content from mixed workload writer.",
|
|
Fields: `{"status":"open"}`,
|
|
})
|
|
writeOps.Add(1)
|
|
if err != nil {
|
|
writeErrs.Add(1)
|
|
}
|
|
i++
|
|
}
|
|
}(w)
|
|
}
|
|
|
|
wg.Wait()
|
|
|
|
driver := string(s.D().Driver())
|
|
t.Logf("\n%s — Mixed Read/Write (%d readers, %d writers, %s)", driver, readers, writers, duration)
|
|
t.Logf(" Reads: %d ops (%d errors)", readOps.Load(), readErrs.Load())
|
|
t.Logf(" Writes: %d ops (%d errors)", writeOps.Load(), writeErrs.Load())
|
|
t.Logf(" Read throughput: %.0f ops/s", float64(readOps.Load())/duration.Seconds())
|
|
t.Logf(" Write throughput: %.0f ops/s", float64(writeOps.Load())/duration.Seconds())
|
|
|
|
// PostgreSQL should have zero errors.
|
|
if driver == "postgres" && (readErrs.Load() > 0 || writeErrs.Load() > 0) {
|
|
t.Errorf("PostgreSQL had errors under mixed workload: %d read, %d write", readErrs.Load(), writeErrs.Load())
|
|
}
|
|
}
|