Skip to content

feat: clean up old revisions according to a cleanup strategy - #895

Draft
pbleser-oc wants to merge 16 commits into
opencloud-eu:mainfrom
pbleser-oc:feat/1419-clean-up-file-versions
Draft

pbleser-oc wants to merge 16 commits into
opencloud-eu:mainfrom
pbleser-oc:feat/1419-clean-up-file-versions

Conversation

@pbleser-oc

@pbleser-oc pbleser-oc commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

Implements the reva side of opencloud-eu/opencloud#1419: old revisions (versions) are deleted according to a configurable cleanup strategy, for the decomposed, decomposeds3 and posix drivers.

The OpenCloud side (configuration and the opencloud storage-users revisions cleanup command) follows in a separate PR in opencloud-eu/opencloud, once this one is merged.

What it does

Cleanup whenever a file gets a new revision

Triggers:

  • after an upload replaced an existing file, once it is complete (with async postprocessing, once postprocessing has succeeded);
  • after a revision was restored; the revision holding the content from before the restore is always kept;
  • with posix and enable_fs_revisions, after a change on disk created a revision, once the new content has been copied to .CURRENT;
  • in the new upload pipeline (not called yet), once per upload: when MarkProcessing clears the processing mark of its session. Since reimplement commit upload #902, CommitUpload only writes the bytes of an upload and no longer creates a revision; the revision and the processing mark come from the planned PrepareUpload (feat: OCISDEV-1058 prepare upload owncloud/reva#664, not ported yet), so clearing the mark is when the upload is done.

Files with an upload still in postprocessing are skipped.

Strategies

  • auto (keeps one revision per time slot, with longer slots for older revisions),
  • a number of revisions (10),
  • a duration (30d; with an uppercase unit, 1D or 1W, counting from midnight),
    and combinations: 14d, auto, auto, 90d, 5, 30d, 7d, auto, 90d.

Empty or disabled (the default) keeps everything. An invalid strategy makes the driver fail at startup.

In the background by default

  • a queue and workers per driver, so the cleanup doesn't slow down the write that triggered it
  • requests are de-duplicated per file,
  • and a file never occupies more than one worker

