Skip to content
Closed
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ The project publishes 0.x prerelease versions; a stable release line is not yet
path now proves a lock can be inspected before treating those Windows errors
as contention, preserves prompt failure for unrelated permission errors, and
observes its deadline when a competing lock disappears during inspection.
- Recursive folder delete now removes associated objects from bucket storage
after the database transaction commits. Previously only DB rows were deleted,
leaving orphan blobs in the bucket. Blob deletion is best-effort and logged
on failure, matching the existing single-file delete behavior.

## [0.1.1] - 2026-08-31

Expand Down
11 changes: 11 additions & 0 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,17 @@ Redis AOF protects normal restarts but is not in the portable backup. A restore
therefore starts with an empty queue/replay window. Requeue or reindex any file
whose processing did not reach a terminal state before the backup.

### Object retention

Deleting a file or recursively deleting a folder removes the database rows and
then deletes the associated objects from the bucket. Object deletion is
best-effort and happens after the database transaction commits, so a process
crash between commit and blob removal may leave orphan objects in the bucket.
These orphans are unreferenced — no live database row points at them — and are
safe to leave in place. They are not automatically reclaimed; operators may
remove them manually if bucket accounting matters. This crash window also
applies to single-file deletion.

### Restore drill

Restore only into an empty installation. The script verifies every checksum
Expand Down
6 changes: 6 additions & 0 deletions docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,12 @@ real `tools/call → HTTP → PostgreSQL` remember, checkpoint inspection,
feedback, archive, restore and logical forget. Archive must remove the memory
from recall, restore must make it recallable again, and forget must redact the
live PostgreSQL projection and event actors before the database is destroyed.
The same required lifecycle gate runs `TestRecursiveDeleteCleansBlobs` against
its migrated PostgreSQL database and live MinIO endpoint. It requires the
named test's structured PASS event; a missing test, skipped service dependency,
or failed object cleanup fails the gate. The test uses only the disposable
stack's data, and the existing cleanup trap removes that stack.

The script never reads or writes the user's CLI configuration. Set
`MEM_ACCEPTANCE_HTTP_PORT` only when an explicit fixed port is required.

Expand Down
15 changes: 15 additions & 0 deletions scripts/acceptance_agent_memory.sh
Original file line number Diff line number Diff line change
Expand Up @@ -891,4 +891,19 @@ printf '%s' "$redaction_json" |
.marker_absent_from_events == true
' >/dev/null

log "Verifying recursive folder deletion against real PostgreSQL and MinIO"
(
cd "${REPO_ROOT}/server"
MEM_TEST_DB="$DB_URL" \
MEM_TEST_S3_ENDPOINT="127.0.0.1:${S3_PORT}" \
MEM_TEST_S3_ACCESS_KEY="mem" \
MEM_TEST_S3_SECRET_KEY="mem-minio-password" \
MEM_TEST_S3_BUCKET="mem" \
go test -json -count=1 -timeout 2m \
-run '^TestRecursiveDeleteCleansBlobs$' ./internal/folder
) | tee "${E2E_DIR}/folder-blob-cleanup.json"
jq -s -e 'any(.[]; .Action == "pass" and .Test == "TestRecursiveDeleteCleansBlobs")' \
"${E2E_DIR}/folder-blob-cleanup.json" >/dev/null \
|| die "TestRecursiveDeleteCleansBlobs did not execute and pass"

log "PASS: isolated HTTP, CLI and MCP Agent-memory lifecycle acceptance"
22 changes: 22 additions & 0 deletions scripts/test_release_guards.sh
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,28 @@ expect_failure() {
fi
}

check_changelog_subsections() {
awk '
/^## \[/ {
for (heading in seen) delete seen[heading]
}
/^### / && seen[$0]++ {
printf "ERROR: duplicate changelog subsection at line %d: %s\n", NR, $0 > "/dev/stderr"
exit 1
}
' "$1"
}

# A category may recur in another release, but not twice in the same release.
changelog_fixture="${tmp_dir}/changelog.md"
printf '%s\n' '## [Unreleased]' '### Fixed' '- Current fix.' \
'## [0.1.1] - 2026-08-31' '### Fixed' '- Earlier fix.' >"${changelog_fixture}"
check_changelog_subsections "${changelog_fixture}"
printf '%s\n' '## [Unreleased]' '### Fixed' '- First fix.' \
'### Security' '- Security fix.' '### Fixed' '- Second fix.' >"${changelog_fixture}"
expect_failure "duplicate Unreleased subsection" check_changelog_subsections "${changelog_fixture}"
check_changelog_subsections "${repo_root}/CHANGELOG.md"

