Skip to content

feat(asset): on-demand subtree-data admission control + 503/Retry-After#896

Merged
icellan merged 2 commits into
bsv-blockchain:mainfrom
icellan:feat/asset-admission-control-retry
May 19, 2026
Merged

feat(asset): on-demand subtree-data admission control + 503/Retry-After#896
icellan merged 2 commits into
bsv-blockchain:mainfrom
icellan:feat/asset-admission-control-retry

Conversation

@icellan

@icellan icellan commented May 19, 2026

Copy link
Copy Markdown
Contributor

Split out of #828 so it can be reviewed independently.

Summary

  • New setting asset_concurrency_subtree_data_create (default 4) caps simultaneous on-demand subtree-data file creations triggered by /subtree_data when the file does not yet exist.
  • Wired via a non-blocking TryAcquire in GetSubtreeDataReader: at capacity the handler returns HTTP 503 immediately instead of queueing. Each in-flight create holds chunk-sized tx metadata in memory plus pipe buffers; an unbounded queue here trades graceful 503 for OOM.
  • Peer-side: util.DoHTTPRequestBodyReaderWithRetry + parseRetryAfter honor the Retry-After header with a bounded retry budget. Callers in subtree validation (getSubtreeMissingTxs, CheckBlockSubtrees) and block validation (get_blocks.go) switched to the retry helper. Other errors fail through immediately — only 503 is retried.

Test plan

  • go build ./... clean
  • go vet ./services/asset/... ./services/subtreevalidation/... ./services/blockvalidation/... ./util/... clean
  • make lint-new clean
  • New tests in util/http_test.go cover the retry path and Retry-After parsing
  • Smoke: verify a saturated asset returns 503 + Retry-After under load, callers back off and recover

/subtree_data on-demand creations are now gated by a non-blocking
TryAcquire on a dedicated semaphore (asset_concurrency_subtree_data_create,
default 4). At capacity the handler returns HTTP 503 immediately
instead of queueing — each in-flight create holds chunk-sized tx
metadata in memory plus pipe buffers, so an unbounded queue here is
the difference between graceful 503 and OOM.

Callers that fetch subtree_data over HTTP (subtree validation,
block validation peer fetches) now use DoHTTPRequestBodyReaderWithRetry,
which honors Retry-After on 503 with a bounded retry budget. Other
errors fail through immediately.

Surfaced as a follow-up split out of PR bsv-blockchain#828; reviewable on its own.
@github-actions

github-actions Bot commented May 19, 2026

Copy link
Copy Markdown
Contributor

🤖 Claude Code Review

Status: Complete


Current Review:

No critical issues found

The PR implements admission control and retry logic correctly. The code is well-tested with comprehensive test coverage. One minor observation:

  • [Note] Content-Type inconsistency: doHTTPRequestForStreamingWithRetryAfter (line 454) sets Content-Type: application/json for POST requests, while the main executeHTTPRequest (line 289) uses application/octet-stream. This means retry attempts will use a different Content-Type than the original request path through DoHTTPRequestBodyReader.

Previous inline comment resolved:

  • parseRetryAfter now uses strconv.Atoi for strict numeric validation (line 355), addressing the earlier suggestion about numeric validation.

Summary:

  • Strong implementation of 503/Retry-After admission control
  • Excellent test coverage including edge cases
  • Clean separation of concerns between semaphore-based admission control and retry logic
  • Documentation in settings is comprehensive and helpful

Comment thread util/http.go
@icellan icellan requested review from freemans13 and oskarszoon May 19, 2026 13:13
@github-actions

github-actions Bot commented May 19, 2026

Copy link
Copy Markdown
Contributor

Benchmark Comparison Report

Baseline: main (unknown)

Current: PR-896 (37413aa)

Summary

  • Regressions: 0
  • Improvements: 0
  • Unchanged: 144
  • Significance level: p < 0.05
All benchmark results (sec/op)
Benchmark Baseline Current Change p-value
_NewBlockFromBytes-4 1.190µ 1.194µ ~ 0.600
SplitSyncedParentMap_SetIfNotExists/256_buckets-4 54.87n 55.07n ~ 0.200
SplitSyncedParentMap_SetIfNotExists/16_buckets-4 55.15n 54.99n ~ 0.400
SplitSyncedParentMap_SetIfNotExists/1_bucket-4 54.94n 54.93n ~ 1.000
SplitSyncedParentMap_ConcurrentSetIfNotExists/256_buckets... 24.99n 24.86n ~ 0.400
SplitSyncedParentMap_ConcurrentSetIfNotExists/16_buckets_... 42.82n 42.17n ~ 1.000
SplitSyncedParentMap_ConcurrentSetIfNotExists/1_bucket_pa... 98.43n 97.58n ~ 0.700
MiningCandidate_Stringify_Short-4 167.0n 165.4n ~ 0.200
MiningCandidate_Stringify_Long-4 1.237µ 1.225µ ~ 0.400
MiningSolution_Stringify-4 641.0n 641.1n ~ 1.000
BlockInfo_MarshalJSON-4 1.311µ 1.323µ ~ 0.100
NewFromBytes-4 127.8n 128.8n ~ 0.300
AddTxBatchColumnar_Validation-4 2.588µ 2.532µ ~ 0.700
OffsetValidationLoop-4 636.5n 637.1n ~ 1.000
Mine_EasyDifficulty-4 67.18µ 66.91µ ~ 0.700
Mine_WithAddress-4 6.978µ 7.015µ ~ 1.000
DiskTxMap_SetIfNotExists-4 3.440µ 3.873µ ~ 0.400
DiskTxMap_SetIfNotExists_Parallel-4 3.304µ 3.561µ ~ 0.100
DiskTxMap_ExistenceOnly-4 342.3n 405.2n ~ 0.700
Queue-4 193.2n 196.1n ~ 0.400
AtomicPointer-4 4.380n 4.601n ~ 0.400
ReorgOptimizations/DedupFilterPipeline/Old/10K-4 849.5µ 851.9µ ~ 0.400
ReorgOptimizations/DedupFilterPipeline/New/10K-4 805.8µ 868.6µ ~ 0.100
ReorgOptimizations/AllMarkFalse/Old/10K-4 103.0µ 110.9µ ~ 0.100
ReorgOptimizations/AllMarkFalse/New/10K-4 62.94µ 62.61µ ~ 0.400
ReorgOptimizations/HashSlicePool/Old/10K-4 54.03µ 53.22µ ~ 0.200
ReorgOptimizations/HashSlicePool/New/10K-4 12.42µ 12.05µ ~ 0.100
ReorgOptimizations/NodeFlags/Old/10K-4 4.518µ 4.645µ ~ 0.100
ReorgOptimizations/NodeFlags/New/10K-4 1.625µ 1.623µ ~ 1.000
ReorgOptimizations/DedupFilterPipeline/Old/100K-4 10.09m 10.13m ~ 0.400
ReorgOptimizations/DedupFilterPipeline/New/100K-4 11.04m 10.24m ~ 0.100
ReorgOptimizations/AllMarkFalse/Old/100K-4 1.145m 1.094m ~ 0.100
ReorgOptimizations/AllMarkFalse/New/100K-4 688.3µ 688.7µ ~ 0.700
ReorgOptimizations/HashSlicePool/Old/100K-4 573.0µ 577.1µ ~ 1.000
ReorgOptimizations/HashSlicePool/New/100K-4 309.9µ 336.8µ ~ 1.000
ReorgOptimizations/NodeFlags/Old/100K-4 53.64µ 50.03µ ~ 0.100
ReorgOptimizations/NodeFlags/New/100K-4 17.98µ 16.99µ ~ 0.200
TxMapSetIfNotExists-4 53.03n 53.09n ~ 1.000
TxMapSetIfNotExistsDuplicate-4 39.67n 40.18n ~ 0.400
ChannelSendReceive-4 588.6n 603.7n ~ 0.100
BlockAssembler_AddTx-4 0.02584n 0.02693n ~ 1.000
AddNode-4 10.83 10.90 ~ 0.700
AddNodeWithMap-4 11.88 11.00 ~ 0.100
DirectSubtreeAdd/4_per_subtree-4 57.94n 56.96n ~ 0.200
DirectSubtreeAdd/64_per_subtree-4 29.78n 28.97n ~ 0.100
DirectSubtreeAdd/256_per_subtree-4 27.80n 28.41n ~ 0.700
DirectSubtreeAdd/1024_per_subtree-4 26.52n 26.42n ~ 0.600
DirectSubtreeAdd/2048_per_subtree-4 26.17n 26.32n ~ 0.200
SubtreeProcessorAdd/4_per_subtree-4 292.3n 292.5n ~ 1.000
SubtreeProcessorAdd/64_per_subtree-4 289.5n 290.6n ~ 0.700
SubtreeProcessorAdd/256_per_subtree-4 284.9n 284.8n ~ 0.600
SubtreeProcessorAdd/1024_per_subtree-4 274.4n 277.5n ~ 0.100
SubtreeProcessorAdd/2048_per_subtree-4 277.3n 286.7n ~ 0.400
SubtreeProcessorRotate/4_per_subtree-4 280.1n 283.8n ~ 0.700
SubtreeProcessorRotate/64_per_subtree-4 275.9n 280.5n ~ 0.200
SubtreeProcessorRotate/256_per_subtree-4 276.3n 286.0n ~ 0.100
SubtreeProcessorRotate/1024_per_subtree-4 276.2n 281.2n ~ 0.100
SubtreeNodeAddOnly/4_per_subtree-4 55.20n 54.77n ~ 0.100
SubtreeNodeAddOnly/64_per_subtree-4 35.96n 35.95n ~ 1.000
SubtreeNodeAddOnly/256_per_subtree-4 35.01n 34.96n ~ 0.600
SubtreeNodeAddOnly/1024_per_subtree-4 34.34n 34.39n ~ 0.400
SubtreeCreationOnly/4_per_subtree-4 110.8n 109.9n ~ 0.200
SubtreeCreationOnly/64_per_subtree-4 347.7n 349.2n ~ 1.000
SubtreeCreationOnly/256_per_subtree-4 1.228µ 1.226µ ~ 1.000
SubtreeCreationOnly/1024_per_subtree-4 3.792µ 3.777µ ~ 0.700
SubtreeCreationOnly/2048_per_subtree-4 6.758µ 6.903µ ~ 0.100
SubtreeProcessorOverheadBreakdown/64_per_subtree-4 277.7n 302.1n ~ 0.100
SubtreeProcessorOverheadBreakdown/1024_per_subtree-4 276.4n 281.8n ~ 0.400
ParallelGetAndSetIfNotExists/1k_nodes-4 2.000m 2.074m ~ 0.200
ParallelGetAndSetIfNotExists/10k_nodes-4 5.119m 5.254m ~ 0.100
ParallelGetAndSetIfNotExists/50k_nodes-4 7.081m 7.416m ~ 0.100
ParallelGetAndSetIfNotExists/100k_nodes-4 9.738m 10.272m ~ 0.100
SequentialGetAndSetIfNotExists/1k_nodes-4 1.788m 1.773m ~ 0.700
SequentialGetAndSetIfNotExists/10k_nodes-4 4.422m 4.516m ~ 0.400
SequentialGetAndSetIfNotExists/50k_nodes-4 13.86m 14.04m ~ 0.100
SequentialGetAndSetIfNotExists/100k_nodes-4 25.30m 25.64m ~ 0.200
ProcessOwnBlockSubtreeNodesParallel/1k_nodes-4 2.056m 2.056m ~ 1.000
ProcessOwnBlockSubtreeNodesParallel/10k_nodes-4 8.436m 8.395m ~ 0.700
ProcessOwnBlockSubtreeNodesParallel/100k_nodes-4 13.33m 13.86m ~ 0.100
ProcessOwnBlockSubtreeNodesSequential/1k_nodes-4 1.814m 1.870m ~ 0.100
ProcessOwnBlockSubtreeNodesSequential/10k_nodes-4 8.057m 8.164m ~ 0.100
ProcessOwnBlockSubtreeNodesSequential/100k_nodes-4 43.14m 45.75m ~ 0.100
CalcBlockWork-4 487.7n 492.7n ~ 0.100
CalculateWork-4 683.2n 690.9n ~ 1.000
BuildBlockLocatorString_Helpers/Size_10-4 1.624µ 1.649µ ~ 1.000
BuildBlockLocatorString_Helpers/Size_100-4 12.78µ 12.87µ ~ 0.400
BuildBlockLocatorString_Helpers/Size_1000-4 124.8µ 126.8µ ~ 0.100
CatchupWithHeaderCache-4 104.0m 104.1m ~ 0.700
_prepareTxsPerLevel-4 413.3m 420.3m ~ 1.000
_prepareTxsPerLevelOrdered-4 3.646m 3.546m ~ 0.700
_prepareTxsPerLevel_Comparison/Original-4 419.7m 411.0m ~ 0.400
_prepareTxsPerLevel_Comparison/Optimized-4 3.616m 3.543m ~ 0.100
SubtreeSizes/10k_tx_4_per_subtree-4 1.399m 1.380m ~ 1.000
SubtreeSizes/10k_tx_16_per_subtree-4 324.5µ 325.9µ ~ 1.000
SubtreeSizes/10k_tx_64_per_subtree-4 78.71µ 77.17µ ~ 0.200
SubtreeSizes/10k_tx_256_per_subtree-4 19.59µ 19.13µ ~ 0.100
SubtreeSizes/10k_tx_512_per_subtree-4 9.673µ 9.533µ ~ 0.100
SubtreeSizes/10k_tx_1024_per_subtree-4 4.783µ 4.686µ ~ 0.100
SubtreeSizes/10k_tx_2k_per_subtree-4 2.420µ 2.382µ ~ 0.100
BlockSizeScaling/10k_tx_64_per_subtree-4 76.98µ 75.03µ ~ 0.100
BlockSizeScaling/10k_tx_256_per_subtree-4 19.43µ 19.10µ ~ 0.100
BlockSizeScaling/10k_tx_1024_per_subtree-4 4.867µ 4.715µ ~ 0.100
BlockSizeScaling/50k_tx_64_per_subtree-4 400.0µ 388.1µ ~ 0.200
BlockSizeScaling/50k_tx_256_per_subtree-4 96.84µ 94.45µ ~ 0.100
BlockSizeScaling/50k_tx_1024_per_subtree-4 23.87µ 23.20µ ~ 0.100
SubtreeAllocations/small_subtrees_exists_check-4 160.3µ 163.0µ ~ 0.200
SubtreeAllocations/small_subtrees_data_fetch-4 172.7µ 163.0µ ~ 0.100
SubtreeAllocations/small_subtrees_full_validation-4 332.8µ 327.4µ ~ 0.100
SubtreeAllocations/medium_subtrees_exists_check-4 9.377µ 9.690µ ~ 0.100
SubtreeAllocations/medium_subtrees_data_fetch-4 10.152µ 9.425µ ~ 0.100
SubtreeAllocations/medium_subtrees_full_validation-4 19.62µ 19.10µ ~ 0.100
SubtreeAllocations/large_subtrees_exists_check-4 2.255µ 2.326µ ~ 0.100
SubtreeAllocations/large_subtrees_data_fetch-4 2.487µ 2.276µ ~ 0.100
SubtreeAllocations/large_subtrees_full_validation-4 4.882µ 4.843µ ~ 1.000
_BufferPoolAllocation/16KB-4 4.681µ 3.728µ ~ 0.100
_BufferPoolAllocation/32KB-4 9.371µ 9.922µ ~ 0.700
_BufferPoolAllocation/64KB-4 18.27µ 19.47µ ~ 0.100
_BufferPoolAllocation/128KB-4 27.91µ 31.75µ ~ 0.100
_BufferPoolAllocation/512KB-4 118.1µ 112.1µ ~ 0.400
_BufferPoolConcurrent/32KB-4 19.38µ 18.93µ ~ 0.400
_BufferPoolConcurrent/64KB-4 33.30µ 29.41µ ~ 0.200
_BufferPoolConcurrent/512KB-4 162.1µ 150.1µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/16KB-4 662.7µ 630.4µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/32KB-4 648.8µ 613.2µ ~ 0.700
_SubtreeDeserializationWithBufferSizes/64KB-4 663.8µ 643.6µ ~ 0.200
_SubtreeDeserializationWithBufferSizes/128KB-4 655.6µ 620.0µ ~ 0.200
_SubtreeDeserializationWithBufferSizes/512KB-4 680.2µ 630.7µ ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/16KB-4 38.00m 36.49m ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/32KB-4 37.79m 36.52m ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/64KB-4 37.45m 36.61m ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/128KB-4 37.67m 36.33m ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/512KB-4 37.66m 36.30m ~ 0.100
_PooledVsNonPooled/Pooled-4 835.2n 834.9n ~ 0.700
_PooledVsNonPooled/NonPooled-4 9.196µ 7.248µ ~ 0.100
_MemoryFootprint/Current_512KB_32concurrent-4 7.898µ 8.039µ ~ 0.100
_MemoryFootprint/Proposed_32KB_32concurrent-4 10.94µ 10.29µ ~ 0.100
_MemoryFootprint/Alternative_64KB_32concurrent-4 11.401µ 9.715µ ~ 0.100
StoreBlock_Sequential/BelowCSVHeight-4 251.5µ 251.5µ ~ 0.700
StoreBlock_Sequential/AboveCSVHeight-4 252.3µ 253.2µ ~ 0.100
GetUtxoHashes-4 271.2n 279.7n ~ 0.100
GetUtxoHashes_ManyOutputs-4 45.83µ 44.89µ ~ 0.400
_NewMetaDataFromBytes-4 213.0n 214.5n ~ 0.200
_Bytes-4 397.6n 398.1n ~ 1.000
_MetaBytes-4 338.3n 341.2n ~ 0.400

