Skip to content

Latest commit

 

History

History
513 lines (437 loc) · 23.5 KB

workflows.md

File metadata and controls

513 lines (437 loc) · 23.5 KB

Workflows

How Drivel actually runs, in the order things happen. Architecture is the static view — what talks to what; this is the dynamic one. Section numbers point into DESIGN.md for the reasoning.

1. Outbound: a write becomes an upload

Nothing on this path blocks the FUSE reply. The mount's only obligation is to put an event on a buffered channel.

sequenceDiagram
    autonumber
    actor app as Application
    participant vfs as internal/vfs<br/>(FUSE node + handle)
    participant back as Backing dir
    participant eng as Engine.Run<br/>(one goroutine)
    participant w as Worker<br/>(path-hashed)
    participant st as provider.Store
    participant state as state (bbolt)

    app->>vfs: write(fd, buf, off)
    vfs->>back: pwrite
    vfs-->>app: return (never waits on network)
    Note over vfs: the handle records<br/>the dirty extent

    app->>vfs: close(fd)
    vfs->>back: fstat — grow the extent set<br/>to the file's real size
    vfs->>eng: fsevent.Event{OpWrite, path, Dirty}

    eng->>eng: coalesce per path,<br/>re-arm the -push-delay timer<br/>(capped by the max-wait deadline)
    Note over eng: a structural op (rename,<br/>unlink) flushes that path first,<br/>preserving per-path order
    eng->>w: dispatch after the timer fires
    w->>w: three gates (below)
    w->>st: Put / PutRange
    st-->>w: RemoteFile{hash, version}
    w->>state: record echo (§4)
Loading

Three details that look incidental and are not:

  • Release fstats the backing file before the wrapped handle closes. A handle only sees its own writes, so its idea of the file's length stops at the last byte it wrote. Without the fstat the engine's size cross-check rejects every extent set and range writes silently never fire.
  • Workers are chosen by path hash, so all operations on one path are ordered with respect to each other, while unrelated paths proceed in parallel.
  • The OpWrite arrives on close, not on each write(2) — note where the arrow leaves the diagram. That is why -push-delay defaults to a value as short as 300ms: it is merging an edit's few closes, not batching a transfer, and a 50 GB copy passes through it as one event — a new file's OpCreate also leaves at the creating handle's first Flush (synchronous with close(2), where Release is not), not from Create, which until 2026-10-18 made every copy outlasting the window two uploads. It also means a file held open and never closed — new or not — produces no event at all, at any setting, and is reached only by the sweep. Because each later event re-arms the timer, the window alone cannot bound how long a path waits; the max-wait deadline, counted from the change that made the path pending, is what stops a never-quiet path from being held until shutdown.

2. The three gates before a content push

Every content upload runs this. Anything that does not clear a gate falls through to the whole-file Put that M1–M5 always did — that fallback is never wrong, only slower, which is what makes the whole optimisation safe.

flowchart TD
    start["pushContent(path, dirty)"] --> ph{"Is it a<br/>placeholder? (M5)"}
    ph -->|yes, or unsure| skip1["SKIP — nothing local to push"]
    ph -->|no| small{"size &lt; one block?"}
    small -->|yes| put["Whole-file Put<br/>(chunked, resumable)"]
    small -->|no| stat["one Stat: what does<br/>the remote hold now?"]

    stat --> rw{"RangePutter?<br/>extents known?<br/>remote size == local?<br/>remote still matches the echo?"}
    rw -->|all yes| patch["PutRange — send only<br/>the changed extents"]
    rw -->|any no| hash{"local bytes hash to<br/>what Stat just reported?"}
    patch -->|error| hash
    hash -->|yes| skip2["SKIP — remote already<br/>holds these bytes"]
    hash -->|no| put

    put --> echo["record echo"]
    patch --> echo
    skip2 --> done([done])
    skip1 --> done
    echo --> done

    classDef danger fill:#7a1f1f,stroke:#3d0f0f,color:#fff
    classDef safe fill:#1f6f43,stroke:#0d3b23,color:#fff
    class ph danger
    class put safe
Loading

