Skip to content

Commit 92ec82a

Browse files
corylanouclaude
andcommitted
refactor(ipc): remove /status endpoint, add lastSyncAt to /list
Per review feedback on PR #1015, this removes the /status endpoint and socket mode from the status command to avoid the confusing behavior where only one command requires the -socket flag. Instead of /status, the /list endpoint now includes lastSyncAt for each database, providing the sync timing information without needing a separate endpoint. Changes: - Remove GET /status endpoint from server - Remove -socket flag and runWithSocket from status command - Add LastSyncAt field to DatabaseSummary in /list response - Update tests accordingly Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 9d48175 commit 92ec82a

File tree

4 files changed

+20
-387
lines changed

4 files changed

+20
-387
lines changed

cmd/litestream/status.go

Lines changed: 3 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,10 @@ package main
22

33
import (
44
"context"
5-
"encoding/json"
65
"flag"
76
"fmt"
8-
"io"
9-
"net"
10-
"net/http"
11-
"net/url"
127
"os"
138
"text/tabwriter"
14-
"time"
159

1610
"github.com/dustin/go-humanize"
1711

@@ -25,77 +19,14 @@ type StatusCommand struct{}
2519
func (c *StatusCommand) Run(ctx context.Context, args []string) (err error) {
2620
fs := flag.NewFlagSet("litestream-status", flag.ContinueOnError)
2721
configPath, noExpandEnv := registerConfigFlag(fs)
28-
socketPath := fs.String("socket", "", "control socket path for querying running daemon")
29-
timeout := fs.Int("timeout", 10, "timeout in seconds for socket connection")
3022
fs.Usage = c.Usage
3123
if err := fs.Parse(args); err != nil {
3224
return err
3325
}
3426

35-
// If socket path is provided, query the running daemon.
36-
if *socketPath != "" {
37-
return c.runWithSocket(ctx, *socketPath, *timeout, fs.Args())
38-
}
39-
40-
// Otherwise, use config file to get static status.
4127
return c.runWithConfig(ctx, *configPath, *noExpandEnv, fs.Args())
4228
}
4329

44-
// runWithSocket queries the running daemon via Unix socket.
45-
func (c *StatusCommand) runWithSocket(_ context.Context, socketPath string, timeout int, args []string) error {
46-
if len(args) == 0 {
47-
return fmt.Errorf("database path required when using -socket")
48-
}
49-
if len(args) > 1 {
50-
return fmt.Errorf("too many arguments")
51-
}
52-
53-
dbPath := args[0]
54-
55-
clientTimeout := time.Duration(timeout) * time.Second
56-
client := &http.Client{
57-
Timeout: clientTimeout,
58-
Transport: &http.Transport{
59-
DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
60-
return net.DialTimeout("unix", socketPath, clientTimeout)
61-
},
62-
},
63-
}
64-
65-
reqURL := fmt.Sprintf("http://localhost/status?path=%s", url.QueryEscape(dbPath))
66-
resp, err := client.Get(reqURL)
67-
if err != nil {
68-
return fmt.Errorf("failed to connect to control socket: %w", err)
69-
}
70-
defer resp.Body.Close()
71-
72-
body, err := io.ReadAll(resp.Body)
73-
if err != nil {
74-
return fmt.Errorf("failed to read response: %w", err)
75-
}
76-
77-
if resp.StatusCode != http.StatusOK {
78-
var errResp litestream.ErrorResponse
79-
if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error != "" {
80-
return fmt.Errorf("status failed: %s", errResp.Error)
81-
}
82-
return fmt.Errorf("status failed: %s", string(body))
83-
}
84-
85-
var result litestream.StatusResponse
86-
if err := json.Unmarshal(body, &result); err != nil {
87-
return fmt.Errorf("failed to parse response: %w", err)
88-
}
89-
90-
output, err := json.MarshalIndent(result, "", " ")
91-
if err != nil {
92-
return fmt.Errorf("failed to format response: %w", err)
93-
}
94-
fmt.Println(string(output))
95-
96-
return nil
97-
}
98-
9930
// runWithConfig reads the config file and displays static status.
10031
func (c *StatusCommand) runWithConfig(_ context.Context, configPath string, noExpandEnv bool, args []string) error {
10132
// Load configuration.
@@ -195,33 +126,18 @@ Usage:
195126
196127
litestream status [arguments] [database path]
197128
198-
litestream status -socket PATH DB_PATH
199-
200129
Arguments:
201130
202131
-config PATH
203132
Specifies the configuration file.
204133
Defaults to %s
205134
206-
-socket PATH
207-
Path to control socket for querying a running daemon.
208-
When specified, queries the daemon for live status.
209-
210-
-timeout SECONDS
211-
Timeout for socket connection (default: 10).
212-
213135
-no-expand-env
214136
Disables environment variable expansion in configuration file.
215137
216-
Without -socket flag:
217-
Reads the config file and displays static status from local files.
218-
If a database path is provided, only that database's status is shown.
219-
Otherwise, all configured databases are displayed.
220-
221-
With -socket flag:
222-
Queries the running daemon for live replication status.
223-
A database path is required when using -socket.
224-
Returns JSON output with current status, position, and replica info.
138+
Reads the config file and displays static status from local files.
139+
If a database path is provided, only that database's status is shown.
140+
Otherwise, all configured databases are displayed.
225141
226142
`[1:],
227143
DefaultConfigPath(),

cmd/litestream/status_test.go

Lines changed: 0 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,7 @@ import (
66
"path/filepath"
77
"testing"
88

9-
"github.com/benbjohnson/litestream"
109
main "github.com/benbjohnson/litestream/cmd/litestream"
11-
"github.com/benbjohnson/litestream/internal/testingutil"
1210
)
1311

1412
func TestStatusCommand_Run(t *testing.T) {
@@ -20,36 +18,6 @@ func TestStatusCommand_Run(t *testing.T) {
2018
}
2119
})
2220

23-
t.Run("SocketModeRequiresPath", func(t *testing.T) {
24-
cmd := &main.StatusCommand{}
25-
err := cmd.Run(context.Background(), []string{"-socket", "/tmp/test.sock"})
26-
if err == nil {
27-
t.Error("expected error for missing database path")
28-
}
29-
if err.Error() != "database path required when using -socket" {
30-
t.Errorf("unexpected error: %v", err)
31-
}
32-
})
33-
34-
t.Run("SocketModeTooManyArgs", func(t *testing.T) {
35-
cmd := &main.StatusCommand{}
36-
err := cmd.Run(context.Background(), []string{"-socket", "/tmp/test.sock", "/db1", "/db2"})
37-
if err == nil {
38-
t.Error("expected error for too many arguments")
39-
}
40-
if err.Error() != "too many arguments" {
41-
t.Errorf("unexpected error: %v", err)
42-
}
43-
})
44-
45-
t.Run("SocketModeConnectionError", func(t *testing.T) {
46-
cmd := &main.StatusCommand{}
47-
err := cmd.Run(context.Background(), []string{"-socket", "/nonexistent/socket.sock", "/path/to/db"})
48-
if err == nil {
49-
t.Error("expected error for socket connection failure")
50-
}
51-
})
52-
5321
t.Run("WithConfig", func(t *testing.T) {
5422
dir := t.TempDir()
5523
dbPath := filepath.Join(dir, "test.db")
@@ -103,29 +71,4 @@ func TestStatusCommand_Run(t *testing.T) {
10371
t.Errorf("unexpected error: %v", err)
10472
}
10573
})
106-
107-
t.Run("SocketModeSuccess", func(t *testing.T) {
108-
db, sqldb := testingutil.MustOpenDBs(t)
109-
defer testingutil.MustCloseDBs(t, db, sqldb)
110-
111-
store := litestream.NewStore([]*litestream.DB{db}, litestream.CompactionLevels{{Level: 0}})
112-
store.CompactionMonitorEnabled = false
113-
if err := store.Open(context.Background()); err != nil {
114-
t.Fatal(err)
115-
}
116-
defer store.Close(context.Background())
117-
118-
server := litestream.NewServer(store)
119-
server.SocketPath = testSocketPath(t)
120-
if err := server.Start(); err != nil {
121-
t.Fatal(err)
122-
}
123-
defer server.Close()
124-
125-
cmd := &main.StatusCommand{}
126-
err := cmd.Run(context.Background(), []string{"-socket", server.SocketPath, db.Path()})
127-
if err != nil {
128-
t.Errorf("unexpected error: %v", err)
129-
}
130-
})
13174
}

server.go

Lines changed: 12 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,6 @@ func NewServer(store *Store) *Server {
7272
mux := http.NewServeMux()
7373
mux.HandleFunc("POST /start", s.handleStart)
7474
mux.HandleFunc("POST /stop", s.handleStop)
75-
mux.HandleFunc("GET /status", s.handleStatus)
7675
mux.HandleFunc("GET /list", s.handleList)
7776
mux.HandleFunc("GET /info", s.handleInfo)
7877

@@ -267,70 +266,6 @@ type ErrorResponse struct {
267266
Details interface{} `json:"details,omitempty"`
268267
}
269268

270-
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
271-
path := r.URL.Query().Get("path")
272-
if path == "" {
273-
writeJSONError(w, http.StatusBadRequest, "path required", nil)
274-
return
275-
}
276-
277-
expandedPath, err := s.expandPath(path)
278-
if err != nil {
279-
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("invalid path: %v", err), nil)
280-
return
281-
}
282-
283-
db := s.store.FindDB(expandedPath)
284-
if db == nil {
285-
writeJSONError(w, http.StatusNotFound, fmt.Sprintf("database not found: %s", expandedPath), nil)
286-
return
287-
}
288-
289-
resp := StatusResponse{
290-
Path: db.Path(),
291-
PageSize: db.PageSize(),
292-
}
293-
294-
// Determine replication status more accurately.
295-
// "replicating" means DB is open AND has an active replica monitor.
296-
// "open" means DB is open but replication is paused/disabled.
297-
// "stopped" means DB is closed.
298-
if db.IsOpen() {
299-
if db.Replica != nil && db.Replica.MonitorEnabled {
300-
resp.Status = "replicating"
301-
} else {
302-
resp.Status = "open"
303-
}
304-
} else {
305-
resp.Status = "stopped"
306-
}
307-
308-
// Get position from db.Pos() which reads the full position from disk,
309-
// including both TXID and checksum. We don't use Replica.Pos() here
310-
// because it only caches TXID, not the checksum.
311-
if pos, err := db.Pos(); err != nil {
312-
resp.Error = fmt.Sprintf("failed to read position: %v", err)
313-
} else if pos.TXID > 0 {
314-
resp.Position = &PositionInfo{
315-
TXID: pos.TXID.String(),
316-
PostApplyChecksum: fmt.Sprintf("%016x", pos.PostApplyChecksum),
317-
}
318-
}
319-
320-
if t := db.LastSuccessfulSyncAt(); !t.IsZero() {
321-
resp.LastSyncAt = &t
322-
}
323-
324-
if db.Replica != nil && db.Replica.Client != nil {
325-
resp.Replicas = append(resp.Replicas, ReplicaInfo{
326-
Name: db.Replica.Client.Type(),
327-
Type: db.Replica.Client.Type(),
328-
})
329-
}
330-
331-
writeJSON(w, http.StatusOK, resp)
332-
}
333-
334269
func (s *Server) handleList(w http.ResponseWriter, _ *http.Request) {
335270
dbs := s.store.DBs()
336271
resp := ListResponse{
@@ -348,10 +283,17 @@ func (s *Server) handleList(w http.ResponseWriter, _ *http.Request) {
348283
} else {
349284
status = "stopped"
350285
}
351-
resp.Databases = append(resp.Databases, DatabaseSummary{
286+
287+
summary := DatabaseSummary{
352288
Path: db.Path(),
353289
Status: status,
354-
})
290+
}
291+
292+
if t := db.LastSuccessfulSyncAt(); !t.IsZero() {
293+
summary.LastSyncAt = &t
294+
}
295+
296+
resp.Databases = append(resp.Databases, summary)
355297
}
356298

357299
writeJSON(w, http.StatusOK, resp)
@@ -369,39 +311,16 @@ func (s *Server) handleInfo(w http.ResponseWriter, _ *http.Request) {
369311
writeJSON(w, http.StatusOK, resp)
370312
}
371313

372-
// StatusResponse is the response body for the /status endpoint.
373-
type StatusResponse struct {
374-
Path string `json:"path"`
375-
Status string `json:"status"`
376-
Position *PositionInfo `json:"position,omitempty"`
377-
PageSize int `json:"page_size,omitempty"`
378-
LastSyncAt *time.Time `json:"last_sync_at,omitempty"`
379-
Replicas []ReplicaInfo `json:"replicas,omitempty"`
380-
Error string `json:"error,omitempty"`
381-
}
382-
383-
// PositionInfo contains replication position information.
384-
type PositionInfo struct {
385-
TXID string `json:"txid"`
386-
PostApplyChecksum string `json:"post_apply_checksum"`
387-
}
388-
389-
// ReplicaInfo contains information about a replica.
390-
type ReplicaInfo struct {
391-
Name string `json:"name"`
392-
Type string `json:"type"`
393-
Generation string `json:"generation,omitempty"`
394-
}
395-
396314
// ListResponse is the response body for the /list endpoint.
397315
type ListResponse struct {
398316
Databases []DatabaseSummary `json:"databases"`
399317
}
400318

401319
// DatabaseSummary contains summary information about a database.
402320
type DatabaseSummary struct {
403-
Path string `json:"path"`
404-
Status string `json:"status"`
321+
Path string `json:"path"`
322+
Status string `json:"status"`
323+
LastSyncAt *time.Time `json:"last_sync_at,omitempty"`
405324
}
406325

407326
// InfoResponse is the response body for the /info endpoint.

0 commit comments

Comments
 (0)