├── .credo.exs ├── .formatter.exs ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── SECURITY.md ├── assets ├── oban-logo.svg ├── oban-logotype-dark.png └── oban-logotype-light.png ├── bench ├── bench_helper.exs ├── benchmarks │ ├── engine_bench.exs │ ├── inserting_bench.exs │ ├── throughput_bench.exs │ └── unique_inserts_bench.exs └── support │ └── setup.exs ├── config └── config.exs ├── guides ├── advanced │ ├── release_configuration.md │ ├── scaling.md │ ├── troubleshooting.md │ └── writing_plugins.md ├── introduction │ ├── installation.md │ └── ready_for_production.md ├── learning │ ├── clustering.md │ ├── defining_queues.md │ ├── error_handling.md │ ├── instrumentation.md │ ├── isolation.md │ ├── job_lifecycle.md │ ├── operational_maintenance.md │ ├── periodic_jobs.md │ ├── scheduling_jobs.md │ └── unique_jobs.md ├── recipes │ ├── expected-failures.md │ ├── migrating-from-other-languages.md │ ├── recursive-jobs.md │ ├── reliable-scheduling.md │ ├── reporting-progress.md │ └── splitting-queues.md ├── testing │ ├── testing.md │ ├── testing_config.md │ ├── testing_queues.md │ └── testing_workers.md └── upgrading │ ├── v2.0.md │ ├── v2.11.md │ ├── v2.12.md │ ├── v2.14.md │ ├── v2.17.md │ ├── v2.20.md │ └── v2.6.md ├── lib ├── mix │ └── tasks │ │ └── oban.install.ex ├── oban.ex └── oban │ ├── application.ex │ ├── backoff.ex │ ├── config.ex │ ├── cron.ex │ ├── cron │ └── expression.ex │ ├── engine.ex │ ├── engines │ ├── basic.ex │ ├── dolphin.ex │ ├── inline.ex │ └── lite.ex │ ├── exceptions.ex │ ├── job.ex │ ├── json.ex │ ├── midwife.ex │ ├── migration.ex │ ├── migrations │ ├── myxql.ex │ ├── postgres.ex │ ├── postgres │ │ ├── v01.ex │ │ ├── v02.ex │ │ ├── v03.ex │ │ ├── v04.ex │ │ ├── v05.ex │ │ ├── v06.ex │ │ ├── v07.ex │ │ ├── v08.ex │ │ ├── v09.ex │ │ ├── v10.ex │ │ ├── v11.ex │ │ ├── v12.ex │ │ └── v13.ex │ └── sqlite.ex │ ├── notifier.ex │ ├── notifiers │ ├── isolated.ex │ ├── pg.ex │ └── postgres.ex │ ├── nursery.ex │ ├── peer.ex │ ├── peers │ ├── database.ex │ ├── global.ex │ └── isolated.ex │ ├── plugin.ex │ ├── plugins │ ├── cron.ex │ ├── gossip.ex │ ├── lifeline.ex │ ├── pruner.ex │ ├── reindexer.ex │ └── repeater.ex │ ├── queue │ ├── drainer.ex │ ├── executor.ex │ ├── producer.ex │ ├── supervisor.ex │ └── watchman.ex │ ├── registry.ex │ ├── repo.ex │ ├── sonar.ex │ ├── stager.ex │ ├── telemetry.ex │ ├── testing.ex │ ├── validation.ex │ └── worker.ex ├── mix.exs ├── mix.lock └── test ├── mix └── tasks │ └── oban.install_test.exs ├── oban ├── backoff_test.exs ├── config_test.exs ├── cron │ └── expression_test.exs ├── cron_test.exs ├── engine_test.exs ├── engines │ ├── basic_test.exs │ └── inline_test.exs ├── job_test.exs ├── migrations │ ├── lite_test.exs │ ├── myxql_test.exs │ └── postgres_test.exs ├── notifier_test.exs ├── notifiers │ └── pg_test.exs ├── peer_test.exs ├── peers │ ├── database_test.exs │ ├── global_test.exs │ └── isolated_test.exs ├── plugins │ ├── cron_test.exs │ ├── gossip_test.exs │ ├── lifeline_test.exs │ ├── pruner_test.exs │ ├── reindexer_test.exs │ └── repeater_test.exs ├── queue │ ├── executor_test.exs │ └── watchman_test.exs ├── repo_test.exs ├── sonar_test.exs ├── stager_test.exs ├── telemetry_test.exs ├── testing_test.exs └── worker_test.exs ├── oban_test.exs ├── support ├── case.ex ├── exercise.ex ├── myxql │ └── migrations │ │ └── 20240831185338_add_oban_jobs_table.exs ├── postgres │ └── migrations │ │ └── 20190210214150_add_oban_jobs_table.exs ├── repo.ex ├── sqlite │ └── migrations │ │ └── 20221222130000_add_oban_jobs_table.exs ├── telemetry_handler.ex └── worker.ex └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | import_deps: [ 4 | :ecto, 5 | :ecto_sql, 6 | :stream_data 7 | ], 8 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], 9 | export: [ 10 | locals_without_parens: [ 11 | assert_enqueued: 1, 12 | assert_enqueued: 2, 13 | refute_enqueued: 1, 14 | refute_enqueued: 2 15 | ] 16 | ], 17 | locals_without_parens: [ 18 | assert_enqueued: 1, 19 | assert_enqueued: 2, 20 | refute_enqueued: 1, 21 | refute_enqueued: 2 22 | ] 23 | ] 24 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: ["https://oban.pro"] 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### Precheck 11 | 12 | - Do a quick search and make sure the bug has not yet been reported 13 | - For support, favor using the Elixir Forum, Slack, IRC, etc. 14 | - Be friendly and polite! 15 | 16 | ### Environment 17 | 18 | - Oban Version 19 | - PostgreSQL Version 20 | - Elixir & Erlang/OTP Versions (`elixir --version`) 21 | 22 | ### Current Behavior 23 | 24 | Include code samples, errors and stacktraces if appropriate. 25 | 26 | ### Expected Behavior 27 | 28 | A short description on how you expect the code to behave. 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### Is your feature request related to a problem? Please describe. 11 | 12 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 13 | 14 | ### Describe the Solution You'd Like 15 | 16 | A clear and concise description of what you want to happen. 17 | 18 | ### Describe Alternatives You've Considered 19 | 20 | A clear and concise description of any alternative solutions or features you've considered. 21 | 22 | ### Additional Context 23 | 24 | Add any other context or screenshots about the feature request here. 25 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "mix" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | groups: 8 | production-dependencies: 9 | dependency-type: "production" 10 | development-dependencies: 11 | dependency-type: "development" 12 | - package-ecosystem: "github-actions" 13 | directory: "/" 14 | schedule: 15 | interval: "weekly" 16 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | paths-ignore: 6 | - 'guides/**' 7 | pull_request: 8 | paths-ignore: 9 | - 'guides/**' 10 | 11 | jobs: 12 | ci: 13 | env: 14 | MIX_ENV: test 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | include: 19 | - pair: 20 | elixir: '1.15' 21 | otp: '24.3' 22 | mysql: '8.4' 23 | postgres: '12.13-alpine' 24 | exclude_tags: 'lite' 25 | - pair: 26 | elixir: '1.18' 27 | otp: '27.2' 28 | mysql: '9.1' 29 | postgres: '17.2-alpine' 30 | exclude_tags: 'gossip' 31 | lint: lint 32 | 33 | runs-on: ubuntu-24.04 34 | 35 | services: 36 | postgres: 37 | image: postgres:${{matrix.pair.postgres}} 38 | env: 39 | POSTGRES_DB: oban_test 40 | POSTGRES_PASSWORD: postgres 41 | POSTGRES_USER: postgres 42 | options: >- 43 | --health-cmd pg_isready 44 | --health-interval 10s 45 | --health-timeout 5s 46 | --health-retries 5 47 | ports: 48 | - 5432/tcp 49 | mysql: 50 | image: mysql:${{matrix.pair.mysql}} 51 | env: 52 | MYSQL_ALLOW_EMPTY_PASSWORD: "yes" 53 | options: >- 54 | --health-cmd "mysqladmin ping" 55 | --health-interval 10s 56 | --health-timeout 5s 57 | --health-retries 5 58 | ports: 59 | - 3306/tcp 60 | 61 | steps: 62 | - uses: actions/checkout@v4 63 | 64 | - uses: erlef/setup-beam@v1 65 | with: 66 | otp-version: ${{matrix.pair.otp}} 67 | elixir-version: ${{matrix.pair.elixir}} 68 | 69 | - uses: actions/cache@v4 70 | with: 71 | path: | 72 | deps 73 | _build 74 | key: ${{ runner.os }}-mix-${{matrix.pair.elixir}}-${{matrix.pair.otp}}-${{ hashFiles('**/mix.lock') }} 75 | restore-keys: | 76 | ${{ runner.os }}-mix-${{matrix.pair.elixir}}-${{matrix.pair.otp}}- 77 | 78 | - name: Run mix deps.get 79 | run: mix deps.get --only test 80 | 81 | - name: Run mix format 82 | run: mix format --check-formatted 83 | if: ${{ matrix.lint }} 84 | 85 | - name: Run mix deps.unlock 86 | run: mix deps.unlock --check-unused 87 | if: ${{ matrix.lint }} 88 | 89 | - name: Run mix deps.compile 90 | run: mix deps.compile 91 | 92 | - name: Run mix compile 93 | run: mix compile --warnings-as-errors 94 | if: ${{ matrix.lint }} 95 | 96 | - name: Run credo 97 | run: mix credo --strict 98 | if: ${{ matrix.lint }} 99 | 100 | - name: Run mix ecto.migrate 101 | env: 102 | MYSQL_URL: mysql://root@localhost:${{job.services.mysql.ports[3306]}}/oban_test 103 | POSTGRES_URL: postgresql://postgres:postgres@localhost:${{job.services.postgres.ports[5432]}}/oban_test 104 | run: mix test.setup 105 | 106 | - name: Run mix test 107 | env: 108 | MYSQL_URL: mysql://root@localhost:${{job.services.mysql.ports[3306]}}/oban_test 109 | POSTGRES_URL: postgresql://postgres:postgres@localhost:${{job.services.postgres.ports[5432]}}/oban_test 110 | run: mix test --exclude ${{ matrix.exclude_tags }} || mix test --failed 111 | 112 | - name: Run dialyzer 113 | run: mix dialyzer 114 | if: ${{ matrix.lint }} 115 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | oban-*.tar 24 | 25 | # Ignore local .db files 26 | /priv/*.db* 27 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Oban applies bug fixes only to the latest minor version. Security patches are 6 | available for the last 3 minor versions: 7 | 8 | | Oban Version | Support | 9 | | ------------ | ------------------------------ | 10 | | 2.17 | Bug fixes and security patches | 11 | | 2.16 | Security patches only | 12 | | 2.15 | Security patches only | 13 | 14 | ## Announcements 15 | 16 | [Security advisories will be published on GitHub](https://github.com/oban-bg/oban/security). 17 | 18 | ## Reporting Vulnerabilities 19 | 20 | [Please disclose security vulnerabilities via GitHub](https://github.com/oban-bg/oban/security). 21 | -------------------------------------------------------------------------------- /assets/oban-logotype-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oban-bg/oban/57f11142abac655d88b53b59872fb86e45f5fe58/assets/oban-logotype-dark.png -------------------------------------------------------------------------------- /assets/oban-logotype-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oban-bg/oban/57f11142abac655d88b53b59872fb86e45f5fe58/assets/oban-logotype-light.png -------------------------------------------------------------------------------- /bench/bench_helper.exs: -------------------------------------------------------------------------------- 1 | Code.require_file("support/setup.exs", __DIR__) 2 | Code.require_file("benchmarks/engine_bench.exs", __DIR__) 3 | Code.require_file("benchmarks/throughput_bench.exs", __DIR__) 4 | Code.require_file("benchmarks/inserting_bench.exs", __DIR__) 5 | Code.require_file("benchmarks/unique_inserts_bench.exs", __DIR__) 6 | -------------------------------------------------------------------------------- /bench/benchmarks/engine_bench.exs: -------------------------------------------------------------------------------- 1 | defmodule BenchWorker do 2 | @moduledoc false 3 | 4 | use Oban.Worker 5 | 6 | @impl Oban.Worker 7 | def perform(%{args: %{"max" => max, "bin_pid" => bin_pid, "bin_cnt" => bin_cnt}}) do 8 | pid = BenchHelper.base64_to_term(bin_pid) 9 | ctn = BenchHelper.base64_to_term(bin_cnt) 10 | 11 | :ok = :counters.add(ctn, 1, 1) 12 | 13 | if :counters.get(ctn, 1) >= max do 14 | send(pid, :finished) 15 | end 16 | 17 | :ok 18 | end 19 | end 20 | 21 | counter = :counters.new(1, []) 22 | 23 | insert_and_await = fn _engine -> 24 | :ok = :counters.put(counter, 1, 0) 25 | 26 | args = %{ 27 | max: 1_000, 28 | bin_pid: BenchHelper.term_to_base64(self()), 29 | bin_cnt: BenchHelper.term_to_base64(counter) 30 | } 31 | 32 | 0..1_000 33 | |> Enum.map(fn _ -> BenchWorker.new(args, queue: :default) end) 34 | |> Oban.insert_all() 35 | 36 | receive do 37 | :finished -> :ok 38 | after 39 | 30_000 -> raise "Timeout" 40 | end 41 | end 42 | 43 | Benchee.run( 44 | %{"Insert & Execute" => insert_and_await}, 45 | inputs: %{ 46 | "Basic" => {Oban.Engines.Basic, Oban.Test.Repo}, 47 | "Lite" => {Oban.Engines.Lite, Oban.Test.LiteRepo} 48 | }, 49 | before_scenario: fn {engine, repo} -> 50 | Oban.start_link( 51 | engine: engine, 52 | peer: Oban.Peers.Global, 53 | prefix: prefix, 54 | queues: [default: 10], 55 | repo: repo 56 | ) 57 | end, 58 | after_scenario: fn _ -> 59 | Oban 60 | |> Oban.Registry.whereis() 61 | |> Supervisor.stop() 62 | end 63 | ) 64 | -------------------------------------------------------------------------------- /bench/benchmarks/inserting_bench.exs: -------------------------------------------------------------------------------- 1 | Oban.start_link(repo: Oban.Test.Repo, queues: []) 2 | 3 | args = %{expires_at: "2021-01-25", id: "156de198-bfb6-4c1a-be2c-da5b19ebc468"} 4 | meta = %{trace: "72f19313-9e4a-4c51-bb9b-1bc082e4da5e", vsn: "9.68.90"} 5 | 6 | insert_all = fn -> 7 | 0..1_000 8 | |> Enum.map(fn _ -> Oban.Job.new(args, worker: FakeWorker, meta: meta) end) 9 | |> Oban.insert_all() 10 | end 11 | 12 | Benchee.run(%{"Insert All" => insert_all}) 13 | -------------------------------------------------------------------------------- /bench/benchmarks/throughput_bench.exs: -------------------------------------------------------------------------------- 1 | defmodule BenchWorker do 2 | @moduledoc false 3 | 4 | use Oban.Worker 5 | 6 | @impl Oban.Worker 7 | def perform(%{args: %{"max" => max, "bin_pid" => bin_pid, "bin_cnt" => bin_cnt}}) do 8 | pid = BenchHelper.base64_to_term(bin_pid) 9 | ctn = BenchHelper.base64_to_term(bin_cnt) 10 | 11 | :ok = :counters.add(ctn, 1, 1) 12 | 13 | if :counters.get(ctn, 1) >= max do 14 | send(pid, :finished) 15 | end 16 | 17 | :ok 18 | end 19 | end 20 | 21 | queues = [small: 1, medium: 10, large: 100, xlarge: 500] 22 | counter = :counters.new(1, []) 23 | 24 | Oban.start_link(repo: Oban.Test.Repo, queues: queues) 25 | 26 | BenchHelper.reset_db() 27 | 28 | insert_and_await = fn queue -> 29 | :ok = :counters.put(counter, 1, 0) 30 | 31 | args = %{ 32 | max: 1_000, 33 | bin_pid: BenchHelper.term_to_base64(self()), 34 | bin_cnt: BenchHelper.term_to_base64(counter) 35 | } 36 | 37 | 0..1_000 38 | |> Enum.map(fn _ -> BenchWorker.new(args, queue: queue) end) 39 | |> Oban.insert_all() 40 | 41 | receive do 42 | :finished -> :ok 43 | after 44 | 30_000 -> raise "Timeout" 45 | end 46 | end 47 | 48 | Benchee.run( 49 | %{"Insert & Execute" => insert_and_await}, 50 | inputs: for({queue, _limit} <- queues, do: {to_string(queue), queue}) 51 | ) 52 | -------------------------------------------------------------------------------- /bench/benchmarks/unique_inserts_bench.exs: -------------------------------------------------------------------------------- 1 | defmodule UniqueWorker do 2 | use Oban.Worker, unique: [period: :infinity] 3 | 4 | @impl true 5 | def perform(_), do: :ok 6 | end 7 | 8 | Oban.start_link(repo: Oban.Test.Repo, queues: []) 9 | 10 | unique_insert = fn _ -> 11 | %{id: 1} 12 | |> UniqueWorker.new() 13 | |> Oban.insert() 14 | end 15 | 16 | Benchee.run( 17 | %{"Unique Insert" => unique_insert}, 18 | inputs: Map.new([0, 1000, 10_000, 100_000, 1_000_000], fn x -> {to_string(x), x} end), 19 | before_scenario: fn input -> 20 | BenchHelper.reset_db() 21 | 22 | (0..input 23 | |> Enum.chunk_every(5_000) 24 | |> Enum.each(fn ids -> 25 | ids 26 | |> Enum.map(&UniqueWorker.new(%{id: &1})) 27 | |> Oban.insert_all() 28 | end)) 29 | 30 | Oban.Test.Repo.query!("VACUUM ANALYZE oban_jobs", [], log: false) 31 | end 32 | ) 33 | -------------------------------------------------------------------------------- /bench/support/setup.exs: -------------------------------------------------------------------------------- 1 | defmodule BenchHelper do 2 | alias Oban.Test.{LiteRepo, Repo} 3 | 4 | def start do 5 | Application.ensure_all_started(:postgrex) 6 | Repo.start_link() 7 | LiteRepo.start_link() 8 | 9 | reset_db() 10 | end 11 | 12 | def reset_db do 13 | Repo.query!("TRUNCATE oban_jobs", [], log: false) 14 | LiteRepo.query!("DELETE FROM oban_jobs", [], log: false) 15 | end 16 | 17 | def term_to_base64(term) do 18 | term 19 | |> :erlang.term_to_binary() 20 | |> Base.encode64() 21 | end 22 | 23 | def base64_to_term(bin) do 24 | bin 25 | |> Base.decode64!() 26 | |> :erlang.binary_to_term() 27 | end 28 | end 29 | 30 | BenchHelper.start() 31 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :elixir, :time_zone_database, Tz.TimeZoneDatabase 4 | 5 | config :logger, level: :warning 6 | 7 | config :oban, Oban.Backoff, retry_mult: 1 8 | 9 | config :oban, Oban.Test.Repo, 10 | migration_lock: false, 11 | pool: Ecto.Adapters.SQL.Sandbox, 12 | pool_size: System.schedulers_online() * 2, 13 | priv: "test/support/postgres", 14 | show_sensitive_data_on_connection_error: true, 15 | stacktrace: true, 16 | url: System.get_env("POSTGRES_URL") || "postgres://localhost:5432/oban_test" 17 | 18 | config :oban, Oban.Test.LiteRepo, 19 | database: "priv/oban.db", 20 | priv: "test/support/sqlite", 21 | stacktrace: true, 22 | temp_store: :memory 23 | 24 | config :oban, Oban.Test.DolphinRepo, 25 | pool: Ecto.Adapters.SQL.Sandbox, 26 | pool_size: System.schedulers_online() * 2, 27 | priv: "test/support/myxql", 28 | show_sensitive_data_on_connection_error: true, 29 | stacktrace: true, 30 | url: System.get_env("MYSQL_URL") || "mysql://root@localhost:3306/oban_test" 31 | 32 | config :oban, 33 | ecto_repos: [Oban.Test.Repo, Oban.Test.LiteRepo, Oban.Test.DolphinRepo] 34 | -------------------------------------------------------------------------------- /guides/advanced/release_configuration.md: -------------------------------------------------------------------------------- 1 | # Release Configuration 2 | 3 | While having the same Oban configuration for every environment might be fine, 4 | there are certainly times you might want to make changes for a specific 5 | environment. For example, you may want to increase or decrease a queue's 6 | concurrency. 7 | 8 | ## Using Config Providers 9 | 10 | If you are using Elixir Releases, this is straight forward to do using [Config 11 | Providers][mcp]: 12 | 13 | 14 | ```elixir 15 | defmodule MyApp.ConfigProvider do 16 | @moduledoc """ 17 | Provide release configuration for Oban Queue Concurrency 18 | """ 19 | 20 | @behaviour Config.Provider 21 | 22 | def init(path) when is_binary(path), do: path 23 | 24 | def load(config, path) do 25 | case parse_json(path) do 26 | nil -> 27 | config 28 | 29 | queues -> 30 | Config.Reader.merge(config, ingestion: [{Oban, [queues: queues]}]) 31 | end 32 | end 33 | 34 | defp parse_json(path) do 35 | if File.exists?(path) do 36 | path 37 | |> File.read!() 38 | |> JSON.decode!() 39 | |> Map.fetch!("queues") 40 | |> Keyword.new(fn {key, value} -> {String.to_atom(key), value} end) 41 | end 42 | end 43 | end 44 | ``` 45 | 46 | Our config provider ensures that the `Jason` app is loaded so that we can parse 47 | a `JSON` configuration file. Once the JSON is loaded we must extract the 48 | `queues` map and convert it to a keyword list where all of the keys are atoms. 49 | The use of `String.to_atom/1` is safe because all of our queue names are 50 | already defined. 51 | 52 | Then you include this in your `mix.exs` file, where your release is configured: 53 | 54 | ```elixir 55 | releases: [ 56 | umbrella_app: [ 57 | version: "0.0.1", 58 | applications: [ 59 | child_app: :permanent 60 | ], 61 | config_providers: [{Path.To.ConfigProvider, "/etc/config.json"}] 62 | ] 63 | ] 64 | ``` 65 | 66 | Then when you release your app, you ensure that you have a JSON file mounted at 67 | whatever path you specified above and that it contains all of your desired queues: 68 | 69 | ```json 70 | {"queues": {"special": 1, "default": 10, "events": 20}} 71 | ``` 72 | 73 | [mcp]: https://hexdocs.pm/mix/Mix.Tasks.Release.html#module-config-providers 74 | -------------------------------------------------------------------------------- /guides/advanced/troubleshooting.md: -------------------------------------------------------------------------------- 1 | # Troubleshooting 2 | 3 | ## Jobs Stuck Executing Forever 4 | 5 | During deployment or unexpected node restarts, jobs may be left in an `executing` state 6 | indefinitely. We call these jobs "orphans", but orphaning isn't a bad thing. It means that the job 7 | wasn't lost and it may be retried again when the system comes back online. 8 | 9 | There are two mechanisms to mitigate orphans: 10 | 11 | 1. Increase the [shutdown_grace_period](Oban.html#start_link/1-twiddly-options) to allow the 12 | system more time to finish executing before shutdown. During shutdown each queue stops 13 | fetching more jobs, but executing jobs have up to the grace period to complete. The default 14 | value is `15000ms`, or 15 seconds. 15 | 16 | 2. Use the [Lifeline plugin](Oban.Plugins.Lifeline.html) to automatically move those jobs back to 17 | available so they can run again. 18 | 19 | ```elixir 20 | config :my_app, Oban, 21 | plugins: [Oban.Plugins.Lifeline], 22 | shutdown_grace_period: :timer.seconds(60), 23 | ... 24 | ``` 25 | 26 | ## Jobs or Plugins aren't Running 27 | 28 | Sometimes `Cron` or `Pruner` plugins appear to stop working unexpectedly. Typically, this happens in 29 | systems with multi-node setups where "web" nodes only enqueue jobs while "worker" nodes are 30 | configured to run queues and plugins. Most plugins require leadership to function, so when a "web" 31 | node becomes leader the plugins go dormant. 32 | 33 | The solution is to disable leadership with `peer: false` on any node that doesn't run plugins: 34 | 35 | ```elixir 36 | config :my_app, Oban, peer: false, ... 37 | ``` 38 | 39 | ## No Notifications with PgBouncer 40 | 41 | Using PgBouncer's "Transaction Pooling" setup disables all of PostgreSQL's `LISTEN` and `NOTIFY` 42 | activity. Some functionality, such as triggering job execution, scaling queues, canceling jobs, 43 | etc. rely on those notifications. 44 | 45 | There are several options available to ensure functional notifications: 46 | 47 | 1. Switch to the `Oban.Notifiers.PG` notifier. This alternative notifier relies on Distributed 48 | Erlang and exchanges messages within a cluster. The only drawback to the PG notifier is that it 49 | doesn't trigger job insertion events. 50 | 51 | 2. Switch `PgBouncer` to "Session Pooling". Session pooling isn't as resource efficient as 52 | transaction pooling, but it retains all Postgres functionality. 53 | 54 | 3. Use a dedicated Repo that connects directly to the database, bypassing `PgBouncer`. 55 | 56 | If none of those options work, the `Oban.Stager` will switch to `local` polling mode to ensure 57 | that queues keep processing jobs. 58 | 59 | ## Unexpectedly Re-running All Migrations 60 | 61 | Without a version comment on the `oban_jobs` table, it will rerun all of the migrations. This can 62 | happen when comments are stripped when restoring from a backup, most commonly during a transition 63 | from one database to another. 64 | 65 | The fix is to set the latest migrated version as a comment. To start, search through your previous 66 | migrations and find the last time you ran an Oban migration. Once you've found the latest version, 67 | e.g. `version: 10`, then you can set that as a comment on the `oban_jobs` table: 68 | 69 | ```sql 70 | COMMENT ON TABLE public.oban_jobs IS '10'" 71 | ``` 72 | 73 | Once the comment is in place only the migrations from that version onward will 74 | run. 75 | -------------------------------------------------------------------------------- /guides/learning/clustering.md: -------------------------------------------------------------------------------- 1 | # Clustering 2 | 3 | Oban supports running in clusters of nodes. It supports both nodes that are connected to each 4 | other (via *distributed Erlang*), as well as nodes that are not connected to each other but that 5 | communicate via the database's pubsub mechanism. 6 | 7 | Usually, scheduled job management operates in **global mode** and notifies queues of available 8 | jobs via pub/sub to minimize database load. However, when pubsub isn't available, staging 9 | switches to a **local mode** where each queue polls independently. 10 | 11 | Local mode is less efficient and will only happen if you're running in an environment where 12 | neither PostgreSQL nor PG notifications work. That situation should be rare and limited to the 13 | following conditions: 14 | 15 | 1. Running with a connection pooler, like [pg_bouncer], in transaction mode. 16 | 2. Running without clustering, that is, without *distributed Erlang*. 17 | 18 | If **both** of those criteria apply and pubsub notifications won't work, then staging will switch 19 | to polling in local mode. 20 | 21 | [pg_bouncer]: http://www.pgbouncer.org 22 | -------------------------------------------------------------------------------- /guides/learning/defining_queues.md: -------------------------------------------------------------------------------- 1 | # Defining Queues 2 | 3 | Queues are the foundation of how Oban organizes and processes jobs. They allow you to: 4 | 5 | - Separate different types of work (e.g., emails, report generation, media processing) 6 | - Control the concurrency of job execution 7 | - Prioritize certain jobs over others 8 | - Manage resource consumption across your application 9 | 10 | Each queue operates independently with its own set of worker processes and concurrency limits. 11 | 12 | ## Basic Queue Configuration 13 | 14 | Queues are defined as a keyword list where the key is the name of the queue and the value is the 15 | maximum number of concurrent jobs. The following configuration would start four queues with 16 | concurrency ranging from 5 to 50: 17 | 18 | ```elixir 19 | config :my_app, Oban, 20 | queues: [default: 10, mailers: 20, events: 50, media: 5], 21 | repo: MyApp.Repo 22 | ``` 23 | 24 | In this example: 25 | 26 | - The `default` queue will process up to 10 jobs simultaneously 27 | - The `mailers` queue will process up to 20 jobs simultaneously 28 | - The `events` queue will process up to 50 jobs simultaneously 29 | - The `media` queue will process up to 5 jobs simultaneously 30 | 31 | ## Advanced Queue Configuration 32 | 33 | For more control, you can use an expanded form to configure queues with individual overrides: 34 | 35 | ```elixir 36 | config :my_app, Oban, 37 | queues: [ 38 | default: 10, 39 | mailers: [limit: 20, dispatch_cooldown: 50], 40 | events: [limit: 50, paused: true], 41 | media: [limit: 1, global_limit: 10] 42 | ], 43 | repo: MyApp.Repo 44 | ``` 45 | 46 | This expanded configuration demonstrates several advanced options: 47 | 48 | * The `mailers` queue has a dispatch cooldown of 50ms between job fetching 49 | * The `events` queue starts in a paused state, which means it won't process anything until 50 | `Oban.resume_queue/2` is called to start it 51 | * The `media` queue uses a global limit (an Oban Pro feature) 52 | 53 | ### Paused Queues 54 | 55 | When a queue is configured with `paused: true`, it won't process any jobs until explicitly 56 | started. This is useful for: 57 | 58 | * Maintenance periods 59 | * Controlling when resource-intensive jobs can run 60 | * Temporarily disabling certain types of jobs 61 | 62 | You can resume a paused queue programmatically: 63 | 64 | ```elixir 65 | Oban.resume_queue(queue: :events) 66 | ``` 67 | 68 | And pause an active queue: 69 | 70 | ```elixir 71 | Oban.pause_queue(queue: :media) 72 | ``` 73 | 74 | ## Queue Planning Guidelines 75 | 76 | There isn't a limit to the number of queues or how many jobs may execute concurrently in each 77 | queue. However, consider these important guidelines: 78 | 79 | #### Resource Considerations 80 | 81 | * Each queue will run as many jobs as possible concurrently, up to the configured limit. Make sure 82 | your system has enough resources (such as *database connections*) to handle the concurrent load. 83 | 84 | * Consider the total concurrency across all queues. For example, if you have 4 queues with limits 85 | of 10, 20, 30, and 40, your system needs to handle up to 100 concurrent jobs, each potentially 86 | requiring database connections and other resources. 87 | 88 | #### Concurrency and Distribution 89 | 90 | * Queue limits are **local** (per-node), not global (per-cluster). For example, running a queue 91 | with a local limit of `2` on three separate nodes is effectively a global limit of *six 92 | concurrent jobs*. If you require a global limit, you must restrict the number of nodes running a 93 | particular queue or consider Oban Pro's [Smart Engine][smart], which can manage global 94 | concurrency *automatically*! 95 | 96 | #### Queue Planning 97 | 98 | * Only jobs in the configured queues will execute. Jobs in any other queue will stay in the 99 | database untouched. Be sure to configure all queues you intend to use. 100 | 101 | * Organize queues by workload characteristics. For example: 102 | 103 | - CPU-intensive jobs might benefit from a dedicated low-concurrency queue 104 | - I/O-bound jobs (like sending emails) can often use higher concurrency 105 | - Priority work should have dedicated queues with higher concurrency 106 | 107 | #### External Process Considerations 108 | 109 | * Pay attention to the number of concurrent jobs making expensive system calls (such as calls to 110 | resource-intensive tools like [FFMpeg][ffmpeg] or [ImageMagick][imagemagick]). The BEAM ensures 111 | that the system stays responsive under load, but those guarantees don't apply when using ports 112 | or shelling out commands. 113 | 114 | * Consider creating dedicated queues with lower concurrency for jobs that interact with external 115 | processes or services that have their own concurrency limitations. 116 | 117 | [ffmpeg]: https://www.ffmpeg.org 118 | [imagemagick]: https://imagemagick.org/index.php 119 | [smart]: https://oban.pro/docs/pro/Oban.Pro.Engines.Smart.html 120 | -------------------------------------------------------------------------------- /guides/learning/error_handling.md: -------------------------------------------------------------------------------- 1 | # Error Handling 2 | 3 | This page guides you through handling and reporting errors in Oban. 4 | 5 | Jobs can fail in expected or unexpected ways. To mark a job as failed, you can return `{:error, 6 | reason}` from a worker's [`perform/1` callback](`c:Oban.Worker.perform/1`), as documented in the 7 | `t:Oban.Worker.result/0` type. A job can also fail because of unexpected raised errors or exits. 8 | 9 | In any case, when a job fails the details of the failure are recorded in the `errors` array on the 10 | `Oban.Job` struct. 11 | 12 | ## Error Details 13 | 14 | Oban stores execution errors as a list of maps (`t:Oban.Job.errors/0`). Each error contains the 15 | following keys: 16 | 17 | * `:at` — The UTC timestamp when the error occurred at 18 | * `:attempt` — The attempt number when the error occurred 19 | * `:error` — A *formatted* error message and stacktrace 20 | 21 | See the [Instrumentation docs](instrumentation.html) for an example of integrating with external 22 | error reporting systems. 23 | 24 | ## Retries 25 | 26 | When a job fails and the number of execution attempts is below the configured `max_attempts` limit 27 | for that job, the job will automatically be retried in the future. If the number of failures 28 | reaches `max_attempts`, the job gets **discarded**. 29 | 30 | The retry delay has an *exponential backoff with jitter*. This means that the delay between 31 | attempts grows exponentially (8s, 16s, and so on), and a randomized "jitter" is introduced for 32 | each attempt, so that chances of jobs overlapping when being retried go down. So, a job could be 33 | retried after 7.3s, then 17.1s, and so on. 34 | 35 | See the `Oban.Worker` documentation on "Customizing Backoff" for alternative backoff strategies. 36 | 37 | ### Limiting Retries 38 | 39 | By default, jobs are retried up to 20 times. The number of retries is controlled by the 40 | `:max_attempts` value, which can be set at the **worker** or **job** level. For example, to 41 | instruct a worker to discard jobs after three failures: 42 | 43 | ```elixir 44 | use Oban.Worker, queue: :limited, max_attempts: 3 45 | ``` 46 | 47 | ## Reporting Errors 48 | 49 | Another great use of execution data and instrumentation is error reporting. Here is an example of 50 | an event handler module that integrates with [Honeybadger][honeybadger] to report job failures: 51 | 52 | ```elixir 53 | defmodule MyApp.ErrorReporter do 54 | def attach do 55 | :telemetry.attach( 56 | "oban-errors", 57 | [:oban, :job, :exception], 58 | &__MODULE__.handle_event/4, 59 | [] 60 | ) 61 | end 62 | 63 | def handle_event([:oban, :job, :exception], measure, meta, _) do 64 | Honeybadger.notify(meta.reason, stacktrace: meta.stacktrace) 65 | end 66 | end 67 | 68 | # Attach it with: 69 | MyApp.ErrorReporter.attach() 70 | ``` 71 | 72 | You can use exception events to send error reports to Sentry, AppSignal, Honeybadger, Rollbar, or 73 | any other application monitoring platform. 74 | 75 | ### Built-in Reporting 76 | 77 | Some error-reporting and application-monitoring services support reporting Oban errors out of the 78 | box: 79 | 80 | - Sentry — [Oban integration documentation][sentry-integration] 81 | - AppSignal — [Oban integration documentation][appsignal-integration] 82 | 83 | [honeybadger]: https://www.honeybadger.io 84 | [sentry-integration]: https://docs.sentry.io/platforms/elixir/integrations/oban 85 | [appsignal-integration]: https://docs.appsignal.com/elixir/integrations/oban.html 86 | -------------------------------------------------------------------------------- /guides/learning/instrumentation.md: -------------------------------------------------------------------------------- 1 | # Instrumentation and Logging 2 | 3 | Oban provides integration with [Telemetry][telemetry], a dispatching library for metrics and 4 | instrumentation. It is easy to report Oban metrics to any backend by attaching to Telemetry events 5 | prefixed with `:oban`. 6 | 7 | ## Default Logger 8 | 9 | The `Oban.Telemetry` module provides a robust structured logger that handles all of Oban's 10 | telemetry events. As in the example above, attach it within your application module: 11 | 12 | ```elixir 13 | :ok = Oban.Telemetry.attach_default_logger() 14 | ``` 15 | 16 | For more details on the default structured logger and information on event metadata see docs for 17 | the `Oban.Telemetry` module. 18 | 19 | ## Custom Handlers 20 | 21 | Here is an example of an unstructured log handler: 22 | 23 | ```elixir 24 | defmodule MyApp.ObanLogger do 25 | require Logger 26 | 27 | def handle_event([:oban, :job, :start], measure, meta, _) do 28 | Logger.warning("[Oban] :started #{meta.worker} at #{measure.system_time}") 29 | end 30 | 31 | def handle_event([:oban, :job, event], measure, meta, _) do 32 | Logger.warning("[Oban] #{event} #{meta.worker} ran in #{measure.duration}") 33 | end 34 | end 35 | ``` 36 | 37 | Attach the handler to success and failure events in your application's `c:Application.start/2` 38 | callback (usually in `lib/my_app/application.ex`): 39 | 40 | ```elixir 41 | def start(_type, _args) do 42 | events = [ 43 | [:oban, :job, :start], 44 | [:oban, :job, :stop], 45 | [:oban, :job, :exception] 46 | ] 47 | 48 | :telemetry.attach_many("oban-logger", events, &MyApp.ObanLogger.handle_event/4, []) 49 | 50 | Supervisor.start_link(...) 51 | end 52 | ``` 53 | 54 | [telemetry]: https://github.com/beam-telemetry/telemetry 55 | -------------------------------------------------------------------------------- /guides/learning/operational_maintenance.md: -------------------------------------------------------------------------------- 1 | # Operational Maintenance 2 | 3 | This guide walks you through *maintaining* a production Oban setup from an operational 4 | perspective. Proper maintenance ensures your job processing system remains efficient, responsive, 5 | and reliable over time. 6 | 7 | ## Understanding Job Persistence 8 | 9 | Oban stores all jobs in the database, which offers several advantages: 10 | 11 | - **Durability**: Jobs survive application restarts and crashes 12 | - **Visibility**: Administrators can inspect job status and history 13 | - **Accountability**: Complete audit trail of job execution 14 | - **Analyzability**: Ability to gather statistics and metrics 15 | 16 | However, this persistence strategy means that without proper maintenance, your job table will grow 17 | indefinitely. 18 | 19 | ## Pruning Historic Jobs 20 | 21 | Job stats and queue introspection are built on *keeping job rows in the database* after they have 22 | completed. This allows administrators to review completed jobs and build informative aggregates, 23 | at the expense of storage and an unbounded table size. To prevent the `oban_jobs` table from 24 | growing indefinitely, Oban actively prunes `completed`, `cancelled`, and `discarded` jobs. 25 | 26 | By default, the [pruner plugin](`Oban.Plugins.Pruner`) retains jobs for 60 seconds. You can 27 | configure a longer retention period by providing a `:max_age` in seconds to the pruner plugin. 28 | 29 | ```elixir 30 | config :my_app, Oban, 31 | plugins: [{Oban.Plugins.Pruner, max_age: _5_minutes_in_seconds = 300}], 32 | # ... 33 | ``` 34 | 35 | #### How Pruning Works 36 | 37 | The pruner plugin periodically runs SQL queries to delete jobs that: 38 | 39 | 1. Are in a final state (`completed`, `cancelled`, or `discarded`) 40 | 2. Have exceeded their retention period 41 | 42 | This happens in the background without impacting job execution. 43 | 44 | ## Indexes 45 | 46 | Oban relies on database indexes to efficiently query and process jobs. As the `oban_jobs` table 47 | experiences high write activity, index bloat and fragmentation can occur over time, potentially 48 | degrading performance. 49 | 50 | #### Understanding Oban Indexes 51 | 52 | Oban creates several important indexes on the `oban_jobs` table: 53 | 54 | - Indexes for efficiently fetching jobs by state and priority 55 | - Indexes for scheduling future jobs 56 | - Indexes for searching by queue and other attributes 57 | 58 | With heavy usage, these indexes can become less efficient due to: 59 | 60 | - Index bloat (where indexes contain obsolete data) 61 | - Fragmentation (where index data is scattered across storage) 62 | 63 | #### Using the Reindexer Plugin 64 | 65 | Oban provides a dedicated plugin for maintaining index health: `Oban.Plugins.Reindexer`. This 66 | plugin periodically rebuilds indexes concurrently to ensure optimal performance. 67 | 68 | To enable automatic index maintenance, add the Reindexer to your Oban configuration: 69 | 70 | ```elixir 71 | config :my_app, Oban, 72 | plugins: [Oban.Plugins.Pruner, Oban.Plugins.Reindexer], 73 | # ... 74 | ``` 75 | 76 | ### Caveats & Guidelines 77 | 78 | * Pruning is best-effort and performed out-of-band. This means that all limits are soft; jobs 79 | beyond a specified age may not be pruned immediately after jobs complete. 80 | 81 | * Pruning is only applied to jobs that are `completed`, `cancelled`, or `discarded`. It'll never 82 | delete a new job, a scheduled job, or a job that failed and will be retried. 83 | 84 | * Schedule reindexing during low-traffic periods when possible because it can be 85 | resource-intensive. 86 | -------------------------------------------------------------------------------- /guides/learning/scheduling_jobs.md: -------------------------------------------------------------------------------- 1 | # Scheduling Jobs 2 | 3 | Oban provides flexible options to schedule jobs for future execution. This is useful for scenarios 4 | like delayed notifications, periodic maintenance tasks, or scheduling work during off-peak hours. 5 | 6 | ### Schedule in Relative Time 7 | 8 | You can **schedule** jobs to run after a specific dalay (in seconds): 9 | 10 | ```elixir 11 | %{id: 1} 12 | |> MyApp.SomeWorker.new(schedule_in: _seconds = 5) 13 | |> Oban.insert() 14 | ``` 15 | 16 | This is useful for tasks that need to happen after a short delay, such as sending a follow-up 17 | email or retrying a failed operation. 18 | 19 | ### Schedule at a Specific Time 20 | 21 | For tasks that need to run at a precise moment, you can schedule jobs at a *specific timestamp*: 22 | 23 | ```elixir 24 | %{id: 1} 25 | |> MyApp.SomeWorker.new(scheduled_at: ~U[2020-12-25 19:00:00Z]) 26 | |> Oban.insert() 27 | ``` 28 | 29 | This is particularly useful for time-sensitive operations like sending birthday messages, 30 | executing a maintenance task at off-hours, or preparing monthly reports. 31 | 32 | ## Time Zone Considerations 33 | 34 | Scheduling in Oban is *always* done in UTC. If you're working with timestamps in different time 35 | zones, you must convert them to UTC before scheduling: 36 | 37 | ```elixir 38 | # Convert a datetime in a local timezone to UTC for scheduling 39 | utc_datetime = DateTime.shift_zone!(local_datetime, "Etc/UTC") 40 | 41 | %{id: 1} 42 | |> MyApp.SomeWorker.new(scheduled_at: utc_datetime) 43 | |> Oban.insert() 44 | ``` 45 | 46 | This ensures consistent behavior across different server locations and prevents daylight saving 47 | time issues. 48 | 49 | ## How Scheduling Works 50 | 51 | Behind the scenes, Oban stores your job in the database with the specified scheduled time. The job 52 | remains in a "scheduled" state until that time arrives, at which point it becomes available for 53 | execution by the appropriate worker. 54 | 55 | Scheduled jobs don't consume worker resources until they're ready to execute, allowing you to 56 | queue thousands of future jobs without impacting current performance. 57 | 58 | ## Distributed Scheduling 59 | 60 | Scheduling works seamlessly across a cluster of nodes. A job scheduled on one node will execute on 61 | whichever node has an available worker when the scheduled time arrives. See the [*Clustering* 62 | guide](clustering.html) for more detailed information about how Oban manages jobs in a distributed 63 | environment. 64 | -------------------------------------------------------------------------------- /guides/recipes/expected-failures.md: -------------------------------------------------------------------------------- 1 | # Handling Expected Failures 2 | 3 | Reporting job errors by sending notifications to an external service is 4 | essential to maintaining application health. While reporting is essential, noisy 5 | reports for flaky jobs can become a distraction that gets ignored. Sometimes we 6 | _expect_ that a job will error a few times. That could be because the job relies 7 | on an external service that is flaky, because it is prone to race conditions, or 8 | because the world is a crazy place. Regardless of _why_ a job fails, reporting 9 | every failure may be undesirable. 10 | 11 | ## Use Case: Silencing Initial Notifications for Flaky Services 12 | 13 | One solution for reducing noisy error notifications is to start reporting only 14 | after a job has failed several times. Oban uses [Telemetry][tele] to make 15 | reporting errors and exceptions a simple matter of attaching a handler function. 16 | In this example we will extend [Honeybadger][hb] reporting from the 17 | `Oban.Telemetry` documentation, but account for the number of processing attempts. 18 | 19 | To start, we'll define a `Reportable` [protocol][pro] with a single 20 | `reportable?/2` function: 21 | 22 | ```elixir 23 | defprotocol MyApp.Reportable do 24 | @fallback_to_any true 25 | def reportable?(worker, attempt) 26 | end 27 | 28 | defimpl MyApp.Reportable, for: Any do 29 | def reportable?(_worker, _attempt), do: true 30 | end 31 | ``` 32 | 33 | The `Reportable` protocol has a default implementation which always returns 34 | `true`, meaning it reports all errors. Our application has a `FlakyWorker` 35 | that's known to fail a few times before succeeding. We don't want to see a 36 | report until after a job has failed three times, so we'll add an implementation 37 | of `Reportable` within the worker module: 38 | 39 | ```elixir 40 | defmodule MyApp.Workers.FlakyWorker do 41 | use Oban.Worker 42 | 43 | defstruct [] 44 | 45 | defimpl MyApp.Reportable do 46 | @threshold 3 47 | 48 | def reportable?(_worker, attempt), do: attempt > @threshold 49 | end 50 | 51 | @impl true 52 | def perform(%{args: %{"email" => email}}) do 53 | MyApp.ExternalService.deliver(email) 54 | end 55 | end 56 | ``` 57 | 58 | Note that we've also used `defstruct []` to make our worker a viable struct. 59 | This is necessary for our protocol to dispatch correctly, as protocols consider 60 | all modules to be a plain `atom`. 61 | 62 | The final step is to call `reportable?/2` from our application's error reporter, 63 | passing in the worker module and the attempt number: 64 | 65 | ```elixir 66 | defmodule MyApp.ErrorReporter do 67 | alias MyApp.Reportable 68 | 69 | def handle_event(_, _, meta, _) do 70 | worker_struct = maybe_get_worker_struct(meta.job.worker) 71 | 72 | if Reportable.reportable?(worker_struct, meta.job.attempt) do 73 | context = Map.take(meta.job, [:id, :args, :queue, :worker]) 74 | 75 | Honeybadger.notify(meta.reason, context, meta.stacktrace) 76 | end 77 | end 78 | 79 | def maybe_get_worker_struct(worker) do 80 | try do 81 | {:ok, module} = Oban.Worker.from_string(worker) 82 | 83 | struct(module) 84 | rescue 85 | UndefinedFunctionError -> worker 86 | end 87 | end 88 | end 89 | ``` 90 | 91 | Attach the failure handler somewhere in your `application.ex` module: 92 | 93 | ```elixir 94 | :telemetry.attach("oban-errors", [:oban, :job, :exception], &ErrorReporter.handle_event/4, nil) 95 | ``` 96 | 97 | With the failure handler attached you will start getting error reports **only 98 | after the third error**. 99 | 100 | ### Giving Time to Recover 101 | 102 | If a service is especially flaky you may find that Oban's default backoff 103 | strategy is too fast. By defining a custom `backoff` function on the 104 | `FlakyWorker` we can set a linear delay before retries: 105 | 106 | ```elixir 107 | # inside of MyApp.Workers.FlakyWorker 108 | 109 | @impl true 110 | def backoff(%Job{attempt: attempt}) do 111 | attempt * 60 112 | end 113 | ``` 114 | 115 | Now the first retry is scheduled `60s` later, the second `120s` later, and so on. 116 | 117 | ### Building Blocks 118 | 119 | Elixir's powerful primitives of behaviours, protocols and event handling make 120 | flexible error reporting seamless and extensible. While our `Reportable` 121 | protocol only considered the number of attempts, this same mechanism is suitable 122 | for filtering by any other `meta` value. 123 | 124 | [tele]: https://github.com/beam-telemetry/telemetry 125 | [hb]: https://www.honeybadger.io/ 126 | [pro]: https://hexdocs.pm/elixir/Protocol.html 127 | -------------------------------------------------------------------------------- /guides/recipes/migrating-from-other-languages.md: -------------------------------------------------------------------------------- 1 | # Migrating from Other Languages 2 | 3 | Migrating background jobs to Elixir is easy with Oban because everything lives in your PostgreSQL 4 | database. Oban relies on a structured `oban_jobs` table as its job queue, and purposefully uses 5 | JSON as a portable data structures for serialization. That makes enqueueing jobs into Oban simple for 6 | any language with a PostgreSQL adapter—no Oban client necessary. 7 | 8 | ## Use Case: Inserting Jobs from Rails 9 | 10 | It's no secret that Ruby to Elixir is a common migration path for developers and existing 11 | applications alike. Let's explore how to write an adapter for inserting Oban jobs from a Rails 12 | application. 13 | 14 | To start, define a skeletal `ActiveRecord` model with a few conveniences for scheduling jobs: 15 | 16 | ```ruby 17 | class Oban::Job < ApplicationRecord 18 | # This column is in use, but not used for the insert workflow. 19 | self.ignored_columns = %w[errors] 20 | 21 | # A simple wrapper around `create` that ensures the job is scheduled immediately. 22 | def self.insert(worker:, args: {}, queue: "default", scheduled_at: nil) 23 | create( 24 | worker: worker, 25 | queue: queue, 26 | args: args, 27 | scheduled_at: scheduled_at || Time.now.utc, 28 | state: scheduled_at ? "scheduled" : "available" 29 | ) 30 | end 31 | end 32 | ``` 33 | 34 | The `insert` class method is a convenience that uses named arguments to force passing a `worker` 35 | while providing some defaults. The only semi-magical thing within `insert` is determining the 36 | correct state for scheduled jobs. In Oban, jobs that are ready to execute have an `available` 37 | state, while jobs slated for the future are `scheduled`. 38 | 39 | To insert a single job using the `insert` class method: 40 | 41 | ```ruby 42 | Oban::Job.insert(worker: "MyWorker", args: {id: 1}, queue: "default") 43 | ``` 44 | 45 | Provided your Elixir application has a worker named `MyWorker` and the `default` queue is running, 46 | Oban will pick up and execute the job immediately. To schedule the job to run a minute in the 47 | future instead, pass a `scheduled_at` timestamp: 48 | 49 | ```ruby 50 | Oban::Job.insert(worker: "MyWorker", args: {id: 1}, scheduled_at: 1.minute.from_now.utc) 51 | ``` 52 | 53 | Now, if you're using Rails 6+, you can also use `insert_all` to batch insert jobs: 54 | 55 | ```ruby 56 | Oban::Job.insert_all([ 57 | {worker: "MyWorker", args: {id: 1}, queue: "default"}, 58 | {worker: "MyWorker", args: {id: 2}, queue: "default"}, 59 | {worker: "MyWorker", args: {id: 3}, queue: "default"}, 60 | ]) 61 | ``` 62 | 63 | ## Safety Guaranteed 64 | 65 | Most columns in `oban_jobs` have sensible defaults, so only the `worker` and `args` are typically 66 | required. For integrity, all required columns are marked as `NON NULL`, and several have `CHECK` 67 | constraints as well for extra enforcement. 68 | 69 | That's all you need to start migrating background jobs from Rails to Elixir (if you're using Oban, 70 | that is). Naturally, the same pattern would work for Python, Node, PHP, or any other language with 71 | a Postgres adapter. 72 | -------------------------------------------------------------------------------- /guides/recipes/recursive-jobs.md: -------------------------------------------------------------------------------- 1 | # Recursive Jobs 2 | 3 | Recursive jobs, like recursive functions, call themselves after they have 4 | executed. Except unlike recursive functions, where recursion happens in a tight 5 | loop, a recursive job enqueues a new version of itself and may add a slight 6 | delay to alleviate pressure on the queue. 7 | 8 | Recursive jobs are a great way to backfill large amounts of data where a 9 | database migration or a mix task may not be suitable. Here are a few reasons 10 | that a recursive job may be better suited for backfilling data: 11 | 12 | * Data can't be backfilled with a database migration, it may require talking to 13 | an external service 14 | * A task may fail partway through execution; resuming the task would mean 15 | starting over again, or tracking progress manually to resume where the failure 16 | occurred 17 | * A task may be computationally intensive or put heavy pressure on the database 18 | * A task may run for too long and would be interrupted by code releases or other 19 | node restarts 20 | * A task may interface with an external service and require some rate limiting 21 | * A job can be used directly for new records _and_ to backfill existing records 22 | 23 | Let's explore recursive jobs with a use case that builds on several of those 24 | reasons. 25 | 26 | ## Use Case: Backfilling Timezone Data 27 | 28 | Consider a worker that queries an external service to determine what timezone a 29 | user resides in. The external service has a rate limit and the response time is 30 | unpredictable. We have a lot of users in our database missing timezone 31 | information, and we need to backfill. 32 | 33 | Our application has an existing `TimezoneWorker` that accepts a user's `id`, 34 | makes an external request and then updates the user's timezone. We can modify 35 | the worker to handle backfilling by adding a new clause to `perform/1`. The new 36 | clause explicitly checks for a `backfill` argument and will enqueue the next job 37 | after it executes: 38 | 39 | ```elixir 40 | defmodule MyApp.Workers.TimezoneWorker do 41 | use Oban.Worker 42 | 43 | import Ecto.Query 44 | 45 | alias MyApp.{Repo, User} 46 | 47 | @backfill_delay 1 48 | 49 | @impl true 50 | def perform(%{args: %{"id" => id, "backfill" => true}}) do 51 | with :ok <- perform(%{args: %{"id" => id}}) do 52 | case fetch_next(id) do 53 | next_id when is_integer(next_id) -> 54 | %{id: next_id, backfill: true} 55 | |> new(schedule_in: @backfill_delay) 56 | |> Oban.insert() 57 | 58 | nil -> 59 | :ok 60 | end 61 | end 62 | end 63 | 64 | def perform(%{args: %{"id" => id}}) do 65 | update_timezone(id) 66 | end 67 | 68 | defp fetch_next(current_id) do 69 | User 70 | |> where([u], is_nil(u.timezone)) 71 | |> where([u], u.id > ^current_id) 72 | |> order_by(asc: :id) 73 | |> limit(1) 74 | |> select([u], u.id) 75 | |> Repo.one() 76 | end 77 | 78 | defp update_timezone(_id), do: Enum.random([:ok, {:error, :reason}]) 79 | end 80 | ``` 81 | 82 | There is a lot happening in the worker module, so let's unpack it a little bit. 83 | 84 | 1. There are two clauses for `perform/1`, the first only matches when a job is 85 | marked as `"backfill" => true`, the second does the actual work of updating the 86 | timezone. 87 | 2. The backfill clause checks that the timezone update succeeds and then uses 88 | `fetch_next/1` to look for the id of the next user without a timezone. 89 | 3. When another user needing a backfill is available it enqueues a new backfill 90 | job with a one second delay. 91 | 92 | With the new `perform/1` clause in place and our code deployed we can kick off 93 | the recursive backfill. Assuming the `id` of the first user is `1`, you can 94 | start the job from an `iex` console: 95 | 96 | ```elixir 97 | iex> %{id: 1, backfill: true} |> MyApp.Workers.TimezoneWorker.new() |> Oban.insert() 98 | ``` 99 | 100 | Now the jobs will chug along at a steady rate of one per second until the 101 | backfill is complete (or something fails). If there are any errors the backfill 102 | will pause until the failing job completes: especially useful for jobs relying 103 | on flaky external services. Finally, when there aren't any more user's without a 104 | timezone, the backfill is complete and recursion will stop. 105 | 106 | ## Building On Recursive Jobs 107 | 108 | This was a relatively simple example, and hopefully it illustrates the power and 109 | flexibility of recursive jobs. Recursive jobs are a general pattern and aren't 110 | specific to Oban. In fact, aside from the `use Oban.Worker` directive there 111 | isn't anything specific to Oban in the recipe! 112 | -------------------------------------------------------------------------------- /guides/recipes/reliable-scheduling.md: -------------------------------------------------------------------------------- 1 | # Reliable Scheduled Jobs 2 | 3 | A common variant of recursive jobs are "scheduled jobs", where the goal is for a 4 | job to repeat indefinitely with a fixed amount of time between executions. The 5 | part that makes it "reliable" is the guarantee that we'll keep retrying the 6 | job's business logic when the job retries, but we'll **only schedule the next 7 | occurrence once**. In order to achieve this guarantee we'll make use of the 8 | `perform` function to receive a complete `Oban.Job` struct. 9 | 10 | Time for illustrative example! 11 | 12 | ## Use Case: Delivering Daily Digest Emails 13 | 14 | When a new user signs up to use our site we need to start sending them daily 15 | digest emails. We want to deliver the emails around the same time a user signed 16 | up, repeating every 24 hours. It is important that we don't spam them with 17 | duplicate emails, so we ensure that the next email is only scheduled on our 18 | first attempt. 19 | 20 | ```elixir 21 | defmodule MyApp.Workers.ScheduledWorker do 22 | use Oban.Worker, queue: :scheduled, max_attempts: 10 23 | 24 | alias MyApp.Mailer 25 | 26 | @one_day 60 * 60 * 24 27 | 28 | @impl true 29 | def perform(%{args: %{"email" => email} = args, attempt: 1}) do 30 | args 31 | |> new(schedule_in: @one_day) 32 | |> Oban.insert!() 33 | 34 | Mailer.deliver_email(email) 35 | end 36 | 37 | def perform(%{args: %{"email" => email}}) do 38 | Mailer.deliver_email(email) 39 | end 40 | end 41 | ``` 42 | 43 | You'll notice that the first `perform/1` clause only matches a job struct on the 44 | first attempt. When it matches, the first clause schedules the next iteration 45 | immediately, _before_ attempting to deliver the email. Any subsequent retries 46 | fall through to the second `perform/1` clause, which only attempts to deliver 47 | the email again. Combined, the clauses get us close to **at-most-once semantics 48 | for scheduling**, and **at-least-once semantics for delivery**. 49 | 50 | ## More Flexible Than CRON Scheduling 51 | 52 | Delivering around the same time using cron-style scheduling would need extra 53 | book-keeping to check when a user signed up, and then only deliver to those 54 | users that signed up within that window of time. The recursive scheduling 55 | approach is more accurate and entirely self contained—when and if the digest 56 | interval changes the scheduling will pick it up automatically once our code 57 | deploys. 58 | 59 | _An [extensive discussion][oi27] on the Oban issue tracker prompted this example 60 | along with the underlying feature that made it possible._ 61 | 62 | [oi27]: https://github.com/oban-bg/oban/issues/27 63 | 64 | ## Considerations for Scheduling Jobs in the Very-Near-Future 65 | 66 | If you use the `schedule_in` or `scheduled_at` options with a value that will 67 | resolve to the very-near-future, for example: 68 | 69 | ```elixir 70 | # 1 second from now 71 | %{} 72 | |> new(schedule_in: 1) 73 | |> Oban.insert() 74 | 75 | # 500 milliseconds from now 76 | very_soon = DateTime.utc_now() |> DateTime.add(500, :millisecond) 77 | 78 | %{} 79 | |> new(scheduled_at: very_soon) 80 | |> Oban.insert() 81 | ``` 82 | 83 | your workers may not be aware of/attempt to perform the job until the next tick as specific by the 84 | `Oban.Stager` `:interval` option. By default this is set to `1_000ms`. 85 | 86 | **Be aware:** Configuring the `:interval` option below the recommended default 87 | can have a considerable impact on database performance! It is not advised to 88 | lower this value and should only be done as a last resort after considering 89 | other ways to achieve your desired outcome. 90 | -------------------------------------------------------------------------------- /guides/recipes/splitting-queues.md: -------------------------------------------------------------------------------- 1 | # Splitting Queues Between Nodes 2 | 3 | Running every job queue on every node isn't always ideal. Imagine that your 4 | application has some CPU intensive jobs that you'd prefer not to run on nodes 5 | that serve web requests. Perhaps you start temporary nodes that are only meant 6 | to _insert_ jobs but should never _execute_ any. Fortunately, we can control 7 | this by configuring certain node types, or even single nodes, to **run only a 8 | subset of queues**. 9 | 10 | ## Use Case: Isolating Video Processing Intensive Jobs 11 | 12 | One notorious type of CPU intensive work is video processing. When our 13 | application is transcoding multiple videos simultaneously it is a _major_ drain 14 | on system resources and may impact response times. To avoid this we can run 15 | dedicated worker nodes that don't serve any web requests and handle all of the 16 | transcoding. 17 | 18 | While it's possible to separate our system into `web` and `worker` apps within 19 | an umbrella, that wouldn't allow us to dynamically change queues at runtime. 20 | Let's look at an **environment variable based method** for dynamically 21 | configuring queues at runtime. 22 | 23 | Within `config.exs` our application is configured to run three queues: 24 | `default`, `media` and `events`: 25 | 26 | ```elixir 27 | config :my_app, Oban, 28 | repo: MyApp.Repo, 29 | queues: [default: 15, media: 10, events: 25] 30 | ``` 31 | 32 | We will use an `OBAN_QUEUES` environment variable to override the queues at 33 | runtime. For illustration purposes the queue parsing all happens within the 34 | application module, but it would work equally well in `releases.exs` (or `runtime.exs`). 35 | 36 | ```elixir 37 | defmodule MyApp.Application do 38 | @moduledoc false 39 | 40 | use Application 41 | 42 | def start(_type, _args) do 43 | children = [ 44 | MyApp.Repo, 45 | MyApp.Endpoint, 46 | {Oban, oban_opts()} 47 | ] 48 | 49 | Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor) 50 | end 51 | 52 | defp oban_opts do 53 | env_queues = System.get_env("OBAN_QUEUES") 54 | 55 | :my_app 56 | |> Application.get_env(Oban) 57 | |> Keyword.update(:queues, [], &queues(env_queues, &1)) 58 | end 59 | 60 | defp queues("*", defaults), do: defaults 61 | defp queues(nil, defaults), do: defaults 62 | defp queues(_, false), do: false 63 | 64 | defp queues(values, _defaults) when is_binary(values) do 65 | values 66 | |> String.split(" ", trim: true) 67 | |> Enum.map(&String.split(&1, ",", trim: true)) 68 | |> Keyword.new(fn [queue, limit] -> 69 | {String.to_existing_atom(queue), String.to_integer(limit)} 70 | end) 71 | end 72 | end 73 | ``` 74 | 75 | The `queues` function's first three clauses ensure that we can fall back to the 76 | queues specified in our configuration (or `false`, for testing). The fourth 77 | clause is much more involved, and that is where the environment parsing happens. 78 | It expects the `OBAN_QUEUES` value to be a string formatted as `queue,limit` 79 | pairs and separated by spaces. For example, to run only the `default` and 80 | `media` queues with a limit of 5 and 10 respectively, you would pass the string 81 | `default,5 media,10`. 82 | 83 | Note that the parsing clause has a couple of safety mechanisms to ensure that 84 | only real queues are specified: 85 | 86 | 1. It automatically trims while splitting values, so extra whitespace won't 87 | break parsing (i.e. ` default,3 `) 88 | 2. It only converts the `queue` string to _an existing atom_, hopefully 89 | preventing typos that would start a random queue (i.e. `default`) 90 | 91 | ### Usage Examples 92 | 93 | In development (or when using `mix` rather than releases) we can specify the 94 | environment variable inline: 95 | 96 | ```bash 97 | OBAN_QUEUES="default,10 media,5" mix phx.server # default: 10, media: 5 98 | ``` 99 | 100 | We can also explicitly opt in to running all of the configured queues: 101 | 102 | ```bash 103 | OBAN_QUEUES="*" mix phx.server # default: 15, media: 10, events: 25 104 | ``` 105 | 106 | Finally, without `OBAN_QUEUES` set at all it will implicitly fall back to the 107 | configured queues: 108 | 109 | ```bash 110 | mix phx.server # default: 15, media: 10, events: 25 111 | ``` 112 | 113 | ## Flexible Across all Environments 114 | 115 | This environment variable based solution is more flexible than running separate 116 | umbrella apps because we can reconfigure at any time. In a limited environment, 117 | like staging, we can run _all_ the queues on a single node using the exact same 118 | code we use in production. In the future, if other workers start to utilize too 119 | much CPU or RAM we can shift them to the worker node **without any code 120 | changes**. 121 | 122 | _This guide was [prompted by an inquiry][oi82] on the Oban issue tracker._ 123 | 124 | [oi82]: https://github.com/oban-bg/oban/issues/82 125 | -------------------------------------------------------------------------------- /guides/testing/testing.md: -------------------------------------------------------------------------------- 1 | # Introduction to Testing 2 | 3 | Automated testing is an essential component of building reliable, long-lived 4 | applications. Oban orchestrates your application's background tasks, so naturally, 5 | testing Oban is highly recommended. 6 | 7 | ## Setup Application Config 8 | 9 | Ensure your app is configured for testing before you begin running tests. 10 | 11 | There are two testing modes available: 12 | 13 | * `:inline`—jobs execute immediately within the calling process and without 14 | touching the database. This mode is simple and may not be suitable for apps 15 | with complex jobs. 16 | * `:manual`—jobs are inserted into the database where they can be verified and 17 | executed when desired. This mode is more advanced and trades simplicity for 18 | flexibility. 19 | 20 | If you're just starting out, `:inline` mode is recommended: 21 | 22 | ```elixir 23 | config :my_app, Oban, testing: :inline 24 | ``` 25 | 26 | For more complex applications, or if you'd like complete control over when jobs 27 | run, then use `:manual` mode instead: 28 | 29 | ```elixir 30 | config :my_app, Oban, testing: :manual 31 | ``` 32 | 33 | Both testing modes prevent Oban from running any database queries in the 34 | background. This simultaneously prevents Sandbox errors from plugin queries and 35 | prevents queues from executing jobs unexpectedly. 36 | 37 | ### Changing Testing Modes 38 | 39 | Once the application starts in a particular testing mode it can't be changed. 40 | That's inconvenient if you're running in `:inline` mode and don't want a 41 | particular job to execute inline! `Oban.Testing` provides a helper to 42 | temporarily change testing modes within the context of a function. 43 | 44 | For example, to switch to `:manual` mode when Oban is configured for `:inline` 45 | testing: 46 | 47 | ```elixir 48 | Oban.Testing.with_testing_mode(:manual, fn -> 49 | Oban.insert(MyWorker.new(%{id: 123})) 50 | 51 | assert_enqueued worker: MyWorker, args: %{id: 123} 52 | end) 53 | ``` 54 | 55 | Or vice-versa, switch to `:inline` mode when the application is configured for 56 | `:manual` mode: 57 | 58 | ```elixir 59 | Oban.Testing.with_testing_mode(:inline, fn -> 60 | {:ok, %Job{state: "completed"}} = Oban.insert(MyWorker.new(%{id: 123})) 61 | end) 62 | ``` 63 | 64 | ## Setup Testing Helpers 65 | 66 | Oban provides helpers to facilitate manual testing. These helpers handle the 67 | boilerplate of making assertions on which jobs are enqueued. 68 | 69 | The most convenient way to use the helpers is to `use` the module within your 70 | test case: 71 | 72 | ```elixir 73 | defmodule MyApp.Case do 74 | use ExUnit.CaseTemplate 75 | 76 | using do 77 | quote do 78 | use Oban.Testing, repo: MyApp.Repo 79 | end 80 | end 81 | end 82 | ``` 83 | 84 | Alternatively, you can `use` the testing module in individual tests if you'd 85 | prefer not to include helpers in _every_ test. 86 | 87 | ```elixir 88 | defmodule MyApp.WorkerTest do 89 | use MyApp.Case, async: true 90 | 91 | use Oban.Testing, repo: MyApp.Repo 92 | end 93 | ``` 94 | 95 | Whichever way you choose, using `use Oban.Testing` requires the `repo` option 96 | because it's injected into many of the generated helpers. 97 | 98 | If you are using isolation with namespaces through PostgreSQL schemas (Ecto 99 | "prefixes"), you can also specify this prefix when using `Oban.Testing`: 100 | 101 | ```elixir 102 | use Oban.Testing, repo: MyApp.Repo, prefix: "private" 103 | ``` 104 | 105 | With Oban configured for testing and helpers in the appropriate places, you're 106 | ready for testing. Learn about unit testing with [Testing Workers][tw], 107 | integration testing with [Testing Queues][tq], or prepare for production with 108 | [Testing Config][tc]. 109 | 110 | [tw]: testing_workers.html 111 | [tq]: testing_queues.html 112 | [tc]: testing_config.html 113 | -------------------------------------------------------------------------------- /guides/testing/testing_config.md: -------------------------------------------------------------------------------- 1 | # Testing Config 2 | 3 | An Oban instance's config is built from options passed to `Oban.start_link` 4 | during initialization and it determines the supervision tree that Oban builds. 5 | The instance config is encapsulated by an `Oban.Config` struct, which is then 6 | referenced by plugins, queues, and most public functions. 7 | 8 | ## Testing Dynamic Config 9 | 10 | Oban automatically normalizes and validates config options on initialization. 11 | That covers any static configuration, but it won't help when config is generated 12 | dynamically, at runtime. 13 | 14 | The `Oban.Config.validate/1` helper is used internally when the config 15 | initializes. Along with each top level option, like the `:engine` or `:repo`, it 16 | recursively verifies all queue and plugin options. 17 | 18 | You can use `validate/1` to verify dynamic configuration, e.g. options specified 19 | in `runtime.exs`: 20 | 21 | ```elixir 22 | test "production oban config is valid" do 23 | config = 24 | "config/config.exs" 25 | |> Config.Reader.read!(env: :prod) 26 | |> get_in([:my_app, Oban]) 27 | 28 | assert :ok = Oban.Config.validate(config) 29 | end 30 | ``` 31 | 32 | When the configuration contains **any** invalid options, like an invalid engine, 33 | you'll see the test fail with an error like this: 34 | 35 | ```elixir 36 | {:error, "expected :engine to be an Oban.Queue.Engine, got: MyApp.Repo"} 37 | ``` 38 | 39 | ## Testing Dynamic Plugin Config 40 | 41 | Validation is especially helpful for plugins that have complex configuration, 42 | e.g. `Cron` or the `Dynamic*` plugins from Oban Pro. As of Oban v2.12.0 all 43 | plugins implement the `c:Oban.Plugin.validate/1` callback and we can test them 44 | in isolation as well as through the top level config. 45 | 46 | Suppose you have a helper function that returns a crontab config at runtime: 47 | 48 | ```elixir 49 | defmodule MyApp.Oban do 50 | def cron_config do 51 | [crontab: [{"0 24 * * *", MyApp.Worker}]] 52 | end 53 | end 54 | ``` 55 | 56 | You can call that function within a test and then assert that it is valid with 57 | `c:Oban.Plugin.validate/1`: 58 | 59 | ```elixir 60 | test "testing cron plugin configuration" do 61 | config = MyApp.Oban.cron_config() 62 | 63 | assert :ok = Oban.Plugins.Cron.validate(config) 64 | end 65 | ``` 66 | 67 | Running this test will return an error tuple, showing that the cron expression 68 | isn't valid. 69 | 70 | ```elixir 71 | {:error, %ArgumentError{message: "expression field 24 is out of range 0..23"}} 72 | ``` 73 | 74 | Switch the expression from `0 24 * * *` to `0 23 * * *`, run the tests again, 75 | and everything passes! 76 | -------------------------------------------------------------------------------- /guides/testing/testing_queues.md: -------------------------------------------------------------------------------- 1 | # Testing Queues 2 | 3 | Where workers are the primary "unit" of an Oban system, queues are the "integration" point between 4 | the database and your application. That means to test queues and the jobs within them, your tests 5 | will have to interact with the database. To simplify that interaction, reduce boilerplate, and 6 | make assertions more expressive `Oban.Testing` provides a variety of helpers. 7 | 8 | ## Asserting Enqueued Jobs 9 | 10 | During test runs you don't typically want to execute jobs. Rather, you need to verify that the job 11 | was enqueued properly. With the recommended test setup queues and plugins are disabled, and jobs 12 | won't be inserted into the database at all. Instead, they'll be executed immediately within the 13 | calling process. The `Oban.Testing.assert_enqueued/2` and `Oban.Testing.refute_enqueued/2` helpers 14 | simplify running queries to check for those `available` or `scheduled` jobs sitting in the 15 | database. 16 | 17 | Let's look at an example where we want to check that an activation job is enqueued after a user 18 | signs up: 19 | 20 | ```elixir 21 | test "scheduling activation upon sign up" do 22 | {:ok, account} = MyApp.Account.sign_up(email: "parker@example.com") 23 | 24 | assert_enqueued worker: MyApp.ActivationWorker, args: %{id: account.id}, queue: :default 25 | end 26 | ``` 27 | 28 | It's also possible to assert that job `args` or `meta` have a particular shape, without matching 29 | exact values: 30 | 31 | ```elixir 32 | test "enqueued args have a particular key" do 33 | :ok = MyApp.Account.notify_owners(account()) 34 | 35 | assert_enqueued queue: :default, args: %{email: _} 36 | end 37 | ``` 38 | 39 | You can also refute that a job was enqueued. The `refute_enqueued` helper takes the same arguments 40 | as `assert_enqueued`, though you should take care to be as unspecific as possible. 41 | 42 | Building on the example above, let's refute that a job is enqueued when account sign up fails: 43 | 44 | ```elixir 45 | test "bypassing activation when sign up fails" do 46 | {:error, _reason} = MyApp.Account.sign_up(email: "parker@example.com") 47 | 48 | refute_enqueued worker: MyApp.ActivationWorker 49 | end 50 | ``` 51 | 52 | ## Asserting Multiple Jobs 53 | 54 | Asserting and refuting about a single job isn't always enough. Sometimes you need to check for 55 | multiple jobs at once, or perform more complex assertions on the jobs themselves. In that 56 | situation, you can use `all_enqueued` instead. 57 | 58 | The first example we'll look at asserts that multiple jobs from the same worker are enqueued all 59 | at once: 60 | 61 | ```elixir 62 | test "enqueuing one job for each child record" do 63 | :ok = MyApp.Account.notify_owners(account()) 64 | 65 | assert jobs = all_enqueued(worker: MyApp.NotificationWorker) 66 | assert 3 == length(jobs) 67 | end 68 | ``` 69 | 70 | The `enqueued` helpers all build dynamic queries to check for jobs within the 71 | database. Dynamic queries don't work for complex objects with nested values or a 72 | partial set of keys. In that case, you can use `all_enqueued` to pull jobs into 73 | your tests and use the full power of pattern matching for assertions. 74 | 75 | ```elixir 76 | test "enqueued jobs have args that match a particular pattern" do 77 | :ok = MyApp.Account.notify_owners(account()) 78 | 79 | for job <- all_enqueued(queue: :default) do 80 | assert %{"email" => _, "avatar" => %{"url" => _}} = job.args 81 | end 82 | end 83 | ``` 84 | 85 | ## Integration Testing Queues 86 | 87 | During integration tests it may be necessary to run jobs because they do work essential for the 88 | test to complete, i.e. sending an email, processing media, etc. You can execute all available jobs 89 | in a particular queue by calling `Oban.drain_queue/1,2` directly from your tests. 90 | 91 | For example, to process all pending jobs in the "mailer" queue while testing some business logic: 92 | 93 | ```elixir 94 | defmodule MyApp.BusinessTest do 95 | use MyApp.DataCase, async: true 96 | 97 | alias MyApp.{Business, Worker} 98 | 99 | test "we stay in the business of doing business" do 100 | :ok = Business.schedule_a_meeting(%{email: "monty@brewster.com"}) 101 | 102 | assert %{success: 1, failure: 0} = Oban.drain_queue(queue: :mailer) 103 | 104 | # Now, make an assertion about the email delivery 105 | end 106 | end 107 | ``` 108 | 109 | See `Oban.drain_queue/1,2` for a myriad of options and additional details. 110 | -------------------------------------------------------------------------------- /guides/testing/testing_workers.md: -------------------------------------------------------------------------------- 1 | # Testing Workers 2 | 3 | Worker modules are the primary "unit" of an Oban system. You can (and should) 4 | test a worker's callback functions locally, in-process, without touching the 5 | database. 6 | 7 | Most worker callback functions take a single argument: an `Oban.Job` struct. A 8 | job encapsulates arguments, metadata, and other options. Creating jobs, and 9 | verifying that they're built correctly, requires some boilerplate...that's where 10 | `Oban.Testing.perform_job/3` comes in! 11 | 12 | ## Testing Perform 13 | 14 | The `perform_job/3` helper reduces boilerplate when constructing jobs for unit 15 | tests and checks for common pitfalls. For example, it automatically converts 16 | `args` to string keys before calling `perform/1`, ensuring that perform clauses 17 | aren't erroneously trying to match on atom keys. 18 | 19 | Let's work through test-driving a worker to demonstrate. 20 | 21 | Start by defining a test that creates a user and then use `perform_job` to 22 | manually call an account activation worker. In this context "activation" could 23 | mean sending an email, notifying administrators, or any number of 24 | business-critical functions—what's important is how we're testing it. 25 | 26 | ```elixir 27 | defmodule MyApp.ActivationWorkerTest do 28 | use MyApp.Case, async: true 29 | 30 | test "activating a new user" do 31 | user = MyApp.User.create(email: "parker@example.com") 32 | 33 | {:ok, _user} = perform_job(MyApp.ActivationWorker, %{id: user.id}) 34 | end 35 | end 36 | ``` 37 | 38 | Running the test at this point will raise an error that explains the module 39 | doesn't implement the `Oban.Worker` behaviour. 40 | 41 | ```text 42 | 1) test activating a new account (MyApp.ActivationWorkerTest) 43 | 44 | Expected worker to be a module that implements the Oban.Worker behaviour, got: 45 | 46 | MyApp.ActivationWorker 47 | 48 | code: {:ok, user} = perform_job(MyApp.ActivationWorker, %{id: user.id}) 49 | ``` 50 | 51 | To fix it, define a worker module with the appropriate signature and return 52 | value: 53 | 54 | ```elixir 55 | defmodule MyApp.ActivationWorker do 56 | use Oban.Worker 57 | 58 | @impl Worker 59 | def perform(%Job{args: %{"id" => user_id}}) do 60 | MyApp.Account.activate(user_id) 61 | end 62 | end 63 | ``` 64 | 65 | The `perform_job/3` helper's errors will guide you through implementing a 66 | complete worker with the following assertions: 67 | 68 | * That the worker implements the `Oban.Worker` behaviour 69 | * That `args` is encodable/decodable to JSON and always has string keys 70 | * That the options provided build a valid job 71 | * That the return value is expected, e.g. `:ok`, `{:ok, value}`, `{:error, value}` etc. 72 | * That a custom `new/1,2` callback works properly 73 | 74 | If all of the assertions pass, then you'll get the result of `perform/1` for you 75 | to make additional assertions on. 76 | 77 | ## Testing Other Callbacks 78 | 79 | You may wish to test less-frequently used worker callbacks such as `backoff/1` 80 | and `timeout/1`, but those callbacks don't have dedicated testing helpers. 81 | Never fear, it's adequate to build a job struct and test callbacks directly! 82 | 83 | Here's a sample test that asserts the backoff value is simply two-times the 84 | job's `attempt`: 85 | 86 | ```elixir 87 | test "calculating custom backoff as a multiple of job attempts" do 88 | assert 2 == MyWorker.backoff(%Oban.Job{attempt: 1}) 89 | assert 4 == MyWorker.backoff(%Oban.Job{attempt: 2}) 90 | assert 6 == MyWorker.backoff(%Oban.Job{attempt: 3}) 91 | end 92 | ``` 93 | 94 | Similarly, here's a sample that verifies a `timeout/1` callback always returns 95 | some number of milliseconds: 96 | 97 | ```elixir 98 | test "allowing a multiple of the attempt as job timeout" do 99 | assert 1000 == MyWorker.timeout(%Oban.Job{attempt: 1}) 100 | assert 2000 == MyWorker.timeout(%Oban.Job{attempt: 2}) 101 | end 102 | ``` 103 | 104 | Jobs are Ecto schemas, and therefore structs. There isn't anything magical about 105 | them! Explore the `Oban.Job` documentation to see all of the types and fields 106 | available for testing. 107 | -------------------------------------------------------------------------------- /guides/upgrading/v2.11.md: -------------------------------------------------------------------------------- 1 | # Upgrading to v2.11 2 | 3 | This Oban release includes a required migration and a couple of optional, but 4 | recommended, changes. 5 | 6 | ## Bump Your Deps 7 | 8 | Update Oban, Web, and Pro to the latest versions: 9 | 10 | ```elixir 11 | [ 12 | {:oban, "~> 2.11"}, 13 | {:oban_pro, "~> 0.10", repo: "oban"}, 14 | {:oban_web, "~> 2.9", repo: "oban"} 15 | ] 16 | ``` 17 | 18 | ## Run Oban.Migrations for v11 19 | 20 | Oban's new leadership mechanism uses an unlogged table to track state globally. 21 | The v11 migration creates a new `oban_peers` table, and is required for 22 | leadership—without it many plugins won't run. 23 | 24 | To get started, create a migration to create the table: 25 | 26 | ```bash 27 | $ mix ecto.gen.migration create_oban_peers 28 | ``` 29 | 30 | Within the generated migration module: 31 | 32 | ```elixir 33 | use Ecto.Migration 34 | 35 | def up, do: Oban.Migrations.up(version: 11) 36 | 37 | def down, do: Oban.Migrations.down(version: 11) 38 | ``` 39 | 40 | If you have multiple Oban instances or use an alternate prefix you'll need to 41 | run the migration for each prefix. 42 | 43 | The `Oban.Peer` module will safely handle a missing `oban_peers` table and log a 44 | warning. 45 | 46 | ## Update Notifier Names 47 | 48 | Now that we've pulled the `PG` notifier in from Oban Pro there are a few 49 | naming changes you should make. 50 | 51 | 1. If your config explicitly declares `Oban.PostgresNotifier`, change it to the 52 | new namespaced version: `Oban.Notifiers.Postgres`: 53 | 54 | ```diff 55 | -notifier: Oban.PostgresNotifier 56 | +notifier: Oban.Notifiers.Postgres 57 | ``` 58 | 59 | 2. If you are using Pro's `PG` notifier, change it to the new namespaced version 60 | from Oban: 61 | 62 | ```diff 63 | -notifier: Oban.Pro.Notifiers.PG 64 | +notifier: Oban.Notifiers.PG 65 | ``` 66 | 67 | ## Check Configuration for Multi-Node Setups 68 | 69 | This release introduces centralized leadership through the `Oban.Peer` 70 | behaviour. To prevent duplicate plugin work across nodes, only one Oban instance 71 | within a cluster may be the leader. Unfortunately, if a node that doesn't run 72 | plugins becomes the leader then jobs may get stuck as `available` and plugins 73 | like `Cron` or `Pruner` won't run. 74 | 75 | The simplest solution is avoid `plugins: false` altogether: 76 | 77 | ```diff 78 | -plugins: false 79 | +plugins: [] 80 | ``` 81 | 82 | See the [Troubleshooting guide](./troubleshooting.md) for more context. 83 | 84 | ## Swap the Compound Index (Optional, but Recommended) 85 | 86 | Oban uses a single compound index for most queries. The index is comprised of 87 | job `state`, `queue`, `priority`, `scheduled_at`, and `id`. That single index is 88 | flexible enough to power most of Oban's queries. However, the column order is 89 | important, and the order created by Oban's migrations isn't optimal in all 90 | situations. 91 | 92 | If you're experiencing slow plugin queries, e.g. the `Stager`, then you may 93 | benefit from swapping the indexes. To do so, create a migration: 94 | 95 | ```bash 96 | $ mix ecto.gen.migration swap_primary_oban_indexes 97 | ``` 98 | 99 | Within the generated migration module: 100 | 101 | ```elixir 102 | @disable_ddl_transaction true 103 | @disable_migration_lock true 104 | 105 | def change do 106 | create_if_not_exists index( 107 | :oban_jobs, 108 | [:state, :queue, :priority, :scheduled_at, :id], 109 | concurrently: true, 110 | prefix: "public" 111 | ) 112 | 113 | drop_if_exists index( 114 | :oban_jobs, 115 | [:queue, :state, :priority, :scheduled_at, :id], 116 | concurrently: true, 117 | prefix: "public" 118 | ) 119 | end 120 | ``` 121 | 122 | Be sure to reference the correct prefix if your `oban_jobs` table uses a prefix 123 | other than `public`. 124 | -------------------------------------------------------------------------------- /guides/upgrading/v2.12.md: -------------------------------------------------------------------------------- 1 | # Upgrading to v2.12 2 | 3 | This Oban release includes a couple of optional configuration changes to aid in 4 | testing and development. 5 | 6 | ## Bump Your Deps 7 | 8 | Update Oban (and optionally Pro) to the latest versions: 9 | 10 | ```elixir 11 | [ 12 | {:oban, "~> 2.12"}, 13 | {:oban_pro, "~> 0.11", repo: "oban"} 14 | ] 15 | ``` 16 | 17 | ## Modify Configuration for Testing 18 | 19 | The new `:testing` option automates configuring an Oban instance for testing. 20 | Make the following change to your `test.exs` to opt into `:manual` testing mode: 21 | 22 | ```diff 23 | # test.exs 24 | - config :my_app, Oban, queues: false, plugins: false 25 | + config :my_app, Oban, testing: :manual 26 | ``` 27 | 28 | If you'd prefer to run jobs inline as they're inserted, without involving the 29 | database, then you can use `:inline` mode instead: 30 | 31 | ```elixir 32 | config :my_app, Oban, testing: :inline 33 | ``` 34 | 35 | See the [testing guide](testing.html) to learn more about test configuration. 36 | -------------------------------------------------------------------------------- /guides/upgrading/v2.14.md: -------------------------------------------------------------------------------- 1 | # Upgrading to v2.14 2 | 3 | This Oban release includes a number of configuration changes and deprecations for redundant 4 | functionality. 5 | 6 | ## Bump Your Deps 7 | 8 | Update Oban (and optionally Pro) to the latest versions: 9 | 10 | ```elixir 11 | [ 12 | {:oban, "~> 2.14"}, 13 | {:oban_pro, "~> 0.13", repo: "oban"} 14 | ] 15 | ``` 16 | 17 | ## Remove Repeater and Stager Plugins 18 | 19 | The `Repeater` plugin is no longer necessary as the new `Stager` falls back to polling mode 20 | automatically. Remove the `Repeater` from your plugins: 21 | 22 | ```diff 23 | plugins: [ 24 | Oban.Plugins.Lifeline, 25 | Oban.Plugins.Pruner, 26 | - Oban.Plugins.Repeater 27 | ``` 28 | 29 | The `Stager` is no longer a plugin because it's essential for queue operation. If you've 30 | overridden the staging interval: 31 | 32 | 1. Reconsider whether that's necessary, staging is optimized to be a light-weight operation. 33 | 2. If you're set on using a different interval, move it to `:stage_interval` 34 | 35 | ```diff 36 | plugins: [ 37 | Oban.Plugins.Lifeline, 38 | Oban.Plugins.Pruner, 39 | - {Oban.Plugins.Stager, interval: 5_000} 40 | ], 41 | + stage_interval: 5_000 42 | ``` 43 | 44 | ## Ensure Configuration for Testing 45 | 46 | Now that `Stager` isn't a plugin, it isn't disabled by `plugins: false`. Be sure 47 | to use the `:testing` option [introduced in v2.12][v212] to automate configuration: 48 | 49 | ```diff 50 | # test.exs 51 | - config :my_app, Oban, queues: false, plugins: false 52 | + config :my_app, Oban, testing: :manual 53 | ``` 54 | 55 | Without this change you may see a flurry of `DBConnection.OwnershipError` errors 56 | during test runs. 57 | 58 | [v212]: v2-12.html#modify-configuration-for-testing 59 | -------------------------------------------------------------------------------- /guides/upgrading/v2.17.md: -------------------------------------------------------------------------------- 1 | # Upgrading to v2.17 2 | 3 | This Oban release includes an optional, but recommended migration. 4 | 5 | > #### Prevent Duplicate Insert Notifications {: .warning} 6 | > 7 | > You must either [run the v12 migrations](#run-oban-migrations-for-v12-optional) or [disable 8 | > insert triggers](#disable-insert-notifications-optional) in your Oban configuration, otherwise 9 | > you'll receive duplicate insert notifications for each job. 10 | 11 | ## Bump Your Deps 12 | 13 | Update Oban (and optionally Pro) to the latest versions: 14 | 15 | ```elixir 16 | [ 17 | {:oban, "~> 2.17"}, 18 | {:oban_pro, "~> 1.2", repo: "oban"} 19 | ] 20 | ``` 21 | 22 | ## Run Oban.Migrations for v12 (Optional) 23 | 24 | The v12 migration removes insert triggers and relaxes the `priority` column's check constraint to 25 | allow values in the new range of `0..9`. 26 | 27 | To get started, create a migration to create the table: 28 | 29 | ```bash 30 | $ mix ecto.gen.migration upgrade_oban_jobs_to_v12 31 | ``` 32 | 33 | Within the generated migration module: 34 | 35 | ```elixir 36 | use Ecto.Migration 37 | 38 | def up, do: Oban.Migrations.up(version: 12) 39 | 40 | def down, do: Oban.Migrations.down(version: 12) 41 | ``` 42 | 43 | If you have multiple Oban instances, or use an alternate prefix, you'll need to run the migration 44 | for each prefix. 45 | 46 | ## Disable Insert Notifications (Optional) 47 | 48 | If you opt not to run the v12 migration to disable Postgres triggers, then you should disable 49 | insert notifications in your configuration: 50 | 51 | ```diff 52 | config :my_app, Oban, 53 | + insert_trigger: false, 54 | ... 55 | ``` 56 | 57 | ## Remove the Gossip Plugin 58 | 59 | The Gossip plugin is no longer used by Oban Web and now useless. You can safely remove it from 60 | your configuration: 61 | 62 | ```diff 63 | config :my_app, Oban, 64 | plugins: [ 65 | - Oban.Plugins.Gossip, 66 | ] 67 | ``` 68 | -------------------------------------------------------------------------------- /guides/upgrading/v2.20.md: -------------------------------------------------------------------------------- 1 | # Upgrading to v2.20 2 | 3 | This Oban release includes an optional, but recommended migration. 4 | 5 | ## Bump Your Deps 6 | 7 | Update Oban (and optionally Pro) to the latest versions: 8 | 9 | ```elixir 10 | [ 11 | {:oban, "~> 2.20"}, 12 | ] 13 | ``` 14 | 15 | ## Run Oban.Migrations for v13 (Optional) 16 | 17 | The v13 migration adds compound indexes for `cancelled_at` and `discarded_at` columns. This is 18 | done to improve `Oban.Plugins.Pruner` performance for cancelled and discarded jobs. 19 | 20 | To get started, create a migration to create the table: 21 | 22 | ```bash 23 | $ mix ecto.gen.migration upgrade_oban_jobs_to_v13 24 | ``` 25 | 26 | Within the generated migration module: 27 | 28 | ```elixir 29 | use Ecto.Migration 30 | 31 | def up, do: Oban.Migrations.up(version: 13) 32 | 33 | def down, do: Oban.Migrations.down(version: 13) 34 | ``` 35 | 36 | If you have multiple Oban instances, or use an alternate prefix, you'll need to run the migration 37 | for each prefix. 38 | 39 | -------------------------------------------------------------------------------- /guides/upgrading/v2.6.md: -------------------------------------------------------------------------------- 1 | # Upgrading to v2.6 2 | 3 | For Oban OSS users the v2.6 upgrade is a drop in replacement—there isn't 4 | anything to do! However, Web+Pro users will need to make some changes to unlock 5 | the goodness of engines. 6 | 7 | ## Bump Your Deps 8 | 9 | Update Oban, Web, and Pro to the latest versions: 10 | 11 | ```elixir 12 | [ 13 | {:oban, "~> 2.6"}, 14 | {:oban_web, "~> 2.6", repo: "oban"}, 15 | {:oban_pro, "~> 0.7", repo: "oban"} 16 | ... 17 | ] 18 | ``` 19 | 20 | Be sure to specify **both `:oban_web` and `:oban_pro`** if you use them both. 21 | There aren't any dependencies between Web and Pro now. That means you're free to 22 | use Pro for workers and only include Web for Phoenix servers, etc. 23 | 24 | ## Switch to the SmartEngine 25 | 26 | The `SmartEngine` uses centralized records to track and exchange state globally, 27 | enabling features such as global concurrency. 28 | 29 | First, create a migration to add the new `oban_producers` table: 30 | 31 | ```bash 32 | $ mix ecto.gen.migration add_oban_producers 33 | ``` 34 | 35 | Within the migration module: 36 | 37 | ```elixir 38 | use Ecto.Migration 39 | 40 | defdelegate change, to: Oban.Pro.Migrations.Producers 41 | ``` 42 | 43 | If you have multiple Oban instances or use prefixes, you can specify the prefix 44 | and create multiple tables in one migration: 45 | 46 | ```elixir 47 | use Ecto.Migration 48 | 49 | def change do 50 | Oban.Pro.Migrations.Producers.change() 51 | Oban.Pro.Migrations.Producers.change(prefix: "special") 52 | Oban.Pro.Migrations.Producers.change(prefix: "private") 53 | end 54 | ``` 55 | 56 | Next, update your config to use the `SmartEngine`: 57 | 58 | ```elixir 59 | config :my_app, Oban, 60 | engine: Oban.Pro.Queue.SmartEngine, 61 | ... 62 | ``` 63 | 64 | If you have multiple Oban instances you need to configure each one to use the 65 | `SmartEngine`, otherwise they'll default to the `Basic` engine. 66 | 67 | ## Start Gossiping 68 | 69 | Oban Pro no longer writes heartbeat records to `oban_beats`. Instead, any Oban 70 | instance that runs queues must use the `Gossip` plugin to broadcast status via 71 | PubSub. 72 | 73 | To start, include the `Gossip` plugin in your Oban config: 74 | 75 | ```elixir 76 | config :my_app, Oban, 77 | plugins: [ 78 | Oban.Plugins.Gossip 79 | ... 80 | ``` 81 | 82 | With the default configuration the plugin will broadcast every 1 second. If that 83 | is too frequent you can configure the interval: 84 | 85 | ```elixir 86 | plugins: [ 87 | {Oban.Plugins.Gossip, interval: :timer.seconds(5)} 88 | ... 89 | ``` 90 | 91 | ## Remove the Workflow Manager 92 | 93 | Due to an improvement in how configuration is passed to workers the 94 | `WorkflowManager` plugin is no longer needed. You can remove it from your list 95 | of plugins: 96 | 97 | ```diff 98 | plugins: [ 99 | Oban.Pro.Plugins.Lifeline, 100 | - Oban.Pro.Plugins.WorkflowManager, 101 | ... 102 | ``` 103 | 104 | ## Remove Extra Lifeline Options 105 | 106 | The `Lifeline` plugin is simplified and doesn't accept as many configuration 107 | options. If you previously configured the `record_interval` or `delete_interval` 108 | you can remove them: 109 | 110 | ```diff 111 | plugins: [{ 112 | Oban.Pro.Plugins.Lifeline, 113 | - delete_interval: :timer.minutes(10), 114 | - record_interval: :timer.seconds(10), 115 | rescue_interval: :timer.minutes(5) 116 | }] 117 | ``` 118 | 119 | ## Drop the Beats Table 120 | 121 | Once you've rolled out the switch to producer records, the smart engine and the 122 | gossip plugin you are free to remove the `oban_beats` table at your discretion 123 | (preferably in a follow up release, to prevent errors): 124 | 125 | ```bash 126 | $ mix ecto.gen.migration drop_oban_beats 127 | ``` 128 | 129 | Within the generated migration module: 130 | 131 | ```elixir 132 | use Ecto.Migration 133 | 134 | def up do 135 | drop_if_exists table("oban_beats") 136 | drop_if_exists table("oban_beats", prefix: "private") # If you have any prefixes: 137 | end 138 | 139 | def down do 140 | # No going back! 141 | end 142 | ``` 143 | -------------------------------------------------------------------------------- /lib/oban/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Oban.Application do 2 | @moduledoc false 3 | 4 | use Application 5 | 6 | def start(_type, _args) do 7 | Supervisor.start_link( 8 | [Oban.Registry], 9 | strategy: :one_for_one, 10 | name: __MODULE__ 11 | ) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/oban/backoff.ex: -------------------------------------------------------------------------------- 1 | defmodule Oban.Backoff do 2 | @moduledoc false 3 | 4 | @type jitter_mode :: :inc | :dec | :both 5 | 6 | @retry_mult Application.compile_env(:oban, [Oban.Backoff, :retry_mult], 100) 7 | 8 | @doc """ 9 | Calculate an exponential backoff in seconds for a given attempt. 10 | 11 | By default, the exponent is clamped to a maximum of 10 to prevent unreasonably long delays. 12 | 13 | ## Examples 14 | 15 | iex> Oban.Backoff.exponential(1) 16 | 2 17 | 18 | iex> Oban.Backoff.exponential(1, mult: 100) 19 | 200 20 | 21 | iex> Oban.Backoff.exponential(1, min_pad: 10) 22 | 12 23 | 24 | iex> Oban.Backoff.exponential(10) 25 | 1024 26 | 27 | iex> Oban.Backoff.exponential(11) 28 | 1024 29 | """ 30 | @spec exponential(pos_integer(), opts :: keyword()) :: number() 31 | def exponential(attempt, opts \\ []) do 32 | max_pow = Keyword.get(opts, :max_pow, 10) 33 | min_pad = Keyword.get(opts, :min_pad, 0) 34 | mult = Keyword.get(opts, :mult, 1) 35 | 36 | min_pad + mult * Integer.pow(2, min(attempt, max_pow)) 37 | end 38 | 39 | @doc """ 40 | Applies a random amount of jitter to the provided value. 41 | 42 | ## Examples 43 | 44 | iex> jitter = Oban.Backoff.jitter(200) 45 | ...> jitter in 180..220 46 | true 47 | 48 | iex> jitter = Oban.Backoff.jitter(200, mode: :inc) 49 | ...> jitter in 200..220 50 | true 51 | 52 | iex> jitter = Oban.Backoff.jitter(200, mode: :dec) 53 | ...> jitter in 180..200 54 | true 55 | """ 56 | @spec jitter(time :: pos_integer(), mode: jitter_mode(), mult: float()) :: number() 57 | def jitter(time, opts \\ []) do 58 | mode = Keyword.get(opts, :mode, :both) 59 | mult = Keyword.get(opts, :mult, 0.1) 60 | rand = :rand.uniform() 61 | 62 | diff = trunc(rand * mult * time) 63 | 64 | case mode do 65 | :inc -> 66 | time + diff 67 | 68 | :dec -> 69 | time - diff 70 | 71 | :both -> 72 | if rand >= 0.5 do 73 | time + diff 74 | else 75 | time - diff 76 | end 77 | end 78 | end 79 | 80 | @doc """ 81 | Attempt a database interaction repeatedly until it succeeds or retries are exhausted. 82 | 83 | Failed attempts are spaced out using exponential backoff with jitter. By default, functions are 84 | retried _infinitely_ with a maximum of ~100 seconds between retries. 85 | 86 | This function is designed to guard against flickering database errors and retry safety only 87 | applies `DBConnection.ConnectionError`, `Postgrex.Error`, and `GenServer` timeouts. 88 | 89 | ## Examples 90 | 91 | iex> Oban.Backoff.with_retry(fn -> :ok end) 92 | :ok 93 | 94 | iex> Oban.Backoff.with_retry(fn -> :ok end, :infinity) 95 | :ok 96 | 97 | iex> Oban.Backoff.with_retry(fn -> :ok end, 10) 98 | :ok 99 | """ 100 | @spec with_retry((-> term()), :infinity | pos_integer()) :: term() 101 | def with_retry(fun, retries \\ :infinity) when is_function(fun, 0) do 102 | with_retry(fun, retries, 1) 103 | end 104 | 105 | @db_errors [DBConnection.ConnectionError, Postgrex.Error] 106 | 107 | defp with_retry(fun, retries, attempt) do 108 | fun.() 109 | rescue 110 | error in @db_errors -> 111 | retry_or_raise(fun, retries, attempt, :error, error, __STACKTRACE__) 112 | catch 113 | :exit, {error, _} = reason when error in [:timeout | @db_errors] -> 114 | retry_or_raise(fun, retries, attempt, :exit, reason, __STACKTRACE__) 115 | end 116 | 117 | @compile {:inline, retry_or_raise: 6} 118 | 119 | defp retry_or_raise(fun, retries, attempt, kind, reason, stacktrace) do 120 | if retries == :infinity or attempt < retries do 121 | attempt 122 | |> exponential(mult: @retry_mult) 123 | |> jitter() 124 | |> Process.sleep() 125 | 126 | with_retry(fun, retries, attempt + 1) 127 | else 128 | :erlang.raise(kind, reason, stacktrace) 129 | end 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /lib/oban/cron.ex: -------------------------------------------------------------------------------- 1 | defmodule Oban.Cron do 2 | @moduledoc false 3 | 4 | alias Oban.Cron.Expression 5 | 6 | @spec schedule_interval(pid(), term(), binary(), Calendar.time_zone()) :: :ok 7 | def schedule_interval(pid, message, schedule, timezone \\ "Etc/UTC") do 8 | :timer.apply_after( 9 | interval_to_next_minute(), 10 | __MODULE__, 11 | :__schedule_interval__, 12 | [pid, message, schedule, timezone] 13 | ) 14 | 15 | :ok 16 | end 17 | 18 | @doc false 19 | def __schedule_interval__(pid, message, schedule, timezone) do 20 | exp = Expression.parse!(schedule) 21 | now = DateTime.now!(timezone) 22 | 23 | if Expression.now?(exp, now) do 24 | send(pid, message) 25 | end 26 | 27 | schedule_interval(pid, message, schedule, timezone) 28 | end 29 | 30 | @spec interval_to_next_minute(Time.t()) :: pos_integer() 31 | def interval_to_next_minute(time \\ Time.utc_now()) do 32 | time 33 | |> Time.add(60) 34 | |> Map.put(:second, 0) 35 | |> Time.diff(time) 36 | |> Integer.mod(86_400) 37 | |> :timer.seconds() 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/oban/engines/inline.ex: -------------------------------------------------------------------------------- 1 | defmodule Oban.Engines.Inline do 2 | @moduledoc """ 3 | A testing-specific engine that's used when Oban is started with `testing: :inline`. 4 | 5 | ## Usage 6 | 7 | This is meant for testing and shouldn't be configured directly: 8 | 9 | Oban.start_link(repo: MyApp.Repo, testing: :inline) 10 | """ 11 | 12 | @behaviour Oban.Engine 13 | 14 | import DateTime, only: [utc_now: 0] 15 | 16 | alias Ecto.Changeset 17 | alias Oban.{Config, Engine, Job, JSON} 18 | alias Oban.Queue.Executor 19 | 20 | @impl Engine 21 | def init(_conf, opts), do: {:ok, Map.new(opts)} 22 | 23 | @impl Engine 24 | def put_meta(_conf, meta, key, value), do: Map.put(meta, key, value) 25 | 26 | @impl Engine 27 | def check_meta(_conf, meta, _running), do: meta 28 | 29 | @impl Engine 30 | def refresh(_conf, meta), do: meta 31 | 32 | @impl Engine 33 | def shutdown(_conf, meta), do: meta 34 | 35 | @impl Engine 36 | def insert_job(%Config{} = conf, %Changeset{} = changeset, _opts) do 37 | {:ok, execute_job(conf, changeset)} 38 | end 39 | 40 | @impl Engine 41 | def insert_all_jobs(%Config{} = conf, changesets, _opts) do 42 | changesets 43 | |> expand() 44 | |> Enum.map(&execute_job(conf, &1)) 45 | end 46 | 47 | @impl Engine 48 | def fetch_jobs(_conf, meta, _running), do: {:ok, {meta, []}} 49 | 50 | @impl Engine 51 | def complete_job(_conf, _job), do: :ok 52 | 53 | @impl Engine 54 | def discard_job(_conf, _job), do: :ok 55 | 56 | @impl Engine 57 | def error_job(_conf, _job, seconds) when is_integer(seconds), do: :ok 58 | 59 | @impl Engine 60 | def snooze_job(_conf, _job, seconds) when is_integer(seconds), do: :ok 61 | 62 | @impl Engine 63 | def cancel_job(_conf, _job), do: :ok 64 | 65 | @impl Engine 66 | def cancel_all_jobs(_conf, _queryable), do: {:ok, []} 67 | 68 | @impl Engine 69 | def retry_job(_conf, _job), do: :ok 70 | 71 | @impl Engine 72 | def retry_all_jobs(_conf, _queryable), do: {:ok, []} 73 | 74 | # Changeset Helpers 75 | 76 | defp expand(value), do: expand(value, %{}) 77 | defp expand(fun, changes) when is_function(fun, 1), do: expand(fun.(changes), changes) 78 | defp expand(%{changesets: changesets}, _), do: expand(changesets, %{}) 79 | defp expand(changesets, _), do: changesets 80 | 81 | # Execution Helpers 82 | 83 | defp execute_job(conf, changeset) do 84 | changeset = 85 | changeset 86 | |> Changeset.put_change(:attempt, 1) 87 | |> Changeset.put_change(:attempted_by, [conf.node]) 88 | |> Changeset.put_change(:attempted_at, utc_now()) 89 | |> Changeset.put_change(:scheduled_at, utc_now()) 90 | |> Changeset.update_change(:args, &json_recode/1) 91 | |> Changeset.update_change(:meta, &json_recode/1) 92 | 93 | case Changeset.apply_action(changeset, :insert) do 94 | {:ok, job} -> 95 | conf 96 | |> Executor.new(job, safe: false) 97 | |> Executor.call() 98 | |> complete_job() 99 | 100 | {:error, changeset} -> 101 | raise Ecto.InvalidChangesetError, action: :insert, changeset: changeset 102 | end 103 | end 104 | 105 | defp json_recode(map) do 106 | map 107 | |> JSON.encode!() 108 | |> JSON.decode!() 109 | end 110 | 111 | defp complete_job(%{job: job, state: :failure}) do 112 | %{job | errors: [Job.format_attempt(job)], state: "retryable", scheduled_at: utc_now()} 113 | end 114 | 115 | defp complete_job(%{job: job, state: :cancelled}) do 116 | %{job | errors: [Job.format_attempt(job)], state: "cancelled", cancelled_at: utc_now()} 117 | end 118 | 119 | defp complete_job(%{job: job, state: state}) when state in [:discard, :exhausted] do 120 | %{job | errors: [Job.format_attempt(job)], state: "discarded", discarded_at: utc_now()} 121 | end 122 | 123 | defp complete_job(%{job: job, state: :success}) do 124 | %{job | state: "completed", completed_at: utc_now()} 125 | end 126 | 127 | defp complete_job(%{job: job, result: {:snooze, snooze}, state: :snoozed}) do 128 | %{job | state: "scheduled", scheduled_at: DateTime.add(utc_now(), snooze, :second)} 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /lib/oban/exceptions.ex: -------------------------------------------------------------------------------- 1 | defmodule Oban.CrashError do 2 | @moduledoc """ 3 | Wraps unhandled exits and throws that occur during job execution. 4 | """ 5 | 6 | defexception [:message, :reason] 7 | 8 | @impl Exception 9 | def exception({kind, reason, stacktrace}) do 10 | message = Exception.format_banner(kind, reason, stacktrace) 11 | 12 | %__MODULE__{message: message, reason: reason} 13 | end 14 | end 15 | 16 | defmodule Oban.PerformError do 17 | @moduledoc """ 18 | Wraps the reason returned by `{:error, reason}`, `{:cancel, reason}`, or `{:discard, reason}` in 19 | a proper exception. 20 | 21 | The original return tuple is available in the `:reason` key. 22 | """ 23 | 24 | alias Oban.Worker 25 | 26 | defexception [:message, :reason] 27 | 28 | @impl Exception 29 | def exception({worker, reason}) do 30 | message = "#{Worker.to_string(worker)} failed with #{inspect(reason)}" 31 | 32 | %__MODULE__{message: message, reason: reason} 33 | end 34 | end 35 | 36 | defmodule Oban.TimeoutError do 37 | @moduledoc """ 38 | Returned when a job is terminated early due to a custom timeout. 39 | """ 40 | 41 | alias Oban.Worker 42 | 43 | defexception [:message, :reason] 44 | 45 | @impl Exception 46 | def exception({worker, timeout}) do 47 | message = "#{Worker.to_string(worker)} timed out after #{timeout}ms" 48 | 49 | %__MODULE__{message: message, reason: :timeout} 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/oban/json.ex: -------------------------------------------------------------------------------- 1 | defmodule Oban.JSON do 2 | @moduledoc false 3 | 4 | # Delegates to JSON in Elixir v1.18+ or Jason for earlier versions 5 | 6 | cond do 7 | Code.ensure_loaded?(JSON) -> 8 | defdelegate decode!(data), to: JSON 9 | defdelegate encode!(data), to: JSON 10 | defdelegate encode_to_iodata!(data), to: JSON 11 | 12 | Code.ensure_loaded?(Jason) -> 13 | defdelegate decode!(data), to: Jason 14 | defdelegate encode!(data), to: Jason 15 | defdelegate encode_to_iodata!(data), to: Jason 16 | 17 | true -> 18 | message = "Missing a compatible JSON library, add `:jason` to your deps." 19 | 20 | IO.warn(message, Macro.Env.stacktrace(__ENV__)) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/oban/midwife.ex: -------------------------------------------------------------------------------- 1 | defmodule Oban.Midwife do 2 | @moduledoc false 3 | 4 | use GenServer 5 | 6 | alias Oban.{Config, Notifier, Queue, Registry} 7 | alias __MODULE__, as: State 8 | 9 | require Logger 10 | 11 | defstruct [:conf] 12 | 13 | @spec start_link(Keyword.t()) :: GenServer.on_start() 14 | def start_link(opts) do 15 | {name, opts} = Keyword.pop(opts, :name) 16 | 17 | GenServer.start_link(__MODULE__, struct!(State, opts), name: name) 18 | end 19 | 20 | @spec start_queue(Config.t(), Keyword.t() | {String.t(), Keyword.t()}) :: 21 | DynamicSupervisor.on_start_child() 22 | def start_queue(conf, opts) when is_list(opts) do 23 | queue = 24 | opts 25 | |> Keyword.fetch!(:queue) 26 | |> to_string() 27 | 28 | opts = 29 | opts 30 | |> Keyword.put(:conf, conf) 31 | |> Keyword.put(:queue, queue) 32 | |> Keyword.put(:name, Registry.via(conf.name, {:queue, queue})) 33 | 34 | conf 35 | |> foreman() 36 | |> DynamicSupervisor.start_child({Queue.Supervisor, opts}) 37 | end 38 | 39 | def start_queue(conf, {queue, opts}) do 40 | opts 41 | |> Keyword.put(:queue, queue) 42 | |> then(&start_queue(conf, &1)) 43 | end 44 | 45 | @spec stop_queue(Config.t(), atom() | String.t()) :: :ok | {:error, :not_found} 46 | def stop_queue(conf, queue) do 47 | case Registry.whereis(conf.name, {:queue, queue}) do 48 | pid when is_pid(pid) -> 49 | conf 50 | |> foreman() 51 | |> DynamicSupervisor.terminate_child(pid) 52 | 53 | nil -> 54 | {:error, :not_found} 55 | end 56 | end 57 | 58 | @impl GenServer 59 | def init(state) do 60 | state.conf.queues 61 | |> Task.async_stream(fn opts -> {:ok, _} = start_queue(state.conf, opts) end) 62 | |> Stream.run() 63 | 64 | {:ok, state, {:continue, :start}} 65 | end 66 | 67 | @impl GenServer 68 | def handle_continue(:start, %State{conf: conf} = state) do 69 | Notifier.listen(conf.name, :signal) 70 | 71 | {:noreply, state} 72 | end 73 | 74 | @impl GenServer 75 | def handle_info({:notification, :signal, payload}, %State{conf: conf} = state) do 76 | case payload do 77 | %{"action" => "start"} -> 78 | opts = 79 | payload 80 | |> Map.drop(["action", "ident", "local_only"]) 81 | |> Keyword.new(fn {key, val} -> {String.to_existing_atom(key), val} end) 82 | 83 | start_queue(conf, opts) 84 | 85 | %{"action" => "stop", "queue" => queue} -> 86 | stop_queue(conf, queue) 87 | 88 | _ -> 89 | :ok 90 | end 91 | 92 | {:noreply, state} 93 | end 94 | 95 | def handle_info(message, state) do 96 | Logger.warning( 97 | message: "Received unexpected message: #{inspect(message)}", 98 | source: :oban, 99 | module: __MODULE__ 100 | ) 101 | 102 | {:noreply, state} 103 | end 104 | 105 | defp foreman(conf), do: Registry.via(conf.name, Foreman) 106 | end 107 | -------------------------------------------------------------------------------- /lib/oban/migrations/myxql.ex: -------------------------------------------------------------------------------- 1 | defmodule Oban.Migrations.MyXQL do 2 | @moduledoc false 3 | 4 | @behaviour Oban.Migration 5 | 6 | use Ecto.Migration 7 | 8 | @impl Oban.Migration 9 | def up(_opts) do 10 | states = 11 | :"ENUM('available', 'scheduled', 'executing', 'retryable', 'completed', 'cancelled', 'discarded')" 12 | 13 | create_if_not_exists table(:oban_jobs, primary_key: false) do 14 | add :id, :bigserial, primary_key: true 15 | add :state, states, null: false, default: "available" 16 | add :queue, :string, null: false, default: "default" 17 | add :worker, :string, null: false 18 | add :args, :json, null: false, default: %{} 19 | add :meta, :json, null: false, default: %{} 20 | add :tags, :json, null: false, default: fragment("('[]')") 21 | add :attempted_by, :json, null: false, default: fragment("('[]')") 22 | add :errors, :json, null: false, default: fragment("('[]')") 23 | add :attempt, :integer, null: false, default: 0 24 | add :max_attempts, :integer, null: false, default: 20 25 | add :priority, :integer, null: false, default: 0 26 | 27 | add :inserted_at, :utc_datetime_usec, null: false, default: fragment("(UTC_TIMESTAMP(6))") 28 | add :scheduled_at, :utc_datetime_usec, null: false, default: fragment("(UTC_TIMESTAMP(6))") 29 | add :attempted_at, :utc_datetime_usec 30 | add :cancelled_at, :utc_datetime_usec 31 | add :completed_at, :utc_datetime_usec 32 | add :discarded_at, :utc_datetime_usec 33 | end 34 | 35 | create_if_not_exists table(:oban_peers, primary_key: false) do 36 | add :name, :string, null: false, primary_key: true 37 | add :node, :string, null: false 38 | add :started_at, :utc_datetime_usec, null: false 39 | add :expires_at, :utc_datetime_usec, null: false 40 | end 41 | 42 | create index(:oban_jobs, [:state, :queue, :priority, :scheduled_at, :id]) 43 | 44 | create constraint(:oban_jobs, :attempt_range, check: "attempt between 0 and max_attempts") 45 | create constraint(:oban_jobs, :non_negative_priority, check: "priority >= 0") 46 | create constraint(:oban_jobs, :positive_max_attempts, check: "max_attempts > 0") 47 | 48 | create constraint(:oban_jobs, :queue_length, 49 | check: "char_length(queue) > 0 AND char_length(queue) < 128" 50 | ) 51 | 52 | create constraint(:oban_jobs, :worker_length, 53 | check: "char_length(worker) > 0 AND char_length(worker) < 128" 54 | ) 55 | 56 | :ok 57 | end 58 | 59 | @impl Oban.Migration 60 | def down(_opts) do 61 | drop_if_exists table(:oban_peers) 62 | drop_if_exists table(:oban_jobs) 63 | 64 | :ok 65 | end 66 | 67 | @impl Oban.Migration 68 | def migrated_version(_opts), do: 0 69 | end 70 | -------------------------------------------------------------------------------- /lib/oban/migrations/postgres.ex: -------------------------------------------------------------------------------- 1 | defmodule Oban.Migrations.Postgres do 2 | @moduledoc false 3 | 4 | @behaviour Oban.Migration 5 | 6 | use Ecto.Migration 7 | 8 | @initial_version 1 9 | @current_version 13 10 | @default_prefix "public" 11 | 12 | @doc false 13 | def initial_version, do: @initial_version 14 | 15 | @doc false 16 | def current_version, do: @current_version 17 | 18 | @impl Oban.Migration 19 | def up(opts) do 20 | opts = with_defaults(opts, @current_version) 21 | initial = migrated_version(opts) 22 | 23 | cond do 24 | initial == 0 -> 25 | change(@initial_version..opts.version, :up, opts) 26 | 27 | initial < opts.version -> 28 | change((initial + 1)..opts.version, :up, opts) 29 | 30 | true -> 31 | :ok 32 | end 33 | end 34 | 35 | @impl Oban.Migration 36 | def down(opts) do 37 | opts = with_defaults(opts, @initial_version) 38 | initial = max(migrated_version(opts), @initial_version) 39 | 40 | if initial >= opts.version do 41 | change(initial..opts.version//-1, :down, opts) 42 | end 43 | end 44 | 45 | @impl Oban.Migration 46 | def migrated_version(opts) do 47 | opts = with_defaults(opts, @initial_version) 48 | 49 | repo = Map.get_lazy(opts, :repo, fn -> repo() end) 50 | escaped_prefix = Map.fetch!(opts, :escaped_prefix) 51 | 52 | query = """ 53 | SELECT pg_catalog.obj_description(pg_class.oid, 'pg_class') 54 | FROM pg_class 55 | LEFT JOIN pg_namespace ON pg_namespace.oid = pg_class.relnamespace 56 | WHERE pg_class.relname = 'oban_jobs' 57 | AND pg_namespace.nspname = '#{escaped_prefix}' 58 | """ 59 | 60 | case repo.query(query, [], log: false) do 61 | {:ok, %{rows: [[version]]}} when is_binary(version) -> String.to_integer(version) 62 | _ -> 0 63 | end 64 | end 65 | 66 | defp change(range, direction, opts) do 67 | for index <- range do 68 | pad_idx = String.pad_leading(to_string(index), 2, "0") 69 | 70 | [__MODULE__, "V#{pad_idx}"] 71 | |> Module.concat() 72 | |> apply(direction, [opts]) 73 | end 74 | 75 | case direction do 76 | :up -> record_version(opts, Enum.max(range)) 77 | :down -> record_version(opts, Enum.min(range) - 1) 78 | end 79 | end 80 | 81 | defp record_version(_opts, 0), do: :ok 82 | 83 | defp record_version(%{prefix: prefix}, version) do 84 | execute "COMMENT ON TABLE #{inspect(prefix)}.oban_jobs IS '#{version}'" 85 | end 86 | 87 | defp with_defaults(opts, version) do 88 | opts = Enum.into(opts, %{prefix: @default_prefix, version: version}) 89 | 90 | opts 91 | |> Map.put(:quoted_prefix, inspect(opts.prefix)) 92 | |> Map.put(:escaped_prefix, String.replace(opts.prefix, "'", "\\'")) 93 | |> Map.put_new(:unlogged, true) 94 | |> Map.put_new(:create_schema, opts.prefix != @default_prefix) 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /lib/oban/migrations/postgres/v01.ex: -------------------------------------------------------------------------------- 1 | defmodule Oban.Migrations.Postgres.V01 do 2 | @moduledoc false 3 | 4 | use Ecto.Migration 5 | 6 | def up(%{create_schema: create?, prefix: prefix} = opts) do 7 | %{escaped_prefix: escaped, quoted_prefix: quoted} = opts 8 | 9 | if create?, do: execute("CREATE SCHEMA IF NOT EXISTS #{quoted}") 10 | 11 | execute """ 12 | DO $$ 13 | BEGIN 14 | IF NOT EXISTS (SELECT 1 FROM pg_type 15 | WHERE typname = 'oban_job_state' 16 | AND typnamespace = '#{escaped}'::regnamespace::oid) THEN 17 | CREATE TYPE #{quoted}.oban_job_state AS ENUM ( 18 | 'available', 19 | 'scheduled', 20 | 'executing', 21 | 'retryable', 22 | 'completed', 23 | 'discarded' 24 | ); 25 | END IF; 26 | END$$; 27 | """ 28 | 29 | create_if_not_exists table(:oban_jobs, primary_key: false, prefix: prefix) do 30 | add :id, :bigserial, primary_key: true 31 | add :state, :"#{quoted}.oban_job_state", null: false, default: "available" 32 | add :queue, :text, null: false, default: "default" 33 | add :worker, :text, null: false 34 | add :args, :map, null: false 35 | add :errors, {:array, :map}, null: false, default: [] 36 | add :attempt, :integer, null: false, default: 0 37 | add :max_attempts, :integer, null: false, default: 20 38 | 39 | add :inserted_at, :utc_datetime_usec, 40 | null: false, 41 | default: fragment("timezone('UTC', now())") 42 | 43 | add :scheduled_at, :utc_datetime_usec, 44 | null: false, 45 | default: fragment("timezone('UTC', now())") 46 | 47 | add :attempted_at, :utc_datetime_usec 48 | add :completed_at, :utc_datetime_usec 49 | end 50 | 51 | create_if_not_exists index(:oban_jobs, [:queue], prefix: prefix) 52 | create_if_not_exists index(:oban_jobs, [:state], prefix: prefix) 53 | create_if_not_exists index(:oban_jobs, [:scheduled_at], prefix: prefix) 54 | 55 | execute """ 56 | CREATE OR REPLACE FUNCTION #{quoted}.oban_jobs_notify() RETURNS trigger AS $$ 57 | DECLARE 58 | channel text; 59 | notice json; 60 | BEGIN 61 | IF (TG_OP = 'INSERT') THEN 62 | channel = '#{escaped}.oban_insert'; 63 | notice = json_build_object('queue', NEW.queue, 'state', NEW.state); 64 | 65 | -- No point triggering for a job that isn't scheduled to run now 66 | IF NEW.scheduled_at IS NOT NULL AND NEW.scheduled_at > now() AT TIME ZONE 'utc' THEN 67 | RETURN null; 68 | END IF; 69 | ELSE 70 | channel = '#{escaped}.oban_update'; 71 | notice = json_build_object('queue', NEW.queue, 'new_state', NEW.state, 'old_state', OLD.state); 72 | END IF; 73 | 74 | PERFORM pg_notify(channel, notice::text); 75 | 76 | RETURN NULL; 77 | END; 78 | $$ LANGUAGE plpgsql; 79 | """ 80 | 81 | execute "DROP TRIGGER IF EXISTS oban_notify ON #{quoted}.oban_jobs" 82 | 83 | execute """ 84 | CREATE TRIGGER oban_notify 85 | AFTER INSERT OR UPDATE OF state ON #{quoted}.oban_jobs 86 | FOR EACH ROW EXECUTE PROCEDURE #{quoted}.oban_jobs_notify(); 87 | """ 88 | end 89 | 90 | def down(%{prefix: prefix, quoted_prefix: quoted}) do 91 | execute "DROP TRIGGER IF EXISTS oban_notify ON #{quoted}.oban_jobs" 92 | execute "DROP FUNCTION IF EXISTS #{quoted}.oban_jobs_notify()" 93 | 94 | drop_if_exists table(:oban_jobs, prefix: prefix) 95 | 96 | execute "DROP TYPE IF EXISTS #{quoted}.oban_job_state" 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /lib/oban/migrations/postgres/v02.ex: -------------------------------------------------------------------------------- 1 | defmodule Oban.Migrations.Postgres.V02 do 2 | @moduledoc false 3 | 4 | use Ecto.Migration 5 | 6 | def up(%{prefix: prefix, quoted_prefix: quoted}) do 7 | # We only need the scheduled_at index for scheduled and available jobs 8 | drop_if_exists index(:oban_jobs, [:scheduled_at], prefix: prefix) 9 | 10 | state = "#{quoted}.oban_job_state" 11 | 12 | create index(:oban_jobs, [:scheduled_at], 13 | where: "state in ('available'::#{state}, 'scheduled'::#{state})", 14 | prefix: prefix 15 | ) 16 | 17 | create constraint(:oban_jobs, :worker_length, 18 | check: "char_length(worker) > 0 AND char_length(worker) < 128", 19 | prefix: prefix 20 | ) 21 | 22 | create constraint(:oban_jobs, :queue_length, 23 | check: "char_length(queue) > 0 AND char_length(queue) < 128", 24 | prefix: prefix 25 | ) 26 | 27 | execute """ 28 | CREATE OR REPLACE FUNCTION #{quoted}.oban_wrap_id(value bigint) RETURNS int AS $$ 29 | BEGIN 30 | RETURN (CASE WHEN value > 2147483647 THEN mod(value, 2147483647) ELSE value END)::int; 31 | END; 32 | $$ LANGUAGE plpgsql IMMUTABLE; 33 | """ 34 | end 35 | 36 | def down(%{prefix: prefix, quoted_prefix: quoted}) do 37 | drop_if_exists constraint(:oban_jobs, :queue_length, prefix: prefix) 38 | drop_if_exists constraint(:oban_jobs, :worker_length, prefix: prefix) 39 | 40 | drop_if_exists index(:oban_jobs, [:scheduled_at], prefix: prefix) 41 | create index(:oban_jobs, [:scheduled_at], prefix: prefix) 42 | 43 | execute("DROP FUNCTION IF EXISTS #{quoted}.oban_wrap_id(value bigint)") 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/oban/migrations/postgres/v03.ex: -------------------------------------------------------------------------------- 1 | defmodule Oban.Migrations.Postgres.V03 do 2 | @moduledoc false 3 | 4 | use Ecto.Migration 5 | 6 | def up(%{prefix: prefix}) do 7 | alter table(:oban_jobs, prefix: prefix) do 8 | add_if_not_exists(:attempted_by, {:array, :text}) 9 | end 10 | end 11 | 12 | def down(%{prefix: prefix}) do 13 | alter table(:oban_jobs, prefix: prefix) do 14 | remove_if_exists(:attempted_by, {:array, :text}) 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/oban/migrations/postgres/v04.ex: -------------------------------------------------------------------------------- 1 | defmodule Oban.Migrations.Postgres.V04 do 2 | @moduledoc false 3 | 4 | use Ecto.Migration 5 | 6 | def up(%{quoted_prefix: quoted}) do 7 | execute("DROP FUNCTION IF EXISTS #{quoted}.oban_wrap_id(value bigint)") 8 | end 9 | 10 | def down(%{quoted_prefix: quoted}) do 11 | execute """ 12 | CREATE OR REPLACE FUNCTION #{quoted}.oban_wrap_id(value bigint) RETURNS int AS $$ 13 | BEGIN 14 | RETURN (CASE WHEN value > 2147483647 THEN mod(value, 2147483647) ELSE value END)::int; 15 | END; 16 | $$ LANGUAGE plpgsql IMMUTABLE; 17 | """ 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/oban/migrations/postgres/v05.ex: -------------------------------------------------------------------------------- 1 | defmodule Oban.Migrations.Postgres.V05 do 2 | @moduledoc false 3 | 4 | use Ecto.Migration 5 | 6 | def up(%{prefix: prefix}) do 7 | drop_if_exists index(:oban_jobs, [:scheduled_at], prefix: prefix) 8 | drop_if_exists index(:oban_jobs, [:queue], prefix: prefix) 9 | drop_if_exists index(:oban_jobs, [:state], prefix: prefix) 10 | 11 | create_if_not_exists index(:oban_jobs, [:queue, :state, :scheduled_at, :id], prefix: prefix) 12 | end 13 | 14 | def down(%{prefix: prefix, quoted_prefix: quoted}) do 15 | drop_if_exists index(:oban_jobs, [:queue, :state, :scheduled_at, :id], prefix: prefix) 16 | 17 | state = "#{quoted}.oban_job_state" 18 | 19 | create_if_not_exists index(:oban_jobs, [:queue], prefix: prefix) 20 | create_if_not_exists index(:oban_jobs, [:state], prefix: prefix) 21 | 22 | create index(:oban_jobs, [:scheduled_at], 23 | where: "state in ('available'::#{state}, 'scheduled'::#{state})", 24 | prefix: prefix 25 | ) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/oban/migrations/postgres/v06.ex: -------------------------------------------------------------------------------- 1 | defmodule Oban.Migrations.Postgres.V06 do 2 | @moduledoc false 3 | 4 | use Ecto.Migration 5 | 6 | def up(_opts) do 7 | # This used to modify oban_beats, which aren't included anymore 8 | end 9 | 10 | def down(_opts) do 11 | # This used to modify oban_beats, which aren't included anymore 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/oban/migrations/postgres/v07.ex: -------------------------------------------------------------------------------- 1 | defmodule Oban.Migrations.Postgres.V07 do 2 | @moduledoc false 3 | 4 | use Ecto.Migration 5 | 6 | def up(%{prefix: prefix}) do 7 | create_if_not_exists index( 8 | :oban_jobs, 9 | ["attempted_at desc", :id], 10 | where: "state in ('completed', 'discarded')", 11 | prefix: prefix, 12 | name: :oban_jobs_attempted_at_id_index 13 | ) 14 | end 15 | 16 | def down(%{prefix: prefix}) do 17 | drop_if_exists index(:oban_jobs, [:attempted_at, :id], prefix: prefix) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/oban/migrations/postgres/v08.ex: -------------------------------------------------------------------------------- 1 | defmodule Oban.Migrations.Postgres.V08 do 2 | @moduledoc false 3 | 4 | use Ecto.Migration 5 | 6 | def up(%{escaped_prefix: escaped, prefix: prefix, quoted_prefix: quoted}) do 7 | alter table(:oban_jobs, prefix: prefix) do 8 | add_if_not_exists(:discarded_at, :utc_datetime_usec) 9 | add_if_not_exists(:priority, :integer) 10 | add_if_not_exists(:tags, {:array, :text}) 11 | end 12 | 13 | alter table(:oban_jobs, prefix: prefix) do 14 | modify :priority, :integer, default: 0 15 | modify :tags, {:array, :text}, default: [] 16 | end 17 | 18 | drop_if_exists index(:oban_jobs, [:queue, :state, :scheduled_at, :id], prefix: prefix) 19 | 20 | create_if_not_exists index(:oban_jobs, [:state, :queue, :priority, :scheduled_at, :id], 21 | prefix: prefix 22 | ) 23 | 24 | execute """ 25 | CREATE OR REPLACE FUNCTION #{quoted}.oban_jobs_notify() RETURNS trigger AS $$ 26 | DECLARE 27 | channel text; 28 | notice json; 29 | BEGIN 30 | IF NEW.state = 'available' THEN 31 | channel = '#{escaped}.oban_insert'; 32 | notice = json_build_object('queue', NEW.queue); 33 | 34 | PERFORM pg_notify(channel, notice::text); 35 | END IF; 36 | 37 | RETURN NULL; 38 | END; 39 | $$ LANGUAGE plpgsql; 40 | """ 41 | 42 | execute "DROP TRIGGER IF EXISTS oban_notify ON #{quoted}.oban_jobs" 43 | 44 | execute """ 45 | CREATE TRIGGER oban_notify 46 | AFTER INSERT ON #{quoted}.oban_jobs 47 | FOR EACH ROW EXECUTE PROCEDURE #{quoted}.oban_jobs_notify(); 48 | """ 49 | end 50 | 51 | def down(%{escaped_prefix: escaped, prefix: prefix, quoted_prefix: quoted}) do 52 | drop_if_exists index(:oban_jobs, [:queue, :state, :priority, :scheduled_at, :id], 53 | prefix: prefix 54 | ) 55 | 56 | create_if_not_exists index(:oban_jobs, [:queue, :state, :scheduled_at, :id], prefix: prefix) 57 | 58 | alter table(:oban_jobs, prefix: prefix) do 59 | remove_if_exists(:discarded_at, :utc_datetime_usec) 60 | remove_if_exists(:priority, :integer) 61 | remove_if_exists(:tags, {:array, :string}) 62 | end 63 | 64 | execute """ 65 | CREATE OR REPLACE FUNCTION #{quoted}.oban_jobs_notify() RETURNS trigger AS $$ 66 | DECLARE 67 | channel text; 68 | notice json; 69 | BEGIN 70 | IF (TG_OP = 'INSERT') THEN 71 | channel = '#{escaped}.oban_insert'; 72 | notice = json_build_object('queue', NEW.queue, 'state', NEW.state); 73 | 74 | -- No point triggering for a job that isn't scheduled to run now 75 | IF NEW.scheduled_at IS NOT NULL AND NEW.scheduled_at > now() AT TIME ZONE 'utc' THEN 76 | RETURN null; 77 | END IF; 78 | ELSE 79 | channel = '#{escaped}.oban_update'; 80 | notice = json_build_object('queue', NEW.queue, 'new_state', NEW.state, 'old_state', OLD.state); 81 | END IF; 82 | 83 | PERFORM pg_notify(channel, notice::text); 84 | 85 | RETURN NULL; 86 | END; 87 | $$ LANGUAGE plpgsql; 88 | """ 89 | 90 | execute "DROP TRIGGER IF EXISTS oban_notify ON #{quoted}.oban_jobs" 91 | 92 | execute """ 93 | CREATE TRIGGER oban_notify 94 | AFTER INSERT OR UPDATE OF state ON #{quoted}.oban_jobs 95 | FOR EACH ROW EXECUTE PROCEDURE #{quoted}.oban_jobs_notify(); 96 | """ 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /lib/oban/migrations/postgres/v09.ex: -------------------------------------------------------------------------------- 1 | defmodule Oban.Migrations.Postgres.V09 do 2 | @moduledoc false 3 | 4 | use Ecto.Migration 5 | 6 | def up(%{prefix: prefix, quoted_prefix: quoted}) do 7 | alter table(:oban_jobs, prefix: prefix) do 8 | add_if_not_exists(:meta, :map, default: %{}) 9 | add_if_not_exists(:cancelled_at, :utc_datetime_usec) 10 | end 11 | 12 | execute """ 13 | DO $$ 14 | DECLARE 15 | version int; 16 | already bool; 17 | BEGIN 18 | SELECT current_setting('server_version_num')::int INTO version; 19 | SELECT '{cancelled}' <@ enum_range(NULL::#{quoted}.oban_job_state)::text[] INTO already; 20 | 21 | IF already THEN 22 | RETURN; 23 | ELSIF version >= 120000 THEN 24 | ALTER TYPE #{quoted}.oban_job_state ADD VALUE IF NOT EXISTS 'cancelled'; 25 | ELSE 26 | ALTER TYPE #{quoted}.oban_job_state RENAME TO old_oban_job_state; 27 | 28 | CREATE TYPE #{quoted}.oban_job_state AS ENUM ( 29 | 'available', 30 | 'scheduled', 31 | 'executing', 32 | 'retryable', 33 | 'completed', 34 | 'discarded', 35 | 'cancelled' 36 | ); 37 | 38 | ALTER TABLE #{quoted}.oban_jobs RENAME column state TO _state; 39 | ALTER TABLE #{quoted}.oban_jobs ADD state #{quoted}.oban_job_state NOT NULL default 'available'; 40 | 41 | UPDATE #{quoted}.oban_jobs SET state = _state::text::#{quoted}.oban_job_state; 42 | 43 | ALTER TABLE #{quoted}.oban_jobs DROP column _state; 44 | DROP TYPE #{quoted}.old_oban_job_state; 45 | END IF; 46 | END$$; 47 | """ 48 | 49 | create_if_not_exists index(:oban_jobs, [:state, :queue, :priority, :scheduled_at, :id], 50 | prefix: prefix 51 | ) 52 | end 53 | 54 | def down(%{prefix: prefix, quoted_prefix: quoted}) do 55 | alter table(:oban_jobs, prefix: prefix) do 56 | remove_if_exists(:meta, :map) 57 | remove_if_exists(:cancelled_at, :utc_datetime_usec) 58 | end 59 | 60 | execute """ 61 | DO $$ 62 | BEGIN 63 | UPDATE #{quoted}.oban_jobs SET state = 'discarded' WHERE state = 'cancelled'; 64 | 65 | ALTER TYPE #{quoted}.oban_job_state RENAME TO old_oban_job_state; 66 | 67 | CREATE TYPE #{quoted}.oban_job_state AS ENUM ( 68 | 'available', 69 | 'scheduled', 70 | 'executing', 71 | 'retryable', 72 | 'completed', 73 | 'discarded' 74 | ); 75 | 76 | ALTER TABLE #{quoted}.oban_jobs RENAME column state TO _state; 77 | 78 | ALTER TABLE #{quoted}.oban_jobs ADD state #{quoted}.oban_job_state NOT NULL default 'available'; 79 | 80 | UPDATE #{quoted}.oban_jobs SET state = _state::text::#{quoted}.oban_job_state; 81 | 82 | ALTER TABLE #{quoted}.oban_jobs DROP column _state; 83 | 84 | DROP TYPE #{quoted}.old_oban_job_state; 85 | END$$; 86 | """ 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /lib/oban/migrations/postgres/v10.ex: -------------------------------------------------------------------------------- 1 | defmodule Oban.Migrations.Postgres.V10 do 2 | @moduledoc false 3 | 4 | use Ecto.Migration 5 | 6 | def up(%{prefix: prefix}) do 7 | # These didn't have defaults out of consideration for older PG versions 8 | alter table(:oban_jobs, prefix: prefix) do 9 | modify :args, :map, default: %{} 10 | modify :priority, :integer, null: false 11 | end 12 | 13 | # These could happen from an insert_all call with invalid data 14 | create constraint(:oban_jobs, :priority_range, 15 | check: "priority between 0 and 3", 16 | prefix: prefix 17 | ) 18 | 19 | create constraint(:oban_jobs, :positive_max_attempts, 20 | check: "max_attempts > 0", 21 | prefix: prefix 22 | ) 23 | 24 | create constraint(:oban_jobs, :attempt_range, 25 | check: "attempt between 0 and max_attempts", 26 | prefix: prefix 27 | ) 28 | 29 | # These are unused or unnecessary 30 | drop_if_exists index(:oban_jobs, [:args], name: :oban_jobs_args_vector, prefix: prefix) 31 | drop_if_exists index(:oban_jobs, [:worker], name: :oban_jobs_worker_gist, prefix: prefix) 32 | 33 | drop_if_exists index(:oban_jobs, [:attempted_at], 34 | name: :oban_jobs_attempted_at_id_index, 35 | prefix: prefix 36 | ) 37 | 38 | # These are necessary to keep unique checks fast in large tables 39 | create_if_not_exists index(:oban_jobs, [:args], using: :gin, prefix: prefix) 40 | create_if_not_exists index(:oban_jobs, [:meta], using: :gin, prefix: prefix) 41 | end 42 | 43 | def down(%{prefix: prefix}) do 44 | alter table(:oban_jobs, prefix: prefix) do 45 | modify :args, :map, default: nil 46 | modify :priority, :integer, null: true 47 | end 48 | 49 | drop_if_exists constraint(:oban_jobs, :attempt_range, prefix: prefix) 50 | drop_if_exists constraint(:oban_jobs, :positive_max_attempts, prefix: prefix) 51 | drop_if_exists constraint(:oban_jobs, :priority_range, prefix: prefix) 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/oban/migrations/postgres/v11.ex: -------------------------------------------------------------------------------- 1 | defmodule Oban.Migrations.Postgres.V11 do 2 | @moduledoc false 3 | 4 | use Ecto.Migration 5 | 6 | def up(%{prefix: prefix, quoted_prefix: quoted, unlogged: unlogged?}) do 7 | create_if_not_exists table(:oban_peers, primary_key: false, prefix: prefix) do 8 | add :name, :text, null: false, primary_key: true 9 | add :node, :text, null: false 10 | add :started_at, :utc_datetime_usec, null: false 11 | add :expires_at, :utc_datetime_usec, null: false 12 | end 13 | 14 | if unlogged? do 15 | execute "ALTER TABLE #{quoted}.oban_peers SET UNLOGGED" 16 | end 17 | end 18 | 19 | def down(%{prefix: prefix}) do 20 | drop table(:oban_peers, prefix: prefix) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/oban/migrations/postgres/v12.ex: -------------------------------------------------------------------------------- 1 | defmodule Oban.Migrations.Postgres.V12 do 2 | @moduledoc false 3 | 4 | use Ecto.Migration 5 | 6 | def up(%{prefix: prefix, quoted_prefix: quoted}) do 7 | drop constraint(:oban_jobs, :priority_range, prefix: prefix) 8 | 9 | create constraint(:oban_jobs, :non_negative_priority, 10 | check: "priority >= 0", 11 | validate: false, 12 | prefix: prefix 13 | ) 14 | 15 | execute "DROP TRIGGER IF EXISTS oban_notify ON #{quoted}.oban_jobs" 16 | execute "DROP FUNCTION IF EXISTS #{quoted}.oban_jobs_notify()" 17 | end 18 | 19 | def down(%{escaped_prefix: escaped, prefix: prefix, quoted_prefix: quoted}) do 20 | drop constraint(:oban_jobs, :non_negative_priority, prefix: prefix) 21 | 22 | create constraint(:oban_jobs, :priority_range, 23 | check: "priority between 0 and 3", 24 | validate: false, 25 | prefix: prefix 26 | ) 27 | 28 | execute """ 29 | CREATE OR REPLACE FUNCTION #{quoted}.oban_jobs_notify() RETURNS trigger AS $$ 30 | DECLARE 31 | channel text; 32 | notice json; 33 | BEGIN 34 | IF NEW.state = 'available' THEN 35 | channel = '#{escaped}.oban_insert'; 36 | notice = json_build_object('queue', NEW.queue); 37 | 38 | PERFORM pg_notify(channel, notice::text); 39 | END IF; 40 | 41 | RETURN NULL; 42 | END; 43 | $$ LANGUAGE plpgsql; 44 | """ 45 | 46 | execute """ 47 | CREATE TRIGGER oban_notify 48 | AFTER INSERT ON #{quoted}.oban_jobs 49 | FOR EACH ROW EXECUTE PROCEDURE #{quoted}.oban_jobs_notify(); 50 | """ 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/oban/migrations/postgres/v13.ex: -------------------------------------------------------------------------------- 1 | defmodule Oban.Migrations.Postgres.V13 do 2 | @moduledoc false 3 | 4 | use Ecto.Migration 5 | 6 | def up(%{prefix: prefix}) do 7 | create_if_not_exists index(:oban_jobs, [:state, :cancelled_at], prefix: prefix) 8 | create_if_not_exists index(:oban_jobs, [:state, :discarded_at], prefix: prefix) 9 | end 10 | 11 | def down(%{prefix: prefix}) do 12 | drop_if_exists index(:oban_jobs, [:state, :discarded_at], prefix: prefix) 13 | drop_if_exists index(:oban_jobs, [:state, :cancelled_at], prefix: prefix) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/oban/migrations/sqlite.ex: -------------------------------------------------------------------------------- 1 | defmodule Oban.Migrations.SQLite do 2 | @moduledoc false 3 | 4 | @behaviour Oban.Migration 5 | 6 | use Ecto.Migration 7 | 8 | @impl Oban.Migration 9 | def up(_opts) do 10 | create_if_not_exists table(:oban_jobs, primary_key: false) do 11 | add :id, :bigserial, primary_key: true 12 | add :state, :text, null: false, default: "available" 13 | add :queue, :text, null: false, default: "default" 14 | add :worker, :text, null: false 15 | add :args, :json, null: false, default: %{} 16 | add :meta, :json, null: false, default: %{} 17 | add :tags, :json, null: false, default: [] 18 | add :errors, :json, null: false, default: [] 19 | add :attempt, :integer, null: false, default: 0 20 | add :max_attempts, :integer, null: false, default: 20 21 | add :priority, :integer, null: false, default: 0 22 | 23 | add :inserted_at, :utc_datetime_usec, null: false, default: fragment("CURRENT_TIMESTAMP") 24 | add :scheduled_at, :utc_datetime_usec, null: false, default: fragment("CURRENT_TIMESTAMP") 25 | 26 | add :attempted_at, :utc_datetime_usec 27 | add :attempted_by, :json, null: false, default: [] 28 | 29 | add :cancelled_at, :utc_datetime_usec 30 | add :completed_at, :utc_datetime_usec 31 | add :discarded_at, :utc_datetime_usec 32 | end 33 | 34 | create_if_not_exists index(:oban_jobs, [:state, :queue, :priority, :scheduled_at, :id]) 35 | 36 | :ok 37 | end 38 | 39 | @impl Oban.Migration 40 | def down(_opts) do 41 | drop_if_exists table(:oban_jobs) 42 | 43 | :ok 44 | end 45 | 46 | @impl Oban.Migration 47 | def migrated_version(_opts), do: 0 48 | end 49 | -------------------------------------------------------------------------------- /lib/oban/notifiers/isolated.ex: -------------------------------------------------------------------------------- 1 | defmodule Oban.Notifiers.Isolated do 2 | @moduledoc false 3 | 4 | @behaviour Oban.Notifier 5 | 6 | use GenServer 7 | 8 | alias Oban.Notifier 9 | 10 | defstruct [:conf, connected: true, listeners: %{}] 11 | 12 | @impl Notifier 13 | def start_link(opts) do 14 | {name, opts} = Keyword.pop(opts, :name) 15 | 16 | GenServer.start_link(__MODULE__, struct!(__MODULE__, opts), name: name) 17 | end 18 | 19 | @impl Notifier 20 | def listen(server, channels) do 21 | GenServer.call(server, {:listen, channels}) 22 | end 23 | 24 | @impl Notifier 25 | def unlisten(server, channels) do 26 | GenServer.call(server, {:unlisten, channels}) 27 | end 28 | 29 | @impl Notifier 30 | def notify(server, channel, payload) do 31 | with {:ok, %{connected: true} = state} <- GenServer.call(server, :get_state) do 32 | for {pid, channels} <- state.listeners, message <- payload, channel in channels do 33 | Notifier.relay(state.conf, [pid], channel, message) 34 | end 35 | end 36 | 37 | :ok 38 | end 39 | 40 | @impl GenServer 41 | def init(state) do 42 | {:ok, state} 43 | end 44 | 45 | @impl GenServer 46 | def handle_call(:get_state, _from, state) do 47 | {:reply, {:ok, state}, state} 48 | end 49 | 50 | def handle_call({:listen, channels}, {pid, _}, %{listeners: listeners} = state) do 51 | if Map.has_key?(listeners, pid) do 52 | {:reply, :ok, state} 53 | else 54 | Process.monitor(pid) 55 | 56 | {:reply, :ok, %{state | listeners: Map.put(listeners, pid, channels)}} 57 | end 58 | end 59 | 60 | def handle_call({:unlisten, channels}, {pid, _}, %{listeners: listeners} = state) do 61 | orig_channels = Map.get(listeners, pid, []) 62 | 63 | listeners = 64 | case orig_channels -- channels do 65 | [] -> Map.delete(listeners, pid) 66 | new_channels -> Map.put(listeners, pid, new_channels) 67 | end 68 | 69 | {:reply, :ok, %{state | listeners: listeners}} 70 | end 71 | 72 | @impl GenServer 73 | def handle_info({:DOWN, _ref, :process, pid, _reason}, state) do 74 | {:noreply, %{state | listeners: Map.delete(state.listeners, pid)}} 75 | end 76 | 77 | def handle_info(_message, state) do 78 | {:noreply, state} 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/oban/notifiers/pg.ex: -------------------------------------------------------------------------------- 1 | defmodule Oban.Notifiers.PG do 2 | @moduledoc """ 3 | A [PG (Process Groups)][pg] based notifier implementation that runs with Distributed Erlang. 4 | This notifier scales much better than `Oban.Notifiers.Postgres` but lacks its transactional 5 | guarantees. 6 | 7 | > #### Distributed Erlang {: .info} 8 | > 9 | > PG requires a functional [Distributed Erlang][de] cluster to broadcast between nodes. If your 10 | > application isn't clustered, then you should consider an alternative notifier such as 11 | > `Oban.Notifiers.Postgres` 12 | 13 | ## Usage 14 | 15 | Specify the `PG` notifier in your Oban configuration: 16 | 17 | ```elixir 18 | config :my_app, Oban, 19 | notifier: Oban.Notifiers.PG, 20 | ... 21 | ``` 22 | 23 | By default, all Oban instances using the same `prefix` option will receive notifications from 24 | each other. You can use the `namespace` option to separate instances that are in the same 25 | cluster _without_ changing the prefix: 26 | 27 | ```elixir 28 | config :my_app, Oban, 29 | notifier: {Oban.Notifiers.PG, namespace: :custom_namespace} 30 | ... 31 | ``` 32 | 33 | The namespace may be any term. 34 | 35 | [pg]: https://www.erlang.org/doc/man/pg.html 36 | [de]: https://elixir-lang.org/getting-started/mix-otp/distributed-tasks.html#our-first-distributed-code 37 | """ 38 | 39 | @behaviour Oban.Notifier 40 | 41 | use GenServer 42 | 43 | alias Oban.Notifier 44 | 45 | defstruct [:conf, :namespace, listeners: %{}] 46 | 47 | @impl Notifier 48 | def start_link(opts) do 49 | {name, opts} = Keyword.pop(opts, :name) 50 | 51 | conf = Keyword.fetch!(opts, :conf) 52 | opts = Keyword.put_new(opts, :namespace, conf.prefix) 53 | 54 | GenServer.start_link(__MODULE__, struct!(__MODULE__, opts), name: name) 55 | end 56 | 57 | @impl Notifier 58 | def listen(server, channels) do 59 | GenServer.call(server, {:listen, channels}) 60 | end 61 | 62 | @impl Notifier 63 | def unlisten(server, channels) do 64 | GenServer.call(server, {:unlisten, channels}) 65 | end 66 | 67 | @impl Notifier 68 | def notify(server, channel, payload) do 69 | with %{namespace: namespace} <- get_state(server) do 70 | pids = :pg.get_members(__MODULE__, namespace) 71 | 72 | for pid <- pids, message <- payload_to_messages(channel, payload) do 73 | send(pid, message) 74 | end 75 | 76 | :ok 77 | end 78 | end 79 | 80 | @impl GenServer 81 | def init(state) do 82 | put_state(state) 83 | 84 | :pg.start_link(__MODULE__) 85 | :pg.join(__MODULE__, state.namespace, self()) 86 | 87 | {:ok, state} 88 | end 89 | 90 | defp put_state(state) do 91 | Registry.update_value(Oban.Registry, {state.conf.name, Oban.Notifier}, fn _ -> state end) 92 | end 93 | 94 | defp get_state(server) do 95 | [name] = Registry.keys(Oban.Registry, server) 96 | 97 | case Oban.Registry.lookup(name) do 98 | {_pid, state} -> state 99 | nil -> {:error, RuntimeError.exception("no notifier running as #{inspect(name)}")} 100 | end 101 | end 102 | 103 | @impl GenServer 104 | def handle_call({:listen, channels}, {pid, _}, %{listeners: listeners} = state) do 105 | if Map.has_key?(listeners, pid) do 106 | {:reply, :ok, state} 107 | else 108 | Process.monitor(pid) 109 | 110 | {:reply, :ok, %{state | listeners: Map.put(listeners, pid, channels)}} 111 | end 112 | end 113 | 114 | def handle_call({:unlisten, channels}, {pid, _}, %{listeners: listeners} = state) do 115 | orig_channels = Map.get(listeners, pid, []) 116 | 117 | listeners = 118 | case orig_channels -- channels do 119 | [] -> Map.delete(listeners, pid) 120 | new_channels -> Map.put(listeners, pid, new_channels) 121 | end 122 | 123 | {:reply, :ok, %{state | listeners: listeners}} 124 | end 125 | 126 | @impl GenServer 127 | def handle_info({:notification, channel, payload}, state) do 128 | listeners = for {pid, channels} <- state.listeners, channel in channels, do: pid 129 | 130 | Notifier.relay(state.conf, listeners, channel, payload) 131 | 132 | {:noreply, state} 133 | end 134 | 135 | def handle_info({:DOWN, _ref, :process, pid, _reason}, state) do 136 | {:noreply, %{state | listeners: Map.delete(state.listeners, pid)}} 137 | end 138 | 139 | def handle_info(_message, state) do 140 | {:noreply, state} 141 | end 142 | 143 | ## Message Helpers 144 | 145 | defp payload_to_messages(channel, payload) do 146 | Enum.map(payload, &{:notification, channel, &1}) 147 | end 148 | end 149 | -------------------------------------------------------------------------------- /lib/oban/nursery.ex: -------------------------------------------------------------------------------- 1 | defmodule Oban.Nursery do 2 | @moduledoc false 3 | 4 | use Supervisor 5 | 6 | alias Oban.{Config, Midwife, Registry} 7 | 8 | @type opts :: [conf: Config.t(), name: GenServer.name()] 9 | 10 | @spec start_link(opts()) :: Supervisor.on_start() 11 | def start_link(opts) when is_list(opts) do 12 | Supervisor.start_link(__MODULE__, opts, name: opts[:name]) 13 | end 14 | 15 | @spec child_spec(opts()) :: Supervisor.child_spec() 16 | def child_spec(opts) do 17 | name = Keyword.fetch!(opts, :name) 18 | 19 | %{super(opts) | id: name} 20 | end 21 | 22 | @impl Supervisor 23 | def init(opts) do 24 | conf = Keyword.fetch!(opts, :conf) 25 | 26 | children = [ 27 | {DynamicSupervisor, name: Registry.via(conf.name, Foreman)}, 28 | {Midwife, conf: conf, name: Registry.via(conf.name, Midwife)} 29 | ] 30 | 31 | Supervisor.init(children, max_restarts: 5, max_seconds: 30, strategy: :rest_for_one) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/oban/peers/global.ex: -------------------------------------------------------------------------------- 1 | defmodule Oban.Peers.Global do 2 | @moduledoc """ 3 | A cluster based peer that coordinates through a distributed registry. 4 | 5 | Leadership is coordinated through global locks. It requires a functional distributed Erlang 6 | cluster, without one global plugins (Cron, Lifeline, etc.) will not function correctly. 7 | 8 | ## Usage 9 | 10 | Specify the `Global` peer in your Oban configuration. 11 | 12 | config :my_app, Oban, 13 | peer: Oban.Peers.Global, 14 | ... 15 | """ 16 | 17 | @behaviour Oban.Peer 18 | 19 | use GenServer 20 | 21 | alias Oban.{Backoff, Notifier} 22 | alias __MODULE__, as: State 23 | 24 | defstruct [ 25 | :conf, 26 | :leader, 27 | :timer, 28 | interval: :timer.seconds(30), 29 | leader?: false 30 | ] 31 | 32 | @doc false 33 | def child_spec(opts), do: super(opts) 34 | 35 | @impl Oban.Peer 36 | def start_link(opts) do 37 | {name, opts} = Keyword.pop(opts, :name) 38 | 39 | GenServer.start_link(__MODULE__, struct!(State, opts), name: name) 40 | end 41 | 42 | @impl Oban.Peer 43 | def leader?(pid, timeout \\ 5_000) do 44 | GenServer.call(pid, :leader?, timeout) 45 | end 46 | 47 | @impl Oban.Peer 48 | def get_leader(pid, timeout \\ 5_000) do 49 | GenServer.call(pid, :get_leader, timeout) 50 | end 51 | 52 | @impl GenServer 53 | def init(state) do 54 | Process.flag(:trap_exit, true) 55 | 56 | {:ok, state, {:continue, :start}} 57 | end 58 | 59 | @impl GenServer 60 | def terminate(_reason, %State{timer: timer} = state) do 61 | if is_reference(timer), do: Process.cancel_timer(timer) 62 | 63 | if state.leader? do 64 | try do 65 | delete_self(state) 66 | notify_down(state.conf) 67 | catch 68 | :exit, _reason -> :ok 69 | end 70 | end 71 | 72 | :ok 73 | end 74 | 75 | @impl GenServer 76 | def handle_continue(:start, %State{} = state) do 77 | Notifier.listen(state.conf.name, :leader) 78 | 79 | handle_info(:election, state) 80 | end 81 | 82 | @impl GenServer 83 | def handle_call(:leader?, _from, %State{} = state) do 84 | {:reply, state.leader?, state} 85 | end 86 | 87 | def handle_call(:get_leader, _from, %State{} = state) do 88 | {:reply, state.leader, state} 89 | end 90 | 91 | @impl GenServer 92 | def handle_info(:election, %State{} = state) do 93 | meta = %{conf: state.conf, leader: state.leader?, peer: __MODULE__, was_leader: nil} 94 | 95 | locked? = 96 | :telemetry.span([:oban, :peer, :election], meta, fn -> 97 | locked? = :global.set_lock(key(state), nodes(), 0) 98 | 99 | {locked?, %{meta | leader: locked?, was_leader: meta.leader}} 100 | end) 101 | 102 | if locked?, do: notify_lock(state.conf) 103 | 104 | {:noreply, schedule_election(%{state | leader?: locked?})} 105 | end 106 | 107 | def handle_info({:notification, :leader, %{"lock" => node}}, %State{} = state) do 108 | {:noreply, %{state | leader: node}} 109 | end 110 | 111 | def handle_info({:notification, :leader, %{"down" => name}}, %State{conf: conf} = state) do 112 | if name == inspect(conf.name) do 113 | handle_info(:election, state) 114 | else 115 | {:noreply, state} 116 | end 117 | end 118 | 119 | def handle_info(_message, state) do 120 | {:noreply, state} 121 | end 122 | 123 | # Helpers 124 | 125 | defp schedule_election(%State{interval: interval} = state) do 126 | time = Backoff.jitter(interval, mode: :dec) 127 | 128 | %{state | timer: Process.send_after(self(), :election, time)} 129 | end 130 | 131 | defp delete_self(state) do 132 | :global.del_lock(key(state), nodes()) 133 | end 134 | 135 | defp notify_down(conf) do 136 | Notifier.notify(conf, :leader, %{down: inspect(conf.name)}) 137 | end 138 | 139 | defp notify_lock(conf) do 140 | Notifier.notify(conf, :leader, %{lock: conf.node}) 141 | end 142 | 143 | defp key(state), do: {state.conf.name, state.conf.node} 144 | 145 | defp nodes, do: [Node.self() | Node.list()] 146 | end 147 | -------------------------------------------------------------------------------- /lib/oban/peers/isolated.ex: -------------------------------------------------------------------------------- 1 | defmodule Oban.Peers.Isolated do 2 | @moduledoc false 3 | 4 | @behaviour Oban.Peer 5 | 6 | @impl Oban.Peer 7 | def start_link(opts) do 8 | state = 9 | opts 10 | |> Keyword.put_new(:leader?, true) 11 | |> Map.new() 12 | 13 | Agent.start_link(fn -> state end, name: opts[:name]) 14 | end 15 | 16 | @impl Oban.Peer 17 | def leader?(pid, timeout \\ 5_000) do 18 | Agent.get(pid, & &1.leader?, timeout) 19 | end 20 | 21 | @impl Oban.Peer 22 | def get_leader(pid, timeout \\ 5_000) do 23 | Agent.get(pid, fn state -> if state.leader?, do: state.conf.node, else: nil end, timeout) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/oban/plugin.ex: -------------------------------------------------------------------------------- 1 | defmodule Oban.Plugin do 2 | @moduledoc """ 3 | Defines a shared behaviour for Oban plugins. 4 | 5 | In addition to implementing the Plugin behaviour, all plugins **must** be a `GenServer`, `Agent`, or 6 | another OTP compliant module. 7 | 8 | ## Example 9 | 10 | Defining a basic plugin that satisfies the minimum behaviour: 11 | 12 | defmodule MyPlugin do 13 | @behaviour Oban.Plugin 14 | 15 | use GenServer 16 | 17 | @impl Oban.Plugin 18 | def start_link(opts) do 19 | GenServer.start_link(__MODULE__, opts, name: opts[:name]) 20 | end 21 | 22 | @impl Oban.Plugin 23 | def validate(opts) do 24 | if is_atom(opts[:mode]) 25 | :ok 26 | else 27 | {:error, "expected opts to have a :mode key"} 28 | end 29 | end 30 | 31 | @impl GenServer 32 | def init(opts) do 33 | case validate(opts) do 34 | :ok -> {:ok, opts} 35 | {:error, reason} -> {:stop, reason} 36 | end 37 | end 38 | end 39 | """ 40 | 41 | alias Oban.Config 42 | 43 | @type option :: {:conf, Config.t()} | {:name, GenServer.name()} | {atom(), term()} 44 | 45 | @doc """ 46 | Starts a Plugin process linked to the current process. 47 | 48 | Plugins are typically started as part of an Oban supervision tree and will receive the current 49 | configuration as `:conf`, along with a `:name` and any other provided options. 50 | """ 51 | @callback start_link([option()]) :: GenServer.on_start() 52 | 53 | @doc """ 54 | Validate the structure, presence, or values of keyword options. 55 | """ 56 | @callback validate([option()]) :: :ok | {:error, String.t()} 57 | 58 | @doc """ 59 | Format telemetry event meta emitted by the for inclusion in the default logger. 60 | """ 61 | @callback format_logger_output(Config.t(), map()) :: map() 62 | 63 | @optional_callbacks [format_logger_output: 2] 64 | end 65 | -------------------------------------------------------------------------------- /lib/oban/plugins/gossip.ex: -------------------------------------------------------------------------------- 1 | defmodule Oban.Plugins.Gossip do 2 | @moduledoc false 3 | 4 | @behaviour Oban.Plugin 5 | 6 | use GenServer 7 | 8 | alias Oban.Plugin 9 | 10 | require Logger 11 | 12 | @impl Plugin 13 | def start_link(_opts) do 14 | Logger.warning(""" 15 | Gossip is deprecated. 16 | 17 | Gossip is no longer needed for queue monitoring in Oban Web. You can safely remove Gossip from 18 | your plugins. 19 | """) 20 | 21 | GenServer.start_link(__MODULE__, []) 22 | end 23 | 24 | @impl Plugin 25 | def validate(_opts), do: :ok 26 | 27 | @impl GenServer 28 | def init(_opts), do: :ignore 29 | end 30 | -------------------------------------------------------------------------------- /lib/oban/plugins/repeater.ex: -------------------------------------------------------------------------------- 1 | defmodule Oban.Plugins.Repeater do 2 | @moduledoc false 3 | 4 | @behaviour Oban.Plugin 5 | 6 | use GenServer 7 | 8 | alias Oban.Plugin 9 | 10 | require Logger 11 | 12 | @impl Plugin 13 | def start_link(_opts) do 14 | Logger.warning(""" 15 | Repeater is deprecated. 16 | 17 | Stager automatically forces polling when notifications aren't available. You can safely remove 18 | the Repeater from your plugins. 19 | """) 20 | 21 | GenServer.start_link(__MODULE__, []) 22 | end 23 | 24 | @impl Plugin 25 | def validate(_opts), do: :ok 26 | 27 | @impl GenServer 28 | def init(_opts), do: :ignore 29 | end 30 | -------------------------------------------------------------------------------- /lib/oban/queue/drainer.ex: -------------------------------------------------------------------------------- 1 | defmodule Oban.Queue.Drainer do 2 | @moduledoc false 3 | 4 | import Ecto.Query, only: [where: 3] 5 | 6 | alias Oban.{Config, Job, Repo} 7 | alias Oban.Queue.Executor 8 | 9 | @infinite 100_000_000 10 | 11 | def drain(%Config{} = conf, [_ | _] = opts) do 12 | Process.put(:oban_draining, true) 13 | 14 | args = 15 | opts 16 | |> Map.new() 17 | |> Map.put_new(:with_limit, @infinite) 18 | |> Map.put_new(:with_recursion, false) 19 | |> Map.put_new(:with_safety, true) 20 | |> Map.put_new(:with_scheduled, false) 21 | |> Map.update!(:queue, &to_string/1) 22 | 23 | drain(conf, %{cancelled: 0, discard: 0, failure: 0, snoozed: 0, success: 0}, args) 24 | after 25 | Process.delete(:oban_draining) 26 | end 27 | 28 | defp stage_scheduled(conf, queue, with_scheduled) do 29 | query = 30 | Job 31 | |> where([j], j.state in ["scheduled", "retryable"]) 32 | |> where([j], j.queue == ^queue) 33 | 34 | query = 35 | case with_scheduled do 36 | true -> query 37 | %DateTime{} -> where(query, [j], j.scheduled_at <= ^with_scheduled) 38 | end 39 | 40 | Repo.update_all(conf, query, set: [state: "available"]) 41 | end 42 | 43 | defp drain(conf, old_acc, %{queue: queue} = args) do 44 | if args.with_scheduled, do: stage_scheduled(conf, queue, args.with_scheduled) 45 | 46 | new_acc = 47 | conf 48 | |> fetch_available(args) 49 | |> Enum.reduce(old_acc, fn job, acc -> 50 | result = 51 | conf 52 | |> Executor.new(job, safe: args.with_safety) 53 | |> Executor.call() 54 | |> case do 55 | %{state: :exhausted} -> :discard 56 | %{state: state} -> state 57 | end 58 | 59 | Map.update(acc, result, 1, &(&1 + 1)) 60 | end) 61 | 62 | if args.with_recursion and old_acc != new_acc do 63 | drain(conf, new_acc, args) 64 | else 65 | new_acc 66 | end 67 | end 68 | 69 | defp fetch_available(conf, %{queue: queue, with_limit: limit}) do 70 | {:ok, meta} = conf.engine.init(conf, queue: queue, limit: limit) 71 | {:ok, {_meta, jobs}} = conf.engine.fetch_jobs(conf, meta, %{}) 72 | 73 | jobs 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /lib/oban/queue/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Oban.Queue.Supervisor do 2 | @moduledoc false 3 | 4 | use Supervisor 5 | 6 | alias Oban.Registry 7 | alias Oban.Queue.{Producer, Watchman} 8 | 9 | @spec start_link(Keyword.t()) :: Supervisor.on_start() 10 | def start_link(opts) when is_list(opts) do 11 | Supervisor.start_link(__MODULE__, opts, name: opts[:name]) 12 | end 13 | 14 | @spec child_spec(Keyword.t()) :: Supervisor.child_spec() 15 | def child_spec(opts) do 16 | name = Keyword.fetch!(opts, :name) 17 | 18 | %{super(opts) | id: name} 19 | end 20 | 21 | @impl Supervisor 22 | def init(opts) do 23 | conf = Keyword.fetch!(opts, :conf) 24 | queue = Keyword.fetch!(opts, :queue) 25 | 26 | fore_name = Registry.via(conf.name, {:foreman, queue}) 27 | prod_name = Registry.via(conf.name, {:producer, queue}) 28 | watch_name = Registry.via(conf.name, {:watchman, queue}) 29 | 30 | fore_opts = [name: fore_name] 31 | 32 | prod_opts = 33 | opts 34 | |> Keyword.drop([:name]) 35 | |> Keyword.merge(foreman: fore_name, name: prod_name) 36 | |> Keyword.put_new(:dispatch_cooldown, conf.dispatch_cooldown) 37 | 38 | watch_opts = [ 39 | conf: conf, 40 | name: watch_name, 41 | producer: prod_name, 42 | shutdown: conf.shutdown_grace_period 43 | ] 44 | 45 | prod_mod = Keyword.get(opts, :producer, Producer) 46 | 47 | children = [ 48 | {Task.Supervisor, fore_opts}, 49 | {prod_mod, prod_opts}, 50 | {Watchman, watch_opts} 51 | ] 52 | 53 | Supervisor.init(children, strategy: :one_for_all) 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/oban/queue/watchman.ex: -------------------------------------------------------------------------------- 1 | defmodule Oban.Queue.Watchman do 2 | @moduledoc false 3 | 4 | use GenServer 5 | 6 | alias Oban.Queue.Producer 7 | alias __MODULE__, as: State 8 | 9 | defstruct [:conf, :producer, :shutdown, interval: 10] 10 | 11 | @spec child_spec(Keyword.t()) :: Supervisor.child_spec() 12 | def child_spec(opts) do 13 | shutdown = Keyword.fetch!(opts, :shutdown) + Keyword.get(opts, :interval, 10) 14 | 15 | %{id: __MODULE__, start: {__MODULE__, :start_link, [opts]}, shutdown: shutdown} 16 | end 17 | 18 | @spec start_link(Keyword.t()) :: GenServer.on_start() 19 | def start_link(opts) do 20 | {name, opts} = Keyword.pop(opts, :name) 21 | 22 | GenServer.start_link(__MODULE__, struct!(State, opts), name: name) 23 | end 24 | 25 | @impl GenServer 26 | def init(state) do 27 | Process.flag(:trap_exit, true) 28 | 29 | {:ok, state} 30 | end 31 | 32 | @impl GenServer 33 | def terminate(_reason, %State{} = state) do 34 | # The producer may not exist, and we don't want to raise during shutdown. 35 | :ok = Producer.shutdown(state.producer) 36 | :ok = wait_for_executing(0, state) 37 | catch 38 | :exit, _reason -> :ok 39 | end 40 | 41 | defp wait_for_executing(elapsed, state) do 42 | check = Producer.check(state.producer) 43 | 44 | if check.running == [] or elapsed >= state.shutdown do 45 | :telemetry.execute( 46 | [:oban, :queue, :shutdown], 47 | %{elapsed: elapsed, ellapsed: elapsed}, 48 | %{conf: state.conf, orphaned: check.running, queue: check.queue} 49 | ) 50 | 51 | :ok 52 | else 53 | :ok = Process.sleep(state.interval) 54 | 55 | wait_for_executing(elapsed + state.interval, state) 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/oban/registry.ex: -------------------------------------------------------------------------------- 1 | defmodule Oban.Registry do 2 | @moduledoc """ 3 | Local process storage for Oban instances. 4 | """ 5 | 6 | @type role :: term() 7 | @type key :: Oban.name() | {Oban.name(), role()} 8 | @type value :: term() 9 | 10 | @doc false 11 | def child_spec(_arg) do 12 | [keys: :unique, name: __MODULE__] 13 | |> Registry.child_spec() 14 | |> Supervisor.child_spec(id: __MODULE__) 15 | end 16 | 17 | @doc """ 18 | Fetch the config for an Oban supervisor instance. 19 | 20 | ## Example 21 | 22 | Get the default instance config: 23 | 24 | Oban.Registry.config(Oban) 25 | 26 | Get config for a custom named instance: 27 | 28 | Oban.Registry.config(MyApp.Oban) 29 | """ 30 | @spec config(Oban.name()) :: Oban.Config.t() 31 | def config(oban_name) do 32 | case lookup(oban_name) do 33 | {_pid, config} -> 34 | config 35 | 36 | _ -> 37 | raise RuntimeError, """ 38 | No Oban instance named `#{inspect(oban_name)}` is running and config isn't available. 39 | """ 40 | end 41 | end 42 | 43 | @doc """ 44 | Find the `{pid, value}` pair for a registered Oban process. 45 | 46 | ## Example 47 | 48 | Get the default instance config: 49 | 50 | Oban.Registry.lookup(Oban) 51 | 52 | Get a supervised module's pid: 53 | 54 | Oban.Registry.lookup(Oban, Oban.Notifier) 55 | """ 56 | @spec lookup(Oban.name(), role()) :: nil | {pid(), value()} 57 | def lookup(oban_name, role \\ nil) do 58 | __MODULE__ 59 | |> Registry.lookup(key(oban_name, role)) 60 | |> List.first() 61 | end 62 | 63 | @doc """ 64 | Select details of registered Oban processes using a full match spec. 65 | 66 | ## Example 67 | 68 | Get a list of all running Oban instances: 69 | 70 | Oban.Registry.select([{{:"$1", :_, :_}, [{:is_atom, :"$1"}], [:"$1"]}]) 71 | """ 72 | @spec select(Registry.spec()) :: [term()] 73 | def select(spec) do 74 | Registry.select(__MODULE__, spec) 75 | end 76 | 77 | @doc """ 78 | Returns the pid of a supervised Oban process, or `nil` if the process can't be found. 79 | 80 | ## Example 81 | 82 | Get the Oban supervisor's pid: 83 | 84 | Oban.Registry.whereis(Oban) 85 | 86 | Get a supervised module's pid: 87 | 88 | Oban.Registry.whereis(Oban, Oban.Notifier) 89 | 90 | Get the pid for a plugin: 91 | 92 | Oban.Registry.whereis(Oban, {:plugin, MyApp.Oban.Plugin}) 93 | 94 | Get the pid for a queue's producer: 95 | 96 | Oban.Registry.whereis(Oban, {:producer, "default"}) 97 | """ 98 | @spec whereis(Oban.name(), role()) :: pid() | {atom(), node()} | nil 99 | def whereis(oban_name, role \\ nil) do 100 | oban_name 101 | |> via(role) 102 | |> GenServer.whereis() 103 | end 104 | 105 | @doc """ 106 | Build a via tuple suitable for calls to a supervised Oban process. 107 | 108 | ## Example 109 | 110 | For an Oban supervisor: 111 | 112 | Oban.Registry.via(Oban) 113 | 114 | For a supervised module: 115 | 116 | Oban.Registry.via(Oban, Oban.Notifier) 117 | 118 | For a plugin: 119 | 120 | Oban.Registry.via(Oban, {:plugin, Oban.Plugins.Cron}) 121 | """ 122 | @spec via(Oban.name(), role(), value()) :: {:via, Registry, {__MODULE__, key()}} 123 | def via(oban_name, role \\ nil, value \\ nil) 124 | def via(oban_name, role, nil), do: {:via, Registry, {__MODULE__, key(oban_name, role)}} 125 | def via(oban_name, role, value), do: {:via, Registry, {__MODULE__, key(oban_name, role), value}} 126 | 127 | defp key(oban_name, nil), do: oban_name 128 | defp key(oban_name, role), do: {oban_name, role} 129 | end 130 | -------------------------------------------------------------------------------- /lib/oban/sonar.ex: -------------------------------------------------------------------------------- 1 | defmodule Oban.Sonar do 2 | @moduledoc false 3 | 4 | use GenServer 5 | 6 | alias Oban.Notifier 7 | alias __MODULE__, as: State 8 | 9 | require Logger 10 | 11 | defstruct [ 12 | :conf, 13 | :timer, 14 | interval: :timer.seconds(5), 15 | nodes: %{}, 16 | stale_mult: 6, 17 | status: :unknown 18 | ] 19 | 20 | @spec start_link(keyword()) :: GenServer.on_start() 21 | def start_link(opts) do 22 | {name, opts} = Keyword.pop(opts, :name) 23 | 24 | conf = Keyword.fetch!(opts, :conf) 25 | 26 | if conf.testing != :disabled do 27 | :ignore 28 | else 29 | GenServer.start_link(__MODULE__, struct!(State, opts), name: name) 30 | end 31 | end 32 | 33 | @impl GenServer 34 | def init(state) do 35 | Process.flag(:trap_exit, true) 36 | 37 | {:ok, state, {:continue, :start}} 38 | end 39 | 40 | @impl GenServer 41 | def terminate(_reason, state) do 42 | if is_reference(state.timer), do: Process.cancel_timer(state.timer) 43 | 44 | :ok 45 | end 46 | 47 | @impl GenServer 48 | def handle_continue(:start, state) do 49 | :ok = Notifier.listen(state.conf.name, :sonar) 50 | :ok = Notifier.notify(state.conf, :sonar, %{node: state.conf.node, ping: true}) 51 | 52 | {:noreply, schedule_ping(state)} 53 | end 54 | 55 | @impl GenServer 56 | def handle_call(:get_nodes, _from, state) do 57 | {:reply, Map.keys(state.nodes), state} 58 | end 59 | 60 | def handle_call(:get_status, _from, state) do 61 | {:reply, state.status, state} 62 | end 63 | 64 | def handle_call(:prune_nodes, _from, state) do 65 | state = prune_stale_nodes(state) 66 | 67 | {:reply, Map.keys(state.nodes), state} 68 | end 69 | 70 | @impl GenServer 71 | def handle_info(:ping, state) do 72 | :ok = Notifier.notify(state.conf, :sonar, %{node: state.conf.node, ping: true}) 73 | 74 | state = 75 | state 76 | |> prune_stale_nodes() 77 | |> update_status() 78 | |> schedule_ping() 79 | 80 | {:noreply, state} 81 | end 82 | 83 | def handle_info({:notification, :sonar, %{"node" => node} = payload}, state) do 84 | time = Map.get(payload, "time", System.monotonic_time(:millisecond)) 85 | 86 | state = 87 | state 88 | |> Map.update!(:nodes, &Map.put(&1, node, time)) 89 | |> update_status() 90 | 91 | {:noreply, state} 92 | end 93 | 94 | def handle_info(message, state) do 95 | Logger.warning( 96 | message: "Received unexpected message: #{inspect(message)}", 97 | source: :oban, 98 | module: __MODULE__ 99 | ) 100 | 101 | {:noreply, state} 102 | end 103 | 104 | # Helpers 105 | 106 | defp schedule_ping(state) do 107 | timer = Process.send_after(self(), :ping, state.interval) 108 | 109 | %{state | timer: timer} 110 | end 111 | 112 | defp update_status(state) do 113 | node = state.conf.node 114 | 115 | status = 116 | case Map.keys(state.nodes) do 117 | [] -> :isolated 118 | [^node] -> :solitary 119 | [_ | _] -> :clustered 120 | end 121 | 122 | if status != state.status do 123 | :telemetry.execute([:oban, :notifier, :switch], %{}, %{conf: state.conf, status: status}) 124 | end 125 | 126 | %{state | status: status} 127 | end 128 | 129 | defp prune_stale_nodes(state) do 130 | stime = System.monotonic_time(:millisecond) 131 | stale = state.interval * state.stale_mult 132 | nodes = Map.reject(state.nodes, fn {_, recorded} -> stime - recorded > stale end) 133 | 134 | %{state | nodes: nodes} 135 | end 136 | end 137 | -------------------------------------------------------------------------------- /lib/oban/stager.ex: -------------------------------------------------------------------------------- 1 | defmodule Oban.Stager do 2 | @moduledoc false 3 | 4 | use GenServer 5 | 6 | alias Oban.{Engine, Job, Notifier, Peer, Plugin, Registry, Repo} 7 | alias __MODULE__, as: State 8 | 9 | require Logger 10 | 11 | @type option :: Plugin.option() | {:interval, pos_integer()} 12 | 13 | defstruct [ 14 | :conf, 15 | :timer, 16 | interval: :timer.seconds(1), 17 | limit: 5_000, 18 | mode: :global 19 | ] 20 | 21 | @spec start_link([option()]) :: GenServer.on_start() 22 | def start_link(opts) do 23 | {name, opts} = Keyword.pop(opts, :name) 24 | 25 | conf = Keyword.fetch!(opts, :conf) 26 | 27 | if conf.stage_interval == :infinity do 28 | :ignore 29 | else 30 | state = %State{conf: conf, interval: conf.stage_interval} 31 | 32 | GenServer.start_link(__MODULE__, state, name: name) 33 | end 34 | end 35 | 36 | @impl GenServer 37 | def init(state) do 38 | Process.flag(:trap_exit, true) 39 | 40 | # Init event is essential for auto-allow and backward compatibility. 41 | :telemetry.execute([:oban, :plugin, :init], %{}, %{conf: state.conf, plugin: __MODULE__}) 42 | 43 | {:ok, schedule_staging(state)} 44 | end 45 | 46 | @impl GenServer 47 | def terminate(_reason, %State{timer: timer}) do 48 | if is_reference(timer), do: Process.cancel_timer(timer) 49 | 50 | :ok 51 | end 52 | 53 | @impl GenServer 54 | def handle_info(:stage, %State{} = state) do 55 | state = check_mode(state) 56 | meta = %{conf: state.conf, leader: Peer.leader?(state.conf), plugin: __MODULE__} 57 | 58 | :telemetry.span([:oban, :plugin], meta, fn -> 59 | case stage_and_notify(meta.leader, state) do 60 | {:ok, staged} -> 61 | {:ok, Map.merge(meta, %{staged_count: length(staged), staged_jobs: staged})} 62 | 63 | {:error, error} -> 64 | {:error, Map.put(meta, :error, error)} 65 | end 66 | end) 67 | 68 | {:noreply, schedule_staging(state)} 69 | end 70 | 71 | def handle_info(message, state) do 72 | Logger.warning( 73 | message: "Received unexpected message: #{inspect(message)}", 74 | source: :oban, 75 | module: __MODULE__ 76 | ) 77 | 78 | {:noreply, state} 79 | end 80 | 81 | defp stage_and_notify(true = _leader, state) do 82 | Repo.transaction(state.conf, fn -> 83 | {:ok, staged} = Engine.stage_jobs(state.conf, Job, limit: state.limit) 84 | 85 | notify_queues(state) 86 | 87 | staged 88 | end) 89 | rescue 90 | error in [DBConnection.ConnectionError, Postgrex.Error] -> {:error, error} 91 | end 92 | 93 | defp stage_and_notify(_leader, state) do 94 | if state.mode == :local, do: notify_queues(state) 95 | 96 | {:ok, []} 97 | end 98 | 99 | defp notify_queues(%{conf: conf, mode: :global}) do 100 | {:ok, queues} = Engine.check_available(conf) 101 | 102 | payload = Enum.map(queues, &%{queue: &1}) 103 | 104 | Notifier.notify(conf, :insert, payload) 105 | end 106 | 107 | defp notify_queues(%{conf: conf, mode: :local}) do 108 | match = [{{{conf.name, {:producer, :"$1"}}, :"$2", :_}, [], [{{:"$1", :"$2"}}]}] 109 | 110 | for {queue, pid} <- Registry.select(match) do 111 | send(pid, {:notification, :insert, %{"queue" => queue}}) 112 | end 113 | 114 | :ok 115 | end 116 | 117 | # Helpers 118 | 119 | defp schedule_staging(state) do 120 | timer = Process.send_after(self(), :stage, state.interval) 121 | 122 | %{state | timer: timer} 123 | end 124 | 125 | defp check_mode(state) do 126 | next_mode = 127 | case Notifier.status(state.conf) do 128 | :clustered -> :global 129 | :isolated -> :local 130 | :solitary -> if Peer.leader?(state.conf), do: :global, else: :local 131 | :unknown -> state.mode 132 | end 133 | 134 | if state.mode != next_mode do 135 | :telemetry.execute([:oban, :stager, :switch], %{}, %{conf: state.conf, mode: next_mode}) 136 | end 137 | 138 | %{state | mode: next_mode} 139 | end 140 | end 141 | -------------------------------------------------------------------------------- /test/oban/backoff_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Oban.BackoffTest do 2 | use Oban.Case, async: true 3 | 4 | use ExUnitProperties 5 | 6 | doctest Oban.Backoff 7 | 8 | alias Oban.Backoff 9 | 10 | describe "exponential/2" do 11 | property "exponential backoff is clamped within a fixed range" do 12 | maximum = Integer.pow(2, 10) * 10 13 | 14 | check all mult <- integer(1..10), 15 | attempt <- integer(1..20) do 16 | result = Backoff.exponential(attempt, mult: mult) 17 | 18 | assert result >= 2 19 | assert result <= maximum 20 | end 21 | end 22 | end 23 | 24 | describe "jitter/2" do 25 | property "jitter creates time deviations within interval" do 26 | check all mode <- one_of([:inc, :dec, :both]), 27 | mult <- float(min: 0), 28 | time <- positive_integer() do 29 | result = Backoff.jitter(time, mult: mult, mode: mode) 30 | max_diff = trunc(time * mult) 31 | 32 | assert result <= time + max_diff 33 | assert result >= time - max_diff 34 | end 35 | end 36 | end 37 | 38 | describe "with_retry/2" do 39 | test "retrying known database connection errors" do 40 | fun = fn -> raise DBConnection.ConnectionError end 41 | 42 | assert_raise DBConnection.ConnectionError, fn -> 43 | fun 44 | |> fail_first() 45 | |> Backoff.with_retry(1) 46 | end 47 | 48 | assert :ok = 49 | fun 50 | |> fail_first() 51 | |> Backoff.with_retry(2) 52 | end 53 | 54 | test "retrying caught timeout exits" do 55 | fun = fn -> exit({:timeout, {GenServer, :call, []}}) end 56 | 57 | assert fun 58 | |> fail_first() 59 | |> Backoff.with_retry(1) 60 | |> catch_exit() 61 | 62 | assert :ok = 63 | fun 64 | |> fail_first() 65 | |> Backoff.with_retry(2) 66 | end 67 | 68 | test "reraising unknown exceptions and exits" do 69 | assert_raise RuntimeError, fn -> 70 | Backoff.with_retry(fn -> raise RuntimeError end, 3) 71 | end 72 | 73 | assert catch_exit(Backoff.with_retry(fn -> exit(:normal) end, 3)) 74 | end 75 | end 76 | 77 | defp fail_first(return_fun) do 78 | ref = :counters.new(1, []) 79 | 80 | fn -> 81 | :counters.add(ref, 1, 1) 82 | 83 | case :counters.get(ref, 1) do 84 | 1 -> return_fun.() 85 | _ -> :ok 86 | end 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /test/oban/cron_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Oban.CronTest do 2 | use ExUnit.Case, async: true 3 | 4 | use ExUnitProperties 5 | 6 | alias Oban.Cron 7 | 8 | describe "interval_to_next_minute/1" do 9 | property "calculated time is always within a short future range" do 10 | check all hour <- integer(0..23), 11 | minute <- integer(0..59), 12 | second <- integer(0..59), 13 | max_runs: 1_000 do 14 | {:ok, time} = Time.new(hour, minute, second) 15 | 16 | assert Cron.interval_to_next_minute(time) in 1_000..60_000 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/oban/engines/basic_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Oban.Engines.BasicTest do 2 | use Oban.Case, async: true 3 | 4 | test "inserting jobs with a custom prefix" do 5 | name = start_supervised_oban!(prefix: "private") 6 | 7 | Oban.insert!(name, Worker.new(%{ref: 1, action: "OK"})) 8 | 9 | assert [%Job{}] = Repo.all(Job, prefix: "private") 10 | end 11 | 12 | test "inserting unique jobs with a custom prefix" do 13 | name = start_supervised_oban!(prefix: "private") 14 | opts = [unique: [period: 60, fields: [:worker]]] 15 | 16 | Oban.insert!(name, Worker.new(%{ref: 1, action: "OK"}, opts)) 17 | Oban.insert!(name, Worker.new(%{ref: 2, action: "OK"}, opts)) 18 | 19 | assert [%Job{args: %{"ref" => 1}}] = Repo.all(Job, prefix: "private") 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/oban/engines/inline_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Oban.Engines.InlineTest do 2 | use Oban.Case, async: true 3 | 4 | alias Ecto.Multi 5 | 6 | test "executing a single inserted job inline" do 7 | name = start_supervised_oban!(testing: :inline) 8 | 9 | assert {:ok, job_1} = Oban.insert(name, Worker.new(%{ref: 1, action: "OK"})) 10 | assert {:ok, job_2} = Oban.insert(name, Worker.new(%{ref: 2, action: "ERROR"})) 11 | assert {:ok, job_3} = Oban.insert(name, Worker.new(%{ref: 3, action: "SNOOZE"})) 12 | assert {:ok, job_4} = Oban.insert(name, Worker.new(%{ref: 4, action: "DISCARD"})) 13 | assert {:ok, job_5} = Oban.insert(name, Worker.new(%{ref: 5, action: "CANCEL"})) 14 | 15 | assert %{attempt: 1, completed_at: %_{}, state: "completed"} = job_1 16 | assert %{attempt: 1, scheduled_at: %_{}, errors: [_], state: "retryable"} = job_2 17 | assert %{attempt: 1, scheduled_at: %_{}, state: "scheduled"} = job_3 18 | assert %{attempt: 1, discarded_at: %_{}, state: "discarded"} = job_4 19 | assert %{attempt: 1, cancelled_at: %_{}, state: "cancelled"} = job_5 20 | 21 | assert_receive {:ok, 1} 22 | assert_receive {:error, 2} 23 | assert_receive {:snooze, 3} 24 | assert_receive {:discard, 4} 25 | assert_receive {:cancel, 5} 26 | end 27 | 28 | test "executing a job with errors raises" do 29 | name = start_supervised_oban!(testing: :inline) 30 | 31 | assert_raise RuntimeError, fn -> 32 | Oban.insert(name, Worker.new(%{ref: 1, action: "FAIL"})) 33 | end 34 | 35 | assert_raise Oban.CrashError, fn -> 36 | Oban.insert(name, Worker.new(%{ref: 1, action: "EXIT"})) 37 | end 38 | end 39 | 40 | test "executing multiple jobs inserted from a stream" do 41 | name = start_supervised_oban!(testing: :inline) 42 | 43 | stream = Stream.map(1..2, &Worker.new(%{ref: &1, action: "OK"})) 44 | 45 | assert [_job_1, _job_2] = Oban.insert_all(name, stream) 46 | 47 | assert_receive {:ok, 1} 48 | assert_receive {:ok, 2} 49 | end 50 | 51 | test "executing single jobs inserted within a multi" do 52 | name = start_supervised_oban!(testing: :inline) 53 | 54 | multi = Multi.new() 55 | multi = Oban.insert(name, multi, :job_1, Worker.new(%{ref: 1, action: "OK"})) 56 | multi = Oban.insert(name, multi, :job_2, fn _ -> Worker.new(%{ref: 2, action: "OK"}) end) 57 | 58 | assert {:ok, %{job_1: _, job_2: _}} = Repo.transaction(multi) 59 | 60 | assert_receive {:ok, 1} 61 | assert_receive {:ok, 2} 62 | end 63 | 64 | test "executing multiple jobs inserted inline" do 65 | name = start_supervised_oban!(testing: :inline) 66 | 67 | jobs_1 = for ref <- 1..2, do: Worker.new(%{ref: ref, action: "OK"}) 68 | jobs_2 = for ref <- 3..4, do: Worker.new(%{ref: ref, action: "OK"}) 69 | 70 | assert [%Job{}, %Job{}] = Oban.insert_all(name, jobs_1) 71 | assert [%Job{}, %Job{}] = Oban.insert_all(name, %{changesets: jobs_2}) 72 | 73 | assert_receive {:ok, 2} 74 | assert_receive {:ok, 4} 75 | end 76 | 77 | test "executing multiple jobs inserted within a multi" do 78 | name = start_supervised_oban!(testing: :inline) 79 | 80 | jobs_1 = for ref <- 1..2, do: Worker.new(%{ref: ref, action: "OK"}) 81 | jobs_2 = for ref <- 3..4, do: Worker.new(%{ref: ref, action: "OK"}) 82 | jobs_3 = for ref <- 5..6, do: Worker.new(%{ref: ref, action: "OK"}) 83 | 84 | multi = Multi.new() 85 | multi = Oban.insert_all(name, multi, :jobs_1, jobs_1) 86 | multi = Oban.insert_all(name, multi, :jobs_2, %{changesets: jobs_2}) 87 | multi = Oban.insert_all(name, multi, :jobs_3, fn _ -> jobs_3 end) 88 | 89 | assert {:ok, %{jobs_1: _, jobs_2: _, jobs_3: _}} = Repo.transaction(multi) 90 | 91 | assert_receive {:ok, 2} 92 | assert_receive {:ok, 4} 93 | assert_receive {:ok, 6} 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /test/oban/migrations/lite_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Oban.Migrations.SQLiteTest do 2 | use Oban.Case, async: true 3 | 4 | defmodule MigrationRepo do 5 | @moduledoc false 6 | 7 | use Ecto.Repo, otp_app: :oban, adapter: Ecto.Adapters.SQLite3 8 | 9 | alias Oban.Test.LiteRepo 10 | 11 | def init(_, _) do 12 | {:ok, Keyword.put(LiteRepo.config(), :database, "priv/migration.db")} 13 | end 14 | end 15 | 16 | @moduletag :lite 17 | 18 | defmodule Migration do 19 | use Ecto.Migration 20 | 21 | def up do 22 | Oban.Migration.up() 23 | end 24 | 25 | def down do 26 | Oban.Migration.down() 27 | end 28 | end 29 | 30 | test "migrating a sqlite database" do 31 | start_supervised!(MigrationRepo) 32 | 33 | MigrationRepo.__adapter__().storage_up(MigrationRepo.config()) 34 | 35 | assert :ok = Ecto.Migrator.up(MigrationRepo, 1, Migration) 36 | assert table_exists?() 37 | 38 | assert :ok = Ecto.Migrator.down(MigrationRepo, 1, Migration) 39 | refute table_exists?() 40 | end 41 | 42 | defp table_exists? do 43 | query = """ 44 | SELECT EXISTS ( 45 | SELECT 1 46 | FROM sqlite_master 47 | WHERE type='table' 48 | AND name='oban_jobs' 49 | ) 50 | """ 51 | 52 | {:ok, %{rows: [[exists]]}} = MigrationRepo.query(query) 53 | 54 | exists != 0 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /test/oban/migrations/myxql_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Oban.Migrations.MyXQLTest do 2 | use Oban.Case, async: true 3 | 4 | defmodule MigrationRepo do 5 | @moduledoc false 6 | 7 | use Ecto.Repo, otp_app: :oban, adapter: Ecto.Adapters.MyXQL 8 | 9 | alias Oban.Test.DolphinRepo 10 | 11 | def init(_, _) do 12 | {:ok, Keyword.put(DolphinRepo.config(), :database, "oban_migrations")} 13 | end 14 | end 15 | 16 | @moduletag :lite 17 | 18 | defmodule Migration do 19 | use Ecto.Migration 20 | 21 | def up do 22 | Oban.Migration.up() 23 | end 24 | 25 | def down do 26 | Oban.Migration.down() 27 | end 28 | end 29 | 30 | test "migrating a mysql database" do 31 | MigrationRepo.__adapter__().storage_up(MigrationRepo.config()) 32 | 33 | start_supervised!(MigrationRepo) 34 | 35 | assert :ok = Ecto.Migrator.up(MigrationRepo, 1, Migration) 36 | assert table_exists?("oban_jobs") 37 | assert table_exists?("oban_peers") 38 | 39 | assert :ok = Ecto.Migrator.down(MigrationRepo, 1, Migration) 40 | refute table_exists?("oban_jobs") 41 | refute table_exists?("oban_peers") 42 | after 43 | MigrationRepo.__adapter__().storage_down(MigrationRepo.config()) 44 | end 45 | 46 | defp table_exists?(name) do 47 | query = """ 48 | SELECT EXISTS ( 49 | SELECT 1 50 | FROM information_schema.TABLES 51 | WHERE TABLE_SCHEMA = 'oban_migrations' 52 | AND TABLE_NAME = '#{name}' 53 | ) 54 | """ 55 | 56 | {:ok, %{rows: [[exists]]}} = MigrationRepo.query(query) 57 | 58 | exists != 0 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /test/oban/notifier_test.exs: -------------------------------------------------------------------------------- 1 | for notifier <- [Oban.Notifiers.Isolated, Oban.Notifiers.PG, Oban.Notifiers.Postgres] do 2 | defmodule Module.concat(notifier, Test) do 3 | use Oban.Case, async: notifier != Oban.Notifiers.Postgres 4 | 5 | alias Ecto.Adapters.SQL.Sandbox 6 | alias Oban.{Config, Notifier} 7 | 8 | @notifier notifier 9 | 10 | describe "with #{inspect(notifier)}" do 11 | test "broadcasting notifications to subscribers" do 12 | unboxed_run(fn -> 13 | name = start_supervised_oban!(notifier: @notifier) 14 | 15 | await_joined() 16 | 17 | :ok = Notifier.listen(name, :signal) 18 | :ok = Notifier.notify(name, :signal, %{incoming: "message"}) 19 | 20 | assert_receive {:notification, :signal, %{"incoming" => "message"}} 21 | end) 22 | end 23 | 24 | test "returning an error without a live notifier process" do 25 | conf = Config.new(name: make_ref(), repo: Repo, notifier: @notifier) 26 | 27 | assert {:error, %RuntimeError{}} = Notifier.notify(conf, :signal, %{}) 28 | end 29 | 30 | test "notifying with complex types" do 31 | unboxed_run(fn -> 32 | name = start_supervised_oban!(notifier: @notifier) 33 | 34 | Notifier.listen(name, [:insert, :gossip, :signal]) 35 | 36 | Notifier.notify(name, :signal, %{ 37 | date: ~D[2021-08-09], 38 | keyword: [a: 1, b: 1], 39 | map: %{tuple: {1, :second}}, 40 | tuple: {1, :second} 41 | }) 42 | 43 | assert_receive {:notification, :signal, notice} 44 | assert %{"date" => "2021-08-09", "keyword" => [["a", 1], ["b", 1]]} = notice 45 | assert %{"map" => %{"tuple" => [1, "second"]}, "tuple" => [1, "second"]} = notice 46 | end) 47 | end 48 | 49 | test "broadcasting on select channels" do 50 | unboxed_run(fn -> 51 | name = start_supervised_oban!(notifier: @notifier) 52 | 53 | await_joined() 54 | 55 | :ok = Notifier.listen(name, [:signal, :gossip]) 56 | :ok = Notifier.unlisten(name, [:gossip]) 57 | 58 | :ok = Notifier.notify(name, :gossip, %{foo: "bar"}) 59 | :ok = Notifier.notify(name, :signal, %{baz: "bat"}) 60 | 61 | assert_receive {:notification, :signal, _} 62 | refute_received {:notification, :gossip, _} 63 | end) 64 | end 65 | 66 | test "ignoring messages scoped to other instances" do 67 | unboxed_run(fn -> 68 | name = start_supervised_oban!(notifier: @notifier) 69 | 70 | await_joined() 71 | 72 | :ok = Notifier.listen(name, [:gossip, :signal]) 73 | 74 | ident = 75 | name 76 | |> Oban.config() 77 | |> Config.to_ident() 78 | 79 | :ok = Notifier.notify(name, :gossip, %{foo: "bar", ident: ident}) 80 | :ok = Notifier.notify(name, :signal, %{foo: "baz", ident: "bogus.ident"}) 81 | 82 | assert_receive {:notification, :gossip, _} 83 | refute_received {:notification, :signal, _} 84 | end) 85 | end 86 | end 87 | 88 | if @notifier == Oban.Notifiers.PG do 89 | defp await_joined do 90 | case :pg.get_local_members(Oban.Notifiers.PG, "public") do 91 | [] -> await_joined() 92 | _ -> :ok 93 | end 94 | end 95 | else 96 | defp await_joined, do: :ok 97 | end 98 | 99 | if @notifier == Oban.Notifiers.Postgres do 100 | defp unboxed_run(fun), do: Sandbox.unboxed_run(Repo, fun) 101 | else 102 | defp unboxed_run(fun), do: fun.() 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /test/oban/notifiers/pg_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Oban.Notifiers.PGTest do 2 | use Oban.Case, async: true 3 | 4 | alias Oban.Notifier 5 | alias Oban.Notifiers.PG 6 | 7 | describe "namespacing" do 8 | test "namespacing by configured prefix without an override" do 9 | name_1 = start_supervised_oban!(notifier: PG, prefix: "pg_test") 10 | name_2 = start_supervised_oban!(notifier: PG, prefix: "pg_test") 11 | 12 | await_joined("pg_test") 13 | 14 | :ok = Notifier.listen(name_1, :signal) 15 | :ok = Notifier.notify(name_2, :signal, %{incoming: "message"}) 16 | 17 | assert_receive {:notification, :signal, %{"incoming" => "message"}} 18 | end 19 | 20 | test "overriding the default namespace" do 21 | name_1 = start_supervised_oban!(notifier: {PG, namespace: :pg_test}, prefix: "pg_a") 22 | name_2 = start_supervised_oban!(notifier: {PG, namespace: :pg_test}, prefix: "pg_b") 23 | 24 | await_joined(:pg_test) 25 | 26 | :ok = Notifier.listen(name_1, :signal) 27 | :ok = Notifier.notify(name_2, :signal, %{incoming: "message"}) 28 | 29 | assert_receive {:notification, :signal, %{"incoming" => "message"}} 30 | end 31 | end 32 | 33 | defp await_joined(group) do 34 | # We can't use monitor_scope/1 because it's only available as of OTP 25. This does the trick 35 | # for now. 36 | case :pg.get_local_members(PG, group) do 37 | [] -> await_joined(group) 38 | _ -> :ok 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /test/oban/peer_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Oban.PeerTest do 2 | use Oban.Case 3 | 4 | alias Oban.{Peer, Registry} 5 | 6 | describe "configuration" do 7 | test "leadership is disabled when peer is false" do 8 | name = start_supervised_oban!(peer: false) 9 | 10 | refute Peer.leader?(name) 11 | end 12 | 13 | test "leadership is disabled along with plugins" do 14 | name = start_supervised_oban!(peer: nil, plugins: false) 15 | 16 | refute Peer.leader?(name) 17 | end 18 | end 19 | 20 | describe "compatibility" do 21 | @tag :dolphin 22 | test "maintaining leadership using the Dolphin engine" do 23 | name = 24 | start_supervised_oban!( 25 | engine: Oban.Engines.Dolphin, 26 | node: "web.1", 27 | peer: Oban.Peers.Database, 28 | repo: Oban.Test.DolphinRepo 29 | ) 30 | 31 | assert Peer.leader?(name) 32 | assert "web.1" == Peer.get_leader(name) 33 | 34 | # Force a second election after the peer is leader 35 | assert %{leader?: true} = 36 | name 37 | |> Oban.Registry.whereis(Oban.Peer) 38 | |> GenServer.call(:election) 39 | end 40 | end 41 | 42 | for peer <- [Oban.Peers.Global, Oban.Peers.Database] do 43 | @peer peer 44 | 45 | describe "using #{peer}" do 46 | test "forwarding opts to peer instances" do 47 | assert_raise RuntimeError, ~r/key :unknown not found/, fn -> 48 | start_supervised_oban!(peer: {@peer, unknown: :option}) 49 | end 50 | end 51 | 52 | test "a single node acquires leadership" do 53 | name = start_supervised_oban!(peer: @peer, node: "web.1") 54 | 55 | assert Peer.leader?(name) 56 | assert "web.1" == Peer.get_leader(name) 57 | end 58 | 59 | test "leadership transfers to another peer when the leader exits" do 60 | name = start_supervised_oban!(plugins: false) 61 | conf = %{Oban.config(name) | peer: {@peer, []}} 62 | 63 | peer_a = start_supervised!({Peer, conf: conf, name: Peer.A}) 64 | 65 | Process.sleep(50) 66 | 67 | peer_b = start_supervised!({Peer, conf: conf, name: Peer.B}) 68 | 69 | assert @peer.leader?(peer_a) 70 | 71 | stop_supervised(Peer.A) 72 | 73 | with_backoff([sleep: 10, total: 30], fn -> 74 | assert @peer.leader?(peer_b) 75 | end) 76 | end 77 | 78 | @tag :capture_log 79 | test "leadership checks return false after a timeout" do 80 | name = start_supervised_oban!(peer: @peer) 81 | 82 | assert Peer.leader?(name) 83 | 84 | name 85 | |> Registry.whereis(Peer) 86 | |> :sys.suspend() 87 | 88 | refute Peer.leader?(name, 10) 89 | refute Peer.get_leader(name, 10) 90 | end 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /test/oban/peers/database_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Oban.Peers.DatabaseTest do 2 | use Oban.Case 3 | 4 | import ExUnit.CaptureLog 5 | 6 | alias Oban.Peer 7 | alias Oban.Peers.Database 8 | alias Oban.TelemetryHandler 9 | alias Oban.Test.DolphinRepo 10 | 11 | test "enforcing a single leader with the Basic engine" do 12 | name = start_supervised_oban!(peer: false) 13 | conf = Oban.config(name) 14 | 15 | assert [_leader] = 16 | [A, B, C] 17 | |> Enum.map(&start_supervised_peer!(conf, &1)) 18 | |> Enum.filter(&Database.leader?/1) 19 | end 20 | 21 | @tag :dolphin 22 | test "enforcing a single leader with the Dolphin engine" do 23 | name = start_supervised_oban!(engine: Oban.Engines.Dolphin, peer: false, repo: DolphinRepo) 24 | conf = Oban.config(name) 25 | 26 | assert [_leader] = 27 | [A, B, C] 28 | |> Enum.map(&start_supervised_peer!(conf, &1)) 29 | |> Enum.filter(&Database.leader?/1) 30 | end 31 | 32 | defp start_supervised_peer!(conf, name) do 33 | node = "web.#{name}" 34 | conf = %{conf | node: node, peer: {Database, []}} 35 | 36 | start_supervised!({Peer, conf: conf, name: name}) 37 | end 38 | 39 | test "dispatching leadership election events" do 40 | TelemetryHandler.attach_events() 41 | 42 | start_supervised_oban!(peer: Database) 43 | 44 | assert_receive {:event, [:election, :start], _measure, 45 | %{leader: false, peer: Database, was_leader: nil}} 46 | 47 | assert_receive {:event, [:election, :stop], _measure, 48 | %{leader: true, peer: Database, was_leader: false}} 49 | end 50 | 51 | test "gracefully handling a missing oban_peers table" do 52 | mangle_peers_table!() 53 | 54 | logged = 55 | capture_log(fn -> 56 | name = start_supervised_oban!(peer: Database) 57 | conf = Oban.config(name) 58 | 59 | start_supervised!({Peer, conf: conf, name: Peer}) 60 | 61 | refute Database.leader?(Peer) 62 | end) 63 | 64 | assert logged =~ "leadership is disabled" 65 | after 66 | reform_peers_table!() 67 | end 68 | 69 | defp mangle_peers_table! do 70 | Repo.query!("ALTER TABLE oban_peers RENAME TO oban_reeps") 71 | end 72 | 73 | defp reform_peers_table! do 74 | Repo.query!("ALTER TABLE oban_reeps RENAME TO oban_peers") 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /test/oban/peers/global_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Oban.Peers.GlobalTest do 2 | use Oban.Case, async: true 3 | 4 | alias Oban.Peer 5 | alias Oban.Peers.Global 6 | alias Oban.TelemetryHandler 7 | 8 | test "only a single peer is leader" do 9 | # Start an instance just to provide a notifier under the Oban name 10 | start_supervised_oban!(name: Oban, peer: false) 11 | 12 | name_1 = start_supervised_oban!(peer: Global, node: "worker.1") 13 | name_2 = start_supervised_oban!(peer: Global, node: "worker.2") 14 | 15 | conf_1 = %{Oban.config(name_1) | name: Oban} 16 | conf_2 = %{Oban.config(name_2) | name: Oban} 17 | 18 | peer_1 = start_supervised!({Peer, conf: conf_1, name: A}) 19 | peer_2 = start_supervised!({Peer, conf: conf_2, name: B}) 20 | 21 | assert [_pid] = Enum.filter([peer_1, peer_2], &Global.leader?/1) 22 | end 23 | 24 | test "leadership changes when a peer terminates" do 25 | start_supervised_oban!(name: Oban, peer: false) 26 | 27 | :ok = Oban.Notifier.listen(Oban, :leader) 28 | 29 | conf = 30 | [peer: false, node: "worker.1"] 31 | |> start_supervised_oban!() 32 | |> Oban.config() 33 | |> Map.put(:peer, {Global, []}) 34 | 35 | peer_1 = start_supervised!({Peer, name: A, conf: %{conf | name: Oban, node: "web.1"}}) 36 | peer_2 = start_supervised!({Peer, name: B, conf: %{conf | name: Oban, node: "web.2"}}) 37 | 38 | {leader, name} = 39 | Enum.find([{peer_1, A}, {peer_2, B}], fn {pid, _name} -> 40 | Global.leader?(pid) 41 | end) 42 | 43 | stop_supervised!(name) 44 | 45 | assert_receive {:notification, :leader, %{"down" => _}} 46 | 47 | with_backoff(fn -> 48 | assert Enum.find([peer_1, peer_2] -- [leader], &Global.leader?/1) 49 | end) 50 | end 51 | 52 | test "emitting telemetry events for elections" do 53 | TelemetryHandler.attach_events() 54 | 55 | start_supervised_oban!(peer: Global, node: "worker.1") 56 | 57 | assert_receive {:event, [:election, :start], _measure, 58 | %{leader: _, peer: Oban.Peers.Global, was_leader: nil}} 59 | 60 | assert_receive {:event, [:election, :stop], _measure, 61 | %{leader: _, peer: Oban.Peers.Global, was_leader: false}} 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /test/oban/peers/isolated_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Oban.Peers.IsolatedTest do 2 | use Oban.Case, async: true 3 | 4 | describe "get_leader/2" do 5 | test "returning the current node only when leader" do 6 | name = start_supervised_oban!(peer: {Oban.Peers.Isolated, leader?: false}) 7 | 8 | refute Oban.Peer.leader?(name) 9 | refute Oban.Peer.get_leader(name) 10 | 11 | name = start_supervised_oban!(peer: {Oban.Peers.Isolated, leader?: true}) 12 | 13 | assert Oban.Peer.leader?(name) 14 | assert Oban.Peer.get_leader(name) 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/oban/plugins/gossip_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Oban.Plugins.GossipTest do 2 | use Oban.Case, async: true 3 | 4 | import ExUnit.CaptureLog 5 | 6 | test "ignoring plugin startup" do 7 | assert capture_log(fn -> 8 | start_supervised_oban!(plugins: [Oban.Plugins.Gossip]) 9 | end) =~ "Gossip is deprecated" 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/oban/plugins/lifeline_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Oban.Plugins.LifelineTest do 2 | use Oban.Case, async: true 3 | 4 | alias Oban.Plugins.Lifeline 5 | alias Oban.TelemetryHandler 6 | alias Oban.Test.DolphinRepo 7 | 8 | describe "validate/1" do 9 | test "validating interval options" do 10 | assert {:error, _} = Lifeline.validate(interval: 0) 11 | assert {:error, _} = Lifeline.validate(rescue_after: 0) 12 | 13 | assert :ok = Lifeline.validate(interval: :timer.seconds(30)) 14 | assert :ok = Lifeline.validate(rescue_after: :timer.minutes(30)) 15 | end 16 | 17 | test "providing suggestions for unknown options" do 18 | assert {:error, "unknown option :inter, did you mean :interval?"} = 19 | Lifeline.validate(inter: 1) 20 | end 21 | end 22 | 23 | describe "integration" do 24 | setup do 25 | TelemetryHandler.attach_events() 26 | end 27 | 28 | test "rescuing executing jobs older than the rescue window" do 29 | name = start_supervised_oban!(plugins: [{Lifeline, rescue_after: 5_000}]) 30 | 31 | job_1 = insert!(%{}, state: "executing", attempted_at: seconds_ago(3)) 32 | job_2 = insert!(%{}, state: "executing", attempted_at: seconds_ago(7)) 33 | job_3 = insert!(%{}, state: "executing", attempted_at: seconds_ago(8), attempt: 20) 34 | 35 | send_rescue(name) 36 | 37 | assert_receive {:event, :start, _measure, %{plugin: Lifeline}} 38 | assert_receive {:event, :stop, _measure, %{plugin: Lifeline} = meta} 39 | 40 | assert %{rescued_jobs: [_]} = meta 41 | assert %{discarded_jobs: [_]} = meta 42 | 43 | assert %{state: "executing"} = Repo.reload(job_1) 44 | assert %{state: "available"} = Repo.reload(job_2) 45 | assert %{state: "discarded"} = Repo.reload(job_3) 46 | 47 | stop_supervised(name) 48 | end 49 | 50 | test "rescuing jobs within a custom prefix" do 51 | name = start_supervised_oban!(prefix: "private", plugins: [{Lifeline, rescue_after: 5_000}]) 52 | 53 | job_1 = insert!(name, %{}, state: "executing", attempted_at: seconds_ago(1)) 54 | job_2 = insert!(name, %{}, state: "executing", attempted_at: seconds_ago(7)) 55 | 56 | send_rescue(name) 57 | 58 | assert_receive {:event, :stop, _meta, %{plugin: Lifeline, rescued_jobs: [_]}} 59 | 60 | assert %{state: "executing"} = Repo.reload(job_1) 61 | assert %{state: "available"} = Repo.reload(job_2) 62 | 63 | stop_supervised(name) 64 | end 65 | end 66 | 67 | describe "compatibility" do 68 | setup do 69 | TelemetryHandler.attach_events() 70 | end 71 | 72 | @tag :dolphin 73 | test "rescuing stuck jobs using the Dolphin engine" do 74 | name = 75 | start_supervised_oban!( 76 | engine: Oban.Engines.Dolphin, 77 | plugins: [{Lifeline, rescue_after: 5_000}], 78 | repo: DolphinRepo 79 | ) 80 | 81 | job_1 = 82 | Oban.insert!(name, Worker.new(%{}, state: "executing", attempted_at: seconds_ago(3))) 83 | 84 | job_2 = 85 | Oban.insert!(name, Worker.new(%{}, state: "executing", attempted_at: seconds_ago(8))) 86 | 87 | send_rescue(name) 88 | 89 | assert_receive {:event, :stop, _meta, %{plugin: Lifeline, rescued_jobs: [_]}} 90 | 91 | assert %{state: "executing"} = DolphinRepo.reload(job_1) 92 | assert %{state: "available"} = DolphinRepo.reload(job_2) 93 | 94 | stop_supervised(name) 95 | end 96 | end 97 | 98 | defp send_rescue(name) do 99 | name 100 | |> Oban.Registry.whereis({:plugin, Lifeline}) 101 | |> send(:rescue) 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /test/oban/plugins/pruner_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Oban.Plugins.PrunerTest do 2 | use Oban.Case, async: true 3 | 4 | import Ecto.Query 5 | 6 | alias Oban.Plugins.Pruner 7 | alias Oban.TelemetryHandler 8 | 9 | describe "validate/1" do 10 | test "validating numerical values" do 11 | assert {:error, _} = Pruner.validate(interval: 0) 12 | assert {:error, _} = Pruner.validate(max_age: 0) 13 | assert {:error, _} = Pruner.validate(limit: 0) 14 | 15 | assert :ok = Pruner.validate(interval: :timer.seconds(30)) 16 | assert :ok = Pruner.validate(max_age: 60) 17 | assert :ok = Pruner.validate(limit: 1_000) 18 | end 19 | 20 | test "providing suggestions for unknown options" do 21 | assert {:error, "unknown option :inter, did you mean :interval?"} = 22 | Pruner.validate(inter: 1) 23 | end 24 | end 25 | 26 | describe "integration" do 27 | test "historic jobs are pruned when they are older than the configured age" do 28 | TelemetryHandler.attach_events() 29 | 30 | %Job{id: _id_} = insert_historical("cancelled", :cancelled_at, 61, 61) 31 | %Job{id: _id_} = insert_historical("cancelled", :cancelled_at, 61, 59) 32 | %Job{id: _id_} = insert_historical("discarded", :discarded_at, 61, 61) 33 | %Job{id: _id_} = insert_historical("discarded", :discarded_at, 61, 59) 34 | %Job{id: _id_} = insert_historical("completed", :completed_at, 61, 61) 35 | %Job{id: _id_} = insert_historical("completed", :completed_at, 59, 61) 36 | 37 | %Job{id: id_1} = insert_historical("cancelled", :cancelled_at, 59, 61) 38 | %Job{id: id_2} = insert_historical("cancelled", :cancelled_at, 59, 59) 39 | %Job{id: id_3} = insert_historical("discarded", :discarded_at, 59, 61) 40 | %Job{id: id_4} = insert_historical("discarded", :discarded_at, 59, 59) 41 | %Job{id: id_5} = insert_historical("completed", :completed_at, 59, 59) 42 | %Job{id: id_6} = insert_historical("completed", :completed_at, 61, 59) 43 | 44 | start_supervised_oban!(plugins: [{Pruner, interval: 10, max_age: 60}]) 45 | 46 | assert_receive {:event, :stop, _, %{plugin: Pruner} = meta} 47 | assert %{pruned_count: 6, pruned_jobs: [_ | _]} = meta 48 | 49 | assert [id_1, id_2, id_3, id_4, id_5, id_6] == 50 | Job 51 | |> select([j], j.id) 52 | |> order_by(asc: :id) 53 | |> Repo.all() 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /test/oban/plugins/reindexer_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Oban.Plugins.ReindexerTest do 2 | use Oban.Case, async: true 3 | 4 | alias Oban.Plugins.Reindexer 5 | alias Oban.{Registry, TelemetryHandler} 6 | 7 | describe "validate/1" do 8 | test "validating that schedule is a valid cron expression" do 9 | assert {:error, _} = Reindexer.validate(schedule: "-1 * * * *") 10 | 11 | assert :ok = Reindexer.validate(schedule: "0 0 * * *") 12 | end 13 | 14 | test "validating that :indexes are a list of index strings" do 15 | assert {:error, _} = Reindexer.validate(indexes: "") 16 | assert {:error, _} = Reindexer.validate(indexes: [:index]) 17 | assert {:error, _} = Reindexer.validate(indexes: ["index", :index]) 18 | 19 | assert :ok = Reindexer.validate(indexes: ["oban_jobs_some_index"]) 20 | end 21 | 22 | test "validating that :timezone is a known timezone" do 23 | assert {:error, _} = Reindexer.validate(timezone: "") 24 | assert {:error, _} = Reindexer.validate(timezone: nil) 25 | assert {:error, _} = Reindexer.validate(timezone: "america") 26 | assert {:error, _} = Reindexer.validate(timezone: "america/chicago") 27 | 28 | assert :ok = Reindexer.validate(timezone: "Etc/UTC") 29 | assert :ok = Reindexer.validate(timezone: "Europe/Copenhagen") 30 | assert :ok = Reindexer.validate(timezone: "America/Chicago") 31 | end 32 | 33 | test "validating that :timeout is a non negative integer" do 34 | assert {:error, _} = Reindexer.validate(timeout: "") 35 | assert {:error, _} = Reindexer.validate(timeout: -1) 36 | 37 | assert :ok = Reindexer.validate(timeout: :timer.minutes(1)) 38 | end 39 | 40 | test "providing suggestions for unknown options" do 41 | assert {:error, "unknown option :timeo, did you mean :timeout?"} = 42 | Reindexer.validate(timeo: 1) 43 | end 44 | end 45 | 46 | describe "integration" do 47 | @describetag :reindex 48 | 49 | setup do 50 | TelemetryHandler.attach_events() 51 | end 52 | 53 | test "reindexing with an unknown column causes an exception" do 54 | name = start_supervised_oban!(plugins: [{Reindexer, indexes: ["a"], schedule: "* * * * *"}]) 55 | 56 | name 57 | |> Registry.whereis({:plugin, Reindexer}) 58 | |> send(:reindex) 59 | 60 | assert_receive {:event, :stop, _, %{error: _, plugin: Reindexer}}, 500 61 | 62 | stop_supervised(name) 63 | end 64 | 65 | test "reindexing according to the provided schedule" do 66 | name = start_supervised_oban!(plugins: [{Reindexer, schedule: "* * * * *"}]) 67 | 68 | name 69 | |> Registry.whereis({:plugin, Reindexer}) 70 | |> send(:reindex) 71 | 72 | assert_receive {:event, :start, _, %{plugin: Reindexer}}, 500 73 | assert_receive {:event, :stop, _, %{plugin: Reindexer}}, 500 74 | 75 | stop_supervised(name) 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /test/oban/plugins/repeater_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Oban.Plugins.RepeaterTest do 2 | use Oban.Case, async: true 3 | 4 | import ExUnit.CaptureLog 5 | 6 | test "ignoring plugin startup" do 7 | assert capture_log(fn -> 8 | start_supervised_oban!(plugins: [Oban.Plugins.Repeater]) 9 | end) =~ "Repeater is deprecated" 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/oban/queue/executor_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Oban.Queue.ExecutorTest do 2 | use Oban.Case, async: true 3 | 4 | alias Oban.{CrashError, PerformError, TimeoutError} 5 | alias Oban.Queue.Executor 6 | 7 | defmodule Worker do 8 | use Oban.Worker 9 | 10 | @impl Worker 11 | def perform(%{args: %{"mode" => "ok"}}), do: :ok 12 | def perform(%{args: %{"mode" => "result"}}), do: {:ok, :result} 13 | def perform(%{args: %{"mode" => "snooze_zero"}}), do: {:snooze, 0} 14 | def perform(%{args: %{"mode" => "snooze"}}), do: {:snooze, 1} 15 | def perform(%{args: %{"mode" => "raise"}}), do: raise(ArgumentError) 16 | def perform(%{args: %{"mode" => "catch"}}), do: throw({:error, :no_reason}) 17 | def perform(%{args: %{"mode" => "error"}}), do: {:error, "no reason"} 18 | def perform(%{args: %{"mode" => "sleep"}}), do: Process.sleep(10) 19 | def perform(%{args: %{"mode" => "discard"}}), do: {:discard, :no_reason} 20 | def perform(%{args: %{"mode" => "timeout"}}), do: Process.sleep(10) 21 | 22 | @impl Worker 23 | def timeout(%_{args: %{"mode" => "timeout"}}), do: 1 24 | def timeout(_job), do: :infinity 25 | end 26 | 27 | @conf Config.new(repo: Repo) 28 | 29 | describe "perform/1" do 30 | test "accepting :ok as a success" do 31 | assert %{state: :success, result: :ok} = call_with_mode("ok") 32 | assert %{state: :success, result: {:ok, :result}} = call_with_mode("result") 33 | end 34 | 35 | test "reporting :snooze status" do 36 | assert %{state: :snoozed, snooze: 0} = call_with_mode("snooze_zero") 37 | assert %{state: :snoozed, snooze: 1} = call_with_mode("snooze") 38 | end 39 | 40 | test "reporting :discard status" do 41 | assert %{state: :discard} = call_with_mode("discard") 42 | end 43 | 44 | test "raising, catching and error tuples are failures" do 45 | assert %{state: :failure} = call_with_mode("raise") 46 | assert %{state: :failure, error: %CrashError{}} = call_with_mode("catch") 47 | assert %{state: :failure, error: %PerformError{}} = call_with_mode("error") 48 | end 49 | 50 | test "reporting a failure with exhausted retries as :exhausted" do 51 | job = %Job{args: %{"mode" => "error"}, worker: inspect(Worker), attempt: 1, max_attempts: 1} 52 | 53 | assert %{state: :exhausted, error: %PerformError{}} = exec(job) 54 | end 55 | 56 | test "reporting missing workers as a :failure" do 57 | job = %Job{args: %{}, worker: "Not.A.Real.Worker"} 58 | 59 | assert %{state: :failure, error: %RuntimeError{message: "unknown worker" <> _}} = 60 | @conf 61 | |> Executor.new(job) 62 | |> Executor.resolve_worker() 63 | |> Executor.perform() 64 | end 65 | 66 | test "reporting timeouts when a job exceeds the configured time" do 67 | Process.flag(:trap_exit, true) 68 | 69 | call_with_mode("timeout") 70 | 71 | assert_receive {:EXIT, _pid, %TimeoutError{message: message, reason: :timeout}} 72 | assert message =~ "Worker timed out after 1ms" 73 | end 74 | 75 | test "reporting duration and queue_time measurements" do 76 | now = DateTime.utc_now() 77 | 78 | job = %Job{ 79 | args: %{"mode" => "sleep"}, 80 | worker: to_string(Worker), 81 | attempted_at: DateTime.add(now, 30, :millisecond), 82 | scheduled_at: now 83 | } 84 | 85 | assert %{duration: duration, queue_time: queue_time} = 86 | @conf 87 | |> Executor.new(job) 88 | |> Executor.resolve_worker() 89 | |> Executor.perform() 90 | |> Executor.normalize_state() 91 | |> Executor.record_finished() 92 | 93 | duration_ms = System.convert_time_unit(duration, :native, :millisecond) 94 | queue_time_ms = System.convert_time_unit(queue_time, :native, :millisecond) 95 | 96 | assert duration_ms >= 10 97 | assert queue_time_ms >= 30 98 | end 99 | end 100 | 101 | defp call_with_mode(mode) do 102 | exec(%Job{args: %{"mode" => mode}, worker: to_string(Worker)}) 103 | end 104 | 105 | defp exec(job) do 106 | @conf 107 | |> Executor.new(job) 108 | |> Executor.resolve_worker() 109 | |> Executor.set_label() 110 | |> Executor.start_timeout() 111 | |> Executor.perform() 112 | |> Executor.normalize_state() 113 | |> Executor.cancel_timeout() 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /test/oban/queue/watchman_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Oban.Queue.WatchmanTest do 2 | use Oban.Case, async: true 3 | 4 | alias Oban.Queue.Watchman 5 | 6 | describe "terminate/2" do 7 | test "safely shutting down with missing processes" do 8 | opts = [ 9 | name: WatchmanTest, 10 | shutdown: 1_000, 11 | producer: Watchman.Producer 12 | ] 13 | 14 | start_supervised!({Watchman, opts}) 15 | 16 | assert :ok = stop_supervised(Watchman) 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/oban/repo_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Oban.RepoTest do 2 | use Oban.Case, async: true 3 | 4 | alias Oban.Test.DynamicRepo 5 | 6 | @moduletag :unboxed 7 | 8 | test "querying with a dynamic repo (MFA)" do 9 | {:ok, repo_pid} = start_supervised({DynamicRepo, name: nil}) 10 | 11 | DynamicRepo.put_dynamic_repo(nil) 12 | 13 | name = 14 | start_supervised_oban!( 15 | get_dynamic_repo: {DynamicRepo, :use_dynamic_repo, [repo_pid]}, 16 | repo: DynamicRepo 17 | ) 18 | 19 | conf = Oban.config(name) 20 | 21 | Oban.Repo.insert!(conf, Worker.new(%{ref: 1, action: "OK"})) 22 | end 23 | 24 | test "querying with a dynamic repo (anonymous function)" do 25 | {:ok, repo_pid} = start_supervised({DynamicRepo, name: nil}) 26 | 27 | DynamicRepo.put_dynamic_repo(nil) 28 | 29 | name = start_supervised_oban!(get_dynamic_repo: fn -> repo_pid end, repo: DynamicRepo) 30 | conf = Oban.config(name) 31 | 32 | Oban.Repo.insert!(conf, Worker.new(%{ref: 1, action: "OK"})) 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /test/oban/sonar_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Oban.SonarTest do 2 | use Oban.Case, async: true 3 | 4 | alias Oban.{Notifier, Registry, Sonar} 5 | 6 | describe "integration" do 7 | setup do 8 | :telemetry_test.attach_event_handlers(self(), [[:oban, :notifier, :switch]]) 9 | 10 | :ok 11 | end 12 | 13 | test "starting with an :unknown status" do 14 | name = start_supervised_oban!(notifier: Oban.Notifiers.Postgres) 15 | 16 | assert :unknown = Notifier.status(name) 17 | end 18 | 19 | test "remaining isolated without any notifications" do 20 | name = start_supervised_oban!(notifier: Oban.Notifiers.Postgres) 21 | 22 | name 23 | |> Registry.whereis(Sonar) 24 | |> send(:ping) 25 | 26 | with_backoff(fn -> 27 | assert :isolated = Notifier.status(name) 28 | end) 29 | 30 | assert_received {_event, _ref, _timing, %{status: :isolated}} 31 | end 32 | 33 | test "reporting a solitary status with only a single node" do 34 | name = start_supervised_oban!(notifier: Oban.Notifiers.Isolated) 35 | 36 | with_backoff(fn -> 37 | assert :solitary = Notifier.status(name) 38 | end) 39 | 40 | assert_received {_event, _ref, _timing, %{status: :solitary}} 41 | end 42 | 43 | test "reporting a clustered status with multiple nodes" do 44 | name = start_supervised_oban!(notifier: Oban.Notifiers.Isolated) 45 | 46 | Notifier.notify(name, :sonar, %{node: "web.1"}) 47 | 48 | with_backoff(fn -> 49 | assert :clustered = Notifier.status(name) 50 | end) 51 | 52 | assert_received {_event, _ref, _timing, %{status: :clustered}} 53 | end 54 | 55 | test "pruning stale nodes based on the last notification time" do 56 | name = start_supervised_oban!(notifier: Oban.Notifiers.Isolated) 57 | time = System.monotonic_time(:millisecond) 58 | 59 | Notifier.notify(name, :sonar, %{node: "web.0", time: time - :timer.seconds(10)}) 60 | Notifier.notify(name, :sonar, %{node: "web.1", time: time - :timer.seconds(29)}) 61 | Notifier.notify(name, :sonar, %{node: "web.2", time: time - :timer.seconds(31)}) 62 | 63 | nodes = 64 | name 65 | |> Registry.whereis(Sonar) 66 | |> GenServer.call(:prune_nodes) 67 | 68 | assert "web.1" in nodes 69 | refute "web.2" in nodes 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /test/oban/stager_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Oban.StagerTest do 2 | use Oban.Case, async: true 3 | 4 | alias Oban.Stager 5 | alias Oban.TelemetryHandler 6 | 7 | test "descheduling jobs to make them available for execution" do 8 | job_1 = insert!(%{}, state: "scheduled", scheduled_at: seconds_ago(10)) 9 | job_2 = insert!(%{}, state: "scheduled", scheduled_at: seconds_from_now(10)) 10 | job_3 = insert!(%{}, state: "retryable") 11 | 12 | start_supervised_oban!(stage_interval: 5, testing: :disabled) 13 | 14 | with_backoff(fn -> 15 | assert %{state: "available"} = Repo.reload(job_1) 16 | assert %{state: "scheduled"} = Repo.reload(job_2) 17 | assert %{state: "available"} = Repo.reload(job_3) 18 | end) 19 | end 20 | 21 | test "emitting telemetry data for staged jobs" do 22 | TelemetryHandler.attach_events() 23 | 24 | start_supervised_oban!(stage_interval: 5, testing: :disabled) 25 | 26 | assert_receive {:event, :stop, %{duration: _}, %{plugin: Stager} = meta} 27 | 28 | assert %{staged_count: _, staged_jobs: []} = meta 29 | end 30 | 31 | test "switching to local mode without functional pubsub" do 32 | :telemetry_test.attach_event_handlers(self(), [[:oban, :stager, :switch]]) 33 | 34 | [stage_interval: 5, notifier: {Oban.Notifiers.Isolated, connected: false}] 35 | |> start_supervised_oban!() 36 | |> ping_sonar() 37 | 38 | assert_receive {[:oban, :stager, :switch], _, %{}, %{mode: :local}} 39 | end 40 | 41 | test "switching to local mode when solitary and not the leader" do 42 | :telemetry_test.attach_event_handlers(self(), [[:oban, :stager, :switch]]) 43 | 44 | opts = [ 45 | stage_interval: 5, 46 | notifier: Oban.Notifiers.Isolated, 47 | peer: {Oban.Peers.Isolated, leader?: false} 48 | ] 49 | 50 | opts 51 | |> start_supervised_oban!() 52 | |> ping_sonar() 53 | 54 | assert_receive {[:oban, :stager, :switch], _, %{}, %{mode: :local}} 55 | end 56 | 57 | test "dispatching directly to registered producers in local mode" do 58 | name = 59 | start_supervised_oban!( 60 | stage_interval: 5, 61 | notifier: Oban.Notifiers.Isolated, 62 | peer: {Oban.Peers.Isolated, leader?: false} 63 | ) 64 | 65 | ping_sonar(name) 66 | 67 | with {:via, _, {_, prod_name}} <- Oban.Registry.via(name, {:producer, "staging_test"}) do 68 | Registry.register(Oban.Registry, prod_name, nil) 69 | end 70 | 71 | assert_receive {:notification, :insert, %{"queue" => "staging_test"}} 72 | end 73 | 74 | defp ping_sonar(name) do 75 | name 76 | |> Oban.Registry.whereis(Oban.Sonar) 77 | |> send(:ping) 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /test/support/case.ex: -------------------------------------------------------------------------------- 1 | defmodule Oban.Case do 2 | @moduledoc false 3 | 4 | use ExUnit.CaseTemplate 5 | 6 | alias Ecto.Adapters.SQL.Sandbox 7 | alias Oban.Integration.Worker 8 | alias Oban.Job 9 | alias Oban.Test.{DolphinRepo, LiteRepo, Repo, UnboxedRepo} 10 | 11 | using do 12 | quote do 13 | import Oban.Case 14 | 15 | alias Oban.Integration.Worker 16 | alias Oban.{Config, Job} 17 | alias Oban.Test.{DolphinRepo, LiteRepo, Repo, UnboxedRepo} 18 | end 19 | end 20 | 21 | setup context do 22 | cond do 23 | context[:unboxed] -> 24 | on_exit(fn -> 25 | UnboxedRepo.delete_all(Oban.Job) 26 | UnboxedRepo.delete_all(Oban.Job, prefix: "private") 27 | end) 28 | 29 | context[:lite] -> 30 | on_exit(fn -> 31 | LiteRepo.delete_all(Oban.Job) 32 | end) 33 | 34 | context[:dolphin] -> 35 | pid = Sandbox.start_owner!(DolphinRepo, shared: not context[:async]) 36 | 37 | on_exit(fn -> Sandbox.stop_owner(pid) end) 38 | 39 | true -> 40 | pid = Sandbox.start_owner!(Repo, shared: not context[:async]) 41 | 42 | on_exit(fn -> Sandbox.stop_owner(pid) end) 43 | end 44 | 45 | :ok 46 | end 47 | 48 | def start_supervised_oban!(opts) do 49 | opts = 50 | opts 51 | |> Keyword.put_new(:name, make_ref()) 52 | |> Keyword.put_new(:notifier, Oban.Notifiers.Isolated) 53 | |> Keyword.put_new(:peer, Oban.Peers.Isolated) 54 | |> Keyword.put_new(:stage_interval, :infinity) 55 | |> Keyword.put_new(:repo, Repo) 56 | |> Keyword.put_new(:shutdown_grace_period, 250) 57 | 58 | name = Keyword.fetch!(opts, :name) 59 | repo = Keyword.fetch!(opts, :repo) 60 | 61 | attach_auto_allow(repo, name) 62 | start_supervised!({Oban, opts}) 63 | ensure_started(name, opts) 64 | 65 | name 66 | end 67 | 68 | def with_backoff(opts \\ [], fun) do 69 | total = Keyword.get(opts, :total, 100) 70 | sleep = Keyword.get(opts, :sleep, 10) 71 | 72 | with_backoff(fun, 0, total, sleep) 73 | end 74 | 75 | def with_backoff(fun, count, total, sleep) do 76 | fun.() 77 | rescue 78 | exception in [ExUnit.AssertionError] -> 79 | if count < total do 80 | Process.sleep(sleep) 81 | 82 | with_backoff(fun, count + 1, total, sleep) 83 | else 84 | reraise(exception, __STACKTRACE__) 85 | end 86 | end 87 | 88 | # Building 89 | 90 | def build(args, opts \\ []) do 91 | if opts[:worker] do 92 | Job.new(args, opts) 93 | else 94 | Worker.new(args, opts) 95 | end 96 | end 97 | 98 | def insert!(args, opts \\ []) do 99 | args 100 | |> build(opts) 101 | |> Repo.insert!() 102 | end 103 | 104 | def insert!(oban, args, opts) do 105 | changeset = build(args, opts) 106 | 107 | Oban.insert!(oban, changeset) 108 | end 109 | 110 | # Pruning test helpers 111 | 112 | def insert_historical(state, timestamp_field, timestamp_age, scheduled_age) do 113 | opts = 114 | Keyword.put( 115 | [state: state, scheduled_at: seconds_ago(scheduled_age)], 116 | timestamp_field, 117 | seconds_ago(timestamp_age) 118 | ) 119 | 120 | insert!(%{}, opts) 121 | end 122 | 123 | # Time 124 | 125 | def seconds_from_now(seconds) do 126 | DateTime.add(DateTime.utc_now(), seconds, :second) 127 | end 128 | 129 | def seconds_ago(seconds) do 130 | DateTime.add(DateTime.utc_now(), -seconds) 131 | end 132 | 133 | # Attaching 134 | 135 | defp attach_auto_allow(repo, name) when repo in [Repo, DolphinRepo] do 136 | telemetry_name = "oban-auto-allow-#{inspect(name)}" 137 | 138 | auto_allow = fn _event, _measure, %{conf: conf}, {name, repo, test_pid} -> 139 | if conf.name == name, do: Sandbox.allow(repo, test_pid, self()) 140 | end 141 | 142 | :telemetry.attach_many( 143 | telemetry_name, 144 | [[:oban, :engine, :init, :start], [:oban, :plugin, :init]], 145 | auto_allow, 146 | {name, repo, self()} 147 | ) 148 | 149 | on_exit(name, fn -> :telemetry.detach(telemetry_name) end) 150 | end 151 | 152 | defp attach_auto_allow(_repo, _name), do: :ok 153 | 154 | defp ensure_started(name, opts) do 155 | opts 156 | |> Keyword.get(:queues, []) 157 | |> Keyword.keys() 158 | |> Enum.each(&Oban.check_queue(name, queue: &1)) 159 | end 160 | end 161 | -------------------------------------------------------------------------------- /test/support/exercise.ex: -------------------------------------------------------------------------------- 1 | defmodule Oban.Test.Exercise do 2 | @moduledoc false 3 | 4 | alias Ecto.Multi 5 | alias Oban.Job 6 | 7 | defmodule Used do 8 | @moduledoc false 9 | 10 | use Oban, otp_app: :oban 11 | end 12 | 13 | def check_used do 14 | changeset = changeset() 15 | 16 | {:ok, _} = Used.insert(changeset) 17 | {:ok, _} = Used.insert(changeset, timeout: 500) 18 | 19 | %Multi{} = Used.insert(Multi.new(), :job, changeset) 20 | %Multi{} = Used.insert(Multi.new(), :job, changeset, timeout: 500) 21 | end 22 | 23 | def check_insert do 24 | changeset = changeset() 25 | 26 | {:ok, _} = Oban.insert(changeset) 27 | {:ok, _} = Oban.insert(Oban, changeset) 28 | {:ok, _} = Oban.insert(changeset, timeout: 500) 29 | {:ok, _} = Oban.insert(Oban, changeset, timeout: 500) 30 | 31 | %Multi{} = Oban.insert(Multi.new(), :job, changeset) 32 | %Multi{} = Oban.insert(Multi.new(), :job, changeset, timeout: 500) 33 | %Multi{} = Oban.insert(Oban, Multi.new(), :job, changeset) 34 | %Multi{} = Oban.insert(Oban, Multi.new(), :job, changeset, timeout: 500) 35 | end 36 | 37 | def check_insert_all do 38 | changeset = changeset() 39 | stream = Stream.duplicate(changeset, 1) 40 | wrapper = %{changesets: [changeset]} 41 | 42 | [_ | _] = Oban.insert_all([changeset]) 43 | [_ | _] = Oban.insert_all(Oban, [changeset]) 44 | [_ | _] = Oban.insert_all([changeset], timeout: 500) 45 | [_ | _] = Oban.insert_all(Oban, [changeset], timeout: 500) 46 | 47 | [_ | _] = Oban.insert_all(stream) 48 | [_ | _] = Oban.insert_all(Oban, stream) 49 | [_ | _] = Oban.insert_all(stream, timeout: 500) 50 | [_ | _] = Oban.insert_all(Oban, stream, timeout: 500) 51 | 52 | [_ | _] = Oban.insert_all(wrapper) 53 | [_ | _] = Oban.insert_all(Oban, wrapper) 54 | [_ | _] = Oban.insert_all(wrapper, timeout: 500) 55 | [_ | _] = Oban.insert_all(Oban, wrapper, timeout: 500) 56 | 57 | %Multi{} = Oban.insert_all(Multi.new(), :job, [changeset]) 58 | %Multi{} = Oban.insert_all(Multi.new(), :job, [changeset], timeout: 500) 59 | %Multi{} = Oban.insert_all(Oban, Multi.new(), :job, [changeset]) 60 | %Multi{} = Oban.insert_all(Oban, Multi.new(), :job, [changeset], timeout: 500) 61 | end 62 | 63 | def check_pause_resume_all do 64 | Oban.pause_all_queues() 65 | Oban.pause_all_queues(Oban) 66 | Oban.pause_all_queues(Oban, local_only: true) 67 | Oban.pause_all_queues(local_only: true) 68 | 69 | Oban.resume_all_queues() 70 | Oban.resume_all_queues(Oban) 71 | Oban.resume_all_queues(Oban, local_only: true) 72 | Oban.resume_all_queues(local_only: true) 73 | end 74 | 75 | defp changeset, do: Job.new(%{}, worker: "FakeWorker") 76 | end 77 | -------------------------------------------------------------------------------- /test/support/myxql/migrations/20240831185338_add_oban_jobs_table.exs: -------------------------------------------------------------------------------- 1 | defmodule Oban.Test.XQLRepo.Migrations.AddObanJobsTable do 2 | use Ecto.Migration 3 | 4 | defdelegate up, to: Oban.Migration 5 | defdelegate down, to: Oban.Migration 6 | end 7 | -------------------------------------------------------------------------------- /test/support/postgres/migrations/20190210214150_add_oban_jobs_table.exs: -------------------------------------------------------------------------------- 1 | defmodule Oban.Test.Repo.Postgres.Migrations.AddObanJobsTable do 2 | use Ecto.Migration 3 | 4 | def up do 5 | Oban.Migrations.up(unlogged: false) 6 | Oban.Migrations.up(prefix: "private", unlogged: false) 7 | end 8 | 9 | def down do 10 | Oban.Migrations.down(prefix: "private", version: 1) 11 | Oban.Migrations.down(version: 1) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/support/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Oban.Test.Repo do 2 | @moduledoc false 3 | 4 | use Ecto.Repo, 5 | otp_app: :oban, 6 | adapter: Ecto.Adapters.Postgres 7 | end 8 | 9 | defmodule Oban.Test.DynamicRepo do 10 | @moduledoc false 11 | 12 | use Ecto.Repo, 13 | otp_app: :oban, 14 | adapter: Ecto.Adapters.Postgres 15 | 16 | def use_dynamic_repo(pid) do 17 | pid 18 | end 19 | 20 | def init(_, _) do 21 | {:ok, Oban.Test.Repo.config()} 22 | end 23 | end 24 | 25 | defmodule Oban.Test.LiteRepo do 26 | @moduledoc false 27 | 28 | use Ecto.Repo, otp_app: :oban, adapter: Ecto.Adapters.SQLite3 29 | end 30 | 31 | defmodule Oban.Test.DolphinRepo do 32 | @moduledoc false 33 | 34 | use Ecto.Repo, otp_app: :oban, adapter: Ecto.Adapters.MyXQL 35 | end 36 | 37 | defmodule Oban.Test.UnboxedRepo do 38 | @moduledoc false 39 | 40 | use Ecto.Repo, 41 | otp_app: :oban, 42 | adapter: Ecto.Adapters.Postgres 43 | 44 | def init(_, _) do 45 | config = Oban.Test.Repo.config() 46 | 47 | {:ok, Keyword.delete(config, :pool)} 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /test/support/sqlite/migrations/20221222130000_add_oban_jobs_table.exs: -------------------------------------------------------------------------------- 1 | defmodule Oban.Test.Repo.SQLite.Migrations.AddObanJobsTable do 2 | use Ecto.Migration 3 | 4 | defdelegate up, to: Oban.Migration 5 | defdelegate down, to: Oban.Migration 6 | end 7 | -------------------------------------------------------------------------------- /test/support/telemetry_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Oban.TelemetryHandler do 2 | @moduledoc """ 3 | This utility module is used during job tests to capture telemetry events 4 | and send them back to the test processes that registered the handler. 5 | """ 6 | 7 | import ExUnit.Callbacks, only: [on_exit: 1] 8 | 9 | @span_tail [:start, :stop, :exception] 10 | @span_type [ 11 | :job, 12 | :plugin, 13 | [:engine, :insert_job], 14 | [:engine, :insert_all_jobs], 15 | [:engine, :fetch_jobs], 16 | [:peer, :election] 17 | ] 18 | 19 | def attach_events(opts \\ []) do 20 | span_tail = Keyword.get(opts, :span_tail, @span_tail) 21 | span_type = Keyword.get(opts, :span_type, @span_type) 22 | 23 | name = name() 24 | 25 | events = 26 | for type <- span_type, tail <- span_tail do 27 | List.flatten([:oban, type, tail]) 28 | end 29 | 30 | events = [[:oban, :supervisor, :init] | events] 31 | 32 | on_exit(fn -> :telemetry.detach(name) end) 33 | 34 | :telemetry.attach_many(name, events, &handle/4, self()) 35 | end 36 | 37 | def handle([:oban, :job, :start], %{system_time: start_time}, meta, pid) do 38 | send(pid, {:event, :start, start_time, meta}) 39 | end 40 | 41 | def handle([:oban, :job, event], measure, meta, pid) do 42 | send(pid, {:event, event, measure, meta}) 43 | end 44 | 45 | def handle([:oban, :engine | event], measure, meta, pid) do 46 | send(pid, {:event, event, measure, meta}) 47 | end 48 | 49 | def handle([:oban, :plugin, event], measure, meta, pid) do 50 | send(pid, {:event, event, measure, meta}) 51 | end 52 | 53 | def handle([:oban, :peer | event], measure, meta, pid) do 54 | send(pid, {:event, event, measure, meta}) 55 | end 56 | 57 | def handle(event, measure, meta, pid) do 58 | send(pid, {:event, event, measure, meta}) 59 | end 60 | 61 | defp name, do: "handler-#{System.unique_integer([:positive])}" 62 | end 63 | -------------------------------------------------------------------------------- /test/support/worker.ex: -------------------------------------------------------------------------------- 1 | defmodule Oban.Integration.Worker do 2 | @moduledoc false 3 | 4 | use Oban.Worker, queue: :alpha 5 | 6 | @impl Worker 7 | def new(args, opts) do 8 | opts = Keyword.merge(__opts__(), opts) 9 | 10 | args 11 | |> Map.new() 12 | |> Map.put_new(:bin_pid, pid_to_bin()) 13 | |> Job.new(opts) 14 | end 15 | 16 | @impl Worker 17 | # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity 18 | def perform(%_{args: %{"ref" => ref, "action" => action, "bin_pid" => bin_pid}}) do 19 | pid = bin_to_pid(bin_pid) 20 | 21 | case action do 22 | "OK" -> 23 | send(pid, {:ok, ref}) 24 | 25 | :ok 26 | 27 | "CANCEL" -> 28 | send(pid, {:cancel, ref}) 29 | 30 | {:cancel, :because} 31 | 32 | "DISCARD" -> 33 | send(pid, {:discard, ref}) 34 | 35 | :discard 36 | 37 | "ERROR" -> 38 | send(pid, {:error, ref}) 39 | 40 | {:error, "ERROR"} 41 | 42 | "EXIT" -> 43 | send(pid, {:exit, ref}) 44 | 45 | GenServer.call(FakeServer, :exit) 46 | 47 | "FAIL" -> 48 | send(pid, {:fail, ref}) 49 | 50 | raise RuntimeError, "FAILED" 51 | 52 | "KILL" -> 53 | send(pid, {:kill, ref}) 54 | 55 | Process.exit(self(), :kill) 56 | 57 | "SNOOZE" -> 58 | send(pid, {:snooze, ref}) 59 | 60 | {:snooze, 60} 61 | 62 | "TASK_ERROR" -> 63 | send(pid, {:async, ref}) 64 | 65 | fn -> apply(FakeServer, :call, []) end 66 | |> Task.async() 67 | |> Task.await() 68 | 69 | :ok 70 | 71 | "TASK_EXIT" -> 72 | send(pid, {:async, ref}) 73 | 74 | fn -> apply(Kernel, :exit, [{:timeout, :not_a_list}]) end 75 | |> Task.async() 76 | |> Task.await() 77 | end 78 | end 79 | 80 | def perform(%_{args: %{"ref" => ref, "recur" => recur, "bin_pid" => bin_pid}} = job) do 81 | bin_pid 82 | |> bin_to_pid() 83 | |> send({:ok, ref, recur}) 84 | 85 | if ref < recur do 86 | new_job = new(%{"ref" => ref + 1, "recur" => recur, "bin_pid" => bin_pid}) 87 | 88 | Oban.insert!(job.conf.name, new_job) 89 | end 90 | 91 | :ok 92 | end 93 | 94 | def perform(%_{args: %{"ref" => ref, "sleep" => sleep, "bin_pid" => bin_pid}}) do 95 | pid = bin_to_pid(bin_pid) 96 | 97 | send(pid, {:started, ref}) 98 | 99 | :ok = Process.sleep(sleep) 100 | 101 | send(pid, {:ok, ref}) 102 | 103 | :ok 104 | end 105 | 106 | @impl Worker 107 | def backoff(%_{args: %{"backoff" => backoff}}), do: backoff 108 | def backoff(%_{} = job), do: Worker.backoff(job) 109 | 110 | @impl Worker 111 | def timeout(%_{args: %{"timeout" => timeout}}) when timeout > 0, do: timeout 112 | def timeout(_job), do: :infinity 113 | 114 | def pid_to_bin(pid \\ self()) do 115 | pid 116 | |> :erlang.term_to_binary() 117 | |> Base.encode64() 118 | end 119 | 120 | def bin_to_pid(bin) do 121 | bin 122 | |> Base.decode64!() 123 | |> :erlang.binary_to_term() 124 | end 125 | end 126 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Application.ensure_all_started(:postgrex) 2 | 3 | Oban.Test.Repo.start_link() 4 | Oban.Test.DolphinRepo.start_link() 5 | Oban.Test.LiteRepo.start_link() 6 | Oban.Test.UnboxedRepo.start_link() 7 | ExUnit.start(assert_receive_timeout: 500, refute_receive_timeout: 50, exclude: [:skip]) 8 | Ecto.Adapters.SQL.Sandbox.mode(Oban.Test.DolphinRepo, :manual) 9 | Ecto.Adapters.SQL.Sandbox.mode(Oban.Test.Repo, :manual) 10 | 11 | :pg.start_link(Oban.Notifiers.PG) 12 | --------------------------------------------------------------------------------