Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion config/scrapers_config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ config :sanbase, Sanbase.ExternalServices.Coinmarketcap.TickerFetcher,
sync_enabled: {:system, "COINMARKETCAP_TICKER_FETCHER_ENABLED", false},
top_projects_to_follow: {:system, "TOP_PROJECTS_TO_FOLLOW", "25"}

config :sanbase, Sanbase.ExternalServices.Coinmarketcap.ProBackfill,
rate_limiter_scale: {:system, "CMC_PRO_BACKFILL_RATE_LIMITER_SCALE_MS", "60000"},
rate_limiter_limit: {:system, "CMC_PRO_BACKFILL_RATE_LIMITER_LIMIT", "30"},
rate_limiter_time_between_requests:
{:system, "CMC_PRO_BACKFILL_RATE_LIMITER_TIME_BETWEEN_REQUESTS_MS", "1000"}

config :sanbase, Sanbase.ExternalServices.Etherscan.Requests,
apikey: {:system, "ETHERSCAN_APIKEY", ""}

Expand All @@ -30,7 +36,9 @@ config :sanbase, Oban.Scrapers,
cryptocompare_funding_rate_historical_jobs_queue: [limit: 10, paused: true],
cryptocompare_funding_rate_historical_jobs_pause_resume_queue: 1,
# Twitter queues
twitter_followers_migration_queue: [limit: 25, paused: true]
twitter_followers_migration_queue: [limit: 25, paused: true],
coinmarketcap_pro_backfill_jobs: [limit: 10, paused: false],
coinmarketcap_pro_backfill_control: 2
],
plugins: [
# The default values of interval: 1000, limit: 5000 cause the stager to timeout
Expand Down
83 changes: 83 additions & 0 deletions docs/ops/cmc_pro_backfill_runbook.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# CMC Pro Backfill Runbook

## Dry run for one asset

```elixir
slug = "bitcoin"

{:ok, precheck} =
Sanbase.ExternalServices.Coinmarketcap.ProBackfill.gap_check(
scope: :single,
slug: slug,
interval: "5m"
)

from = precheck.recommended_time_start
to = precheck.recommended_time_end

{:ok, run} =
Sanbase.ExternalServices.Coinmarketcap.ProBackfill.start_run(
scope: :single,
slug: slug,
time_start: from,
time_end: to,
interval: "5m",
dry_run?: true
)
```

## Verify dry run

```elixir
Sanbase.ExternalServices.Coinmarketcap.ProBackfill.status(run.id)

Sanbase.ExternalServices.Coinmarketcap.ProBackfill.gap_check(
scope: :single,
slug: slug,
interval: "5m"
)
Comment on lines +34 to +38

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Same incorrect module path for gap_check.

This call should also use ProBackfill.Verification.gap_check/1 for consistency with the actual implementation.

📝 Proposed fix
-Sanbase.ExternalServices.Coinmarketcap.ProBackfill.gap_check(
+Sanbase.ExternalServices.Coinmarketcap.ProBackfill.Verification.gap_check(
   scope: :single,
   slug: slug,
   interval: "5m"
 )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Sanbase.ExternalServices.Coinmarketcap.ProBackfill.gap_check(
scope: :single,
slug: slug,
interval: "5m"
)
Sanbase.ExternalServices.Coinmarketcap.ProBackfill.Verification.gap_check(
scope: :single,
slug: slug,
interval: "5m"
)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/ops/cmc_pro_backfill_runbook.md` around lines 34 - 38, The docs call
uses the wrong module path
Sanbase.ExternalServices.Coinmarketcap.ProBackfill.gap_check; replace that with
ProBackfill.Verification.gap_check/1 (keeping the same argument map: %{scope:
:single, slug: slug, interval: "5m"}) so the example matches the actual
implementation and naming used by the codebase.

```

## Actual run for one asset

```elixir
{:ok, run} =
Sanbase.ExternalServices.Coinmarketcap.ProBackfill.start_run(
scope: :single,
slug: slug,
time_start: from,
time_end: to,
interval: "5m"
)
```

## Actual run for all assets

```elixir
{:ok, run} =
Sanbase.ExternalServices.Coinmarketcap.ProBackfill.start_run(
scope: :all,
time_start: from,
time_end: to,
interval: "5m"
)
```

## Progress and controls

```elixir
Sanbase.ExternalServices.Coinmarketcap.ProBackfill.status(run.id)
Sanbase.ExternalServices.Coinmarketcap.ProBackfill.pause_run(run.id)
Sanbase.ExternalServices.Coinmarketcap.ProBackfill.resume_run(run.id)
Sanbase.ExternalServices.Coinmarketcap.ProBackfill.cancel_run(run.id)
Sanbase.ExternalServices.Coinmarketcap.ProBackfill.list_runs(limit: 20)
Sanbase.ExternalServices.Coinmarketcap.ProBackfill.AuditReport.run_report(run.id)
```

## Final verification

Run the same gap check for the completed interval and ensure:

- `has_gap` is false for fillable ranges
- run status is `completed`
- `failed_assets` is zero or only contains expected deferred ranges
31 changes: 31 additions & 0 deletions lib/sanbase/application/scrapers.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ defmodule Sanbase.Application.Scrapers do
import Sanbase.ApplicationUtils

alias Sanbase.ExternalServices.RateLimiting
alias Sanbase.Utils.Config

def init(), do: :ok

Expand Down Expand Up @@ -38,6 +39,10 @@ defmodule Sanbase.Application.Scrapers do
limit: 5,
time_between_requests: 2000
),
RateLimiting.Server.child_spec(
:api_coinmarketcap_backfill_rate_limiter,
backfill_rate_limiter_opts()
),

# Coinmarketcap http rate limiter
RateLimiting.Server.child_spec(
Expand Down Expand Up @@ -100,4 +105,30 @@ defmodule Sanbase.Application.Scrapers do
false -> config
end
end

defp backfill_rate_limiter_opts() do
[
scale:
Config.module_get(
Sanbase.ExternalServices.Coinmarketcap.ProBackfill,
:rate_limiter_scale,
"60000"
)
|> Sanbase.Math.to_integer(),
limit:
Config.module_get(
Sanbase.ExternalServices.Coinmarketcap.ProBackfill,
:rate_limiter_limit,
"30"
)
|> Sanbase.Math.to_integer(),
time_between_requests:
Config.module_get(
Sanbase.ExternalServices.Coinmarketcap.ProBackfill,
:rate_limiter_time_between_requests,
"1000"
)
|> Sanbase.Math.to_integer()
]
end
end
238 changes: 238 additions & 0 deletions lib/sanbase/external_services/coinmarketcap/pro_backfill.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
defmodule Sanbase.ExternalServices.Coinmarketcap.ProBackfill do
import Ecto.Query

alias Oban.Job
alias Sanbase.Model.LatestCoinmarketcapData
alias Sanbase.Project
alias Sanbase.Repo

alias Sanbase.ExternalServices.Coinmarketcap.ProBackfill.{
Asset,
Run,
RunSeederWorker,
Verification
}

@oban_conf_name :oban_scrapers
@worker_queue :coinmarketcap_pro_backfill_jobs
@supported_intervals ~w(5m)

def gap_check(opts), do: Verification.gap_check(opts)

def start_run(opts) do
with {:ok, interval} <- fetch_interval(Keyword.get(opts, :interval, "5m")),
{:ok, result} <- Verification.gap_check(Keyword.put(opts, :interval, interval)) do
assets_with_gap =
result.assets
|> Enum.filter(&(length(&1.fillable_missing_ranges) > 0))

if assets_with_gap == [] do
{:ok,
%{
status: "no_gap",
total_assets: result.total_assets,
fillable_now_assets: result.fillable_now_assets,
deferred_assets: result.deferred_assets
}}
else
create_run_with_assets(opts, interval, assets_with_gap)
end
Comment on lines +22 to +39

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Propagate the resolved discovery window into the run.

Verification.gap_check/1 can synthesize time_start and time_end for :single, but this branch still passes the original opts into create_run_with_assets/3. When gaps are found, Keyword.fetch!/2 at Line 120 and Line 121 raises because those keys were never present. Use result.time_start and result.time_end when creating the run.

Proposed fix
-        create_run_with_assets(opts, interval, assets_with_gap)
+        create_run_with_assets(
+          Keyword.merge(opts,
+            time_start: result.time_start,
+            time_end: result.time_end
+          ),
+          interval,
+          assets_with_gap
+        )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/sanbase/external_services/coinmarketcap/pro_backfill.ex` around lines 22
- 39, The start_run/1 function calls Verification.gap_check/1 which may
synthesize time_start and time_end (e.g., for :single), but the code still
passes the original opts into create_run_with_assets/3 causing later
Keyword.fetch! calls to fail; update the call to create_run_with_assets to
include the resolved discovery window from result (use result.time_start and
result.time_end) instead of the original opts so create_run_with_assets (and any
subsequent Keyword.fetch!/2) receive the synthesized time_start/time_end values.

end
end

def pause_run(run_id) do
with %Run{} = run <- Run.get(run_id),
{:ok, _} <- Run.mark_paused(run) do
Oban.pause_queue(@oban_conf_name, queue: @worker_queue)
:ok
else
_ -> {:error, "Run not found"}
end
end

def resume_run(run_id) do
with %Run{} = run <- Run.get(run_id),
{:ok, _} <- Run.update_run(run, %{status: "running"}) do
Oban.resume_queue(@oban_conf_name, queue: @worker_queue)
:ok
Comment on lines +43 to +57

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n lib/sanbase/external_services/coinmarketcap/pro_backfill.ex | head -100

Repository: santiment/sanbase2

Length of output: 3625


🏁 Script executed:

rg "pause_run|resume_run" -A 3 -B 1 lib/sanbase/external_services/coinmarketcap/

Repository: santiment/sanbase2

Length of output: 1041


🏁 Script executed:

rg "@worker_queue|@oban_conf_name" lib/sanbase/external_services/coinmarketcap/pro_backfill.ex

Repository: santiment/sanbase2

Length of output: 386


🏁 Script executed:

fd "run_seeder" lib/sanbase/external_services/coinmarketcap/ -type f

Repository: santiment/sanbase2

Length of output: 232


🏁 Script executed:

rg "RunSeederWorker|def perform" lib/sanbase/external_services/coinmarketcap/ -A 10

Repository: santiment/sanbase2

Length of output: 5402


🏁 Script executed:

rg "run_id.*active|status.*paused" lib/sanbase/external_services/coinmarketcap/ -B 2 -A 2

Repository: santiment/sanbase2

Length of output: 1479


🏁 Script executed:

rg "insert.*@oban_conf_name" lib/sanbase/external_services/coinmarketcap/pro_backfill.ex -B 5 -A 5

Repository: santiment/sanbase2

Length of output: 418


Remove queue-wide pause/resume operations; rely on existing per-run pause checks in workers.

pause_run/1 and resume_run/1 pause the entire shared queue, blocking all other backfill runs on that queue. However, AssetWorker already checks run.status and snoozes when paused ({:snooze, 30}), making the queue pause redundant. Remove Oban.pause_queue() and Oban.resume_queue() calls—the per-run status checks in workers provide the needed pause behavior without blocking concurrent runs.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/sanbase/external_services/coinmarketcap/pro_backfill.ex` around lines 43
- 57, The pause_run/1 and resume_run/1 functions currently call
Oban.pause_queue/2 and Oban.resume_queue/2 which pauses the entire shared queue;
remove those queue-wide calls so pausing is implemented per-run: in pause_run/1
(which uses Run.get/1 and Run.mark_paused/1) delete the
Oban.pause_queue(`@oban_conf_name`, queue: `@worker_queue`) call and simply return
:ok after marking the run paused, and in resume_run/1 (which uses Run.get/1 and
Run.update_run/2) delete the Oban.resume_queue(`@oban_conf_name`, queue:
`@worker_queue`) call and simply return :ok after updating the run status; keep
the Run.* calls and existing error handling intact so workers (e.g.,
AssetWorker) continue to rely on run.status checks/snooze behavior.

else
_ -> {:error, "Run not found"}
end
end

def cancel_run(run_id) do
with %Run{} = run <- Run.get(run_id),
{:ok, _} <- Run.mark_canceled(run) do
from(a in Asset, where: a.run_id == ^run_id and a.status in ["pending", "running"])
|> Repo.update_all(set: [status: "canceled", finished_at: DateTime.utc_now()])

:ok
else
_ -> {:error, "Run not found"}
end
end

def list_runs(opts \\ []) do
Run.list(opts)
|> Enum.map(&run_summary/1)
end

def status(run_id) do
with %Run{} = run <- Run.get(run_id) do
failed_assets =
Asset.list_failed_by_run(run_id, 10)
|> Enum.map(fn a -> %{slug: a.slug, last_error: a.last_error} end)

%{
id: run.id,
status: run.status,
scope: run.scope,
interval: run.interval,
time_start: run.time_start,
time_end: run.time_end,
total_assets: run.total_assets,
done_assets: run.done_assets,
failed_assets: run.failed_assets,
pending_assets: run.pending_assets,
percent_complete: percent_complete(run),
eta_seconds: eta_seconds(run),
running_workers: running_workers(run.id),
top_failed_assets: failed_assets,
api_credits_used_total: run.api_credits_used_total,
api_calls_total: run.api_calls_total,
rate_limited_calls_total: run.rate_limited_calls_total,
usage_precision: run.usage_precision,
dry_run: run.dry_run
}
else
_ -> {:error, "Run not found"}
end
end

defp create_run_with_assets(opts, interval, assets_with_gap) do
now = DateTime.utc_now()

attrs = %{
source: "coinmarketcap",
scope: normalize_scope(opts),
status: "pending",
interval: interval,
time_start: Keyword.fetch!(opts, :time_start),
time_end: Keyword.fetch!(opts, :time_end),
dry_run: Keyword.get(opts, :dry_run?, false),
total_assets: 0,
pending_assets: 0,
started_at: nil,
finished_at: nil
}

Repo.transaction(fn ->
{:ok, run} =
Run.create(attrs)

projects_map =
Project.List.projects_with_source("coinmarketcap",
include_hidden: true,
order_by_rank: true
)
|> Map.new(&{&1.id, &1})

rows =
assets_with_gap
|> Enum.map(fn gap ->
project = projects_map[gap.project_id]
cmc_data = LatestCoinmarketcapData.latest_coinmarketcap_data(project)
cmc_integer_id = if(cmc_data, do: cmc_data.coinmarketcap_integer_id, else: nil)
rank = if(cmc_data, do: cmc_data.rank, else: nil)
ranges = %{"ranges" => gap.fillable_missing_ranges}

{rank || 9_999_999, -(gap.missing_points_count || 0),
%{
run_id: run.id,
project_id: project.id,
slug: project.slug,
cmc_integer_id: cmc_integer_id,
rank: rank,
status: "pending",
missing_ranges: ranges,
inserted_at: now,
updated_at: now
}}
end)
|> Enum.sort_by(
fn {rank, missing_points_count, _row} -> {rank, missing_points_count} end,
:asc
)
|> Enum.map(&elem(&1, 2))
|> Enum.reject(&is_nil(&1.cmc_integer_id))

Asset.insert_many(rows)
Run.update_run(run, %{total_assets: length(rows), pending_assets: length(rows)})

if rows != [] do
job = RunSeederWorker.new(%{"run_id" => run.id})
Oban.insert(@oban_conf_name, job)
else
Run.update_run(run, %{status: "completed", finished_at: DateTime.utc_now()})
Comment on lines +129 to +176

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, let's find the file and understand its structure
cd /repo
find . -name "pro_backfill.ex" -type f

Repository: santiment/sanbase2

Length of output: 196


🏁 Script executed:

#!/bin/bash
# Read the file to see the actual implementation
cat -n lib/sanbase/external_services/coinmarketcap/pro_backfill.ex | head -200

Repository: santiment/sanbase2

Length of output: 7444


🏁 Script executed:

#!/bin/bash
# Search for Run.create, Run.update_run implementations
fd -e ex -e exs | xargs rg "def create\(" | grep -i run

Repository: santiment/sanbase2

Length of output: 211


🏁 Script executed:

#!/bin/bash
# Search for the Run module
fd -e ex -e exs | xargs rg "defmodule.*Run" | grep -v test | head -20

Repository: santiment/sanbase2

Length of output: 1386


🏁 Script executed:

#!/antml:bsh
# Look for return types and error handling patterns
rg "def (create|update_run)" -A 5 lib/sanbase/repo.ex

Repository: santiment/sanbase2

Length of output: 44


🏁 Script executed:

#!/bin/bash
# Get the Run module implementation
cat -n lib/sanbase/external_services/coinmarketcap/pro_backfill/run.ex | head -100

Repository: santiment/sanbase2

Length of output: 3474


🏁 Script executed:

#!/bin/bash
# Check Oban.insert return type
rg "def insert" lib/sanbase/ -A 3 | head -50

Repository: santiment/sanbase2

Length of output: 958


🏁 Script executed:

#!/bin/bash
# Look for Asset.insert_many implementation
fd -e ex -e exs | xargs rg "def insert_many" | head -10

Repository: santiment/sanbase2

Length of output: 173


🏁 Script executed:

#!/antml:bsh
# Check the broader transaction context and error handling
cat -n lib/sanbase/external_services/coinmarketcap/pro_backfill.ex | sed -n '112,181p'

Repository: santiment/sanbase2

Length of output: 2699


🏁 Script executed:

#!/bin/bash
# Verify Oban.insert behavior and if it's inside transaction context
rg "Oban.insert" lib/sanbase/external_services/coinmarketcap/pro_backfill.ex -B 5 -A 2

Repository: santiment/sanbase2

Length of output: 397


Use with to handle tagged tuple returns and ensure transaction rollback on failure.

Run.update_run/2 and Oban.insert/2 return {:ok, _} or {:error, _} tuples at lines 170, 174, and 176, but their results are ignored. The bare match on Run.create/1 at line 130 raises an exception instead of graceful error handling. If the enqueue at line 174 fails, assets remain in "pending" status indefinitely with no job scheduled. If Run.update_run/2 fails at lines 170 or 176, run metadata is never persisted. Additionally, line 179 returns the original run struct before line 170's update, yielding stale counts. Chain operations with with for consistent error propagation and transaction rollback.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/sanbase/external_services/coinmarketcap/pro_backfill.ex` around lines 129
- 176, The transaction currently ignores tagged-tuple returns and can leave
stale state; refactor the Repo.transaction block to use a with chain that
matches {:ok, run} <- Run.create(attrs), {:ok, _} <- Asset.insert_many(rows) (or
wrap insert_many to return {:ok, _}), {:ok, run_after_update} <-
Run.update_run(run, %{total_assets: length(rows), pending_assets:
length(rows)}), and when enqueuing, {:ok, _job} <- Oban.insert(`@oban_conf_name`,
RunSeederWorker.new(%{"run_id" => run_after_update.id})) (or handle the else
branch to update run status and return {:ok, run_after_update}); ensure every
call (Run.create, Asset.insert_many, Run.update_run, Oban.insert) is
pattern-matched so any {:error, _} bubbles out of the transaction to trigger
rollback and return the updated run struct (run_after_update) rather than the
original run.

end

run
end)
end