Why it is in this order:

  • The placeholder gate is first and nothing may get in front of it. A placeholder holds zero bytes at full apparent size; uploading one replaces the remote file with nothing. It fails safe — unsure means "placeholder" means skip. Do not replace it with a size heuristic: a legitimate truncate-to-zero looks identical and must still sync.
  • The range gate runs before the hash gate because hashing reads the whole file. Checking "did anything change?" first would spend a 4 GB read to avoid a 4 MiB upload.
  • The range gate requires the remote to still match the echo record. A whole-file Put over someone else's edit loses it per documented §6 policy — bad, but recoverable, and the loser's bytes existed as a coherent version. Splicing extents into a diverged remote makes a hybrid file that existed nowhere, with no conflict copy. Diverged, no echo, or no state store ⇒ decline.
  • The hash gate compares against what Stat says the remote holds now, not against the echo. A stale echo would otherwise skip the push forever and never restore the file.
  • Both gates share one Stat and are skipped below one block, where the round-trip costs about what the upload would.

Drive implements no RangePutter — files.update replaces content wholesale — and that is deliberate, not a gap. What Drive gets from this is the hash gate plus chunked resumable sessions.

3. Inbound: a remote change becomes a local file

sequenceDiagram
    autonumber
    participant dl as Downloader.Run
    participant st as provider.ChangeSource
    participant state as state (bbolt)
    participant back as Backing dir
    participant hyd as Hydrator<br/>(lazy mode only)

    loop adaptive interval
        dl->>state: read cursor
        dl->>st: Changes(cursor)
        st-->>dl: []RemoteChange, next cursor

        loop each change
            alt removed
                dl->>back: RemoveAll (idempotent)
                dl->>state: forget baseline
            else Google-native doc
                Note over dl: no byte stream —<br/>logged, never materialised
            else
                dl->>state: GetEcho(path)
                alt echo matches hash/version
                    Note over dl: our own upload<br/>coming back — drop it (§4)
                else lazy + local placeholder
                    dl->>hyd: re-stamp with new metadata
                else local bytes already identical
                    dl->>state: record echo only
                else local diverged from baseline
                    dl->>back: conflict copy (§6),<br/>last-writer-wins by mtime
                else
                    dl->>back: download, or write a placeholder
                    dl->>state: record echo
                end
            end
        end
        dl->>state: store next cursor
    end
Loading

Echo suppression (§4) is the load-bearing correctness concern in this codebase. Without it every upload comes back through the feed as a remote change and is re-applied, which at best wastes a round trip and at worst overwrites a newer local edit. Both directions consult the same store.

Note the two distinct "nothing to do" outcomes: an echo match (this is our own content) and identical local bytes (common on a first sync over a directory that already mirrors Drive). They are reached by different evidence and both matter.

4. The enumeration sweep and reconcile

The change feed only reports what changed after a cursor was taken, so a Drive that existed before the first mount is invisible to it. A sweep is what makes the tree present. It runs off the FUSE path, on a first run, a resumed sweep, a dead cursor, an elapsed -sweep-interval, -resync, or — since M27 — a push the outbound path gave up on, which is the one trigger that fires with -sweep-interval 0 as well.

sequenceDiagram
    autonumber
    participant dl as Downloader
    participant st as provider.Enumerator
    participant state as state (bbolt)
    participant eng as Engine (Pusher)

    Note over dl,state: 1. SNAPSHOT
    dl->>st: StartCursor()
    st-->>dl: token
    dl->>state: persist sweep{gen, token, started}

    Note over dl,st: 2. ENUMERATE
    loop each page
        st-->>dl: []RemoteFile
        dl->>state: MarkSeen(gen, paths) — before acting
        loop each object
            dl->>dl: reconcileRemote — same rows as an inbound apply
        end
        dl->>state: persist sweep cursor (resume point)
    end

    Note over dl,eng: 3. LOCAL WALK
    dl->>dl: walk the backing tree
    Note over dl: skip placeholders, non-regular files, .drivel-*,<br/>sibling conflict copies, unsynced conflict folders
    dl->>eng: Push(OpWrite) for anything with<br/>no baseline and no seen mark
    dl->>eng: Push(OpWrite) for a baseline whose LOCAL copy<br/>diverged before this mount opened (M27 part 4)

    Note over dl,state: 4. DELETES — only now, only if complete
    dl->>state: EachUnseenEcho(gen)
    dl->>dl: apply the four guards, cap at -max-deletes

    Note over dl,state: 5. FINISH
    dl->>state: stamp sweep done
    dl->>state: adopt the token from step 1
Loading

Snapshot, then tail. The start token is taken before the sweep and adopted only after it. The overlap replays changes that are idempotent and that echo suppression drops; the other order loses everything that changed during the sweep, permanently and silently.

