Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add TTL to taskfile.Cache #1702

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/task/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ func run() error {
Download: flags.Download,
Offline: flags.Offline,
Timeout: flags.Timeout,
CacheTTL: flags.CacheTTL,
Watch: flags.Watch,
Verbose: flags.Verbose,
Silent: flags.Silent,
Expand Down
6 changes: 5 additions & 1 deletion internal/flags/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/spf13/pflag"

"github.com/go-task/task/v3/internal/experiments"
"github.com/go-task/task/v3/taskfile"
"github.com/go-task/task/v3/taskfile/ast"
)

Expand Down Expand Up @@ -67,6 +68,7 @@ var (
Offline bool
ClearCache bool
Timeout time.Duration
CacheTTL time.Duration
)

func init() {
Expand Down Expand Up @@ -115,12 +117,14 @@ func init() {
pflag.BoolVarP(&ForceAll, "force", "f", false, "Forces execution even when the task is up-to-date.")
}

// Remote Taskfiles experiment will adds the "download" and "offline" flags
// Remote Taskfiles experiment will add the following flags.
if experiments.RemoteTaskfiles.Enabled {
pflag.BoolVar(&Download, "download", false, "Downloads a cached version of a remote Taskfile.")
pflag.BoolVar(&Offline, "offline", false, "Forces Task to only use local or cached Taskfiles.")
pflag.DurationVar(&Timeout, "timeout", time.Second*10, "Timeout for downloading remote Taskfiles.")
pflag.BoolVar(&ClearCache, "clear-cache", false, "Clear the remote cache.")
pflag.DurationVar(&CacheTTL, "remote-cache-ttl", taskfile.DefaultCacheTTL,
"TTL of remote Taskfiles downloaded into the local cache.")
}

pflag.Parse()
Expand Down
1 change: 1 addition & 0 deletions setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ func (e *Executor) readTaskfile(node taskfile.Node) error {
e.Download,
e.Offline,
e.Timeout,
e.CacheTTL,
e.TempDir.Remote,
e.Logger,
)
Expand Down
4 changes: 1 addition & 3 deletions signals_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,7 @@ import (
"time"
)

var (
SLEEPIT, _ = filepath.Abs("./bin/sleepit")
)
var SLEEPIT, _ = filepath.Abs("./bin/sleepit")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While you are simply applying a gofumpt on this.

I'm surprised to see:

  • the error is ignored
  • this variable name is in uppercase
  • the variable is apparently used only in one test.

I would expect this variable to be used only where needed and to check the possible error


func TestSignalSentToProcessGroup(t *testing.T) {
task, err := getTaskPath()
Expand Down
1 change: 1 addition & 0 deletions task.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ type Executor struct {
Download bool
Offline bool
Timeout time.Duration
CacheTTL time.Duration
Watch bool
Verbose bool
Silent bool
Expand Down
43 changes: 43 additions & 0 deletions taskfile/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# The `taskfile` package

```mermaid
---
title: taskfile.Cache behaviour
---
flowchart LR
%% Beginning state
start([A remote Taskfile
is required])

%% Checks to decide
cached{Remote Taskfile
already cached?}

subgraph checkTTL [Is the cached Taskfile still inside TTL?]
%% Beginning state
lastModified(Stat the cached
Taskfile and get last
modified timestamp)

%% Check to decide
timestampPlusTTL{Timestamp
plus TTL is in
the future?}

%% Flowlines
lastModified-->timestampPlusTTL
end

%% End states
useCached([Use the
cached Taskfile])
download(["(Re)download the
remote Taskfile"])

%% Flowlines
start-->cached
cached-- Yes -->lastModified
cached-- No -->download
timestampPlusTTL-- Yes -->useCached
timestampPlusTTL-- No -->download
```
47 changes: 43 additions & 4 deletions taskfile/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,51 @@ package taskfile

import (
"crypto/sha256"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)

// DefaultCacheTTL is used when a value is not explicitly given to a new Cache.
const DefaultCacheTTL = time.Duration(time.Hour * 24)

type Cache struct {
dir string
ttl time.Duration
}

func NewCache(dir string) (*Cache, error) {
// ErrExpired is returned when a cached file has expired.
var ErrExpired = errors.New("task: cache expired")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should go in the errors package.
You need to be a bit more specific in the error message, for example write wich file is expired


type CacheOption func(*Cache)

func NewCache(dir string, opts ...CacheOption) (*Cache, error) {
dir = filepath.Join(dir, "remote")
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
return &Cache{

cache := &Cache{
dir: dir,
}, nil
ttl: DefaultCacheTTL,
}

// Apply options.
for _, opt := range opts {
opt(cache)
}

return cache, nil
}

// WithTTL will override the default TTL setting on a new Cache.
func WithTTL(ttl time.Duration) CacheOption {
return func(cache *Cache) {
cache.ttl = ttl
}
}

func checksum(b []byte) string {
Expand All @@ -33,7 +60,19 @@ func (c *Cache) write(node Node, b []byte) error {
}

func (c *Cache) read(node Node) ([]byte, error) {
return os.ReadFile(c.cacheFilePath(node))
cfp := c.cacheFilePath(node)

fi, err := os.Stat(cfp)
if err != nil {
return nil, fmt.Errorf("could not stat cached file: %w", err)
}

expiresAt := fi.ModTime().Add(c.ttl)
if expiresAt.Before(time.Now()) {
return nil, ErrExpired
}

return os.ReadFile(cfp)
}

func (c *Cache) writeChecksum(node Node, checksum string) error {
Expand Down
99 changes: 99 additions & 0 deletions taskfile/cache_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package taskfile

import (
"fmt"
"io"
"os"
"path/filepath"
"testing"
"time"

"github.com/stretchr/testify/require"

"github.com/go-task/task/v3/internal/logger"
)

func ExampleWithTTL() {
c, _ := NewCache(os.TempDir(), WithTTL(2*time.Minute+30*time.Second))

fmt.Println(c.ttl)
// Output: 2m30s
}

var discardLogger = &logger.Logger{
Stdout: io.Discard,
Stderr: io.Discard,
}

func primeNewCache(t *testing.T, cacheOpts ...CacheOption) (*Cache, *FileNode) {
t.Helper()

cache, err := NewCache(t.TempDir(), cacheOpts...)
require.NoErrorf(t, err, "creating new cache")

// Prime the temporary cache directory with a basic Taskfile.
filename := "Taskfile.yaml"
srcTaskfile := filepath.Join("testdata", filename)
dstTaskfile := filepath.Join(cache.dir, filename)

taskfileBytes, err := os.ReadFile(srcTaskfile)
require.NoErrorf(t, err, "reading from testdata Taskfile (%s)", srcTaskfile)

err = os.WriteFile(dstTaskfile, taskfileBytes, 0o640)
require.NoErrorf(t, err, "writing to temporary Taskfile (%s)", dstTaskfile)

// Create a new file node in the cache, with the entrypoint copied above.
fileNode, err := NewFileNode(discardLogger, dstTaskfile, cache.dir)
require.NoError(t, err, "creating new file node")

return cache, fileNode
}

func TestCache(t *testing.T) {
cache, fileNode := primeNewCache(t)

// Attempt to read from cache, then write, then read again.
_, err := cache.read(fileNode)
require.ErrorIs(t, err, os.ErrNotExist, "reading from cache before writing should match error type")

writeBytes := []byte("some bytes")
err = cache.write(fileNode, writeBytes)
require.NoError(t, err, "writing bytes to cache")

readBytes, err := cache.read(fileNode)
require.NoError(t, err, "reading from cache after write should not error")
require.Equal(t, writeBytes, readBytes, "bytes read from cache should match bytes written")
}

func TestCacheInsideTTL(t *testing.T) {
// Prime a new Cache with a TTL of one minute.
cache, fileNode := primeNewCache(t, WithTTL(time.Minute))

// Write some bytes for the cached file.
writeBytes := []byte("some bytes")
err := cache.write(fileNode, writeBytes)
require.NoError(t, err, "writing bytes to cache")

// Reading from the cache while still inside the TTL should get the written bytes back.
readBytes, err := cache.read(fileNode)
require.NoError(t, err, "reading from cache inside TTL should not error")
require.Equal(t, writeBytes, readBytes, "bytes read from cache should match bytes written")
}

func TestCacheOutsideTTL(t *testing.T) {
// Prime a new Cache with an extremely short TTL.
cache, fileNode := primeNewCache(t, WithTTL(time.Millisecond))

// Write some bytes for the cached file.
writeBytes := []byte("some bytes")
err := cache.write(fileNode, writeBytes)
require.NoError(t, err, "writing bytes to cache")

// Sleep for 5ms so that the cached file is outside of TTL.
time.Sleep(5 * time.Millisecond)

// Reading from the cache after sleeping past the end of TTL should get an error.
readBytes, err := cache.read(fileNode)
require.Empty(t, readBytes, "should not have read any bytes from cache")
require.ErrorIs(t, err, ErrExpired, "should get 'expired' error when attempting to read from cache outside of TTL")
}
29 changes: 29 additions & 0 deletions taskfile/cache_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package taskfile_test

import (
"testing"
"time"

"github.com/stretchr/testify/require"

"github.com/go-task/task/v3/taskfile"
)

func TestNewCache(t *testing.T) {
testCases := map[string]struct {
options []taskfile.CacheOption
}{
"no options set": {},

"TTL option set": {
options: []taskfile.CacheOption{taskfile.WithTTL(time.Hour)},
},
}

for desc, testCase := range testCases {
t.Run(desc, func(t *testing.T) {
_, err := taskfile.NewCache(t.TempDir(), testCase.options...)
require.NoError(t, err, "creating new cache")
})
}
}
5 changes: 4 additions & 1 deletion taskfile/reader.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ type Reader struct {
download bool
offline bool
timeout time.Duration
cacheTTL time.Duration
tempDir string
logger *logger.Logger
}
Expand All @@ -46,6 +47,7 @@ func NewReader(
download bool,
offline bool,
timeout time.Duration,
cacheTTL time.Duration,
tempDir string,
logger *logger.Logger,
) *Reader {
Expand All @@ -56,6 +58,7 @@ func NewReader(
download: download,
offline: offline,
timeout: timeout,
cacheTTL: cacheTTL,
tempDir: tempDir,
logger: logger,
}
Expand Down Expand Up @@ -185,7 +188,7 @@ func (r *Reader) readNode(node Node) (*ast.Taskfile, error) {
var cache *Cache

if node.Remote() {
cache, err = NewCache(r.tempDir)
cache, err = NewCache(r.tempDir, WithTTL(r.cacheTTL))
if err != nil {
return nil, err
}
Expand Down
3 changes: 3 additions & 0 deletions taskfile/testdata/Taskfile.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
version: '3'

tasks: {}
6 changes: 3 additions & 3 deletions watch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,10 @@ Hello, World!
require.NoError(t, e.Setup())
buff.Reset()

err := os.MkdirAll(filepathext.SmartJoin(dir, "src"), 0755)
err := os.MkdirAll(filepathext.SmartJoin(dir, "src"), 0o755)
require.NoError(t, err)

err = os.WriteFile(filepathext.SmartJoin(dir, "src/a"), []byte("test"), 0644)
err = os.WriteFile(filepathext.SmartJoin(dir, "src/a"), []byte("test"), 0o644)
if err != nil {
t.Fatal(err)
}
Expand All @@ -67,7 +67,7 @@ Hello, World!
}(ctx)

time.Sleep(10 * time.Millisecond)
err = os.WriteFile(filepathext.SmartJoin(dir, "src/a"), []byte("test updated"), 0644)
err = os.WriteFile(filepathext.SmartJoin(dir, "src/a"), []byte("test updated"), 0o644)
if err != nil {
t.Fatal(err)
}
Expand Down
Loading