Repository navigation
feat: clean up old revisions according to a cleanup strategy - #895
pbleser-oc wants to merge 16 commits into
Conversation
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
left a comment
There was a problem hiding this comment.
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.
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. |
4ebaa06 to
6083983
Compare
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.
6083983 to
eba5978
Compare
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.
|
Thanks, both fixed, amended into the commits they belong to:
|
eba5978 to
60d1d78
Compare
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.
|
Issues and PRs connected to this one, grouped by how they relate to it: Merge order
Independent, in any order: #899, Follow-ups of this PR
Overlapping uploadsFound while checking how the cleanup copes with a file that gets a new upload while an older one is still in postprocessing:
posix
|
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.
60d1d78 to
e42dd53
Compare
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.
|
I added one commit after approval, 2bcbce0 Why: The command only deletes revisions, so the only stale entries are those of deleted revisions. But
All three cases also left a revision of the current content behind, as The fix:
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 This affects any stale metadata cache (e.g. several storage-users instances with the |
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.
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.
2bcbce0 to
414fb63
Compare
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.
Implements the reva side of opencloud-eu/opencloud#1419: old revisions (versions) are deleted according to a configurable cleanup strategy, for the
decomposed,decomposeds3andposixdrivers.The OpenCloud side (configuration and the
opencloud storage-users revisions cleanupcommand) 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:
enable_fs_revisions, after a change on disk created a revision, once the new content has been copied to.CURRENT;MarkProcessingclears the processing mark of its session. Since reimplement commit upload #902,CommitUploadonly writes the bytes of an upload and no longer creates a revision; the revision and the processing mark come from the plannedPrepareUpload(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),10),30d; with an uppercase unit,1Dor1W, 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
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. Withblock, 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 withdrop: 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.Shutdownstops 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 cleanuplinked 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(therevisioncleanup.OnDemandinterface) 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
It reports a
Resultfor 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:
revisions_cleanup_strategy""(disabled)revisions_cleanup_asynctruerevisions_cleanup_async_workers0(half the CPUs, at least 2)revisions_cleanup_async_queue_size0(64 per worker)revisions_cleanup_async_queue_fulldroprevisions_cleanup_async_queue_full_timeout5s(forblock;0waits as long as it takes)Changes to shared code
Uncheckedsuffix, so that they aren't used by mistake where permissions must be checked. For that, a separate commit renames the existingNode.DeleteRevisiontoNode.DeleteRevisionUnchecked, asDecomposedfs.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.ListRevisionsUncheckedlists revisions without permission checks;ListRevisionsnow checks permissions and calls it. There is also a newListFolderKnownUncheckedfor the on-demand walk, which, unlikeListFolder, 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.revisioncleanup.PurgeRevisionUncheckedvalidates the key (node.ValidateRevisionKey, which rejects keys that would turn into an empty version), then callsNode.DeleteRevisionUncheckedandTree.DeleteBlob.aspects.Aspectshas a new field,RevisionsCleanup. The posix test env passes it on, asposix.Newdoes.UnixMapper.ScopeUserByIds, which the cleanup calls for every file withuse_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.Indexeslists the indexes, so that "all spaces" covers every space type.posixFSforwardsCleanupRevisionsUnchecked.pkg/storage/revisions, which only plans what to keep and what to delete and touches no storage. It rejectsX1, X2pairs 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 unitsDandW.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 onmain, so it has a commit of its own, the first one, together with package comments fordecomposedfs,decomposed,decomposeds3andposixthat point to it. The feature commits then add the few lines about the revision cleanup.Review notes
Node.DeleteRevisionrename first, then one per step of the feature. The first four steps build the cleanup bottom up: the strategies, listing and deleting revisions, the cleaner that applies a strategy to a node, and only then the wiring that calls it. Each commit builds and passes its tests. Reviewing commit by commit is probably easier than the whole diff (70 files, ~8900 lines, more than half of them tests).pkg/storage/pkg/decomposedfs/commit_upload.go, from Backport wp1 715 #882 and Backport wp1 716 #888, @dragonchaser):MarkProcessingnow triggers the revision cleanup when it clears the processing mark of an upload, and releases the node lock a bit earlier for it, before the cleanup is handed over, asRestoreRevisiondoes (a cleanup waiting for room in a full queue must not hold the lock).CommitUploadno longer triggers it: since reimplement commit upload #902 it doesn't create a revision anymore. WhenPrepareUploadand the coordinator are ported, every path through the coordinator, including the failure path, should end withMarkProcessing(false), otherwise the cleanup of that file waits for its next revision. Further backports of that code may need to be adapted to these changes.30ddeletes it right away. This is documented, with ways to avoid it (5, 30d,auto). Whether to record the replacement time instead is open for discussion in Revision cleanup: should the age of a revision be the age of its content, or the time it was replaced? opencloud#3735.Follow-ups
Decomposedfs.deleteRevisionFileto userevisioncleanup.PurgeRevisionUnchecked, to get the key validation on that path too.The legacy copy
pkg/storage/utils/decomposedfs(ocisands3ngdrivers) 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,MarkProcessingclearing 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 passesgo vet,golangci-lintand the tests;make testandmake lintpass on the head. The integration tests (tests/integration) fail locally in the same way with and without this PR: 8 public-link specs fail onmaintoo, 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 needsCAP_SETGID, and as root file permissions don't apply. The switch itself is the existingUnixMapper.ScopeUserByIds, which the middleware and the assimilation use too; what's new is only that the cleanup calls it. A check on a server withuse_space_groupsis still to be done.