The three-way decision

flowchart TD
    q{"Remote object?"} -->|yes| ql{"Local copy?"}
    q -->|"no (local only)"| qb2{"Has a baseline?"}

    ql -->|no| qg{"baseline the remote<br/>still matches?"}
    qg -->|no| mat{"lazy, or<br/>-materialize?"}
    qg -->|yes| guards2{"sweep complete?<br/>baseline predates it?<br/>remote re-checked?<br/>folder accounted for?<br/>within -max-deletes?"}
    guards2 -->|all yes| delr["delete REMOTELY —<br/>deleted here"]
    guards2 -->|any no| keepr["KEEP the remote copy"]
    mat -->|yes| dl2["materialise:<br/>download or placeholder"]
    mat -->|no| defer["count as deferred,<br/>still marked seen"]

    ql -->|yes| qb{"remote matches<br/>the baseline?"}
    qb -->|no| conf["§6 conflict copy"]
    qb -->|"yes / echo match"| qlf{"local copy still<br/>matches it?"}
    qlf -->|yes| nop["nothing to do"]
    qlf -->|"no fingerprint, or<br/>modified since this<br/>mount opened"| nop
    qlf -->|"no, and older than<br/>this mount"| repush["RE-PUSH — a push<br/>was lost (M27 pt 4)"]

    qb2 -->|no| push["PUSH — it is new locally"]
    qb2 -->|yes| guards{"sweep complete?<br/>baseline predates it?<br/>local unmodified?<br/>within -max-deletes?"}
    guards -->|all yes| del["delete"]
    guards -->|any no| keep["KEEP — and push back<br/>if it diverged"]

    classDef danger fill:#7a1f1f,stroke:#3d0f0f,color:#fff
    class del,delr danger
Loading

A delete is inferred only from a baseline, never from absence alone. The baseline is the echo store ("we have synced this content at this path"); the per-generation seen marks answer "did this sweep observe it?". No baseline means the path is new, whichever side it is on — which is why the first-ever run deletes nothing.

Both directions have a delete row. Unlisted remotely, present here and unmodified is a deletion made elsewhere and removes the local copy. Listed remotely and still matching the baseline, absent here is a deletion made here — while drivel was down, or one whose push was abandoned — and removes the remote copy. That second row was in DESIGN.md's table from M7b and was not implemented until 2026-10-20: the remote-present rows were left to the pull's apply, which returns on an echo match before it looks at the local side, so such a path sat with both sides disagreeing for good. It is goneHere/deleteHere, and it adds three checks of its own: the remote is re-Stated immediately before the removal and must still match (an edit made since wins over the deletion); the local path must still be absent; and a directory is removed only if every object the sweep saw inside it was removed first, because Store.Remove is recursive.

The four guards on the delete rows are each load-bearing, in both directions:

  1. Deletes run only after a complete sweep.
  2. The baseline must predate the sweep, or a file created locally during it looks remotely deleted.
  3. A local copy that diverged from its baseline is kept and pushed back.
  4. -max-deletes abandons the whole pass rather than trimming it — a huge count means the premise is broken, not that there are 4000 real deletions. A refused pass still counts as completed, so recovery is -resync and a higher cap.

The sweep has one more pass, and it runs before the local walk above: pushOwedRepairs re-sends every path the outbound path gave up on (M27). It cannot be folded into the walk, and the reason is the whole shape of the feature: the walk is the new locally row and skips any path with a baseline, while an abandoned push is almost always a rewrite of a file that has one — whose baseline still describes exactly what the remote holds, so the remote rows match the echo and correctly do nothing. Before M27 such a file stayed local forever. A repair that succeeds writes a fresh echo, so the walk then skips it for the ordinary reason and nothing is sent twice.

M27 part 4 then gave the walk itself a second row, for the pushes no record could ever name: the engine writes the repair record when it gives up, which is code that runs, and kill -9 runs none. So repushDiverged compares each local file against the fingerprint its baseline already carries (state.Echo.LocalSize/LocalMTime, M18) and re-sends the ones that no longer match — which also covers a power cut, an OOM kill, a drain that timed out and a backing file written while the mount was down. Four things bound it: the local mtime must predate this mount's open (anything newer is the running engine's to finish, and without the bound a long copy is re-uploaded once per sweep); a baseline with no fingerprint is counted and skipped rather than guessed at; an unlisted path belongs to the delete pass; and a path the remote pass could not settle, or one the repair pass already claimed, is left alone. The converse half is in the engine: M6's gate 3 records the baseline it verified, without which a file whose mtime moved but whose content did not is offered for upload on every mount, forever.