verify_manifest() {
local directory="$1"
(
Expand Down
2 changes: 1 addition & 1 deletion server/cmd/memd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ func run() error {
"providers", cfg.ManagedEmbeddingProviders,
)
}
folderSvc := folder.New(database.Pool)
folderSvc := folder.New(database.Pool, store, logger)
fileSvc := file.New(database.Pool, store, folderSvc)
memorySvc := memory.New(database.Pool)
durableContextSvc := durablecontext.New(database.Pool, memorySvc)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ func TestContentDispositionHTTPPostgres(t *testing.T) {

authService := auth.New(database.Pool)
workspaceService := workspace.New(database.Pool)
folderService := folder.New(database.Pool)
folderService := folder.New(database.Pool, nil, nil)
user, err := authService.CreateUser(
ctx,
"content-disposition-"+uuid.NewString()+"@example.test",
Expand Down
2 changes: 1 addition & 1 deletion server/internal/api/relocate_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ func TestRelocateHTTPPostgres(t *testing.T) {
t.Fatalf("create restricted relocation token: %v", err)
}

folderService := folder.New(database.Pool)
folderService := folder.New(database.Pool, nil, nil)
fileService := file.New(database.Pool, nil, folderService)
server := httptest.NewServer((&Server{
Auth: authService,
Expand Down
14 changes: 7 additions & 7 deletions server/internal/file/workspace_lock_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,15 @@ func TestFilePathLockingIntegration(t *testing.T) {

t.Run("Put then Rename has one path", func(t *testing.T) {
userID, _ := createFileLockTenant(t, ctx, database.Pool, "put-rename")
folders := folder.New(database.Pool)
folders := folder.New(database.Pool, nil, nil)
if _, err := folders.Create(ctx, userID, "/A"); err != nil {
t.Fatalf("create destination: %v", err)
}
putBackend := pglockwait.NewBackend(t, ctx, dsn, "file-put")
renameBackend := pglockwait.NewBackend(t, ctx, dsn, "file-put-rename")
store := newBlockingObjectStore()
service := New(putBackend.Pool, store, folder.New(putBackend.Pool))
renameFolders := folder.New(renameBackend.Pool)
service := New(putBackend.Pool, store, folder.New(putBackend.Pool, nil, nil))
renameFolders := folder.New(renameBackend.Pool, nil, nil)

type putOutcome struct {
result *PutResult
Expand Down Expand Up @@ -130,7 +130,7 @@ func TestFilePathLockingIntegration(t *testing.T) {

t.Run("Move then Rename has one path", func(t *testing.T) {
userID, _ := createFileLockTenant(t, ctx, database.Pool, "move-rename")
folders := folder.New(database.Pool)
folders := folder.New(database.Pool, nil, nil)
source, err := folders.Create(ctx, userID, "/Source")
if err != nil {
t.Fatalf("create source: %v", err)
Expand Down Expand Up @@ -161,9 +161,9 @@ func TestFilePathLockingIntegration(t *testing.T) {
service := New(
moveBackend.Pool,
&recordingObjectStore{},
folder.New(moveBackend.Pool),
folder.New(moveBackend.Pool, nil, nil),
)
renameFolders := folder.New(renameBackend.Pool)
renameFolders := folder.New(renameBackend.Pool, nil, nil)
fileGate, fileGatePID := lockFilesTable(t, ctx, database.Pool)
moveDone := make(chan error, 1)
go func() {
Expand Down Expand Up @@ -206,7 +206,7 @@ func TestFilePathLockingIntegration(t *testing.T) {

t.Run("Relocate validates and commits path with name", func(t *testing.T) {
userID, _ := createFileLockTenant(t, ctx, database.Pool, "atomic-relocate")
folders := folder.New(database.Pool)
folders := folder.New(database.Pool, nil, nil)
source, err := folders.Create(ctx, userID, "/RelocateSource")
if err != nil {
t.Fatalf("create relocate source: %v", err)
Expand Down
73 changes: 60 additions & 13 deletions server/internal/folder/folder.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"time"

Expand Down Expand Up @@ -58,13 +59,29 @@ type Node struct {
Children []*Node `json:"children,omitempty"`
}

// blobDeleter is the narrow contract for best-effort object removal. The folder
// package depends on this interface rather than the concrete storage.Store so
// that tests and callers that do not need blob cleanup can pass nil.
type blobDeleter interface {
Delete(ctx context.Context, key string) error
}

// Service is the folder service.
type Service struct {
pool *pgxpool.Pool
pool *pgxpool.Pool
store blobDeleter
log *slog.Logger
}

// New constructs a folder Service.
func New(pool *pgxpool.Pool) *Service { return &Service{pool: pool} }
// New constructs a folder Service. A nil store skips object-storage cleanup on
// recursive delete (useful for tests that have no bucket). A nil logger falls
// back to slog.Default.
func New(pool *pgxpool.Pool, store blobDeleter, log *slog.Logger) *Service {
if log == nil {
log = slog.Default()
}
return &Service{pool: pool, store: store, log: log}
}

// Sentinel errors.
var (
Expand Down Expand Up @@ -586,9 +603,10 @@ func rewritePrefixTx(ctx context.Context, tx pgx.Tx, userID, srcID uuid.UUID, ol
//
// - recursive=false (default): folder must be empty (no subfolders, no files)
// or ErrNotEmpty is returned.
// - recursive=true: subfolders + files are deleted from the DB. S3 cleanup
// is TODO — for now we only purge the DB rows; orphan blobs will be
// reaped by a future garbage-collection pass.
// - recursive=true: subfolders + files are deleted from the DB and their
// objects are removed from bucket storage. Blob deletion is best-effort
// and happens after the database transaction commits, so a crash between
// commit and blob removal may leave orphan objects (see docs/DEPLOYMENT.md).
func (s *Service) Delete(ctx context.Context, userID uuid.UUID, path string, recursive bool) error {
norm, err := pathx.Normalize(path)
if err != nil {
Expand All @@ -597,7 +615,8 @@ func (s *Service) Delete(ctx context.Context, userID uuid.UUID, path string, rec
if norm == pathx.Root {
return ErrRootOp
}
return s.withPathMutationTx(ctx, userID, func(tx pgx.Tx) error {
var orphanKeys []string
txErr := s.withPathMutationTx(ctx, userID, func(tx pgx.Tx) error {
src, err := selectFolderByPathTx(ctx, tx, userID, norm)
if err != nil {
return err
Expand Down Expand Up @@ -628,17 +647,34 @@ func (s *Service) Delete(ctx context.Context, userID uuid.UUID, path string, rec
// explicit forget operation first.
return ErrContainsMemories
}
// Hard delete: remove all descendant files first (FKs cascade
// from folders → files would only NULL out folder_id, so we have
// to delete files explicitly).
if _, err := tx.Exec(ctx,
// Delete descendant files explicitly: the folder FK would only
// NULL out folder_id. RETURNING retains their storage keys for
// best-effort object removal after this transaction commits.
rows, err := tx.Query(ctx,
`DELETE FROM files
WHERE user_id = $1
AND (folder_id = $2 OR path = $3
OR left(path, length($3) + 1) = $3 || '/')`,
userID, src.ID, src.Path); err != nil {
OR left(path, length($3) + 1) = $3 || '/')
RETURNING storage_key`,
userID, src.ID, src.Path)
if err != nil {
return fmt.Errorf("recursive delete files: %w", err)
}
for rows.Next() {
var key string
if err := rows.Scan(&key); err != nil {
rows.Close()
return fmt.Errorf("scan storage_key: %w", err)
}
if key != "" {
orphanKeys = append(orphanKeys, key)
}
}
if err := rows.Err(); err != nil {
rows.Close()
return fmt.Errorf("iterate deleted storage_keys: %w", err)
}
rows.Close()
// Subfolder rows cascade via the FK ON DELETE CASCADE when we
// drop the parent below.
}
Expand All @@ -648,6 +684,17 @@ func (s *Service) Delete(ctx context.Context, userID uuid.UUID, path string, rec
}
return nil
})
if txErr != nil {
return txErr
}
if s.store != nil {
for _, key := range orphanKeys {
if derr := s.store.Delete(ctx, key); derr != nil {
s.log.Warn("folder.blob_delete_failed", "storage_key", key, "err", derr)
}
}
}
return nil
}

func isEmptyTx(ctx context.Context, tx pgx.Tx, userID, folderID uuid.UUID, path string) (bool, error) {
Expand Down
Loading
Loading