Skip to content

Commit f289bda

Browse files
authored
Merge pull request #48 from SpecterOps/kpom/safety-harnesses
[PQE-401] Integration tests, data portability and benchmarks
2 parents 29ae6d8 + 7a759ed commit f289bda

40 files changed

Lines changed: 4185 additions & 1 deletion

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
*.iml
22

33
# User IDE Files
4-
.idea
4+
.idea
5+
6+
# Local integration test datasets
7+
integration/testdata/local/

cmd/benchmark/README.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# Benchmark
2+
3+
Runs query scenarios against a real database and outputs a markdown timing table.
4+
5+
## Usage
6+
7+
```bash
8+
# Default dataset (base)
9+
go run ./cmd/benchmark -connection "postgresql://dawgs:dawgs@localhost:5432/dawgs"
10+
11+
# Local dataset (not committed to repo)
12+
go run ./cmd/benchmark -connection "..." -dataset local/phantom
13+
14+
# Default + local dataset
15+
go run ./cmd/benchmark -connection "..." -local-dataset local/phantom
16+
17+
# Neo4j
18+
go run ./cmd/benchmark -driver neo4j -connection "neo4j://neo4j:password@localhost:7687"
19+
20+
# Save to file
21+
go run ./cmd/benchmark -connection "..." -output report.md
22+
```
23+
24+
## Flags
25+
26+
| Flag | Default | Description |
27+
|------|---------|-------------|
28+
| `-driver` | `pg` | Database driver (`pg`, `neo4j`) |
29+
| `-connection` | | Connection string (or `PG_CONNECTION_STRING` env) |
30+
| `-iterations` | `10` | Timed iterations per scenario |
31+
| `-dataset` | | Run only this dataset |
32+
| `-local-dataset` | | Add a local dataset to the default set |
33+
| `-dataset-dir` | `integration/testdata` | Path to testdata directory |
34+
| `-output` | stdout | Markdown output file |
35+
36+
## Example: Neo4j on local/phantom
37+
38+
```
39+
$ go run ./cmd/benchmark -driver neo4j -connection "neo4j://neo4j:testpassword@localhost:7687" -dataset local/phantom
40+
```
41+
42+
| Query | Dataset | Median | P95 | Max |
43+
|-------|---------|-------:|----:|----:|
44+
| Match Nodes | local/phantom | 1.4ms | 2.3ms | 2.3ms |
45+
| Match Edges | local/phantom | 1.6ms | 1.9ms | 1.9ms |
46+
| Filter By Kind / User | local/phantom | 2.0ms | 2.6ms | 2.6ms |
47+
| Filter By Kind / Group | local/phantom | 2.1ms | 2.3ms | 2.3ms |
48+
| Filter By Kind / Computer | local/phantom | 1.6ms | 2.0ms | 2.0ms |
49+
| Traversal Depth / depth 1 | local/phantom | 1.4ms | 2.1ms | 2.1ms |
50+
| Traversal Depth / depth 2 | local/phantom | 1.6ms | 1.9ms | 1.9ms |
51+
| Traversal Depth / depth 3 | local/phantom | 2.5ms | 3.3ms | 3.3ms |
52+
| Edge Kind Traversal / MemberOf | local/phantom | 1.2ms | 1.4ms | 1.4ms |
53+
| Edge Kind Traversal / GenericAll | local/phantom | 1.1ms | 1.5ms | 1.5ms |
54+
| Edge Kind Traversal / HasSession | local/phantom | 1.1ms | 1.4ms | 1.4ms |
55+
| Shortest Paths / 41 -> 587 | local/phantom | 1.5ms | 1.9ms | 1.9ms |
56+
57+
## Example: PG on local/phantom
58+
59+
```
60+
$ export PG_CONNECTION_STRING="postgresql://dawgs:dawgs@localhost:5432/dawgs"
61+
$ go run ./cmd/benchmark -dataset local/phantom
62+
```
63+
64+
| Query | Dataset | Median | P95 | Max |
65+
|-------|---------|-------:|----:|----:|
66+
| Match Nodes | local/phantom | 2.0ms | 6.5ms | 6.5ms |
67+
| Match Edges | local/phantom | 464ms | 604ms | 604ms |
68+
| Filter By Kind / User | local/phantom | 4.5ms | 18.3ms | 18.3ms |
69+
| Filter By Kind / Group | local/phantom | 6.2ms | 28.8ms | 28.8ms |
70+
| Filter By Kind / Computer | local/phantom | 1.1ms | 5.5ms | 5.5ms |
71+
| Traversal Depth / depth 1 | local/phantom | 596ms | 636ms | 636ms |
72+
| Traversal Depth / depth 2 | local/phantom | 639ms | 660ms | 660ms |
73+
| Traversal Depth / depth 3 | local/phantom | 726ms | 745ms | 745ms |
74+
| Edge Kind Traversal / MemberOf | local/phantom | 602ms | 627ms | 627ms |
75+
| Edge Kind Traversal / GenericAll | local/phantom | 676ms | 791ms | 791ms |
76+
| Edge Kind Traversal / HasSession | local/phantom | 682ms | 778ms | 778ms |
77+
| Shortest Paths / 41 -> 587 | local/phantom | 708ms | 731ms | 731ms |