Three implementation notes. The marks are persistent, because an in-memory seen-set would report every page a previous process consumed as remotely deleted. The completion stamp is persistent for a different reason: the periodic schedule measures from it, so someone who mounts for an hour a day still reaches an interval. And the sweep is the only correct place to prune a baseline, because pruning one and inferring a delete are the same decision from the same evidence — TTL and LRU eviction both fail here, silently.

Scoped versus flat enumeration (M7c)

Drive has no recursive "everything under this folder" query, so the two strategies are genuinely different shapes, and neither dominates.

flowchart LR
    subgraph flat["flat — one files.list over the account"]
        direction TB
        f1["page 1 (1000 objects)"] --> f2["page 2"] --> f3["page N"]
        f3 --> park["park objects whose parent<br/>is not yet seen; release when<br/>it arrives; drop what is still<br/>parked (outside the mount)"]
    end
    subgraph scoped["scoped — breadth-first descent"]
        direction TB
        s1["mount root"] --> s2["list up to 8 folders<br/>concurrently"]
        s2 --> s3["queue their subfolders"]
        s3 --> s2
        s2 -.->|throttled| adj["keep what succeeded,<br/>re-queue only what failed,<br/>halve concurrency"]
        adj --> s2
        s2 -.->|clean| grow["grow back, ceiling 8"]
    end
Loading

flat costs ~1 request per 1000 account objects; scoped costs ~1 request per folder in the mounted subtree. So a folder-dense subtree is cheaper flat, and auto picks by whether -drive-root names a concrete folder.

Two things about the scoped path are not optional. Under throttling it keeps the listings that succeeded and re-queues only the ones that failed — putting a throttled batch back whole measured 97× the ideal request count at a 20% refusal rate, because at fanout 8 most batches are spoiled and re-issuing all eight feeds the throttle. And the fan-out is a ceiling, not a rate: it halves on a throttled listing and grows back on a clean one, floor 1.

The parking machinery belongs to the flat path alone — the descent is parent-first by construction. Do not unify them. Resume also stops mattering in scoped mode: the frontier is in memory, and a cursor arriving without one simply restarts a descent that is cheap by construction.

5. Lazy hydration: fault-in on first I/O

sequenceDiagram
    autonumber
    actor app as Application
    participant vfs as fileHandle
    participant hyd as Hydrator
    participant back as Backing file
    participant st as provider.Store

    app->>vfs: open(path)
    Note over vfs: fetches NOTHING —<br/>open/truncate/rewrite are free

    app->>vfs: read(fd, …) or partial write
    vfs->>vfs: ensureResident
    vfs->>hyd: IsPlaceholder(path)?
    hyd->>back: getxattr(marker)
    hyd-->>vfs: yes
    vfs->>hyd: Hydrate(path) — singleflight
    hyd->>st: Get(path)
    st-->>hyd: content
    hyd->>back: fill in place, drop the marker
    alt fetch failed
        hyd-->>vfs: error
        vfs-->>app: EIO
        Note over vfs: never a short read —<br/>zeros served as content<br/>are silent corruption
    else
        vfs->>back: proceed with the original read/write
        vfs-->>app: data
    end
Loading

Hydrate on first I/O, not on Open. FUSE delivers O_TRUNC as a separate Setattr unless atomic_o_trunc is negotiated, so Open handles only the atomic case (dropping the mark) and Setattr handles truncation. The consequence is that opening a file to overwrite it never downloads the content it is about to discard.

The xattr is authoritative and there is no fallback. IsPlaceholder reads the marker and only the marker — the state DB caches present ranges, which answers a different question. No marker means no placeholder record at all, immediately, which is why -lazy is unsafe on a backing filesystem without user extended attributes rather than merely fragile.

Conflict copies are always downloaded in full, because the copy is made by drivel rather than materialised from a listing, so nothing would ever hydrate it.

6. Launching a backend, and surviving one that dies

A backend is a separate process (M9, DESIGN.md §2.10) — whether its code is bundled in this binary and launched by re-executing it (M23) or installed as a drivel-provider-KIND executable. This is what a mount does to get one, and what happens when it goes away underneath.

