resolve dangliing hashes

This commit is contained in:
2026-04-21 11:47:56 +02:00
parent 682b7a0228
commit 0a25bd559c
14 changed files with 390 additions and 59 deletions
+44 -13
View File
@@ -16,21 +16,32 @@ type BoltStore struct {
db *bbolt.DB
}
func NewBoltStore(path string) (*BoltStore, error) {
db, err := bbolt.Open(path, 0600, nil)
func NewBoltStore(path string, opts *bbolt.Options) (*BoltStore, error) {
db, err := bbolt.Open(path, 0600, opts)
if err != nil {
return nil, err
}
err = db.Update(func(tx *bbolt.Tx) error {
if _, err := tx.CreateBucketIfNotExists(pastesBucket); err != nil {
return err
bucketsExist := false
db.View(func(tx *bbolt.Tx) error {
if tx.Bucket(pastesBucket) != nil && tx.Bucket(hashesBucket) != nil {
bucketsExist = true
}
_, err := tx.CreateBucketIfNotExists(hashesBucket)
return err
return nil
})
if err != nil {
return nil, err
if !bucketsExist {
err = db.Update(func(tx *bbolt.Tx) error {
if _, err := tx.CreateBucketIfNotExists(pastesBucket); err != nil {
return err
}
_, err := tx.CreateBucketIfNotExists(hashesBucket)
return err
})
if err != nil {
db.Close()
return nil, err
}
}
return &BoltStore{db: db}, nil
@@ -70,7 +81,7 @@ func (s *BoltStore) GetIDByHash(hash string) (string, bool, error) {
return id, exists, err
}
func (s *BoltStore) Set(id, hash, content string) error {
func (s *BoltStore) Set(id, hash, content string, metadata map[string]interface{}) error {
return s.db.Update(func(tx *bbolt.Tx) error {
pb := tx.Bucket(pastesBucket)
hb := tx.Bucket(hashesBucket)
@@ -78,6 +89,8 @@ func (s *BoltStore) Set(id, hash, content string) error {
paste := Paste{
Content: content,
CreatedAt: time.Now(),
Hash: hash,
Metadata: metadata,
}
encoded, err := json.Marshal(paste)
if err != nil {
@@ -93,12 +106,23 @@ func (s *BoltStore) Set(id, hash, content string) error {
func (s *BoltStore) Del(id string) error {
return s.db.Update(func(tx *bbolt.Tx) error {
return tx.Bucket(pastesBucket).Delete([]byte(id))
pb := tx.Bucket(pastesBucket)
hb := tx.Bucket(hashesBucket)
val := pb.Get([]byte(id))
if val != nil {
var p Paste
if err := json.Unmarshal(val, &p); err == nil && p.Hash != "" {
hb.Delete([]byte(p.Hash))
}
}
return pb.Delete([]byte(id))
})
}
func (s *BoltStore) Cleanup(maxAge time.Duration) {
var keysToDelete [][]byte
var hashesToDelete [][]byte
s.db.View(func(tx *bbolt.Tx) error {
b := tx.Bucket(pastesBucket)
c := b.Cursor()
@@ -107,6 +131,9 @@ func (s *BoltStore) Cleanup(maxAge time.Duration) {
if err := json.Unmarshal(v, &p); err == nil {
if time.Since(p.CreatedAt) > maxAge {
keysToDelete = append(keysToDelete, k)
if p.Hash != "" {
hashesToDelete = append(hashesToDelete, []byte(p.Hash))
}
}
}
}
@@ -115,9 +142,13 @@ func (s *BoltStore) Cleanup(maxAge time.Duration) {
if len(keysToDelete) > 0 {
s.db.Update(func(tx *bbolt.Tx) error {
b := tx.Bucket(pastesBucket)
pb := tx.Bucket(pastesBucket)
hb := tx.Bucket(hashesBucket)
for _, k := range keysToDelete {
b.Delete(k)
pb.Delete(k)
}
for _, h := range hashesToDelete {
hb.Delete(h)
}
return nil
})
+87 -7
View File
@@ -8,22 +8,40 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.etcd.io/bbolt"
bbolterrors "go.etcd.io/bbolt/errors"
)
func TestBoltStore(t *testing.T) {
dbPath := "test.db"
defer os.Remove(dbPath)
s, err := NewBoltStore(dbPath)
s, err := NewBoltStore(dbPath, nil)
require.NoError(t, err)
defer s.Close()
t.Run("Open Existing Store", func(t *testing.T) {
dbPath := "existing_store.db"
defer os.Remove(dbPath)
s1, err := NewBoltStore(dbPath, nil)
require.NoError(t, err)
err = s1.Close()
require.NoError(t, err)
s2, err := NewBoltStore(dbPath, nil)
require.NoError(t, err)
defer s2.Close()
assert.NotNil(t, s2)
})
t.Run("Set and Get", func(t *testing.T) {
id := "id1"
hash := "hash1"
content := "content1"
err := s.Set(id, hash, content)
err := s.Set(id, hash, content, nil)
assert.NoError(t, err)
p, exists, err := s.Get(id)
@@ -37,7 +55,7 @@ func TestBoltStore(t *testing.T) {
hash := "hash2"
content := "content2"
s.Set(id, hash, content)
s.Set(id, hash, content, nil)
storedID, exists, err := s.GetIDByHash(hash)
assert.NoError(t, err)
assert.True(t, exists)
@@ -46,19 +64,25 @@ func TestBoltStore(t *testing.T) {
t.Run("Del", func(t *testing.T) {
id := "id3"
s.Set(id, "h3", "c3")
s.Set(id, "h3", "c3", nil)
err := s.Del(id)
assert.NoError(t, err)
_, exists, _ := s.Get(id)
assert.False(t, exists)
_, hashExists, _ := s.GetIDByHash("h3")
assert.False(t, hashExists)
})
t.Run("Cleanup", func(t *testing.T) {
s.Set("old", "oldhash", "oldcontent")
s.Set("old", "oldhash", "oldcontent", nil)
s.Cleanup(-time.Hour)
_, exists, _ := s.Get("old")
assert.False(t, exists)
_, hashExists, _ := s.GetIDByHash("oldhash")
assert.False(t, hashExists)
})
t.Run("Cleanup Bad Data", func(t *testing.T) {
@@ -68,15 +92,71 @@ func TestBoltStore(t *testing.T) {
s.Cleanup(-time.Hour)
// Should skip without panic
})
t.Run("Set Marshal Error", func(t *testing.T) {
dbPath := "marshal_error.db"
db, _ := bbolt.Open(dbPath, 0666, nil)
store := &BoltStore{db: db}
defer db.Close()
defer os.Remove(dbPath)
err := store.Set("id", "h", "c", map[string]interface{}{
"foo": func() {},
})
assert.Error(t, err)
})
t.Run("Set Put Error", func(t *testing.T) {
dbPath := "put_error.db"
s, err := NewBoltStore(dbPath, nil)
require.NoError(t, err)
defer s.Close()
defer os.Remove(dbPath)
err = s.Set("", "hash", "content", nil)
assert.ErrorIs(t, err, bbolterrors.ErrKeyRequired)
})
t.Run("Del Error", func(t *testing.T) {
dbPath := "error_del.db"
db, _ := bbolt.Open(dbPath, 0666, nil)
store := &BoltStore{db: db}
db.Close()
err := store.Del("id")
assert.Error(t, err)
os.Remove(dbPath)
})
t.Run("Get Error", func(t *testing.T) {
dbPath := "error_get.db"
db, _ := bbolt.Open(dbPath, 0666, nil)
store := &BoltStore{db: db}
db.Close()
_, _, err := store.Get("id")
assert.Error(t, err)
os.Remove(dbPath)
})
}
func TestNewBoltStoreError(t *testing.T) {
// Use a directory name as file path to trigger error
err := os.Mkdir("testdir", 0755)
require.NoError(t, err)
defer os.RemoveAll("testdir")
s, err := NewBoltStore("testdir")
s, err := NewBoltStore("testdir", nil)
assert.Error(t, err)
assert.Nil(t, s)
t.Run("Bucket Creation Error", func(t *testing.T) {
dbPath := "bucket_error.db"
defer os.Remove(dbPath)
originalPastesBucket := pastesBucket
pastesBucket = []byte("")
defer func() { pastesBucket = originalPastesBucket }()
s, err := NewBoltStore(dbPath, nil)
assert.ErrorIs(t, err, bbolterrors.ErrBucketNameRequired)
assert.Nil(t, s)
})
}
+6 -1
View File
@@ -32,12 +32,14 @@ func (s *MemoryStore) GetIDByHash(hash string) (string, bool, error) {
return id, ok, nil
}
func (s *MemoryStore) Set(id, hash, content string) error {
func (s *MemoryStore) Set(id, hash, content string, metadata map[string]interface{}) error {
s.mu.Lock()
defer s.mu.Unlock()
s.pastes[id] = &Paste{
Content: content,
CreatedAt: time.Now(),
Hash: hash,
Metadata: metadata,
}
s.hashes[hash] = id
return nil
@@ -46,6 +48,9 @@ func (s *MemoryStore) Set(id, hash, content string) error {
func (s *MemoryStore) Del(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
if p, ok := s.pastes[id]; ok && p.Hash != "" {
delete(s.hashes, p.Hash)
}
delete(s.pastes, id)
return nil
}
+6 -3
View File
@@ -14,7 +14,7 @@ func TestMemoryStore(t *testing.T) {
hash := "hash1"
content := "content1"
err := s.Set(id, hash, content)
err := s.Set(id, hash, content, nil)
assert.NoError(t, err)
p, exists, err := s.Get(id)
@@ -29,7 +29,7 @@ func TestMemoryStore(t *testing.T) {
hash := "hash2"
content := "content2"
s.Set(id, hash, content)
s.Set(id, hash, content, nil)
storedID, exists, err := s.GetIDByHash(hash)
assert.NoError(t, err)
assert.True(t, exists)
@@ -50,11 +50,14 @@ func TestMemoryStore(t *testing.T) {
t.Run("Del", func(t *testing.T) {
id := "id3"
s.Set(id, "h3", "c3")
s.Set(id, "h3", "c3", nil)
err := s.Del(id)
assert.NoError(t, err)
_, exists, _ := s.Get(id)
assert.False(t, exists)
_, hashExists, _ := s.GetIDByHash("h3")
assert.False(t, hashExists)
})
}
+5 -3
View File
@@ -3,13 +3,15 @@ package store
import "time"
type Paste struct {
Content string `json:"content"`
CreatedAt time.Time `json:"createdAt"`
Content string `json:"content"`
CreatedAt time.Time `json:"createdAt"`
Hash string `json:"hash,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
type Store interface {
Get(id string) (*Paste, bool, error)
GetIDByHash(hash string) (string, bool, error)
Set(id, hash, content string) error
Set(id, hash, content string, metadata map[string]interface{}) error
Del(id string) error
}