Skip to content

Commit 40663c5

Browse files
mklos-kwkobergj
andauthored
pkg/storage: release quota of orphaned upload sessions (#692) (#730)
When an upload's target node lost its metadata, e.g. because an ancestor was trashed while the upload was in flight, the node file remained without a readable .mpk. Reading it fails with "Missing parent ID on node", so the upload never finished postprocessing: it stayed in "Processing", could not be downloaded or deleted, and kept consuming the quota. Cleanup made this worse. It removed the upload bytes and the session info file before attempting to revert the node, then bailed out on the failing node read without releasing the quota - destroying both the only copy of the data and the parent id needed to repair the node, while freeing nothing. Revert the node before anything irreversible and fall back to the parent id recorded in the session when the node cannot be read, so the quota is released and the orphaned node removed. Keep the upload if the quota cannot be released. Also clean up such sessions when postprocessing finishes instead of returning early and leaving them to be retried forever. Add an Orphaned upload session filter to list affected sessions. It is only evaluated when set, as it reads the node metadata of every session. Signed-off-by: Julian Koberg <julian.koberg@kiteworks.com> Co-authored-by: kobergj <juliankoberg@googlemail.com>
1 parent 3dbd3c2 commit 40663c5

7 files changed

Lines changed: 180 additions & 18 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
Bugfix: Release the quota of upload sessions with unreadable node metadata
2+
3+
When an upload's target node lost its metadata, e.g. because an ancestor was
4+
moved to the trash while the upload was still in flight, the node file remained
5+
on disk without a readable `.mpk`. Reading such a node fails with
6+
`Missing parent ID on node`, so the upload could never finish postprocessing. It
7+
stayed in "Processing" forever, could not be downloaded or deleted, and kept
8+
consuming the space quota.
9+
10+
Cleaning these sessions up did not work either. `Cleanup` removed the upload
11+
bytes and the session info file *before* attempting to revert the node, then
12+
bailed out on the failing node read without ever releasing the quota. That
13+
destroyed both the only copy of the uploaded data and the session metadata
14+
needed to repair the node, while freeing nothing.
15+
16+
Cleanup now reverts the node before removing anything irreversible and falls
17+
back to the parent id recorded in the session when the node metadata cannot be
18+
read, so the quota is released and the orphaned node is removed. If the quota
19+
cannot be released the upload is kept so it can be retried instead of being lost.
20+
Sessions whose node is unreadable are now also cleaned up when postprocessing
21+
finishes, instead of being left behind to be retried indefinitely.
22+
23+
A new `Orphaned` upload session filter allows listing the affected sessions. It
24+
is only evaluated when set, as it reads the node metadata of every session.
25+
26+
https://github.com/owncloud/reva/pull/692

‎pkg/storage/uploads.go‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,4 +87,8 @@ type UploadSessionFilter struct {
8787
Processing *bool
8888
Expired *bool
8989
HasVirus *bool
90+
// Orphaned filters sessions by whether their target node can still be
91+
// resolved. Evaluating it requires reading the node metadata of every
92+
// session, so it is only evaluated when set.
93+
Orphaned *bool
9094
}

‎pkg/storage/utils/decomposedfs/decomposedfs.go‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,12 @@ func (fs *Decomposedfs) Postprocessing(ch <-chan events.Event) {
296296

297297
n, err := session.Node(ctx)
298298
if err != nil {
299-
sublog.Error().Err(err).Msg("could not read node")
299+
// The node metadata is unreadable, so this upload can never finish:
300+
// the destination cannot be resolved. Clean the session up instead of
301+
// leaving it behind to be retried forever. Cleanup falls back to the
302+
// session metadata to release the quota.
303+
sublog.Error().Err(err).Msg("could not read node, cleaning up orphaned session")
304+
session.Cleanup(true, true, true, false)
300305
continue
301306
}
302307
sublog = log.With().Str("spaceid", session.SpaceID()).Str("nodeid", session.NodeID()).Logger()

‎pkg/storage/utils/decomposedfs/upload.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,11 @@ func (fs *Decomposedfs) ListUploadSessions(ctx context.Context, filter storage.U
411411
continue
412412
}
413413
}
414+
// evaluated last: unlike the other filters this reads the node metadata
415+
// from disk, so it is only done for sessions that passed all other filters
416+
if filter.Orphaned != nil && *filter.Orphaned != session.IsOrphaned(ctx) {
417+
continue
418+
}
414419
filteredSessions = append(filteredSessions, session)
415420
}
416421
return filteredSessions, nil