defp normalize_scope(opts) do
case Keyword.get(opts, :scope) do
scope when scope in [:single, :all, :list] -> Atom.to_string(scope)
scope when scope in ["single", "all", "list"] -> scope
_ -> "all"
end
end

defp fetch_interval(interval) when interval in @supported_intervals, do: {:ok, interval}
defp fetch_interval(_), do: {:error, "Only 5m interval is currently supported"}

defp percent_complete(%Run{total_assets: 0}), do: 100.0

defp percent_complete(%Run{} = run) do
((run.done_assets + run.failed_assets) / run.total_assets * 100) |> Float.round(2)
end

defp eta_seconds(%Run{started_at: nil}), do: nil
defp eta_seconds(%Run{done_assets: 0}), do: nil

defp eta_seconds(%Run{} = run) do
elapsed = DateTime.diff(DateTime.utc_now(), run.started_at, :second)
avg_per_asset = elapsed / run.done_assets
round(avg_per_asset * run.pending_assets)
end

defp run_summary(%Run{} = run) do
%{
id: run.id,
status: run.status,
scope: run.scope,
interval: run.interval,
time_start: run.time_start,
time_end: run.time_end,
total_assets: run.total_assets,
done_assets: run.done_assets,
failed_assets: run.failed_assets,
pending_assets: run.pending_assets,
percent_complete: percent_complete(run),
api_credits_used_total: run.api_credits_used_total,
api_calls_total: run.api_calls_total,
rate_limited_calls_total: run.rate_limited_calls_total,
usage_precision: run.usage_precision
}
end

defp running_workers(run_id) do
from(j in Job,
where:
j.queue == ^to_string(@worker_queue) and j.state == "executing" and
fragment("?->>'run_id' = ?", j.args, ^to_string(run_id)),
select: count()
)
|> Repo.one()
end
end
Loading