github.com/jstaf/onedriver@v0.14.2-0.20240420231225-f07678f9e6ef/fs/delta.go (about)

     1  package fs
     2  
     3  import (
     4  	"encoding/json"
     5  	"errors"
     6  	"strings"
     7  	"time"
     8  
     9  	"github.com/jstaf/onedriver/fs/graph"
    10  	"github.com/rs/zerolog/log"
    11  	bolt "go.etcd.io/bbolt"
    12  )
    13  
    14  // DeltaLoop creates a new thread to poll the server for changes and should be
    15  // called as a goroutine
    16  func (f *Filesystem) DeltaLoop(interval time.Duration) {
    17  	log.Trace().Msg("Starting delta goroutine.")
    18  	for { // eva
    19  		// get deltas
    20  		log.Trace().Msg("Fetching deltas from server.")
    21  		pollSuccess := false
    22  		deltas := make(map[string]*graph.DriveItem)
    23  		for {
    24  			incoming, cont, err := f.pollDeltas(f.auth)
    25  			if err != nil {
    26  				// the only thing that should be able to bring the FS out
    27  				// of a read-only state is a successful delta call
    28  				log.Error().Err(err).
    29  					Msg("Error during delta fetch, marking fs as offline.")
    30  				f.Lock()
    31  				f.offline = true
    32  				f.Unlock()
    33  				break
    34  			}
    35  
    36  			for _, delta := range incoming {
    37  				// As per the API docs, the last delta received from the server
    38  				// for an item is the one we should use.
    39  				deltas[delta.ID] = delta
    40  			}
    41  			if !cont {
    42  				log.Info().Msgf("Fetched %d deltas.", len(deltas))
    43  				pollSuccess = true
    44  				break
    45  			}
    46  		}
    47  
    48  		// now apply deltas
    49  		secondPass := make([]string, 0)
    50  		for _, delta := range deltas {
    51  			err := f.applyDelta(delta)
    52  			// retry deletion of non-empty directories after all other deltas applied
    53  			if err != nil && err.Error() == "directory is non-empty" {
    54  				secondPass = append(secondPass, delta.ID)
    55  			}
    56  		}
    57  		for _, id := range secondPass {
    58  			// failures should explicitly be ignored the second time around as per docs
    59  			f.applyDelta(deltas[id])
    60  		}
    61  
    62  		if !f.IsOffline() {
    63  			f.SerializeAll()
    64  		}
    65  
    66  		if pollSuccess {
    67  			f.Lock()
    68  			if f.offline {
    69  				log.Info().Msg("Delta fetch success, marking fs as online.")
    70  			}
    71  			f.offline = false
    72  			f.Unlock()
    73  
    74  			f.db.Batch(func(tx *bolt.Tx) error {
    75  				return tx.Bucket(bucketDelta).Put([]byte("deltaLink"), []byte(f.deltaLink))
    76  			})
    77  
    78  			// wait until next interval
    79  			time.Sleep(interval)
    80  		} else {
    81  			// shortened duration while offline
    82  			time.Sleep(2 * time.Second)
    83  		}
    84  	}
    85  }
    86  
    87  type deltaResponse struct {
    88  	NextLink  string             `json:"@odata.nextLink,omitempty"`
    89  	DeltaLink string             `json:"@odata.deltaLink,omitempty"`
    90  	Values    []*graph.DriveItem `json:"value,omitempty"`
    91  }
    92  
    93  // Polls the delta endpoint and return deltas + whether or not to continue
    94  // polling. Does not perform deduplication. Note that changes from the local
    95  // client will actually appear as deltas from the server (there is no
    96  // distinction between local and remote changes from the server's perspective,
    97  // everything is a delta, regardless of where it came from).
    98  func (f *Filesystem) pollDeltas(auth *graph.Auth) ([]*graph.DriveItem, bool, error) {
    99  	resp, err := graph.Get(f.deltaLink, auth)
   100  	if err != nil {
   101  		return make([]*graph.DriveItem, 0), false, err
   102  	}
   103  
   104  	page := deltaResponse{}
   105  	json.Unmarshal(resp, &page)
   106  
   107  	// If the server does not provide a `@odata.nextLink` item, it means we've
   108  	// reached the end of this polling cycle and should not continue until the
   109  	// next poll interval.
   110  	if page.NextLink != "" {
   111  		f.deltaLink = strings.TrimPrefix(page.NextLink, graph.GraphURL)
   112  		return page.Values, true, nil
   113  	}
   114  	f.deltaLink = strings.TrimPrefix(page.DeltaLink, graph.GraphURL)
   115  	return page.Values, false, nil
   116  }
   117  
   118  // applyDelta diagnoses and applies a server-side change to our local state.
   119  // Things we care about (present in the local cache):
   120  // * Deleted items
   121  // * Changed content remotely, but not locally
   122  // * New items in a folder we have locally
   123  func (f *Filesystem) applyDelta(delta *graph.DriveItem) error {
   124  	id := delta.ID
   125  	name := delta.Name
   126  	parentID := delta.Parent.ID
   127  	ctx := log.With().
   128  		Str("id", id).
   129  		Str("parentID", parentID).
   130  		Str("name", name).
   131  		Logger()
   132  	ctx.Debug().Msg("Applying delta")
   133  
   134  	// diagnose and act on what type of delta we're dealing with
   135  
   136  	// do we have it at all?
   137  	if parent := f.GetID(parentID); parent == nil {
   138  		// Nothing needs to be applied, item not in cache, so latest copy will
   139  		// be pulled down next time it's accessed.
   140  		ctx.Trace().
   141  			Str("delta", "skip").
   142  			Msg("Skipping delta, item's parent not in cache.")
   143  		return nil
   144  	}
   145  
   146  	local := f.GetID(id)
   147  
   148  	// was it deleted?
   149  	if delta.Deleted != nil {
   150  		if delta.IsDir() && local != nil && local.HasChildren() {
   151  			// from docs: you should only delete a folder locally if it is empty
   152  			// after syncing all the changes.
   153  			ctx.Warn().Str("delta", "delete").
   154  				Msg("Refusing delta deletion of non-empty folder as per API docs.")
   155  			return errors.New("directory is non-empty")
   156  		}
   157  		ctx.Info().Str("delta", "delete").
   158  			Msg("Applying server-side deletion of item.")
   159  		f.DeleteID(id)
   160  		return nil
   161  	}
   162  
   163  	// does the item exist locally? if not, add the delta to the cache under the
   164  	// appropriate parent
   165  	if local == nil {
   166  		// check if we don't have it here first
   167  		local, _ = f.GetChild(parentID, name, nil)
   168  		if local != nil {
   169  			localID := local.ID()
   170  			ctx.Info().
   171  				Str("localID", localID).
   172  				Msg("Local item already exists under different ID.")
   173  			if isLocalID(localID) {
   174  				if err := f.MoveID(localID, id); err != nil {
   175  					ctx.Error().
   176  						Str("localID", localID).
   177  						Err(err).
   178  						Msg("Could not move item to new, nonlocal ID!")
   179  				}
   180  			}
   181  		} else {
   182  			ctx.Info().Str("delta", "create").
   183  				Msg("Creating inode from delta.")
   184  			f.InsertChild(parentID, NewInodeDriveItem(delta))
   185  			return nil
   186  		}
   187  	}
   188  
   189  	// was the item moved?
   190  	localName := local.Name()
   191  	if local.ParentID() != parentID || local.Name() != name {
   192  		log.Info().
   193  			Str("parent", local.ParentID()).
   194  			Str("name", localName).
   195  			Str("newParent", parentID).
   196  			Str("newName", name).
   197  			Str("id", id).
   198  			Str("delta", "rename").
   199  			Msg("Applying server-side rename")
   200  		oldParentID := local.ParentID()
   201  		// local rename only
   202  		f.MovePath(oldParentID, parentID, localName, name, f.auth)
   203  		// do not return, there may be additional changes
   204  	}
   205  
   206  	// Finally, check if the content/metadata of the remote has changed.
   207  	// "Interesting" changes must be synced back to our local state without
   208  	// data loss or corruption. Currently the only thing the local filesystem
   209  	// actually modifies remotely is the actual file data, so we simply accept
   210  	// the remote metadata changes that do not deal with the file's content
   211  	// changing.
   212  	if delta.ModTimeUnix() > local.ModTime() && !delta.ETagIsMatch(local.ETag) {
   213  		sameContent := false
   214  		if !delta.IsDir() && delta.File != nil {
   215  			local.RLock()
   216  			sameContent = local.VerifyChecksum(delta.File.Hashes.QuickXorHash)
   217  			local.RUnlock()
   218  		}
   219  
   220  		if !sameContent {
   221  			//TODO check if local has changes and rename the server copy if so
   222  			ctx.Info().Str("delta", "overwrite").
   223  				Msg("Overwriting local item, no local changes to preserve.")
   224  			// update modtime, hashes, purge any local content in memory
   225  			local.Lock()
   226  			defer local.Unlock()
   227  			local.DriveItem.ModTime = delta.ModTime
   228  			local.DriveItem.Size = delta.Size
   229  			local.DriveItem.ETag = delta.ETag
   230  			// the rest of these are harmless when this is a directory
   231  			// as they will be null anyways
   232  			local.DriveItem.File = delta.File
   233  			local.hasChanges = false
   234  			return nil
   235  		}
   236  	}
   237  
   238  	ctx.Trace().Str("delta", "skip").Msg("Skipping, no changes relative to local state.")
   239  	return nil
   240  }