Skip to content

Commit 4fa3900

Browse files
committed
feat: add content-addressed put flag
1 parent 68a8683 commit 4fa3900

7 files changed

Lines changed: 124 additions & 25 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ All notable changes to StreamHive are documented here. This project follows [Sem
1010
- **`storage`**: `BlobKeyLister` interface plus deterministic `ListKeys` support for memory and file stores.
1111
- **`replication`**: `blob.has`, `blob.get`, and `blob.missing` message types for anti-entropy sync.
1212
- **CLI**: startup anti-entropy sync for `-replicate` peers using `blob.has` / `blob.missing` / `blob.put`.
13+
- **CLI**: `-put-content-key` for sending blobs under `SHA-256(-put-data)` content keys.
1314
- **Demo**: 3-node Docker Compose demo with durable stores and node restart rehydration.
1415
- **Metrics**: Prometheus text endpoint at `/metrics/prometheus`.
1516

README.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ go run . -listen 127.0.0.1:7070 -replicate -health 127.0.0.1:8080
3535
Terminal 2: dial the receiver and send one blob.
3636

3737
```bash
38-
go run . -listen 127.0.0.1:0 -dial 127.0.0.1:7070 -put-key demo -put-data "hello streamhive" -exit-after-put
38+
go run . -listen 127.0.0.1:0 -dial 127.0.0.1:7070 -put-content-key -put-data "hello streamhive" -exit-after-put
3939
```
4040

4141
Inspect counters:
@@ -44,7 +44,7 @@ Inspect counters:
4444
curl -s http://127.0.0.1:8080/metrics
4545
```
4646

47-
Look for `replication_blobs_stored`, `replication_bytes_stored`, and transport frame counters. Use `/metrics` for JSON or `/metrics/prometheus` for Prometheus text format.
47+
Look for `replication_blobs_stored`, `replication_bytes_stored`, and transport frame counters. The sender derives the blob key from `SHA-256(put-data)` when `-put-content-key` is set. Use `/metrics` for JSON or `/metrics/prometheus` for Prometheus text format.
4848

4949
Or run the whole flow:
5050

@@ -79,7 +79,7 @@ go run . -listen 127.0.0.1:7070 -replicate -store-dir ./streamhive-data
7979
| Import | Purpose |
8080
|--------|---------|
8181
| `github.com/AliSinaDevelo/StreamHive/p2p` | `TCPTransport`, framing (`ReadFrame` / `WriteFrame`), metrics |
82-
| `github.com/AliSinaDevelo/StreamHive/replication` | Blob replication messages (`blob.put`) and store apply helper |
82+
| `github.com/AliSinaDevelo/StreamHive/replication` | Blob replication messages (`blob.put`, `blob.has`, `blob.get`, `blob.missing`) and store apply helper |
8383
| `github.com/AliSinaDevelo/StreamHive/storage` | `BlobStore`, `BlobKeyLister`, `MemoryStore`, `FileStore`, SHA-256 content key helpers |
8484

8585
Wire handshake string constant: `p2p.HandshakeVersionV1` (carry inside application frames).
@@ -101,7 +101,8 @@ Wire handshake string constant: `p2p.HandshakeVersionV1` (carry inside applicati
101101
| `-tls-ca` / `-tls-server-name` / `-tls-insecure-skip-verify` | Client TLS |
102102
| `-replicate` | Enable blob replication from framed peers |
103103
| `-store-dir` | Persist replicated blobs with `storage.FileStore` |
104-
| `-put-key` / `-put-data` | Send one blob to the `-dial` peer |
104+
| `-put-key` / `-put-data` | Send one manually keyed blob to outbound peers |
105+
| `-put-content-key` | Derive the outbound blob key from `SHA-256(-put-data)` |
105106
| `-exit-after-put` | Exit after the outbound blob frame is written |
106107
| `-max-blob-bytes` | Cap replicated blob payload size |
107108

@@ -116,7 +117,7 @@ flowchart TB
116117
T[TCPTransport]
117118
F[SHV1 frames]
118119
S[BlobStore]
119-
R[replication blob.put]
120+
R[replication blob.put / blob.has]
120121
CLI --> T
121122
T --> F
122123
F --> R

docker-compose.yml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,7 @@ services:
7373
- 127.0.0.1:0
7474
- -dial
7575
- node1:7070
76-
- -put-key
77-
- compose-demo
76+
- -put-content-key
7877
- -put-data
7978
- hello from compose
8079
- -exit-after-put

docs/ARCHITECTURE.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ classDiagram
8181
- Listener and peer map share a mutex; the accept loop exits when the listener is closed.
8282
- `Close` stops new accepts, waits for the accept goroutine, then closes open peer connections. Peer goroutines remove themselves from the map on EOF / error via `unregisterPeer`.
8383
- Optional `FrameHandler` runs per frame on each peer session until error, context cancellation, or disconnect.
84-
- CLI replication installs a `FrameHandler` that decodes `blob.put` messages and writes to `MemoryStore` by default, or `FileStore` when `-store-dir` is set. Outbound `-put-key` / `-put-data` sends one frame after `-dial` or `-peers` connects.
84+
- CLI replication installs a `FrameHandler` that decodes `blob.put` messages and writes to `MemoryStore` by default, or `FileStore` when `-store-dir` is set. Outbound `-put-key` / `-put-data` sends one manually keyed frame after `-dial` or `-peers` connects; `-put-content-key` derives the key from `SHA-256(-put-data)`.
8585
- `-peer-reconnect` manages only static `-peers` targets. It retries failed dials with exponential backoff and schedules another retry when an outbound configured peer disconnects.
8686
- Replication peers advertise local keys on connect. Receivers reply with `blob.missing`, and owners send the requested blobs with `blob.put`.
8787

@@ -122,7 +122,7 @@ sequenceDiagram
122122
participant Replication
123123
participant Store as BlobStore
124124
125-
Sender->>SenderTransport: -dial receiver + -put-key/-put-data
125+
Sender->>SenderTransport: -dial receiver + -put-key/-put-data or -put-content-key
126126
SenderTransport->>ReceiverTransport: SHV1 frame carrying blob.put JSON
127127
ReceiverTransport->>Replication: FrameHandler(payload)
128128
Replication->>Replication: decode + validate limits
@@ -133,8 +133,7 @@ Implemented:
133133

134134
- Static peer replication over `-dial` and comma-separated `-peers`.
135135
- Optional reconnect/backoff for `-peers`.
136-
- One message type: `blob.put`.
137-
- Sync message vocabulary: `blob.has`, `blob.get`, and `blob.missing`.
136+
- Message types: `blob.put`, `blob.has`, `blob.get`, and `blob.missing`.
138137
- Startup anti-entropy for connected `-replicate` peers.
139138
- Receiver-side storage via `storage.MemoryStore` or durable `storage.FileStore` with `-store-dir`.
140139
- JSON `/metrics` counters for stored/sent blobs, bytes, and replication errors.

main.go

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"crypto/tls"
66
"crypto/x509"
7+
"encoding/hex"
78
"encoding/json"
89
"errors"
910
"flag"
@@ -56,7 +57,8 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) error {
5657
storeDir := fs.String("store-dir", "", "directory for durable replicated blobs (requires -replicate)")
5758
putKey := fs.String("put-key", "", "send one replicated blob key to -dial peer")
5859
putData := fs.String("put-data", "", "send one replicated blob value to -dial peer")
59-
exitAfterPut := fs.Bool("exit-after-put", false, "exit after sending -put-key to the dialed peer")
60+
putContentKey := fs.Bool("put-content-key", false, "derive the replicated blob key from SHA-256(-put-data)")
61+
exitAfterPut := fs.Bool("exit-after-put", false, "exit after sending one blob to outbound peers")
6062
maxBlobBytes := fs.Int("max-blob-bytes", replication.DefaultMaxDataBytes, "max replicated blob payload bytes")
6163

6264
tlsCert := fs.String("tls-cert", "", "path to PEM certificate (enables TLS on listener)")
@@ -81,15 +83,19 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) error {
8183
return err
8284
}
8385
peerTargets := combinePeerTargets(dialTarget, peerList)
84-
if *putKey != "" && len(peerTargets) == 0 {
85-
return fmt.Errorf("replication: -put-key requires -dial or -peers")
86+
putRequested := *putKey != "" || *putContentKey
87+
if putRequested && len(peerTargets) == 0 {
88+
return fmt.Errorf("replication: -put-key or -put-content-key requires -dial or -peers")
89+
}
90+
if *putContentKey && *putKey != "" {
91+
return fmt.Errorf("replication: -put-content-key cannot be combined with -put-key")
8692
}
8793
if *peerReconnect {
8894
if len(peerList) == 0 {
8995
return fmt.Errorf("peers: -peer-reconnect requires -peers")
9096
}
91-
if *putKey != "" {
92-
return fmt.Errorf("replication: -peer-reconnect cannot be combined with -put-key")
97+
if putRequested {
98+
return fmt.Errorf("replication: -peer-reconnect cannot be combined with -put-key or -put-content-key")
9399
}
94100
if err := validateReconnectBackoff(*peerReconnectMin, *peerReconnectMax); err != nil {
95101
return err
@@ -120,9 +126,12 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) error {
120126
}
121127
}
122128
var putPayload []byte
129+
var putKeyBytes []byte
130+
var putKeyLabel string
123131
var putResult chan error
124-
if *putKey != "" {
125-
putPayload, err = replication.EncodeBlobPut([]byte(*putKey), []byte(*putData), replLimits)
132+
if putRequested {
133+
putKeyBytes, putKeyLabel = resolvePutKey(*putKey, []byte(*putData), *putContentKey)
134+
putPayload, err = replication.EncodeBlobPut(putKeyBytes, []byte(*putData), replLimits)
126135
if err != nil {
127136
return err
128137
}
@@ -147,7 +156,7 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) error {
147156
replMetrics.BlobsSent.Add(1)
148157
replMetrics.BytesSent.Add(uint64(len(*putData)))
149158
reportPutResult(putResult, nil)
150-
log.Info("replicated blob sent", "remote", peer.RemoteAddr().String(), "key", *putKey, "bytes", len(*putData))
159+
log.Info("replicated blob sent", "remote", peer.RemoteAddr().String(), "key", putKeyLabel, "bytes", len(*putData))
151160
}
152161
if keyLister != nil {
153162
if err := sendBlobHas(ctx, peer, keyLister, replLimits, tr.MaxFrameBytes); err != nil {
@@ -311,7 +320,7 @@ func handleReplicationMessage(
311320
}
312321
metrics.BlobsStored.Add(1)
313322
metrics.BytesStored.Add(uint64(len(msg.Data)))
314-
attrs := []any{"remote", peer.RemoteAddr().String(), "key", string(msg.Key), "bytes", len(msg.Data)}
323+
attrs := []any{"remote", peer.RemoteAddr().String(), "key", formatBlobKey(msg.Key), "bytes", len(msg.Data)}
315324
if memoryStore != nil {
316325
attrs = append(attrs, "blobs", memoryStore.Len())
317326
}
@@ -391,11 +400,26 @@ func sendRequestedBlobs(
391400
}
392401
metrics.BlobsSent.Add(1)
393402
metrics.BytesSent.Add(uint64(len(data)))
394-
log.Info("replicated blob sent", "remote", peer.RemoteAddr().String(), "key", string(key), "bytes", len(data))
403+
log.Info("replicated blob sent", "remote", peer.RemoteAddr().String(), "key", formatBlobKey(key), "bytes", len(data))
395404
}
396405
return nil
397406
}
398407

408+
func resolvePutKey(key string, data []byte, contentKey bool) ([]byte, string) {
409+
if contentKey {
410+
digest := storage.SHA256Key(data)
411+
return digest, storage.SHA256KeyHex(data)
412+
}
413+
return []byte(key), key
414+
}
415+
416+
func formatBlobKey(key []byte) string {
417+
if err := storage.ValidateSHA256Key(key); err == nil {
418+
return hex.EncodeToString(key)
419+
}
420+
return string(key)
421+
}
422+
399423
func writePeerFrame(peer p2p.Peer, payload []byte, maxFrameBytes int) error {
400424
tcpPeer, ok := peer.(*p2p.TCPPeer)
401425
if !ok {

main_test.go

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,19 @@ func TestRun_putRequiresDial(t *testing.T) {
123123
var out bytes.Buffer
124124
err := run(context.Background(), []string{"-put-key", "alpha", "-put-data", "hello"}, &out, io.Discard)
125125
require.Error(t, err)
126-
assert.Contains(t, err.Error(), "-put-key requires -dial or -peers")
126+
assert.Contains(t, err.Error(), "-put-key or -put-content-key requires -dial or -peers")
127+
}
128+
129+
func TestRun_putContentKeyRejectsExplicitKey(t *testing.T) {
130+
var out bytes.Buffer
131+
err := run(context.Background(), []string{
132+
"-dial", "127.0.0.1:1",
133+
"-put-key", "alpha",
134+
"-put-content-key",
135+
"-put-data", "hello",
136+
}, &out, io.Discard)
137+
require.Error(t, err)
138+
assert.Contains(t, err.Error(), "-put-content-key cannot be combined with -put-key")
127139
}
128140

129141
func TestRun_storeDirRequiresReplicate(t *testing.T) {
@@ -240,6 +252,54 @@ func TestRun_replicatesBlobPutToFileStore(t *testing.T) {
240252
require.NoError(t, <-serverErrCh)
241253
}
242254

255+
func TestRun_replicatesContentKeyedBlobPutToFileStore(t *testing.T) {
256+
storeDir := t.TempDir()
257+
serverCtx, serverCancel := context.WithCancel(context.Background())
258+
defer serverCancel()
259+
var serverOut, serverErr safeBuffer
260+
serverErrCh := make(chan error, 1)
261+
go func() {
262+
serverErrCh <- run(serverCtx, []string{
263+
"-listen", "127.0.0.1:0",
264+
"-replicate",
265+
"-store-dir", storeDir,
266+
}, &serverOut, &serverErr)
267+
}()
268+
269+
require.Eventually(t, func() bool {
270+
return strings.Contains(serverOut.String(), "listening on")
271+
}, 3*time.Second, 20*time.Millisecond)
272+
273+
re := regexp.MustCompile(`listening on ([^\n]+)`)
274+
m := re.FindStringSubmatch(serverOut.String())
275+
require.Len(t, m, 2, "stdout=%q", serverOut.String())
276+
277+
data := []byte("address me by content")
278+
var clientOut, clientErr safeBuffer
279+
err := run(context.Background(), []string{
280+
"-listen", "127.0.0.1:0",
281+
"-dial", m[1],
282+
"-put-content-key",
283+
"-put-data", string(data),
284+
"-exit-after-put",
285+
}, &clientOut, &clientErr)
286+
require.NoError(t, err, "client logs=%q", clientErr.String())
287+
288+
key := storage.SHA256Key(data)
289+
require.Eventually(t, func() bool {
290+
store, err := storage.NewFileStore(storeDir)
291+
if err != nil {
292+
return false
293+
}
294+
got, err := store.Get(context.Background(), key)
295+
return err == nil && string(got) == string(data)
296+
}, 3*time.Second, 20*time.Millisecond, "server logs=%q", serverErr.String())
297+
assert.Contains(t, serverErr.String(), storage.SHA256KeyHex(data))
298+
299+
serverCancel()
300+
require.NoError(t, <-serverErrCh)
301+
}
302+
243303
func TestRun_syncsMissingBlobOnConnect(t *testing.T) {
244304
sourceDir := t.TempDir()
245305
targetDir := t.TempDir()
@@ -358,6 +418,22 @@ func TestMissingKeys(t *testing.T) {
358418
require.Equal(t, [][]byte{[]byte("a")}, again)
359419
}
360420

421+
func TestResolvePutKey(t *testing.T) {
422+
key, label := resolvePutKey("manual", []byte("hello"), false)
423+
assert.Equal(t, []byte("manual"), key)
424+
assert.Equal(t, "manual", label)
425+
426+
key, label = resolvePutKey("", []byte("hello"), true)
427+
assert.Equal(t, storage.SHA256Key([]byte("hello")), key)
428+
assert.Equal(t, storage.SHA256KeyHex([]byte("hello")), label)
429+
}
430+
431+
func TestFormatBlobKey(t *testing.T) {
432+
data := []byte("hello")
433+
assert.Equal(t, "manual", formatBlobKey([]byte("manual")))
434+
assert.Equal(t, storage.SHA256KeyHex(data), formatBlobKey(storage.SHA256Key(data)))
435+
}
436+
361437
func TestWritePrometheusMetrics(t *testing.T) {
362438
var out bytes.Buffer
363439
writePrometheusMetrics(&out, map[string]int64{

scripts/demo-replication.sh

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
55
BIN="$ROOT_DIR/bin/fs"
66
P2P_ADDR="${P2P_ADDR:-127.0.0.1:7070}"
77
HEALTH_ADDR="${HEALTH_ADDR:-127.0.0.1:8080}"
8-
BLOB_KEY="${BLOB_KEY:-demo}"
98
BLOB_DATA="${BLOB_DATA:-hello streamhive}"
109
LOG_FILE="$(mktemp -t streamhive-demo.XXXXXX)"
1110

@@ -39,7 +38,7 @@ done
3938
"$BIN" \
4039
-listen 127.0.0.1:0 \
4140
-dial "$P2P_ADDR" \
42-
-put-key "$BLOB_KEY" \
41+
-put-content-key \
4342
-put-data "$BLOB_DATA" \
4443
-exit-after-put
4544

@@ -54,6 +53,6 @@ until curl -fsS "http://$HEALTH_ADDR/metrics" | grep '"replication_blobs_stored"
5453
sleep 0.1
5554
done
5655

57-
echo "replicated blob '$BLOB_KEY' (${#BLOB_DATA} bytes)"
56+
echo "replicated content-addressed blob (${#BLOB_DATA} bytes)"
5857
curl -fsS "http://$HEALTH_ADDR/metrics"
5958
echo

0 commit comments

Comments
 (0)