The worker count, the queue size and what happens when the queue is full are configurable. By default, a cleanup that finds the queue full is skipped (drop, logged with the file's location on disk), and happens with the file's next revision, so that a write never waits for the cleanup. With block, the write waits for room, but at most for a timeout (5s by default, 0 for as long as it takes), after which the cleanup is skipped as with drop: blocking without a bound would, under sustained load, slow down every write, e.g. the consumer of the postprocessing events.

A file whose space was disabled (moved to the trash) between being queued and being cleaned up is skipped, and its files are left alone.

Decomposedfs.Shutdown stops the workers.

A queued cleanup doesn't keep the request that triggered it alive: its context only carries the logger, with the request's fields and id, and it gets a trace of its own, a span revision cleanup linked to the request's span.

Node Lock

The node lock is held briefly: the blobs of deleted revisions are deleted after the lock is released, as with decomposeds3 every blob is a request. A revision only counts as deleted once its blob is; otherwise it is reported and counted as a failure.

Posix with use_space_groups

The cleanup switches to the group of the space before it touches its files (revisioncleanup.SpaceScoper), as the middleware does for user requests, also in background workers and in on-demand runs.

On Demand

Decomposedfs.CleanupRevisionsUnchecked (the revisioncleanup.OnDemand interface) cleans up all spaces, given spaces, or a file or folder within a space.

With posix, it can also clean up a file or folder given by its path on disk.

It supports

  • a dry run,
  • its own strategy and async settings,
  • a timeout with a graceful stop,
  • and a random order for runs that can't cover everything at once.

It reports a Result for every file.

It needs no user in the context and checks no permissions, as it is meant for command line tools.

Prometheus Metrics

reva_revision_cleanup_*: deleted revisions and bytes, failed deletions, queue length, capacity and full events, workers.

A Reason for Every Decision

Logged at debug level for every deleted revision, e.g. deleted by "auto" (last hour): its slot of 1 minute starting … already keeps a newer revision.

Configuration

New decomposedfs options, which posix inherits:

option default
revisions_cleanup_strategy "" (disabled)
revisions_cleanup_async true
revisions_cleanup_async_workers 0 (half the CPUs, at least 2)
revisions_cleanup_async_queue_size 0 (64 per worker)
revisions_cleanup_async_queue_full drop
revisions_cleanup_async_queue_full_timeout 5s (for block; 0 waits as long as it takes)

Changes to shared code

  • Naming of functions without permission checks: they are named after their checked counterpart with an Unchecked suffix, so that they aren't used by mistake where permissions must be checked. For that, a separate commit renames the existing Node.DeleteRevision to Node.DeleteRevisionUnchecked, as Decomposedfs.DeleteRevision, of the same name, checks permissions. The functions of the cleanup that keep their names, e.g. Cleaner.Cleanup, say in their doc comment that they check no permissions.
  • node.Tree.ListRevisionsUnchecked lists revisions without permission checks; ListRevisions now checks permissions and calls it. There is also a new ListFolderKnownUnchecked for the on-demand walk, which, unlike ListFolder, checks no permissions and lists only the children known to the storage: with posix, it skips files created on disk that aren't assimilated yet instead of assimilating them, so that a cleanup, a dry run in particular, changes nothing but the revisions it deletes.
  • Deleting a revision needs no driver-specific code. revisioncleanup.PurgeRevisionUnchecked validates the key (node.ValidateRevisionKey, which rejects keys that would turn into an empty version), then calls Node.DeleteRevisionUnchecked and Tree.DeleteBlob.
  • aspects.Aspects has a new field, RevisionsCleanup. The posix test env passes it on, as posix.New does.
  • UnixMapper.ScopeUserByIds, which the cleanup calls for every file with use_space_groups, leaves the goroutine locked to its OS thread when it fails. That would pin a background worker to its thread for good; the fix is in its own PR, fix: unlock the OS thread when ScopeUserByIds fails #899.
  • Decomposedfs.MarkProcessing, new upstream, triggers the cleanup, see above.
  • spaceidindex.Index.Indexes lists the indexes, so that "all spaces" covers every space type.
  • posixFS forwards CleanupRevisionsUnchecked.
  • The strategies live in the new package pkg/storage/revisions, which only plans what to keep and what to delete and touches no storage. It rejects X1, X2 pairs of the same kind, with the equivalent single value in the error. A number of revisions is counted in absolute terms. Only days and weeks can count from midnight, with the uppercase units D and W.

Documentation

  • pkg/storage/pkg/decomposedfs/revisioncleanup/README.md, for operators: options, strategies with examples, how the age of a revision is measured, when the cleanup runs, metrics with PromQL examples, logging.
  • pkg/storage/pkg/decomposedfs/README.md, new: how the three drivers are built on decomposedfs, with diagrams. It documents the code as it is on main, so it has a commit of its own, the first one, together with package comments for decomposedfs, decomposed, decomposeds3 and posix that point to it. The feature commits then add the few lines about the revision cleanup.

Review notes

Follow-ups

The legacy copy pkg/storage/utils/decomposedfs (ocis and s3ng drivers) is left unchanged; it gets no revision cleanup.

Tests

Unit tests for the strategies and the parser (including the reasons), the Service (de-duplication, queue full policies, shutdown, Wait, the context of a queued cleanup), the metrics, and the cleanup on real decomposedfs and posix trees: uploads, async postprocessing, MarkProcessing clearing the processing mark of its session, restores, changes on disk, overlapping uploads, blobs deleted only after the node lock is released, and on-demand runs including dry runs, timeouts, paths on disk and spaces given twice. Every commit builds and passes go vet, golangci-lint and the tests; make test and make lint pass on the head. The integration tests (tests/integration) fail locally in the same way with and without this PR: 8 public-link specs fail on main too, and the whole suite runs into its 15 minute timeout. End to end, the OpenCloud PR adds Behat acceptance tests for the command and for the configured strategy.

Not tested: the actual switch to the group of a space with use_space_groups. The tests check that the cleanup asks for it (with a stand-in) and that the posix tree puts the group of the space root into the context, but switching the fsgid needs CAP_SETGID, and as root file permissions don't apply. The switch itself is the existing UnixMapper.ScopeUserByIds, which the middleware and the assimilation use too; what's new is only that the cleanup calls it. A check on a server with use_space_groups is still to be done.

@pbleser-oc pbleser-oc self-assigned this Oct 21, 2026
@github-project-automation github-project-automation Bot moved this to Qualification in OpenCloud Team Board Oct 21, 2026
pbleser-oc added a commit to pbleser-oc/opencloud that referenced this pull request Oct 21, 2026
Points github.com/opencloud-eu/reva/v2 at the head of
opencloud-eu/reva#895 in its fork (pbleser-oc/reva 4ebaa069b), which
adds the revision cleanup this branch configures and calls, and
re-vendors.

Temporary: to be replaced by a regular reva bump once
opencloud-eu/reva#895 is merged.

@butonic butonic left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Ok, I could lament that some of the changes are not really necessary for the retention, but then again, they all prepare the code to be testable. Ultimately, they make the retention feature properly testable.

I'm happy to merge this now and change the implementation after we decided opencloud-eu/opencloud#3735, unless someone thinks we should have a non-surprising retention policy from the start.

@butonic

butonic commented Oct 21, 2026

Copy link
Copy Markdown
Member

two comments from a local review:

pkg/storage/pkg/decomposedfs/revisioncleanup/service.go, Service.send:

With the default block policy, a driver Service blocks the calling request goroutine (FinishUploadDecomposed, MarkProcessing, CommitUpload, RestoreRevision) until a worker frees a queue slot: stopped is only set for on-demand runs, so the request's context is never considered. This is documented as intentional backpressure and can't deadlock (the node lock is released before enqueueing, workers never block in send), but under sustained load with a slow blobstore (decomposeds3) it directly throttles uploads — and with FinishUploadDecomposed's context.WithoutCancel, even a disconnected client keeps waiting.

Options worth considering: default to drop (nothing is lost permanently, the node is cleaned up again at its next revision, and the on-demand run covers the rest), or make block bounded (wait for room up to a timeout, then drop and count it on reva_revision_cleanup_queue_full_total).

Since this can be changed and is disabled by default, not a merge blocker and we can revisit this after a loadtest.


pkg/storage/pkg/decomposedfs/revisioncleanup/service.go, Service.cleanupNode:

node.ReadNode(ctx, s.lu, spaceID, nodeID, "", false, nil, false) passes canListDisabledSpace=false, so a queued cleanup of a file in a disabled (trashed) space fails with "could not read node" and is reported as an error result — while the on-demand path (Decomposedfs.revisionCleanupRoot) explicitly distinguishes disabled spaces and reports them as skipped. Cosmetic, but the worker path could do the same (read with true, check IsDisabled, report Skipped) so disabled spaces don't show up as cleanup failures in the results/logs.

@pbleser-oc
pbleser-oc force-pushed the feat/1419-clean-up-file-versions branch from 4ebaa06 to 6083983 Compare October 21, 2026 16:03
pbleser-oc added a commit to pbleser-oc/opencloud that referenced this pull request Oct 21, 2026
Points github.com/opencloud-eu/reva/v2 at the head of
opencloud-eu/reva#895 in its fork (pbleser-oc/reva 608398352), which
adds the revision cleanup this branch configures and calls, and
re-vendors.

Temporary: to be replaced by a regular reva bump once
opencloud-eu/reva#895 is merged.
@pbleser-oc
pbleser-oc force-pushed the feat/1419-clean-up-file-versions branch from 6083983 to eba5978 Compare October 21, 2026 16:49
pbleser-oc added a commit to pbleser-oc/opencloud that referenced this pull request Oct 21, 2026
Points github.com/opencloud-eu/reva/v2 at the head of
opencloud-eu/reva#895 in its fork (pbleser-oc/reva eba5978bc), which
adds the revision cleanup this branch configures and calls, and
re-vendors.

Temporary: to be replaced by a regular reva bump once
opencloud-eu/reva#895 is merged.
@pbleser-oc

Copy link
Copy Markdown
Member Author

Thanks, both fixed, amended into the commits they belong to:

  1. Unbounded block: block now waits at most revisions_cleanup_async_queue_full_timeout (default 5s, 0 for as long as it takes), then skips the cleanup of the node like drop, logs it with the node's location on disk, and counts it as dropped in reva_revision_cleanup_queue_full_total (after blocked when it started to wait). In addition, drop is now the default, so that a write never waits for the cleanup unless configured to. The on-demand runs are the exception: they queue files as fast as the walk finds them, so dropping them would skip most of them. Without AsyncOptions of their own, they wait for room as long as it takes, and the opencloud storage-users revisions cleanup command defaults to --queue-full block with --queue-full-timeout 0.
  2. Disabled space: cleanupNode now reads the node with canListDisabledSpace=true, and skips it if its space is disabled. On-demand runs report it as skipped ("the space is disabled") instead of as an error.

@pbleser-oc
pbleser-oc force-pushed the feat/1419-clean-up-file-versions branch from eba5978 to 60d1d78 Compare October 23, 2026 06:17
pbleser-oc added a commit to pbleser-oc/opencloud that referenced this pull request Oct 23, 2026
Points github.com/opencloud-eu/reva/v2 at the head of
opencloud-eu/reva#895 in its fork (pbleser-oc/reva 60d1d7896), which
adds the revision cleanup this branch configures and calls, and
re-vendors.

Temporary: to be replaced by a regular reva bump once
opencloud-eu/reva#895 is merged.
@pbleser-oc

pbleser-oc commented Oct 23, 2026 •

Copy link
Copy Markdown
Member Author

Issues and PRs connected to this one, grouped by how they relate to it:

Merge order

  1. this PR
  2. feat: clean up old revisions according to a cleanup strategy opencloud#3778, configuration and opencloud storage-users revisions cleanup command. It temporarily uses this PR's branch from my fork; once this PR is merged, that becomes a regular reva bump.
  3. docs(admin): document the cleanup of old file versions docs#1222, admin docs for the configuration and the command of feat: clean up old revisions according to a cleanup strategy opencloud#3778

Independent, in any order: #899, ScopeUserByIds leaves the goroutine locked to its OS thread on errors. Found here, as the cleanup's background workers call it for every file with use_space_groups.

Follow-ups of this PR

Overlapping uploads

Found while checking how the cleanup copes with a file that gets a new upload while an older one is still in postprocessing:

posix

pbleser-oc added a commit to pbleser-oc/reva that referenced this pull request Oct 23, 2026
UnixMapper.ScopeUserByIds locks the goroutine to its OS thread before it
switches the fsuid and the fsgid, as they belong to the thread. The
function it returns restores them and unlocks the thread again. On an
error, though, it returned without that function and without unlocking:
the goroutine stayed locked to its thread for good, and a fsuid it had
already switched before the fsgid failed was not restored.

For a short-lived goroutine of a request, Go discards the locked thread
when the goroutine ends. Long-lived goroutines would stay pinned to their
thread after a single failure, with one more lock nested for every
further one. The background workers of the revision cleanup proposed in
opencloud-eu#895 are such goroutines: with use_space_groups, they
switch to the group of a space for every file.

ScopeUserByIds now restores the fsuid and unlocks the thread on every
error path.

Tests: the fsuid and fsgid are restored after switching, and unchanged
after a failed switch to a group the process is not in (skipped where
the switch succeeds, e.g. as root). Whether the thread was unlocked
can't be observed from Go, there is no API to query it.
@pbleser-oc
pbleser-oc force-pushed the feat/1419-clean-up-file-versions branch from 60d1d78 to e42dd53 Compare October 23, 2026 06:49
pbleser-oc added a commit to pbleser-oc/opencloud that referenced this pull request Oct 23, 2026
Points github.com/opencloud-eu/reva/v2 at the head of
opencloud-eu/reva#895 in its fork (pbleser-oc/reva e42dd538c), which
adds the revision cleanup this branch configures and calls, and
re-vendors.

Temporary: to be replaced by a regular reva bump once
opencloud-eu/reva#895 is merged.
@pbleser-oc

Copy link
Copy Markdown
Member Author

I added one commit after approval, 2bcbce0

Why: opencloud storage-users revisions cleanup (opencloud-eu/opencloud#3778) runs its own storage driver in its own process, next to the running server. With the default in-memory file metadata cache (STORAGE_USERS_FILEMETADATA_CACHE_STORE=memory), the server doesn't learn about the revisions the command deletes: their entries stay in the server's cache, for up to 24 hours. The same problem with opencloud posixfs scan is opencloud-eu/opencloud#3804.

The command only deletes revisions, so the only stale entries are those of deleted revisions. But RestoreRevision and DownloadRevision trusted that metadata without checking the disk. Reproduced locally with a server and the command built from these branches:

restore of a revision the command deleted restore file afterwards
decomposed, the server still has the revision's metadata cached (e.g. the Versions panel listed it) HTTP 204, reported as successful broken: GET returns 500, the node points to the revision's deleted blob; the previous content survives as a new revision
decomposed, server restarted before the restore HTTP 500 intact
posix, still cached HTTP 500 intact

All three cases also left a revision of the current content behind, as RestoreRevision created it before reading the revision.

The fix:

  • Decomposedfs.RestoreRevision checks that the revision exists on disk, under the node's lock (which deleting a revision takes too), before it creates the revision of the current content; otherwise it drops the stale cache entry and returns NotFound.
  • Tree.DownloadRevision (decomposed and posix) checks the same and returns NotFound.

With the fix, the stale-cache restore above returns HTTP 404, the file stays intact, and no revision is left behind. The new tests in revisions_test.go simulate a revision deleted by another process (cache entry kept, files removed) and fail without the fix. They cover decomposed only: the posix test helper builds its tree without permissions, so the posix DownloadRevision change has no test of its own.

This affects any stale metadata cache (e.g. several storage-users instances with the memory store), not only the cleanup command. The remaining parts belong to the other PRs: in opencloud-eu/opencloud#3778, failing fast without NATS and reacting to Ctrl+C while the command opens the driver (it has the problem of opencloud-eu/opencloud#3805), and in opencloud-eu/docs#1222, describing what running the command next to the server implies.

@pbleser-oc
pbleser-oc requested a review from aduffeck October 25, 2026 01:03
@pbleser-oc

Copy link
Copy Markdown
Member Author

@aduffeck could you review 2bcbce0? It touches RestoreRevision for both drivers and the posix DownloadRevision, see the comment above.

pbleser-oc added a commit to pbleser-oc/opencloud that referenced this pull request Oct 25, 2026
Points github.com/opencloud-eu/reva/v2 at the head of
opencloud-eu/reva#895 in its fork (pbleser-oc/reva 2bcbce084), which
adds the revision cleanup this branch configures and calls, and
re-vendors.

Temporary: to be replaced by a regular reva bump once
opencloud-eu/reva#895 is merged.
@butonic
butonic marked this pull request as draft October 26, 2026 15:39
@maki5 maki5 removed the status in OpenCloud Team Board Oct 28, 2026
The decomposed, decomposeds3 and posix drivers share most of their logic
through pkg/storage/pkg/decomposedfs, but how they are put together, and
where posix overrides the shared code, is spread over many places.

The new pkg/storage/pkg/decomposedfs/README.md describes:

 * the three drivers and where they keep content and metadata
 * how requests reach the storage, and the components and extension
   points (aspects.Aspects), with diagrams
 * how each driver is registered, created and builds itself
 * how Decomposedfs delegates to the aspects, what differs per
   component, and how posix wraps and extends Decomposedfs
 * changes on disk with posix (watcher, assimilation)
 * where to start reading

The packages decomposedfs, decomposed, decomposeds3 and posix get a
package comment that points to it.

Documentation only, no functional change.
Node.DeleteRevision checks no permissions, while
Decomposedfs.DeleteRevision, of the same name, checks that the user may
restore file versions. The two only differ in their receiver, so the
unchecked one is easy to pick by mistake, e.g. in a new code path that
acts on behalf of a user.

Functions that check no permissions get an "Unchecked" suffix after the
name of their checked counterpart, so that the difference shows at the
call site, and both appear next to each other when looking for one of
them. The revision cleanup builds on this convention for its own
functions without permission checks.

No functional change.
The new package pkg/storage/revisions decides which revisions of a file
to keep and which to delete, according to a strategy given as a short
specification. It only plans: it works on a list of revisions (key,
modification time, size) and deletes nothing itself, and nothing calls
it yet.

The specification is a comma separated list of up to three parts. Each
X is either a plain number, a number of revisions counting from the
newest one, or a number with a unit suffix (w, d, h, m, s), a duration:

 * "auto": thin out revisions the older they get, keeping one per time
   slot (10 seconds in the last minute, then one per minute, hour, day,
   and week for the older ones)
 * "X": delete everything beyond X, without thinning, e.g. "10", "30d";
   "0" deletes all revisions
 * "X, auto": keep everything within X, thin out the older revisions
 * "auto, X": delete everything beyond X, thin out the rest
 * "X1, X2": keep everything within X1, delete everything beyond X2;
   one must be a number and the other a duration, as two values of the
   same kind collapse into one ("5, 10" is the same as "10"), so such
   specifications are rejected with an error naming the equivalent
 * "X1, auto, X2": keep everything within X1, delete everything beyond
   X2, apply "auto" in between

Deleting by number of revisions is absolute: in "7d, 10" the revisions
younger than 7 days count towards the 10. Durations in days and weeks
with an uppercase unit, D or W, count from midnight instead of from now
("1D" means since yesterday 00:00 rather than the last 24 hours); this
uses calendar arithmetic, so it stays at midnight across DST changes.

Empty and "disabled" are not strategies: there is nothing to plan, and
the callers handle them.
…n checks

The revision cleanup has to list and delete revisions on its own, e.g.
in async postprocessing, where there is no user in the context. Nothing
calls these functions yet. Like Node.DeleteRevisionUnchecked, they are
named after their checked counterparts with an "Unchecked" suffix, so
that they aren't used by mistake where permissions must be checked.

 * node.Tree has a new method ListRevisionsUnchecked, implemented in
   both trees, which lists a node's revisions without permission checks;
   ListRevisions now checks permissions and then calls it
 * deleting a revision needs no driver specific code: the new
   revisioncleanup.PurgeRevisionUnchecked, the unchecked counterpart of
   Decomposedfs.DeleteRevision, validates the key, removes the revision
   with Node.DeleteRevisionUnchecked, and deletes the returned blob with
   Tree.DeleteBlob (a no-op in posix, where the revision file holds the
   content itself)
 * node.ValidateRevisionKey makes sure a key really belongs to the node,
   so a wrong key can't delete anything else; in particular it rejects
   keys that would turn into an empty version, for which
   Node.DeleteRevisionUnchecked would revert or purge the node itself

Tests, for both trees: listing, purging a revision with its metadata,
lockfile and (in decomposedfs) its blob, leaving the current version
alone, and rejecting the keys of other nodes and bare node ids (and, in
decomposedfs, keys with path separators).

The operator README of the revision cleanup,
pkg/storage/pkg/decomposedfs/revisioncleanup/README.md, starts here
with its introduction; the following commits document their parts in
it.
revisioncleanup.Cleaner brings the strategies and the deletion of
revisions together. Nothing creates or calls it yet.

 * revisioncleanup.New parses a strategy; empty or "disabled" returns a
   disabled Cleaner (Enabled is false), whose Cleanup does nothing, so
   that callers can call it unconditionally, without the risk of a nil
   pointer, while skipping all the work when the feature is off; a nil
   Cleaner behaves the same, as a safeguard
 * Cleanup locks the node and does nothing while an upload to that node
   is still being processed, as the upload may still need its revisions
   to write its content into or to roll back to; the status is read
   straight from the metadata backend, as the copy cached on the node
   object can be stale
 * it runs the strategy on all revisions and purges what the plan marks
   as deleted; callers can name revisions that must never be deleted
 * the blobs of the purged revisions are only deleted once the node
   lock is released: nothing refers to them anymore by then, and with
   decomposeds3 every blob is a request, which would otherwise block
   uploads to the node all along
 * trees whose files may only be accessible to the group of their space
   (posix with use_space_groups) can implement SpaceScoper; Cleanup then
   switches to that group first, as nothing has done so for a cleanup
   running on behalf of the system, unlike for user requests
 * a revision's time comes from the timestamp in its key, falling back
   to the file's mtime
 * errors are only logged, so that a failed cleanup will never fail the
   operation that triggered it
 * every purged revision is logged at debug level, plus one summary per
   node at info level (number and size of the purged revisions), only
   when something was purged

Tests: the revision time from keys with and without a collision suffix,
and the fallback to the mtime; on both trees, purging what the strategy
deletes; and on decomposedfs, keeping the revisions it was asked to
keep, doing nothing while the node is processing, and disabled and
invalid configurations, deleting the blobs only after releasing the node
lock, and switching to the group of the space with a SpaceScoper.

The operator README documents the strategies, and the decomposedfs
README links to it.
Old revisions are now deleted after each new revision, for posixfs,
decomposedfs and decomposeds3, including revisions that posixfs creates
when it detects a change on disk.

Where it runs:

 * after a successful synchronous upload and after successful async
   postprocessing, only when the upload actually created a revision
 * in the new upload pipeline (not called yet): when MarkProcessing
   clears the processing mark of its session, as the cleanup skips nodes
   while they are marked
 * after RestoreRevision, always keeping the revision holding the
   pre-restore content
 * in posix, when a change on disk turns .CURRENT into a revision; it
   runs once the new content has been copied to .CURRENT, so that
   .CURRENT is missing no longer than before, as a change on disk in
   that window would find no .CURRENT to create a revision from

Config:

 * new decomposedfs option 'revisions_cleanup_strategy', inherited by
   posix, e.g. "auto", "14d, auto", "auto, 90d", "5, 30d" or
   "7d, auto, 90d" (see package pkg/storage/revisions)
 * empty or "disabled" keeps all revisions, which is the default; an
   invalid value makes the driver fail at startup
 * the cleaner is created once per driver and passed around through
   aspects.Aspects; the posix test env passes it on as posix.New does
 * with use_space_groups, the posix tree switches the cleanup to the
   group of the space (revisioncleanup.SpaceScoper), like the space
   group hooks of the middleware do for user requests

Tests: uploads with and without a strategy, async postprocessing that
succeeds or fails, MarkProcessing clearing the mark of its session,
restores, and an invalid strategy at startup, on a decomposedfs; on
posix, revisions created from changes on disk, an invalid strategy at
startup, and switching to the group of a space with and without
use_space_groups. The posix tests trigger assimilation through a
test-only Tree.AssimilateForTest (export_test.go), because starting a
second inotify watcher kills the first one (KillOthers in
watcher_linux.go).

Overlapping uploads: if an older upload is still in postprocessing after
a newer one finished, the cleanup can't see it, as the processing status
only tracks the latest upload of a file. That is safe: if the cleanup
deletes the revision the older upload writes its content into, the older
upload creates it again when it finishes; and the revision the older
upload would roll back to after failed postprocessing must not be
restored once a newer upload finished, as that would discard the newer
upload's content.

The operator README documents the option, when the cleanup runs, the
logging of the purged revisions, and that only files that get a new
revision are cleaned up.
The revision cleanup no longer slows down the upload, restore or change
on disk that created the new revision: by default, it is queued and run
by background workers (issue requirement: "asynchronous / non-blocking
for writing the file").

New decomposedfs options, inherited by posix:

 * revisions_cleanup_async (bool, default true): run the cleanup in
   background workers; false keeps the previous behaviour of running it
   right away, in the caller's goroutine
 * revisions_cleanup_async_workers (int, default 0 = automatic): half
   the available CPUs, at least 2; based on runtime.GOMAXPROCS, which
   respects container CPU limits, unlike runtime.NumCPU
 * revisions_cleanup_async_queue_size (int, default 0 = automatic):
   64 queue slots per worker
 * revisions_cleanup_async_queue_full ("drop" or "block", default
   "drop"): with "drop" the cleanup of that node is skipped and logged,
   with its location on disk, and happens with the node's next
   revision; with "block" the caller waits for room in the queue
 * revisions_cleanup_async_queue_full_timeout (duration, default 5s):
   how long "block" waits at most, after which the cleanup of the node
   is skipped as with "drop"; 0 waits as long as it takes, which never
   skips a cleanup but can hold up uploads without bound

Invalid values make the driver fail at startup.

How it works (new revisioncleanup.Service):

 * all four triggers (sync uploads, async postprocessing, restores,
   posix assimilation) report the event with
   Service.HandleRevisionCreated instead of calling Cleaner.Cleanup;
   the Service decides whether to clean up right away or to queue the
   node, so callers don't depend on the mode
 * posix assimilation reports it with HandleRevisionCreatedByID, with
   the node's ids instead of a node: the assimilation still uses its
   node, so the trigger had to read one of its own, also when the
   cleanup was disabled, and in the background, the worker reads it
   anyway; now the node is read only to be cleaned up
 * without a strategy, NewService returns a disabled Service (Enabled
   is false), which starts no workers and ignores every request, so
   that callers never deal with nil; a nil Service behaves the same, as
   a safeguard
 * requests only carry the space and node id plus the revisions to
   keep; the worker reads the node again, as a *node.Node must not be
   shared between goroutines (per-goroutine lock-held flag, cached
   attributes)
 * nor do they keep the request that triggered them alive while they
   wait: their context only carries the logger, with the fields of the
   request including its id, as the other values may only be valid
   during the request; the cleanup gets a trace of its own, a span
   "revision cleanup" started in the worker, linked to the span of the
   request
 * a node whose space was disabled meanwhile is skipped: the space is
   in the trash, and its files are left alone; the worker reads the
   node also in a disabled space, to tell it apart from an error
 * requests are de-duplicated per node: a node waiting in the queue is
   not queued twice (the revisions to keep are merged), and a node whose
   cleanup is running is not queued either: the requests that come in
   meanwhile are collected, and the node is queued once more when its
   cleanup is done, at the end of the queue; a node therefore never
   occupies more than one worker, and a single busy file can't keep the
   workers from the others
 * the storage drivers shut the Service down again when their setup
   fails after it was created, so that its workers don't linger
 * a restore releases the node lock before it reports the new
   revision, as waiting for room in the queue while holding it could
   deadlock with a worker waiting for that lock
 * the settings are passed as AsyncOptions parameters, so that the
   planned on-demand CLI can use its own command line switches;
   NewServiceFromOptions builds the Service from the driver options
 * Decomposedfs.Shutdown stops the workers: a running cleanup is
   finished, queued ones are abandoned and logged (they are picked up
   again with the node's next revision), blocked callers are released;
   posix shares the Service (and its workers) between its tree and
   Decomposedfs, which also stops it
 * aspects.Aspects.RevisionsCleanup is now a *revisioncleanup.Service
   instead of a *revisioncleanup.Cleaner

Tests:

 * Service unit tests with a controllable fake worker function:
   automatic values and validation, the context of a cleanup detached
   from the request, de-duplication and re-queueing, a node on one
   worker at a time, a node given by its ids, both queue full policies
   and the timeout of "block", shutdown and shutdown timeout
 * integration tests on a real decomposedfs tree in both modes,
   including a node that was deleted before its cleanup ran, and a
   restore waiting for room in a full queue without holding the lock;
   no workers left behind when the decomposedfs or posix tree setup
   fails
 * the posix end-to-end test for changes on disk runs in both modes
 * option defaults and explicit values

The operator README documents the new options, their logging, and
that the cleanups still queued at shutdown are abandoned.
So that sysadmins can follow the revision cleanup in Grafana, e.g. the
rate of deleted revisions, and see whether the background queues get
close to full and more workers are needed.

Counters:

 * reva_revision_cleanup_revisions_deleted_total: deleted revisions
 * reva_revision_cleanup_bytes_deleted_total: size of the deleted
   revisions
 * reva_revision_cleanup_purge_failures_total: revisions that could not
   be deleted (retried with the node's next revision), and revisions
   whose blob could not be deleted afterwards (the blob is left behind,
   logged with its blob id); a revision only counts as deleted once its
   blob is
 * reva_revision_cleanup_queue_full_total{outcome="blocked"|"dropped"}:
   requests that found the queue full; the most direct sign that the
   number of workers or the queue size is too small; a caller that
   waited in vain until the timeout of "block" counts as both

Gauges:

 * reva_revision_cleanup_queue_length: files waiting for a background
   cleanup, including callers waiting for room in a full queue; the
   queue holds de-duplicated nodes, not individual revisions, as what
   gets deleted is only known when a worker runs the strategy
 * reva_revision_cleanup_queue_capacity: queue size
 * reva_revision_cleanup_workers: background workers
 * reva_revision_cleanup_running: cleanups being processed right now
 * reva_revision_cleanup_enabled: 1 if a cleanup strategy is
   configured, 0 otherwise

The metrics are registered once per process at package level, as in
the rest of reva: a process can create several storage drivers (e.g.
the storageprovider and the dataprovider each create their own), and
thus several cleanup Services, and registering a metric twice panics.
The queue and worker gauges are therefore summed over all Services of
the process: each Service adds its share when it starts and removes it
when it shuts down; the queue full counter covers what a sum can hide.

The queue length is counted exactly: it goes up when a node starts
waiting and down where it stops waiting (taken by a worker, dropped,
released by a shutdown, or abandoned by Shutdown). Shutdown now also
makes sure that a worker which took a request just before the shutdown
doesn't start processing it, so that "abandoned" is accurate.

The operator README documents the metrics, with example PromQL
queries.

Tests read the metrics through the default gatherer, like a scrape,
which avoids a new dependency on the prometheus testutil package: queue
length and capacity, workers and running cleanups through the whole
lifecycle, both queue full outcomes and the timeout of "block",
abandoning on shutdown, a disabled Service, which leaves them all alone,
and the counters on a real decomposedfs tree, including a failed
deletion.
The time slots of the 'auto' strategy, and chains like "7d, auto, 90d",
are not trivial to understand. Every decision of a cleanup strategy now
comes with a human readable reason, which names the rule in the syntax
of the configuration and the concrete boundary it was evaluated with:

 * protected by "7d": newer than 2026-10-07T12:00:00Z
 * deleted by "10": beyond the 10 newest, counting the 3 protected ones
 * deleted by "auto" (last hour): its slot of 1 minute starting
   2026-10-14T09:05:00Z already keeps a newer revision
 * kept by "auto" (last 30 days): the newest in its slot of 1 day
   starting 2026-10-01T00:00:00Z

Changes:

 * revisions.Plan: Delete, Retain and Safeguard take a reason, and
   Reason(key) returns it; every strategy gives reasons, enforced by
   the compiler
 * the chain strategy keeps the reason of the step that decided about
   a revision; revisions no step decided about keep the reason of the
   last step
 * the 'auto' tiers have a description ("last hour", ...), used in the
   reasons; this replaces the unused explain parameter and the
   bucketExplanation of evaluateRetention, which only had per tier
   lists; the explain test helper now logs every decision with its
   reason
 * ExecuteCleanupStrategy passes the reason of every deleted revision
   to its callback
 * the debug line "revision cleanup: purged revision" has a new field
   "reason"; kept and protected revisions are still not logged
 * the operator README documents the field, with examples
 * a revision dated in the future (its content's modification time,
   which comes from the client or the change on disk, e.g. a client
   with a wrong clock) counts as the newest one: it takes the first
   places of a number of versions to keep, and no duration deletes it
   before its date. That stays as it is, but its reason now says so,
   and each such revision is logged as a warning, so that admins can
   look into the client or the clock

The reasons are also meant for the verbose output of the planned
on-demand cleanup command.

Tests: every decision in every plan of the parser tests has a reason;
exact reasons for the main forms (protected, auto slots, deletion by
duration and by count, counting protected revisions, midnight
alignment); evaluateRetention with custom tiers, covering
the tiers without an interval (kept for configurable tiers) and slots
keeping several revisions; the reason in the debug line; the formatting
of durations in the reasons; the reasons and warnings for revisions
dated in the future.
A revision's age is the time its content was written, i.e. the
modification time the file had before it was changed, as revisions are
named after the mtime of the content they preserve. It is not the time
of that change, which turned the content into a revision and is not
recorded anywhere.

For files that are unchanged for a long time the two are far apart: a
file unchanged for two months and changed today gets a revision that
counts as two months old, so a strategy deleting by duration, e.g.
"30d", deletes it right away.

Document this in the operator README (with the example and how to
avoid it: combine durations with a number of revisions, e.g. "5, 30d",
or use "auto"), in the option and strategy grammar docs, in
revisioncleanup.revisionTime where the age is determined, and where
revisions are named: uploads, restores and posix changes on disk.
For changes on disk, posix names a revision after the time its content
was copied to '.CURRENT' when that change was picked up, which is
normally right after it was written. Where the age is determined and
where posix names those revisions, the comments link to
opencloud-eu/opencloud#3735, which discusses whether the age should
rather be the time a revision was created.

Comments and documentation only, no functional change.
Preparation for the on-demand cleanup command, whose verbose and dry
run output needs to know what happened to every revision, and why.

 * Cleaner.Cleanup now returns a Result: one RevisionResult per
   revision, newest first, with its time, size, decision ("deleted",
   "kept" or "protected"), the reason the strategy gave, and the error
   if deleting it failed, also if only its blob could not be deleted
   after the node lock was released; plus why a node was skipped (e.g.
   an upload to it is still being processed) or why the cleanup failed
   as a whole. Callers that don't report on the cleanup ignore it.
 * Revisions protected by the caller (the keep keys, e.g. the state
   from before a restore) are reported as "protected", with a reason
   saying so.
 * Cleaner.DryRun decides exactly like Cleanup (it also takes the node
   lock and skips nodes that are being processed), but deletes
   nothing, counts no metrics and logs no purges.
 * revisions.ExecuteCleanupStrategy returns the strategy's whole Plan,
   so that kept and protected revisions can be reported too.
Preparation for the on-demand cleanup command, which queues the
cleanups of many files and has to wait until all of them are done
before it can report and exit. Shutdown doesn't help there, as it
abandons the cleanups that are still queued.

Service.Wait(ctx) waits until no cleanup is queued or running anymore,
or until ctx ends. It counts every request from being queued until its
cleanup has finished; requests merged into a waiting one don't count
twice, and requests that were dropped (queue full policy "drop"),
released by a shutdown while blocked, or abandoned by Shutdown don't
keep it waiting. It doesn't stop anything, and can be called again
after new cleanups were queued. For a synchronous or a nil Service it
returns right away, as the cleanups already ran in their callers.

Tests: waiting for queued, running, merged and re-queued cleanups,
waiting again after new work, the context, and dropped and abandoned
cleanups; the test service gained a helper to wait until Shutdown has
begun, so that a worker can't take a queued cleanup before.
The entry point for the planned "revisions cleanup" command: it cleans
up the revisions of whole spaces, all spaces, or a file or folder
within a space, also of files that never get a new revision anymore.

 * revisioncleanup.Request describes a run: the space ids or all
   spaces, an optional path within a single space, and the strategy,
   AsyncOptions and dry run of the run; the strategy and AsyncOptions
   default to the configured ones, so that a command can override them
   with its switches. Without AsyncOptions of its own, though, a run
   waits for room in a full queue for as long as it takes, whatever the
   configured queue full policy: it queues the files as fast as it finds
   them, so "drop", or the timeout of "block" meant for uploads, would
   skip most of them. Request.Validate checks the scope.
 * revisioncleanup.OnDemand is the interface the storage drivers
   implement: CleanupRevisionsUnchecked(ctx, req, report) hands the
   Result of every file to report, one at a time, and returns once all
   cleanups are done.
 * revisioncleanup.Run is the common implementation: it walks the
   given files and folders (revisioncleanup.Walker) and queues every
   file into a Service of its own, with the run's Cleaner and
   AsyncOptions, then waits for it (Service.Wait) and shuts it down.
   That Service runs dry runs and reports the Result of every cleanup,
   including files dropped by a full queue or after the timeout of
   "block", files that no longer exist or whose space was disabled
   meanwhile, and folders that could not be listed, whose error names
   their location on disk (Node.InternalPath), for admins and bug
   reports. Every Result carries the path of its file within its space,
   which the walk derives from the folders it lists, without reading any
   more metadata.
 * Decomposedfs.CleanupRevisionsUnchecked resolves the request: all
   spaces come from the space type index, of all the space types found
   in it (the new spaceidindex.Index.Indexes lists them), without
   permission checks or a user, as it is meant for command line tools;
   paths are resolved within the space; a space given more than once
   is cleaned up once. With all spaces, a space that
   can't be cleaned up doesn't stop the others: a disabled space (in the
   trash) is reported as skipped, any other one as failed. A space
   requested by its id fails the request instead.
 * The walk lists folders with the new ListFolderKnownUnchecked of both
   trees, which, unlike ListFolder, checks no permissions: ListFolder
   leaves out the files denied to the user in the context, and panics
   without a user, which a command line tool doesn't have. In the posix
   tree, ListFolderKnownUnchecked also skips the files the storage
   doesn't know yet, which ListFolder assimilates on the fly. A cleanup,
   a dry run in particular, therefore changes nothing but the revisions
   it deletes; otherwise, the assimilation could even create a revision
   and run a real cleanup with the driver's own strategy during a dry
   run. The skipped files have no revisions the storage knows of; a
   later run covers them once they are registered.
 * The older log line next to it in the posix tree's listFolder, for an
   item it assimilates on the fly, now says "item" instead of "entity",
   like the new one for the skipped items and the other log lines of the
   assimilation.
 * With a SpaceScoper (posix with use_space_groups), the walk switches
   to the group of each space before it lists its folders, as the
   cleanup of each file does.
 * posixFS forwards CleanupRevisionsUnchecked to its inner Decomposedfs,
   as the middleware it wraps it in doesn't pass optional interfaces
   through.
 * revisioncleanup.AsyncOptionsFromOptions returns the configured
   AsyncOptions.
 * The Service of a run leaves the metrics of the storage driver's
   Service alone (enabled, workers, queue capacity and length, running
   and queue full), as they describe the cleanup that runs whenever a
   file gets a new revision, should a run ever happen in the same
   process; its deletions are counted all the same.
 * The README no longer lists the revisions of files that are never
   changed again as a limitation; it refers to the OpenCloud command
   that cleans them up on demand, 'opencloud storage-users revisions
   cleanup', which is documented in OpenCloud, and so does its note
   that "0" also deletes the revisions that have accumulated.

The Service used by the storage driver itself is unchanged: dry runs
and reporting are only set through the new internal constructor.

Tests, all without a user in the context: whole spaces (recursively), a
space given twice, all spaces, a folder and a file, dry runs, the paths
of the files, the strategy and the AsyncOptions of the request (waiting
for the background cleanup), no files dropped by a full queue without
them, files dropped with them, also after the timeout of "block",
skipped files, a file whose space was disabled meanwhile, and invalid
requests, on a decomposedfs; a dry run and a real run in the posix
layout; the forwarding in posixFS; switching to the group of the walked
space with a SpaceScoper; the metrics of a run; a file unknown to the
posix storage stays unknown through a dry run and a real run; all spaces
include spaces of any type, skip disabled ones and report missing ones;
the index lists its indexes.
…ath on disk

With posix, admins see the files of the spaces on disk under their own
names, and want to clean up the revisions of a file or folder by that
path, without looking up the space id first.

 * revisioncleanup.Request.FSPath is a file or folder (recursively)
   given by its path on disk. It can't be combined with spaces or a
   path within a space.
 * posix Lookup.SpacePathFromFSPath resolves a path on disk to its
   space and its path within the space, through the id cache: the path
   must be within the storage root and known to the storage, i.e.
   assimilated. Relative paths are taken relative to the working
   directory; symlinks are not resolved.
 * posixFS.CleanupRevisionsUnchecked resolves Request.FSPath that way
   before it hands the request to the Decomposedfs.
 * Decomposedfs.CleanupRevisionsUnchecked rejects Request.FSPath as not
   supported: the decomposed layouts store files under their ids.

Whether a path is within the storage root, and within the root of its
space, is checked with filepath.IsLocal on the relative path
(relWithin), which, unlike a check for a ".." prefix, accepts names like
"..foo".

Tests: the resolution of files, folders and the space root on the
posix layout, of paths outside the storage root and of unknown paths,
followed by a cleanup; relWithin, including the root itself, a sibling
sharing the root as a prefix and a name starting with ".."; the
rewriting of the request in posixFS; and the invalid requests with a
path on disk.
…e its order

A command running the on-demand cleanup, e.g. from a cron job, can limit
its duration with a timeout on the context it passes to
CleanupRevisionsUnchecked. When the context ends, the run now stops
gracefully:

 * the files being cleaned up are finished, also in synchronous mode,
   where each file's cleanup gets a context that can't be cancelled;
   the walk stops before the next file;
 * a walk waiting for room in a full queue (queue full policy "block")
   gives up right away and reports its file as skipped;
 * the files still queued are reported as skipped by Shutdown, so that
   the report accounts for every file the walk reached;
 * a folder listing interrupted by the stop isn't reported as an error;
 * CleanupRevisionsUnchecked returns the context's error.

Only on-demand runs behave like that: the Service of the storage driver
still never gives up on a full queue, even when the context of the
triggering request ends, so that no cleanup is lost.

A run that is stopped early starts over from the beginning the next
time, in the same order. With Request.Shuffle, the spaces, and the
files and folders within each folder, are cleaned up in a random order
instead, so that repeated runs start in different places and
eventually reach all files, also within a space that is too large for
a single run. It's optional, the order of the spaces stays sorted by
default.

The settings of Run are now bundled in RunOptions (AsyncOptions, dry
run and shuffle).

Tests: on the Service, a caller blocked on a full queue gives up with an
on-demand run, and doesn't otherwise, and the abandoned files are
reported; on a decomposedfs, runs stopped in the middle of a file, in
synchronous and asynchronous mode, every file being either cleaned up
completely, reported as skipped, or untouched; with a tree that controls
the timing, the cleanup of a file can't be cancelled, a walk blocked on
the full queue gives up, a stopped folder listing isn't reported, and a
folder that can't be listed is reported with its location on disk; the
shuffled order of the spaces; and the shuffled order of the files within
a folder, with a tree that lists folders sorted by name, as the
decomposedfs tree lists them in no particular order.
RestoreRevision and DownloadRevision trusted the revision's metadata,
which may come from a stale cache entry: with the default in-memory
metadata cache, a revision deleted by another process, e.g.
`opencloud storage-users revisions cleanup`, keeps its entry in the
server's cache for up to 24 hours.

Restoring such a revision on decomposed reported success but pointed
the node to the revision's deleted blob, so the file could no longer
be downloaded; every failed restore also left a revision of the
current content behind. Downloading it failed with an error instead
of "not found".

Both now check that the revision exists on disk first and return
NotFound otherwise. RestoreRevision checks under the node's lock,
which deleting a revision takes too, before it creates the revision
of the current content, and drops the stale cache entry.
@pbleser-oc
pbleser-oc force-pushed the feat/1419-clean-up-file-versions branch from 2bcbce0 to 414fb63 Compare October 28, 2026 17:19
pbleser-oc added a commit to pbleser-oc/opencloud that referenced this pull request Oct 28, 2026
Points github.com/opencloud-eu/reva/v2 at the head of
opencloud-eu/reva#895 in its fork (pbleser-oc/reva 414fb6332), which
adds the revision cleanup this branch configures and calls, and
re-vendors.

Temporary: to be replaced by a regular reva bump once
opencloud-eu/reva#895 is merged.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants