Oban unique job periods are not what you think

Fixed windows vs sliding windows: a subtle but important difference.

Update 2026-04-07: Note optional performance boost via @>.


Oban's unique job period option uses fixed time windows (buckets), not sliding windows relative to the current timestamp. This can lead to unexpected duplicate jobs.

Diagram comparing fixed windows vs sliding windows for unique job periods

When this matters

Consider a weekly digest email with unique: [period: {7, :days}]. Business requirement: Never send digests on consecutive days (users would consider that spam).

With fixed 7-day buckets, this can fail:

  • User triggers digest on day 6 (end of bucket 1)
  • User triggers digest on day 8 (start of bucket 2)
  • Both emails are sent, just 2 days apart

With a sliding window, the second would be blocked because it's within 7 days of the first.

The problem in detail

You might expect: "No duplicate jobs within any 7-day sliding window."

What actually happens: Time is divided into fixed 7-day buckets. Jobs are unique within each bucket, but two jobs just days apart can both be inserted if they fall into different buckets.

defmodule UniqueJobTest do
  use MyDataCase
  use Oban.Pro.Testing, repo: Repo
  use Oban.Pro.Decorator

  @job unique: [period: {7, :days}, timestamp: :scheduled_at]
  def send_weekly_digest(_args), do: :ok

  # Only tests that start on bucket boundaries succeed (shift in [0, 7, 14])
  for shift <- 0..14 do
    @shift shift
    test "shift #{shift}" do
      timestamp = DateTime.shift(~U[2026-01-01T00:00:00Z], day: @shift)
      {:ok, %{conflict?: false}} = insert_send_weekly_digest(1, scheduled_at: timestamp)
      {:ok, %{conflict?: true}} = insert_send_weekly_digest(1, scheduled_at: DateTime.shift(timestamp, day: 1))
      {:ok, %{conflict?: true}} = insert_send_weekly_digest(1, scheduled_at: DateTime.shift(timestamp, day: 6))
      {:ok, %{conflict?: false}} = insert_send_weekly_digest(1, scheduled_at: DateTime.shift(timestamp, day: 7))
    end
  end
end

Tests starting on Jan 1, 8, 15 pass (bucket boundaries). Other tests fail because day+7 crosses a bucket boundary.

Why it works this way

I reached out to Parker & Shannon from the Oban team, who quickly confirmed 🙏 this behavior:

You're correct that unique job periods are treated as buckets, based on the current timestamp, but not strictly adding to it. This is a byproduct of an implementation that's backed by a unique index, as time comparisons are forbidden in index expressions.

The job.meta.uniq_key field contains the bucket identifier, enforced via a unique index. This is efficient but means true sliding windows aren't possible with the built-in mechanism.

The workaround: manual query

For true sliding window uniqueness, you need a custom query. This works for regular Oban jobs and decorated Oban Pro jobs.

Note: This is a simplified implementation compared to Oban's built-in unique jobs:

  • Ignores job state (matches jobs regardless of available, completed, cancelled, etc.)
  • Only matches on worker + args (no queue or partial keys matching)
  • Requires exact args equality (can't match on a subset of keys)

Adapt as needed for your use case:

defmodule UniqueJob do
  @moduledoc """
  Sliding window unique job insertion for Oban.
  """
  import Ecto.Query

  @doc """
  Insert a unique job based on a sliding window period.
  Returns `{:ok, existing_job}` with `conflict?: true` if a conflicting job exists.
  """
  def insert_with_sliding_period_window(%Ecto.Changeset{} = changeset, duration) do
    %Oban.Job{} = job = Ecto.Changeset.apply_changes(changeset)
    shift = duration |> Duration.new!() |> Duration.negate()
    cutoff = DateTime.shift(DateTime.utc_now(), shift)
    
    query =
      from(j in Oban.Job,
        where: j.worker == ^job.worker and j.args == ^job.args and j.scheduled_at > ^cutoff,
        order_by: [desc: j.scheduled_at],
        limit: 1
      )

    Repo.transact(fn ->
      case Repo.one(query) do
        nil -> Oban.insert(changeset)
        conflict -> {:ok, %{conflict | conflict?: true}}
      end
    end)
  end
end

Usage:

# Instead of:
SendWeeklyDigest.new(%{user_id: 123}, unique: [period: {7, :days}])
|> Oban.insert()

# Use:
SendWeeklyDigest.new(%{user_id: 123})
|> UniqueJob.insert_with_sliding_period_window(day: 7)

Optional: boost performance via index

If this query runs often, you can leverage Oban's existing GIN index on args by adding a containment check.

     from(j in Oban.Job,
-      where: j.worker == ^job.worker and j.args == ^job.args and j.scheduled_at > ^cutoff,
+      where:
+        j.worker == ^job.worker and
+          fragment("? @> ?", j.args, type(^job.args, :map)) and
+          j.args == ^job.args and
+          j.scheduled_at > ^cutoff,
       order_by: [desc: j.scheduled_at],
       limit: 1
     )

Keeping j.args == ^job.args in the query preserves exact equality semantics, because @> alone would also match supersets. This works both for regular Oban jobs and decorated Oban Pro jobs.

Conclusion

Oban's unique job periods provide efficient deduplication for many use cases. But if you need strict sliding window guarantees, implement a custom query. The Oban team mentioned they're thinking about how to support sliding windows natively, so this may change in the future.