José Agretti
12/10/2025, 12:31 PMMarvin
12/10/2025, 12:31 PMMarvin
12/10/2025, 12:33 PMprefect-gcp to the latest for bugfixes.
Recommended upgrade checklist
- Verify Python >= 3.10 on any machines running agents/workers.
- If self-hosting Prefect Server, back up your database. Migrations will run automatically on startup.
- Update packages:
- Upgrade Prefect to 3.6.5.
- If you use GCP/Vertex AI, also upgrade prefect-gcp to the latest.
- Smoke test:
- Start a worker for your Vertex AI work pool and run one deployment end-to-end.
- If you use concurrency limits, trigger a run that should be limited and confirm behavior.
- If you use event-driven triggers/automations, trigger one and confirm it fires.
Known safe areas for your setup
- Vertex AI workers: no breaking API changes in this range.
- Docker image architecture: nothing in 3.6.x changes architectures; keep your base image/worker platform consistent (amd64 vs arm64) to avoid the mismatch you hit previously.
Release notes for reference
- OSS 3.x release notes: Prefect 3.x release notes
- 3.6.5 diff: 3.6.5
- 3.6.0 diff: 3.6.0
- 3.5.0 (Python 3.10+): 3.5.0
If you share your Python version and whether you’re self-hosting Server or using Cloud, I can flag any remaining gotchas and tailor a quick rollout plan.José Agretti
12/10/2025, 2:18 PMMarvin
12/10/2025, 2:18 PMMarvin
12/10/2025, 2:23 PMprefect server database upgrade --dry-run
- Verify the migrate-on-start setting is enabled (it is by default):
prefect config view --show-defaults | grep PREFECT_SERVER_DATABASE_MIGRATE_ON_START
- If you prefer a definitive check against the DB, look at the alembic_version table in your database and ensure it’s at head.
If you want more visibility next time
- Start the server with higher log level:
PREFECT_SERVER_LOGGING_LEVEL=INFO prefect server start
- To see SQL emitted during migrations:
PREFECT_SERVER_DATABASE_SQLALCHEMY__ECHO=true prefect server database upgrade --dry-run
If you run the dry run and see no pending SQL/migrations, you’re fully up to date.José Agretti
12/17/2025, 12:07 PMMarvin
12/17/2025, 12:08 PMMarvin
12/17/2025, 12:26 PMstrict=True.
- There aren’t any Cloud Run–specific issues reported around this in 3.6.5; this is most often caused by renewal timing out or the lease expiring before renewal runs.
Given what you shared (Cloud Run worker pool, jobs ~6–7 minutes, you set lease timeout to 900s), a couple things don’t quite add up yet: with a 900s lease, the first renewal would occur around 675s (~11.25 minutes). If your jobs consistently finish in 6–7 minutes, they shouldn’t hit the first renewal at all. That suggests either:
- The error is not from the global concurrency limit you configured but from a different strict lease (commonly: deployment concurrency), or
- The lease duration in effect is not actually 900s for the lease that’s failing (e.g., default 300s), or
- The process missed the renewal window (e.g., long blocking work, event loop starved, or a transient network/clock issue) and the server returned 410 Gone.
A few targeted questions to narrow this down:
1) Where did you set the 900s “LEASE TIMEOUT”? In the Prefect Cloud UI for the global concurrency limit, or in code, or elsewhere?
2) Are you also using deployment-level concurrency on this deployment?
3) Do you set strict=True in any prefect.concurrency.*.concurrency(...) blocks in your flow?
4) Roughly when does the failure occur relative to run start? Around 3.75 minutes (75% of 300s), around 11.25 minutes (75% of 900s), or right at ~15 minutes?
Immediate diagnostics and mitigations:
- Turn up logging to DEBUG in the flow run to capture more context around the renewal attempt (any 4xx from the renew endpoint will be relevant).
- If you’re using a code-level global concurrency block and can tolerate overages temporarily, try strict=False to validate that it’s a strict-termination path:
from prefect.concurrency.sync import concurrency
with concurrency("your-limit-name", occupy=1, strict=False):
# work
If the error becomes a warning and the run completes, we’ve confirmed it’s that lease.
- If you suspect deployment-level concurrency is the strict lease that’s failing, temporarily disable deployment concurrency or inspect the deployment to confirm its concurrency settings:
prefect deployment inspect "<flow-name>/<deployment-name>"
- Double-check your Cloud Run job’s max runtime and that it’s not being throttled or paused in a way that would delay the renewal loop. Also ensure outbound connectivity to Prefect Cloud is stable throughout the run.
- If you do need strict enforcement and long leases, increase the lease timeout further as a test and see if the failure shifts accordingly. That will tell us whether this is a simple expiry/renewal timing issue.
Notes on versions:
- I didn’t find any open issues specific to Cloud Run and lease renewal failures in 3.6.5, and there are no related GitHub Discussions at the moment.
- There have been small concurrency-related improvements after 3.6.5; if you can upgrade, consider testing on the latest 3.6.x to pick up improvements. Full changelog compare: 3.6.5 → 3.6.6
If you can share:
- A link to one failing run,
- Where/how you set the 900s lease timeout,Marvin
12/17/2025, 12:26 PMstrict=True in code,
- The timestamp when the error logs relative to run start,
…I can pinpoint which lease is failing (deployment vs global) and suggest the exact fix (e.g., adjust lease duration source, switch strict mode, or tune deployment concurrency).José Agretti
12/17/2025, 12:30 PMHTTP Request: POST <https://prefect-server-986334413485.us-central1.run.app/api/v2/concurrency_limits/leases/554f5153-f728-44b9-bdd3-8702ade3dd40/renew> "HTTP/1.1 410 Gone
Is this a network issue, or is Prefect just not being able to renew things?Marvin
12/17/2025, 12:30 PMMarvin
12/17/2025, 12:33 PM# Use Redis-backed lease storage
PREFECT_SERVER_CONCURRENCY_LEASE_STORAGE=prefect_redis.lease_storage
# Redis connection (shared with Prefect messaging)
PREFECT_REDIS_MESSAGING_HOST=your-redis-host
PREFECT_REDIS_MESSAGING_PORT=6379
PREFECT_REDIS_MESSAGING_DB=0
PREFECT_REDIS_MESSAGING_USERNAME=default
PREFECT_REDIS_MESSAGING_PASSWORD=your-password
PREFECT_REDIS_MESSAGING_SSL=false
If you run multiple server workers, Redis is required for messaging and leases.
2) Confirm which lease is failing by checking the timing:
- ~225–240s after run start: likely deployment concurrency lease (defaults to 300s, strict).
- ~75% of your configured global concurrency lease (e.g., ~675s for 900s): then it’s your global limit’s lease.
3) If it’s deployment concurrency and you truly need longer leases, adjust the server’s initial deployment lease duration:
PREFECT_SERVER_CONCURRENCY_INITIAL_DEPLOYMENT_LEASE_DURATION=900
Note: deployment concurrency leases are strict by default; a failed renewal will terminate the run.
4) For code-level global concurrency, ensure you’re actually passing the desired lease duration and set strict mode according to your tolerance:
from prefect.concurrency.sync import concurrency
with concurrency("your-limit", occupy=1, lease_duration=900, strict=True):
# your work
5) Check for restarts around the 410 in your Prefect Server logs and verify Cloud Run isn’t scaling your server instance to zero during runs.
6) If you can, test on the latest 3.6.x to pick up concurrency improvements: 3.6.5 → 3.6.6
If you can share:
- Whether your Prefect Server is self-hosted on Cloud Run and which lease storage backend it’s using,
- The timestamp of the 410 relative to run start,
- Whether you’re using deployment concurrency for this deployment,
…I can confirm which lease is failing (deployment vs global) and give you the exact configuration change to stop the 410s.José Agretti
12/17/2025, 12:43 PMMarvin
12/17/2025, 12:43 PMMarvin
12/17/2025, 12:47 PMconcurrency() context), not from a per‑limit setting in the UI. The defaults are:
- Public concurrency context: lease_duration=300 unless you set it
- Task tag concurrency (automatic, per task tag): lease_duration=60 internally
- If you intended 900s, make sure your flow code is acquiring the lease with that duration, e.g.:
from prefect.concurrency.sync import concurrency
with concurrency("your-limit-name", occupy=1, lease_duration=900, strict=True):
# your long-running work
If you’re relying solely on task tags for concurrency, note those leases are 60s by default — long tasks can easily outlive that unless the renew loop runs reliably.
Why a renew could miss and let the lease expire
- Async flows: long synchronous/blocking work can block the event loop, delaying the renewal task. If the planned renewal at 75% of the TTL (for 900s → ~11.25m) doesn’t run, the lease can expire at 15m; the next attempt (e.g., near 16m) will 410.
- Heavy CPU-bound sections in a single process can starve scheduling.
- The limit was deleted/reset (rare, but would revoke leases).
- Redis eviction or restart removed the keys (check evictions/flushes; you said Redis is in use, but memory pressure or eviction policy could still be a factor).
Concrete next steps
1) Confirm how you’re acquiring the limit:
- Are you using prefect.concurrency.concurrency(..., lease_duration=900, strict=?) around the long-running section? If yes, please share that snippet.
- Or are you relying on task tags only? If so, consider wrapping the long-lived section in an explicit concurrency() block with a larger lease_duration.
2) If your flow is async, don’t block the event loop:
- Offload blocking work with a thread/process executor:
from prefect import flow
from prefect.concurrency.asyncio import concurrency
import anyio
@flow
async def run():
async with concurrency("your-limit-name", lease_duration=900, strict=True):
await anyio.to_thread.run_sync(long_blocking_function)
- Or keep the flow sync so the renewal runs in a background thread.
3) Validate Redis is not evicting leases:
- Check maxmemory and maxmemory-policy (avoid allkeys/volatile evictions for lease keys).
- Inspect INFO stats for evicted_keys and Redis logs around the failure time.
- Ensure there were no Redis restarts or FLUSH events.
4) Quick confirmation test:
- Temporarily increase lease_duration (e.g., 1800s) in your concurrency() call. If the 410 shifts correspondingly (~30 min), that confirms lease expiry due to missed renewal.
- While diagnosing, you can set strict=False to avoid crashing on renewal failure and just log a warning.
5) Server logs:
- Check Prefect Server logs around 15–16 minutes after start for the repossessor expiring that lease id or any errors touching concurrency leases.
If you can share:
- How you’re acquiring the global concurrency lease (code snippet),
- Whether your flow is async and has long blocking sections,Marvin
12/17/2025, 12:47 PMmaxmemory-policy and any eviction stats around the timestamp,
…I can give you a precise fix. The most common resolution is to explicitly acquire with lease_duration=900 and ensure the renewal loop isn’t blocked (or avoid async event loop blocking).José Agretti
12/17/2025, 1:02 PM@flow in the functions, and then we define the deployment in the prefect.yaml file
3. Redis is not evicting keys, but periodically has several expired keys, and there were no restarts
4. How do I set strict=False but as a Prefect configuration? I'm not defining the limits in any way in code, just in Prefect yaml file like this
concurrency_limit: { "limit": 50, "collision_strategy": "ENQUEUE" }
5. The server logs at that time look like this
{
"insertId": "69429a4000099702ef18df68",
"httpRequest": {
"requestMethod": "POST",
"requestUrl": "<https://prefect-server-986334413485.us-central1.run.app/api/v2/concurrency_limits/leases/554f5153-f728-44b9-bdd3-8702ade3dd40/renew>",
"requestSize": "481",
"status": 410,
"responseSize": "166",
"userAgent": "prefect/3.6.5 (API 0.8.4)",
"remoteIp": "0.0.0.0",
"serverIp": "34.143.77.2",
"latency": "0.005120760s",
"protocol": "HTTP/1.1"
},
"resource": {
"type": "cloud_run_revision",
"labels": {
"project_id": "<>",
"service_name": "prefect-server",
"revision_name": "prefect-server-00033-j27",
"location": "us-central1",
"configuration_name": "prefect-server"
}
},
"timestamp": "2025-12-17T11:55:44.621495Z",
"severity": "WARNING",
"labels": {
"instanceId": "0007e26d68755e6a7137bac591294e3ad4ca55826fd711e83fb1b7171739550656323275162ff7f3a60ed675a82cd92c38937eb142f5bd5b270b8b801ec8286b45529a92cea73691f1c3006a"
},
"logName": "projects/<>/logs/run.googleapis.com%2Frequests",
"trace": "projects/<>/traces/bc522b9f33114b074ac6c5ee03f39bad",
"receiveTimestamp": "2025-12-17T11:55:44.735331471Z",
"spanId": "a97dc2cae8c65f1a"
}Marvin
12/17/2025, 1:02 PMMarvin
12/17/2025, 1:05 PMstrict=False, then remove the UI-managed global limit for that run. Example:
from prefect import flow
from prefect.concurrency.sync import concurrency # or prefect.concurrency.asyncio for async flows
@flow
def my_flow():
with concurrency("your-global-limit-name", occupy=1, lease_duration=900, strict=False):
# your long-running work
...
That will log a warning if renewal fails but will not terminate the run. Make sure to remove the UI-configured global limit for this deployment to avoid double-acquisition.
- Note: the concurrency_limit block you showed in prefect.yaml:
concurrency_limit: { "limit": 50, "collision_strategy": "ENQUEUE" }
is a separate feature: per-deployment concurrency (max parallel runs for the deployment). That also uses a strict lease under the hood. If you don’t intend to use per‑deployment concurrency, remove this from your deployment config to avoid maintaining an extra lease.
5) The server log you shared confirms a clean, low-latency 410 on renew — consistent with “lease already expired,” not connectivity.
What I recommend
Option A (keep UI-managed global limit):
- Increase the lease timeout in the UI (e.g., 1800s) and see if the 410 shifts accordingly. If yes, we’ve confirmed expiry due to missed renewal.
- Ensure the renewal loop is not starved:
- If your flow is async, don’t block the event loop; offload blocking work with anyio.to_thread.run_sync or similar.
- If your flow is sync and CPU-heavy on a single core, the background renewal thread should still run, but severe CPU contention can delay scheduling; consider slightly higher TTL for safety.
Option B (own it in code for non‑strict):
- Remove the UI-attached global limit for this deployment and wrap the critical section in a concurrency(...) block with lease_duration=900 and strict=False (or True if you still want strict).
- This gives you full control over TTL, strictness, and renew behavior.
Useful references
- Global concurrency limits overview: Docs: Concurrency limits
- Deployments and deployment concurrency: Docs: Deployments
If you can confirm:
- Whether your flow is async or sync,
- Whether it does long blocking/CPU-bound work,
- Whether you want strict enforcement or just best‑effort,Marvin
12/17/2025, 1:05 PMJosé Agretti
12/17/2025, 1:13 PMconcurrency_limit: { "limit": 50, "collision_strategy": "ENQUEUE" }
And that I'm currently usingMarvin
12/17/2025, 1:13 PMMarvin
12/17/2025, 1:18 PMconcurrency_limit: { limit: 50, collision_strategy: ENQUEUE }.
- That concurrency uses a strict lease under the hood. If the server says the lease no longer exists (410 Gone), Prefect terminates the run to prevent over-allocation.
- Your 410 at ~16 minutes means the lease expired (hit its TTL) and when the engine tried to renew, the server had already cleaned it up. With Redis, that’s normal behavior for an expired lease — not a network issue.
Key implications
- Deployment concurrency strictness is not configurable via YAML or a global setting. There’s no “strict=False” switch for deployment concurrency; the engine always treats it as strict.
- If a run can exceed the lease TTL, you must increase the TTL for deployment concurrency.
How to increase the deployment concurrency lease duration
You control it with the per-deployment “grace period” (initial lease duration). Add it to your deployment’s YAML:
deployments:
- name: your-deployment
entrypoint: path/to/flow.py:your_flow
work_pool:
name: your-pool
concurrency_limit:
limit: 50
collision_strategy: ENQUEUE
grace_period_seconds: 1200 # e.g., 20 minutes
Notes:
- grace_period_seconds valid range is 60–86400.
- This only affects deployment concurrency; it does not change strictness (still strict).
Optional server-wide default (if you don’t want to set it per deployment)
Set this on your Prefect Server:
PREFECT_SERVER_CONCURRENCY_INITIAL_DEPLOYMENT_LEASE_DURATION=1200
This sets the default initial deployment concurrency lease duration for all deployments unless they override with grace_period_seconds.
Double-check you aren’t also using a Global Concurrency Limit
Since you only want deployment concurrency, please verify in the UI that there isn’t a Global Concurrency Limit also applied to this deployment (e.g., a limit named for the deployment or a tag). If there is, remove it — otherwise you end up with two leases to maintain, and either one expiring will terminate the run.
Verify the setting
Run:
prefect deployment inspect "<flow-name>/<deployment-name>"
Look for concurrency_options.grace_period_seconds in the output to confirm it’s applied.
If you ever need non-strict behavior
The only way is to manage concurrency in code:
- Remove the deployment’s concurrency_limit
- Wrap the critical section with a concurrency block and set `strict=False`:
from prefect import flow
from prefect.concurrency.sync import concurrency
@flow
def your_flow():
with concurrency("your-limit-name", occupy=1, lease_duration=1200, strict=False):
# work
But based on your goal (“only deployment concurrency”), the better path is to keep deployment concurrency and set grace_period_seconds high enough for your longest runs.
Next steps I recommend
- Add grace_period_seconds to your deployment YAML (try 1200s to start).
- Ensure no Global Concurrency Limits are attached to this deployment in the UI.
- Re-deploy and re-run. If a run occasionally exceeds even that window, bump it again.
Docs for reference:
- Deployment concurrency options: Deployments: Concurrency limiting
- Concurrency limits concept: Global concurrency limitsJosé Agretti
12/17/2025, 1:20 PMdeployment:d1eed5c8-5415-40fe-8a65-4f71f86e002a
that I think is the one defined by the concurrency limit in the prefect.yaml filewMarvin
12/17/2025, 1:21 PMMarvin
12/17/2025, 1:21 PMdeployment:d1eed5c8-5415-40fe-8a65-4f71f86e002a
is the system-managed limit that backs your deployment-level concurrency. When you set concurrency_limit in your deployment spec, Prefect creates and manages that limit under the hood using the same global concurrency mechanism, so it shows up in the Global Concurrency Limits UI. You don’t need to create a separate limit for it.
Why you’re seeing 410s
- Deployment concurrency uses a strict lease. If the lease expires (TTL reached) before the renewal loop successfully renews it, the next renewal call returns 410 Gone and the run is terminated.
- The fix is to increase the initial lease duration for deployment concurrency — called the grace period — so it comfortably covers your longest runs.
How to fix it in your deployment YAML
Add grace_period_seconds to your deployment’s concurrency_limit block. For 6–7 minute runs, give yourself buffer (e.g., 1200 seconds = 20 minutes):
deployments:
- name: your-deployment
entrypoint: path/to/flow.py:your_flow
work_pool:
name: your-pool
concurrency_limit:
limit: 50
collision_strategy: ENQUEUE
grace_period_seconds: 1200
Notes
- Deployment concurrency strictness cannot be turned off via config; there is no strict=False knob for this path. If you ever need non-strict behavior, you’d have to manage concurrency in code with concurrency(..., strict=False) and remove the deployment-level concurrency — but based on your intent, stick with deployment concurrency and increase the grace period.
- You can verify the applied value with:
prefect deployment inspect "<flow-name>/<deployment-name>"
Look for concurrency_options.grace_period_seconds.
Optional server-wide default
If you want a new default for all deployments (unless they override it), set this on your Prefect Server:
PREFECT_SERVER_CONCURRENCY_INITIAL_DEPLOYMENT_LEASE_DURATION=1200
Docs
- Deployments: Concurrency limiting: Docs
- Concurrency limits concept: Docs
If you bump grace_period_seconds and still see a 410 around the new TTL, share the new timestamp relative to run start and we can dig deeper into why the renewal loop might be missing its window.José Agretti
12/17/2025, 1:23 PMMarvin
12/17/2025, 1:23 PMMarvin
12/17/2025, 1:24 PMdeployments:
- name: your-deployment
entrypoint: path/to/flow.py:your_flow
work_pool:
name: your-pool
concurrency_limit:
limit: 50
collision_strategy: ENQUEUE
grace_period_seconds: 1200 # e.g., 20 minutes
- Or increase the server default further:
PREFECT_SERVER_CONCURRENCY_INITIAL_DEPLOYMENT_LEASE_DURATION=1200
Then redeploy and verify with:
prefect deployment inspect "<flow-name>/<deployment-name>"
Look for concurrency_options.grace_period_seconds.
- Make sure the renewal loop can run:
- Since your flows are sync, a background renewal thread should be able to run — unless the work is CPU-bound pure Python that holds the GIL for long stretches.
- If you have long, CPU-bound sections in pure Python, consider:
- Moving those sections to multiprocessing (bypasses GIL), or
- Using libraries that release the GIL (NumPy, Pandas C-ops usually do), or
- Breaking long loops with brief yields (even tiny sleeps) so other threads can run.
- Also confirm your Cloud Run job isn’t being CPU-throttled; Jobs generally have CPU while running, but double-check CPU allocation and limits.
- Ensure there’s only one lease in play:
- You’re using deployment concurrency only — good. Just make sure there isn’t an additional global limit attached to this deployment in the UI. The “deployment:<uuid>” you see is the system-managed one for deployment concurrency; you do not need a separate custom limit.
If you bump grace_period_seconds to, say, 1200 and still get a 410 around ~20 minutes, that confirms the renewal loop is being delayed by several minutes. In that case, I’d focus on the CPU/GIL angle or further increase the grace period to match worst-case runtime plus buffer. If you can share whether your flow has long CPU-heavy pure-Python sections, I can suggest a concrete pattern (e.g., multiprocessing) to keep renewals timely.José Agretti
12/17/2025, 1:36 PMMarvin
12/17/2025, 1:36 PMMarvin
12/17/2025, 1:37 PMJosé Agretti
12/17/2025, 3:42 PMConcurrency lease renewal failed - slots are no longer reserved. Execution will continue, but concurrency limits may be exceeded.
when we were running Prefect 3.4.20. And then we started to see the other error when we upgraded to version 3.6.5. Do you know if in one version in between the concurrency limits started to be strict?Marvin
12/17/2025, 3:43 PMMarvin
12/17/2025, 3:45 PMdeployments:
- name: your-deployment
entrypoint: path/to/flow.py:your_flow
work_pool:
name: your-pool
concurrency_limit:
limit: 50
collision_strategy: ENQUEUE
grace_period_seconds: 1200 # try 20 minutes
- Or increase the server default further:
PREFECT_SERVER_CONCURRENCY_INITIAL_DEPLOYMENT_LEASE_DURATION=1200
- Verify the value applied:
prefect deployment inspect "<flow-name>/<deployment-name>"
Look for concurrency_options.grace_period_seconds.
- Since your flows are sync, the renewal runs in a background thread; if you have long, CPU-bound pure-Python loops, they can still starve other threads. If you still see expirations near the TTL after increasing it:
- Add tiny sleeps/yields in long tight loops or move heavy CPU work to multiprocessing.
- Or bump grace_period_seconds further to match worst-case runtime.
References:
- Deployments: concurrency limiting Docs
- Concurrency limits concept Docs
If you want non-strict behavior, that’s only possible by managing concurrency in code (remove deployment concurrency and wrap with concurrency(..., strict=False)), but if your intent is to use deployment concurrency, increasing grace_period_seconds is the right fix.José Agretti
12/17/2025, 3:47 PMMarvin
12/17/2025, 3:47 PMMarvin
12/17/2025, 3:48 PMdeployments:
- name: your-deployment
entrypoint: path/to/flow.py:your_flow
work_pool:
name: your-pool
concurrency_limit:
limit: 50
collision_strategy: ENQUEUE
grace_period_seconds: 1800 # try 30 minutes (valid range 60–86400)
Then redeploy and verify with:
prefect deployment inspect "<flow-name>/<deployment-name>"
Look for concurrency_options.grace_period_seconds.
- Avoid blocking the event loop in async flows:
- Don’t call sync/blocking APIs directly (e.g., requests, most Google Cloud Python clients, boto3, pandas IO, time.sleep, heavy pure-Python loops) from async code.
- Offload sync or CPU-bound work so the event loop stays free for the renewal task:
import anyio
# Blocking I/O or sync client
result = await anyio.to_thread.run_sync(sync_fn, *args, **kwargs)
# CPU-bound work: prefer a process to bypass the GIL and keep the loop free
from anyio import to_process
result = await to_process.run_sync(cpu_heavy_fn, *args, **kwargs)
# If you have long async loops, add periodic yields
await asyncio.sleep(0) # inside long-running loops
- For HTTP, prefer async clients (e.g., httpx.AsyncClient, aiohttp) where possible; otherwise wrap the sync calls with to_thread.run_sync.
- Sanity-check timing:
- Renewals run at ~75% of the lease duration. With 900s TTL, the renewal should happen ~11m15s in. If you’re consistently seeing 410s around TTL+1 minute, the loop was blocked past expiry.
- If your async jobs sometimes spend many minutes in blocking sections, push grace_period_seconds higher (e.g., 3600s) to give a big buffer while you refactor hotspots.
- Optional test to confirm cause:
- Temporarily raise grace_period_seconds substantially (e.g., 3600). If failures shift to ~1h, it’s purely a missed renewal window.
- Enable more logging to correlate timings:
PREFECT_LOGGING_LEVEL=DEBUG
on your worker to capture renewal attempts and failures.
Docs for reference:
- Deployments: concurrency limiting Docs
- Concurrency limits concept Docs
If you can share a small snippet of one failing async flow (especially the parts doing network IO, pandas/BigQuery work, or heavy computation), I can point to the exact spots to offload to threads/processes so the lease renewal task stays on schedule.José Agretti
12/18/2025, 12:48 PMMarvin
12/18/2025, 12:48 PMMarvin
12/18/2025, 12:49 PMJosé Agretti
12/18/2025, 4:05 PMMarvin
12/18/2025, 4:05 PMMarvin
12/18/2025, 4:14 PMfrom prefect import task, flow
@task(tags=["db"])
def do_work(i):
...
@flow
def my_flow():
for i in range(20):
do_work.submit(i)
2) Create and manage the limit (CLI)
# Create a limit of 5 for the "db" tag
prefect concurrency-limit create db 5
# List all tag limits
prefect concurrency-limit ls
# Inspect a specific tag limit (shows active task run IDs)
prefect concurrency-limit inspect db
# Reset slots (e.g., if they got wedged)
prefect concurrency-limit reset db
# Delete the limit
prefect concurrency-limit delete db
3) Behavior and queuing
- When a task is scheduled, Prefect acquires a slot for each of its tags. If any tag is at capacity, the task waits; the scheduler retries with backoff (no explicit “queue” object).
- If a task has multiple tags, it must have capacity for all tags (logical AND).
- The slots are tracked via timed leases; for tag-based limits, Prefect renews the lease periodically during execution.
Are tag-based limits “strict”?
- Default: Not strict during execution. If the lease renewal fails mid-run, Prefect logs a message and the task keeps running. Concurrency may be exceeded temporarily.
- Strict cancellation on lease renewal failure is used for deployment-level concurrency, not tag-based task limits. If you’re seeing runs cancel with messages like “Terminating execution to prevent over-allocation,” that’s usually a deployment concurrency lease, or explicit global concurrency usage with strict=True — not tag tags.
Where to configure/monitor
- CLI: see above.
- UI: Concurrency section. You’ll see “tag:your-tag” entries (tag limits are backed by global concurrency limits under the hood). You can enable/disable, update, and observe slot usage there.
- Docs:
- Tag-based concurrency concepts: Concepts
- How-to (tags): How-to
- CLI (tags): CLI reference
- Global concurrency concepts: Concepts
- CLI (global): CLI reference
Cloud Run specifics (since your jobs run ~6–7 minutes)
- Tag-based task leases renew on a short interval (~60s lease with periodic renewal). Temporary network blips in Cloud Run can cause “Concurrency lease renewal failed” logs. For tag-based limits this should not cancel the task by default; it should log and continue.
- If you’re seeing cancellations/crashes, it’s likely one of:
- Deployment concurrency is enabled for the deployment (that one is strict by default and will cancel the flow if renewal fails).
- You’re using the global concurrency context manager in code with strict=True.
- Recommendation:
- Confirm whether your deployment has “Max concurrent runs” or a deployment concurrency limit enabled.
- Check the exact log line: does it say “Execution will continue…” (soft) or “Terminating execution…” (strict)?
- If you do use global concurrency in code on Cloud Run, consider a longer lease_duration (e.g., 300–600s) and avoid strict=True unless you need hard guarantees.
Quick checklist to improve usage
- Keep tag sets minimal; remember multi-tag is AND’ed.Marvin
12/18/2025, 4:14 PMJosé Agretti
12/18/2025, 4:24 PMMarvin
12/18/2025, 4:24 PMMarvin
12/18/2025, 4:30 PM# Create a queue in your existing work pool
prefect work-queue create my-deploy-q --pool <your-pool>
# Cap concurrency for that queue
prefect work-queue set-concurrency-limit my-deploy-q 2
# See queues and limits
prefect work-queue ls
- Route your deployment to that queue:
- Easiest in UI: Deployment > Edit > Work queue = my-deploy-q
- Or set it at deploy time if you use CLI (UI path is simplest).
- Notes:
- Queue limit <= pool limit (if pool also has a limit).
- This approach avoids concurrency lease renewal at the flow level, so you won’t see “lease renewal failed” logs. It’s a “soft” cap on starting new runs.
Option B — Per‑pool cap (broad brush)
- If you can isolate a deployment into its own work pool, set the pool’s concurrency limit:
prefect work-pool set-concurrency-limit <your-pool> 2
prefect work-pool ls
- This also gates submission (no cancellations), but it applies to all runs in that pool. Use this if you don’t need per‑deployment granularity.
Option C — Per‑flow (soft) using Global Concurrency Limits in code
- Use a Global Concurrency Limit (GCL) with a name per flow/deployment and wrap your flow body. With strict=False (default), failure to renew the lease logs but does not cancel.
- Create the GCL:
prefect gcl create flow:my-flow --limit 2
- Wrap your flow:
from prefect import flow
from prefect.concurrency.sync import concurrency
@flow
def my_flow():
with concurrency("flow:my-flow", strict=False):
# flow body
...
- For async flows:
from prefect.concurrency.asyncio import concurrency
@flow
async def my_flow():
async with concurrency("flow:my-flow", strict=False):
...
- Tradeoffs:
- Pro: Easy, truly per‑flow/deployment, remains “soft” (no cancellations).
- Con: Uses leases under the hood; in Cloud Run you may still see occasional “Concurrency lease renewal failed … execution will continue” logs during network blips.
Key points for your environment (Cloud Run v2, 6–7 min jobs)
- If you want to avoid lease-related logs entirely, prefer Option A (queue limit) or B (pool limit). They control submission and do not rely on leases during execution.
- If you do use Option C, keep strict=False (default). That way, even if a lease renewal fails, the flow keeps running.
Docs and references
- Work pools: Concepts and How-to
- Work queue CLI:
- CLI reference
- Verified commands: prefect work-queue set-concurrency-limit, prefect work-queue clear-concurrency-limit, prefect work-queue ls
- Global concurrency limits: Concepts and CLI reference
Quick suggestion for you
- For “per deployment, non‑strict” caps with Cloud Run, a dedicated work queue per deployment with a queue limit is usually the cleanest and most robust. Do you prefer to manage this via UI or CLI? If you share the deployment name and pool, I can provide exact commands/steps for your setup.José Agretti
12/18/2025, 6:06 PMMarvin
12/18/2025, 6:06 PMMarvin
12/18/2025, 6:07 PM# Create a queue and set its limit in one go
prefect work-queue create my-deploy-q --pool <your-pool> --limit 2
# Or create first, then set the limit
prefect work-queue create my-deploy-q --pool <your-pool>
prefect work-queue set-concurrency-limit my-deploy-q 2
- Point your deployment at that queue:
- UI: Deployment > Edit > Work queue = my-deploy-q (save)
- Verify/monitor:
prefect work-queue ls
prefect work-queue inspect my-deploy-q
Notes
- If your work pool also has a pool-level concurrency limit, the effective concurrency is constrained by both; a queue cannot exceed the pool’s capacity.
- Multiple deployments can share a queue and will share its limit. Use one queue per deployment if you want strict isolation.
- This gates new flow runs at submission time (no mid-run cancellations and no lease renewal noise). Tasks inside those flows are unaffected; if you also need task-level caps, add tag-based concurrency to those tasks.
Docs and references
- Work pools (concepts): Concepts
- Work queue CLI: CLI reference
If you share your work pool name and desired queue/limit, I can give you the exact commands tailored to your setup.José Agretti
12/18/2025, 6:09 PMapi with a limit of 50 runsMarvin
12/18/2025, 6:09 PMMarvin
12/18/2025, 6:09 PM# Create the 'api' work queue in your 'cloud-run-v2' pool with a limit of 50
prefect work-queue create api --pool cloud-run-v2 --limit 50
# Verify it was created and the limit is applied
prefect work-queue inspect api
prefect work-queue ls
Next, point your deployment(s) to the api queue:
- UI: Deployment > Edit > Work queue = api > Save
Notes:
- This caps concurrent flow runs launched from the api queue at 50. It won’t cancel in-flight runs; it just gates new starts.
- If the cloud-run-v2 pool has its own pool-level limit set lower than 50, that lower pool limit will be the effective cap.
- Multiple deployments can share api and will share the 50‑run limit. Create more queues if you want isolation per deployment.
If you want me to double-check your pool’s current limit to ensure it won’t constrain the queue, I can share the command for that too.José Agretti
12/18/2025, 6:20 PMMarvin
12/18/2025, 6:20 PMMarvin
12/18/2025, 6:22 PMJosé Agretti
12/18/2025, 6:47 PMMarvin
12/18/2025, 6:47 PMMarvin
12/18/2025, 6:47 PMapi
- “Ready” = Scheduled with scheduled_time <= now and eligible to start.
- Server computes open slots: open_slots = 50 - active_runs_for_queue
- active_runs_for_queue counts currently-active flow runs for that queue across all workers (e.g., Running/Submitted).
2) Server returns up to open_slots runs
- If open_slots is 0 (because 50 are already active), the server returns an empty list. The remaining Scheduled runs are untouched and remain Scheduled.
3) Worker submits the returned runs
- Those runs transition through Pending → Submitted → Running as usual.
4) Next poll while 50 are still active
- open_slots is still 0 → server returns nothing → Scheduled runs remain Scheduled.
5) When one active run finishes
- active_runs_for_queue drops below 50 → on the next poll, open_slots > 0 and the server returns up to that many Scheduled runs.
Key properties
- Global per-queue cap: enforced across all workers attached to the same queue (not per worker).
- No special state: runs exceeding the cap remain in Scheduled; AwaitingConcurrencySlot is only used by deployment-level concurrency limits.
- Selection order: the server returns eligible runs by earliest scheduled time (and a stable tiebreaker). Queue priority only matters when a worker is polling multiple queues; within a single queue, it’s schedule-time-based.
- Pool-level limit (if set) also applies: the effective throughput is min(queue_limit, pool_limit).
Helpful commands
# See which runs would be returned next from the queue
prefect work-queue preview api
# Inspect queue and confirm the limit
prefect work-queue inspect api
# List all queues and limits
prefect work-queue ls
Docs
- Work pools and queues: Concepts
- Work queue CLI: CLI referenceJosé Agretti
12/18/2025, 6:48 PMMarvin
12/18/2025, 6:48 PMMarvin
12/18/2025, 6:49 PMapi at any moment using:
prefect work-queue preview api