Skip to content

Commit 27d2635

Browse files
authored
Merge pull request #61 from fastly/feature-simple-cache-api
2 parents 51abb7f + 8357f18 commit 27d2635

File tree

4 files changed

+279
-8
lines changed

4 files changed

+279
-8
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
## Unreleased
22

3+
### Added
4+
5+
- Add Simple Cache API
6+
37
### 0.1.5 (2023-06-23)
48

59
### Changed

_examples/simplecache/main.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"crypto/sha256"
6+
"fmt"
7+
"io"
8+
"os"
9+
"time"
10+
11+
"github.com/fastly/compute-sdk-go/cache/simple"
12+
"github.com/fastly/compute-sdk-go/fsthttp"
13+
)
14+
15+
func main() {
16+
fsthttp.ServeFunc(func(ctx context.Context, w fsthttp.ResponseWriter, r *fsthttp.Request) {
17+
w.Header().Set("Service-Version", os.Getenv("FASTLY_SERVICE_VERSION"))
18+
19+
key := keyForRequest(r)
20+
switch r.Method {
21+
22+
// Fetch content from the cache.
23+
case "GET":
24+
rc, err := simple.Get(key)
25+
if err != nil {
26+
fsthttp.Error(w, err.Error(), fsthttp.StatusInternalServerError)
27+
return
28+
}
29+
defer rc.Close()
30+
31+
msg, err := io.ReadAll(rc)
32+
if err != nil {
33+
fsthttp.Error(w, err.Error(), fsthttp.StatusInternalServerError)
34+
return
35+
}
36+
37+
w.Header().Set("Content-Type", "text/plain")
38+
fmt.Fprintf(w, "%s's message for %s is: %s\n", getPOP(), r.URL.Path, msg)
39+
40+
// Write data to the cache (if there's nothing there) and stream it back to the client.
41+
case "POST":
42+
if r.Header.Get("Content-Type") != "text/plain" && r.Header.Get("Content-Type") != "text/plain; charset=utf-8" {
43+
w.WriteHeader(fsthttp.StatusUnsupportedMediaType)
44+
return
45+
}
46+
47+
var set bool
48+
rc, err := simple.GetOrSet(key, func() (simple.CacheEntry, error) {
49+
set = true
50+
return simple.CacheEntry{
51+
Body: r.Body,
52+
TTL: 3 * time.Minute,
53+
}, nil
54+
})
55+
if err != nil {
56+
fsthttp.Error(w, err.Error(), fsthttp.StatusInternalServerError)
57+
return
58+
}
59+
defer rc.Close()
60+
61+
if !set {
62+
w.WriteHeader(fsthttp.StatusConflict)
63+
return
64+
}
65+
66+
msg, err := io.ReadAll(rc)
67+
if err != nil {
68+
fsthttp.Error(w, err.Error(), fsthttp.StatusInternalServerError)
69+
return
70+
}
71+
72+
w.Header().Set("Content-Type", "text/plain")
73+
w.WriteHeader(fsthttp.StatusOK)
74+
fmt.Fprintf(w, "%s's message for %s is: %s\n", getPOP(), r.URL.Path, msg)
75+
76+
// Purge the key from the cache.
77+
case "DELETE":
78+
if err := simple.Purge(key, simple.PurgeOptions{}); err != nil {
79+
fsthttp.Error(w, err.Error(), fsthttp.StatusInternalServerError)
80+
return
81+
}
82+
w.WriteHeader(fsthttp.StatusAccepted)
83+
84+
default:
85+
w.WriteHeader(fsthttp.StatusMethodNotAllowed)
86+
}
87+
})
88+
}
89+
90+
func keyForRequest(r *fsthttp.Request) []byte {
91+
h := sha256.New()
92+
h.Write([]byte(r.URL.Path))
93+
return h.Sum(nil)
94+
}
95+
96+
func getPOP() string {
97+
return os.Getenv("FASTLY_POP")
98+
}

cache/core/core.go

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -684,20 +684,14 @@ func (t *Transaction) Cancel() error {
684684
return mapFastlyError(t.abiEntry.Cancel())
685685
}
686686

687-
// Close ends the transaction, commits any inserts or updates, and
688-
// cleans up resources associated with it.
689-
//
690-
// If a Found is associated with this transaction, its Body will be
691-
// closed if it hasn't been already.
687+
// Close ends the transaction and cleans up resources associated with
688+
// it.
692689
func (t *Transaction) Close() error {
693690
if t.ended {
694691
return nil
695692
}
696693
t.ended = true
697694

698-
if t.found != nil {
699-
t.found.Body.Close()
700-
}
701695
return t.abiEntry.Close()
702696
}
703697