sequenceDiagram
    autonumber
    participant App as internal/app.Open
    participant Loader as plugin.Loader
    participant Proc as plugin process supervisor
    participant Plug as the backend process
    participant Engine as syncengine.Engine

    App->>Loader: Register(registry)
    Loader->>Loader: take the bundled kinds<br/>scan the search path for the rest
    Loader->>Loader: refuse group/world-writable binaries
    App->>Proc: Factory(ctx, Params{Config, Log})
    Proc->>Plug: fork/exec with a BUILT environment<br/>(this image + "plugin-serve KIND", or the installed file)
    Plug-->>Proc: handshake on stdout
    Proc->>Plug: Open{config TOML, content broker id}
    Plug->>Plug: factory(ctx, Params)<br/>provider.Capabilities(store)
    Plug-->>Proc: {capabilities}
    Proc-->>App: provider.Store (proxy, declaring those capabilities)
    App->>App: AsChangeSource? → start the pull loop, or not

    Note over Engine,Plug: normal operation
    Engine->>Proc: Put(path, reader)
    Proc->>Plug: stream: path, then 256 KiB chunks
    Plug-->>Proc: RemoteFile

    Note over Plug: the backend crashes
    Engine->>Proc: Put(path, reader)
    Proc-->>Engine: retryable: "restarting in 1s"
    Engine->>Engine: back off (M4 retry, unchanged)
    Engine->>Proc: Put(path, reader)
    Proc->>Plug: fork/exec, handshake, Open
    Proc->>Plug: stream the upload
    Plug-->>Engine: RemoteFile
Loading

Three things this picture is making a point of:

  • The capability answer is taken once, at step 9, and held for the life of the mount — including across a restart. The engine decides at step 11 whether this mount has a pull loop at all, so an answer that changed underneath it would leave a downloader polling a feed that is gone.
  • A crash is a retryable error, not a failed push. There is no supervisor goroutine; the engine's existing backoff is what waits, and the next call is what relaunches. The relaunch is rate-limited, and a process that lived at least a minute does not inherit a crash loop's delay.
  • The environment at step 5 is built, not inherited. See plugin/env.go — the point is that a backend authenticates with what its configuration names, so which account a mount uses is a property of the config file rather than of the shell that started it.
  • Everything from step 6 on is identical for both launch paths, and that is the requirement M23 is built on rather than something it happened to achieve: a bundled backend reaches the host through the same handshake, socket and generated protocol an installed one does, so the only thing the two differ in is what step 5 executes.

7. Mount lifecycle

stateDiagram-v2
    [*] --> Opening
    Opening: app.Open — acquire
    Opening: resolve backing, open state DB,<br/>build provider, validate cross-mount
    Opening --> Serving: all mounts opened
    Opening --> Closing: any mount failed<br/>(the ones that came up still drain)

    Serving: app.Run — serve
    Serving: FUSE server + Engine + Downloader
    Serving --> Draining: SIGINT / SIGTERM

    Draining: 1. unmount
    Draining: 2. wait for the inbound loop<br/>(pull + sweep), bounded
    Draining: 3. close(events)
    Draining: 4. engine drains in-flight uploads<br/>on a DETACHED context, bounded
    Draining --> Closing

    Closing: app.Close — release
    Closing: state DB, index, provider
    Closing --> [*]
Loading

The three-phase split is what lets a process that fails to bring up mount 3 of 5 unmount and drain the two that came up. Inside Run the per-mount ordering is load-bearing: unmount → wait for the inbound loop → close the event channel → wait for the engine's bounded drain, with the engine on a detached context so that the Ctrl-C which stopped the mount does not also abort the uploads it is trying to finish. App aggregates around that; it must never collapse it into one cancellation.

Both waits are bounded and the bounds differ on purpose. The engine is finishing uploads the user is waiting for; the pull loop and the sweep have nothing in hand worth delaying a shutdown for, since an unfinished sweep is resumed and an unfinished download is retried. What waiting for the inbound loop buys is that neither of them is still holding the state DB or the backing descriptor when Close takes those away — before it, a sweep outliving Run met a closed bbolt, and in in-place mode a download could have written through a /proc/self/fd/N whose number had been reused.

Where to look next

  • The path→ID resolution ladder and both database layouts: schema.
  • Why each of these rules exists, at length: DESIGN.md §2–§7 and §9.
  • The words used here without definition: glossary.