Skip to content

Commit 600e212

Browse files
committed
feat(ops): expose peer snapshot endpoint
1 parent 32e8936 commit 600e212

6 files changed

Lines changed: 93 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ All notable changes to StreamHive are documented here. This project follows [Sem
44

55
## [Unreleased]
66

7+
### Added
8+
9+
- **Ops**: `/peers` JSON endpoint for inspecting active peer addresses and connection direction.
10+
711
## [0.5.0] — 2026-07-01
812

913
### Added

README.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
[![CI](https://github.com/AliSinaDevelo/StreamHive/actions/workflows/ci.yml/badge.svg)](https://github.com/AliSinaDevelo/StreamHive/actions/workflows/ci.yml)
44

5-
StreamHive is a **Go library and CLI** for experimenting with distributed, content-addressed storage. It ships a production-minded **TCP transport** (context-aware listen/dial, TLS hooks, framing, metrics, limits), a **length-prefixed wire format** (`SHV1`), a typed **blob replication protocol**, memory and file-backed **blob stores**, and operational endpoints (`/livez`, `/readyz`, `/metrics`, `/metrics/prometheus`).
5+
StreamHive is a **Go library and CLI** for experimenting with distributed, content-addressed storage. It ships a production-minded **TCP transport** (context-aware listen/dial, TLS hooks, framing, metrics, limits), a **length-prefixed wire format** (`SHV1`), a typed **blob replication protocol**, memory and file-backed **blob stores**, and operational endpoints (`/livez`, `/readyz`, `/peers`, `/metrics`, `/metrics/prometheus`).
66

77
**Semver:** public API versions are tracked in [CHANGELOG.md](CHANGELOG.md) and [internal/version/version.go](internal/version/version.go) (currently **v0.5.0**, pre-1.0).
88

@@ -21,7 +21,7 @@ go test ./...
2121
go run . -version
2222
make run
2323
./bin/fs -listen :7070 -dial 127.0.0.1:8080
24-
./bin/fs -listen 127.0.0.1:0 -health 127.0.0.1:8080 # HTTP live/ready/metrics
24+
./bin/fs -listen 127.0.0.1:0 -health 127.0.0.1:8080 # HTTP live/ready/peers/metrics
2525
```
2626

2727
### Two-node replication demo
@@ -44,7 +44,13 @@ Inspect counters:
4444
curl -s http://127.0.0.1:8080/metrics
4545
```
4646

47-
Look for `replication_blobs_stored`, `replication_bytes_stored`, duplicate counters, and transport frame counters. The sender derives the blob key from `SHA-256(put-data)` when `-put-content-key` is set; receivers verify SHA-256-shaped keys before storing. Use `/metrics` for JSON or `/metrics/prometheus` for Prometheus text format.
47+
Inspect connected peers:
48+
49+
```bash
50+
curl -s http://127.0.0.1:8080/peers
51+
```
52+
53+
Look for `replication_blobs_stored`, `replication_bytes_stored`, duplicate counters, and transport frame counters. The sender derives the blob key from `SHA-256(put-data)` when `-put-content-key` is set; receivers verify SHA-256-shaped keys before storing. Use `/metrics` for JSON counters, `/metrics/prometheus` for Prometheus text format, and `/peers` for a sorted peer snapshot.
4854

4955
Or run the whole flow:
5056

@@ -106,7 +112,7 @@ Wire handshake string constant: `p2p.HandshakeVersionV1` (carry inside applicati
106112
| `-peer-reconnect` | Retry `-peers` with exponential backoff |
107113
| `-peer-reconnect-min` / `-peer-reconnect-max` | Reconnect backoff bounds |
108114
| `-sync-interval` | Periodically advertise local blob keys to connected peers (0 = startup only) |
109-
| `-health` | HTTP `host:port` for `/livez`, `/readyz`, `/metrics` |
115+
| `-health` | HTTP `host:port` for `/livez`, `/readyz`, `/peers`, `/metrics` |
110116
| `-max-peers` | Cap simultaneous peers (0 = unlimited) |
111117
| `-dial-timeout` | Outbound dial timeout |
112118
| `-read-idle-timeout` | Peer read deadline refresh |

docs/ARCHITECTURE.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ flowchart TD
1212
rep --> st["Storage (storage)<br/>BlobStore"]
1313
st --> mem["MemoryStore"]
1414
st --> file["FileStore (durable)"]
15-
tr -. exposes .-> health["HTTP /livez /readyz /metrics"]
15+
tr -. exposes .-> health["HTTP /livez /readyz /peers /metrics"]
1616
```
1717

1818
## Layers
@@ -137,11 +137,12 @@ Implemented:
137137
- Message types: `blob.put`, `blob.has`, `blob.get`, and `blob.missing`.
138138
- Startup anti-entropy for connected `-replicate` peers.
139139
- Receiver-side storage via `storage.MemoryStore` or durable `storage.FileStore` with `-store-dir`.
140-
- JSON `/metrics` counters for stored/sent blobs, bytes, and replication errors.
140+
- JSON `/peers` snapshots for connected peer addresses/direction.
141+
- JSON `/metrics` counters for stored/sent blobs, bytes, duplicates, and replication errors.
141142

142143
Not implemented yet:
143144

144-
- Continuous anti-entropy, retries for partial sync failures, or conflict resolution.
145+
- Retries for partial sync failures or conflict resolution.
145146
- Automated peer discovery beyond static dial targets.
146147
- Authenticated application-level identity beyond optional TLS/mTLS configuration.
147148

docs/DEPLOYMENT.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ docker run --rm -p 7070:7070 -p 8080:8080 streamhive:local \
1212
```
1313

1414
- **7070** — P2P TCP listener (example).
15-
- **8080** — HTTP `/livez`, `/readyz`, `/metrics` (JSON counters), `/metrics/prometheus` (Prometheus text).
15+
- **8080** — HTTP `/livez`, `/readyz`, `/peers` (JSON peer snapshot), `/metrics` (JSON counters), `/metrics/prometheus` (Prometheus text).
1616

1717
Use TLS flags (`-tls-cert`, `-tls-key`, `-tls-ca`, …) when exposing services beyond a lab network.
1818

@@ -88,4 +88,5 @@ Define error budgets once you expose a workload to users. Baseline probes:
8888

8989
- **Availability**: `/livez` success rate.
9090
- **Readiness**: `/readyz` reflects listener bound (`TCPTransport.Ready`).
91+
- **Peer visibility**: `/peers` returns active connected peers and whether each connection is outbound.
9192
- **Saturation**: JSON `/metrics` fields `active_peers` and `peers_rejected`, or Prometheus samples from `/metrics/prometheus`.

main.go

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) error {
5050
peerReconnectMin := fs.Duration("peer-reconnect-min", 500*time.Millisecond, "minimum reconnect backoff for -peer-reconnect")
5151
peerReconnectMax := fs.Duration("peer-reconnect-max", 30*time.Second, "maximum reconnect backoff for -peer-reconnect")
5252
syncInterval := fs.Duration("sync-interval", 0, "periodically advertise local blob keys to connected peers (0 = startup only)")
53-
health := fs.String("health", "", "optional HTTP listen addr for /livez /readyz /metrics (e.g. :8080)")
53+
health := fs.String("health", "", "optional HTTP listen addr for /livez /readyz /peers /metrics (e.g. :8080)")
5454
maxPeers := fs.Int("max-peers", 0, "max simultaneous peers (0 = unlimited)")
5555
dialTimeout := fs.Duration("dial-timeout", 0, "default dial timeout (0 = use context only)")
5656
readIdle := fs.Duration("read-idle-timeout", 0, "TCP read deadline refresh for peer loops (0 = none for discard mode)")
@@ -695,6 +695,36 @@ func (m *replicationMetrics) Snapshot() map[string]int64 {
695695
}
696696
}
697697

698+
type peerStatus struct {
699+
RemoteAddr string `json:"remote_addr"`
700+
Outbound bool `json:"outbound"`
701+
}
702+
703+
type peersResponse struct {
704+
ActivePeers int `json:"active_peers"`
705+
Peers []peerStatus `json:"peers"`
706+
}
707+
708+
func snapshotPeers(peers []p2p.Peer) peersResponse {
709+
statuses := make([]peerStatus, 0, len(peers))
710+
for _, peer := range peers {
711+
statuses = append(statuses, peerStatus{
712+
RemoteAddr: peer.RemoteAddr().String(),
713+
Outbound: peer.IsOutbound(),
714+
})
715+
}
716+
sort.Slice(statuses, func(i, j int) bool {
717+
if statuses[i].RemoteAddr == statuses[j].RemoteAddr {
718+
return !statuses[i].Outbound && statuses[j].Outbound
719+
}
720+
return statuses[i].RemoteAddr < statuses[j].RemoteAddr
721+
})
722+
return peersResponse{
723+
ActivePeers: len(statuses),
724+
Peers: statuses,
725+
}
726+
}
727+
698728
func startHealth(addr string, tr *p2p.TCPTransport, replMetrics *replicationMetrics, log *slog.Logger) (*http.Server, error) {
699729
mux := http.NewServeMux()
700730
mux.HandleFunc("/livez", func(w http.ResponseWriter, _ *http.Request) {
@@ -719,6 +749,12 @@ func startHealth(addr string, tr *p2p.TCPTransport, replMetrics *replicationMetr
719749
enc.SetIndent("", " ")
720750
_ = enc.Encode(snapshot)
721751
})
752+
mux.HandleFunc("/peers", func(w http.ResponseWriter, _ *http.Request) {
753+
w.Header().Set("Content-Type", "application/json")
754+
enc := json.NewEncoder(w)
755+
enc.SetIndent("", " ")
756+
_ = enc.Encode(snapshotPeers(tr.Peers()))
757+
})
722758
mux.HandleFunc("/metrics/prometheus", func(w http.ResponseWriter, _ *http.Request) {
723759
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
724760
snapshot := tr.Metrics().Snapshot()

main_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,20 @@ func (testPeer) RemoteAddr() net.Addr { return &net.TCPAddr{IP: net.IPv4(127, 0,
3434
func (testPeer) Close() error { return nil }
3535
func (testPeer) IsOutbound() bool { return false }
3636

37+
type staticPeer struct {
38+
addr string
39+
outbound bool
40+
}
41+
42+
func (p staticPeer) RemoteAddr() net.Addr { return staticAddr(p.addr) }
43+
func (p staticPeer) Close() error { return nil }
44+
func (p staticPeer) IsOutbound() bool { return p.outbound }
45+
46+
type staticAddr string
47+
48+
func (a staticAddr) Network() string { return "tcp" }
49+
func (a staticAddr) String() string { return string(a) }
50+
3751
func (s *safeBuffer) Write(p []byte) (int, error) {
3852
s.mu.Lock()
3953
defer s.mu.Unlock()
@@ -122,6 +136,15 @@ func TestRun_healthEndpoints(t *testing.T) {
122136
assert.Contains(t, string(body), "streamhive_active_peers")
123137
assert.Contains(t, string(body), "streamhive_replication_blobs_stored")
124138

139+
resp5, err := client.Get(base + "/peers")
140+
require.NoError(t, err)
141+
defer func() { _ = resp5.Body.Close() }()
142+
assert.Equal(t, http.StatusOK, resp5.StatusCode)
143+
var peers peersResponse
144+
require.NoError(t, json.NewDecoder(resp5.Body).Decode(&peers))
145+
assert.Equal(t, 0, peers.ActivePeers)
146+
assert.Empty(t, peers.Peers)
147+
125148
cancel()
126149
<-errCh
127150
}
@@ -606,6 +629,19 @@ func TestWritePrometheusMetrics(t *testing.T) {
606629
assert.Equal(t, "streamhive_a_metric 1\nstreamhive_z_metric 2\n", out.String())
607630
}
608631

632+
func TestSnapshotPeersSortsByAddress(t *testing.T) {
633+
resp := snapshotPeers([]p2p.Peer{
634+
staticPeer{addr: "127.0.0.1:9002", outbound: true},
635+
staticPeer{addr: "127.0.0.1:9001", outbound: false},
636+
})
637+
638+
require.Equal(t, 2, resp.ActivePeers)
639+
require.Equal(t, []peerStatus{
640+
{RemoteAddr: "127.0.0.1:9001", Outbound: false},
641+
{RemoteAddr: "127.0.0.1:9002", Outbound: true},
642+
}, resp.Peers)
643+
}
644+
609645
func TestValidateReconnectBackoff(t *testing.T) {
610646
assert.NoError(t, validateReconnectBackoff(10*time.Millisecond, 20*time.Millisecond))
611647
assert.Error(t, validateReconnectBackoff(0, 20*time.Millisecond))

0 commit comments

Comments
 (0)