Skip to content

Commit b4c9ec7

Browse files
authored
Merge pull request #1 from OffPeakEngineer/feat/vibe/usage-feedback-implementation-spike
vibe: this is an implementation spike of the planned items for review
2 parents e8e0543 + bbcb45d commit b4c9ec7

21 files changed

Lines changed: 989 additions & 88 deletions

.commitlintrc.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,23 @@
11
module.exports = {
22
extends: ['@commitlint/config-conventional'],
3+
rules: {
4+
'body-max-line-length': [0],
5+
'type-enum': [2, 'always', [
6+
'build',
7+
'chore',
8+
'ci',
9+
'docs',
10+
'wiki',
11+
'feat',
12+
'fix',
13+
'perf',
14+
'refactor',
15+
'revert',
16+
'style',
17+
'test',
18+
'vibe'
19+
'plan'
20+
'vision',
21+
]],
22+
},
323
};

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,20 @@ export PSSTD_GOSSIP=":7947" # peer sync listen address, de
4848
export PSSTD_SEEDS="10.0.1.20:7946,10.0.1.21:7946" # explicit peer sync addresses
4949
export PSSTD_DB="./data" # local state directory
5050
export PSSTD_WEB="true" # set false for sync-only nodes
51+
export PSSTD_NODE_NAME="rack-a-01" # optional stable node identity override
52+
export PSSTD_NODE_TTL="15s" # how long this node's heartbeat stays online
5153
./psstd
5254
```
5355

56+
By default, psstd uses the OS hostname as the node identity. Set
57+
`PSSTD_NODE_NAME` for cloned hosts, containers, or multiple test instances that
58+
would otherwise publish the same hostname. The override must be non-empty and
59+
must not contain whitespace.
60+
61+
Each node publishes its own heartbeat TTL with `PSSTD_NODE_TTL`. Shorter values
62+
make stale/offline indication react faster; longer values are better for slow or
63+
lossy networks. The default is `15s`, and values must be at least `2s`.
64+
5465
## Discovery
5566

5667
| Environment | How nodes find each other |

discovery.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,6 @@ func discoverPeers() []string {
6363
continue
6464
}
6565
peer := fmt.Sprintf("%s:%d", addr.String(), entry.Port)
66-
log.Printf("mDNS: discovered peer %s (%s)", entry.Name, peer)
6766
peers = append(peers, peer)
6867
}
6968
return peers

gossip.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import (
55
"errors"
66
"log"
77
"sync"
8-
"time"
98

109
"github.com/cockroachdb/pebble/v2"
1110
"github.com/hashicorp/memberlist"
@@ -14,14 +13,15 @@ import (
1413
var appVersion = "dev"
1514

1615
type NodeStats struct {
17-
Name string `json:"name"`
18-
Version string `json:"version,omitempty"`
19-
WebURL string `json:"web,omitempty"`
20-
CPU []float64 `json:"cpu"`
21-
MemUsed uint64 `json:"mu"`
22-
MemTotal uint64 `json:"mt"`
23-
Load [3]float64 `json:"ld"`
24-
UpdatedAt int64 `json:"ts"` // unix nano, LWW key
16+
Name string `json:"name"`
17+
Version string `json:"version,omitempty"`
18+
WebURL string `json:"web,omitempty"`
19+
TTLSeconds int `json:"ttl,omitempty"`
20+
CPU []float64 `json:"cpu"`
21+
MemUsed uint64 `json:"mu"`
22+
MemTotal uint64 `json:"mt"`
23+
Load [3]float64 `json:"ld"`
24+
UpdatedAt int64 `json:"ts"` // unix nano, LWW key
2525
}
2626

2727
var errStaleVersion = errors.New("stale version")
@@ -107,7 +107,7 @@ func purgeOfflineDifferentVersion(db *pebble.DB, version string) error {
107107
}
108108

109109
func nodeRecordOffline(s NodeStats) bool {
110-
return s.UpdatedAt == 0 || time.Since(time.Unix(0, s.UpdatedAt)) > 15*time.Second
110+
return nodeHealth(s).State == healthOffline
111111
}
112112

113113
// ── Delegate ──────────────────────────────────────────────────────────────────

main.go

Lines changed: 123 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package main
22

33
import (
44
"encoding/json"
5+
"flag"
56
"fmt"
67
"log"
78
"net"
@@ -15,19 +16,34 @@ import (
1516
"github.com/hashicorp/memberlist"
1617
)
1718

19+
type cliOptions struct {
20+
List bool
21+
}
22+
1823
const (
19-
envDB = "PSSTD_DB"
20-
envHTTP = "PSSTD_HTTP"
21-
envGossip = "PSSTD_GOSSIP"
22-
envSeeds = "PSSTD_SEEDS"
23-
envHTTPAd = "PSSTD_ADVERTISE_HTTP"
24-
envWeb = "PSSTD_WEB" // "true" to enable HTTP, default true
25-
gossipPort = 7946
26-
httpPort = 8080
24+
envDB = "PSSTD_DB"
25+
envHTTP = "PSSTD_HTTP"
26+
envGossip = "PSSTD_GOSSIP"
27+
envSeeds = "PSSTD_SEEDS"
28+
envHTTPAd = "PSSTD_ADVERTISE_HTTP"
29+
envWeb = "PSSTD_WEB" // "true" to enable HTTP, default true
30+
envNodeName = "PSSTD_NODE_NAME"
31+
envNodeTTL = "PSSTD_NODE_TTL"
32+
gossipPort = 7946
33+
httpPort = 8080
2734
)
2835

2936
func main() {
37+
opts := parseCLI(os.Args[1:])
3038
hostname, _ := os.Hostname()
39+
nodeName, err := nodeNameFromEnv(hostname)
40+
if err != nil {
41+
log.Fatalf("node name: %v", err)
42+
}
43+
nodeTTL, err := nodeTTLFromEnv()
44+
if err != nil {
45+
log.Fatalf("node ttl: %v", err)
46+
}
3147

3248
dbPath := envOr(envDB, "./data")
3349
httpAddr := envOr(envHTTP, fmt.Sprintf(":%d", httpPort))
@@ -47,7 +63,7 @@ func main() {
4763
if err != nil {
4864
if pebbleLockHeld(err) {
4965
log.Printf("psstd already appears to own %s; starting terminal mirror instead", dbPath)
50-
runTerminalMirror(hostname, gossipAddr, seeds)
66+
runTerminalMirror(nodeName, gossipAddr, seeds, opts.List)
5167
return
5268
}
5369
log.Fatalf("pebble open: %v", err)
@@ -65,7 +81,7 @@ func main() {
6581
delegate := newKVDelegate(db, appVersion)
6682

6783
cfg := memberlist.DefaultLANConfig()
68-
cfg.Name = hostname
84+
cfg.Name = nodeName
6985
cfg.BindAddr, cfg.BindPort = splitHostPort(gossipAddr)
7086
cfg.Delegate = delegate
7187
cfg.Events = newEventDelegate(db, appVersion)
@@ -79,7 +95,7 @@ func main() {
7995
}
8096
db = nil
8197
log.Printf("psstd already appears to be listening on %s; starting terminal mirror instead", gossipAddr)
82-
runTerminalMirror(hostname, gossipAddr, seeds)
98+
runTerminalMirror(nodeName, gossipAddr, seeds, opts.List)
8399
return
84100
}
85101
log.Fatalf("memberlist create: %v", err)
@@ -88,39 +104,87 @@ func main() {
88104

89105
// ── Discovery ────────────────────────────────────────────────────────────
90106
// 1. Register ourselves via mDNS so peers can find us on LAN
91-
stopMDNS := registerMDNS(hostname, cfg.BindPort)
107+
stopMDNS := registerMDNS(nodeName, cfg.BindPort)
92108
defer stopMDNS()
93109

94110
// 2. Scan for existing peers (mDNS + any explicit seeds)
95111
discovered := discoverPeers()
96112
allSeeds := append(seeds, discovered...)
113+
joinedPeers := 0
114+
joinErr := error(nil)
97115
if len(allSeeds) > 0 {
98-
if n, err := list.Join(allSeeds); err != nil {
99-
log.Printf("join warning (joined %d): %v", n, err)
100-
} else {
101-
log.Printf("joined cluster, %d peer(s)", n)
102-
}
103-
} else {
104-
log.Println("no peers found — running solo, will be discovered by others")
116+
joinedPeers, joinErr = list.Join(allSeeds)
105117
}
118+
logStartupConfig(startupConfig{
119+
NodeName: nodeName,
120+
DBPath: dbPath,
121+
HTTPAddr: httpAddr,
122+
WebURL: webURL,
123+
GossipAddr: gossipAddr,
124+
WebEnabled: webEnabled,
125+
Version: appVersion,
126+
NodeTTL: nodeTTL,
127+
SeedCount: len(seeds),
128+
MDNSCount: len(discovered),
129+
JoinedPeers: joinedPeers,
130+
JoinErr: joinErr,
131+
})
106132

107133
// ── Stats heartbeat ─────────────────────────────────────────────────────
108-
go statsLoop(hostname, webURL, appVersion, db, delegate)
134+
go statsLoop(nodeName, webURL, appVersion, nodeTTL, db, delegate)
109135

110136
// ── HTTP ─────────────────────────────────────────────────────────────────
111137
if webEnabled {
112138
mux := http.NewServeMux()
113-
mux.HandleFunc("/", makeHandler(db, hostname))
114-
log.Printf("psstd version=%s node=%s http=%s advertise=%s gossip=%s web=true", appVersion, hostname, httpAddr, webURL, gossipAddr)
139+
mux.HandleFunc("/", makeHandler(db, nodeName))
115140
if err := http.ListenAndServe(httpAddr, mux); err != nil {
116141
log.Fatalf("http: %v", err)
117142
}
118143
} else {
119-
log.Printf("psstd version=%s node=%s gossip=%s web=false", appVersion, hostname, gossipAddr)
120144
select {} // block forever
121145
}
122146
}
123147

148+
func parseCLI(args []string) cliOptions {
149+
fs := flag.NewFlagSet("psstd", flag.ExitOnError)
150+
fs.SetOutput(os.Stderr)
151+
var opts cliOptions
152+
fs.BoolVar(&opts.List, "l", false, "render terminal mirror as a vertical node list")
153+
fs.BoolVar(&opts.List, "list", false, "render terminal mirror as a vertical node list")
154+
_ = fs.Parse(args)
155+
return opts
156+
}
157+
158+
type startupConfig struct {
159+
NodeName string
160+
DBPath string
161+
HTTPAddr string
162+
WebURL string
163+
GossipAddr string
164+
WebEnabled bool
165+
Version string
166+
NodeTTL time.Duration
167+
SeedCount int
168+
MDNSCount int
169+
JoinedPeers int
170+
JoinErr error
171+
}
172+
173+
func logStartupConfig(cfg startupConfig) {
174+
log.Print(startupSummary(cfg))
175+
}
176+
177+
func startupSummary(cfg startupConfig) string {
178+
join := "solo"
179+
if cfg.JoinErr != nil {
180+
join = fmt.Sprintf("warning joined=%d error=%q", cfg.JoinedPeers, cfg.JoinErr)
181+
} else if cfg.JoinedPeers > 0 {
182+
join = fmt.Sprintf("joined=%d", cfg.JoinedPeers)
183+
}
184+
return fmt.Sprintf("psstd startup: version=%s node=%s db=%s web=%t http=%s advertise=%s gossip=%s ttl=%s seeds=%d mdns=%d join=%s",
185+
cfg.Version, cfg.NodeName, cfg.DBPath, cfg.WebEnabled, cfg.HTTPAddr, cfg.WebURL, cfg.GossipAddr, cfg.NodeTTL, cfg.SeedCount, cfg.MDNSCount, join)
186+
}
187+
124188
func pebbleLockHeld(err error) bool {
125189
msg := strings.ToLower(err.Error())
126190
return strings.Contains(msg, "lock") &&
@@ -137,7 +201,7 @@ func addressInUse(err error) bool {
137201
strings.Contains(msg, "bind: only one usage of each socket address")
138202
}
139203

140-
func runTerminalMirror(hostname, gossipAddr string, seeds []string) {
204+
func runTerminalMirror(hostname, gossipAddr string, seeds []string, listMode bool) {
141205
tmpDir, err := os.MkdirTemp("", "psstd-view-*")
142206
if err != nil {
143207
log.Fatalf("terminal mirror temp db: %v", err)
@@ -172,7 +236,7 @@ func runTerminalMirror(hostname, gossipAddr string, seeds []string) {
172236
log.Printf("terminal mirror joined cluster, %d peer(s)", n)
173237
}
174238

175-
terminalRenderLoop(db)
239+
terminalRenderLoop(db, listMode)
176240
}
177241

178242
func terminalMirrorSeeds(gossipAddr string, seeds []string) []string {
@@ -188,11 +252,11 @@ func terminalMirrorSeeds(gossipAddr string, seeds []string) []string {
188252

189253
// ── Stats loop ───────────────────────────────────────────────────────────────
190254

191-
func statsLoop(hostname, webURL, version string, db *pebble.DB, d *kvDelegate) {
255+
func statsLoop(hostname, webURL, version string, ttl time.Duration, db *pebble.DB, d *kvDelegate) {
192256
ticker := time.NewTicker(2 * time.Second)
193257
defer ticker.Stop()
194258
for range ticker.C {
195-
stats, err := collectStats(hostname, webURL, version)
259+
stats, err := collectStats(hostname, webURL, version, ttl)
196260
if err != nil {
197261
log.Printf("stats error: %v", err)
198262
continue
@@ -255,6 +319,38 @@ func envOr(key, def string) string {
255319
return def
256320
}
257321

322+
func nodeNameFromEnv(hostname string) (string, error) {
323+
override, ok := os.LookupEnv(envNodeName)
324+
if !ok || override == "" {
325+
if strings.TrimSpace(hostname) == "" {
326+
return "", fmt.Errorf("hostname is empty; set %s", envNodeName)
327+
}
328+
return hostname, nil
329+
}
330+
if strings.TrimSpace(override) != override || override == "" {
331+
return "", fmt.Errorf("%s must not be empty or have leading/trailing whitespace", envNodeName)
332+
}
333+
if strings.ContainsAny(override, " \t\r\n") {
334+
return "", fmt.Errorf("%s must not contain whitespace", envNodeName)
335+
}
336+
return override, nil
337+
}
338+
339+
func nodeTTLFromEnv() (time.Duration, error) {
340+
value, ok := os.LookupEnv(envNodeTTL)
341+
if !ok || value == "" {
342+
return defaultNodeTTL, nil
343+
}
344+
ttl, err := time.ParseDuration(value)
345+
if err != nil {
346+
return 0, fmt.Errorf("%s must be a duration such as 15s or 1m: %w", envNodeTTL, err)
347+
}
348+
if ttl < 2*time.Second {
349+
return 0, fmt.Errorf("%s must be at least 2s", envNodeTTL)
350+
}
351+
return ttl, nil
352+
}
353+
258354
func splitCSV(s string) []string {
259355
var out []string
260356
for _, p := range strings.Split(s, ",") {

0 commit comments

Comments
 (0)