Recording to GCS
Store recorded output in a Google Cloud Storage bucket. This is useful for large results and output that should follow the bucket's retention policy rather than remain in Postgres.
This backend uses Req for requests and Goth for GCS's short-lived OAuth tokens. Add both dependencies, then start Goth in your supervision tree with a service account:
def deps do
[
{:req, "~> 0.5"},
{:goth, "~> 1.4"}
]
endcredentials =
"GOOGLE_APPLICATION_CREDENTIALS_JSON"
|> System.fetch_env!()
|> JSON.decode!()
{Goth, name: MyApp.Goth, source: {:service_account, credentials}}The Module
The module keeps the supervised Goth process in its runtime configuration and fetches a valid token for each request. Only non-sensitive values such as the bucket and process name are stored with jobs, service-account credentials remain in the supervised Goth process.
defmodule MyApp.GCS do
@behaviour Oban.Pro.Storage
@impl Oban.Pro.Storage
def init(opts) do
bucket = Keyword.get(opts, :bucket, "my-bucket")
goth = Keyword.get(opts, :goth, MyApp.Goth)
req = Req.new(base_url: "https://#{bucket}.storage.googleapis.com", decode_body: false)
%{req: req, goth: goth}
end
@impl Oban.Pro.Storage
def put(key, payload, conf) do
case request(conf, :put, url: "/#{key}", body: payload) do
{:ok, %{status: status}} when status in 200..299 -> :ok
{:ok, response} -> {:error, response}
{:error, reason} -> {:error, reason}
end
end
@impl Oban.Pro.Storage
def fetch_all(keys, conf) do
payloads =
for {:ok, {key, body}} <- Task.async_stream(keys, &fetch_one(&1, conf), ordered: false),
not is_nil(body),
into: %{},
do: {key, body}
{:ok, payloads}
end
@impl Oban.Pro.Storage
def delete_all(keys, conf) do
Enum.each(keys, &request(conf, :delete, url: "/#{&1}"))
end
defp fetch_one(key, conf) do
case request(conf, :get, url: "/#{key}") do
{:ok, %{status: 200, body: body}} -> {key, body}
_other -> {key, nil}
end
end
defp request(%{req: req, goth: goth}, method, opts) do
%{token: token} = Goth.fetch!(goth)
Req.request(req, [method: method, auth: {:bearer, token}] ++ opts)
end
endThe recipe fetches batches concurrently and omits missing objects. To keep output after its job is
pruned, make delete_all/2 a no-op and use bucket lifecycle rules for retention.
Using It
Configure the backend on a recorded worker and pass non-sensitive options such as the bucket:
use Oban.Pro.Worker, recorded: [storage: {MyApp.GCS, bucket: "oban-output"}]To send every worker's output to GCS without annotating each one, set it as the compile-time default:
config :oban_pro, Oban.Pro.Worker, recorded: [storage: MyApp.GCS]