Skip to content

Commit 61b9650

Browse files
authored
Add endpoint to get counts of running crawls (per-org, or across all orgs) (#3549)
Part of #3541 This PR adds: - New `/orgs/all/crawlconfigs/running` (superadmin) and `/orgs/{oid}/crawlconfigs/running` endpoints that provide counts of the number of workflows in all running, paused, and waiting states, as well as totals. - A new index to the crawls mongo collection to help facilitate the query - Some basic tests
1 parent 6305115 commit 61b9650

4 files changed

Lines changed: 187 additions & 0 deletions

File tree

backend/btrixcloud/crawlconfigs.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
)
2828

2929
from .models import (
30+
ALL_CRAWL_STATES,
3031
SUCCESSFUL_STATES,
3132
TYPE_ALL_CRAWL_STATES,
3233
ConfigRevision,
@@ -35,6 +36,7 @@
3536
CrawlConfigDeletedResponse,
3637
CrawlConfigIn,
3738
CrawlConfigOut,
39+
CrawlConfigRunningCountsResponse,
3840
CrawlConfigSearchValues,
3941
CrawlConfigUpdateResponse,
4042
CrawlerChannel,
@@ -1689,6 +1691,103 @@ async def validate_custom_behavior(self, url: str) -> dict[str, bool]:
16891691

16901692
return {"success": True}
16911693

1694+
async def get_running_counts(
1695+
self, org: Organization | None = None
1696+
) -> CrawlConfigRunningCountsResponse:
1697+
"""Return counts of running workflows, total and status, optionally by org"""
1698+
1699+
state_count_logger = logger.bind(oid=org.id if org else None)
1700+
1701+
try:
1702+
match_query: dict[str, UUID | str] = {}
1703+
if org:
1704+
match_query["oid"] = org.id
1705+
1706+
res = await self.crawls.aggregate(
1707+
[
1708+
{"$match": match_query},
1709+
{"$group": {"_id": "$state", "count": {"$count": {}}}},
1710+
]
1711+
).to_list()
1712+
1713+
state_counts: dict[str, int] = {}
1714+
1715+
for state_dict in res:
1716+
state = state_dict["_id"]
1717+
count = state_dict.get("count", 0)
1718+
if state not in ALL_CRAWL_STATES:
1719+
state_count_logger.error(
1720+
"unexpected_crawl_state_found", state=state, count=count
1721+
)
1722+
else:
1723+
state_counts[state] = count
1724+
1725+
# Running states
1726+
running = state_counts.get("running", 0)
1727+
pending_wait = state_counts.get("pending-wait", 0)
1728+
generate_wacz = state_counts.get("generate-wacz", 0)
1729+
uploading_wacz = state_counts.get("uploading-wacz", 0)
1730+
rate_limited = state_counts.get("rate-limited", 0)
1731+
total_running = (
1732+
running + pending_wait + generate_wacz + uploading_wacz + rate_limited
1733+
)
1734+
1735+
# Paused states
1736+
paused = state_counts.get("paused", 0)
1737+
paused_storage = state_counts.get("paused_storage_quota_reached", 0)
1738+
paused_time = state_counts.get("paused_time_quota_reached", 0)
1739+
paused_read_only = state_counts.get("paused_org_readonly", 0)
1740+
paused_rate_limit = state_counts.get("paused_rate_limit_time_reached", 0)
1741+
1742+
total_paused = (
1743+
paused
1744+
+ paused_storage
1745+
+ paused_time
1746+
+ paused_read_only
1747+
+ paused_rate_limit
1748+
)
1749+
1750+
# Waiting states
1751+
starting = state_counts.get("starting", 0)
1752+
waiting_capacity = state_counts.get("waiting_capacity", 0)
1753+
waiting_org_limit = state_counts.get("waiting_org_limit", 0)
1754+
waiting_dedupe = state_counts.get("waiting_dedupe", 0)
1755+
total_waiting = (
1756+
starting + waiting_capacity + waiting_org_limit + waiting_dedupe
1757+
)
1758+
1759+
total = total_running + total_paused + total_waiting
1760+
1761+
return CrawlConfigRunningCountsResponse(
1762+
totalRunningPausedWaiting=total,
1763+
totalRunning=total_running,
1764+
totalPaused=total_paused,
1765+
totalWaiting=total_waiting,
1766+
# Running states
1767+
running=running,
1768+
pendingWait=pending_wait,
1769+
generateWACZ=generate_wacz,
1770+
uploadingWACZ=uploading_wacz,
1771+
rateLimited=rate_limited,
1772+
# Paused states
1773+
paused=paused,
1774+
pausedStorageQuotaReached=paused_storage,
1775+
pausedTimeQuotaReached=paused_time,
1776+
pausedOrgReadOnly=paused_read_only,
1777+
pausedRateLimitTimeReached=paused_rate_limit,
1778+
# Waiting states
1779+
starting=starting,
1780+
waitingCapacity=waiting_capacity,
1781+
waitingOrgLimit=waiting_org_limit,
1782+
waitingDedupeIndex=waiting_dedupe,
1783+
)
1784+
except Exception:
1785+
state_count_logger.exception(
1786+
"running_workflow_counts_calculation_failed",
1787+
)
1788+
# pylint: disable=raise-missing-from
1789+
raise HTTPException(status_code=400, detail="calculation_failure")
1790+
16921791

16931792
# ============================================================================
16941793
# pylint: disable=too-many-locals
@@ -1945,6 +2044,24 @@ async def get_all_crawler_proxies(
19452044

19462045
return ops.get_crawler_proxies()
19472046

2047+
@router.get("/running", response_model=CrawlConfigRunningCountsResponse)
2048+
async def get_org_crawl_config_running_counts(
2049+
org: Organization = Depends(org_viewer_dep),
2050+
):
2051+
return await ops.get_running_counts(org)
2052+
2053+
@app.get(
2054+
"/orgs/all/crawlconfigs/running",
2055+
response_model=CrawlConfigRunningCountsResponse,
2056+
)
2057+
async def get_all_crawl_config_running_counts(
2058+
user: User = Depends(user_dep),
2059+
):
2060+
if not user.is_superuser:
2061+
raise HTTPException(status_code=403, detail="Not Allowed")
2062+
2063+
return await ops.get_running_counts()
2064+
19482065
@app.get(
19492066
"/orgs/{oid}/crawlconfigs/{cid}/public/replay.json",
19502067
response_model=CrawlOutWithResources,

backend/btrixcloud/crawls.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,9 @@ async def init_index(self):
129129
("started", pymongo.ASCENDING),
130130
]
131131
)
132+
await self.crawls.create_index(
133+
[("oid", pymongo.HASHED), ("state", pymongo.DESCENDING)]
134+
)
132135
await self.crawls.create_index([("finished", pymongo.DESCENDING)])
133136
await self.crawls.create_index([("oid", pymongo.HASHED)])
134137
await self.crawls.create_index([("cid", pymongo.HASHED)])

backend/btrixcloud/models.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -709,6 +709,36 @@ class TagsResponse(BaseModel):
709709
tags: list[TagCount]
710710

711711

712+
# ============================================================================
713+
class CrawlConfigRunningCountsResponse(BaseModel):
714+
"""Response model for counts of running workflows (total and by status)"""
715+
716+
totalRunningPausedWaiting: int = 0
717+
totalRunning: int = 0
718+
totalPaused: int = 0
719+
totalWaiting: int = 0
720+
721+
# Running states
722+
running: int = 0
723+
pendingWait: int = 0
724+
generateWACZ: int = 0
725+
uploadingWACZ: int = 0
726+
rateLimited: int = 0
727+
728+
# Paused states
729+
paused: int = 0
730+
pausedStorageQuotaReached: int = 0
731+
pausedTimeQuotaReached: int = 0
732+
pausedOrgReadOnly: int = 0
733+
pausedRateLimitTimeReached: int = 0
734+
735+
# Waiting states
736+
starting: int = 0
737+
waitingCapacity: int = 0
738+
waitingOrgLimit: int = 0
739+
waitingDedupeIndex: int = 0
740+
741+
712742
# ============================================================================
713743
class CrawlConfigSearchValues(BaseModel):
714744
"""Response model for adding crawlconfigs"""

backend/test/test_run_crawl.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,43 @@ def test_remove_exclusion(admin_auth_headers, default_org_id):
176176
assert r.json()["success"] == True
177177

178178

179+
def test_running_workflow_counts(
180+
admin_auth_headers, crawler_auth_headers, default_org_id
181+
):
182+
# Verify running workflow counts are updated
183+
r = requests.get(
184+
f"{API_PREFIX}/orgs/{default_org_id}/crawlconfigs/running",
185+
headers=admin_auth_headers,
186+
)
187+
assert r.status_code == 200
188+
data = r.json()
189+
assert data["totalRunningPausedWaiting"] >= 1
190+
assert data["totalRunning"] >= 1
191+
assert (
192+
data["running"] >= 1 or data["generateWACZ"] >= 1 or data["uploadingWACZ"] >= 1
193+
)
194+
195+
# Verify again but from non-org-specific endpoint
196+
r = requests.get(
197+
f"{API_PREFIX}/orgs/all/crawlconfigs/running",
198+
headers=admin_auth_headers,
199+
)
200+
assert r.status_code == 200
201+
data = r.json()
202+
assert data["totalRunningPausedWaiting"] >= 1
203+
assert data["totalRunning"] >= 1
204+
assert (
205+
data["running"] >= 1 or data["generateWACZ"] >= 1 or data["uploadingWACZ"] >= 1
206+
)
207+
208+
# Check that non-org-specific endpoint is only available to superadmins
209+
r = requests.get(
210+
f"{API_PREFIX}/orgs/all/crawlconfigs/running",
211+
headers=crawler_auth_headers,
212+
)
213+
assert r.status_code == 403
214+
215+
179216
def test_wait_for_complete(admin_auth_headers, default_org_id):
180217
state = None
181218
data = None

0 commit comments

Comments
 (0)