cache/simple/simple.go

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
// Package simple provides the Simple Cache API, a simplified interface
2+
// to inserting and retrieving entries from Fastly's cache.
3+
//
4+
// Cache operations are local to the Fastly POP serving the request.
5+
// Purging can also be performed globally.
6+
//
7+
// For more advanced uses, see the Core Cache API in the
8+
// [github.com/fastly/compute-sdk-go/cache/core] package.
9+
package simple
10+
11+
import (
12+
"crypto/sha256"
13+
"encoding/hex"
14+
"io"
15+
"os"
16+
"strings"
17+
"time"
18+
19+
"github.com/fastly/compute-sdk-go/cache/core"
20+
"github.com/fastly/compute-sdk-go/purge"
21+
)
22+
23+
// Get retrieves the object stored in the cache for the given key. If
24+
// the key is not cached, [core.ErrNotFound] is returned. Keys can be
25+
// up to 4096 bytes in length.
26+
//
27+
// The returned [io.ReadCloser] must be closed by the caller when
28+
// finished.
29+
func Get(key []byte) (io.ReadCloser, error) {
30+
f, err := core.Lookup(key, core.LookupOptions{})
31+
if err != nil {
32+
return nil, err
33+
}
34+
return f.Body, nil
35+
}
36+
37+
// CacheEntry contains the contents and TTL (time-to-live) for an item
38+
// to be added to the cache via [GetOrSet] or [GetOrSetContents].
39+
type CacheEntry struct {
40+
// The contents of the cached object.
41+
Body io.Reader
42+
43+
// The time-to-live for the cached object.
44+
TTL time.Duration
45+
}
46+
47+
// GetOrSet retrieves the object stored in the cache for the given key
48+
// if it exists, or inserts and returns the contents by running the
49+
// provided setFn function.
50+
//
51+
// The setFn function is only run when no value is present for the key,
52+
// and no other client is in the process of setting it. The function
53+
// should return a populated [CacheEntry] or an error.
54+
//
55+
// If the setFn function returns an error, nothing will be saved to the
56+
// cache and the error will be returned from the GetOrSet function.
57+
// Other concurrent readers will also see an error while reading.
58+
//
59+
// The returned [io.ReadCloser] must be closed by the caller when
60+
// finished.
61+
func GetOrSet(key []byte, setFn func() (CacheEntry, error)) (io.ReadCloser, error) {
62+
tx, err := core.NewTransaction(key, core.LookupOptions{})
63+
if err != nil {
64+
return nil, err
65+
}
66+
defer tx.Close()
67+
68+
if tx.MustInsertOrUpdate() {
69+
e, err := setFn()
70+
if err != nil {
71+
return nil, err
72+
}
73+
74+
w, f, err := tx.InsertAndStreamBack(core.WriteOptions{
75+
TTL: e.TTL,
76+
SurrogateKeys: []string{
77+
SurrogateKeyForCacheKey(key, PurgeScopePOP),
78+
SurrogateKeyForCacheKey(key, PurgeScopeGlobal),
79+
},
80+
})
81+
if err != nil {
82+
return nil, err
83+
}
84+
defer w.Close()
85+
86+
if _, err := io.Copy(w, e.Body); err != nil {
87+
w.Abandon()
88+
return nil, err
89+
}
90+
91+
if err := w.Close(); err != nil {
92+
w.Abandon()
93+
return nil, err
94+
}
95+
96+
return f.Body, nil
97+
}
98+
99+
f, err := tx.Found()
100+
if err != nil {
101+
return nil, err
102+
}
103+
104+
return f.Body, nil
105+
}
106+
107+
// GetOrSetEntry retrieves the object stored in the cache for the given
108+
// key if it exists, or inserts and returns the contents provided in the
109+
// [CacheEntry].
110+
//
111+
// The cache entry is only inserted when no value is present for the
112+
// key, and no other client is in the process of setting it.
113+
//
114+
// If the cache entry body content is costly to compute, consider using
115+
// [GetOrSet] instead to avoid creating its [io.Reader] in the case
116+
// where the value is already present.
117+
//
118+
// The returned [io.ReadCloser] must be closed by the caller when
119+
// finished.
120+
func GetOrSetEntry(key []byte, entry CacheEntry) (io.ReadCloser, error) {
121+
return GetOrSet(key, func() (CacheEntry, error) {
122+
return entry, nil
123+
})
124+
}
125+
126+
// PurgeScope controls the scope of a purge operation. It is used in
127+
// the [PurgeOptions] struct.
128+
type PurgeScope uint32
129+
130+
const (
131+
// PurgeScopePOP purges the entry only from the local POP cache.
132+
PurgeScopePOP PurgeScope = iota
133+
// PurgeScopeGlobal purges the entry from all POP caches.
134+
PurgeScopeGlobal
135+
)
136+
137+
// PurgeOptions controls the behavior of the [Purge] function.
138+
type PurgeOptions struct {
139+
Scope PurgeScope
140+
}
141+
142+
// Purge removes the entry associated with the given cache key, if one
143+
// exists.
144+
//
145+
// The scope of the purge can be controlled with the PurgeOptions.
146+
//
147+
// Purges are handled asynchronously, and the cached object may persist
148+
// in cache for a short time (~150ms or less) after this function
149+
// returns.
150+
func Purge(key []byte, opts PurgeOptions) error {
151+
sk := SurrogateKeyForCacheKey(key, opts.Scope)
152+
return purge.PurgeSurrogateKey(sk, purge.PurgeOptions{})
153+
}
154+
155+
// SurrogateKeyForCacheKey creates a surrogate key for the given cache
156+
// key and purge scope that is compatible with the Simple Cache API.
157+
// Each cache entry for the Simple Cache API is configured with
158+
// surrogate keys from this function.
159+
//
160+
// This function is provided as a convenience for implementors wishing
161+
// to add a Simple Cache-compatible surrogate key manually via the Core
162+
// Cache API ([github.com/fastly/compute-sdk-go/cache/core]) for
163+
// interoperability with [Purge].
164+
func SurrogateKeyForCacheKey(cacheKey []byte, scope PurgeScope) string {
165+
// The values are SHA-256 digests of the cache key (plus the local POP
166+
// for the local surrogate key), converted to uppercase hexadecimal.
167+
// This scheme must be kept consistent across all compute SDKs.
168+
h := sha256.New()
169+
h.Write(cacheKey)
170+
if scope == PurgeScopePOP {
171+
h.Write([]byte(os.Getenv("FASTLY_POP")))
172+
}
173+
174+
return strings.ToUpper(hex.EncodeToString(h.Sum(nil)))
175+
}

0 commit comments

Comments
 (0)