PhoenixKit.Modules.Storage.UploadInbox (phoenix_kit v2.60.2)

Copy Markdown View Source

Where an upload's bytes wait between "the server has them" and "they are stored", so a failure in between is something a person can see and act on.

The media browser used to copy a finished transfer to a temp path, try to store it, and delete the temp file either way. A failed store became one anonymous count in a flash ("2 failed") and the bytes were gone; a raise took the LiveView down with every queued file in it; a page refresh while files were queued lost them without a word. Someone who dropped ten pictures and found six had no way to learn which four, or to try again.

Now a received upload is written here first and removed only once it is stored. One that fails stays, marked with why; one whose processing never finished (the LiveView died, the page was refreshed) stays too, and reads as interrupted once it is old enough that nothing can still be working on it. list/1 is what the browser's "Upload problems" panel shows, and it survives a refresh because it lives on disk rather than in a socket.

One directory per user (<root>/<user_uuid>/), holding each upload's bytes at <id> and what is known about it at <id>.json. The root defaults to the system temp dir — this is a waiting room, not storage — and can be moved with config :phoenix_kit, :upload_inbox_dir, "/path". Anything older than a week is swept on the next listing, and all users' inboxes are swept at most once an hour when an upload arrives.

Where an upload belongs

An item remembers the destination it was dropped on (library_uuid and folder_uuid, recorded by the first browser that claims it) and who is working on it (owner, the LiveView's pid). A retry goes to the recorded destination, never to whichever browser the click happened in — a private library's file must not land in Media — and a panel lists only the items of its own library. An item whose owner is still alive is live, whatever its age; one whose owner is gone reads as interrupted at once. Retry and Discard from a panel that has gone stale go through claim/4 and discard/2, which refuse an item another process is working on.

Persistence

The default root is node-local temporary storage: a multi-node deployment, or a container with an ephemeral filesystem, loses "survives a refresh" when the reconnect lands elsewhere or the host restarts. Point :upload_inbox_dir at storage every node shares if that matters. Directories are created 0700. There is no per-user quota yet.

Summary

Functions

Takes an item to work on, for the calling process: it reads as live while this process is, and dest (%{library_uuid: _, folder_uuid: _}) becomes its destination unless it already has one. {:ok, item} carries the item as it now stands — a retry stores it at item.dest, not where the click was.

Removes an item — it was stored, or the user discarded it.

Removes an item the user let go of. {:error, :busy} — and nothing removed — while another live process is working on it: a panel that went stale must not delete bytes a second tab is storing.

Marks an item failed, keeping its bytes for a retry.

An item as it stands, or nil when it is gone. Its dest is where a retry must put it.

The items that need the user's attention, oldest first: failed ones, and received ones old enough that their processing was clearly interrupted (those come back with status "interrupted"). Sweeps week-old items.

The inbox path of path's item as {user_uuid, id}, or nil if path is not in an inbox.

The bytes of an item, when it is still here.

Moves src into user_uuid's inbox as a received upload and returns the item. meta carries the client's :client_name, :client_type and :client_size. {:error, reason} when the user is not a uuid or the disk refuses — the caller then falls back to its own temp file, which is still where it was: a failure here never costs the bytes.

The inbox root.

Marks an item as being worked on again (a retry), so it reads as live, not interrupted.

Whether an upload is in somebody's hands right now: received, and either claimed by a live process or too young to call abandoned. A page uses it to keep looking while that is so (see list/1).

Types

item()

@type item() :: %{
  id: String.t(),
  client_name: String.t(),
  client_type: String.t() | nil,
  client_size: non_neg_integer() | nil,
  status: String.t(),
  error: String.t() | nil,
  received_at: integer(),
  dest: %{library_uuid: String.t() | nil, folder_uuid: String.t() | nil} | nil,
  owner: String.t() | nil
}

Functions

claim(user_uuid, id, dest, owner \\ self())

@spec claim(String.t(), String.t(), map() | nil, pid()) ::
  {:ok, item()} | {:error, term()}

Takes an item to work on, for the calling process: it reads as live while this process is, and dest (%{library_uuid: _, folder_uuid: _}) becomes its destination unless it already has one. {:ok, item} carries the item as it now stands — a retry stores it at item.dest, not where the click was.

{:error, :busy} when another live process holds it (a stale panel retried an item a second tab has already taken), {:error, :not_found} when its bytes are gone.

delete(user_uuid, id)

@spec delete(String.t(), String.t()) :: :ok

Removes an item — it was stored, or the user discarded it.

discard(user_uuid, id, owner \\ self())

@spec discard(String.t(), String.t(), pid()) :: :ok | {:error, :busy}

Removes an item the user let go of. {:error, :busy} — and nothing removed — while another live process is working on it: a panel that went stale must not delete bytes a second tab is storing.

fail(user_uuid, id, reason)

@spec fail(String.t(), String.t(), String.t()) :: :ok | {:error, term()}

Marks an item failed, keeping its bytes for a retry.

get(user_uuid, id)

@spec get(String.t(), String.t()) :: item() | nil

An item as it stands, or nil when it is gone. Its dest is where a retry must put it.

list(user_uuid)

@spec list(String.t() | nil) :: [item()]

The items that need the user's attention, oldest first: failed ones, and received ones old enough that their processing was clearly interrupted (those come back with status "interrupted"). Sweeps week-old items.

locate(path)

@spec locate(Path.t()) :: {String.t(), String.t()} | nil

The inbox path of path's item as {user_uuid, id}, or nil if path is not in an inbox.

path(user_uuid, id)

@spec path(String.t(), String.t()) :: {:ok, Path.t()} | :error

The bytes of an item, when it is still here.

put(user_uuid, src, meta, opts \\ [])

@spec put(String.t(), Path.t(), map(), keyword()) ::
  {:ok, item(), Path.t()} | {:error, term()}

Moves src into user_uuid's inbox as a received upload and returns the item. meta carries the client's :client_name, :client_type and :client_size. {:error, reason} when the user is not a uuid or the disk refuses — the caller then falls back to its own temp file, which is still where it was: a failure here never costs the bytes.

copy: true leaves src in place (a second browser's own item).

root()

The inbox root.

touch(user_uuid, id)

@spec touch(String.t(), String.t()) :: :ok | {:error, term()}

Marks an item as being worked on again (a retry), so it reads as live, not interrupted.

waiting?(user_uuid)

@spec waiting?(String.t() | nil) :: boolean()

Whether an upload is in somebody's hands right now: received, and either claimed by a live process or too young to call abandoned. A page uses it to keep looking while that is so (see list/1).