Threshold: >10% with p < 0.05 | Generated: 2026-05-19 13:42 UTC

…c validation

time.ParseDuration silently accepts fractional, signed and unit-suffixed
inputs that aren't valid RFC 7231 delta-seconds (e.g. "1.5s", "1m",
"-5s"). Switch to strconv.Atoi so anything that isn't a plain
non-negative integer falls through to "no retry hint" instead of being
silently reinterpreted. Extends TestParseRetryAfter with HTTP-date,
fractional, unit-suffix and whitespace cases.
@icellan

icellan commented May 19, 2026

Copy link
Copy Markdown
Contributor Author

Addressed in af7fb36: parseRetryAfter now uses strconv.Atoi, rejecting fractional, signed and unit-suffixed inputs. Extended TestParseRetryAfter covers HTTP-date, fractional, unit-suffix and whitespace cases.

@oskarszoon oskarszoon left a comment

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.

Clean split out of #828, ready to land.

Semaphore + 503 design verified: acquire/release balance correct across all exit paths in GetSubtreeDataReader, setting wired at construction (not per-request), nil semaphore (value=0) correctly treated as unlimited. parseRetryAfter strictly numeric — HTTP-date / fractions / units / whitespace all rejected and tested. Retry helper budget ≤30s wall with ctx cancellation honored on both sleep and in-flight request. All three callers (blockvalidation fetchSubtreeDataFromPeer, subtreevalidation getSubtreeMissingTxs, check_block_subtrees) use the retry correctly; the blockvalidation peer-fallback model is compatible. 1953 tests pass -race.