cmd/benchmark/main.go

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
// Copyright 2026 Specter Ops, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
//
15+
// SPDX-License-Identifier: Apache-2.0
16+
17+
package main
18+
19+
import (
20+
"context"
21+
"flag"
22+
"fmt"
23+
"os"
24+
"os/exec"
25+
"strings"
26+
"time"
27+
28+
"github.com/specterops/dawgs"
29+
"github.com/specterops/dawgs/drivers/pg"
30+
"github.com/specterops/dawgs/graph"
31+
"github.com/specterops/dawgs/opengraph"
32+
"github.com/specterops/dawgs/util/size"
33+
34+
_ "github.com/specterops/dawgs/drivers/neo4j"
35+
)
36+
37+
func main() {
38+
var (
39+
driver = flag.String("driver", "pg", "database driver (pg, neo4j)")
40+
connStr = flag.String("connection", "", "database connection string (or PG_CONNECTION_STRING)")
41+
iterations = flag.Int("iterations", 10, "timed iterations per scenario")
42+
output = flag.String("output", "", "markdown output file (default: stdout)")
43+
datasetDir = flag.String("dataset-dir", "integration/testdata", "path to testdata directory")
44+
localDataset = flag.String("local-dataset", "", "additional local dataset (e.g. local/phantom)")
45+
onlyDataset = flag.String("dataset", "", "run only this dataset (e.g. diamond, local/phantom)")
46+
)
47+
48+
flag.Parse()
49+
50+
conn := *connStr
51+
if conn == "" {
52+
conn = os.Getenv("PG_CONNECTION_STRING")
53+
}
54+
if conn == "" {
55+
fatal("no connection string: set -connection flag or PG_CONNECTION_STRING env var")
56+
}
57+
58+
ctx := context.Background()
59+
60+
cfg := dawgs.Config{
61+
GraphQueryMemoryLimit: size.Gibibyte,
62+
ConnectionString: conn,
63+
}
64+
65+
if *driver == pg.DriverName {
66+
pool, err := pg.NewPool(conn)
67+
if err != nil {
68+
fatal("failed to create pool: %v", err)
69+
}
70+
cfg.Pool = pool
71+
}
72+
73+
db, err := dawgs.Open(ctx, *driver, cfg)
74+
if err != nil {
75+
fatal("failed to open database: %v", err)
76+
}
77+
defer db.Close(ctx)
78+
79+
// Build dataset list
80+
var datasets []string
81+
if *onlyDataset != "" {
82+
datasets = []string{*onlyDataset}
83+
} else {
84+
datasets = defaultDatasets
85+
if *localDataset != "" {
86+
datasets = append(datasets, *localDataset)
87+
}
88+
}
89+
90+
// Scan all datasets for kinds and assert schema
91+
nodeKinds, edgeKinds := scanKinds(*datasetDir, datasets)
92+
93+
schema := graph.Schema{
94+
Graphs: []graph.Graph{{
95+
Name: "integration_test",
96+
Nodes: nodeKinds,
97+
Edges: edgeKinds,
98+
}},
99+
DefaultGraph: graph.Graph{Name: "integration_test"},
100+
}
101+
102+
if err := db.AssertSchema(ctx, schema); err != nil {
103+
fatal("failed to assert schema: %v", err)
104+
}
105+
106+
report := Report{
107+
Driver: *driver,
108+
GitRef: gitRef(),
109+
Date: time.Now().Format("2006-01-02"),
110+
Iterations: *iterations,
111+
}
112+
113+
for _, ds := range datasets {
114+
fmt.Fprintf(os.Stderr, "benchmarking %s...\n", ds)
115+
116+
// Clear graph
117+
if err := db.WriteTransaction(ctx, func(tx graph.Transaction) error {
118+
return tx.Nodes().Delete()
119+
}); err != nil {
120+
fmt.Fprintf(os.Stderr, " clear failed: %v\n", err)
121+
continue
122+
}
123+
124+
// Load dataset
125+
path := *datasetDir + "/" + ds + ".json"
126+
idMap, err := loadDataset(ctx, db, path)
127+
if err != nil {
128+
fmt.Fprintf(os.Stderr, " load failed: %v\n", err)
129+
continue
130+
}
131+
132+
fmt.Fprintf(os.Stderr, " loaded %d nodes\n", len(idMap))
133+
134+
// Run scenarios
135+
for _, s := range scenariosForDataset(ds, idMap) {
136+
result, err := runScenario(ctx, db, s, *iterations)
137+
if err != nil {
138+
fmt.Fprintf(os.Stderr, " %s/%s failed: %v\n", s.Section, s.Label, err)
139+
continue
140+
}
141+
142+
report.Results = append(report.Results, result)
143+
fmt.Fprintf(os.Stderr, " %s/%s: median=%s p95=%s max=%s\n",
144+
s.Section, s.Label,
145+
fmtDuration(result.Stats.Median),
146+
fmtDuration(result.Stats.P95),
147+
fmtDuration(result.Stats.Max),
148+
)
149+
}
150+
}
151+
152+
// Write markdown
153+
var mdOut *os.File
154+
if *output != "" {
155+
var err error
156+
mdOut, err = os.Create(*output)
157+
if err != nil {
158+
fatal("failed to create output: %v", err)
159+
}
160+
defer mdOut.Close()
161+
} else {
162+
mdOut = os.Stdout
163+
}
164+
165+
if err := writeMarkdown(mdOut, report); err != nil {
166+
fatal("failed to write markdown: %v", err)
167+
}
168+
169+
if *output != "" {
170+
fmt.Fprintf(os.Stderr, "wrote %s\n", *output)
171+
}
172+
}
173+
174+
func scanKinds(datasetDir string, datasets []string) (graph.Kinds, graph.Kinds) {
175+
var nodeKinds, edgeKinds graph.Kinds
176+
177+
for _, ds := range datasets {
178+
path := datasetDir + "/" + ds + ".json"
179+
f, err := os.Open(path)
180+
if err != nil {
181+
continue
182+
}
183+
184+
doc, err := opengraph.ParseDocument(f)
185+
f.Close()
186+
if err != nil {
187+
continue
188+
}
189+
190+
nk, ek := doc.Graph.Kinds()
191+
nodeKinds = nodeKinds.Add(nk...)
192+
edgeKinds = edgeKinds.Add(ek...)
193+
}
194+
195+
return nodeKinds, edgeKinds
196+
}
197+
198+
func loadDataset(ctx context.Context, db graph.Database, path string) (opengraph.IDMap, error) {
199+
f, err := os.Open(path)
200+
if err != nil {
201+
return nil, err
202+
}
203+
defer f.Close()
204+
205+
return opengraph.Load(ctx, db, f)
206+
}
207+
208+
func gitRef() string {
209+
out, err := exec.Command("git", "rev-parse", "--short", "HEAD").Output()
210+
if err != nil {
211+
return "unknown"
212+
}
213+
return strings.TrimSpace(string(out))
214+
}
215+
216+
func fatal(format string, args ...any) {
217+
fmt.Fprintf(os.Stderr, format+"\n", args...)
218+
os.Exit(1)
219+
}