‎pkg/storage/utils/decomposedfs/upload/session.go‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import (
3434
typespb "github.com/cs3org/go-cs3apis/cs3/types/v1beta1"
3535
"github.com/owncloud/reva/v2/pkg/appctx"
3636
ctxpkg "github.com/owncloud/reva/v2/pkg/ctx"
37+
"github.com/owncloud/reva/v2/pkg/errtypes"
3738
"github.com/owncloud/reva/v2/pkg/storage/utils/decomposedfs/node"
3839
"github.com/owncloud/reva/v2/pkg/utils"
3940
)
@@ -166,6 +167,43 @@ func (s *OcisSession) Node(ctx context.Context) (*node.Node, error) {
166167
return node.ReadNode(ctx, s.store.lu, s.SpaceID(), s.info.Storage["NodeId"], false, nil, true)
167168
}
168169

170+
// IsOrphaned returns true if the session's target node can no longer be
171+
// resolved. This happens when the node file still exists but its metadata is
172+
// gone, e.g. because an ancestor was moved to the trash while the upload was in
173+
// flight. Such a session can never finish postprocessing: reading the node
174+
// fails before the destination can be determined.
175+
func (s *OcisSession) IsOrphaned(ctx context.Context) bool {
176+
_, err := s.Node(ctx)
177+
return err != nil
178+
}
179+
180+
// syntheticNode builds a node from the session metadata alone, without reading
181+
// the node from disk. It is used to clean up sessions whose node metadata is
182+
// unreadable: the parent id is still recorded in the session, which is all that
183+
// is needed to walk up the tree and revert the size propagation.
184+
func (s *OcisSession) syntheticNode(ctx context.Context) (*node.Node, error) {
185+
if s.NodeID() == "" || s.NodeParentID() == "" {
186+
return nil, errtypes.InternalError("session has no node and parent id")
187+
}
188+
n := node.New(
189+
s.SpaceID(),
190+
s.NodeID(),
191+
s.NodeParentID(),
192+
s.Filename(),
193+
s.Size(),
194+
s.ID(),
195+
provider.ResourceType_RESOURCE_TYPE_FILE,
196+
nil,
197+
s.store.lu,
198+
)
199+
spaceRoot, err := node.ReadNode(ctx, s.store.lu, s.SpaceID(), s.SpaceID(), false, nil, false)
200+
if err != nil {
201+
return nil, err
202+
}
203+
n.SpaceRoot = spaceRoot
204+
return n, nil
205+
}
206+
169207
// ID returns the upload session id
170208
func (s *OcisSession) ID() string {
171209
return s.info.ID

‎pkg/storage/utils/decomposedfs/upload/upload.go‎

Lines changed: 61 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -330,10 +330,71 @@ func (session *OcisSession) removeNode(ctx context.Context) {
330330
}
331331
}
332332