Memory math: writeTransactionsViaSubtreeStoreStreaming streams 10k-tx chunks (not the full block), so the real worst-case at default concurrency=4 is ~40 MB, not "block_size × 4". No operator concern at the default.

Two doc-only follow-ups worth noting in the PR body for operators:

  1. Retry-After: 1 is a hardcoded fixed value with no jitter. Under burst-503, all callers retry in lockstep 1s later; convergence under heavy contention takes ceil(N/concurrency) retry windows. Bounded by the semaphore, but jitter would smooth it.
  2. During catchup, up to 28 of blockvalidation's 32 concurrent subtree-fetch goroutines can stall up to ~5s when the asset is at create-capacity. The conscious OOM-vs-latency trade-off is the right call but isn't called out in the PR body.

Both are documentation items, not blocking.

@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
69.8% Coverage on New Code (required ≥ 80%)

See analysis details on SonarQube Cloud

@icellan icellan merged commit 1dd3ba6 into bsv-blockchain:main May 19, 2026
25 checks passed
@icellan icellan self-assigned this May 19, 2026
icellan added a commit that referenced this pull request May 19, 2026
Brings in fff6d3e..origin/main (4 commits since previous merge):
  - #897 blockassembly columnar->row fallback on Unimplemented
  - #896 asset on-demand subtree-data admission control + 503/Retry-After
  - #766 utxo: verify spend ownership in Unspend
  - #700 health: disk space check

Conflict resolutions:
  - services/blockassembly/Client.go, util/http.go, util/http_test.go:
    take theirs. Main has the post-Copilot-fix versions of these files
    from #896 and #897 (parseRetryAfter strict-Atoi, debug log on
    columnar fallback, extended TestParseRetryAfter cases) — they
    supersede the pre-fix versions still living on pr-828.
  - stores/utxo/aerospike/un_spend.go: take ours (native-op call,
    no SpendingData arg). The ownership check #766 added to the UDF
    path requires a coordinated update to the BSV-forked aerospike-
    server's subOpUnspend dispatcher. Until that lands, the
    native-op path on this branch skips ownership verification.
    Tracking issue filed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants