Skip to content

Commit 6f496ab

Browse files
committed
verbs: implement cas functionality via gets and cas commands
1 parent ffa29f5 commit 6f496ab

3 files changed

Lines changed: 241 additions & 5 deletions

File tree

e2e_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,3 +507,65 @@ func TestE2E_StatsItems(t *testing.T) {
507507
must.Positive(t, data[0].Number)
508508
must.Positive(t, data[0].MemRequested)
509509
}
510+
511+
func TestE2E_CAS(t *testing.T) {
512+
t.Parallel()
513+
514+
address, done := memctest.LaunchTCP(t, nil)
515+
t.Cleanup(done)
516+
517+
c := New([]string{address})
518+
defer ignore.Close(c)
519+
520+
t.Run("success", func(t *testing.T) {
521+
err := Set(c, "key1", "value1")
522+
must.NoError(t, err)
523+
524+
v, cas, verr := Gets[string](c, "key1")
525+
must.NoError(t, verr)
526+
must.Eq(t, "value1", v)
527+
must.Positive(t, uint64(cas))
528+
529+
err = CompareAndSwap(c, "key1", cas, "value1.updated")
530+
must.NoError(t, err)
531+
532+
v, err = Get[string](c, "key1")
533+
must.NoError(t, err)
534+
must.Eq(t, "value1.updated", v)
535+
})
536+
537+
t.Run("conflict", func(t *testing.T) {
538+
err := Set(c, "key2", "original")
539+
must.NoError(t, err)
540+
541+
_, cas1, verr := Gets[string](c, "key2")
542+
must.NoError(t, verr)
543+
544+
_, _, verr = Gets[string](c, "key2")
545+
must.NoError(t, verr)
546+
547+
err = CompareAndSwap(c, "key2", cas1, "first-update")
548+
must.NoError(t, err)
549+
550+
err = CompareAndSwap(c, "key2", cas1, "stale-update")
551+
must.ErrorIs(t, err, ErrConflict)
552+
553+
v, err := Get[string](c, "key2")
554+
must.NoError(t, err)
555+
must.Eq(t, "first-update", v)
556+
})
557+
558+
t.Run("not found", func(t *testing.T) {
559+
err := Set(c, "key3", "value3")
560+
must.NoError(t, err)
561+
562+
_, cas, verr := Gets[string](c, "key3")
563+
must.NoError(t, verr)
564+
565+
err = Delete(c, "key3")
566+
must.NoError(t, err)
567+
568+
err = CompareAndSwap(c, "key3", cas, "newvalue")
569+
must.ErrorIs(t, err, ErrNotFound)
570+
})
571+
}

iopool/pool.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,10 @@ type Buffer struct {
4444

4545
func newBuffer(conn Connection) *Buffer {
4646
return &Buffer{
47-
bufio.NewReader(conn),
48-
bufio.NewWriter(conn),
49-
conn,
50-
new(atomic.Bool),
47+
Reader: bufio.NewReader(conn),
48+
Writer: bufio.NewWriter(conn),
49+
Closer: conn,
50+
failure: new(atomic.Bool),
5151
}
5252
}
5353

verbs.go

Lines changed: 175 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ var (
2828
ErrCommandIssue = errors.New("memc: got command error response")
2929
)
3030

31+
// CAS represents a Compare-And-Swap token used for optimistic locking.
32+
// It is returned by Gets and must be provided to CompareAndSwap to atomically update a value.
33+
type CAS uint64
34+
3135
// Options contains configuration parameters that may be applied when executing
3236
// a verb like Get, Set, etc.
3337
type Options struct {
@@ -437,6 +441,88 @@ func Add[T any](c *Client, key string, item T, opts ...Option) error {
437441
})
438442
}
439443

444+
// CompareAndSwap will store the item using the given key, but only if the CAS
445+
// token matches the current value's CAS token. This provides atomic
446+
// compare-and-swap functionality for optimistic locking.
447+
//
448+
// If the key does not exist, ErrNotFound is returned.
449+
//
450+
// If the CAS token does not match (meaning the value was modified since it was
451+
// retrieved with Gets), ErrConflict is returned.
452+
//
453+
// Uses Client c to connect to a memcached instance, and automatically handles
454+
// connection pooling and reuse.
455+
//
456+
// One or more Option(s) may be applied to configure things such as the value
457+
// expiration TTL or its associated flags.
458+
func CompareAndSwap[T any](c *Client, key string, cas CAS, item T, opts ...Option) error {
459+
if err := check(key); err != nil {
460+
return err
461+
}
462+
463+
options := &Options{
464+
expiration: c.expiration,
465+
flags: 0,
466+
}
467+
468+
for _, opt := range opts {
469+
opt(options)
470+
}
471+
472+
return c.do(key, func(conn *iopool.Buffer) error {
473+
encoding, encerr := encode(item)
474+
if encerr != nil {
475+
return encerr
476+
}
477+
478+
expiration, experr := c.seconds(options.expiration)
479+
if experr != nil {
480+
return experr
481+
}
482+
483+
// write the header components with CAS token
484+
if _, err := fmt.Fprintf(
485+
conn,
486+
"cas %s %d %d %d %d\r\n",
487+
key, options.flags, expiration, len(encoding), cas,
488+
); err != nil {
489+
return err
490+
}
491+
492+
// write the payload
493+
if _, err := conn.Write(encoding); err != nil {
494+
return err
495+
}
496+
497+
// write clrf
498+
if _, err := io.WriteString(conn, "\r\n"); err != nil {
499+
return err
500+
}
501+
502+
// flush the buffer
503+
if err := conn.Flush(); err != nil {
504+
return err
505+
}
506+
507+
// read response
508+
line, lerr := conn.ReadSlice('\n')
509+
if lerr != nil {
510+
return lerr
511+
}
512+
513+
switch string(line) {
514+
case "STORED\r\n":
515+
return nil
516+
case "NOT_FOUND\r\n":
517+
return ErrNotFound
518+
case "EXISTS\r\n":
519+
return ErrConflict
520+
default:
521+
return fmt.Errorf("memc: unexpected response to cas: %q", string(line))
522+
}
523+
})
524+
}
525+
440526
// Get the value associated with the given key.
441527
//
442528
// Uses Client c to connect to a memcached instance, and automatically handles
@@ -472,6 +558,51 @@ func Get[T any](c *Client, key string) (T, error) {
472558
return result, err
473559
}
474560

561+
// Gets the value associated with the given key, along with its CAS token.
562+
//
563+
// The CAS token can be used with CompareAndSwap to atomically update the value,
564+
// providing optimistic locking. If the value has been modified since it was
565+
// retrieved, CompareAndSwap will return an ErrConflict error.
566+
//
567+
// Uses Client c to connect to a memcached instance, and automatically handles
568+
// connection pooling and reuse.
569+
func Gets[T any](c *Client, key string) (T, CAS, error) {
570+
var result T
571+
var casToken CAS
572+
573+
if err := check(key); err != nil {
574+
return result, 0, err
575+
}
576+
577+
err := c.do(key, func(conn *iopool.Buffer) error {
578+
// write the header components
579+
if _, err := fmt.Fprintf(conn, "gets %s\r\n", key); err != nil {
580+
return err
581+
}
582+
583+
// flush the connection, forcing bytes over the wire
584+
if err := conn.Flush(); err != nil {
585+
return err
586+
}
587+
588+
// read the response payload with CAS token
589+
payload, cas, err := getPayloadWithCAS(conn.Reader)
590+
if err != nil {
591+
return err
592+
}
593+
594+
result, err = decode[T](payload)
595+
if err != nil {
596+
return err
597+
}
598+
599+
casToken = CAS(cas)
600+
return nil
601+
})
602+
603+
return result, casToken, err
604+
}
605+
475606
func getPayload(r *bufio.Reader) ([]byte, error) {
476607
b, err := r.ReadSlice('\n')
477608
if err != nil {
@@ -483,7 +614,6 @@ func getPayload(r *bufio.Reader) ([]byte, error) {
483614
return nil, ErrCacheMiss
484615
}
485616

486-
// TODO: does not handle CAS value for now
487617
expect := "VALUE %s %d %d\r\n"
488618
var (
489619
key string
@@ -515,6 +645,50 @@ func getPayload(r *bufio.Reader) ([]byte, error) {
515645
return payload, err
516646
}
517647

648+
func getPayloadWithCAS(r *bufio.Reader) ([]byte, uint64, error) {
649+
b, err := r.ReadSlice('\n')
650+
if err != nil {
651+
return nil, 0, err
652+
}
653+
654+
// key was not found, is a cache miss
655+
if string(b) == "END\r\n" {
656+
return nil, 0, ErrCacheMiss
657+
}
658+
659+
// handle CAS value - format is "VALUE key flags bytes cas\r\n"
660+
expect := "VALUE %s %d %d %d\r\n"
661+
var (
662+
key string
663+
flags int
664+
size int
665+
cas uint64
666+
)
667+
668+
// scan the header line, giving us a payload size and CAS token
669+
if _, err = fmt.Sscanf(string(b), expect, &key, &flags, &size, &cas); err != nil {
670+
return nil, 0, err
671+
}
672+
673+
// read the data into our payload
674+
payload := make([]byte, size+2) // including \r\n
675+
if _, err = io.ReadFull(r, payload); err != nil {
676+
return nil, 0, err
677+
}
678+
payload = payload[0:size] // chop \r\n
679+
680+
// read the trailing line ("END\r\n")
681+
b, err = r.ReadSlice('\n')
682+
if err != nil {
683+
return nil, 0, err
684+
}
685+
if string(b) != "END\r\n" {
686+
return nil, 0, unexpected(b)
687+
}
688+
689+
return payload, cas, nil
690+
}
691+
518692
// Delete will remove the value associated with key from memcached.
519693
//
520694
// Uses Client c to connect to a memcached instance, and automatically handles

0 commit comments

Comments
 (0)