333+
// revertNode undoes the node changes made when the upload was initiated. For a
334+
// readable node this restores the previous revision. When the node metadata can
335+
// no longer be read the node is orphaned and can never finish postprocessing; in
336+
// that case the node is removed and the optimistic size propagation is reverted
337+
// using the parent id recorded in the session, so the space quota is released.
338+
func (session *OcisSession) revertNode(ctx context.Context) error {
339+
n, err := session.Node(ctx)
340+
if err == nil {
341+
curUpload, perr := n.ProcessingID(ctx)
342+
if perr == nil && curUpload == session.ID() {
343+
if rerr := n.RevertCurrentRevision(ctx); rerr != nil {
344+
return rerr
345+
}
346+
}
347+
return nil
348+
}
349+
350+
// The node is unreadable. Fall back to the session metadata, which still
351+
// carries the node and parent ids needed to release the quota.
352+
log := appctx.GetLogger(ctx)
353+
log.Info().Err(err).Str("sessionid", session.ID()).Msg("node unreadable, cleaning up orphaned upload")
354+
355+
sn, serr := session.syntheticNode(ctx)
356+
if serr != nil {
357+
return serr
358+
}
359+
360+
if sizeDiff := session.SizeDiff(); sizeDiff != 0 {
361+
if perr := session.store.tp.Propagate(ctx, sn, -sizeDiff); perr != nil {
362+
// Without the propagation the quota would stay consumed. Stop here
363+
// so the session can be retried instead of losing the upload.
364+
return perr
365+
}
366+
}
367+
368+
// The orphaned node file cannot be resolved by any other means, remove it
369+
// together with its metadata files. A missing node is not an error here:
370+
// the node may never have been created.
371+
nodePath := sn.InternalPath()
372+
if rerr := utils.RemoveItem(nodePath); rerr != nil && !errors.Is(rerr, fs.ErrNotExist) {
373+
log.Error().Err(rerr).Str("nodepath", nodePath).Msg("removing orphaned node failed")
374+
}
375+
if perr := session.store.lu.MetadataBackend().Purge(ctx, nodePath); perr != nil && !errors.Is(perr, fs.ErrNotExist) {
376+
log.Error().Err(perr).Str("nodepath", nodePath).Msg("purging orphaned node metadata failed")
377+
}
378+
379+
return nil
380+
}
381+
333382
// cleanup cleans up after the upload is finished
334383
func (session *OcisSession) Cleanup(revertNodeMetadata, cleanBin, cleanInfo, unmarkPostprocessing bool) {
335384
ctx := session.Context(context.Background())
336385

386+
if revertNodeMetadata {
387+
// Revert before removing the bin and info files. Both are needed to
388+
// recover from a failure here: the bin file holds the only copy of the
389+
// uploaded data as long as the blob has not been written, and the info
390+
// file is the only remaining source of the node's parent id once the
391+
// node metadata is gone.
392+
if err := session.revertNode(ctx); err != nil {
393+
appctx.GetLogger(ctx).Error().Err(err).Str("sessionid", session.ID()).Msg("reverting node failed, keeping upload")
394+
return
395+
}
396+
}
397+
337398
if cleanBin {
338399
if err := os.Remove(session.binPath()); err != nil && !errors.Is(err, fs.ErrNotExist) {
339400
appctx.GetLogger(ctx).Error().Str("path", session.binPath()).Err(err).Msg("removing upload failed")
@@ -346,22 +407,6 @@ func (session *OcisSession) Cleanup(revertNodeMetadata, cleanBin, cleanInfo, unm
346407
}
347408
}
348409

349-
if revertNodeMetadata {
350-
n, err := session.Node(ctx)
351-
if err != nil {
352-
appctx.GetLogger(ctx).Error().Err(err).Str("sessionid", session.ID()).Msg("reading node for session failed")
353-
return
354-
}
355-
356-
curUpload, err := n.ProcessingID(ctx)
357-
if err == nil && curUpload == session.ID() {
358-
if err := n.RevertCurrentRevision(ctx); err != nil {
359-
appctx.GetLogger(ctx).Error().Err(err).Str("nodepath", n.InternalPath()).Msg("reverting node metadata failed")
360-
return
361-
}
362-
}
363-
}
364-
365410
if unmarkPostprocessing && !revertNodeMetadata { // node reverting automatically unmarks processing
366411
n, err := session.Node(ctx)
367412
if err != nil {

‎pkg/storage/utils/decomposedfs/upload_async_test.go‎

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ var _ = Describe("Async file uploads", Ordered, func() {
7171

7272
ctx context.Context
7373

74-
pub chan interface{}
74+
pub chan interface{}
7575
con chan interface{}
7676
uploadID string
7777

@@ -294,6 +294,45 @@ var _ = Describe("Async file uploads", Ordered, func() {
294294
Expect(err).ToNot(BeNil())
295295
})
296296

297+
It("releases the quota and removes the node when the node metadata is unreadable", func() {
298+
// node is created and the optimistic size has been propagated
299+
resources, err := fs.ListFolder(ctx, rootRef, []string{}, []string{})
300+
Expect(err).ToNot(HaveOccurred())
301+
Expect(len(resources)).To(Equal(1))
302+
Expect(parentSize()).To(Equal(len(firstContent)))
303+
304+
// simulate an orphaned node: the node file is still there but its
305+
// metadata is gone, e.g. because an ancestor was trashed while the
306+
// upload was in flight. Reading the node now fails. Purge instead of
307+
// removing the file directly, so the cached attributes go as well.
308+
nodePath := lu.InternalPath(ref.GetResourceId().GetSpaceId(), resources[0].GetId().GetOpaqueId())
309+
Expect(lu.MetadataBackend().Purge(ctx, nodePath)).To(Succeed())
310+
_, err = node.ReadNode(ctx, lu, ref.GetResourceId().GetSpaceId(), resources[0].GetId().GetOpaqueId(), false, nil, true)
311+
Expect(err).To(HaveOccurred(), "node should be unreadable after purging its metadata")
312+
313+
// No UploadReady event is published for an orphaned session: there is
314+
// no node left to report on. Wait for the bytes to be cleaned up
315+
// instead of for an event that will never arrive.
316+
con <- events.PostprocessingFinished{
317+
UploadID: uploadID,
318+
Outcome: events.PPOutcomeContinue,
319+
}
320+
Eventually(func() bool {
321+
_, err := os.Stat(filepath.Join(o.Root, "uploads", uploadID))
322+
return err != nil
323+
}).Should(BeTrue(), "the upload bytes should be cleaned up")
324+
325+
// the blob was never written
326+
bs.AssertNumberOfCalls(GinkgoT(), "Upload", 0)
327+
328+
// the orphaned node is gone ...
329+
_, err = os.Stat(nodePath)
330+
Expect(err).ToNot(BeNil())
331+
332+
// ... and most importantly the quota has been released
333+
Eventually(parentSize).Should(Equal(0))
334+
})
335+
297336
It("deletes node and keeps the bytes when instructed", func() {
298337
// node is created
299338
resources, err := fs.ListFolder(ctx, rootRef, []string{}, []string{})

0 commit comments

Comments
 (0)