diff --git a/README.md b/README.md
index c24f0d5..c0a5f84 100644
--- a/README.md
+++ b/README.md
@@ -152,6 +152,9 @@ The codec receives `key` as context, but can only return transformed `value` and
### Support Commands
+The public API exposes typed text- and meta-protocol commands only. There is no
+arbitrary raw command passthrough; callers should use the methods below.
+
Now, we have implemented some commands, and we will implement more commands in the future.
| Command | Status | API Usage | Description |
@@ -186,7 +189,7 @@ Now, we have implemented some commands, and we will implement more commands in t
#### Prerequisites
-- Go 1.26 or higher
+- Go 1.26.5 or higher
- Python (for pre-commit hooks) or just `brew install pre-commit` on MacOS
- Docker (for running memcached in tests)
diff --git a/benchmark/README.md b/benchmark/README.md
index ced8e80..56c6842 100644
--- a/benchmark/README.md
+++ b/benchmark/README.md
@@ -1,10 +1,9 @@
## Benchmark differences
-Comparing following three implementations:
+Comparing following implementations:
-- [yeqown/memcached](https://github.com/yeqown/memcahced)
+- [yeqown/memcached](https://github.com/yeqown/memcached)
- [bradfitz/gomemcache](https://github.com/bradfitz/gomemcache)
-- [rainy/memcache](github.com/rainycape/memcache)
## How to run
@@ -12,8 +11,8 @@ Comparing following three implementations:
> And we can use `go build -gcflags="-m" ./...` to check the escape analysis.
```bash
-# install benchcmp
-go install golang.org/x/tools/cmd/benchcmp@latest
+# install benchstat
+go install golang.org/x/perf/cmd/benchstat@latest
```
To run benchmark and analysis:
diff --git a/benchmark/client_test.go b/benchmark/client_test.go
index 125719d..feba93f 100644
--- a/benchmark/client_test.go
+++ b/benchmark/client_test.go
@@ -6,7 +6,6 @@ import (
"time"
"github.com/bradfitz/gomemcache/memcache"
- rainycape "github.com/rainycape/memcache"
"github.com/yeqown/memcached"
)
@@ -15,29 +14,15 @@ func Test_Yeqown(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- defer client.Close()
- client.Set(context.Background(), testKey, testValue, 0, 0)
- item, err := client.Get(context.Background(), testKey)
- if err != nil {
- t.Fatal(err)
- }
- if string(item.Value) != string(testValue) {
- t.Fatalf("expect %s, got %s", string(testValue), string(item.Value))
- }
-}
-
-func Test_Rainycape(t *testing.T) {
- t.Skipf("It's a binary package, not support test")
-
- client, err := rainycape.New("127.0.0.1:11211")
- if err != nil {
- t.Fatal(err)
- }
- client.Set(&rainycape.Item{
- Key: testKey,
- Value: testValue,
+ t.Cleanup(func() {
+ if err := client.Close(); err != nil {
+ t.Errorf("close client: %v", err)
+ }
})
- item, err := client.Get(testKey)
+ if err := client.Set(context.Background(), testKey, testValue, 0, 0); err != nil {
+ t.Fatalf("set failed: %v", err)
+ }
+ item, err := client.Get(context.Background(), testKey)
if err != nil {
t.Fatal(err)
}
diff --git a/benchmark/concurrent_benchmark_test.go b/benchmark/concurrent_benchmark_test.go
index 0448f5c..c964819 100644
--- a/benchmark/concurrent_benchmark_test.go
+++ b/benchmark/concurrent_benchmark_test.go
@@ -13,7 +13,11 @@ func BenchmarkYeqownMemcachedConcurrent(b *testing.B) {
if err != nil {
b.Fatal(err)
}
- defer client.Close()
+ b.Cleanup(func() {
+ if err := client.Close(); err != nil {
+ b.Errorf("close client: %v", err)
+ }
+ })
ctx := context.Background()
diff --git a/benchmark/go.mod b/benchmark/go.mod
index 7651c5b..1716bc3 100644
--- a/benchmark/go.mod
+++ b/benchmark/go.mod
@@ -1,16 +1,23 @@
-module benchmark
+module github.com/yeqown/memcached/benchmark
-go 1.26.0
+go 1.26.5
require (
- github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874
- github.com/rainycape/memcache v0.0.0-20150622160815-1031fa0ce2f2
- github.com/yeqown/memcached v1.1.3
+ github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c
+ github.com/yeqown/memcached v1.4.0
)
require (
- github.com/hashicorp/errwrap v1.0.0 // indirect
- github.com/hashicorp/go-multierror v1.1.1 // indirect
- github.com/pkg/errors v0.9.1 // indirect
- github.com/stretchr/testify v1.11.1 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/go-logr/logr v1.4.4 // indirect
+ github.com/go-logr/stdr v1.2.2 // indirect
+ github.com/golang/snappy v1.0.0 // indirect
+ github.com/klauspost/compress v1.19.2 // indirect
+ github.com/pierrec/lz4/v4 v4.1.28 // indirect
+ go.opentelemetry.io/auto/sdk v1.2.1 // indirect
+ go.opentelemetry.io/otel v1.45.0 // indirect
+ go.opentelemetry.io/otel/metric v1.45.0 // indirect
+ go.opentelemetry.io/otel/trace v1.45.0 // indirect
)
+
+replace github.com/yeqown/memcached => ../
diff --git a/benchmark/go.sum b/benchmark/go.sum
index 0c42fcf..24421d5 100644
--- a/benchmark/go.sum
+++ b/benchmark/go.sum
@@ -1,20 +1,33 @@
-github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 h1:N7oVaKyGp8bttX0bfZGmcGkjz7DLQXhAn3DNd3T0ous=
-github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c=
+github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c h1:6Gpm9YYUEQx2T9zMsYolQhr6sjwwGtFitSA0pQsa7a8=
+github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
-github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
-github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
-github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
-github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
-github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
+github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
+github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
+github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
+github.com/pierrec/lz4/v4 v4.1.28 h1:pPEPwRJ4kybBTfGt28q7lQsRJQHhC08axprdLD5Ppio=
+github.com/pierrec/lz4/v4 v4.1.28/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/rainycape/memcache v0.0.0-20150622160815-1031fa0ce2f2 h1:dq90+d51/hQRaHEqRAsQ1rE/pC1GUS4sc2rCbbFsAIY=
-github.com/rainycape/memcache v0.0.0-20150622160815-1031fa0ce2f2/go.mod h1:7tZKcyumwBO6qip7RNQ5r77yrssm9bfCowcLEBcU5IA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
-github.com/yeqown/memcached v1.1.3 h1:/b5Nwxq/2xDPgido5+2818uwkdDQYja3Pzl8X6LQyC0=
-github.com/yeqown/memcached v1.1.3/go.mod h1:1NndLxAOAhl9ecmycI8wIv4nIv+fcpAUN0vLlqM7jn4=
+go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
+go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
+go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU=
+go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ=
+go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M=
+go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s=
+go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag=
+go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/benchmark/mem_benchmark_test.go b/benchmark/mem_benchmark_test.go
index e8408c4..4970746 100644
--- a/benchmark/mem_benchmark_test.go
+++ b/benchmark/mem_benchmark_test.go
@@ -6,7 +6,6 @@ import (
"time"
"github.com/bradfitz/gomemcache/memcache"
- rainycape "github.com/rainycape/memcache"
"github.com/yeqown/memcached"
)
@@ -22,7 +21,11 @@ func BenchmarkYeqownMemcached(b *testing.B) {
if err != nil {
b.Fatal(err)
}
- defer client.Close()
+ b.Cleanup(func() {
+ if err := client.Close(); err != nil {
+ b.Errorf("close client: %v", err)
+ }
+ })
ctx := context.Background()
@@ -56,27 +59,3 @@ func BenchmarkBradfitzGomemcache(b *testing.B) {
}
}
}
-
-func BenchmarkRainycapeMemcache(b *testing.B) {
- b.Skipf("It's a binary package, not support benchmark.")
-
- client, err := rainycape.New("localhost:11211")
- if err != nil {
- b.Fatal(err)
- }
- defer client.Close()
- item := &rainycape.Item{
- Key: testKey,
- Value: testValue,
- }
-
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- if err := client.Set(item); err != nil {
- b.Fatal(err)
- }
- if _, err := client.Get(testKey); err != nil {
- b.Fatal(err)
- }
- }
-}
diff --git a/client.go b/client.go
index 9790f24..3569357 100644
--- a/client.go
+++ b/client.go
@@ -3,12 +3,12 @@ package memcached
import (
"bytes"
"context"
+ "errors"
+ "fmt"
"io"
"sync"
"time"
- multierror "github.com/hashicorp/go-multierror"
- "github.com/pkg/errors"
"go.opentelemetry.io/otel/trace"
"github.com/yeqown/memcached/telemetry"
@@ -21,8 +21,6 @@ type Client interface {
basicTextProtocolCommander
metaTextProtocolCommander
statisticsTextProtocolCommander
- // TODO: support rawTextProtocolCommander
- // rawTextProtocolCommander
}
var _ Client = (*client)(nil)
@@ -70,11 +68,11 @@ func newClientWithContext(_ context.Context, addr string, opts ...ClientOption)
addrs, err := options.resolver.Resolve(addr)
if err != nil {
- return nil, errors.Wrap(err, "resolve failed")
+ return nil, fmt.Errorf("resolve failed: %w", err)
}
if len(addrs) == 0 {
- return nil, errors.Wrap(ErrInvalidAddress, "empty address")
+ return nil, fmt.Errorf("empty address: %w", ErrInvalidAddress)
}
picker := options.pickBuilder.Build(addrs)
@@ -100,7 +98,7 @@ func (c *client) Close() error {
for _, pool := range c.connPools {
if err := pool.close(); err != nil {
- return errors.Wrap(err, "Close")
+ return fmt.Errorf("Close: %w", err)
}
}
@@ -129,7 +127,7 @@ func (c *client) getConn(ctx context.Context, addr *Addr) (memcachedConn, error)
cn, err = newConnContext(ctx2, addr, c.options.dialTimeout)
if err != nil {
- return nil, errors.Wrap(err, "newConnContext failed")
+ return nil, fmt.Errorf("newConnContext failed: %w", err)
}
// SASL auth if enabled
@@ -196,12 +194,12 @@ func (c *client) broadcastRequest(ctx context.Context, call callFunc) error {
wg.Wait()
close(errCh)
- var multiErr error
+ var errs []error
for err := range errCh {
- multiErr = multierror.Append(multiErr, err)
+ errs = append(errs, err)
}
- return multiErr
+ return errors.Join(errs...)
}
func (c *client) dispatchRequest(ctx context.Context, req *request, resp *response) error {
@@ -213,7 +211,7 @@ func (c *client) dispatchRequest(ctx context.Context, req *request, resp *respon
addr, err := c.picker.Pick(c.addrs, req.cmd, req.key)
if err != nil {
- return errors.Wrap(err, "pick node failed")
+ return fmt.Errorf("pick node failed: %w", err)
}
// START: Telemetry
@@ -232,7 +230,7 @@ func (c *client) dispatchRequest(ctx context.Context, req *request, resp *respon
if c.metrics != nil {
c.metrics.RecordDuration(context.Background(), string(req.cmd), addr.Address, time.Since(start), err)
}
- return errors.Wrap(err, "alloc connection failed")
+ return fmt.Errorf("alloc connection failed: %w", err)
}
defer func() { _ = cn.release() }()
@@ -245,7 +243,7 @@ func (c *client) dispatchRequest(ctx context.Context, req *request, resp *respon
if c.metrics != nil {
c.metrics.RecordDuration(context.Background(), string(req.cmd), addr.Address, time.Since(start), err)
}
- return errors.Wrap(err, "send failed")
+ return fmt.Errorf("send failed: %w", err)
}
recvErr := resp.recv(ctx, cn, c.options.readTimeout)
@@ -275,13 +273,13 @@ func authSASL(conn memcachedConn, username, password string) error {
// 1. first, list mechanisms the server supports
req, resp := saslListMechanisms()
if err := req.send(conn); err != nil {
- return errors.Wrap(err, "authSASL send")
+ return fmt.Errorf("authSASL send: %w", err)
}
if err := resp.read(conn); err != nil {
- return errors.Wrap(err, "authSASL recv")
+ return fmt.Errorf("authSASL recv: %w", err)
}
if err := resp.expect(_binaryStatusOK); err != nil {
- return errors.Wrap(err, "authSASL")
+ return fmt.Errorf("authSASL: %w", err)
}
if !bytes.Contains(resp.value, []byte("PLAIN")) {
@@ -291,13 +289,13 @@ func authSASL(conn memcachedConn, username, password string) error {
// 2. choose one mechanism and send the authentication request
req, resp = saslAuthRequestPlain(username, password)
if err := req.send(conn); err != nil {
- return errors.Wrap(err, "authSASL send")
+ return fmt.Errorf("authSASL send: %w", err)
}
if err := resp.read(conn); err != nil {
- return errors.Wrap(err, "authSASL recv")
+ return fmt.Errorf("authSASL recv: %w", err)
}
if err := resp.expect(_binaryStatusOK); err != nil {
- return errors.Wrap(err, "authSASL")
+ return fmt.Errorf("authSASL: %w", err)
}
return nil
diff --git a/client_cluster.go b/client_cluster.go
index c16f4a6..5a351e2 100644
--- a/client_cluster.go
+++ b/client_cluster.go
@@ -1,12 +1,12 @@
package memcached
import (
+ "fmt"
"hash/crc32"
+ "math"
"net"
"strings"
- "github.com/pkg/errors"
-
"github.com/yeqown/memcached/hash"
)
@@ -52,7 +52,7 @@ type defaultResolver struct{}
func (r defaultResolver) Resolve(addr string) ([]*Addr, error) {
if addr == "" {
- return nil, errors.Wrap(ErrInvalidAddress, "empty address")
+ return nil, fmt.Errorf("empty address: %w", ErrInvalidAddress)
}
addrs := strings.Split(addr, ",")
@@ -73,7 +73,7 @@ func (r defaultResolver) Resolve(addr string) ([]*Addr, error) {
}
if len(result) == 0 {
- return nil, errors.Wrap(ErrInvalidAddress, "no available address")
+ return nil, fmt.Errorf("no available address: %w", ErrInvalidAddress)
}
return result, nil
@@ -83,7 +83,7 @@ func (r defaultResolver) Resolve(addr string) ([]*Addr, error) {
func (r defaultResolver) resolveAddr(address string) (network, addr string, err error) {
address = strings.TrimSpace(address)
if address == "" {
- return "", "", errors.Wrap(ErrInvalidAddress, "empty address")
+ return "", "", fmt.Errorf("empty address: %w", ErrInvalidAddress)
}
network = "tcp"
@@ -108,7 +108,7 @@ func (r defaultResolver) resolveAddr(address string) (network, addr string, err
}
if err != nil {
- return "", "", errors.Wrap(err, "invalid address: "+address)
+ return "", "", fmt.Errorf("invalid address: %s: %w", address, err)
}
return network, addr, nil
@@ -121,14 +121,19 @@ type crc32HashPicker struct{}
func (p *crc32HashPicker) Pick(addrs []*Addr, _, key []byte) (*Addr, error) {
n := len(addrs)
if n == 0 {
- return nil, errors.Wrap(ErrInvalidAddress, "no available address")
+ return nil, fmt.Errorf("no available address: %w", ErrInvalidAddress)
}
if n == 1 {
return addrs[0], nil
}
sum := crc32.ChecksumIEEE(key)
- return addrs[sum%uint32(n)], nil
+ index64 := uint64(sum) % uint64(n)
+ if index64 > math.MaxInt {
+ return nil, fmt.Errorf("hash index exceeds int: %w", ErrInvalidAddress)
+ }
+ index := int(index64)
+ return addrs[index], nil
}
type crc32HashPickBuilder struct{}
@@ -150,7 +155,7 @@ type murmur3HashPicker struct {
func (p *murmur3HashPicker) Pick(addrs []*Addr, _, key []byte) (*Addr, error) {
n := len(addrs)
if n == 0 {
- return nil, errors.Wrap(ErrInvalidAddress, "no available address")
+ return nil, fmt.Errorf("no available address: %w", ErrInvalidAddress)
}
if n == 1 {
return addrs[0], nil
diff --git a/client_commands.go b/client_commands.go
index 017ca29..58eaf89 100644
--- a/client_commands.go
+++ b/client_commands.go
@@ -3,9 +3,10 @@ package memcached
import (
"bytes"
"context"
+ "errors"
+ "fmt"
+ "math"
"time"
-
- "github.com/pkg/errors"
)
type basicTextProtocolCommander interface {
@@ -126,13 +127,6 @@ type statisticsTextProtocolCommander interface {
Stats(ctx context.Context) (*Statistic, error)
}
-type rawTextProtocolCommander interface {
- // Raw is used to send the raw command to the memcached server.
- // Warning: this command is not recommended to use since it expects the server to return
- // the `END` line, which is not guaranteed of all commands. For example, `version`.
- Raw(ctx context.Context, cmd string) ([]string, error)
-}
-
/**
* Storage commands: set, add, replace, append, prepend, cas
*/
@@ -144,16 +138,16 @@ func (c *client) storageCommand(ctx context.Context, command, key string, value
req, resp, err := buildStorageCommand(command, key, value, flag, expiry, c.options.noReply, c.options.codec)
if err != nil {
- return errors.Wrap(err, "build storage command failed")
+ return fmt.Errorf("build storage command failed: %w", err)
}
defer releaseReqAndResp(req, resp)
if err = c.dispatchRequest(ctx, req, resp); err != nil {
- return errors.Wrap(err, "request failed")
+ return fmt.Errorf("request failed: %w", err)
}
if err = resp.expect(_StoredCRLFBytes); err != nil {
- return errors.Wrap(ErrMalformedResponse, err.Error())
+ return fmt.Errorf("%s: %w", err.Error(), ErrMalformedResponse)
}
return nil
@@ -191,11 +185,11 @@ func (c *client) Cas(ctx context.Context, key string, value []byte, flag uint32,
defer releaseReqAndResp(req, resp)
if err = c.dispatchRequest(ctx, req, resp); err != nil {
- return errors.Wrap(err, "request failed")
+ return fmt.Errorf("request failed: %w", err)
}
if err = resp.expect(_StoredCRLFBytes); err != nil {
- return errors.Wrap(ErrMalformedResponse, err.Error())
+ return fmt.Errorf("%s: %w", err.Error(), ErrMalformedResponse)
}
return nil
@@ -214,15 +208,15 @@ func (c *client) Get(ctx context.Context, key string) (*Item, error) {
defer releaseReqAndResp(req, resp)
if err := c.dispatchRequest(ctx, req, resp); err != nil {
- return nil, errors.Wrap(err, "request failed")
+ return nil, fmt.Errorf("request failed: %w", err)
}
items, err := parseValueItems(resp.rawLines, false, false, c.options.codec)
if err != nil {
- return nil, errors.Wrap(err, "parse values failed")
+ return nil, fmt.Errorf("parse values failed: %w", err)
}
if len(items) == 0 {
- return nil, errors.Wrap(ErrNotFound, "no items found")
+ return nil, fmt.Errorf("no items found: %w", ErrNotFound)
}
return items[0], nil
@@ -237,15 +231,15 @@ func (c *client) Gets(ctx context.Context, keys ...string) ([]*Item, error) {
defer releaseReqAndResp(req, resp)
if err := c.dispatchRequest(ctx, req, resp); err != nil {
- return nil, errors.Wrap(err, "request failed")
+ return nil, fmt.Errorf("request failed: %w", err)
}
items, err := parseValueItems(resp.rawLines, false, true, c.options.codec)
if err != nil {
- return nil, errors.Wrap(ErrMalformedResponse, "parse values failed")
+ return nil, fmt.Errorf("parse values failed: %w", ErrMalformedResponse)
}
if len(items) == 0 {
- return nil, errors.Wrap(ErrNotFound, "no items found")
+ return nil, fmt.Errorf("no items found: %w", ErrNotFound)
}
return items, nil
@@ -260,16 +254,16 @@ func (c *client) GetAndTouch(ctx context.Context, expiry time.Duration, key stri
defer releaseReqAndResp(req, resp)
if err := c.dispatchRequest(ctx, req, resp); err != nil {
- return nil, errors.Wrap(err, "request failed")
+ return nil, fmt.Errorf("request failed: %w", err)
}
items, err := parseValueItems(resp.rawLines, false, false, c.options.codec)
if err != nil {
- return nil, errors.Wrap(ErrMalformedResponse, "parse values failed")
+ return nil, fmt.Errorf("parse values failed: %w", ErrMalformedResponse)
}
if len(items) == 0 {
- return nil, errors.Wrap(ErrNotFound, "no items found")
+ return nil, fmt.Errorf("no items found: %w", ErrNotFound)
}
return items[0], nil
@@ -284,17 +278,17 @@ func (c *client) GetAndTouches(ctx context.Context, expiry time.Duration, keys .
defer releaseReqAndResp(req, resp)
if err := c.dispatchRequest(ctx, req, resp); err != nil {
- return nil, errors.Wrap(err, "request failed")
+ return nil, fmt.Errorf("request failed: %w", err)
}
// parse response
items, err := parseValueItems(resp.rawLines, false, true, c.options.codec)
if err != nil {
- return nil, errors.Wrap(ErrMalformedResponse, "parse values failed")
+ return nil, fmt.Errorf("parse values failed: %w", ErrMalformedResponse)
}
if len(items) == 0 {
- return nil, errors.Wrap(ErrNotFound, "no items found")
+ return nil, fmt.Errorf("no items found: %w", ErrNotFound)
}
return items, nil
@@ -313,12 +307,12 @@ func (c *client) Delete(ctx context.Context, key string) error {
defer releaseReqAndResp(req, resp)
if err := c.dispatchRequest(ctx, req, resp); err != nil {
- return errors.Wrap(err, "request failed")
+ return fmt.Errorf("request failed: %w", err)
}
// expect DELETED\r\n
if err := resp.expect(_DeletedCRLFBytes); err != nil {
- return errors.Wrap(ErrMalformedResponse, err.Error())
+ return fmt.Errorf("%s: %w", err.Error(), ErrMalformedResponse)
}
return nil
@@ -331,13 +325,13 @@ func (c *client) Incr(ctx context.Context, key string, delta uint64) (uint64, er
req, resp := buildArithmeticCommand("incr", key, delta, c.options.noReply)
if err := c.dispatchRequest(ctx, req, resp); err != nil {
- return 0, errors.Wrap(err, "request failed")
+ return 0, fmt.Errorf("request failed: %w", err)
}
// parse response
value, err := parseArithmetic(resp.rawLines[0])
if err != nil {
- return 0, errors.Wrap(ErrMalformedResponse, err.Error())
+ return 0, fmt.Errorf("%s: %w", err.Error(), ErrMalformedResponse)
}
return value, nil
@@ -350,13 +344,13 @@ func (c *client) Decr(ctx context.Context, key string, delta uint64) (uint64, er
req, resp := buildArithmeticCommand("decr", key, delta, c.options.noReply)
if err := c.dispatchRequest(ctx, req, resp); err != nil {
- return 0, errors.Wrap(err, "request failed")
+ return 0, fmt.Errorf("request failed: %w", err)
}
// parse response
value, err := parseArithmetic(resp.rawLines[0])
if err != nil {
- return 0, errors.Wrap(ErrMalformedResponse, err.Error())
+ return 0, fmt.Errorf("%s: %w", err.Error(), ErrMalformedResponse)
}
return value, nil
@@ -371,12 +365,12 @@ func (c *client) Touch(ctx context.Context, key string, expiry time.Duration) er
defer releaseReqAndResp(req, resp)
if err := c.dispatchRequest(ctx, req, resp); err != nil {
- return errors.Wrap(err, "request failed")
+ return fmt.Errorf("request failed: %w", err)
}
// expect TOUCHED\r\n
if err := resp.expect(_TouchedCRLFBytes); err != nil {
- return errors.Wrap(ErrMalformedResponse, err.Error())
+ return fmt.Errorf("%s: %w", err.Error(), ErrMalformedResponse)
}
return nil
@@ -387,14 +381,14 @@ func (c *client) Version(ctx context.Context) (string, error) {
defer releaseReqAndResp(req, resp)
if err := c.dispatchRequest(ctx, req, resp); err != nil {
- return "", errors.Wrap(err, "request")
+ return "", fmt.Errorf("request: %w", err)
}
// parse version number from response
// VERSION 1.6.14
line := resp.rawLines[0]
if !bytes.HasPrefix(line, _VersionBytes) {
- return "", errors.Wrap(ErrMalformedResponse, string(line))
+ return "", fmt.Errorf("%s: %w", string(line), ErrMalformedResponse)
}
return string(trimCRLF(line[8:])), nil
@@ -408,22 +402,22 @@ func (c *client) FlushAll(ctx context.Context) error {
c.autoSwitchToUDP(ctx, req, resp)
if err := req.send(ctx, cn, c.options.writeTimeout); err != nil {
- return errors.Wrap(err, "send failed")
+ return fmt.Errorf("send failed: %w", err)
}
if err := resp.recv(ctx, cn, c.options.readTimeout); err != nil {
- return errors.Wrap(err, "recv failed")
+ return fmt.Errorf("recv failed: %w", err)
}
// expect OK\r\n
if err := resp.expect(_OKCRLFBytes); err != nil {
- return errors.Wrap(ErrMalformedResponse, err.Error())
+ return fmt.Errorf("%s: %w", err.Error(), ErrMalformedResponse)
}
return nil
}
if err := c.broadcastRequest(ctx, call); err != nil {
- return errors.Wrap(err, "request failed")
+ return fmt.Errorf("request failed: %w", err)
}
return nil
@@ -443,6 +437,9 @@ func (c *client) MetaSet(ctx context.Context, key, value []byte, msOptions ...Me
for _, applyFn := range msOptions {
applyFn(msFlags)
}
+ if msFlags.T > math.MaxInt64 {
+ return nil, fmt.Errorf("TTL exceeds int64: %w", ErrInvalidArgument)
+ }
clientFlags := msFlags.F
req, resp, err := buildMetaSetCommand(key, value, msFlags, c.options.codec)
@@ -451,7 +448,7 @@ func (c *client) MetaSet(ctx context.Context, key, value []byte, msOptions ...Me
}
defer releaseReqAndResp(req, resp)
if err := c.dispatchRequest(ctx, req, resp); err != nil {
- return nil, errors.Wrap(err, "request failed")
+ return nil, fmt.Errorf("request failed: %w", err)
}
item := &MetaItem{
@@ -486,7 +483,7 @@ func (c *client) MetaGet(ctx context.Context, key []byte, mgOptions ...MetaGetOp
defer releaseReqAndResp(req, resp)
if err := c.dispatchRequest(ctx, req, resp); err != nil {
- return nil, errors.Wrap(err, "request failed")
+ return nil, fmt.Errorf("request failed: %w", err)
}
item := &MetaItem{
@@ -513,7 +510,7 @@ func (c *client) MetaDelete(ctx context.Context, key []byte, options ...MetaDele
defer releaseReqAndResp(req, resp)
if err := c.dispatchRequest(ctx, req, resp); err != nil {
- return nil, errors.Wrap(err, "request failed")
+ return nil, fmt.Errorf("request failed: %w", err)
}
item := &MetaItem{
@@ -540,7 +537,7 @@ func (c *client) MetaArithmetic(ctx context.Context, key []byte, delta uint64, o
defer releaseReqAndResp(req, resp)
if err := c.dispatchRequest(ctx, req, resp); err != nil {
- return nil, errors.Wrap(err, "request failed")
+ return nil, fmt.Errorf("request failed: %w", err)
}
item := &MetaItem{
@@ -579,7 +576,7 @@ func (c *client) MetaDebug(ctx context.Context, key []byte, options ...MetaDebug
defer releaseReqAndResp(req, resp)
if err := c.dispatchRequest(ctx, req, resp); err != nil {
- return nil, errors.Wrap(err, "request failed")
+ return nil, fmt.Errorf("request failed: %w", err)
}
item := &MetaItemDebug{
@@ -598,14 +595,14 @@ func (c *client) MetaNoOp(ctx context.Context) error {
defer releaseReqAndResp(req, resp)
if err := c.dispatchRequest(ctx, req, resp); err != nil {
- return errors.Wrap(err, "request failed")
+ return fmt.Errorf("request failed: %w", err)
}
if err := resp.expect(_MetaMNCRLFBytes); err != nil {
if errors.Is(err, ErrMalformedResponse) {
return err
}
- return errors.Wrap(ErrMalformedResponse, err.Error())
+ return fmt.Errorf("%s: %w", err.Error(), ErrMalformedResponse)
}
return nil
@@ -621,24 +618,8 @@ func (c *client) Stats(ctx context.Context) (*Statistic, error) {
defer releaseReqAndResp(req, resp)
if err := c.dispatchRequest(ctx, req, resp); err != nil {
- return nil, errors.Wrap(err, "request failed")
+ return nil, fmt.Errorf("request failed: %w", err)
}
return parseStats(resp.rawLines)
}
-
-func (c *client) Raw(ctx context.Context, cmd string) ([]string, error) {
- req, resp := buildRawCommand(cmd, endIndicatorSpecificEndLine, 0)
- defer releaseReqAndResp(req, resp)
-
- if err := c.dispatchRequest(ctx, req, resp); err != nil {
- return nil, errors.Wrap(err, "request failed")
- }
-
- lines := make([]string, 0, len(resp.rawLines))
- for _, line := range resp.rawLines {
- lines = append(lines, string(bytes.TrimSuffix(line, _CRLFBytes)))
- }
-
- return lines, nil
-}
diff --git a/client_test.go b/client_test.go
index 3f767cb..17108c6 100644
--- a/client_test.go
+++ b/client_test.go
@@ -2,11 +2,11 @@ package memcached
import (
"context"
+ "errors"
"sync"
"testing"
"time"
- pkgerrors "github.com/pkg/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
@@ -137,7 +137,7 @@ func (su *clientTestSuite) Test_concurrent() {
for counter <= 200 {
item, err := su.client.Get(ctx, key)
- if pkgerrors.Is(err, ErrNotFound) {
+ if errors.Is(err, ErrNotFound) {
goto next
}
su.NoError(err)
@@ -160,7 +160,7 @@ func (su *clientTestSuite) Test_concurrent() {
for counter <= 100 {
err := su.client.Touch(ctx, key, 3)
- if !pkgerrors.Is(err, ErrNotFound) {
+ if !errors.Is(err, ErrNotFound) {
su.NoError(err)
}
@@ -247,11 +247,11 @@ func TestCompressionDisablesAppendPrepend(t *testing.T) {
err := client.Append(context.Background(), "key", []byte("value"), 0, 0)
require.Error(t, err)
- assert.True(t, pkgerrors.Is(err, ErrNotSupported))
+ assert.True(t, errors.Is(err, ErrNotSupported))
err = client.Prepend(context.Background(), "key", []byte("value"), 0, 0)
require.Error(t, err)
- assert.True(t, pkgerrors.Is(err, ErrNotSupported))
+ assert.True(t, errors.Is(err, ErrNotSupported))
}
func TestCompressionDisablesMetaAppendPrepend(t *testing.T) {
@@ -269,7 +269,7 @@ func TestCompressionDisablesMetaAppendPrepend(t *testing.T) {
)
assert.Nil(t, item)
require.Error(t, err)
- assert.True(t, pkgerrors.Is(err, ErrNotSupported))
+ assert.True(t, errors.Is(err, ErrNotSupported))
})
}
}
diff --git a/cmd/memcached-cli/commands.go b/cmd/memcached-cli/commands.go
index 3735791..826ec39 100644
--- a/cmd/memcached-cli/commands.go
+++ b/cmd/memcached-cli/commands.go
@@ -1,11 +1,13 @@
package main
import (
+ "errors"
"fmt"
+ "io"
+ "os"
"strings"
"time"
- "github.com/pkg/errors"
"github.com/samber/lo"
"github.com/spf13/cobra"
"github.com/yeqown/memcached"
@@ -162,9 +164,9 @@ func newContextCurrentCommand() *cobra.Command {
const (
magicFlags uint32 = 0x0705
- magicSeed = 0x2014
+ magicSeed uint64 = 0x2014
- historyTimeFormat = "2006-01-02 15:04:05"
+ historyTimeFormat string = "2006-01-02 15:04:05"
)
func newKVGetCommand() *cobra.Command {
@@ -193,14 +195,12 @@ func newKVGetCommand() *cobra.Command {
memcached.MetaGetFlagReturnHitBefore(),
)
if err != nil {
- return ignoreMemcachedError(err)
+ return ignoreMemcachedError(os.Stdout, err)
}
- history.addRecord("get", args)
+ recordHistory(cmd, history, "get", args)
- printMetaItem(item)
-
- return nil
+ return printMetaItem(item)
},
}
}
@@ -224,10 +224,10 @@ func newKVSetCommand() *cobra.Command {
err = client.Set(cmd.Context(), args[0], []byte(args[1]), magicFlags, expiration)
if err != nil {
- return ignoreMemcachedError(err)
+ return ignoreMemcachedError(os.Stdout, err)
}
- history.addRecord("set", args)
+ recordHistory(cmd, history, "set", args)
fmt.Printf("OK\n")
return nil
@@ -254,10 +254,10 @@ func newKVDeleteCommand() *cobra.Command {
err = client.Delete(cmd.Context(), args[0])
if err != nil {
- return ignoreMemcachedError(err)
+ return ignoreMemcachedError(os.Stdout, err)
}
- history.addRecord("delete", args)
+ recordHistory(cmd, history, "delete", args)
fmt.Printf("OK\n")
return nil
@@ -295,18 +295,16 @@ func newKVGetsCommand() *cobra.Command {
memcached.MetaGetFlagReturnHitBefore(),
)
if err != nil {
- fmt.Printf("Encounter an error while getting key '%s': %v\n", key, errors.Cause(err))
+ fmt.Printf("Encounter an error while getting key '%s': %v\n", key, rootErr(err))
continue
}
items = append(items, item)
}
- history.addRecord("gets", args)
-
- printMetaItems(items)
+ recordHistory(cmd, history, "gets", args)
- return nil
+ return printMetaItems(items)
},
}
}
@@ -330,10 +328,10 @@ func newKVTouchCommand() *cobra.Command {
}
if err := client.Touch(cmd.Context(), args[0], expiration); err != nil {
- return ignoreMemcachedError(err)
+ return ignoreMemcachedError(os.Stdout, err)
}
- history.addRecord("touch", args)
+ recordHistory(cmd, history, "touch", args)
fmt.Println("OK")
return nil
@@ -392,10 +390,10 @@ func newKVFlushAllCommand() *cobra.Command {
imme:
if err := client.FlushAll(cmd.Context()); err != nil {
- return ignoreMemcachedError(err)
+ return ignoreMemcachedError(os.Stdout, err)
}
- history.addRecord("flushall", args)
+ recordHistory(cmd, history, "flushall", args)
fmt.Println("OK")
return nil
@@ -406,25 +404,45 @@ func newKVFlushAllCommand() *cobra.Command {
return cmd
}
-func printMetaItems(items []*memcached.MetaItem) {
+func printMetaItems(items []*memcached.MetaItem) error {
+ return writeMetaItems(os.Stdout, items)
+}
+
+func printMetaItem(item *memcached.MetaItem) error {
+ return writeMetaItem(os.Stdout, item)
+}
+
+func writeMetaItems(w io.Writer, items []*memcached.MetaItem) error {
for idx, item := range items {
- fmt.Printf(" ================= The [%d] item =================\n", idx)
- printMetaItem(item)
+ if _, err := io.WriteString(w, fmt.Sprintf(" ================= The [%d] item =================\n", idx)); err != nil {
+ return err
+ }
+ if err := writeMetaItem(w, item); err != nil {
+ return err
+ }
}
+
+ return nil
}
-func printMetaItem(item *memcached.MetaItem) {
+func writeMetaItem(w io.Writer, item *memcached.MetaItem) error {
lastAccessAt := time.Now().Add(-time.Duration(item.LastAccessedTime) * time.Second)
+ output := fmt.Sprintf("Key: %s\n", item.Key) +
+ fmt.Sprintf("Flags: %d (0x%x)\n", item.Flags, item.Flags) +
+ fmt.Sprintf("CAS: %d (0x%x)\n", item.CAS, item.CAS) +
+ fmt.Sprintf("ClientFlags: %d (0x%x)\n", item.Flags, item.Flags) +
+ fmt.Sprintf("LastAccessedTime: %s (%s)\n", lastAccessAt.Format(time.RFC3339), formatSeconds(int(item.LastAccessedTime), "before", "never")) +
+ fmt.Sprintf("HitBefore: %s\n", map[bool]string{true: "✅", false: "❌"}[item.HitBefore]) +
+ fmt.Sprintf("TTL: %d (%s)\n", item.TTL, formatSeconds(int(item.TTL), "later", "never expires")) +
+ fmt.Sprintf("Value: %s\n\n", item.Value)
+ _, err := io.WriteString(w, output)
+ return err
+}
- fmt.Printf("Key: %s\n", item.Key)
- fmt.Printf("Flags: %d (0x%x)\n", item.Flags, item.Flags)
- fmt.Printf("CAS: %d (0x%x)\n", item.CAS, item.CAS)
- fmt.Printf("ClientFlags: %d (0x%x)\n", item.Flags, item.Flags)
- fmt.Printf("LastAccessedTime: %s (%s)\n", lastAccessAt.Format(time.RFC3339), formatSeconds(int(item.LastAccessedTime), "before", "never"))
- fmt.Printf("HitBefore: %s\n", map[bool]string{true: "✅", false: "❌"}[item.HitBefore])
- fmt.Printf("TTL: %d (%s)\n", item.TTL, formatSeconds(int(item.TTL), "later", "never expires"))
- fmt.Printf("Value: %s\n", item.Value)
- fmt.Println()
+func recordHistory(cmd *cobra.Command, history *kvCommandHistoryManager, command string, args []string) {
+ if err := history.addRecord(command, args); err != nil {
+ _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: failed to record %s command in history: %v\n", command, err)
+ }
}
func formatSeconds(seconds int, suffix, zeroString string) (readable string) {
@@ -458,14 +476,14 @@ func formatSeconds(seconds int, suffix, zeroString string) (readable string) {
return readable + " " + suffix
}
-func ignoreMemcachedError(err error) error {
+func ignoreMemcachedError(w io.Writer, err error) error {
if err == nil {
return nil
}
logger.Debugf("ignoreMemcachedError handling err: %v", err)
- var memErrs = []error{
+ memErrs := []error{
memcached.ErrNonexistentCommand,
memcached.ErrClientError,
memcached.ErrServerError,
@@ -486,8 +504,8 @@ func ignoreMemcachedError(err error) error {
for _, memErr := range memErrs {
if errors.Is(err, memErr) {
- fmt.Printf("Memcached Error: %v\n", errors.Cause(err))
- return nil
+ _, writeErr := fmt.Fprintf(w, "Memcached Error: %v\n", rootErr(err))
+ return writeErr
}
}
@@ -510,7 +528,9 @@ func newHistoryEnableCommand() *cobra.Command {
manager := getContextManager(cmd, false)
manager.historyEnabled = true
manager.historyMaxLines = int(historyMaxLines)
- manager.save()
+ if err := manager.save(); err != nil {
+ return fmt.Errorf("save history settings: %w", err)
+ }
fmt.Println("History enabled!")
return nil
},
@@ -530,7 +550,9 @@ func newHistoryDisableCommand() *cobra.Command {
RunE: func(cmd *cobra.Command, args []string) error {
manager := getContextManager(cmd, false)
manager.historyEnabled = false
- manager.save()
+ if err := manager.save(); err != nil {
+ return fmt.Errorf("save history settings: %w", err)
+ }
fmt.Println("History disabled!")
return nil
},
diff --git a/cmd/memcached-cli/context_manager.go b/cmd/memcached-cli/context_manager.go
index a46214f..d8ba276 100644
--- a/cmd/memcached-cli/context_manager.go
+++ b/cmd/memcached-cli/context_manager.go
@@ -2,6 +2,7 @@ package main
import (
"encoding/json"
+ "errors"
"fmt"
"os"
"path/filepath"
@@ -16,6 +17,7 @@ type contextManager struct {
mu sync.RWMutex
contexts map[string]*Context
current string
+ configDir string
path string
historyMaxLines int
historyEnabled bool
@@ -32,8 +34,8 @@ func newContextManager() (*contextManager, error) {
return nil, err
}
- path := filepath.Join(home, ".memcached-cli", "config.json")
- if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
+ configDir := filepath.Join(home, ".memcached-cli")
+ if err := os.MkdirAll(configDir, 0o700); err != nil {
return nil, err
}
@@ -41,7 +43,8 @@ func newContextManager() (*contextManager, error) {
mu: sync.RWMutex{},
contexts: make(map[string]*Context, 4),
current: "",
- path: path,
+ configDir: configDir,
+ path: "config.json",
historyMaxLines: 10000,
historyEnabled: true,
currentClient: nil,
@@ -55,19 +58,26 @@ func newContextManager() (*contextManager, error) {
}
func (m *contextManager) close() error {
- m.save()
-
+ saveErr := m.save()
if m.currentClient != nil {
- return m.currentClient.Close()
+ return errors.Join(saveErr, m.currentClient.Close())
}
- return nil
+ return saveErr
}
// initialize loads CLI config file and initialize current client while
// the current is not empty.
-func (m *contextManager) initialize() error {
- data, err := os.ReadFile(m.path)
+func (m *contextManager) initialize() (retErr error) {
+ root, err := os.OpenRoot(m.configDir)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ retErr = errors.Join(retErr, root.Close())
+ }()
+
+ data, err := root.ReadFile(m.path)
if err != nil {
return err
}
@@ -84,6 +94,7 @@ func (m *contextManager) initialize() error {
}
m.mu.Lock()
+ defer m.mu.Unlock()
m.contexts = stored.Contexts
m.current = stored.Current
m.historyMaxLines = stored.HistoryMaxLines
@@ -102,15 +113,13 @@ func (m *contextManager) initialize() error {
}
}
- m.mu.Unlock()
-
return nil
}
// save writes contexts to disk
-func (m *contextManager) save() error {
- m.mu.RLock()
- defer m.mu.RUnlock()
+func (m *contextManager) save() (retErr error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
if m.resetCurrent != nil {
m.current = m.resetCurrent()
@@ -134,7 +143,18 @@ func (m *contextManager) save() error {
return err
}
- return os.WriteFile(m.path, data, 0644)
+ root, err := os.OpenRoot(m.configDir)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ retErr = errors.Join(retErr, root.Close())
+ }()
+
+ if err := root.WriteFile(m.path, data, 0o600); err != nil {
+ return err
+ }
+ return root.Chmod(m.path, 0o600)
}
// addTemporaryContext creates a new temporary context for interactive use
@@ -145,9 +165,8 @@ func (m *contextManager) save() error {
//
// The temporary context is used to store the current context name, so it can be
// used to switch back to the original context when the CLI exits.
-func (m *contextManager) addTemporaryContext(servers, hashStrategy string) {
+func (m *contextManager) addTemporaryContext(servers, hashStrategy string) error {
m.mu.Lock()
- defer m.mu.Unlock()
ctx := &Context{
Name: "temporary", // temporary context name
@@ -157,11 +176,26 @@ func (m *contextManager) addTemporaryContext(servers, hashStrategy string) {
LastUsed: time.Now(),
}
+ originalCurrent := m.current
+ originalTemporary, hadTemporary := m.contexts[ctx.Name]
+ previousClient := m.currentClient
+ m.currentClient = nil
m.contexts[ctx.Name] = ctx
m.resetCurrent = func() string {
- return m.current
+ if hadTemporary {
+ m.contexts[ctx.Name] = originalTemporary
+ } else {
+ delete(m.contexts, ctx.Name)
+ }
+ return originalCurrent
}
m.current = ctx.Name
+ m.mu.Unlock()
+
+ if previousClient != nil {
+ return previousClient.Close()
+ }
+ return nil
}
// CreateContext creates a new context with the given name and configuration
diff --git a/cmd/memcached-cli/context_manager_test.go b/cmd/memcached-cli/context_manager_test.go
new file mode 100644
index 0000000..97f54fc
--- /dev/null
+++ b/cmd/memcached-cli/context_manager_test.go
@@ -0,0 +1,42 @@
+package main
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestTemporaryContextIsNotPersisted(t *testing.T) {
+ t.Parallel()
+
+ configDir := t.TempDir()
+ manager := &contextManager{
+ contexts: map[string]*Context{
+ "saved": {Name: "saved", Servers: "saved.example:11211"},
+ },
+ current: "saved",
+ configDir: configDir,
+ path: "config.json",
+ historyMaxLines: 100,
+ historyEnabled: true,
+ }
+
+ require.NoError(t, manager.addTemporaryContext("temporary.example:11211", "rendezvous"))
+ assert.Equal(t, "temporary", manager.current)
+ require.NoError(t, manager.save())
+
+ data, err := os.ReadFile(filepath.Join(configDir, "config.json"))
+ require.NoError(t, err)
+ var stored struct {
+ Current string `json:"current"`
+ Contexts map[string]*Context `json:"contexts"`
+ }
+ require.NoError(t, json.Unmarshal(data, &stored))
+ assert.Equal(t, "saved", stored.Current)
+ assert.Contains(t, stored.Contexts, "saved")
+ assert.NotContains(t, stored.Contexts, "temporary")
+}
diff --git a/cmd/memcached-cli/errors.go b/cmd/memcached-cli/errors.go
new file mode 100644
index 0000000..5cdae31
--- /dev/null
+++ b/cmd/memcached-cli/errors.go
@@ -0,0 +1,14 @@
+package main
+
+import "errors"
+
+// rootErr returns the innermost error in an unwrap chain.
+func rootErr(err error) error {
+ for {
+ next := errors.Unwrap(err)
+ if next == nil {
+ return err
+ }
+ err = next
+ }
+}
diff --git a/cmd/memcached-cli/go.mod b/cmd/memcached-cli/go.mod
index 608f5ab..1f7fa9a 100644
--- a/cmd/memcached-cli/go.mod
+++ b/cmd/memcached-cli/go.mod
@@ -1,29 +1,53 @@
module github.com/yeqown/memcached/cmd/memcached-cli
-go 1.26.0
+go 1.26.5
require (
+ charm.land/bubbles/v2 v2.1.1
+ charm.land/bubbletea/v2 v2.0.8
github.com/MakeNowJust/heredoc v1.0.0
- github.com/c-bata/go-prompt v0.2.6
- github.com/pkg/errors v0.9.1
- github.com/samber/lo v1.49.1
- github.com/spf13/cobra v1.9.1
- github.com/yeqown/log v1.1.1
- github.com/yeqown/memcached v1.3.4
+ github.com/samber/lo v1.53.0
+ github.com/spf13/cobra v1.10.2
+ github.com/stretchr/testify v1.11.1
+ github.com/yeqown/log v1.3.1
+ github.com/yeqown/memcached v1.4.0
)
require (
- github.com/hashicorp/errwrap v1.0.0 // indirect
- github.com/hashicorp/go-multierror v1.1.1 // indirect
+ charm.land/lipgloss/v2 v2.0.4 // indirect
+ github.com/atotto/clipboard v0.1.4 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/charmbracelet/colorprofile v0.4.3 // indirect
+ github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 // indirect
+ github.com/charmbracelet/x/ansi v0.11.7 // indirect
+ github.com/charmbracelet/x/term v0.2.2 // indirect
+ github.com/charmbracelet/x/termios v0.1.1 // indirect
+ github.com/charmbracelet/x/windows v0.2.2 // indirect
+ github.com/clipperhouse/displaywidth v0.11.0 // indirect
+ github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
+ github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/go-logr/logr v1.4.4 // indirect
+ github.com/go-logr/stdr v1.2.2 // indirect
+ github.com/golang/snappy v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
- github.com/mattn/go-colorable v0.1.14 // indirect
- github.com/mattn/go-isatty v0.0.20 // indirect
- github.com/mattn/go-runewidth v0.0.16 // indirect
- github.com/mattn/go-tty v0.0.3 // indirect
- github.com/pkg/term v1.2.0-beta.2 // indirect
+ github.com/klauspost/compress v1.19.2 // indirect
+ github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
+ github.com/mattn/go-runewidth v0.0.24 // indirect
+ github.com/muesli/cancelreader v0.2.2 // indirect
+ github.com/pierrec/lz4/v4 v4.1.28 // indirect
+ github.com/pkg/errors v0.9.1 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
- github.com/spf13/pflag v1.0.6 // indirect
- github.com/stretchr/testify v1.11.1 // indirect
- golang.org/x/sys v0.40.0 // indirect
- golang.org/x/text v0.23.0 // indirect
+ github.com/spf13/pflag v1.0.10 // indirect
+ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+ go.opentelemetry.io/auto/sdk v1.2.1 // indirect
+ go.opentelemetry.io/otel v1.45.0 // indirect
+ go.opentelemetry.io/otel/metric v1.45.0 // indirect
+ go.opentelemetry.io/otel/trace v1.45.0 // indirect
+ golang.org/x/sync v0.21.0 // indirect
+ golang.org/x/sys v0.47.0 // indirect
+ golang.org/x/text v0.39.0 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
)
+
+replace github.com/yeqown/memcached => ../..
diff --git a/cmd/memcached-cli/go.sum b/cmd/memcached-cli/go.sum
index d924dca..aec5b83 100644
--- a/cmd/memcached-cli/go.sum
+++ b/cmd/memcached-cli/go.sum
@@ -1,70 +1,108 @@
+charm.land/bubbles/v2 v2.1.1 h1:7r55WzBxpo/R3z98hGmY7KKPd3ET6vsf0Fb9sDHOV60=
+charm.land/bubbles/v2 v2.1.1/go.mod h1:GE6M31gaWZVXzGw73OeuTTgy4lX+OtkH0E5ymnNsHxo=
+charm.land/bubbletea/v2 v2.0.8 h1:SxTJMhCAI3lbPmy4SgX5LWZ24AdINr4I6UEqzZvYJuY=
+charm.land/bubbletea/v2 v2.0.8/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss=
+charm.land/lipgloss/v2 v2.0.4 h1:lcPeVtcp23SNra7lHy8iYE4UC2aIipVQ47sbGyyxR5Q=
+charm.land/lipgloss/v2 v2.0.4/go.mod h1:0653x8epbZSzdDfO/XPS1a/uYPOBeSsCssOpJOqDzik=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
-github.com/c-bata/go-prompt v0.2.6 h1:POP+nrHE+DfLYx370bedwNhsqmpCUynWPxuHi0C5vZI=
-github.com/c-bata/go-prompt v0.2.6/go.mod h1:/LMAke8wD2FsNu9EXNdHxNLbd9MedkPnCdfpU9wwHfY=
+github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
+github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
+github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
+github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
+github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
+github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 h1:3FmWoGNWK4STvqg0O0Aeav2T7rodWJAPeF0QpH+8gFw=
+github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo=
+github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
+github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
+github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
+github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
+github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
+github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
+github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
+github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
+github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
+github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
+github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
+github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
+github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
+github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
-github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
-github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
-github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
+github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
+github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
-github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
-github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
-github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
-github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
-github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
-github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84=
-github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
-github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
-github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
-github.com/mattn/go-runewidth v0.0.6/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
-github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
-github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
-github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
-github.com/mattn/go-tty v0.0.3 h1:5OfyWorkyO7xP52Mq7tB36ajHDG5OHrmBGIS/DtakQI=
-github.com/mattn/go-tty v0.0.3/go.mod h1:ihxohKRERHTVzN+aSVRwACLCeqIoZAWpoICkkvrWyR0=
+github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
+github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
+github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
+github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
+github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
+github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
+github.com/pierrec/lz4/v4 v4.1.28 h1:pPEPwRJ4kybBTfGt28q7lQsRJQHhC08axprdLD5Ppio=
+github.com/pierrec/lz4/v4 v4.1.28/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pkg/term v1.2.0-beta.2 h1:L3y/h2jkuBVFdWiJvNfYfKmzcCnILw7mJWm2JQuMppw=
-github.com/pkg/term v1.2.0-beta.2/go.mod h1:E25nymQcrSllhX42Ok8MRm1+hyBdHY0dCeiKZ9jpNGw=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
-github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
-github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
-github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
-github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
-github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
-github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
+github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
+github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
+github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
+github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
+github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
-github.com/yeqown/log v1.1.1 h1:DKw/ZeyJUTWPh4MDxYKXbBQeKHZJ69lndancmcQoERM=
-github.com/yeqown/log v1.1.1/go.mod h1:RTslXFTg+8Uj5AizIxdfichvBZi/OOKao6yP3tMtTns=
-github.com/yeqown/memcached v1.3.4 h1:v0/ZuojwP3u+3DtXXKjB/NInM8Ob/I3okh/6yQzFAFk=
-github.com/yeqown/memcached v1.3.4/go.mod h1:1NndLxAOAhl9ecmycI8wIv4nIv+fcpAUN0vLlqM7jn4=
-golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200918174421-af09f7315aff/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
-golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
-golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
-golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
+github.com/yeqown/log v1.3.1 h1:Omu/TAw/oMMW4aHBOOHrtEzYDxYE/T1TZVP1H/LqYNI=
+github.com/yeqown/log v1.3.1/go.mod h1:RTslXFTg+8Uj5AizIxdfichvBZi/OOKao6yP3tMtTns=
+go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
+go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
+go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU=
+go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ=
+go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M=
+go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s=
+go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag=
+go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
+golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
+golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
+golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
+golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/cmd/memcached-cli/history.go b/cmd/memcached-cli/history.go
index be58db1..26f997e 100644
--- a/cmd/memcached-cli/history.go
+++ b/cmd/memcached-cli/history.go
@@ -2,6 +2,7 @@ package main
import (
"bufio"
+ "errors"
"fmt"
"os"
"path/filepath"
@@ -18,31 +19,42 @@ type kvCommandHistory struct {
}
type kvCommandHistoryManager struct {
- path string
maxSize int
disabled bool
file *os.File // 保持文件句柄打开
}
+const maxHistoryRecordSize = 2 * 1024 * 1024
+
func newHistoryManager(enabled bool, maxLines int) (*kvCommandHistoryManager, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, err
}
- path := filepath.Join(home, ".memcached-cli", ".history")
- if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
+ historyDir := filepath.Join(home, ".memcached-cli")
+ if err := os.MkdirAll(historyDir, 0o700); err != nil {
return nil, err
}
- // 以追加模式打开文件
- file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_RDWR, 0644)
+ root, err := os.OpenRoot(historyDir)
if err != nil {
return nil, err
}
+ // 以追加模式打开文件
+ file, err := root.OpenFile(".history", os.O_APPEND|os.O_CREATE|os.O_RDWR, 0o600)
+ if err != nil {
+ return nil, errors.Join(err, root.Close())
+ }
+ if err := root.Close(); err != nil {
+ return nil, errors.Join(err, file.Close())
+ }
+ if err := file.Chmod(0o600); err != nil {
+ return nil, errors.Join(err, file.Close())
+ }
+
hm := &kvCommandHistoryManager{
- path: path,
maxSize: maxLines,
disabled: !enabled,
file: file,
@@ -80,10 +92,10 @@ func (hm *kvCommandHistoryManager) addRecord(cmd string, args []string) error {
return hm.file.Sync()
}
-func (hm *kvCommandHistoryManager) search(keyword, since, until string, limit int) []kvCommandHistory {
+func (hm *kvCommandHistoryManager) search(keyword, since, until string, limit int) ([]kvCommandHistory, error) {
if hm == nil || hm.disabled || hm.file == nil {
fmt.Println("History is not available.")
- return []kvCommandHistory{}
+ return []kvCommandHistory{}, nil
}
now := time.Now()
@@ -103,10 +115,11 @@ func (hm *kvCommandHistoryManager) search(keyword, since, until string, limit in
var results []kvCommandHistory
scanner := bufio.NewScanner(hm.file)
+ scanner.Buffer(make([]byte, 64*1024), maxHistoryRecordSize)
// 重置文件指针到开始位置
if _, err := hm.file.Seek(0, 0); err != nil {
- return nil
+ return nil, err
}
logger.Debugf("searching history, keyword=%s, since=%s(%d), until=%s(%d), limit=%d", keyword, since, startTime, until, endTime, limit)
@@ -157,6 +170,10 @@ func (hm *kvCommandHistoryManager) search(keyword, since, until string, limit in
results = append(results, history)
}
+ if err := scanner.Err(); err != nil {
+ return nil, fmt.Errorf("scan history: %w", err)
+ }
+
// 按时间戳倒序排序
sort.Slice(results, func(i, j int) bool {
return results[i].Timestamp > results[j].Timestamp
@@ -167,5 +184,5 @@ func (hm *kvCommandHistoryManager) search(keyword, since, until string, limit in
results = results[:limit]
}
- return results
+ return results, nil
}
diff --git a/cmd/memcached-cli/history_test.go b/cmd/memcached-cli/history_test.go
new file mode 100644
index 0000000..dda7533
--- /dev/null
+++ b/cmd/memcached-cli/history_test.go
@@ -0,0 +1,59 @@
+package main
+
+import (
+ "bytes"
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/spf13/cobra"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestHistorySearchSupportsRecordsLargerThanScannerDefault(t *testing.T) {
+ t.Parallel()
+
+ file, err := os.CreateTemp(t.TempDir(), "history-*")
+ require.NoError(t, err)
+ t.Cleanup(func() { require.NoError(t, file.Close()) })
+
+ value := strings.Repeat("x", 128*1024)
+ _, err = file.WriteString("1 set key " + value + "\n")
+ require.NoError(t, err)
+
+ manager := &kvCommandHistoryManager{file: file}
+ results, err := manager.search("", "", "", 10)
+
+ require.NoError(t, err)
+ require.Len(t, results, 1)
+ assert.Equal(t, "set", results[0].Command)
+ assert.Equal(t, "key "+value, results[0].Args)
+}
+
+func TestHistorySearchReportsReadErrors(t *testing.T) {
+ t.Parallel()
+
+ file, err := os.CreateTemp(t.TempDir(), "history-*")
+ require.NoError(t, err)
+ require.NoError(t, file.Close())
+
+ manager := &kvCommandHistoryManager{file: file}
+ _, err = manager.search("", "", "", 10)
+ assert.Error(t, err)
+}
+
+func TestRecordHistoryWarnsWithoutFailingCommand(t *testing.T) {
+ t.Parallel()
+
+ file, err := os.CreateTemp(t.TempDir(), "history-*")
+ require.NoError(t, err)
+ require.NoError(t, file.Close())
+
+ var stderr bytes.Buffer
+ cmd := &cobra.Command{}
+ cmd.SetErr(&stderr)
+ recordHistory(cmd, &kvCommandHistoryManager{file: file}, "set", []string{"key", "value"})
+
+ assert.Contains(t, stderr.String(), "Warning: failed to record set command in history")
+}
diff --git a/cmd/memcached-cli/main.go b/cmd/memcached-cli/main.go
index ac570ca..93c1ecd 100644
--- a/cmd/memcached-cli/main.go
+++ b/cmd/memcached-cli/main.go
@@ -2,14 +2,13 @@ package main
import (
"context"
+ "errors"
"fmt"
"os"
"strings"
"time"
"github.com/MakeNowJust/heredoc"
- prompt "github.com/c-bata/go-prompt"
- "github.com/pkg/errors"
"github.com/spf13/cobra"
"github.com/yeqown/log"
)
@@ -18,9 +17,7 @@ const (
version = "v1.3.1"
)
-var (
- logger = newLogger()
-)
+var logger = newLogger()
func main() {
var (
@@ -73,7 +70,7 @@ func main() {
}
}
-func runAsREPL(timeout time.Duration, servers, hashStrategy string) error {
+func runAsREPL(timeout time.Duration, servers, hashStrategy string) (retErr error) {
fmt.Println(heredoc.Doc(`
Welcome to memcached-cli
Type 'help' to see available commands
@@ -85,13 +82,22 @@ func runAsREPL(timeout time.Duration, servers, hashStrategy string) error {
manager, err := newContextManager()
if err != nil {
- return errors.Wrap(err, "failed to create context manager")
+ return fmt.Errorf("failed to create context manager: %w", err)
}
+ defer func() {
+ var historyErr error
+ if history := manager.getHistoryManager(); history != nil {
+ historyErr = history.close()
+ }
+ retErr = errors.Join(retErr, historyErr, manager.close())
+ }()
// if servers are not empty, create a temporary context
if servers = strings.TrimSpace(servers); servers != "" {
logger.Debugf("adding servers: %v to temporary context as 'temporary'", servers)
- manager.addTemporaryContext(servers, hashStrategy)
+ if err := manager.addTemporaryContext(servers, hashStrategy); err != nil {
+ return fmt.Errorf("failed to switch to temporary context: %w", err)
+ }
}
contexts := manager.listContexts()
@@ -103,9 +109,9 @@ func runAsREPL(timeout time.Duration, servers, hashStrategy string) error {
`))
}
- current, err := manager.getCurrentContext()
- if err != nil {
- return errors.Wrap(err, "failed to get current context")
+ var current *Context
+ if len(contexts) > 0 {
+ current, _ = manager.getCurrentContext()
}
if len(contexts) > 0 && current == nil {
@@ -127,16 +133,7 @@ func runAsREPL(timeout time.Duration, servers, hashStrategy string) error {
return err
}
- p := prompt.New(
- repl.commandExecutor,
- repl.commandCompleter,
- prompt.OptionTitle("memcached-cli"),
- prompt.OptionPrefix(">>> "),
- prompt.OptionInputTextColor(prompt.Yellow),
- )
-
- p.Run()
- return nil
+ return runREPLTUI(repl)
}
func newVersionCommand() *cobra.Command {
@@ -180,7 +177,6 @@ func newContextCommand() *cobra.Command {
}
func newKVCommand() *cobra.Command {
-
var contextName string
cmd := &cobra.Command{
@@ -256,7 +252,10 @@ func newHistoryCommand() *cobra.Command {
},
RunE: func(cmd *cobra.Command, args []string) error {
history := getContextManager(cmd, false).getHistoryManager()
- results := history.search(keyword, since, until, limit)
+ results, err := history.search(keyword, since, until, limit)
+ if err != nil {
+ return fmt.Errorf("search history: %w", err)
+ }
if len(results) == 0 {
fmt.Println("No history found.")
diff --git a/cmd/memcached-cli/repl.go b/cmd/memcached-cli/repl.go
index f74891e..ac2c98d 100644
--- a/cmd/memcached-cli/repl.go
+++ b/cmd/memcached-cli/repl.go
@@ -1,21 +1,21 @@
package main
import (
+ "bytes"
"context"
"fmt"
- "os"
+ "io"
"strconv"
"strings"
"time"
- "github.com/c-bata/go-prompt"
- "github.com/pkg/errors"
"github.com/yeqown/memcached"
)
type replCommander struct {
cm *contextManager
timeout time.Duration
+ out io.Writer
}
func newREPLCommander(manager *contextManager, timeout time.Duration) (*replCommander, error) {
@@ -26,45 +26,53 @@ func newREPLCommander(manager *contextManager, timeout time.Duration) (*replComm
return &replCommander{
cm: manager,
timeout: timeout,
+ out: io.Discard,
}, nil
}
-func (r *replCommander) commandCompleter(d prompt.Document) []prompt.Suggest {
- suggestions := []prompt.Suggest{
- // context operations
- {Text: "use", Description: "Switch to a different context"},
- {Text: "list", Description: "List all contexts"},
- {Text: "current", Description: "Show current context"},
- // key-value operations
- {Text: "get", Description: "Get value by key"},
- {Text: "gets", Description: "Get multiple values by keys"},
- {Text: "set", Description: "Set key to value"},
- {Text: "delete", Description: "Delete key"},
- {Text: "incr", Description: "Increment value"},
- {Text: "decr", Description: "Decrement value"},
- {Text: "touch", Description: "Update expiration time"},
- // other
- {Text: "version", Description: "Show version information"},
- {Text: "help", Description: "Show help message"},
- {Text: "exit", Description: "Exit the program"},
- {Text: "quit", Description: "Exit the program"},
- }
-
- sub := d.GetWordBeforeCursor()
-
- if sub == "" {
- return []prompt.Suggest{}
- }
-
- return prompt.FilterHasPrefix(suggestions, sub, true)
+var replCommands = []string{
+ "use", "list", "current",
+ "get", "gets", "set", "delete", "incr", "decr", "touch",
+ "version", "help", "exit", "quit",
}
-func (r *replCommander) commandExecutor(line string) {
+// completeREPLCommands returns command suggestions matching the first-token prefix.
+func completeREPLCommands(line string) []string {
+ fields := strings.Fields(line)
+ if len(fields) == 0 || strings.HasSuffix(line, " ") {
+ return nil
+ }
+ if len(fields) != 1 {
+ return nil
+ }
+
+ prefix := strings.ToLower(fields[0])
+ matches := make([]string, 0, len(replCommands))
+ for _, cmd := range replCommands {
+ if strings.HasPrefix(cmd, prefix) {
+ matches = append(matches, cmd)
+ }
+ }
+ return matches
+}
+
+type commandResult struct {
+ output string
+ quit bool
+}
+
+// executeCommand runs a REPL command and captures its output.
+func (r *replCommander) executeCommand(line string) commandResult {
line = strings.TrimSpace(line)
if line == "" {
- return
+ return commandResult{}
}
+ var buf bytes.Buffer
+ prev := r.out
+ r.out = &buf
+ defer func() { r.out = prev }()
+
args := strings.Fields(line)
cmd := args[0]
@@ -72,6 +80,7 @@ func (r *replCommander) commandExecutor(line string) {
ctx, cancel := context.WithTimeout(context.Background(), r.timeout)
defer cancel()
+ quit := false
switch cmd {
case "use":
err = r.handleUse(ctx, args)
@@ -79,7 +88,6 @@ func (r *replCommander) commandExecutor(line string) {
err = r.handleList(ctx)
case "current":
err = r.handleCurrent(ctx)
-
case "get":
err = r.handleGet(ctx, args)
case "gets":
@@ -94,36 +102,41 @@ func (r *replCommander) commandExecutor(line string) {
err = r.handleDecr(ctx, args)
case "touch":
err = r.handleTouch(ctx, args)
-
case "version":
err = r.handleVersion(ctx)
case "help":
- r.handleHelp()
+ err = r.handleHelp()
case "exit", "quit":
- r.handleExit()
+ err = r.handleExit()
+ quit = true
default:
- fmt.Printf("Unknown command: %s\n", cmd)
+ err = writeOutput(r.out, "Unknown command: %s\n", cmd)
}
if err != nil {
- fmt.Printf("Execution `%s` failed: %v\n", cmd, err)
+ if writeErr := writeOutput(r.out, "Execution `%s` failed: %v\n", cmd, err); writeErr != nil {
+ return commandResult{
+ output: fmt.Sprintf("Execution `%s` failed: %v (writing output failed: %v)\n", cmd, err, writeErr),
+ quit: quit,
+ }
+ }
}
-}
-func (r *replCommander) getMemcachedClient() memcached.Client {
- client, err := r.cm.getCurrentClient()
- if err != nil {
- panic(err)
+ return commandResult{
+ output: buf.String(),
+ quit: quit,
}
+}
- return client
+func (r *replCommander) getMemcachedClient() (memcached.Client, error) {
+ return r.cm.getCurrentClient()
}
/**
* context operations
*/
-func (r *replCommander) handleUse(ctx context.Context, args []string) error {
+func (r *replCommander) handleUse(_ context.Context, args []string) error {
if len(args) != 2 {
return fmt.Errorf("usage: use ")
}
@@ -134,9 +147,13 @@ func (r *replCommander) handleList(_ context.Context) error {
ctx, _ := r.cm.getCurrentContext()
for _, name := range r.cm.listContexts() {
if ctx != nil && ctx.Name == name {
- fmt.Printf("* %s\n", name)
+ if err := writeOutput(r.out, "* %s\n", name); err != nil {
+ return err
+ }
} else {
- fmt.Printf(" %s\n", name)
+ if err := writeOutput(r.out, " %s\n", name); err != nil {
+ return err
+ }
}
}
return nil
@@ -147,9 +164,7 @@ func (r *replCommander) handleCurrent(_ context.Context) error {
if err != nil {
return err
}
- fmt.Printf("Current context: %s\n", ctx.Name)
- fmt.Printf("Servers: %s\n", ctx.Servers)
- return nil
+ return writeOutput(r.out, "Current context: %s\nServers: %s\n", ctx.Name, ctx.Servers)
}
/**
@@ -161,7 +176,11 @@ func (r *replCommander) handleGet(ctx context.Context, args []string) error {
return fmt.Errorf("usage: get ")
}
- item, err := r.getMemcachedClient().MetaGet(
+ client, err := r.getMemcachedClient()
+ if err != nil {
+ return err
+ }
+ item, err := client.MetaGet(
ctx,
[]byte(args[1]),
memcached.MetaGetFlagReturnTTL(),
@@ -174,10 +193,9 @@ func (r *replCommander) handleGet(ctx context.Context, args []string) error {
memcached.MetaGetFlagReturnHitBefore(),
)
if err != nil {
- return ignoreMemcachedError(err)
+ return ignoreMemcachedError(r.out, err)
}
- printMetaItem(item)
- return nil
+ return writeMetaItem(r.out, item)
}
func (r *replCommander) handleSet(ctx context.Context, args []string) error {
@@ -192,11 +210,15 @@ func (r *replCommander) handleSet(ctx context.Context, args []string) error {
}
}
- if err := r.getMemcachedClient().Set(ctx, args[1], []byte(args[2]), magicFlags, expiration); err != nil {
- return ignoreMemcachedError(err)
+ client, err := r.getMemcachedClient()
+ if err != nil {
+ return err
}
- fmt.Println("OK")
- return nil
+ if err := client.Set(ctx, args[1], []byte(args[2]), magicFlags, expiration); err != nil {
+ return ignoreMemcachedError(r.out, err)
+ }
+ _, err = fmt.Fprintln(r.out, "OK")
+ return err
}
func (r *replCommander) handleDelete(ctx context.Context, args []string) error {
@@ -204,11 +226,15 @@ func (r *replCommander) handleDelete(ctx context.Context, args []string) error {
return fmt.Errorf("usage: delete ")
}
- if err := r.getMemcachedClient().Delete(ctx, args[1]); err != nil {
- return ignoreMemcachedError(err)
+ client, err := r.getMemcachedClient()
+ if err != nil {
+ return err
}
- fmt.Println("OK")
- return nil
+ if err := client.Delete(ctx, args[1]); err != nil {
+ return ignoreMemcachedError(r.out, err)
+ }
+ _, err = fmt.Fprintln(r.out, "OK")
+ return err
}
func (r *replCommander) handleIncr(ctx context.Context, args []string) error {
@@ -222,12 +248,15 @@ func (r *replCommander) handleIncr(ctx context.Context, args []string) error {
delta = d
}
}
- newValue, err := r.getMemcachedClient().Incr(ctx, args[1], delta)
+ client, err := r.getMemcachedClient()
if err != nil {
- return ignoreMemcachedError(err)
+ return err
}
- fmt.Printf("%d\n", newValue)
- return nil
+ newValue, err := client.Incr(ctx, args[1], delta)
+ if err != nil {
+ return ignoreMemcachedError(r.out, err)
+ }
+ return writeOutput(r.out, "%d\n", newValue)
}
func (r *replCommander) handleDecr(ctx context.Context, args []string) error {
@@ -241,12 +270,15 @@ func (r *replCommander) handleDecr(ctx context.Context, args []string) error {
delta = d
}
}
- newValue, err := r.getMemcachedClient().Decr(ctx, args[1], delta)
+ client, err := r.getMemcachedClient()
+ if err != nil {
+ return err
+ }
+ newValue, err := client.Decr(ctx, args[1], delta)
if err != nil {
- return ignoreMemcachedError(err)
+ return ignoreMemcachedError(r.out, err)
}
- fmt.Printf("%d\n", newValue)
- return nil
+ return writeOutput(r.out, "%d\n", newValue)
}
func (r *replCommander) handleTouch(ctx context.Context, args []string) error {
@@ -258,24 +290,31 @@ func (r *replCommander) handleTouch(ctx context.Context, args []string) error {
if err != nil {
return fmt.Errorf("invalid expiration format: %v", err)
}
- if err := r.getMemcachedClient().Touch(ctx, args[1], time.Duration(expiration)*time.Second); err != nil {
- return ignoreMemcachedError(err)
+ client, err := r.getMemcachedClient()
+ if err != nil {
+ return err
}
- fmt.Println("OK")
- return nil
+ if err := client.Touch(ctx, args[1], time.Duration(expiration)*time.Second); err != nil {
+ return ignoreMemcachedError(r.out, err)
+ }
+ return writeOutput(r.out, "OK\n")
}
func (r *replCommander) handleMGet(ctx context.Context, args []string) error {
if len(args) < 2 {
- return fmt.Errorf("usage: mget [key2 ...]")
+ return fmt.Errorf("usage: gets [key2 ...]")
}
keys := make([]string, len(args)-1)
copy(keys, args[1:])
items := make([]*memcached.MetaItem, 0, len(keys))
+ client, err := r.getMemcachedClient()
+ if err != nil {
+ return err
+ }
for _, key := range keys {
- item, err := r.getMemcachedClient().MetaGet(
+ item, err := client.MetaGet(
ctx,
[]byte(key),
memcached.MetaGetFlagReturnTTL(),
@@ -288,16 +327,16 @@ func (r *replCommander) handleMGet(ctx context.Context, args []string) error {
memcached.MetaGetFlagReturnHitBefore(),
)
if err != nil {
- fmt.Printf("Encounter error while getting key '%s': %v\n", key, errors.Cause(err))
+ if err := writeOutput(r.out, "Encounter error while getting key '%s': %v\n", key, rootErr(err)); err != nil {
+ return err
+ }
continue
}
items = append(items, item)
}
- printMetaItems(items)
-
- return nil
+ return writeMetaItems(r.out, items)
}
/**
@@ -305,29 +344,31 @@ func (r *replCommander) handleMGet(ctx context.Context, args []string) error {
*/
func (r *replCommander) handleVersion(_ context.Context) error {
- fmt.Printf("Version: %s\n", version)
- return nil
+ return writeOutput(r.out, "Version: %s\n", version)
+}
+
+func (r *replCommander) handleHelp() error {
+ return writeOutput(r.out, `Available commands:
+ use Switch to a different context
+ list List all contexts
+ current Show current context
+ get Get value by key
+ gets Get multiple values by keys
+ set Set key to value
+ delete Delete key
+ incr [delta] Increment value
+ decr [delta] Decrement value
+ touch Update expiration time
+ help Show this help message
+ exit, quit Exit the program
+`)
}
-func (r *replCommander) handleHelp() {
- fmt.Println("Available commands:")
- fmt.Println(" use Switch to a different context")
- fmt.Println(" list List all contexts")
- fmt.Println(" current Show current context")
-
- fmt.Println(" get Get value by key")
- fmt.Println(" gets Get multiple values by keys")
- fmt.Println(" set Set key to value")
- fmt.Println(" delete Delete key")
- fmt.Println(" incr [delta] Increment value")
- fmt.Println(" decr [delta] Decrement value")
- fmt.Println(" touch Update expiration time")
-
- fmt.Println(" help Show this help message")
- fmt.Println(" exit, quit Exit the program")
+func (r *replCommander) handleExit() error {
+ return writeOutput(r.out, "Bye!\n")
}
-func (r *replCommander) handleExit() {
- fmt.Println("Bye!")
- os.Exit(0)
+func writeOutput(w io.Writer, format string, args ...any) error {
+ _, err := fmt.Fprintf(w, format, args...)
+ return err
}
diff --git a/cmd/memcached-cli/repl_test.go b/cmd/memcached-cli/repl_test.go
new file mode 100644
index 0000000..c836290
--- /dev/null
+++ b/cmd/memcached-cli/repl_test.go
@@ -0,0 +1,220 @@
+package main
+
+import (
+ "bytes"
+ "fmt"
+ "testing"
+
+ tea "charm.land/bubbletea/v2"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "github.com/yeqown/memcached"
+)
+
+func TestCompleteREPLCommands(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ line string
+ want []string
+ }{
+ {name: "empty", line: "", want: nil},
+ {name: "whitespace", line: " ", want: nil},
+ {name: "known prefix", line: "ge", want: []string{"get", "gets"}},
+ {name: "exact command", line: "help", want: []string{"help"}},
+ {name: "unknown prefix", line: "zzz", want: []string{}},
+ {name: "after first token", line: "get foo", want: nil},
+ {name: "trailing space", line: "get ", want: nil},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ got := completeREPLCommands(tt.line)
+ if tt.want == nil {
+ assert.Nil(t, got)
+ return
+ }
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func TestExecuteCommandHelpAndQuit(t *testing.T) {
+ t.Parallel()
+
+ repl, err := newREPLCommander(nil, 0)
+ require.NoError(t, err)
+
+ help := repl.executeCommand("help")
+ assert.False(t, help.quit)
+ assert.Contains(t, help.output, "Available commands:")
+ assert.Contains(t, help.output, "exit, quit")
+
+ quit := repl.executeCommand("exit")
+ assert.True(t, quit.quit)
+ assert.Contains(t, quit.output, "Bye!")
+}
+
+func TestExecuteCommandEmptyAndUnknown(t *testing.T) {
+ t.Parallel()
+
+ repl, err := newREPLCommander(nil, 0)
+ require.NoError(t, err)
+
+ empty := repl.executeCommand(" ")
+ assert.False(t, empty.quit)
+ assert.Empty(t, empty.output)
+
+ unknown := repl.executeCommand("nope")
+ assert.False(t, unknown.quit)
+ assert.Contains(t, unknown.output, "Unknown command: nope")
+}
+
+func TestExecuteCommandWithoutContextReturnsError(t *testing.T) {
+ t.Parallel()
+
+ repl, err := newREPLCommander(&contextManager{contexts: map[string]*Context{}}, 0)
+ require.NoError(t, err)
+
+ result := repl.executeCommand("get missing")
+ assert.False(t, result.quit)
+ assert.Contains(t, result.output, "Execution `get` failed: no context selected")
+}
+
+func TestIgnoreMemcachedErrorWritesToProvidedWriter(t *testing.T) {
+ t.Parallel()
+
+ var output bytes.Buffer
+ err := ignoreMemcachedError(&output, fmt.Errorf("request failed: %w", memcached.ErrNotFound))
+
+ require.NoError(t, err)
+ assert.Contains(t, output.String(), "Memcached Error:")
+ assert.Contains(t, output.String(), memcached.ErrNotFound.Error())
+}
+
+func TestREPLModelEnterRunsCommand(t *testing.T) {
+ t.Parallel()
+
+ repl, err := newREPLCommander(nil, 0)
+ require.NoError(t, err)
+ m := newREPLModel(repl)
+ m.input.SetValue("help")
+
+ next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+ require.NotNil(t, cmd)
+ updated := next.(replModel)
+ assert.True(t, updated.busy)
+
+ msg := cmd()
+ result, ok := msg.(commandResultMsg)
+ require.True(t, ok)
+ assert.Contains(t, result.output, "Available commands:")
+ assert.False(t, result.quit)
+
+ next, cmd = updated.Update(result)
+ updated = next.(replModel)
+ assert.False(t, updated.busy)
+ assert.Empty(t, updated.input.Value())
+ require.NotNil(t, cmd)
+}
+
+func TestREPLModelHistoryNavigation(t *testing.T) {
+ t.Parallel()
+
+ repl, err := newREPLCommander(nil, 0)
+ require.NoError(t, err)
+ m := newREPLModel(repl)
+ m.appendHistory("help")
+ m.appendHistory("version")
+ m.input.SetValue("draft")
+
+ m.historyUp()
+ assert.Equal(t, "version", m.input.Value())
+ m.historyUp()
+ assert.Equal(t, "help", m.input.Value())
+ m.historyDown()
+ assert.Equal(t, "version", m.input.Value())
+ m.historyDown()
+ assert.Equal(t, "draft", m.input.Value())
+ assert.Equal(t, -1, m.histIdx)
+}
+
+func TestREPLModelCtrlCQuits(t *testing.T) {
+ t.Parallel()
+
+ repl, err := newREPLCommander(nil, 0)
+ require.NoError(t, err)
+ m := newREPLModel(repl)
+
+ _, cmd := m.Update(tea.KeyPressMsg{
+ Code: 'c',
+ Mod: tea.ModCtrl,
+ })
+ require.NotNil(t, cmd)
+}
+
+func TestREPLModelCtrlDQuitsWhenEmpty(t *testing.T) {
+ t.Parallel()
+
+ repl, err := newREPLCommander(nil, 0)
+ require.NoError(t, err)
+ m := newREPLModel(repl)
+
+ _, cmd := m.Update(tea.KeyPressMsg{
+ Code: 'd',
+ Mod: tea.ModCtrl,
+ })
+ require.NotNil(t, cmd)
+}
+
+func TestREPLModelBusyIgnoresTyping(t *testing.T) {
+ t.Parallel()
+
+ repl, err := newREPLCommander(nil, 0)
+ require.NoError(t, err)
+ m := newREPLModel(repl)
+ m.busy = true
+ m.input.SetValue("help")
+
+ next, cmd := m.Update(tea.KeyPressMsg{Code: 'a', Text: "a"})
+ updated := next.(replModel)
+ assert.True(t, updated.busy)
+ assert.Equal(t, "help", updated.input.Value())
+ assert.Nil(t, cmd)
+}
+
+func TestREPLModelExitCommandQuits(t *testing.T) {
+ t.Parallel()
+
+ repl, err := newREPLCommander(nil, 0)
+ require.NoError(t, err)
+ m := newREPLModel(repl)
+ m.input.SetValue("quit")
+
+ next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+ updated := next.(replModel)
+ require.NotNil(t, cmd)
+ assert.True(t, updated.busy)
+
+ result := cmd().(commandResultMsg)
+ assert.True(t, result.quit)
+ assert.Contains(t, result.output, "Bye!")
+
+ _, cmd = updated.Update(result)
+ require.NotNil(t, cmd)
+}
+
+func TestREPLModelViewBusy(t *testing.T) {
+ t.Parallel()
+
+ repl, err := newREPLCommander(nil, 0)
+ require.NoError(t, err)
+ m := newREPLModel(repl)
+ m.busy = true
+
+ view := m.View()
+ assert.Contains(t, view.Content, "running")
+ assert.False(t, view.AltScreen)
+}
diff --git a/cmd/memcached-cli/repl_tui.go b/cmd/memcached-cli/repl_tui.go
new file mode 100644
index 0000000..65596da
--- /dev/null
+++ b/cmd/memcached-cli/repl_tui.go
@@ -0,0 +1,175 @@
+package main
+
+import (
+ "strings"
+
+ "charm.land/bubbles/v2/textinput"
+ tea "charm.land/bubbletea/v2"
+)
+
+type commandResultMsg commandResult
+
+type replModel struct {
+ repl *replCommander
+ input textinput.Model
+ busy bool
+ history []string
+ histIdx int // -1 means browsing is inactive
+ draft string
+ width int
+}
+
+func newREPLModel(repl *replCommander) replModel {
+ ti := textinput.New()
+ ti.Prompt = ">>> "
+ ti.ShowSuggestions = true
+ ti.SetSuggestions(replCommands)
+ ti.SetVirtualCursor(false)
+ ti.Focus()
+ ti.CharLimit = 0
+ ti.SetWidth(80)
+
+ return replModel{
+ repl: repl,
+ input: ti,
+ histIdx: -1,
+ width: 80,
+ }
+}
+
+func runREPLTUI(repl *replCommander) error {
+ p := tea.NewProgram(newREPLModel(repl))
+ _, err := p.Run()
+ return err
+}
+
+func (m replModel) Init() tea.Cmd {
+ return textinput.Blink
+}
+
+func (m replModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ m.width = msg.Width
+ if m.width > 4 {
+ m.input.SetWidth(m.width - 4)
+ }
+ return m, nil
+
+ case commandResultMsg:
+ m.busy = false
+ m.input.SetValue("")
+ m.input.Focus()
+ cmds := make([]tea.Cmd, 0, 2)
+ if msg.output != "" {
+ cmds = append(cmds, tea.Println(strings.TrimRight(msg.output, "\n")))
+ }
+ if msg.quit {
+ cmds = append(cmds, tea.Quit)
+ return m, tea.Batch(cmds...)
+ }
+ return m, tea.Batch(cmds...)
+
+ case tea.KeyPressMsg:
+ if m.busy {
+ if msg.String() == "ctrl+c" {
+ return m, tea.Batch(tea.Println("Bye!"), tea.Quit)
+ }
+ return m, nil
+ }
+
+ switch msg.String() {
+ case "ctrl+c":
+ return m, tea.Batch(tea.Println("Bye!"), tea.Quit)
+ case "ctrl+d":
+ if m.input.Value() == "" {
+ return m, tea.Batch(tea.Println("Bye!"), tea.Quit)
+ }
+ case "enter":
+ line := strings.TrimSpace(m.input.Value())
+ if line == "" {
+ return m, nil
+ }
+ m.appendHistory(line)
+ m.busy = true
+ m.input.Blur()
+ return m, m.runCommand(line)
+ case "up":
+ m.historyUp()
+ return m, nil
+ case "down":
+ m.historyDown()
+ return m, nil
+ }
+ }
+
+ if m.busy {
+ return m, nil
+ }
+
+ var cmd tea.Cmd
+ m.input, cmd = m.input.Update(msg)
+ return m, cmd
+}
+
+func (m replModel) View() tea.View {
+ content := m.input.View()
+ if m.busy {
+ content = "… running"
+ }
+
+ v := tea.NewView(content)
+ if !m.busy {
+ if c := m.input.Cursor(); c != nil {
+ v.Cursor = c
+ }
+ }
+ return v
+}
+
+func (m *replModel) appendHistory(line string) {
+ if n := len(m.history); n > 0 && m.history[n-1] == line {
+ m.histIdx = -1
+ m.draft = ""
+ return
+ }
+ m.history = append(m.history, line)
+ m.histIdx = -1
+ m.draft = ""
+}
+
+func (m *replModel) historyUp() {
+ if len(m.history) == 0 {
+ return
+ }
+ if m.histIdx == -1 {
+ m.draft = m.input.Value()
+ m.histIdx = len(m.history) - 1
+ } else if m.histIdx > 0 {
+ m.histIdx--
+ }
+ m.input.SetValue(m.history[m.histIdx])
+ m.input.CursorEnd()
+}
+
+func (m *replModel) historyDown() {
+ if m.histIdx == -1 {
+ return
+ }
+ if m.histIdx < len(m.history)-1 {
+ m.histIdx++
+ m.input.SetValue(m.history[m.histIdx])
+ m.input.CursorEnd()
+ return
+ }
+ m.histIdx = -1
+ m.input.SetValue(m.draft)
+ m.input.CursorEnd()
+}
+
+func (m replModel) runCommand(line string) tea.Cmd {
+ repl := m.repl
+ return func() tea.Msg {
+ return commandResultMsg(repl.executeCommand(line))
+ }
+}
diff --git a/conn.go b/conn.go
index f5fc7d9..15e8cbc 100644
--- a/conn.go
+++ b/conn.go
@@ -3,14 +3,14 @@ package memcached
import (
"bufio"
"context"
+ "errors"
+ "fmt"
"io"
"net"
"strconv"
"sync"
"sync/atomic"
"time"
-
- "github.com/pkg/errors"
)
type nowFuncType func() time.Time
@@ -121,7 +121,7 @@ type conn struct {
func newConnContext(ctx context.Context, addr *Addr, dialTimeout time.Duration) (*conn, error) {
rawConn, err := addr.dial(ctx, dialTimeout)
if err != nil {
- return nil, errors.Wrap(err, "dialContext")
+ return nil, fmt.Errorf("dialContext: %w", err)
}
cn := &conn{
@@ -244,6 +244,8 @@ func (c *conn) release() error {
return c.pool.put(c)
}
+var errPoolClosed = errors.New("connection pool is closed")
+
// The connPool holds a pool of connections to one memcached server instance
// and provides a way to get a connection from the pool.
//
@@ -268,7 +270,10 @@ type connPool struct {
numOpen atomic.Int32
// Indicate if the pool is closed, if true, no new connections will be created
// and all existing connections will be closed.
- closed bool
+ closed bool
+ // closedCh is closed exactly once when the pool closes, so waiting get()
+ // callers can abort without receiving from a closed idle channel.
+ closedCh chan struct{}
cleanerCh chan struct{}
maxIdleClosed int64 // the number of connections closed due to maxIdle
@@ -292,6 +297,7 @@ func newConnPool(
createConn: createConn,
numOpen: atomic.Int32{},
closed: false,
+ closedCh: make(chan struct{}),
cleanerCh: nil, // created when needed in startCleaner
maxIdleClosed: 0,
@@ -310,11 +316,26 @@ func (p *connPool) close() error {
}
p.closed = true
- // close all existing connections, so that the get() couldn't
- // create a new connection.
- close(p.conns)
+ close(p.closedCh)
+
+ // Drain idle connections without closing the idle channel itself.
+ // Closing the channel would race with lock-free get() receives.
+ for {
+ select {
+ case cn := <-p.conns:
+ _ = cn.Close()
+ p.numOpen.Add(-1)
+ default:
+ goto drained
+ }
+ }
+drained:
+
if p.cleanerCh != nil {
- p.cleanerCh <- struct{}{}
+ select {
+ case p.cleanerCh <- struct{}{}:
+ default:
+ }
}
p.mu.Unlock()
@@ -322,39 +343,48 @@ func (p *connPool) close() error {
}
func (p *connPool) get(ctx context.Context) (memcachedConn, error) {
- if p.closed {
- return nil, errors.New("connection pool is closed")
- }
-
- // try to get a connection from the pool first if there is any
+ // Try to get an idle connection first if there is any,
// otherwise create a new connection.
select {
case cn := <-p.conns:
return cn, nil
default:
- p.mu.Lock()
- // no available connection, check if we can create a new one.
- if int(p.numOpen.Load()) >= p.maxConns {
- p.mu.Unlock()
- // the pool is full, wait for a connection to be returned
- select {
- case cn := <-p.conns:
- return cn, nil
- case <-ctx.Done():
- return nil, ctx.Err()
- }
- }
- p.mu.Unlock()
+ }
- cn, err := p.createConn(ctx)
- if err != nil {
- return nil, err
+ p.mu.Lock()
+ if p.closed {
+ p.mu.Unlock()
+ return nil, errPoolClosed
+ }
+ // No available connection, check if we can create a new one.
+ if int(p.numOpen.Load()) >= p.maxConns {
+ p.mu.Unlock()
+ // The pool is full, wait for a connection to be returned or for close.
+ select {
+ case cn := <-p.conns:
+ return cn, nil
+ case <-p.closedCh:
+ return nil, errPoolClosed
+ case <-ctx.Done():
+ return nil, ctx.Err()
}
- cn.setConnPool(p)
- p.numOpen.Add(1)
+ }
+ p.mu.Unlock()
- return cn, nil
+ cn, err := p.createConn(ctx)
+ if err != nil {
+ return nil, err
}
+ cn.setConnPool(p)
+
+ p.mu.Lock()
+ if p.closed {
+ p.mu.Unlock()
+ return nil, errors.Join(errPoolClosed, cn.Close())
+ }
+ p.numOpen.Add(1)
+ p.mu.Unlock()
+ return cn, nil
}
func (p *connPool) put(cn memcachedConn) error {
@@ -408,7 +438,7 @@ func (p *connPool) connectionsCleaner(d time.Duration) {
for {
select {
case <-t.C:
- case <-p.cleanerCh: // db was closed
+ case <-p.cleanerCh: // pool was closed
}
p.mu.Lock()
@@ -423,7 +453,7 @@ func (p *connPool) connectionsCleaner(d time.Duration) {
return
}
- // make a copy of the connections those need to be closed.
+ // Snapshot idle connections that need to be closed.
d, closing := p.connectionCleanerRunLocked(d)
p.mu.Unlock()
@@ -461,55 +491,65 @@ func (p *connPool) shortestIdleTimeLocked() time.Duration {
//
// 1. if the connection is expired (exceeds maxLifeTime since created).
// 2. if the connection idle time exceeds the idle connection limit(maxIdleTime).
+//
+// The idle channel is kept intact: currently available connections are drained
+// with non-blocking receives, filtered in one pass, and survivors are put back.
func (p *connPool) connectionCleanerRunLocked(d time.Duration) (time.Duration, []memcachedConn) {
var idleClosing int64
closing := make([]memcachedConn, 0, p.maxIdle/2)
- newConns := make(chan memcachedConn, p.maxConns)
-
- if p.maxIdleTime > 0 {
- idleSince := nowFunc().Add(-p.maxIdleTime)
- close(p.conns)
- // TODO(@yeqown): could we optimize the loop here?
- // we re-allocate the newConns channel every time we clean up the connections.
- for c := range p.conns {
- if d2, ok := c.idle(idleSince); !ok {
- if d2 < d {
- // Ensure idle connections are cleaned up as soon
- // as possible.
- d = d2
- }
- newConns <- c // put back
- continue
- }
- closing = append(closing, c)
- idleClosing++
+ kept := make([]memcachedConn, 0, len(p.conns))
+
+ var idleSince, expiredSince time.Time
+ checkIdle := p.maxIdleTime > 0
+ checkLife := p.maxLifeTime > 0
+ if checkIdle {
+ idleSince = nowFunc().Add(-p.maxIdleTime)
+ }
+ if checkLife {
+ expiredSince = nowFunc().Add(-p.maxLifeTime)
+ }
+
+ for {
+ var c memcachedConn
+ select {
+ case c = <-p.conns:
+ default:
+ goto drained
}
- p.conns = newConns
- p.maxIdleTimeClosed += idleClosing
- }
-
- newConns = make(chan memcachedConn, p.maxConns)
- if p.maxLifeTime > 0 {
- expiredSince := nowFunc().Add(-p.maxLifeTime)
- close(p.conns)
- for c := range p.conns {
- if d2, ok := c.expired(expiredSince); !ok {
- if d2 < d {
- // Prevents connections staying in the pool when they
- // have expired.
- d = d2
- }
- newConns <- c // put back
- continue
+ shouldClose := false
+ if checkIdle {
+ if d2, ok := c.idle(idleSince); ok {
+ shouldClose = true
+ idleClosing++
+ } else if d2 < d {
+ // Ensure idle connections are cleaned up as soon as possible.
+ d = d2
+ }
+ }
+ if !shouldClose && checkLife {
+ if d2, ok := c.expired(expiredSince); ok {
+ shouldClose = true
+ } else if d2 < d {
+ // Prevents connections staying in the pool when they have expired.
+ d = d2
}
+ }
+
+ if shouldClose {
closing = append(closing, c)
+ } else {
+ kept = append(kept, c)
}
+ }
- p.conns = newConns
- p.maxLifeTimeClosed += int64(len(closing)) - idleClosing
+drained:
+ for _, c := range kept {
+ p.conns <- c
}
+ p.maxIdleTimeClosed += idleClosing
+ p.maxLifeTimeClosed += int64(len(closing)) - idleClosing
return d, closing
}
diff --git a/conn_test.go b/conn_test.go
index 8db1732..823b83f 100644
--- a/conn_test.go
+++ b/conn_test.go
@@ -2,11 +2,12 @@ package memcached
import (
"context"
+ "errors"
"sync"
+ "sync/atomic"
"testing"
"time"
- "github.com/pkg/errors"
"github.com/stretchr/testify/assert"
)
@@ -21,6 +22,16 @@ type mockConn struct {
pool *connPool
}
+type closeTrackingConn struct {
+ *mockConn
+ closed atomic.Bool
+}
+
+func (m *closeTrackingConn) Close() error {
+ m.closed.Store(true)
+ return nil
+}
+
func newMockConn() *mockConn {
return &mockConn{
createdAt: time.Now(),
@@ -131,14 +142,14 @@ func Test_connPool_new(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
pool := newConnPool(tt.maxIdle, tt.maxConn, tt.maxLifeTime, tt.maxIdleTimeout, createConn)
- // 验证基本属性
assert.NotNil(t, pool)
assert.Equal(t, tt.maxIdle, pool.maxIdle)
assert.Equal(t, tt.maxConn, pool.maxConns)
assert.Equal(t, tt.maxIdleTimeout, pool.maxIdleTime)
- assert.Equal(t, tt.maxConn, cap(pool.conns))
+ assert.Equal(t, 0, pool.stats().IdleConns)
assert.Equal(t, 0, int(pool.numOpen.Load()))
assert.False(t, pool.closed)
+ assert.NotNil(t, pool.closedCh)
assert.Nil(t, pool.cleanerCh)
assert.Equal(t, int64(0), pool.maxIdleClosed)
assert.Equal(t, int64(0), pool.maxIdleTimeClosed)
@@ -172,9 +183,9 @@ func Test_connPool_get_put(t *testing.T) {
wg.Wait()
- // idle connections reached maxConn
+ // idle connections reached maxIdle
assert.Equal(t, 5, int(pool.numOpen.Load()))
- assert.Equal(t, 5, len(pool.conns))
+ assert.Equal(t, 5, pool.stats().IdleConns)
}
// Test_connPool_get_timeout_case1 mocking the case that the createConn function
@@ -271,7 +282,7 @@ func Test_connPool_get_oversize(t *testing.T) {
wg.Wait()
assert.Equal(t, 5, int(pool.numOpen.Load()))
- assert.Equal(t, 5, len(pool.conns))
+ assert.Equal(t, 5, pool.stats().IdleConns)
ctx, cancel = context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel()
@@ -317,7 +328,7 @@ func Test_connPool_cleanup_maxIdle(t *testing.T) {
// connections to 5.
time.Sleep(3 * time.Second)
assert.Equal(t, 0, int(pool.numOpen.Load()))
- assert.Equal(t, 0, len(pool.conns))
+ assert.Equal(t, 0, pool.stats().IdleConns)
assert.False(t, pool.closed)
stat = pool.stats()
@@ -368,16 +379,146 @@ func Test_connPool_cleanup_maxLife(t *testing.T) {
// connections to 5.
time.Sleep(3 * time.Second)
assert.Equal(t, 0, int(pool.numOpen.Load()))
- assert.Equal(t, 0, len(pool.conns))
+ assert.Equal(t, 0, pool.stats().IdleConns)
assert.False(t, pool.closed)
stat = pool.stats()
t.Logf("After cleanup: %+v", stat)
assert.Equal(t, 0, stat.TotalConns)
- assert.Equal(t, 0, stat.IdleConns) // since all connections are cleaned up(idle=1s)
+ assert.Equal(t, 0, stat.IdleConns) // since all connections are cleaned up(life=2s)
assert.Equal(t, 10, stat.MaxConns)
assert.Equal(t, 5, stat.MaxIdle)
assert.Equal(t, int64(5), stat.maxIdleClosed)
assert.Equal(t, int64(5), stat.maxLifeTimeClosed)
assert.Equal(t, int64(0), stat.maxIdleTimeClosed)
}
+
+func Test_connectionCleanerRunLocked_both_limits(t *testing.T) {
+ pool := newConnPool(10, 10, time.Hour, time.Hour, createConn)
+ fixedNow := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+ origNow := nowFunc
+ nowFunc = func() time.Time { return fixedNow }
+ t.Cleanup(func() { nowFunc = origNow })
+
+ idleExpired := newMockConn()
+ idleExpired.createdAt = fixedNow.Add(-30 * time.Minute)
+ idleExpired.returnedAt = fixedNow.Add(-2 * time.Hour)
+
+ lifeExpired := newMockConn()
+ lifeExpired.createdAt = fixedNow.Add(-2 * time.Hour)
+ lifeExpired.returnedAt = fixedNow.Add(-time.Minute)
+
+ fresh := newMockConn()
+ fresh.createdAt = fixedNow.Add(-time.Minute)
+ fresh.returnedAt = fixedNow.Add(-time.Minute)
+
+ pool.conns <- idleExpired
+ pool.conns <- lifeExpired
+ pool.conns <- fresh
+ pool.numOpen.Store(3)
+ connsBefore := pool.conns
+
+ pool.maxIdleTime = time.Hour
+ pool.maxLifeTime = time.Hour
+
+ d, closing := pool.connectionCleanerRunLocked(time.Hour)
+ assert.True(t, connsBefore == pool.conns, "cleaner must keep the idle channel")
+ assert.Len(t, closing, 2)
+ assert.Equal(t, int64(1), pool.maxIdleTimeClosed)
+ assert.Equal(t, int64(1), pool.maxLifeTimeClosed)
+ assert.Equal(t, 1, len(pool.conns))
+ assert.Greater(t, d, time.Duration(0))
+
+ kept := <-pool.conns
+ assert.Same(t, fresh, kept)
+}
+
+func Test_connPool_cleanup_concurrent_get(t *testing.T) {
+ pool := newConnPool(8, 8, time.Hour, 100*time.Millisecond, createConn)
+ ctx := context.Background()
+
+ for i := 0; i < 8; i++ {
+ cn, err := pool.get(ctx)
+ assert.NoError(t, err)
+ assert.NoError(t, pool.put(cn))
+ }
+
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ deadline := time.Now().Add(2 * time.Second)
+ for time.Now().Before(deadline) {
+ cn, err := pool.get(ctx)
+ if err != nil {
+ assert.Fail(t, "get failed during cleanup", err.Error())
+ return
+ }
+ assert.NotNil(t, cn)
+ _ = pool.put(cn)
+ }
+ }()
+
+ <-done
+ assert.NoError(t, pool.close())
+ assert.Equal(t, 0, int(pool.numOpen.Load()))
+}
+
+func Test_connPool_close_wakes_waiters(t *testing.T) {
+ pool := newConnPool(1, 1, time.Hour, time.Hour, createConn)
+ ctx := context.Background()
+
+ cn, err := pool.get(ctx)
+ assert.NoError(t, err)
+ assert.NotNil(t, cn)
+
+ errCh := make(chan error, 1)
+ go func() {
+ _, getErr := pool.get(ctx)
+ errCh <- getErr
+ }()
+
+ time.Sleep(50 * time.Millisecond)
+ assert.NoError(t, pool.close())
+
+ select {
+ case getErr := <-errCh:
+ assert.ErrorIs(t, getErr, errPoolClosed)
+ case <-time.After(time.Second):
+ t.Fatal("waiting get was not woken by close")
+ }
+
+ assert.NoError(t, pool.put(cn))
+ assert.Equal(t, 0, int(pool.numOpen.Load()))
+ assert.Equal(t, 0, pool.stats().IdleConns)
+}
+
+func Test_connPool_get_closes_connection_created_during_close(t *testing.T) {
+ creationStarted := make(chan struct{})
+ releaseCreation := make(chan struct{})
+ createdConn := &closeTrackingConn{mockConn: newMockConn()}
+ pool := newConnPool(1, 1, time.Hour, time.Hour, func(context.Context) (memcachedConn, error) {
+ close(creationStarted)
+ <-releaseCreation
+ return createdConn, nil
+ })
+
+ type getResult struct {
+ conn memcachedConn
+ err error
+ }
+ resultCh := make(chan getResult, 1)
+ go func() {
+ conn, err := pool.get(context.Background())
+ resultCh <- getResult{conn: conn, err: err}
+ }()
+
+ <-creationStarted
+ assert.NoError(t, pool.close())
+ close(releaseCreation)
+
+ result := <-resultCh
+ assert.Nil(t, result.conn)
+ assert.ErrorIs(t, result.err, errPoolClosed)
+ assert.True(t, createdConn.closed.Load())
+ assert.Equal(t, 0, int(pool.numOpen.Load()))
+}
diff --git a/errors.go b/errors.go
index 8dce902..4e59271 100644
--- a/errors.go
+++ b/errors.go
@@ -1,7 +1,7 @@
package memcached
import (
- "github.com/pkg/errors"
+ "errors"
)
var (
diff --git a/errors_test.go b/errors_test.go
new file mode 100644
index 0000000..ea140c0
--- /dev/null
+++ b/errors_test.go
@@ -0,0 +1,27 @@
+package memcached
+
+import (
+ "errors"
+ "fmt"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestWrappedErrorChainPreservesSentinel(t *testing.T) {
+ err := fmt.Errorf("request failed: %w", fmt.Errorf("send failed: %w", ErrNotFound))
+ assert.True(t, errors.Is(err, ErrNotFound))
+ assert.Contains(t, err.Error(), "request failed")
+ assert.Contains(t, err.Error(), "send failed")
+}
+
+func TestErrorsJoinPreservesJoinedSentinels(t *testing.T) {
+ joined := errors.Join(
+ fmt.Errorf("node-a: %w", ErrNotFound),
+ fmt.Errorf("node-b: %w", ErrServerError),
+ )
+ require.Error(t, joined)
+ assert.True(t, errors.Is(joined, ErrNotFound))
+ assert.True(t, errors.Is(joined, ErrServerError))
+}
diff --git a/example/cas.go b/example/cas/main.go
similarity index 100%
rename from example/cas.go
rename to example/cas/main.go
diff --git a/example/cocurrent.go b/example/concurrent/main.go
similarity index 84%
rename from example/cocurrent.go
rename to example/concurrent/main.go
index b8a01ed..35760cb 100644
--- a/example/cocurrent.go
+++ b/example/concurrent/main.go
@@ -28,13 +28,12 @@ func main() {
wg.Add(1)
go func(idx int) {
defer wg.Done()
- defer func() { fmt.Printf("routine(%d) done\n", i) }()
+ defer func() { fmt.Printf("routine(%d) done\n", idx) }()
for counter := 0; counter < limits; counter++ {
key := "example:cas"
// set
- err = client.Set(ctx, key, []byte("value2"), uint32(123), 10*time.Second)
- if err != nil {
+ if err := client.Set(ctx, key, []byte("value2"), uint32(123), 10*time.Second); err != nil {
panic("write: " + err.Error())
}
// get
diff --git a/example/flushall.go b/example/flushall/main.go
similarity index 100%
rename from example/flushall.go
rename to example/flushall/main.go
diff --git a/example/go.mod b/example/go.mod
index c230878..264abcb 100644
--- a/example/go.mod
+++ b/example/go.mod
@@ -1,28 +1,28 @@
module github.com/yeqown/memcached/example
-go 1.26.0
+go 1.26.5
require (
- github.com/yeqown/memcached v1.1.2
- go.opentelemetry.io/otel v1.40.0
- go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0
- go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0
- go.opentelemetry.io/otel/sdk v1.40.0
- go.opentelemetry.io/otel/sdk/metric v1.40.0
+ github.com/yeqown/memcached v1.4.0
+ go.opentelemetry.io/otel v1.45.0
+ go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.45.0
+ go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0
+ go.opentelemetry.io/otel/sdk v1.45.0
+ go.opentelemetry.io/otel/sdk/metric v1.45.0
)
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
- github.com/go-logr/logr v1.4.3 // indirect
+ github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
+ github.com/golang/snappy v1.0.0 // indirect
github.com/google/uuid v1.6.0 // indirect
- github.com/hashicorp/errwrap v1.0.0 // indirect
- github.com/hashicorp/go-multierror v1.1.1 // indirect
- github.com/pkg/errors v0.9.1 // indirect
+ github.com/klauspost/compress v1.19.2 // indirect
+ github.com/pierrec/lz4/v4 v4.1.28 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
- go.opentelemetry.io/otel/metric v1.40.0 // indirect
- go.opentelemetry.io/otel/trace v1.40.0 // indirect
- golang.org/x/sys v0.40.0 // indirect
+ go.opentelemetry.io/otel/metric v1.45.0 // indirect
+ go.opentelemetry.io/otel/trace v1.45.0 // indirect
+ golang.org/x/sys v0.47.0 // indirect
)
replace github.com/yeqown/memcached => ../
diff --git a/example/go.sum b/example/go.sum
index bad0612..987b263 100644
--- a/example/go.sum
+++ b/example/go.sum
@@ -3,43 +3,45 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
-github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
-github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
+github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
+github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
-github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
-github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
-github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
-github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
-github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
+github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
+github.com/pierrec/lz4/v4 v4.1.28 h1:pPEPwRJ4kybBTfGt28q7lQsRJQHhC08axprdLD5Ppio=
+github.com/pierrec/lz4/v4 v4.1.28/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
-go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
-go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
-go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 h1:ZrPRak/kS4xI3AVXy8F7pipuDXmDsrO8Lg+yQjBLjw0=
-go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0/go.mod h1:3y6kQCWztq6hyW8Z9YxQDDm0Je9AJoFar2G0yDcmhRk=
-go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 h1:MzfofMZN8ulNqobCmCAVbqVL5syHw+eB2qPRkCMA/fQ=
-go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0/go.mod h1:E73G9UFtKRXrxhBsHtG00TB5WxX57lpsQzogDkqBTz8=
-go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
-go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
-go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
-go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
-go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
-go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
-go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
-go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
+go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU=
+go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ=
+go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.45.0 h1:dm9iyzn6tioYZtwqaiBSU0TSI8Yu/8dTIbfG0+B49DY=
+go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.45.0/go.mod h1:xAvxYjYK28qvt+yu4BYZ/zMmAjwMXINXD6JiMyeB8iI=
+go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0 h1:lsA/S1bxgdbyFGkTj+3meEdJ6ADVU7QoFstV6MXgE68=
+go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0/go.mod h1:L7u+MirGoB1bjeLH66+xDykF4RC8C3RN7lIFpBiewUo=
+go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M=
+go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s=
+go.opentelemetry.io/otel/metric/x v0.67.0 h1:PcicCNZFkZ4bXfSooXdo3WN7RBOVOtjVdo1wD358Uns=
+go.opentelemetry.io/otel/metric/x v0.67.0/go.mod h1:FBjCWZe6wgcqxcMtjdGiClDKXb2YxxXii0CXftE4QtI=
+go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw=
+go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA=
+go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o=
+go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA=
+go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag=
+go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
-golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
-golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/example/noreply.go b/example/noreply/main.go
similarity index 94%
rename from example/noreply.go
rename to example/noreply/main.go
index 9e06cc7..c9bb946 100644
--- a/example/noreply.go
+++ b/example/noreply/main.go
@@ -14,7 +14,7 @@ func main() {
panic(err)
}
- key := string("noReply")
+ key := "noReply"
value := []byte("noReply")
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Second)
diff --git a/example/options.go b/example/options/main.go
similarity index 83%
rename from example/options.go
rename to example/options/main.go
index 98b6608..2404208 100644
--- a/example/options.go
+++ b/example/options/main.go
@@ -10,9 +10,10 @@ import (
func main() {
addrs := "localhost:11211"
- // Create a new client with the default options.
- client, err := memcached.New(addrs,
- memcached.WithDialTimeout(time.Second*5),
+ // Create a new client with custom timeout options.
+ client, err := memcached.New(
+ addrs,
+ memcached.WithDialTimeout(5*time.Second),
memcached.WithReadTimeout(100*time.Second),
memcached.WithWriteTimeout(100*time.Second),
)
diff --git a/example/otel/main.go b/example/otel/main.go
index b3dc51c..525f155 100644
--- a/example/otel/main.go
+++ b/example/otel/main.go
@@ -35,13 +35,21 @@ func main() {
tp := trace.NewTracerProvider(
trace.WithBatcher(traceExporter),
)
- defer tp.Shutdown(context.Background())
+ defer func() {
+ if err := tp.Shutdown(context.Background()); err != nil {
+ log.Printf("Shutting down tracer provider: %v", err)
+ }
+ }()
// Create meter provider
mp := sdkmetric.NewMeterProvider(
sdkmetric.WithReader(sdkmetric.NewPeriodicReader(metricExporter)),
)
- defer mp.Shutdown(context.Background())
+ defer func() {
+ if err := mp.Shutdown(context.Background()); err != nil {
+ log.Printf("Shutting down meter provider: %v", err)
+ }
+ }()
// Set global providers
otel.SetTracerProvider(tp)
@@ -57,7 +65,11 @@ func main() {
if err != nil {
log.Fatalf("Failed to create client: %v", err)
}
- defer client.Close()
+ defer func() {
+ if err := client.Close(); err != nil {
+ log.Printf("Closing client: %v", err)
+ }
+ }()
ctx := context.Background()
diff --git a/example/simple.go b/example/simple/main.go
similarity index 80%
rename from example/simple.go
rename to example/simple/main.go
index f88018f..480f2d7 100644
--- a/example/simple.go
+++ b/example/simple/main.go
@@ -3,6 +3,7 @@ package main
import (
"context"
"errors"
+ "fmt"
"time"
"github.com/yeqown/memcached"
@@ -22,7 +23,7 @@ func main() {
if err != nil {
panic(err)
}
- println("Version: ", version)
+ fmt.Println("Version:", version)
// get first
item, err := client.Get(ctx, "key")
@@ -31,9 +32,9 @@ func main() {
panic(err)
}
- println("'key' not found")
+ fmt.Println("'key' not found")
} else {
- println("key: ", item.Key, " value: ", string(item.Value))
+ fmt.Println("key:", item.Key, "value:", string(item.Value))
}
if err = client.Set(ctx, "key", []byte("value"), 0, 0); err != nil {
@@ -49,6 +50,6 @@ func main() {
}
for _, item := range items {
- println("key: ", item.Key, " value: ", string(item.Value))
+ fmt.Println("key:", item.Key, "value:", string(item.Value))
}
}
diff --git a/example/udp.go b/example/udp/main.go
similarity index 81%
rename from example/udp.go
rename to example/udp/main.go
index cf0f91c..6328810 100644
--- a/example/udp.go
+++ b/example/udp/main.go
@@ -3,6 +3,7 @@ package main
import (
"context"
"errors"
+ "fmt"
"time"
"github.com/yeqown/memcached"
@@ -22,19 +23,19 @@ func main() {
if err != nil {
panic(err)
}
- println("Version: ", version)
+ fmt.Println("Version:", version)
key := "key:udp"
value := "This is a value for key:udp"
// get first
- item, err := client.Get(ctx, key)
+ _, err = client.Get(ctx, key)
if err != nil {
if !errors.Is(err, memcached.ErrNotFound) {
panic(err)
}
- println("'key' not found")
+ fmt.Println("'key' not found")
}
// set
@@ -43,10 +44,10 @@ func main() {
}
// get again
- item, err = client.Get(ctx, key)
+ item, err := client.Get(ctx, key)
if err != nil {
panic(err)
}
- println("key: ", item.Key, " value: ", string(item.Value))
+ fmt.Println("key:", item.Key, "value:", string(item.Value))
}
diff --git a/go.mod b/go.mod
index d097580..65fd5fb 100644
--- a/go.mod
+++ b/go.mod
@@ -1,210 +1,23 @@
module github.com/yeqown/memcached
-go 1.26.0
+go 1.26.5
require (
- github.com/hashicorp/go-multierror v1.1.1
- github.com/pkg/errors v0.9.1
+ github.com/golang/snappy v1.0.0
+ github.com/klauspost/compress v1.19.2
+ github.com/pierrec/lz4/v4 v4.1.28
github.com/stretchr/testify v1.11.1
- go.opentelemetry.io/otel v1.40.0
- go.opentelemetry.io/otel/metric v1.40.0
- go.opentelemetry.io/otel/trace v1.40.0
+ go.opentelemetry.io/otel v1.45.0
+ go.opentelemetry.io/otel/metric v1.45.0
+ go.opentelemetry.io/otel/trace v1.45.0
)
require (
- 4d63.com/gocheckcompilerdirectives v1.3.0 // indirect
- 4d63.com/gochecknoglobals v0.2.2 // indirect
- github.com/4meepo/tagalign v1.4.2 // indirect
- github.com/Abirdcfly/dupword v0.1.3 // indirect
- github.com/Antonboom/errname v1.0.0 // indirect
- github.com/Antonboom/nilnil v1.0.1 // indirect
- github.com/Antonboom/testifylint v1.5.2 // indirect
- github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect
- github.com/Crocmagnon/fatcontext v0.7.1 // indirect
- github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24 // indirect
- github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.1 // indirect
- github.com/Masterminds/semver/v3 v3.3.0 // indirect
- github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
- github.com/alecthomas/go-check-sumtype v0.3.1 // indirect
- github.com/alexkohler/nakedret/v2 v2.0.5 // indirect
- github.com/alexkohler/prealloc v1.0.0 // indirect
- github.com/alingse/asasalint v0.0.11 // indirect
- github.com/alingse/nilnesserr v0.1.2 // indirect
- github.com/ashanbrown/forbidigo v1.6.0 // indirect
- github.com/ashanbrown/makezero v1.2.0 // indirect
- github.com/beorn7/perks v1.0.1 // indirect
- github.com/bkielbasa/cyclop v1.2.3 // indirect
- github.com/blizzy78/varnamelen v0.8.0 // indirect
- github.com/bombsimon/wsl/v4 v4.5.0 // indirect
- github.com/breml/bidichk v0.3.2 // indirect
- github.com/breml/errchkjson v0.4.0 // indirect
- github.com/butuzov/ireturn v0.3.1 // indirect
- github.com/butuzov/mirror v1.3.0 // indirect
- github.com/catenacyber/perfsprint v0.8.2 // indirect
- github.com/ccojocar/zxcvbn-go v1.0.2 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
- github.com/charithe/durationcheck v0.0.10 // indirect
- github.com/chavacava/garif v0.1.0 // indirect
- github.com/ckaznocha/intrange v0.3.0 // indirect
- github.com/curioswitch/go-reassign v0.3.0 // indirect
- github.com/daixiang0/gci v0.13.5 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
- github.com/denis-tingaikin/go-header v0.5.0 // indirect
- github.com/ettle/strcase v0.2.0 // indirect
- github.com/fatih/color v1.18.0 // indirect
- github.com/fatih/structtag v1.2.0 // indirect
- github.com/firefart/nonamedreturns v1.0.5 // indirect
- github.com/fsnotify/fsnotify v1.9.0 // indirect
- github.com/fzipp/gocyclo v0.6.0 // indirect
- github.com/ghostiam/protogetter v0.3.9 // indirect
- github.com/go-critic/go-critic v0.12.0 // indirect
- github.com/go-logr/logr v1.4.3 // indirect
+ github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
- github.com/go-toolsmith/astcast v1.1.0 // indirect
- github.com/go-toolsmith/astcopy v1.1.0 // indirect
- github.com/go-toolsmith/astequal v1.2.0 // indirect
- github.com/go-toolsmith/astfmt v1.1.0 // indirect
- github.com/go-toolsmith/astp v1.1.0 // indirect
- github.com/go-toolsmith/strparse v1.1.0 // indirect
- github.com/go-toolsmith/typep v1.1.0 // indirect
- github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
- github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
- github.com/gobwas/glob v0.2.3 // indirect
- github.com/gofrs/flock v0.12.1 // indirect
- github.com/golang/protobuf v1.5.3 // indirect
- github.com/golang/snappy v1.0.0 // indirect
- github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
- github.com/golangci/go-printf-func-name v0.1.0 // indirect
- github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect
- github.com/golangci/golangci-lint v1.64.8 // indirect
- github.com/golangci/misspell v0.6.0 // indirect
- github.com/golangci/plugin-module-register v0.1.1 // indirect
- github.com/golangci/revgrep v0.8.0 // indirect
- github.com/golangci/unconvert v0.0.0-20240309020433-c5143eacb3ed // indirect
- github.com/google/go-cmp v0.7.0 // indirect
- github.com/gordonklaus/ineffassign v0.1.0 // indirect
- github.com/gostaticanalysis/analysisutil v0.7.1 // indirect
- github.com/gostaticanalysis/comment v1.5.0 // indirect
- github.com/gostaticanalysis/forcetypeassert v0.2.0 // indirect
- github.com/gostaticanalysis/nilerr v0.1.1 // indirect
- github.com/hashicorp/errwrap v1.0.0 // indirect
- github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect
- github.com/hashicorp/go-version v1.7.0 // indirect
- github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
- github.com/hashicorp/hcl v1.0.0 // indirect
- github.com/hexops/gotextdiff v1.0.3 // indirect
- github.com/inconshreveable/mousetrap v1.1.0 // indirect
- github.com/jgautheron/goconst v1.7.1 // indirect
- github.com/jingyugao/rowserrcheck v1.1.1 // indirect
- github.com/jjti/go-spancheck v0.6.4 // indirect
- github.com/julz/importas v0.2.0 // indirect
- github.com/karamaru-alpha/copyloopvar v1.2.1 // indirect
- github.com/kisielk/errcheck v1.9.0 // indirect
- github.com/kkHAIKE/contextcheck v1.1.6 // indirect
- github.com/klauspost/compress v1.18.6 // indirect
- github.com/kulti/thelper v0.6.3 // indirect
- github.com/kunwardeep/paralleltest v1.0.10 // indirect
- github.com/lasiar/canonicalheader v1.1.2 // indirect
- github.com/ldez/exptostd v0.4.2 // indirect
- github.com/ldez/gomoddirectives v0.6.1 // indirect
- github.com/ldez/grignotin v0.9.0 // indirect
- github.com/ldez/tagliatelle v0.7.1 // indirect
- github.com/ldez/usetesting v0.4.2 // indirect
- github.com/leonklingele/grouper v1.1.2 // indirect
- github.com/macabu/inamedparam v0.1.3 // indirect
- github.com/magiconair/properties v1.8.6 // indirect
- github.com/maratori/testableexamples v1.0.0 // indirect
- github.com/maratori/testpackage v1.1.1 // indirect
- github.com/matoous/godox v1.1.0 // indirect
- github.com/matryer/is v1.4.1 // indirect
- github.com/mattn/go-colorable v0.1.14 // indirect
- github.com/mattn/go-isatty v0.0.20 // indirect
- github.com/mattn/go-runewidth v0.0.16 // indirect
- github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
- github.com/mgechev/revive v1.7.0 // indirect
- github.com/mitchellh/go-homedir v1.1.0 // indirect
- github.com/mitchellh/mapstructure v1.5.0 // indirect
- github.com/moricho/tparallel v0.3.2 // indirect
- github.com/nakabonne/nestif v0.3.1 // indirect
- github.com/nishanths/exhaustive v0.12.0 // indirect
- github.com/nishanths/predeclared v0.2.2 // indirect
- github.com/nunnatsa/ginkgolinter v0.19.1 // indirect
- github.com/olekukonko/tablewriter v0.0.5 // indirect
- github.com/pelletier/go-toml v1.9.5 // indirect
- github.com/pelletier/go-toml/v2 v2.2.3 // indirect
- github.com/pierrec/lz4/v4 v4.1.26 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
- github.com/polyfloyd/go-errorlint v1.7.1 // indirect
- github.com/prometheus/client_golang v1.12.1 // indirect
- github.com/prometheus/client_model v0.2.0 // indirect
- github.com/prometheus/common v0.32.1 // indirect
- github.com/prometheus/procfs v0.7.3 // indirect
- github.com/quasilyte/go-ruleguard v0.4.3-0.20240823090925-0fe6f58b47b1 // indirect
- github.com/quasilyte/go-ruleguard/dsl v0.3.22 // indirect
- github.com/quasilyte/gogrep v0.5.0 // indirect
- github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect
- github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect
- github.com/raeperd/recvcheck v0.2.0 // indirect
- github.com/rivo/uniseg v0.4.7 // indirect
- github.com/rogpeppe/go-internal v1.14.1 // indirect
- github.com/ryancurrah/gomodguard v1.3.5 // indirect
- github.com/ryanrolds/sqlclosecheck v0.5.1 // indirect
- github.com/sanposhiho/wastedassign/v2 v2.1.0 // indirect
- github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect
- github.com/sashamelentyev/interfacebloat v1.1.0 // indirect
- github.com/sashamelentyev/usestdlibvars v1.28.0 // indirect
- github.com/securego/gosec/v2 v2.22.2 // indirect
- github.com/sirupsen/logrus v1.9.3 // indirect
- github.com/sivchari/containedctx v1.0.3 // indirect
- github.com/sivchari/tenv v1.12.1 // indirect
- github.com/sonatard/noctx v0.1.0 // indirect
- github.com/sourcegraph/go-diff v0.7.0 // indirect
- github.com/spf13/afero v1.12.0 // indirect
- github.com/spf13/cast v1.5.0 // indirect
- github.com/spf13/cobra v1.9.1 // indirect
- github.com/spf13/jwalterweatherman v1.1.0 // indirect
- github.com/spf13/pflag v1.0.6 // indirect
- github.com/spf13/viper v1.12.0 // indirect
- github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect
- github.com/stbenjam/no-sprintf-host-port v0.2.0 // indirect
- github.com/stretchr/objx v0.5.2 // indirect
- github.com/subosito/gotenv v1.4.1 // indirect
- github.com/tdakkota/asciicheck v0.4.1 // indirect
- github.com/tetafro/godot v1.5.0 // indirect
- github.com/timakin/bodyclose v0.0.0-20241017074812-ed6a65f985e3 // indirect
- github.com/timonwong/loggercheck v0.10.1 // indirect
- github.com/tomarrell/wrapcheck/v2 v2.10.0 // indirect
- github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect
- github.com/ultraware/funlen v0.2.0 // indirect
- github.com/ultraware/whitespace v0.2.0 // indirect
- github.com/uudashr/gocognit v1.2.0 // indirect
- github.com/uudashr/iface v1.3.1 // indirect
- github.com/xen0n/gosmopolitan v1.2.2 // indirect
- github.com/yagipy/maintidx v1.0.0 // indirect
- github.com/yeya24/promlinter v0.3.0 // indirect
- github.com/ykadowak/zerologlint v0.1.5 // indirect
- gitlab.com/bosi/decorder v0.4.2 // indirect
- go-simpler.org/musttag v0.13.0 // indirect
- go-simpler.org/sloglint v0.9.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
- go.uber.org/atomic v1.7.0 // indirect
- go.uber.org/automaxprocs v1.6.0 // indirect
- go.uber.org/goleak v1.3.0 // indirect
- go.uber.org/multierr v1.6.0 // indirect
- go.uber.org/zap v1.24.0 // indirect
- golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect
- golang.org/x/mod v0.24.0 // indirect
- golang.org/x/sync v0.12.0 // indirect
- golang.org/x/sys v0.40.0 // indirect
- golang.org/x/text v0.23.0 // indirect
- golang.org/x/tools v0.31.0 // indirect
- google.golang.org/protobuf v1.36.5 // indirect
- gopkg.in/ini.v1 v1.67.0 // indirect
- gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
- honnef.co/go/tools v0.6.1 // indirect
- mvdan.cc/gofumpt v0.7.0 // indirect
- mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f // indirect
)
-
-tool github.com/golangci/golangci-lint/cmd/golangci-lint
diff --git a/go.work b/go.work
index a4b482a..cc5a23f 100644
--- a/go.work
+++ b/go.work
@@ -1,4 +1,4 @@
-go 1.26.0
+go 1.26.5
use (
.
diff --git a/gui/go.mod b/gui/go.mod
index 8ad7081..2cd537e 100644
--- a/gui/go.mod
+++ b/gui/go.mod
@@ -1,25 +1,27 @@
-module memcached-gui
+module github.com/yeqown/memcached/gui
-go 1.26.0
+go 1.26.5
require (
github.com/google/uuid v1.6.0
github.com/stretchr/testify v1.11.1
- github.com/wailsapp/wails/v2 v2.12.0
- github.com/yeqown/memcached v1.3.4
+ github.com/wailsapp/wails/v2 v2.14.0
+ github.com/yeqown/memcached v1.4.0
)
require (
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/bep/debounce v1.2.1 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/go-logr/logr v1.4.4 // indirect
+ github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
+ github.com/golang/snappy v1.0.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
- github.com/hashicorp/errwrap v1.0.0 // indirect
- github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
- github.com/kr/pretty v0.3.1 // indirect
+ github.com/klauspost/compress v1.19.2 // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
@@ -28,23 +30,26 @@ require (
github.com/leaanthony/u v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/pierrec/lz4/v4 v4.1.28 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
- github.com/rogpeppe/go-internal v1.14.1 // indirect
- github.com/samber/lo v1.49.1 // indirect
+ github.com/samber/lo v1.53.0 // indirect
github.com/tkrajina/go-reflector v0.5.8 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
- golang.org/x/crypto v0.36.0 // indirect
- golang.org/x/net v0.37.0 // indirect
- golang.org/x/sys v0.40.0 // indirect
- golang.org/x/text v0.23.0 // indirect
- gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
+ go.opentelemetry.io/auto/sdk v1.2.1 // indirect
+ go.opentelemetry.io/otel v1.45.0 // indirect
+ go.opentelemetry.io/otel/metric v1.45.0 // indirect
+ go.opentelemetry.io/otel/trace v1.45.0 // indirect
+ golang.org/x/crypto v0.53.0 // indirect
+ golang.org/x/net v0.56.0 // indirect
+ golang.org/x/sys v0.47.0 // indirect
+ golang.org/x/text v0.39.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
-// replace github.com/wailsapp/wails/v2 v2.12.0 => /Users/apple/.go/pkg/mod
+replace github.com/yeqown/memcached => ../
diff --git a/gui/go.sum b/gui/go.sum
index 1c5d177..7a1758f 100644
--- a/gui/go.sum
+++ b/gui/go.sum
@@ -2,28 +2,33 @@ git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
-github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
+github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
+github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
-github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
-github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
-github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
-github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
-github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
+github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
+github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
-github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
-github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
@@ -47,9 +52,10 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/pierrec/lz4/v4 v4.1.28 h1:pPEPwRJ4kybBTfGt28q7lQsRJQHhC08axprdLD5Ppio=
+github.com/pierrec/lz4/v4 v4.1.28/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
-github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -57,11 +63,10 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
-github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
-github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
-github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
+github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
+github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
@@ -74,26 +79,32 @@ github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6N
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
-github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
-github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
-github.com/yeqown/memcached v1.3.4 h1:v0/ZuojwP3u+3DtXXKjB/NInM8Ob/I3okh/6yQzFAFk=
-github.com/yeqown/memcached v1.3.4/go.mod h1:1NndLxAOAhl9ecmycI8wIv4nIv+fcpAUN0vLlqM7jn4=
-golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
-golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
+github.com/wailsapp/wails/v2 v2.14.0 h1:+GlF6BM8Mg7Saa1lxUAzfZ6jsEMg2zQhQvUHtqB7xpc=
+github.com/wailsapp/wails/v2 v2.14.0/go.mod h1:scxrgwfsv6yR6fE6cCF+Flfl+JeU+SR87T9x4kILJ6M=
+go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
+go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
+go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU=
+go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ=
+go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M=
+go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s=
+go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag=
+go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc=
+golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
+golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
-golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
+golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
+golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
-golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
-golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
+golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
+golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
diff --git a/gui/main.go b/gui/main.go
index 28777ee..ad39cd8 100644
--- a/gui/main.go
+++ b/gui/main.go
@@ -7,7 +7,7 @@ import (
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
- "memcached-gui/service"
+ "github.com/yeqown/memcached/gui/service"
)
//go:embed all:frontend/dist
@@ -39,7 +39,6 @@ func main() {
opSvc,
},
})
-
if err != nil {
println("Error:", err.Error())
}
diff --git a/gui/service/connection.go b/gui/service/connection.go
index 0f586bc..8e84e5e 100644
--- a/gui/service/connection.go
+++ b/gui/service/connection.go
@@ -3,6 +3,7 @@ package service
import (
"context"
"encoding/json"
+ "errors"
"fmt"
"os"
"path/filepath"
@@ -13,6 +14,8 @@ import (
memcached "github.com/yeqown/memcached"
)
+const configFileName = "contexts.json"
+
// ConnectionService manages memcached contexts and connections.
type ConnectionService struct {
mu sync.Mutex
@@ -30,7 +33,7 @@ func NewConnectionService() (*ConnectionService, error) {
}
configDir = filepath.Join(configDir, "memcached-gui")
- if err := os.MkdirAll(configDir, 0755); err != nil {
+ if err := os.MkdirAll(configDir, 0o700); err != nil {
return nil, fmt.Errorf("failed to create config dir: %w", err)
}
@@ -39,28 +42,15 @@ func NewConnectionService() (*ConnectionService, error) {
}, nil
}
-func (s *ConnectionService) configPath() string {
- return filepath.Join(s.configDir, "contexts.json")
-}
-
// LoadContexts loads saved contexts from the config file.
func (s *ConnectionService) LoadContexts() ([]Context, error) {
s.mu.Lock()
defer s.mu.Unlock()
- path := s.configPath()
- data, err := os.ReadFile(path)
+ contexts, err := s.loadContextsLocked()
if err != nil {
- if os.IsNotExist(err) {
- return []Context{}, nil
- }
return nil, fmt.Errorf("failed to read config: %w", err)
}
-
- var contexts []Context
- if err := json.Unmarshal(data, &contexts); err != nil {
- return nil, fmt.Errorf("failed to parse config: %w", err)
- }
return contexts, nil
}
@@ -120,10 +110,8 @@ func (s *ConnectionService) Connect(ctxID string) error {
defer s.mu.Unlock()
// Disconnect existing connection
- if s.client != nil {
- s.client.Close()
- s.client = nil
- s.connected = false
+ if err := s.disconnectLocked(); err != nil {
+ return fmt.Errorf("failed to close existing connection: %w", err)
}
contexts, err := s.loadContextsLocked()
@@ -162,8 +150,10 @@ func (s *ConnectionService) Connect(ctxID string) error {
defer cancel()
if _, err := client.Version(verifyCtx); err != nil {
- client.Close()
- return fmt.Errorf("failed to connect to %s: %w", addr, err)
+ return errors.Join(
+ fmt.Errorf("failed to connect to %s: %w", addr, err),
+ client.Close(),
+ )
}
s.client = client
@@ -177,13 +167,18 @@ func (s *ConnectionService) Disconnect() error {
s.mu.Lock()
defer s.mu.Unlock()
+ return s.disconnectLocked()
+}
+
+func (s *ConnectionService) disconnectLocked() error {
+ var err error
if s.client != nil {
- s.client.Close()
+ err = s.client.Close()
s.client = nil
}
s.connected = false
s.activeCtx = nil
- return nil
+ return err
}
// IsConnected returns whether there's an active connection.
@@ -204,26 +199,44 @@ func (s *ConnectionService) GetClient() (memcached.Client, error) {
return s.client, nil
}
-func (s *ConnectionService) loadContextsLocked() ([]Context, error) {
- path := s.configPath()
- data, err := os.ReadFile(path)
+func (s *ConnectionService) loadContextsLocked() (contexts []Context, retErr error) {
+ root, err := os.OpenRoot(s.configDir)
+ if err != nil {
+ return nil, err
+ }
+ defer func() {
+ retErr = errors.Join(retErr, root.Close())
+ }()
+
+ data, err := root.ReadFile(configFileName)
if err != nil {
if os.IsNotExist(err) {
return []Context{}, nil
}
return nil, err
}
- var contexts []Context
if err := json.Unmarshal(data, &contexts); err != nil {
return nil, err
}
return contexts, nil
}
-func (s *ConnectionService) saveContextsLocked(contexts []Context) error {
+func (s *ConnectionService) saveContextsLocked(contexts []Context) (retErr error) {
data, err := json.MarshalIndent(contexts, "", " ")
if err != nil {
return err
}
- return os.WriteFile(s.configPath(), data, 0644)
+
+ root, err := os.OpenRoot(s.configDir)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ retErr = errors.Join(retErr, root.Close())
+ }()
+
+ if err := root.WriteFile(configFileName, data, 0o600); err != nil {
+ return err
+ }
+ return root.Chmod(configFileName, 0o600)
}
diff --git a/gui/service/operations_test.go b/gui/service/operations_test.go
index 61ade5d..b082459 100644
--- a/gui/service/operations_test.go
+++ b/gui/service/operations_test.go
@@ -9,22 +9,6 @@ import (
memcached "github.com/yeqown/memcached"
)
-type structuredOperationResult struct {
- Success bool `json:"success"`
- Data string `json:"data"`
- Error string `json:"error"`
- Key string `json:"key"`
- Value string `json:"value"`
- TTL int64 `json:"ttl"`
- LastAccessedTime int64 `json:"lastAccessedTime"`
- CAS uint64 `json:"cas"`
- Flags uint32 `json:"flags"`
- Size uint64 `json:"size"`
- HitBefore bool `json:"hitBefore"`
- Opaque uint64 `json:"opaque"`
- ValueKind string `json:"valueKind"`
-}
-
type fakeMemcachedClient struct {
getCalled bool
metaGetCalled bool
diff --git a/protocol.go b/protocol.go
index 7c80a1f..0c0a858 100644
--- a/protocol.go
+++ b/protocol.go
@@ -3,11 +3,11 @@ package memcached
import (
"bytes"
"encoding/json"
+ "fmt"
"log"
+ "math"
"strconv"
"time"
-
- "github.com/pkg/errors"
)
// Codec transforms value and flags at the protocol boundary.
@@ -27,7 +27,7 @@ type Codec interface {
func checkCodecSupportsOperation(codec Codec, operation string) error {
if err := codec.SupportsOperation(operation); err != nil {
- return errors.Wrap(ErrNotSupported, err.Error())
+ return fmt.Errorf("%s: %w", err.Error(), ErrNotSupported)
}
return nil
}
@@ -147,9 +147,8 @@ func buildStorageCommand(
noReply bool,
codec Codec,
) (*request, *response, error) {
-
if err := checkCodecSupportsOperation(codec, command); err != nil {
- return nil, nil, errors.Wrap(err, "codec does not support operation")
+ return nil, nil, fmt.Errorf("codec does not support operation: %w", err)
}
evalue, eflags, err := codec.Encode([]byte(key), value, flags)
@@ -238,7 +237,7 @@ func buildCasCommand(
key string, value []byte, flag uint32, expTime time.Duration, casUnique uint64, noReply bool, codec Codec,
) (*request, *response, error) {
if err := checkCodecSupportsOperation(codec, "cas"); err != nil {
- return nil, nil, errors.Wrap(err, "codec does not support operation")
+ return nil, nil, fmt.Errorf("codec does not support operation: %w", err)
}
evalue, eflag, err := codec.Encode([]byte(key), value, flag)
@@ -328,11 +327,11 @@ func parseValueItems(lines [][]byte, withoutEndLine, withCAS bool, codec Codec)
n := len(lines)
if withoutEndLine && n%2 != 0 {
// n must be even
- return nil, errors.Wrap(ErrMalformedResponse, "want times of 2 lines, got "+strconv.Itoa(n))
+ return nil, fmt.Errorf("want times of 2 lines, got %d: %w", n, ErrMalformedResponse)
}
if !withoutEndLine && n%2 == 0 {
// n must be odd
- return nil, errors.Wrap(ErrMalformedResponse, "want odd lines, got "+strconv.Itoa(n))
+ return nil, fmt.Errorf("want odd lines, got %d: %w", n, ErrMalformedResponse)
}
var (
@@ -365,11 +364,11 @@ func parseValueItems(lines [][]byte, withoutEndLine, withCAS bool, codec Codec)
// Read the data block
if i+1 >= n {
- return nil, errors.Wrap(ErrMalformedResponse, "missing data block")
+ return nil, fmt.Errorf("missing data block: %w", ErrMalformedResponse)
}
item.Value = trimCRLF(lines[i+1])
- if len(item.Value) != int(dataLen) {
- return nil, errors.Wrap(ErrMalformedResponse, "data block length mismatch")
+ if uint64(len(item.Value)) != dataLen {
+ return nil, fmt.Errorf("data block length mismatch: %w", ErrMalformedResponse)
}
decodedValue, decodedFlags, err := codec.Decode([]byte(item.Key), item.Value, uint32(item.Flags))
@@ -404,7 +403,7 @@ func parseValueLine(line []byte, item *Item, withCas bool) (dataLen uint64, err
for i := start; i < n; i++ {
if nField > 5 || (!withCas && nField > 4) {
- return 0, errors.Wrap(ErrMalformedResponse, "invalid VALUE line")
+ return 0, fmt.Errorf("invalid VALUE line: %w", ErrMalformedResponse)
}
if line[i] != ' ' && i != n-1 {
@@ -415,11 +414,14 @@ func parseValueLine(line []byte, item *Item, withCas bool) (dataLen uint64, err
// the 'i' is the index of space or the last byte.
switch nField {
case keyIndex:
- item.Key = unsafeByteSliceToString(line[fieldStart:i])
+ item.Key = byteSliceToString(line[fieldStart:i])
case flagsIndex:
flags, err := parseUintFromBytes(line[fieldStart:i])
if err != nil {
- return 0, errors.Wrap(ErrMalformedResponse, "invalid flags")
+ return 0, fmt.Errorf("invalid flags: %w", ErrMalformedResponse)
+ }
+ if flags > math.MaxUint32 {
+ return 0, fmt.Errorf("flags exceed uint32: %w", ErrMalformedResponse)
}
item.Flags = uint32(flags)
case dataLenIndex:
@@ -429,7 +431,7 @@ func parseValueLine(line []byte, item *Item, withCas bool) (dataLen uint64, err
}
dataLen, err = parseUintFromBytes(line[fieldStart:si])
if err != nil {
- return 0, errors.Wrap(ErrMalformedResponse, "invalid data length")
+ return 0, fmt.Errorf("invalid data length: %w", ErrMalformedResponse)
}
case casIndex:
si := i
@@ -438,7 +440,7 @@ func parseValueLine(line []byte, item *Item, withCas bool) (dataLen uint64, err
}
item.CAS, err = parseUintFromBytes(line[fieldStart:si])
if err != nil {
- return 0, errors.Wrap(ErrMalformedResponse, "invalid CAS")
+ return 0, fmt.Errorf("invalid CAS: %w", ErrMalformedResponse)
}
}
@@ -465,10 +467,14 @@ func parseUintFromBytes(bs []byte) (uint64, error) {
r := uint64(0)
for _, b := range bs {
if b < '0' || b > '9' {
- return 0, errors.Wrap(ErrMalformedResponse, "invalid uint number")
+ return 0, fmt.Errorf("invalid uint number: %w", ErrMalformedResponse)
}
- r = r*10 + uint64(b-'0')
+ digit := uint64(b - '0')
+ if r > (math.MaxUint64-digit)/10 {
+ return 0, fmt.Errorf("uint number overflows uint64: %w", ErrMalformedResponse)
+ }
+ r = r*10 + digit
}
return r, nil
@@ -617,7 +623,7 @@ type Statistic struct {
func parseStats(lines [][]byte) (*Statistic, error) {
if len(lines) <= 0 {
- return nil, errors.Wrap(ErrMalformedResponse, "empty response")
+ return nil, fmt.Errorf("empty response: %w", ErrMalformedResponse)
}
transitionMap := make(map[string]any, len(lines))
@@ -662,12 +668,12 @@ func parseStats(lines [][]byte) (*Statistic, error) {
raw, err := json.Marshal(transitionMap)
if err != nil {
- return nil, errors.Wrap(err, "parseStats marshal transition map failed")
+ return nil, fmt.Errorf("parseStats marshal transition map failed: %w", err)
}
stat := &Statistic{}
if err = json.Unmarshal(raw, stat); err != nil {
- return nil, errors.Wrap(err, "parseStats unmarshal failed")
+ return nil, fmt.Errorf("parseStats unmarshal failed: %w", err)
}
return stat, nil
@@ -691,9 +697,3 @@ func buildStatsCommand(subCommand string) (*request, *response) {
return req, resp
}
-
-//nolint:unused
-func buildRawCommand(rawCommand string, indicator responseEndIndicator, lines int) (*request, *response) {
- _, _, _ = rawCommand, indicator, lines
- panic("IMPLEMENT ME!!!")
-}
diff --git a/protocol_bin.go b/protocol_bin.go
index 6083633..d434a24 100644
--- a/protocol_bin.go
+++ b/protocol_bin.go
@@ -2,9 +2,8 @@ package memcached
import (
"encoding/binary"
+ "fmt"
"io"
-
- "github.com/pkg/errors"
)
// https://docs.memcached.org/protocols/binary/
@@ -126,36 +125,44 @@ type binaryRequest struct {
}
func (br *binaryRequest) send(w io.Writer) error {
- nKey := uint16(len(br.key))
- nValue := uint32(len(br.value))
- nExtras := uint8(len(br.extras))
-
- buf := make([]byte, 24+uint32(nExtras)+uint32(nKey)+nValue)
+ keyLen := uint64(len(br.key))
+ valueLen := uint64(len(br.value))
+ extrasLen := uint64(len(br.extras))
+ bodyLen := keyLen + valueLen + extrasLen
- buf[0] = _binaryMagicReq
- buf[1] = br.opcode
- binary.BigEndian.PutUint16(buf[2:4], nKey) // key length
- buf[4] = nExtras // extras length
- buf[5] = _binaryDataTypeRawBytes // data type
- binary.BigEndian.PutUint16(buf[6:8], uint16(0)) // vbucket id
- binary.BigEndian.PutUint32(buf[8:12], uint32(nKey)+nValue+uint32(nExtras)) // total body length
- binary.BigEndian.PutUint32(buf[12:16], br.opaque) // opaque
- binary.BigEndian.PutUint64(buf[16:24], br.cas) // cas
-
- s := uint32(24)
- if nExtras > 0 {
- copy(buf[s:s+uint32(nExtras)], br.extras)
- s += uint32(nExtras)
+ if keyLen > maxKeySize {
+ return ErrInvalidKey
}
-
- if nKey > 0 {
- copy(buf[s:s+uint32(nKey)], br.key)
- s += uint32(nKey)
+ if valueLen > maxValueSize {
+ return ErrInvalidValue
}
- if nValue > 0 {
- copy(buf[s:], br.value)
+ if extrasLen > 255 {
+ return fmt.Errorf("binary extras exceed uint8: %w", ErrInvalidBinaryProtocol)
+ }
+ if bodyLen > maxValueSize {
+ return fmt.Errorf("binary body exceeds uint32: %w", ErrInvalidBinaryProtocol)
}
+ nKey := uint16(keyLen)
+ nExtras := uint8(extrasLen)
+ nBody := uint32(bodyLen)
+
+ buf := make([]byte, 24)
+
+ buf[0] = _binaryMagicReq
+ buf[1] = br.opcode
+ binary.BigEndian.PutUint16(buf[2:4], nKey) // key length
+ buf[4] = nExtras // extras length
+ buf[5] = _binaryDataTypeRawBytes // data type
+ binary.BigEndian.PutUint16(buf[6:8], uint16(0)) // vbucket id
+ binary.BigEndian.PutUint32(buf[8:12], nBody) // total body length
+ binary.BigEndian.PutUint32(buf[12:16], br.opaque) // opaque
+ binary.BigEndian.PutUint64(buf[16:24], br.cas) // cas
+
+ buf = append(buf, br.extras...)
+ buf = append(buf, br.key...)
+ buf = append(buf, br.value...)
+
_, err := w.Write(buf)
return err
}
@@ -199,25 +206,25 @@ func (br *binaryResponse) expect(status uint16) error {
case _binaryStatusNotSupported:
return ErrNotSupported
case _binaryStatusInternalError:
- return errors.Wrap(ErrServerError, "internal error")
+ return fmt.Errorf("internal error: %w", ErrServerError)
case _binaryStatusInvalidArgs:
return ErrInvalidArgument
case _binaryStatusOutOfMemory:
- return errors.Wrap(ErrServerError, "out of memory")
+ return fmt.Errorf("out of memory: %w", ErrServerError)
}
// return: status: 0x1234 format
- return errors.Wrapf(ErrServerError, "status: %x", br.status)
+ return fmt.Errorf("status: %x: %w", br.status, ErrServerError)
}
func (br *binaryResponse) read(rr io.Reader) error {
buf := make([]byte, 24)
if _, err := io.ReadFull(rr, buf); err != nil {
- return errors.Wrap(err, "read header")
+ return fmt.Errorf("read header: %w", err)
}
if magic := buf[0]; magic != _binaryMagicRes {
- return errors.Wrapf(ErrInvalidBinaryProtocol, "invalid magic: %d", magic)
+ return fmt.Errorf("invalid magic: %d: %w", magic, ErrInvalidBinaryProtocol)
}
br.opcode = buf[1]
@@ -236,7 +243,7 @@ func (br *binaryResponse) read(rr io.Reader) error {
// read the whole body and split them into extras, key, value
body := make([]byte, br.totalBodyLength)
if _, err := io.ReadFull(rr, body); err != nil {
- return errors.Wrap(err, "read body")
+ return fmt.Errorf("read body: %w", err)
}
s := uint32(0)
diff --git a/protocol_bin_test.go b/protocol_bin_test.go
index 69ccccc..aabb253 100644
--- a/protocol_bin_test.go
+++ b/protocol_bin_test.go
@@ -2,10 +2,10 @@ package memcached
import (
"bytes"
+ "errors"
"io"
"testing"
- "github.com/pkg/errors"
"github.com/stretchr/testify/assert"
)
diff --git a/protocol_builder.go b/protocol_builder.go
index 4d7da57..106cb3b 100644
--- a/protocol_builder.go
+++ b/protocol_builder.go
@@ -4,13 +4,12 @@ import (
"bytes"
"context"
"encoding/base64"
+ "errors"
+ "fmt"
"math"
"strconv"
"sync"
"time"
- "unsafe"
-
- "github.com/pkg/errors"
)
const (
@@ -53,10 +52,10 @@ func forecastCommonFaultLine(line []byte) error {
return ErrNonexistentCommand
case bytes.HasPrefix(line, []byte("CLIENT_ERROR")):
message := string(line[12 : len(line)-2])
- return errors.Wrap(ErrClientError, message)
+ return fmt.Errorf("%s: %w", message, ErrClientError)
case bytes.HasPrefix(line, []byte("SERVER_ERROR")):
message := string(line[12 : len(line)-2])
- return errors.Wrap(ErrServerError, message)
+ return fmt.Errorf("%s: %w", message, ErrServerError)
case bytes.Equal(line, []byte("NOT_FOUND\r\n")):
return ErrNotFound
case bytes.Equal(line, []byte("EXISTS\r\n")):
@@ -79,9 +78,16 @@ func forecastCommonFaultLine(line []byte) error {
}
const (
- // defaultBufferSize is the default size of the buffer.
- // TODO: It is used to avoid the buffer growth, but is 64B the most common case?
+ // defaultBufferSize covers common short text/meta command headers and
+ // small payloads without growth. Measured lengths with the builder's
+ // spacing rules: get/delete ≈14-17B, small set ≈32B, meta set with
+ // several flags ≈42B. Larger storage values grow once; oversized
+ // buffers are discarded on release via maxPooledBufferCap.
defaultBufferSize = 64
+
+ // maxPooledBufferCap prevents large storage values from permanently
+ // retaining oversized buffers in the pool.
+ maxPooledBufferCap = 4 << 10 // 4 KiB
)
var (
@@ -127,8 +133,10 @@ func newProtocolBuilder() *protocolBuilder {
func (b *protocolBuilder) release() {
if b.buf != nil {
- b.buf.Reset()
- bufferPool.Put(b.buf)
+ if b.buf.Cap() <= maxPooledBufferCap {
+ b.buf.Reset()
+ bufferPool.Put(b.buf)
+ }
b.buf = nil
}
@@ -188,7 +196,8 @@ func (b *protocolBuilder) AddFlagUint(flag string, tok uint64) *protocolBuilder
return b
}
- b.buf.WriteString(flag + strconv.FormatUint(tok, 10))
+ b.buf.WriteString(flag)
+ b.buf.WriteString(strconv.FormatUint(tok, 10))
b.buf.WriteByte(_SpaceByte)
return b
}
@@ -198,7 +207,8 @@ func (b *protocolBuilder) AddFlagString(flag, tok string) *protocolBuilder {
return b
}
- b.buf.WriteString(flag + tok)
+ b.buf.WriteString(flag)
+ b.buf.WriteString(tok)
b.buf.WriteByte(_SpaceByte)
return b
}
@@ -225,10 +235,6 @@ func trimCRLF(line []byte) []byte {
return bytes.TrimSuffix(line, _CRLFBytes)
}
-func withCRLF(bs []byte) []byte {
- return append(bs, _CRLFBytes...)
-}
-
var requestPool = sync.Pool{
New: func() any {
return &request{
@@ -468,7 +474,7 @@ func (resp *response) read1(rr memcachedConn) error {
for read < int(resp.limitedLines) {
line, err := rr.readLine('\n')
if err != nil {
- return errors.Wrap(err, "dispatchRequest read")
+ return fmt.Errorf("dispatchRequest read: %w", err)
}
if read == 0 {
@@ -495,7 +501,7 @@ func (resp *response) read2(rr memcachedConn) error {
// FIXME(@yeqown): read line would cost too much capacity.
line, err := rr.readLine('\n')
if err != nil {
- return errors.Wrap(err, "dispatchRequest read")
+ return fmt.Errorf("dispatchRequest read: %w", err)
}
if read == 0 && resp.udpEnabled {
@@ -528,7 +534,7 @@ func (resp *response) expect(line []byte) error {
return nil
}
if n := len(resp.rawLines); n != 1 {
- return errors.Wrapf(ErrMalformedResponse, "expect only 1 line, but got %d", n)
+ return fmt.Errorf("expect only 1 line, but got %d: %w", n, ErrMalformedResponse)
}
if bytes.Equal(resp.rawLines[0], line) {
@@ -548,16 +554,6 @@ func base64Encode(src []byte) []byte {
return dst
}
-func base64Decode(src []byte) ([]byte, error) {
- dst := make([]byte, base64.StdEncoding.DecodedLen(len(src)))
- n, err := base64.StdEncoding.Decode(dst, src)
- if err != nil {
- return nil, err
- }
-
- return dst[:n], nil
-}
-
func releaseReqAndResp(req *request, resp *response) {
if req != nil {
req.release()
@@ -568,10 +564,6 @@ func releaseReqAndResp(req *request, resp *response) {
}
}
-func unsafeStringToByteSlice(s string) []byte {
- return unsafe.Slice(unsafe.StringData(s), len(s))
-}
-
-func unsafeByteSliceToString(bs []byte) string {
- return unsafe.String(unsafe.SliceData(bs), len(bs))
+func byteSliceToString(bs []byte) string {
+ return string(bs)
}
diff --git a/protocol_builder_test.go b/protocol_builder_test.go
index 5b158a5..6b43faf 100644
--- a/protocol_builder_test.go
+++ b/protocol_builder_test.go
@@ -1,6 +1,7 @@
package memcached
import (
+ "bytes"
"context"
"testing"
"time"
@@ -16,6 +17,121 @@ func Test_protocolBuilder(t *testing.T) {
AddString("value")
expected := "set key 0 0 5\r\nvalue\r\n"
assert.Equal(t, expected, string(builder.build()))
+ builder.release()
+}
+
+func Test_protocolBuilder_releaseDiscardsOversizedBuffer(t *testing.T) {
+ large := bytes.NewBuffer(make([]byte, 0, maxPooledBufferCap+1024))
+ _, _ = large.Write(make([]byte, maxPooledBufferCap+1))
+ b := &protocolBuilder{buf: large}
+ b.release()
+ assert.Nil(t, b.buf)
+
+ // Oversized buffers must not be reused via the pool.
+ for i := 0; i < 32; i++ {
+ pb := newProtocolBuilder()
+ capSize := pb.buf.Cap()
+ pb.release()
+ assert.LessOrEqual(t, capSize, maxPooledBufferCap)
+ }
+}
+
+func Test_protocolBuilder_shortCommandsFitDefaultCap(t *testing.T) {
+ cases := []struct {
+ name string
+ build func() *protocolBuilder
+ }{
+ {
+ name: "get",
+ build: func() *protocolBuilder {
+ return newProtocolBuilder().AddString("get").AddString("test_key").AddCRLF()
+ },
+ },
+ {
+ name: "delete",
+ build: func() *protocolBuilder {
+ return newProtocolBuilder().AddString("delete").AddString("test_key").AddCRLF()
+ },
+ },
+ {
+ name: "set small",
+ build: func() *protocolBuilder {
+ value := []byte("test_value")
+ return newProtocolBuilder().
+ AddString("set").AddString("test_key").AddInt(0).AddInt(0).AddInt(len(value)).
+ AddCRLF().
+ AddBytes(value)
+ },
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ pb := tc.build()
+ defer pb.release()
+ assert.LessOrEqual(t, pb.buf.Len(), defaultBufferSize)
+ assert.LessOrEqual(t, pb.buf.Cap(), maxPooledBufferCap)
+ })
+ }
+}
+
+func BenchmarkProtocolBuilder_GetShortKey(b *testing.B) {
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ pb := newProtocolBuilder().AddString("get").AddString("test_key").AddCRLF()
+ _ = pb.build()
+ pb.release()
+ }
+}
+
+func BenchmarkProtocolBuilder_DeleteShortKey(b *testing.B) {
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ pb := newProtocolBuilder().AddString("delete").AddString("test_key").AddCRLF()
+ _ = pb.build()
+ pb.release()
+ }
+}
+
+func BenchmarkProtocolBuilder_SetSmallValue(b *testing.B) {
+ b.ReportAllocs()
+ value := []byte("test_value")
+ for i := 0; i < b.N; i++ {
+ pb := newProtocolBuilder().
+ AddString("set").AddString("test_key").AddInt(0).AddInt(0).AddInt(len(value)).
+ AddCRLF().
+ AddBytes(value)
+ _ = pb.build()
+ pb.release()
+ }
+}
+
+func BenchmarkProtocolBuilder_MetaSetManyFlags(b *testing.B) {
+ b.ReportAllocs()
+ value := []byte("test_value")
+ for i := 0; i < b.N; i++ {
+ pb := newProtocolBuilder().
+ AddString("ms").AddBytes([]byte("test_key")).
+ AddString("c").AddString("C").AddString("F").AddUint(1).
+ AddString("T").AddUint(60).AddString("S").AddInt(len(value)).
+ AddCRLF().
+ AddBytes(value)
+ _ = pb.build()
+ pb.release()
+ }
+}
+
+func BenchmarkProtocolBuilder_SetLargeValue(b *testing.B) {
+ b.ReportAllocs()
+ value := make([]byte, 4096)
+ for i := 0; i < b.N; i++ {
+ pb := newProtocolBuilder().
+ AddString("set").AddString("test_key").AddInt(0).AddInt(0).AddInt(len(value)).
+ AddCRLF().
+ AddBytes(value)
+ _ = pb.build()
+ pb.release()
+ }
}
func Test_selectProximateDeadline(t *testing.T) {
diff --git a/protocol_meta.go b/protocol_meta.go
index 660814e..aae49b9 100644
--- a/protocol_meta.go
+++ b/protocol_meta.go
@@ -3,9 +3,8 @@ package memcached
import (
"bytes"
"fmt"
+ "math"
"strconv"
-
- "github.com/pkg/errors"
)
type metaSetFlags struct {
@@ -118,13 +117,13 @@ func buildMetaSetCommand(key, value []byte, flags *metaSetFlags, codec Codec) (*
operation = string(MetaSetModeSet)
}
if err := checkCodecSupportsOperation(codec, operation); err != nil {
- return nil, nil, errors.Wrap(err, "codec does not support operation")
+ return nil, nil, fmt.Errorf("codec does not support operation: %w", err)
}
// codec hook
evalue, eflags, err := codec.Encode(key, value, flags.F)
if err != nil {
- return nil, nil, errors.Wrap(err, "encode value and flags")
+ return nil, nil, fmt.Errorf("encode value and flags: %w", err)
}
flags.F = eflags
@@ -358,13 +357,13 @@ func parseMetaItem(lines [][]byte, item *MetaItem, noReply bool, codec Codec) er
// figure out the
if len(lines) == 0 {
- return errors.Wrap(ErrMalformedResponse, "missing response")
+ return fmt.Errorf("missing response: %w", ErrMalformedResponse)
}
// Normal CD handling
parts := bytes.Split(trimCRLF(lines[0]), _SpaceBytes)
if len(parts) < 1 {
- return errors.Wrap(ErrMalformedResponse, "invalid response")
+ return fmt.Errorf("invalid response: %w", ErrMalformedResponse)
}
const (
CDIndex = 0
@@ -384,21 +383,28 @@ func parseMetaItem(lines [][]byte, item *MetaItem, noReply bool, codec Codec) er
}
if !bytes.Equal(cd, []byte("VA")) {
- parseFlags(parts, 1, item)
- return nil
+ return parseFlags(parts, 1, item)
}
// VA handling
- item.Size, _ = strconv.ParseUint(string(parts[dataLenIndex]), 10, 32)
- parseFlags(parts, 2, item)
+ if len(parts) <= dataLenIndex {
+ return fmt.Errorf("missing value size: %w", ErrMalformedResponse)
+ }
+ size, err := strconv.ParseUint(string(parts[dataLenIndex]), 10, 64)
+ if err != nil {
+ return fmt.Errorf("invalid value size: %w", ErrMalformedResponse)
+ }
+ item.Size = size
+ if err := parseFlags(parts, 2, item); err != nil {
+ return err
+ }
if len(lines) < 2 {
- return errors.Wrap(ErrMalformedResponse, "missing value")
+ return fmt.Errorf("missing value: %w", ErrMalformedResponse)
}
- var err error
if item.Value, item.Flags, err = codec.Decode(item.Key, trimCRLF(lines[1]), item.Flags); err != nil {
- return errors.Wrap(err, "codec decode")
+ return fmt.Errorf("codec decode: %w", err)
}
return nil
@@ -408,38 +414,72 @@ func parseMetaItem(lines [][]byte, item *MetaItem, noReply bool, codec Codec) er
// .e.g:
//
// HD c26 kZm9v b O456 s3\r\n
-func parseFlags(parts [][]byte, startPos int, item *MetaItem) {
- parseUint := func(b []byte) uint64 {
- v, _ := strconv.ParseUint(string(b), 10, 64)
- return v
+func parseFlags(parts [][]byte, startPos int, item *MetaItem) error {
+ parseUint := func(flag byte, b []byte) (uint64, error) {
+ v, err := strconv.ParseUint(string(b), 10, 64)
+ if err != nil {
+ return 0, fmt.Errorf("invalid %c flag: %w", flag, ErrMalformedResponse)
+ }
+ return v, nil
}
- parseInt := func(b []byte) int64 {
- v, _ := strconv.ParseInt(string(b), 10, 64)
- return v
+ parseInt := func(flag byte, b []byte) (int64, error) {
+ v, err := strconv.ParseInt(string(b), 10, 64)
+ if err != nil {
+ return 0, fmt.Errorf("invalid %c flag: %w", flag, ErrMalformedResponse)
+ }
+ return v, nil
}
for i := startPos; i < len(parts); i++ {
- switch parts[i][0] {
+ if len(parts[i]) == 0 {
+ return fmt.Errorf("empty meta flag: %w", ErrMalformedResponse)
+ }
+
+ flag := parts[i][0]
+ value := parts[i][1:]
+ var err error
+ switch flag {
case 'c':
- item.CAS = parseUint(parts[i][1:])
+ item.CAS, err = parseUint(flag, value)
case 'f':
- item.Flags = uint32(parseUint(parts[i][1:]))
+ var flags uint64
+ flags, err = parseUint(flag, value)
+ if err == nil {
+ if flags > math.MaxUint32 {
+ return fmt.Errorf("f flag exceeds uint32: %w", ErrMalformedResponse)
+ }
+ item.Flags = uint32(flags)
+ }
case 't':
- item.TTL = parseInt(parts[i][1:])
+ item.TTL, err = parseInt(flag, value)
case 'l':
- item.LastAccessedTime = int64(parseUint(parts[i][1:]))
+ var lastAccessed uint64
+ lastAccessed, err = parseUint(flag, value)
+ if err == nil {
+ if lastAccessed > math.MaxInt64 {
+ return fmt.Errorf("l flag exceeds int64: %w", ErrMalformedResponse)
+ }
+ item.LastAccessedTime = int64(lastAccessed)
+ }
case 's':
- item.Size = parseUint(parts[i][1:])
+ item.Size, err = parseUint(flag, value)
case 'O':
- item.Opaque = parseUint(parts[i][1:])
+ item.Opaque, err = parseUint(flag, value)
case 'h':
- item.HitBefore = parseUint(parts[i][1:]) == 1
+ var hitBefore uint64
+ hitBefore, err = parseUint(flag, value)
+ item.HitBefore = hitBefore == 1
// NO need to parse key again in client.
// case 'k':
// item.Key = string(parts[i][1:])
}
+ if err != nil {
+ return err
+ }
}
+
+ return nil
}
// MetaDeleteOption is used to set options for MetaDelete command.
@@ -711,12 +751,12 @@ func buildMetaDebugCommand(key []byte, flags *metaDebugFlags) (*request, *respon
// failed: EN\r\n
func parseMetaItemDebug(lines [][]byte, item *MetaItemDebug) error {
if len(lines) != 1 {
- return errors.Wrap(ErrMalformedResponse, "invalid response")
+ return fmt.Errorf("invalid response: %w", ErrMalformedResponse)
}
parts := bytes.Split(trimCRLF(lines[0]), _SpaceBytes)
if len(parts) < 1 {
- return errors.Wrap(ErrMalformedResponse, "invalid response")
+ return fmt.Errorf("invalid response: %w", ErrMalformedResponse)
}
const (
@@ -731,7 +771,7 @@ func parseMetaItemDebug(lines [][]byte, item *MetaItemDebug) error {
case "ME":
// success
default:
- return errors.Wrap(ErrMalformedResponse, "unexpected cd<="+string(cd)+">")
+ return fmt.Errorf("unexpected cd<=%s>: %w", string(cd), ErrMalformedResponse)
}
// parse key
diff --git a/protocol_meta_test.go b/protocol_meta_test.go
index a8ad869..c3ed289 100644
--- a/protocol_meta_test.go
+++ b/protocol_meta_test.go
@@ -62,7 +62,7 @@ func Test_parseFlags(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- parseFlags(tt.args.parts, tt.args.startPos, tt.args.item)
+ require.NoError(t, parseFlags(tt.args.parts, tt.args.startPos, tt.args.item))
assert.Equal(t, tt.want, tt.args.item)
})
}
diff --git a/protocol_test.go b/protocol_test.go
index 18781d4..5282650 100644
--- a/protocol_test.go
+++ b/protocol_test.go
@@ -2,11 +2,11 @@ package memcached
import (
"bytes"
+ "errors"
"strconv"
"testing"
"time"
- "github.com/pkg/errors"
"github.com/stretchr/testify/assert"
memcodec "github.com/yeqown/memcached/codec"
)
@@ -223,6 +223,30 @@ func Test_parseUintFromBytes(t *testing.T) {
want: 1234567890,
wantErr: false,
},
+ {
+ name: "max-uint64",
+ args: args{
+ raw: []byte("18446744073709551615"),
+ },
+ want: ^uint64(0),
+ wantErr: false,
+ },
+ {
+ name: "overflow-uint64",
+ args: args{
+ raw: []byte("18446744073709551616"),
+ },
+ want: 0,
+ wantErr: true,
+ },
+ {
+ name: "overflow-long-number",
+ args: args{
+ raw: []byte("9999999999999999999999999999999999999999"),
+ },
+ want: 0,
+ wantErr: true,
+ },
{
name: "malformed-contains-letters",
args: args{