Skip to content
Merged
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
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ require (
github.com/dustin/go-humanize v1.0.1
github.com/emirpasic/gods v1.18.1
github.com/go-git/go-git/v5 v5.16.4
github.com/gofrs/flock v0.13.0
github.com/libgit2/git2go/v34 v34.0.0
github.com/minio/sha256-simd v1.0.1
github.com/modelpack/model-spec v0.0.7
Expand All @@ -24,7 +25,7 @@ require (
github.com/vbauerster/mpb/v8 v8.11.3
golang.org/x/crypto v0.45.0
golang.org/x/sync v0.18.0
golang.org/x/sys v0.39.0
golang.org/x/sys v0.40.0
google.golang.org/grpc v1.78.0
oras.land/oras-go/v2 v2.6.0
)
Expand Down
6 changes: 4 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
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/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
Expand Down Expand Up @@ -337,8 +339,8 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
Expand Down
210 changes: 210 additions & 0 deletions internal/cache/cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
/*
* Copyright 2025 The CNAI Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package cache

import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"time"

"github.com/gofrs/flock"
)

const (
// TTL is the time-to-live for cached items.
TTL = 24 * time.Hour

// FileLockRetryDelay is the delay between retries when acquiring file locks.
FileLockRetryDelay = 100 * time.Millisecond
)

// ErrNotFound is returned when an item is not found in the cache.
var ErrNotFound = errors.New("item not found")

// Cache is the interface for caching file related information.
type Cache interface {
// Get retrieves an item from the cache.
Get(ctx context.Context, path string) (*Item, error)

// Put inserts or updates an item in the cache.
Put(ctx context.Context, item *Item) error
}

// Item represents a cached file item.
type Item struct {
// Path is the absolute path of the file.
Path string `json:"path"`

// ModTime is the last modification time of the file.
ModTime time.Time `json:"mod_time"`

// Size is the size of the file in bytes.
Size int64 `json:"size"`

// Digest is the SHA-256 digest of the file.
Digest string `json:"digest"`

// CreatedAt is the time when the item was created.
CreatedAt time.Time `json:"created_at"`
}

// cache is the implementation of the Cache interface.
type cache struct {
// storageDir is the directory where the cache items are stored.
storageDir string

// flock is the file lock for the cache file.
flock *flock.Flock
}

// New creates a new cache instance.
func New(storageDir string) (Cache, error) {
c := &cache{
storageDir: storageDir,
}

// Ensure cache directory exists.
cacheDir := filepath.Dir(c.storagePath())
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return nil, err
}

c.flock = flock.New(c.storagePath())
return c, nil
}

// storagePath returns the path to the storage cache file.
func (c *cache) storagePath() string {
return filepath.Join(c.storageDir, "modctl-cache.json")
}

// readItems reads all items from the cache file without locking.
// The caller must hold the lock.
func (c *cache) readItems() (map[string]*Item, error) {
data, err := os.ReadFile(c.storagePath())
if err != nil {
// If the file doesn't exist, return an empty map.
if os.IsNotExist(err) {
return make(map[string]*Item), nil
}
return nil, err
}

// Handle empty file.
if len(data) == 0 {
return make(map[string]*Item), nil
}

var items []*Item
if err := json.Unmarshal(data, &items); err != nil {
return nil, err
}

itemMap := make(map[string]*Item, len(items))
for _, item := range items {
itemMap[item.Path] = item
}

return itemMap, nil
}

// writeItems writes items to the cache file without locking.
// The caller must hold the lock.
func (c *cache) writeItems(itemsMap map[string]*Item) error {
items := make([]*Item, 0, len(itemsMap))
for _, item := range itemsMap {
items = append(items, item)
}

data, err := json.Marshal(items)
if err != nil {
return err
}

return os.WriteFile(c.storagePath(), data, 0644)
}

// prune removes expired items from the map in-place.
func (c *cache) prune(itemsMap map[string]*Item) {
now := time.Now()
for path, item := range itemsMap {
if now.Sub(item.CreatedAt) > TTL {
delete(itemsMap, path)
}
}
}

// Get retrieves an item from the cache.
func (c *cache) Get(ctx context.Context, path string) (*Item, error) {
// Check context before locking
if err := ctx.Err(); err != nil {
return nil, err
}

if _, err := c.flock.TryLockContext(ctx, FileLockRetryDelay); err != nil {
return nil, err
}
defer c.flock.Unlock()

items, err := c.readItems()
if err != nil {
return nil, err
}

item, ok := items[path]
if !ok {
return nil, ErrNotFound
}

// If the item is expired, return not found.
if time.Since(item.CreatedAt) > TTL {
return nil, ErrNotFound
}

return item, nil
}

// Put inserts or updates an item in the cache.
func (c *cache) Put(ctx context.Context, item *Item) error {
// Check context before locking.
if err := ctx.Err(); err != nil {
return err
}

if _, err := c.flock.TryLockContext(ctx, FileLockRetryDelay); err != nil {
return err
}
defer c.flock.Unlock()

// Read existing items.
itemsMap, err := c.readItems()
if err != nil {
return err
}

// Update or insert the item.
itemsMap[item.Path] = item

// Prune expired items.
c.prune(itemsMap)

// Write back to file.
return c.writeItems(itemsMap)
}
Loading