cmd/benchmark/report.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Copyright 2026 Specter Ops, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
//
15+
// SPDX-License-Identifier: Apache-2.0
16+
17+
package main
18+
19+
import (
20+
"fmt"
21+
"io"
22+
"time"
23+
)
24+
25+
// Report holds all benchmark results and metadata.
26+
type Report struct {
27+
Driver string
28+
GitRef string
29+
Date string
30+
Iterations int
31+
Results []Result
32+
}
33+
34+
func writeMarkdown(w io.Writer, r Report) error {
35+
fmt.Fprintf(w, "# Benchmarks — %s @ %s (%s, %d iterations)\n\n", r.Driver, r.GitRef, r.Date, r.Iterations)
36+
fmt.Fprintf(w, "| Query | Dataset | Median | P95 | Max |\n")
37+
fmt.Fprintf(w, "|-------|---------|-------:|----:|----:|\n")
38+
39+
for _, res := range r.Results {
40+
label := res.Section
41+
if res.Label != res.Dataset {
42+
label = res.Section + " / " + res.Label
43+
}
44+
45+
fmt.Fprintf(w, "| %s | %s | %s | %s | %s |\n",
46+
label,
47+
res.Dataset,
48+
fmtDuration(res.Stats.Median),
49+
fmtDuration(res.Stats.P95),
50+
fmtDuration(res.Stats.Max),
51+
)
52+
}
53+
54+
fmt.Fprintln(w)
55+
return nil
56+
}
57+
58+
func fmtDuration(d time.Duration) string {
59+
ms := float64(d.Microseconds()) / 1000.0
60+
if ms < 1 {
61+
return fmt.Sprintf("%.2fms", ms)
62+
}
63+
if ms < 100 {
64+
return fmt.Sprintf("%.1fms", ms)
65+
}
66+
return fmt.Sprintf("%.0fms", ms)
67+
}

0 commit comments

Comments
 (0)