@torrent-tv/proxy 2.87.4 → 2.87.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/opsx/apply.md +155 -155
- package/.claude/commands/opsx/archive.md +160 -160
- package/.claude/commands/opsx/explore.md +174 -174
- package/.claude/commands/opsx/propose.md +109 -109
- package/.claude/commands/opsx/sync.md +143 -143
- package/.claude/skills/openspec-apply-change/SKILL.md +159 -159
- package/.claude/skills/openspec-archive-change/SKILL.md +117 -117
- package/.claude/skills/openspec-explore/SKILL.md +289 -289
- package/.claude/skills/openspec-propose/SKILL.md +113 -113
- package/.claude/skills/openspec-sync-specs/SKILL.md +147 -147
- package/.dockerignore +5 -5
- package/AGENTS.md +322 -322
- package/CHANGELOG.md +4 -0
- package/Dockerfile +30 -30
- package/LICENSE +16 -16
- package/README.md +369 -369
- package/assets/calibration/NOTICE.md +100 -100
- package/assets/diagnostics/sctpstate.gdb +31 -31
- package/bin/cli.js +684 -684
- package/bin/thread-pool.js +30 -30
- package/biome.json +411 -411
- package/docs/browser-proxy-contract.md +94 -94
- package/docs/container-architecture.md +218 -218
- package/docs/download-architecture.md +261 -261
- package/docs/encode-architecture.md +729 -729
- package/docs/encode-run-state.md +96 -96
- package/docs/logs.md +97 -97
- package/docs/socks5-egress-research.md +114 -114
- package/docs/storage-architecture.md +267 -267
- package/knip.json +14 -14
- package/openspec/changes/adaptive-bitrate/design.md +92 -92
- package/openspec/changes/adaptive-bitrate/proposal.md +56 -56
- package/openspec/changes/adaptive-bitrate/specs/adaptive-bitrate/spec.md +51 -51
- package/openspec/changes/adaptive-bitrate/tasks.md +40 -40
- package/openspec/changes/binary-distribution/.openspec.yaml +2 -2
- package/openspec/changes/binary-distribution/proposal.md +53 -53
- package/openspec/changes/chunked-request-bodies/design.md +81 -81
- package/openspec/changes/chunked-request-bodies/proposal.md +53 -53
- package/openspec/changes/chunked-request-bodies/specs/chunked-request-bodies/spec.md +30 -30
- package/openspec/changes/chunked-request-bodies/tasks.md +40 -40
- package/openspec/changes/cold-start/design.md +98 -98
- package/openspec/changes/cold-start/proposal.md +62 -62
- package/openspec/changes/cold-start/specs/cold-start/spec.md +47 -47
- package/openspec/changes/cold-start/tasks.md +48 -48
- package/openspec/changes/disk-cap/.openspec.yaml +2 -2
- package/openspec/changes/disk-cap/proposal.md +37 -37
- package/openspec/changes/disk-cap/specs/disk-cap/spec.md +25 -25
- package/openspec/changes/disk-cap/tasks.md +19 -19
- package/openspec/changes/proxy-observability/.openspec.yaml +2 -2
- package/openspec/changes/proxy-observability/design.md +38 -38
- package/openspec/changes/proxy-observability/proposal.md +49 -49
- package/openspec/changes/proxy-observability/specs/observability/spec.md +33 -33
- package/openspec/changes/proxy-observability/tasks.md +21 -21
- package/openspec/changes/seek-debounce/design.md +118 -118
- package/openspec/changes/seek-debounce/proposal.md +59 -59
- package/openspec/changes/seek-debounce/specs/seek-debounce/spec.md +41 -41
- package/openspec/changes/seek-debounce/tasks.md +33 -33
- package/openspec/changes/subtitle-language/.openspec.yaml +2 -2
- package/openspec/changes/subtitle-language/proposal.md +50 -50
- package/openspec/changes/subtitle-language/specs/subtitle-language/spec.md +34 -34
- package/openspec/changes/subtitle-language/tasks.md +26 -26
- package/openspec/changes/track-selection/.openspec.yaml +2 -2
- package/openspec/changes/track-selection/design.md +47 -47
- package/openspec/changes/track-selection/proposal.md +45 -45
- package/openspec/changes/track-selection/specs/track-selection/spec.md +41 -41
- package/openspec/changes/track-selection/tasks.md +20 -20
- package/openspec/changes/transcode-quality/.openspec.yaml +2 -2
- package/openspec/changes/transcode-quality/proposal.md +51 -51
- package/openspec/changes/transcode-quality/specs/transcode-quality/spec.md +139 -139
- package/openspec/changes/transcode-quality/tasks.md +63 -63
- package/openspec/config.yaml +38 -38
- package/package.json +1 -1
- package/public/.well-known/appspecific/com.chrome.devtools.json +1 -1
- package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -329
- package/research/double-spawn-2026-09-10.md +171 -171
- package/research/handover-2026-09-13.md +209 -209
- package/research/handover-reader-claims-removal-2026-09-12.md +166 -166
- package/research/piece-withdrawn-but-still-claimed-2026-09-12.md +175 -175
- package/research/preset-benchmark-size-scaling-2026-08-22.md +55 -55
- package/research/priority-map-is-the-truth-2026-09-12.md +225 -225
- package/routes/api/client-logs/post.js +86 -86
- package/routes/api/delivery-sink/get.js +72 -72
- package/routes/api/link-probe/get.js +51 -51
- package/routes/api/playback-plan/post.js +53 -53
- package/routes/api/sources/files/get.js +100 -100
- package/routes/api/sources/post.js +36 -36
- package/routes/api/sources/stats/get.js +132 -132
- package/routes/api/subtitles/get.js +230 -230
- package/routes/api/transcode-sessions/fragment-far/post.js +60 -60
- package/routes/api/transcode-sessions/net-report/post.js +129 -129
- package/routes/api/transcode-sessions/post.js +160 -160
- package/routes/api/transcode-sessions/progress/get.js +27 -27
- package/routes/api/transcode-sessions/release/post.js +41 -41
- package/routes/api/transcode-sessions/seek/post.js +63 -63
- package/routes/health/get.js +13 -13
- package/routes/healthz/get.js +13 -13
- package/routes/stream/get.js +361 -361
- package/routes/transcode/audio-file/get.js +64 -64
- package/routes/transcode/audio-warm/get.js +84 -84
- package/routes/transcode/session-file/get.js +52 -52
- package/routes/transcode/variant-file/get.js +95 -95
- package/routes/transcode/variant-warm/get.js +84 -84
- package/scripts/render-run-graph.js +173 -173
- package/services/encode/CoverageMap.js +428 -428
- package/services/encode/CushionReport.js +345 -345
- package/services/encode/EncodeAdmission.js +332 -332
- package/services/encode/EncodeOrchestrator.js +1217 -1217
- package/services/encode/EncodePlan.js +1366 -1366
- package/services/encode/EncodeRun.js +776 -776
- package/services/encode/EncodeRuns.js +1229 -1229
- package/services/encode/Encoder.js +142 -142
- package/services/encode/EncoderSelection.js +38 -38
- package/services/encode/LocalObservations.js +359 -359
- package/services/encode/NvencEncoder.js +50 -50
- package/services/encode/OutputOpening.js +755 -755
- package/services/encode/OutputTimes.js +579 -579
- package/services/encode/QsvEncoder.js +58 -58
- package/services/encode/Renditions.js +2162 -2162
- package/services/encode/RunProgress.js +84 -84
- package/services/encode/SegmentDemand.js +95 -95
- package/services/encode/SoftwareEncoder.js +121 -121
- package/services/encode/V4l2m2mEncoder.js +53 -53
- package/services/encode/VaapiEncoder.js +66 -66
- package/services/encode/args.js +416 -416
- package/services/encode/available-share.js +89 -89
- package/services/encode/calibration.js +361 -361
- package/services/encode/contention.js +128 -128
- package/services/encode/decode-cost-fit.js +363 -363
- package/services/encode/encode-exit.js +114 -114
- package/services/encode/encode-run-state.js +551 -551
- package/services/encode/encoder-readings.js +45 -45
- package/services/encode/fingerprint.js +322 -322
- package/services/encode/host-load.js +174 -174
- package/services/encode/hwaccel.js +1894 -1894
- package/services/encode/index.js +9 -9
- package/services/encode/learned-median.js +90 -90
- package/services/encode/output/EncodedOutput.js +57 -57
- package/services/encode/output/Output.js +131 -131
- package/services/encode/output/OutputCatalog.js +414 -414
- package/services/encode/output/OutputSpec.js +390 -390
- package/services/encode/output/Timeline.js +308 -308
- package/services/encode/output/cut-grid.js +107 -107
- package/services/encode/output/index.js +20 -20
- package/services/encode/output/ladder.js +32 -32
- package/services/encode/output/playlists.js +284 -284
- package/services/encode/output/rates.js +125 -125
- package/services/encode/output-key-format.js +21 -21
- package/services/encode/quality/EncodeCost.js +1083 -1083
- package/services/encode/quality/HostLoad.js +525 -525
- package/services/encode/quality/HostTimings.js +249 -249
- package/services/encode/quality/OutputQualityState.js +28 -28
- package/services/encode/quality/QualityController.js +935 -935
- package/services/encode/quality/QualityOffer.js +416 -416
- package/services/encode/quality/drain-threat.js +47 -47
- package/services/encode/quality/link-budget.js +254 -254
- package/services/encode/quality/output-format.js +266 -266
- package/services/encode/quality/serving-output.js +216 -216
- package/services/encode/quality/visible-rung.js +57 -57
- package/services/encode/run-budget.js +97 -97
- package/services/encode/run-command.js +747 -747
- package/services/encode/run-costs.js +155 -155
- package/services/encode/run-inputs.js +42 -42
- package/services/encode/segment-formats/fmp4.js +437 -437
- package/services/encode/segment-formats/index.js +77 -77
- package/services/encode/segment-formats/init-compat.js +324 -324
- package/services/encode/segment-formats/mp4-boxes.js +311 -311
- package/services/encode/segment-formats/mpegts.js +134 -134
- package/services/encode/start-stop-cost.js +178 -178
- package/services/encode/throughput.js +97 -97
- package/services/encode/torrent-cost.js +88 -88
- package/services/identity/installation-id.js +38 -38
- package/services/media/ContainerOrchestrator.js +149 -149
- package/services/media/KeyframeTables.js +316 -316
- package/services/media/SourceFile.js +298 -298
- package/services/media/SubtitleCues.js +520 -520
- package/services/media/SubtitleOrchestrator.js +167 -167
- package/services/media/audio-inventory.js +212 -212
- package/services/media/container/AviContainer.js +266 -266
- package/services/media/container/Container.js +603 -603
- package/services/media/container/ContainerFactory.js +56 -56
- package/services/media/container/KeyframeTable.js +272 -272
- package/services/media/container/MatroskaContainer.js +1494 -1494
- package/services/media/container/Mp4Container.js +1244 -1244
- package/services/media/container/SubtitleFileContainer.js +322 -322
- package/services/media/container/ebml-reader.js +159 -159
- package/services/media/ffmpeg-banner.js +260 -260
- package/services/media/keyframe-probe.js +157 -157
- package/services/media/media-info-probe.js +93 -93
- package/services/media/tracks/AudioTrack.js +131 -131
- package/services/media/tracks/ContainerTrack.js +75 -75
- package/services/media/tracks/ImageSubtitleTrack.js +19 -19
- package/services/media/tracks/SubtitleTrack.js +38 -38
- package/services/media/tracks/TextSubtitleTrack.js +287 -287
- package/services/media/tracks/VideoTrack.js +33 -33
- package/services/media/tracks/index.js +15 -15
- package/services/media/tracks/language-detect.js +174 -174
- package/services/server/OutputLifecycle.js +418 -418
- package/services/server/SegmentServing.js +1146 -1146
- package/services/server/ViewerRequests.js +522 -522
- package/services/server/WaitLedger.js +142 -142
- package/services/server/controllers/PlaybackController.js +36 -36
- package/services/server/controllers/SubtitleController.js +109 -109
- package/services/server/http-responses.js +60 -60
- package/services/server/transcode-session-files.js +325 -325
- package/services/server/wire-outputs.js +983 -983
- package/services/storage/Diagnostics.js +202 -202
- package/services/storage/MachineBudget.js +235 -235
- package/services/storage/allowance.js +107 -107
- package/services/storage/core-dumps.js +106 -106
- package/services/storage/files/CompletedFiles.js +391 -391
- package/services/storage/files/piece-from-whole-file.js +118 -118
- package/services/storage/free.js +71 -71
- package/services/storage/keep.js +48 -48
- package/services/storage/machine-memory.js +56 -56
- package/services/storage/memory-report.js +575 -575
- package/services/storage/piece-store/piece-disk-store.js +516 -516
- package/services/storage/piece-store/piece-lru.js +495 -495
- package/services/storage/piece-store/shared-piece-store.js +1880 -1880
- package/services/storage/returns.js +103 -103
- package/services/storage/segment-store/SegmentStore.js +1196 -1196
- package/services/storage/wire.js +175 -175
- package/services/torrent/Contents.js +324 -324
- package/services/torrent/demand/DemandRegister.js +202 -202
- package/services/torrent/demand/Urgency.js +137 -137
- package/services/torrent/demand/Window.js +118 -118
- package/services/torrent/demand/index.js +8 -8
- package/services/torrent/demand/pieces.js +140 -140
- package/services/torrent/download/SwarmSelection.js +386 -386
- package/services/torrent/download/registry.js +102 -102
- package/services/torrent/download/withdraw-claim.js +80 -80
- package/services/torrent/files.js +455 -455
- package/services/torrent/naming.js +619 -619
- package/services/torrent/piece-store-of.js +29 -29
- package/services/torrent/supply-margin.js +262 -262
- package/services/torrent/torrent-source-key.js +38 -38
- package/services/torrent/worker/background-fill.js +213 -213
- package/services/torrent/worker/channel.js +271 -271
- package/services/torrent/worker/fastest-wires.js +342 -342
- package/services/torrent/worker/handle-state.js +23 -23
- package/services/torrent/worker/held-bytes.js +155 -155
- package/services/torrent/worker/install-webrtc-shim.js +32 -32
- package/services/torrent/worker/piece-reader.js +1078 -1078
- package/services/torrent/worker/resume-warm.js +107 -107
- package/services/torrent/worker/webrtc-shim.js +219 -219
- package/services/transport/data-channel-handler.js +1356 -1356
- package/services/transport/delivery-probe.js +842 -842
- package/services/transport/health-collector.js +53 -53
- package/services/transport/nat-classifier.js +231 -231
- package/services/transport/packet-witness.js +808 -808
- package/services/transport/port-mapper.js +235 -235
- package/services/transport/registry-api.js +81 -81
- package/services/transport/tunnel-client.js +447 -447
- package/services/transport/usrsctp-state.js +175 -175
- package/services/transport/webrtc-manager.js +484 -484
- package/services/viewer/Assignments.js +340 -340
- package/services/viewer/PriorityMap.js +390 -390
- package/services/viewer/PriorityOrchestrator.js +325 -325
- package/services/viewer/Viewer.js +657 -657
- package/services/viewer/Viewers.js +368 -368
- package/services/viewer/active-output.js +37 -37
- package/services/viewer/audio-start.js +46 -46
- package/services/viewer/buffer-trend.js +81 -81
- package/services/viewer/choices.js +647 -647
- package/services/viewer/positions.js +126 -126
- package/services/viewer/report-intake.js +46 -46
- package/store/source-registry.js +71 -73
- package/test/asset-paths.test.js +28 -28
- package/test/audio-inventory.test.js +176 -176
- package/test/audio-track-bitrate.test.js +40 -40
- package/test/auto-quality-step.test.js +707 -707
- package/test/available-share.test.js +76 -76
- package/test/background-fill.test.js +190 -190
- package/test/client-log-file.test.js +110 -110
- package/test/completed-files.test.js +115 -115
- package/test/concurrent-cost.test.js +148 -148
- package/test/container-media-info.test.js +228 -228
- package/test/contention.test.js +76 -76
- package/test/core-dumps.test.js +57 -57
- package/test/coverage-follows-the-disk.test.js +228 -228
- package/test/coverage-map.test.js +195 -195
- package/test/cue-push-carries-what-the-walk-found.test.js +58 -58
- package/test/cut-times-timeline.test.js +65 -65
- package/test/cuts-follow-published-grid.test.js +109 -109
- package/test/data-channel-frame.test.js +63 -63
- package/test/dead-torrent-handle.test.js +52 -52
- package/test/declared-rates.test.js +116 -116
- package/test/declared-tracks.test.js +35 -35
- package/test/decode-cost-fit.test.js +202 -202
- package/test/decode-cost.test.js +462 -462
- package/test/delivery-probe.test.js +393 -393
- package/test/demand-register.test.js +206 -206
- package/test/dht-bootstrap.test.js +72 -72
- package/test/drain-threat.test.js +65 -65
- package/test/duplicate-add.test.js +48 -48
- package/test/encode-admission.test.js +146 -146
- package/test/encode-cost.test.js +283 -283
- package/test/encode-exit.test.js +111 -111
- package/test/encode-orchestrator.test.js +419 -419
- package/test/encode-plan-viewers.test.js +718 -718
- package/test/encode-plan.test.js +545 -545
- package/test/encode-run-state.test.js +314 -314
- package/test/encode-run.test.js +161 -161
- package/test/encode-slope.test.js +46 -46
- package/test/encoded-output.test.js +39 -39
- package/test/encoder-kinds.test.js +122 -122
- package/test/encoder-readings.test.js +46 -46
- package/test/every-claimant-is-told-its-share.test.js +152 -152
- package/test/failed-encoder-still-serves-what-exists.test.js +354 -354
- package/test/failed-start-breaker.test.js +202 -202
- package/test/fastest-wires.test.js +143 -143
- package/test/ffmpeg-stream-counts.test.js +78 -78
- package/test/file-edges.test.js +191 -191
- package/test/fmp4-self-contained.test.js +80 -80
- package/test/fmp4-timestamps.test.js +140 -140
- package/test/fmp4-track-completeness.test.js +41 -41
- package/test/format-keyed-outputs.test.js +78 -78
- package/test/health-metrics.test.js +38 -38
- package/test/held-request-width.test.js +157 -157
- package/test/helpers/encode-run.js +136 -136
- package/test/helpers/held-file.js +29 -29
- package/test/helpers/manager.js +57 -57
- package/test/helpers/output-spec.js +63 -63
- package/test/host-load.test.js +68 -68
- package/test/index-accuracy-is-about-the-file.test.js +128 -128
- package/test/init-compat.test.js +219 -219
- package/test/input-lost-quiets-the-plan.test.js +261 -261
- package/test/input-unavailable.test.js +48 -48
- package/test/installation-id.test.js +39 -39
- package/test/keeping-period.test.js +83 -83
- package/test/keyframe-index-accuracy.test.js +188 -188
- package/test/keyframe-table-budget.test.js +102 -102
- package/test/keyframes-belong-to-the-file.test.js +249 -249
- package/test/learned-median.test.js +37 -37
- package/test/letting-go-asks-who-is-watching.test.js +191 -191
- package/test/limit-row-by-area.test.js +60 -60
- package/test/link-budget.test.js +111 -111
- package/test/link-probe.test.js +56 -56
- package/test/local-encoding-data.test.js +273 -273
- package/test/logger-repeats.test.js +134 -134
- package/test/look-ahead-coverage.test.js +56 -56
- package/test/manager-surface.test.js +292 -292
- package/test/matroska-cues-track.test.js +192 -192
- package/test/memory-budget.test.js +390 -390
- package/test/move-cost.test.js +216 -216
- package/test/mp4-subtitles.test.js +173 -173
- package/test/one-authority.test.js +285 -285
- package/test/one-budget.test.js +149 -149
- package/test/one-output-one-session.test.js +204 -204
- package/test/one-resource-divided.test.js +185 -185
- package/test/one-serving-output-per-height.test.js +458 -458
- package/test/orchestrator-wired.test.js +206 -206
- package/test/output-catalog.test.js +39 -39
- package/test/output-shape.test.js +21 -21
- package/test/output-spec.test.js +251 -251
- package/test/output-speed.test.js +88 -88
- package/test/outputs-being-prepared.test.js +40 -40
- package/test/packet-witness-ring.test.js +236 -236
- package/test/packet-witness.test.js +148 -148
- package/test/piece-disk-store.test.js +381 -381
- package/test/piece-from-whole-file.test.js +129 -129
- package/test/piece-lru.test.js +319 -319
- package/test/piece-reader.test.js +162 -162
- package/test/piece-selection-offset.test.js +85 -85
- package/test/piece-store-eviction.test.js +560 -560
- package/test/piece-store-never-refuses.test.js +153 -153
- package/test/piece-store-reservations.test.js +318 -318
- package/test/piece-store-slow-disk.test.js +159 -159
- package/test/piece-tail.test.js +97 -97
- package/test/plan-host-timings.test.js +68 -68
- package/test/plan-is-steady.test.js +225 -225
- package/test/priority-map-download.test.js +174 -174
- package/test/priority-map-emptied.test.js +204 -204
- package/test/priority-map-per-output.test.js +215 -215
- package/test/priority-map.test.js +163 -163
- package/test/probe-wedge-certainty.test.js +60 -60
- package/test/produced-copy-choice.test.js +213 -213
- package/test/published-timeline.test.js +83 -83
- package/test/quality-offer.test.js +162 -162
- package/test/quality-variants.test.js +1229 -1229
- package/test/rate-control-in-the-output.test.js +180 -180
- package/test/read-survives-withdrawal.test.js +164 -164
- package/test/read-window.test.js +365 -365
- package/test/resume-warm.test.js +39 -39
- package/test/run-graph-drift.test.js +37 -37
- package/test/run-inputs.test.js +80 -80
- package/test/run-intervals.test.js +100 -100
- package/test/run-position-follows-published-grid.test.js +89 -89
- package/test/run-progress.test.js +65 -65
- package/test/same-height-switch.test.js +664 -664
- package/test/seek-frees-the-viewers-requests.test.js +217 -217
- package/test/seek-landing.test.js +109 -109
- package/test/seek-target-not-superseded.test.js +195 -195
- package/test/segment-closed-on-both-branches.test.js +96 -96
- package/test/segment-cut-times.test.js +63 -63
- package/test/segment-demand.test.js +80 -80
- package/test/segment-hold-supersede.test.js +91 -91
- package/test/segment-judged-by-its-own-header.test.js +221 -221
- package/test/segment-serve-wiring.test.js +475 -475
- package/test/segment-store-eviction.test.js +247 -247
- package/test/segment-store.test.js +323 -323
- package/test/segment-true-start.test.js +86 -86
- package/test/segments-are-shared.test.js +176 -176
- package/test/send-chunk-bytes.test.js +86 -86
- package/test/serving-existing-output.test.js +102 -102
- package/test/serving-output.test.js +102 -102
- package/test/shared-piece-store.test.js +310 -310
- package/test/sidecar-naming.test.js +142 -142
- package/test/source-file.test.js +133 -133
- package/test/source-files-route.test.js +103 -103
- package/test/source-registry.test.js +18 -0
- package/test/split-a-stretch.test.js +139 -139
- package/test/stale-request-after-seek.test.js +158 -158
- package/test/startup-readings.test.js +124 -124
- package/test/stream-route.test.js +182 -182
- package/test/subtitle-cue-framing.test.js +200 -200
- package/test/subtitle-cue-source.test.js +102 -102
- package/test/subtitle-cue-walk.test.js +370 -370
- package/test/subtitle-cursor.test.js +63 -63
- package/test/subtitle-defaults.test.js +118 -118
- package/test/subtitle-language.test.js +252 -252
- package/test/subtitle-track-numbering.test.js +386 -386
- package/test/supply-margin.test.js +177 -177
- package/test/swarm-follows-readers.test.js +174 -174
- package/test/swarm-reach.test.js +130 -130
- package/test/swarm-selection.test.js +220 -220
- package/test/tail-duplication.test.js +167 -167
- package/test/the-channel-has-both-ends.test.js +91 -91
- package/test/the-torrent-thread-serves-bytes.test.js +107 -107
- package/test/thread-pool.test.js +53 -53
- package/test/timeline.test.js +84 -84
- package/test/torrent-contents.test.js +235 -235
- package/test/torrent-cost.test.js +71 -71
- package/test/torrent-files.test.js +221 -221
- package/test/torrent-naming.test.js +255 -255
- package/test/tracks-begin-together.test.js +209 -209
- package/test/tunnel-renewal.test.js +167 -167
- package/test/two-viewers-one-picture.test.js +407 -407
- package/test/upload-hurry.test.js +186 -186
- package/test/usrsctp-state.test.js +131 -131
- package/test/video-facts.test.js +102 -102
- package/test/viewer-assignments.test.js +143 -143
- package/test/viewer-outputs.test.js +285 -285
- package/test/viewer-presence.test.js +116 -116
- package/test/viewer-requests-admission.test.js +89 -89
- package/test/viewer-states.test.js +189 -189
- package/test/viewer-subtitle-subscription.test.js +96 -96
- package/test/viewer.test.js +139 -139
- package/test/viewing-generations.test.js +390 -390
- package/test/visible-rung.test.js +37 -37
- package/test/wait-ledger.test.js +100 -100
- package/test/webrtc-shim.test.js +149 -149
- package/test/wedge-certainty.test.js +131 -131
- package/test/withdraw-piece-claim.test.js +212 -212
- package/test/worker-channel.test.js +158 -158
- package/utils/client-log-file.js +151 -151
- package/utils/logger.js +312 -312
- package/utils/parse-range.js +25 -25
- package/utils/perf.js +59 -59
|
@@ -1,1217 +1,1217 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file How many encoders run on this proxy, on which outputs, and over which
|
|
3
|
-
* stretches.
|
|
4
|
-
*
|
|
5
|
-
* The application layer over `encode/`. It holds four things and owns none of
|
|
6
|
-
* their rules:
|
|
7
|
-
*
|
|
8
|
-
* 1. what viewers want (`SegmentDemand`), stated once each and read as a union;
|
|
9
|
-
* 2. what has been made and what is being made, one `CoverageMap` per output;
|
|
10
|
-
* 3. the encoders that are running (`EncodeRun`), each over a stretch;
|
|
11
|
-
* 4. a tally of how every run ended, which is what makes "abnormal endings do
|
|
12
|
-
* not happen" a number rather than an impression.
|
|
13
|
-
*
|
|
14
|
-
* The decision itself is `EncodePlan.planEncoders`, from numbers alone. This
|
|
15
|
-
* carries it out, and everything it cannot know is injected: how many encoders
|
|
16
|
-
* this machine can afford, how a run is built for a given stretch, and which
|
|
17
|
-
* segments already exist.
|
|
18
|
-
*
|
|
19
|
-
* **No viewer reaches the decision.** A viewer states a window and is forgotten
|
|
20
|
-
* as a name; what the plan sees is a union of windows. That is the rule the
|
|
21
|
-
* layer exists for, stated by the user 2026-09-04: requests come from any
|
|
22
|
-
* viewers in any number, encoders are managed to suit them, and viewers get the
|
|
23
|
-
* result when it is ready.
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
import { CoverageMap } from "./CoverageMap.js";
|
|
27
|
-
import { firstUnmetWant, planEncoders } from "./EncodePlan.js";
|
|
28
|
-
import { endOfRun } from "./EncodeRun.js";
|
|
29
|
-
import { ENCODE_EXIT } from "./encode-exit.js";
|
|
30
|
-
import { affordableRuns } from "./run-budget.js";
|
|
31
|
-
import { RunCosts } from "./run-costs.js";
|
|
32
|
-
import { contentionPenalty } from "./contention.js";
|
|
33
|
-
import { SegmentDemand } from "./SegmentDemand.js";
|
|
34
|
-
import { runStateOf } from "./encode-run-state.js";
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* @typedef {Object} SegmentCoverage
|
|
38
|
-
* @property {(address: string) => number[]} provenNumbers
|
|
39
|
-
* @property {(address: string, index: number) => void} announce
|
|
40
|
-
* @property {(address: string) => number} filesHeld
|
|
41
|
-
* @property {(address: string, startedAt: number) => number} clearUpAfter
|
|
42
|
-
*/
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* How long nothing is placed on an output whose input has just gone, and the
|
|
46
|
-
* ceiling that doubling reaches.
|
|
47
|
-
*
|
|
48
|
-
* Both are chosen, and are named here as chosen rather than dressed as
|
|
49
|
-
* measurements: what they bound is not how long the data takes to come back —
|
|
50
|
-
* that is the swarm's business and nobody here can know it — but how often it
|
|
51
|
-
* is worth asking. They replace the same two figures, with the same values,
|
|
52
|
-
* that lived in the session manager and governed only the dead run's own retry
|
|
53
|
-
* while the plan placed fresh runs beside it every half second.
|
|
54
|
-
*/
|
|
55
|
-
const INPUT_QUIET_BASE_MS = 2_000;
|
|
56
|
-
const INPUT_QUIET_MAX_MS = 15_000;
|
|
57
|
-
const START_FAST_FAIL_MS = 2_000;
|
|
58
|
-
const MAX_FAILED_STARTS = 3;
|
|
59
|
-
|
|
60
|
-
export class EncodeOrchestrator {
|
|
61
|
-
/** Derived priority maps. Discardable and rebuilt from viewer state. */
|
|
62
|
-
#demand = new SegmentDemand();
|
|
63
|
-
/** @type {(address: string) => string} */
|
|
64
|
-
#describeWaits = () => "";
|
|
65
|
-
|
|
66
|
-
/** Output address to what has been made of it. @type {Map<string, CoverageMap>} */
|
|
67
|
-
#coverage = new Map();
|
|
68
|
-
|
|
69
|
-
/** @type {SegmentCoverage | null} */
|
|
70
|
-
#segmentCoverage = null;
|
|
71
|
-
|
|
72
|
-
/** Output address to the runs on it. @type {Map<string, import("../encode/EncodeRun.js").EncodeRun[]>} */
|
|
73
|
-
#runs = new Map();
|
|
74
|
-
|
|
75
|
-
/** Last state after an output has no registered run. @type {Map<string, string>} */
|
|
76
|
-
#lastState = new Map();
|
|
77
|
-
|
|
78
|
-
/** Last encoder error per output. @type {Map<string, string>} */
|
|
79
|
-
#lastError = new Map();
|
|
80
|
-
|
|
81
|
-
/** Last progress snapshot per output after its run ends. @type {Map<string, object>} */
|
|
82
|
-
#lastProgress = new Map();
|
|
83
|
-
|
|
84
|
-
/** Consecutive fast failures at one position, per output. @type {Map<string, { at: number, count: number }>} */
|
|
85
|
-
#failedStarts = new Map();
|
|
86
|
-
|
|
87
|
-
/** The fastest speed measured on one output, kept across restarts. @type {Map<string, number>} */
|
|
88
|
-
#lastSpeed = new Map();
|
|
89
|
-
|
|
90
|
-
/** How runs have ended, by cause. @type {Map<string, number>} */
|
|
91
|
-
#endings = new Map();
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* How many times in a row an output's run has died because its input was not
|
|
95
|
-
* there. Cleared only by a run that PRODUCED something, which is the only
|
|
96
|
-
* proof that the input can be read — see `#noteInputAvailability`.
|
|
97
|
-
*
|
|
98
|
-
* @type {Map<string, number>}
|
|
99
|
-
*/
|
|
100
|
-
#inputLostAttempts = new Map();
|
|
101
|
-
|
|
102
|
-
/** Output address to the time before which nothing is placed on it. @type {Map<string, number>} */
|
|
103
|
-
#quietUntil = new Map();
|
|
104
|
-
|
|
105
|
-
/** The wake-up per output, so a quiet output is reconsidered. @type {Map<string, NodeJS.Timeout>} */
|
|
106
|
-
#quietTimers = new Map();
|
|
107
|
-
|
|
108
|
-
/** The last state said out loud, so an unchanged state is not repeated. */
|
|
109
|
-
#lastDescribed = "";
|
|
110
|
-
|
|
111
|
-
/** What a stop and a start have cost on this host. */
|
|
112
|
-
#costs = new RunCosts();
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* What starting and stopping an encoder was measured to cost on this host,
|
|
116
|
-
* before any viewer existed. Told once; real runs replace it as they end.
|
|
117
|
-
*
|
|
118
|
-
* @param {{ firstByteWaitSec: number, killCostSec: number } | null} measured
|
|
119
|
-
* @returns {void}
|
|
120
|
-
*/
|
|
121
|
-
noteStartupCosts(measured) {
|
|
122
|
-
this.#costs.noteStartup(measured);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/** Derived segment demand for one output, copied from the discardable cache. */
|
|
126
|
-
wantedSegmentsOn(address) {
|
|
127
|
-
return [...this.#demand.mapOn(address)];
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/** The last reason a budget was cut, so the same one is not said twice. */
|
|
131
|
-
#lastBudgetReason = new Map();
|
|
132
|
-
|
|
133
|
-
/** The last unmet want said out loud, so a stuck one is said once. */
|
|
134
|
-
#lastUnmet = new Map();
|
|
135
|
-
|
|
136
|
-
/** The last time the machine bound an output's encoders, so it is said once. */
|
|
137
|
-
#lastMachineBound = new Map();
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* @param {object} params
|
|
141
|
-
* @param {(address: string) => number} params.maxRunsFor - How many encoders
|
|
142
|
-
* this machine can afford on one output. The same arithmetic that decides
|
|
143
|
-
* the quality offer; measured per host, never chosen here.
|
|
144
|
-
* @param {(params: { address: string, from: number, to: number, because: string }) =>
|
|
145
|
-
* import("../encode/EncodeRun.js").EncodeRun} params.makeRun - Build a run
|
|
146
|
-
* for a stretch. What to read, what to map and how to cut belong to whoever
|
|
147
|
-
* knows the source.
|
|
148
|
-
* @param {number} params.segmentSeconds
|
|
149
|
-
* @param {import("../encode/contention.js").ContentionPenalties | null}
|
|
150
|
-
* [params.contentionPenalties] - How much slower one encoder runs beside
|
|
151
|
-
* others, MEASURED on this host at startup and keyed by how many others
|
|
152
|
-
* there are. Null until something has measured it, and then the penalty is
|
|
153
|
-
* 1 — a number invented here would be the same mistake as an invented
|
|
154
|
-
* encoding speed.
|
|
155
|
-
* @param {{ info: (line: string) => void, warn: (line: string) => void }} params.logger
|
|
156
|
-
* @param {() => number} [params.now]
|
|
157
|
-
* @param {(address: string) => string} [params.describeWaits] - What the
|
|
158
|
-
* viewers actually waited for on this output, by band, for the state line.
|
|
159
|
-
* HANDED IN: what anybody waited for belongs to the priority layer, and
|
|
160
|
-
* this one may be asked about encoders with no ledger, no clock and no
|
|
161
|
-
* viewer anywhere. Absent, the line simply does not carry it.
|
|
162
|
-
* @param {SegmentCoverage | null} [params.segmentCoverage]
|
|
163
|
-
*/
|
|
164
|
-
constructor({
|
|
165
|
-
maxRunsFor,
|
|
166
|
-
makeRun,
|
|
167
|
-
segmentSeconds,
|
|
168
|
-
contentionPenalties = null,
|
|
169
|
-
refetchSecPerFilmSecond = () => 0,
|
|
170
|
-
startingSpeedFor = () => 0,
|
|
171
|
-
segmentCoverage = null,
|
|
172
|
-
planSoon = null,
|
|
173
|
-
describeWaits = null,
|
|
174
|
-
admission = null,
|
|
175
|
-
logger,
|
|
176
|
-
now
|
|
177
|
-
}) {
|
|
178
|
-
// HOW MANY ENCODERS THE WHOLE MACHINE HOLDS, across outputs. `maxRunsFor`
|
|
179
|
-
// answers for one output as though it were alone; this is what refuses the
|
|
180
|
-
// encoder that would slow everybody below realtime. Absent in a check that
|
|
181
|
-
// is about one output, and then only the per-output limit binds.
|
|
182
|
-
this.admission = admission && typeof admission.placesFor === "function" ? admission : null;
|
|
183
|
-
this.#describeWaits = typeof describeWaits === "function" ? describeWaits : () => "";
|
|
184
|
-
// Only the segment facts and operations this planner needs cross the
|
|
185
|
-
// storage boundary; the concrete storage remains in the server assembly.
|
|
186
|
-
this.#segmentCoverage = segmentCoverage;
|
|
187
|
-
this.maxRunsFor = maxRunsFor;
|
|
188
|
-
// Seconds of swarm time per second of film: what re-encoding material that
|
|
189
|
-
// already exists costs the download, over and above the encoder's own time.
|
|
190
|
-
// Injected, because the film's byte rate and the swarm's are measured
|
|
191
|
-
// elsewhere and this class must not reach for them.
|
|
192
|
-
this.refetchSecPerFilmSecond = refetchSecPerFilmSecond;
|
|
193
|
-
// Measured per host: what a second encoder costs the first. Unmeasured is 1,
|
|
194
|
-
// and then only the budget bounds how many there are.
|
|
195
|
-
this.contentionPenalties = contentionPenalties instanceof Map ? contentionPenalties : null;
|
|
196
|
-
// WHAT THIS HOST ENCODES AT BEFORE ANY RUN HAS REPORTED. The startup
|
|
197
|
-
// benchmark measures it — a real pipeline over real clips, before a viewer
|
|
198
|
-
// exists — so the plan is never asked to compare arrivals with no speed to
|
|
199
|
-
// compute them from. Every run that then works refines it.
|
|
200
|
-
this.startingSpeedFor = startingSpeedFor;
|
|
201
|
-
this.makeRun = makeRun;
|
|
202
|
-
this.segmentSeconds = segmentSeconds;
|
|
203
|
-
this.logger = logger;
|
|
204
|
-
this.now = typeof now === "function" ? now : Date.now;
|
|
205
|
-
// Asked for, not commanded: this class decides, and something else owns the
|
|
206
|
-
// loop that calls it. Absent in a test, where the clock is the test's own.
|
|
207
|
-
this.planSoon = typeof planSoon === "function" ? planSoon : () => undefined;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
/**
|
|
211
|
-
* The map of one output, made on first mention.
|
|
212
|
-
*
|
|
213
|
-
* @param {string} address
|
|
214
|
-
* @returns {CoverageMap}
|
|
215
|
-
*/
|
|
216
|
-
coverageOf(address) {
|
|
217
|
-
let map = this.#coverage.get(address);
|
|
218
|
-
if (!map) {
|
|
219
|
-
map = new CoverageMap();
|
|
220
|
-
this.#coverage.set(address, map);
|
|
221
|
-
}
|
|
222
|
-
return map;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/**
|
|
226
|
-
* Put the map's picture of what is ready back in step with the disk.
|
|
227
|
-
*
|
|
228
|
-
* ONE AUTHORITY ON WHAT EXISTS, AND IT IS THE STORE. The map holds no memory
|
|
229
|
-
* of readiness between calls: it is handed the whole answer, replacing
|
|
230
|
-
* whatever it had, immediately before anything is decided from it. So a
|
|
231
|
-
* segment whose file was discarded with the run that had it open, dropped to
|
|
232
|
-
* make room, or reopened by a run restarting on it stops being ready in the
|
|
233
|
-
* same breath — without anything having to notice and say so.
|
|
234
|
-
*
|
|
235
|
-
* The map used to be filled from outside, by the session manager, with a
|
|
236
|
-
* method that only ever added. Nothing anywhere took a number back. Field
|
|
237
|
-
* 2026-09-07: the map claimed all 482 segments of a film while the directory
|
|
238
|
-
* held nothing, so every arrangement scored perfect, the only encoder was
|
|
239
|
-
* stopped as unnecessary and none was placed again — two sessions in a row
|
|
240
|
-
* with no picture at all.
|
|
241
|
-
*
|
|
242
|
-
* A store is optional here only in the sense that an authority which was not
|
|
243
|
-
* supplied cannot be consulted: without one the map keeps what it was told
|
|
244
|
-
* directly, which is how this class is exercised with plain numbers.
|
|
245
|
-
*
|
|
246
|
-
* @param {string} address
|
|
247
|
-
* @returns {CoverageMap}
|
|
248
|
-
*/
|
|
249
|
-
#upToDateCoverage(address) {
|
|
250
|
-
const coverage = this.coverageOf(address);
|
|
251
|
-
if (this.#segmentCoverage) {
|
|
252
|
-
coverage.setReady(this.#segmentCoverage.provenNumbers(address));
|
|
253
|
-
}
|
|
254
|
-
return coverage;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
/**
|
|
258
|
-
* Where a run started here must stop: the free stretch in front of it.
|
|
259
|
-
*
|
|
260
|
-
* Asked of the one map, brought up to date first. It used to be worked out by
|
|
261
|
-
* the session manager, which reached into this layer for the map and into the
|
|
262
|
-
* store for what was on the disk and put the two together itself — one fact
|
|
263
|
-
* with two owners and a third party carrying it between them, which is how the
|
|
264
|
-
* two came to disagree.
|
|
265
|
-
*
|
|
266
|
-
* @param {object} params
|
|
267
|
-
* @param {string} params.address
|
|
268
|
-
* @param {number} params.from - Where the run will start.
|
|
269
|
-
* @param {object | null} [params.exceptRun] - The run being replaced, whose
|
|
270
|
-
* own claim is not somebody else's material.
|
|
271
|
-
* @param {number} [params.segmentCount] - The output's length, when known.
|
|
272
|
-
* @returns {number} The last number to work through, or `-1` for the end of
|
|
273
|
-
* the film.
|
|
274
|
-
*/
|
|
275
|
-
freeStretchEnd({ address, from, exceptRun = null, segmentCount = 0 }) {
|
|
276
|
-
if (!address) {
|
|
277
|
-
return -1;
|
|
278
|
-
}
|
|
279
|
-
if (segmentCount > 0) {
|
|
280
|
-
this.coverageOf(address).setSegmentCount(segmentCount);
|
|
281
|
-
}
|
|
282
|
-
const coverage = this.#upToDateCoverage(address);
|
|
283
|
-
const start = Math.max(0, from);
|
|
284
|
-
const free = coverage.freeRunFrom(start, exceptRun);
|
|
285
|
-
if (!Number.isFinite(free)) {
|
|
286
|
-
return -1;
|
|
287
|
-
}
|
|
288
|
-
const end = start + Math.max(1, free) - 1;
|
|
289
|
-
return segmentCount > 0 && end >= segmentCount - 1 ? -1 : end;
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/**
|
|
293
|
-
* @param {string} address
|
|
294
|
-
* @returns {import("../encode/EncodeRun.js").EncodeRun[]}
|
|
295
|
-
*/
|
|
296
|
-
runsOn(address) {
|
|
297
|
-
return this.#runs.get(address) ?? [];
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
/**
|
|
301
|
-
* The aggregate state of this output's current runs, or its last ending when
|
|
302
|
-
* none remain.
|
|
303
|
-
*
|
|
304
|
-
* @param {string} address
|
|
305
|
-
* @returns {string}
|
|
306
|
-
*/
|
|
307
|
-
stateOf(address) {
|
|
308
|
-
const runs = this.runsOn(address);
|
|
309
|
-
return runs.length > 0 ? runStateOf(runs) : (this.#lastState.get(address) ?? runStateOf([]));
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
/** @param {string} address @returns {string} */
|
|
313
|
-
errorOf(address) {
|
|
314
|
-
return this.#lastError.get(address) ?? "";
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
/**
|
|
318
|
-
* Progress of the live run that covers a position, or the most recently
|
|
319
|
-
* updated run when no position is supplied.
|
|
320
|
-
*
|
|
321
|
-
* @param {string} address
|
|
322
|
-
* @param {number | null} [index]
|
|
323
|
-
* @returns {object | null}
|
|
324
|
-
*/
|
|
325
|
-
progressOf(address, index = null) {
|
|
326
|
-
const live = this.runsOn(address).filter((run) => run.isAlive);
|
|
327
|
-
const covering = Number.isInteger(index)
|
|
328
|
-
? live.filter((run) => run.from <= index && endOfRun(run) >= index)
|
|
329
|
-
: live;
|
|
330
|
-
const candidates = covering.length > 0 ? covering : live;
|
|
331
|
-
const run = [...candidates].sort(
|
|
332
|
-
(left, right) => (right.progress?.updatedAt ?? 0) - (left.progress?.updatedAt ?? 0)
|
|
333
|
-
)[0];
|
|
334
|
-
return run?.progress?.snapshot?.() ?? this.#lastProgress.get(address) ?? null;
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
/**
|
|
338
|
-
* Whether another start at this position is allowed.
|
|
339
|
-
*
|
|
340
|
-
* @param {string} address
|
|
341
|
-
* @param {number} from
|
|
342
|
-
* @returns {boolean}
|
|
343
|
-
*/
|
|
344
|
-
mayStartAt(address, from) {
|
|
345
|
-
const failed = this.#failedStarts.get(address);
|
|
346
|
-
return failed?.at !== from || failed.count < MAX_FAILED_STARTS;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
/**
|
|
350
|
-
* Record whether a failed run died before it performed sustained work.
|
|
351
|
-
*
|
|
352
|
-
* @param {string} address
|
|
353
|
-
* @param {number} from
|
|
354
|
-
* @param {number} livedMs
|
|
355
|
-
* @returns {{ at: number, count: number, blocked: boolean, limit: number, fastMs: number }}
|
|
356
|
-
*/
|
|
357
|
-
noteStartFailure(address, from, livedMs) {
|
|
358
|
-
if (!(livedMs < START_FAST_FAIL_MS)) {
|
|
359
|
-
this.#failedStarts.delete(address);
|
|
360
|
-
return { at: -1, count: 0, blocked: false, limit: MAX_FAILED_STARTS, fastMs: START_FAST_FAIL_MS };
|
|
361
|
-
}
|
|
362
|
-
const previous = this.#failedStarts.get(address);
|
|
363
|
-
const next = {
|
|
364
|
-
at: from,
|
|
365
|
-
count: previous?.at === from ? previous.count + 1 : 1
|
|
366
|
-
};
|
|
367
|
-
this.#failedStarts.set(address, next);
|
|
368
|
-
return {
|
|
369
|
-
...next,
|
|
370
|
-
blocked: next.count >= MAX_FAILED_STARTS,
|
|
371
|
-
limit: MAX_FAILED_STARTS,
|
|
372
|
-
fastMs: START_FAST_FAIL_MS
|
|
373
|
-
};
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
/**
|
|
377
|
-
* An output that no longer exists and has no run left on it.
|
|
378
|
-
*
|
|
379
|
-
* How its last run ended, what went wrong and which position kept failing are
|
|
380
|
-
* facts about THAT output. The name follows from the key, so the next output
|
|
381
|
-
* with the same parameters has the same address; kept, those facts would
|
|
382
|
-
* greet it as its own — a failed ending makes its first wait throw before any
|
|
383
|
-
* encoder is placed, and nothing would ever clear it.
|
|
384
|
-
*
|
|
385
|
-
* @param {string} address
|
|
386
|
-
*/
|
|
387
|
-
forgetOutput(address) {
|
|
388
|
-
if (this.runsOn(address).length > 0) {
|
|
389
|
-
return;
|
|
390
|
-
}
|
|
391
|
-
this.#lastState.delete(address);
|
|
392
|
-
this.#lastError.delete(address);
|
|
393
|
-
this.#lastProgress.delete(address);
|
|
394
|
-
this.#failedStarts.delete(address);
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
/** @param {string} address @returns {{ at: number, count: number, blocked: boolean, limit: number }} */
|
|
398
|
-
startFailureOf(address) {
|
|
399
|
-
const failure = this.#failedStarts.get(address) ?? { at: -1, count: 0 };
|
|
400
|
-
return { ...failure, blocked: failure.count >= MAX_FAILED_STARTS, limit: MAX_FAILED_STARTS };
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
/**
|
|
404
|
-
* What the map says about one segment of one output, and about the map it
|
|
405
|
-
* sits in.
|
|
406
|
-
*
|
|
407
|
-
* Asked by whoever is holding a request and measuring the wait: a wait means
|
|
408
|
-
* one thing at the top rank and another at the bottom, and telling them apart
|
|
409
|
-
* needs both the segment's own rank and the highest rank stated, since a rank
|
|
410
|
-
* is a position in this map and not an absolute number.
|
|
411
|
-
*
|
|
412
|
-
* AND AN EMPTY MAP IS NOT AN ANSWER. A top rank of zero says the map has not
|
|
413
|
-
* been built yet — a session created a moment ago, before the first pass —
|
|
414
|
-
* and that is a different thing from "nobody is coming". Conflated, every
|
|
415
|
-
* request behind the run reads as absent for as long as a fresh session has
|
|
416
|
-
* no map.
|
|
417
|
-
*
|
|
418
|
-
* It exists because the one caller reached through this object into the store
|
|
419
|
-
* behind it. A reach like that is how a store becomes the surface: the day it
|
|
420
|
-
* changes, a caller two layers away breaks for a reason it cannot see.
|
|
421
|
-
*
|
|
422
|
-
* @param {string} address
|
|
423
|
-
* @param {number} index
|
|
424
|
-
* @returns {{ rank: number, topRank: number }}
|
|
425
|
-
*/
|
|
426
|
-
rankAt(address, index) {
|
|
427
|
-
return this.#demand.rankOf(address, index);
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
/**
|
|
431
|
-
* How many encoder processes are running on this machine right now, over
|
|
432
|
-
* every output.
|
|
433
|
-
*
|
|
434
|
-
* ONE COUNT, and it is this class's because this class is what makes and
|
|
435
|
-
* unmakes runs: adopted the moment one exists and forgotten the moment one
|
|
436
|
-
* ends, both synchronously. The session manager answered the same question
|
|
437
|
-
* twice by walking its own registry — once per SESSION and once per RUN —
|
|
438
|
-
* which agreed only while a session held at most one run. It has held several
|
|
439
|
-
* since the plan was allowed to place them, and the per-session count is the
|
|
440
|
-
* wrong one of the two wherever they differ: what a second encoder costs was
|
|
441
|
-
* measured between ffmpeg PROCESSES, not between outputs.
|
|
442
|
-
*
|
|
443
|
-
* A SUSPENDED run is not counted. It is stopped where it stands and is using
|
|
444
|
-
* nothing; a cost measured beside it is a cost measured alone.
|
|
445
|
-
*
|
|
446
|
-
* @returns {number}
|
|
447
|
-
*/
|
|
448
|
-
/**
|
|
449
|
-
* How many encoders are alive on each output, suspended ones included.
|
|
450
|
-
*
|
|
451
|
-
* What the machine-wide admission counts as holding a place. A suspended
|
|
452
|
-
* encoder is counted here, unlike in {@link runningCount}: that one answers
|
|
453
|
-
* what is competing for the processor this instant, this one what the
|
|
454
|
-
* machine is committed to — and a parked encoder resumes the moment the
|
|
455
|
-
* viewer catches up.
|
|
456
|
-
*
|
|
457
|
-
* @returns {Map<string, number>}
|
|
458
|
-
*/
|
|
459
|
-
liveRunsByAddress() {
|
|
460
|
-
/** @type {Map<string, number>} */
|
|
461
|
-
const byAddress = new Map();
|
|
462
|
-
for (const [address, runs] of this.#runs) {
|
|
463
|
-
const alive = runs.filter((run) => run.isAlive).length;
|
|
464
|
-
if (alive > 0) {
|
|
465
|
-
byAddress.set(address, alive);
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
return byAddress;
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
runningCount() {
|
|
472
|
-
let running = 0;
|
|
473
|
-
for (const runs of this.#runs.values()) {
|
|
474
|
-
for (const run of runs) {
|
|
475
|
-
if (run.isAlive && !run.isSuspended) {
|
|
476
|
-
running += 1;
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
return running;
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
/**
|
|
484
|
-
* How long an output is, once its playlist is known.
|
|
485
|
-
*
|
|
486
|
-
* @param {string} address
|
|
487
|
-
* @param {number} segmentCount
|
|
488
|
-
*/
|
|
489
|
-
setSegmentCount(address, segmentCount) {
|
|
490
|
-
this.coverageOf(address).setSegmentCount(segmentCount);
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
/**
|
|
494
|
-
* Segments that already exist — from a previous life of this process, or
|
|
495
|
-
* because somebody else made them. Told to the map, which is what stops an
|
|
496
|
-
* encoder being started to make them again.
|
|
497
|
-
*
|
|
498
|
-
* @param {string} address
|
|
499
|
-
* @param {Iterable<number>} indexes
|
|
500
|
-
*/
|
|
501
|
-
noteAlreadyMade(address, indexes) {
|
|
502
|
-
for (const index of indexes) {
|
|
503
|
-
this.noteProduced(address, index);
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
/**
|
|
508
|
-
* A viewer states what it is waiting for. Replaces whatever it said before.
|
|
509
|
-
*
|
|
510
|
-
* @param {object} params
|
|
511
|
-
* @param {string} params.claimant
|
|
512
|
-
* @param {string} params.address
|
|
513
|
-
* @param {number} params.from
|
|
514
|
-
* @param {number} params.to
|
|
515
|
-
* @param {number} [params.priority] - Higher is sooner. One viewer states
|
|
516
|
-
* several stretches at once — what must be ready before they set off, what
|
|
517
|
-
* is reachable while they watch it, the rest of the track — and the filling
|
|
518
|
-
* takes them in this order. Absent means one undifferentiated want, which
|
|
519
|
-
* is what a caller that knows only a position states.
|
|
520
|
-
*/
|
|
521
|
-
/**
|
|
522
|
-
* What is wanted of one output, in its own segment numbers.
|
|
523
|
-
*
|
|
524
|
-
* ONE MAP, ALREADY MERGED, AND WITH NOBODY'S NAME ON IT. It is built once per
|
|
525
|
-
* film by the layer that knows where the viewers are; this layer receives it
|
|
526
|
-
* converted into an output's own numbering and never asks who is in it.
|
|
527
|
-
*
|
|
528
|
-
* That replaced a window per viewer per band stated here and merged here,
|
|
529
|
-
* which was the same work done twice in two layers, with the viewer's name as
|
|
530
|
-
* the key of a claim — against the rule that the encoding and the viewer are
|
|
531
|
-
* not connected at all.
|
|
532
|
-
*
|
|
533
|
-
* An empty map says nobody is coming anywhere in this output, and the plan
|
|
534
|
-
* stops its encoders for it. Nothing has to be released when somebody leaves:
|
|
535
|
-
* the map that arrives next simply does not have them in it.
|
|
536
|
-
*
|
|
537
|
-
* @param {string} address
|
|
538
|
-
* @param {{ from: number, to: number, priority: number, withinSeconds: number }[]} zones
|
|
539
|
-
*/
|
|
540
|
-
notePriorityMap(address, zones) {
|
|
541
|
-
this.#demand.state(address, zones);
|
|
542
|
-
// NOBODY IS COMING HERE, so what was remembered about this output's input
|
|
543
|
-
// is about nobody. Kept, those three entries would stay for the life of the
|
|
544
|
-
// process, and the wait they describe would greet whoever opens this output
|
|
545
|
-
// next — a viewer arriving is new information, and one attempt for them is
|
|
546
|
-
// right whatever the last one met.
|
|
547
|
-
if (!Array.isArray(zones) || zones.length === 0) {
|
|
548
|
-
this.#forgetInputState(address);
|
|
549
|
-
}
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
/**
|
|
553
|
-
* A segment has been finished, by whichever run made it.
|
|
554
|
-
*
|
|
555
|
-
* @param {string} address
|
|
556
|
-
* @param {number} index
|
|
557
|
-
*/
|
|
558
|
-
noteProduced(address, index) {
|
|
559
|
-
// Nothing is told to the store: by the time this is called the piece is
|
|
560
|
-
// ALREADY under its served name, because the rename is what closing it
|
|
561
|
-
// means. A statement kept beside the disk would be a second owner of one
|
|
562
|
-
// fact, which removed the stale second owner from the coverage map.
|
|
563
|
-
this.coverageOf(address).markReady(index);
|
|
564
|
-
this.#segmentCoverage?.announce?.(address, index);
|
|
565
|
-
for (const run of this.runsOn(address)) {
|
|
566
|
-
run.noteProduced(index);
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
/**
|
|
571
|
-
* @param {string} address
|
|
572
|
-
* @param {object} run
|
|
573
|
-
* @param {number} speedX
|
|
574
|
-
*/
|
|
575
|
-
noteSpeed(address, wanted, speedX) {
|
|
576
|
-
for (const run of this.runsOn(address)) {
|
|
577
|
-
if (run === wanted) {
|
|
578
|
-
run.noteSpeed(speedX);
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
// HOW FAST THIS MACHINE ENCODES THIS OUTPUT is a property of the machine and
|
|
582
|
-
// the material, not of one process. Read off `run.speedX` alone it was lost
|
|
583
|
-
// at every restart: a moved encoder is a new object that has measured
|
|
584
|
-
// nothing, so the plan fell back to "nothing is known" and stopped comparing
|
|
585
|
-
// arrivals at all — which is every decision in this layer.
|
|
586
|
-
if (speedX > 0 && speedX > (this.#lastSpeed.get(address) ?? 0)) {
|
|
587
|
-
this.#lastSpeed.set(address, speedX);
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
/**
|
|
592
|
-
* Decide and act, for every output anybody wants anything of and every output
|
|
593
|
-
* that still has an encoder on it.
|
|
594
|
-
*
|
|
595
|
-
* Safe to call as often as anything changes: the plan is a function of the
|
|
596
|
-
* state, so a pass that finds nothing to change does nothing.
|
|
597
|
-
*/
|
|
598
|
-
reconcile() {
|
|
599
|
-
const addresses = new Set([...this.#demand.addresses(), ...this.#runs.keys()]);
|
|
600
|
-
for (const address of addresses) {
|
|
601
|
-
this.#reconcileOne(address);
|
|
602
|
-
}
|
|
603
|
-
// WHAT THIS CLASS BELIEVES, said by this class. `describe()` was written
|
|
604
|
-
// and called from nowhere, so on 2026-09-05 the question "why did the plan
|
|
605
|
-
// not see the gap the viewer was stopped at" had to be answered by
|
|
606
|
-
// inference from start and stop lines, and was not answered at all.
|
|
607
|
-
//
|
|
608
|
-
// Printed on CHANGE rather than on a timer: a quiet session says nothing, a
|
|
609
|
-
// session that is deciding something says what it decided, and there is no
|
|
610
|
-
// interval to choose.
|
|
611
|
-
const state = this.describe();
|
|
612
|
-
if (state !== this.#lastDescribed) {
|
|
613
|
-
this.#lastDescribed = state;
|
|
614
|
-
this.logger.info(state);
|
|
615
|
-
}
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
/**
|
|
619
|
-
* @param {string} address
|
|
620
|
-
*/
|
|
621
|
-
#reconcileOne(address) {
|
|
622
|
-
// WHAT EXISTS IS ASKED OF THE DISK, HERE, EVERY TIME. The plan is arithmetic
|
|
623
|
-
// over what is made, what is being made and what is wanted, and the first of
|
|
624
|
-
// those is not this layer's to remember.
|
|
625
|
-
const coverage = this.#upToDateCoverage(address);
|
|
626
|
-
// A run that has ended and said nothing. One built here reports its own
|
|
627
|
-
// ending and is released by `noteEnded`; one ADOPTED from elsewhere — a
|
|
628
|
-
// session whose encoder stopped — has no such promise, and its claim would
|
|
629
|
-
// otherwise sit in the map for the life of the process, telling the plan
|
|
630
|
-
// that a stretch nobody is making is being made. Nothing would ever be
|
|
631
|
-
// started there again.
|
|
632
|
-
for (const run of this.runsOn(address)) {
|
|
633
|
-
if (!run.isAlive && !run.isStopping) {
|
|
634
|
-
this.noteEnded({
|
|
635
|
-
address,
|
|
636
|
-
run,
|
|
637
|
-
ending: ENCODE_EXIT.GONE,
|
|
638
|
-
because: "it is no longer running, and it did not say so"
|
|
639
|
-
});
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
// ITS INPUT WAS NOT THERE A MOMENT AGO, so nothing is placed yet. The sweep
|
|
643
|
-
// above still runs — a claim left by a dead run must be released whatever
|
|
644
|
-
// the reason — and only the placing waits. This is the one thing that makes
|
|
645
|
-
// the delay bind: it used to be timed against the dead run, which the plan
|
|
646
|
-
// does not consult, so a fresh run went to the same place as fast as ffmpeg
|
|
647
|
-
// could fail there.
|
|
648
|
-
//
|
|
649
|
-
// AND ONLY WHILE NOTHING IS PRODUCING THERE. A run still alive on this
|
|
650
|
-
// output is proof the input can be read, whatever a run beside it met, so
|
|
651
|
-
// the wait must not silence an output that is working.
|
|
652
|
-
if (this.#isQuiet(address) && this.runsOn(address).every((run) => !run.isAlive)) {
|
|
653
|
-
return;
|
|
654
|
-
}
|
|
655
|
-
// ONE MAP, NOT ONE WINDOW PER VIEWER PER ZONE.
|
|
656
|
-
//
|
|
657
|
-
// Two viewers a few seconds apart state stretches that overlap, and the plan
|
|
658
|
-
// puts one encoder on each stretch it is given — so unmerged windows buy an
|
|
659
|
-
// encoder per viewer for film they both want, which is the opposite of what
|
|
660
|
-
// sharing the output is for. Merged, the highest rank and the soonest time
|
|
661
|
-
// per number win and the stretches do not overlap, so one encoder serves
|
|
662
|
-
// everyone standing in front of it.
|
|
663
|
-
//
|
|
664
|
-
// Asked of the register, which is the thing that holds the windows. This
|
|
665
|
-
// used to reach into the layer that STATES them for the same arithmetic,
|
|
666
|
-
// which is the coupling the layer rule forbids; the arithmetic itself now
|
|
667
|
-
// lives where it belongs to nobody.
|
|
668
|
-
const windows = this.#demand.mapOn(address);
|
|
669
|
-
const live = this.runsOn(address).filter((run) => run.isAlive);
|
|
670
|
-
// Asked ONCE. It is arithmetic over measurements, but it also says out loud
|
|
671
|
-
// when the reason it cuts the budget changes, so asking it three times in
|
|
672
|
-
// one pass is three chances to say a thing that happened once.
|
|
673
|
-
const maxRuns = this.#withinMachine(address, live, this.#affordableOn(address, live));
|
|
674
|
-
// THE TERMS EVERY ARRIVAL IS COMPUTED FROM, named here so the line below can
|
|
675
|
-
// print them. A decision of this plan is `delay + (index - at) / rate +
|
|
676
|
-
// madeBetween * refetch` against a deadline, so without the rate and the two
|
|
677
|
-
// prices no recorded decision can be reproduced — which is what happened
|
|
678
|
-
// with the one-piece intervals of 2026-09-08: the rate was substituted six
|
|
679
|
-
// times from the speeds the session reported elsewhere and none of them gave
|
|
680
|
-
// the answer the plan had given.
|
|
681
|
-
const costs = this.#costs.seconds();
|
|
682
|
-
const refetchSecPerFilmSecond = this.refetchSecPerFilmSecond(address);
|
|
683
|
-
// The best figure this host has: what a run here is doing now, what one was
|
|
684
|
-
// last measured doing, or what the startup benchmark predicted. The first
|
|
685
|
-
// two are this output's own; the third exists before either.
|
|
686
|
-
const speedX = Math.max(
|
|
687
|
-
live.reduce((best, run) => Math.max(best, run.speedX || 0), 0),
|
|
688
|
-
this.#lastSpeed.get(address) ?? 0,
|
|
689
|
-
this.startingSpeedFor(address) || 0
|
|
690
|
-
);
|
|
691
|
-
const actions = planEncoders({
|
|
692
|
-
coverage,
|
|
693
|
-
windows,
|
|
694
|
-
// The runs themselves. The plan is arithmetic and reads four numbers off
|
|
695
|
-
// each; what it hands back names the run by BEING it, so nothing has to
|
|
696
|
-
// invent a token to refer to one by.
|
|
697
|
-
runs: live,
|
|
698
|
-
maxRuns,
|
|
699
|
-
segmentSeconds: this.segmentSeconds,
|
|
700
|
-
// What a start and a kill cost, measured from this host's own runs rather
|
|
701
|
-
// than written into the code from one machine's reading. Zero until
|
|
702
|
-
// something has been measured, which is the same convention as the
|
|
703
|
-
// refetch price and is stated so the bias is known.
|
|
704
|
-
...costs,
|
|
705
|
-
// What a second of film costs to fetch again, in seconds of swarm time.
|
|
706
|
-
// Answered by whoever measures the film's own byte rate and the swarm's;
|
|
707
|
-
// zero until they have, which makes driving through look cheaper than it
|
|
708
|
-
// is and is stated here so the bias is known.
|
|
709
|
-
refetchSecPerFilmSecond,
|
|
710
|
-
// How much slower one encoder runs beside others, read off this host's own
|
|
711
|
-
// startup measurement. A pure function over a measured table: beyond what
|
|
712
|
-
// was measured it holds the largest reading rather than continuing a curve
|
|
713
|
-
// nothing observed.
|
|
714
|
-
contentionPenaltyFor: (others) => contentionPenalty(others, this.contentionPenalties).penalty,
|
|
715
|
-
speedX
|
|
716
|
-
});
|
|
717
|
-
|
|
718
|
-
// A move is the plan taking a running encoder away from where it already
|
|
719
|
-
// stands, which is exactly the decision that was found wandering back and
|
|
720
|
-
// forth in the field on 2026-09-07 with no way to see why: the "because"
|
|
721
|
-
// line names the comparison in words, never the numbers it was decided
|
|
722
|
-
// from. Said here, once per reconcile, and only when a move actually
|
|
723
|
-
// happens — everything a rerun of the same decision needs: the windows
|
|
724
|
-
// this call saw (priority, the real time, which side of the viewers),
|
|
725
|
-
// the budget, and where every live run stood.
|
|
726
|
-
// WHAT WAS DECIDED AND WITH WHAT INTERVAL, said whenever anything is placed
|
|
727
|
-
// or taken away — not only on a move.
|
|
728
|
-
//
|
|
729
|
-
// Field 2026-09-08 could not be diagnosed from this line: it printed the
|
|
730
|
-
// windows, the budget and where the live runs stood, and NOT the intervals
|
|
731
|
-
// the actions carried. What the session actually did was give every encoder
|
|
732
|
-
// an interval of exactly ONE segment — 63 runs, each making one piece and
|
|
733
|
-
// exiting, twelve of them normally — and the reasons printed beside them
|
|
734
|
-
// read as moves, so the fault was diagnosed three times as something it was
|
|
735
|
-
// not. An interval is what a run is, and it was the one thing missing.
|
|
736
|
-
//
|
|
737
|
-
// AND THE TERMS, for the same reason one step further: an interval says what
|
|
738
|
-
// was decided and the terms say why. `speed` is what every arrival is
|
|
739
|
-
// divided by, so a decision recorded without it can be re-read and not
|
|
740
|
-
// recomputed; `firstByte` and `kill` are what a start and a stop cost here;
|
|
741
|
-
// `refetch` is what a second of film costs to fetch again. A zero in the
|
|
742
|
-
// last three is a measurement nobody has taken, not a free operation, and it
|
|
743
|
-
// is printed so that reading it as free is a choice rather than an accident.
|
|
744
|
-
if (actions.some((action) => action.type !== "keep")) {
|
|
745
|
-
this.logger.info(
|
|
746
|
-
`encode-plan on ${address}: ` +
|
|
747
|
-
`${actions.map((action) => `${action.type} #${action.from ?? "?"}..#${action.to ?? "?"}`).join(", ")}` +
|
|
748
|
-
` [speed=${speedX.toFixed(2)}x firstByte=${costs.firstByteWaitSec.toFixed(2)}s ` +
|
|
749
|
-
`kill=${costs.killCostSec.toFixed(2)}s refetch=${refetchSecPerFilmSecond.toFixed(3)}s/s ` +
|
|
750
|
-
`maxRuns=${maxRuns} live=${live.length}]`
|
|
751
|
-
);
|
|
752
|
-
}
|
|
753
|
-
if (actions.some((action) => action.type === "move")) {
|
|
754
|
-
this.logger.info(
|
|
755
|
-
`encode-plan move on ${address}: windows=${JSON.stringify(windows)} ` +
|
|
756
|
-
`maxRuns=${maxRuns} ` +
|
|
757
|
-
`live=${JSON.stringify(live.map((run) => ({ from: run.from, to: run.to, head: run.head, speedX: run.speedX })))}`
|
|
758
|
-
);
|
|
759
|
-
}
|
|
760
|
-
for (const action of actions) {
|
|
761
|
-
if (action.type === "stop") {
|
|
762
|
-
this.#stop(action.run, action.because);
|
|
763
|
-
continue;
|
|
764
|
-
}
|
|
765
|
-
if (action.type === "move") {
|
|
766
|
-
// A running encoder's position cannot be changed — it is fixed when the
|
|
767
|
-
// process starts — so a move is this one ending and another beginning
|
|
768
|
-
// where the material is missing. Both halves are recorded as what they
|
|
769
|
-
// are, which is why the ending of a moved run is not called normal.
|
|
770
|
-
this.#stop(action.run, action.because);
|
|
771
|
-
this.#start(address, action.from, action.to, action.because);
|
|
772
|
-
continue;
|
|
773
|
-
}
|
|
774
|
-
if (action.type === "start") {
|
|
775
|
-
this.#start(address, action.from, action.to, action.because);
|
|
776
|
-
continue;
|
|
777
|
-
}
|
|
778
|
-
// A run that stays keeps its claim current: the free stretch ahead of it
|
|
779
|
-
// may have shrunk since it was given one.
|
|
780
|
-
//
|
|
781
|
-
// THE CLAIM IS THE STRETCH IT WAS GIVEN, and there is one rule for that
|
|
782
|
-
// everywhere. It used to be narrowed here to what the run had already
|
|
783
|
-
// MADE whenever the run had no end, which meant a run claimed the single
|
|
784
|
-
// number it was writing and nothing beyond. The plan then read the road
|
|
785
|
-
// in front of a working encoder as free and started more encoders on it:
|
|
786
|
-
// three processes writing one directory with the same names, field
|
|
787
|
-
// 2026-09-06, and a piece of the film lost for good when the first of
|
|
788
|
-
// them was cleaned up after.
|
|
789
|
-
//
|
|
790
|
-
// The worry that narrowing was written for is real and is answered where
|
|
791
|
-
// it belongs — a viewer opening the same film further in must not find
|
|
792
|
-
// every number taken. That is the plan's business, and the plan can take
|
|
793
|
-
// road away from a run that has no end, because such a run carries no
|
|
794
|
-
// `-to` and simply stops when its head meets somebody else's claim.
|
|
795
|
-
coverage.claim(action.run, action.from, endOfRun({ from: action.from, to: action.to }));
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
// NOBODY IS MAKING WHAT SOMEBODY IS WAITING FOR. Said here, with the numbers
|
|
799
|
-
// the decision was taken from, because it is the one state in which a viewer
|
|
800
|
-
// waits for ever and every line above it reads as a healthy proxy.
|
|
801
|
-
//
|
|
802
|
-
// Field 2026-09-07, twice in one evening: the last word about an output was
|
|
803
|
-
// "the film is no worse off without it", and after it nothing — no run, no
|
|
804
|
-
// refusal, no answer to the browser's request for the header. The wait ended
|
|
805
|
-
// at the browser's own timeout with a message naming no cause, and the
|
|
806
|
-
// proxy's log named none either.
|
|
807
|
-
//
|
|
808
|
-
// SAID ONCE PER STATE, not once per pass. A stuck output is reconciled on
|
|
809
|
-
// every event that touches it, and a line repeated for as long as the state
|
|
810
|
-
// lasts is what buried the last one: 49 295 copies of `send queue stuck` in
|
|
811
|
-
// a 159 090-line file, 31 % of the log, all of one wedge.
|
|
812
|
-
const wanting = firstUnmetWant(coverage, windows);
|
|
813
|
-
const stillRunning = this.runsOn(address).filter((run) => run.isAlive);
|
|
814
|
-
if (wanting === null || stillRunning.length > 0) {
|
|
815
|
-
this.#lastUnmet.delete(address);
|
|
816
|
-
} else if (this.#lastUnmet.get(address) !== wanting) {
|
|
817
|
-
this.#lastUnmet.set(address, wanting);
|
|
818
|
-
const held = this.#segmentCoverage ? this.#segmentCoverage.filesHeld(address) : -1;
|
|
819
|
-
this.logger.warn(
|
|
820
|
-
`encode: #${wanting} of ${address} is wanted and NO ENCODER IS MAKING IT — ` +
|
|
821
|
-
`ready=${coverage.stats().ready} of ${coverage.segmentCount} ` +
|
|
822
|
-
`files=${held < 0 ? "?" : held} maxRuns=${maxRuns} ` +
|
|
823
|
-
`windows=${JSON.stringify(windows)}`
|
|
824
|
-
);
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
/**
|
|
829
|
-
* @param {string} address
|
|
830
|
-
* @param {number} from
|
|
831
|
-
* @param {number} to
|
|
832
|
-
* @param {string} because
|
|
833
|
-
*/
|
|
834
|
-
#start(address, from, to, because) {
|
|
835
|
-
// The encoder is built here and now: whoever builds one waits for nothing,
|
|
836
|
-
// so it exists by the time this line returns. That is what makes the
|
|
837
|
-
// stretch held from this instant — this class knows what it is making
|
|
838
|
-
// because it has just made it, and no second encoder can be started for the
|
|
839
|
-
// same stretch on the next pass.
|
|
840
|
-
//
|
|
841
|
-
// It was not always so. The builder used to answer with nothing and start
|
|
842
|
-
// the encoder behind the answer, so the stretch stayed FREE for as long as
|
|
843
|
-
// that took, and every pass in between started another one: 684 starts in
|
|
844
|
-
// 482 seconds of field 2026-09-05, of which 973 answers said the encoder
|
|
845
|
-
// was not there yet — every start without exception.
|
|
846
|
-
//
|
|
847
|
-
// The run names itself: identity is a property of the thing, and two
|
|
848
|
-
// places minting names is how one stops being unique.
|
|
849
|
-
const run = this.makeRun({ address, from, to, because });
|
|
850
|
-
if (!run) {
|
|
851
|
-
// A refusal, not a wait: no session serves this output, or this position
|
|
852
|
-
// has failed to start too many times running.
|
|
853
|
-
this.logger.warn(`encode: no encoder could be made for #${from}..#${to} of ${address}`);
|
|
854
|
-
return;
|
|
855
|
-
}
|
|
856
|
-
// What this machine has been measured to do on this output, carried over.
|
|
857
|
-
// A restart does not make the machine slower, and without this every moved
|
|
858
|
-
// encoder began as one whose speed nothing had measured — which the plan
|
|
859
|
-
// reads as "no arrival can be computed" and answers by comparing nothing.
|
|
860
|
-
const known = this.#lastSpeed.get(address) ?? 0;
|
|
861
|
-
if (known > 0) {
|
|
862
|
-
run.noteSpeed(known);
|
|
863
|
-
}
|
|
864
|
-
const onThisOutput = this.#runs.get(address) ?? [];
|
|
865
|
-
onThisOutput.push(run);
|
|
866
|
-
this.#runs.set(address, onThisOutput);
|
|
867
|
-
// Nothing has to be un-proved when a run takes a stretch that has already
|
|
868
|
-
// been written. A piece is only ever NAMED as served once it is closed, and
|
|
869
|
-
// a run writing that number again writes under a working name until it
|
|
870
|
-
// closes its own — so the file standing there is a complete piece made by
|
|
871
|
-
// somebody, and serving it is right until the newer one replaces it whole.
|
|
872
|
-
// The claim comes after the run exists, and that is not a race: everything
|
|
873
|
-
// between is synchronous, and a process cannot say a word before the next
|
|
874
|
-
// tick. What it removes is the second act — building a run and starting it
|
|
875
|
-
// were two steps, and two owners each performed the second one.
|
|
876
|
-
this.coverageOf(address).claim(run, from, endOfRun({ from, to }));
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
/**
|
|
880
|
-
* How many encoders may run on this output, from every limit at once.
|
|
881
|
-
*
|
|
882
|
-
* The processor is one of them and is answered from outside, where the
|
|
883
|
-
* machine is measured. The other two are known here: what the swarm delivers,
|
|
884
|
-
* through the seconds of swarm time a second of film costs, and — once it is
|
|
885
|
-
* supplied — the memory the piece store may hold against what one encoder's
|
|
886
|
-
* reader keeps.
|
|
887
|
-
*
|
|
888
|
-
* Said out loud when it is not the processor that decided, because "why is
|
|
889
|
-
* there only one encoder" is otherwise a question no log can answer.
|
|
890
|
-
*
|
|
891
|
-
* @param {string} address
|
|
892
|
-
* @param {{ speedX: number }[]} live
|
|
893
|
-
* @returns {number}
|
|
894
|
-
*/
|
|
895
|
-
#affordableOn(address, live) {
|
|
896
|
-
const byProcessor = Math.max(0, this.maxRunsFor(address));
|
|
897
|
-
// The best figure this host has: what a run here is doing now, what one was
|
|
898
|
-
// last measured doing, or what the startup benchmark predicted. The first
|
|
899
|
-
// two are this output's own; the third exists before either, so the budget
|
|
900
|
-
// is never asked to price encoders at a speed of zero.
|
|
901
|
-
const fastest = Math.max(
|
|
902
|
-
live.reduce((best, run) => Math.max(best, run.speedX || 0), 0),
|
|
903
|
-
this.#lastSpeed.get(address) ?? 0,
|
|
904
|
-
this.startingSpeedFor(address) || 0
|
|
905
|
-
);
|
|
906
|
-
const budget = affordableRuns({
|
|
907
|
-
byProcessor,
|
|
908
|
-
speedX: fastest,
|
|
909
|
-
refetchSecPerFilmSecond: this.refetchSecPerFilmSecond(address),
|
|
910
|
-
// How much slower one encoder runs beside others, read off this host's own
|
|
911
|
-
// startup measurement. A pure function over a measured table: beyond what
|
|
912
|
-
// was measured it holds the largest reading rather than continuing a curve
|
|
913
|
-
// nothing observed.
|
|
914
|
-
contentionPenaltyFor: (others) => contentionPenalty(others, this.contentionPenalties).penalty
|
|
915
|
-
});
|
|
916
|
-
if (budget.runs !== byProcessor && budget.because !== this.#lastBudgetReason.get(address)) {
|
|
917
|
-
this.#lastBudgetReason.set(address, budget.because);
|
|
918
|
-
this.logger.info(
|
|
919
|
-
`encode: ${budget.runs} encoder(s) on ${address.slice(0, 60)} — ${budget.because} ` +
|
|
920
|
-
`(the processor alone would allow ${byProcessor})`
|
|
921
|
-
);
|
|
922
|
-
}
|
|
923
|
-
return budget.runs;
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
/**
|
|
927
|
-
* The per-output limit, bounded by what the whole machine can still hold.
|
|
928
|
-
*
|
|
929
|
-
* The per-output answer is at least one whatever else is running; the
|
|
930
|
-
* machine's may be none, and that is the refusal: the output gets no encoder
|
|
931
|
-
* until one elsewhere ends. It is never below what already runs here, so a
|
|
932
|
-
* refusal stops a start and never takes an encoder away.
|
|
933
|
-
*
|
|
934
|
-
* Said out loud when the machine is what bound it, once per change.
|
|
935
|
-
*
|
|
936
|
-
* @param {string} address
|
|
937
|
-
* @param {object[]} live
|
|
938
|
-
* @param {number} perOutput
|
|
939
|
-
* @returns {number}
|
|
940
|
-
*/
|
|
941
|
-
#withinMachine(address, live, perOutput) {
|
|
942
|
-
if (!this.admission) {
|
|
943
|
-
return perOutput;
|
|
944
|
-
}
|
|
945
|
-
const machine = this.admission.placesFor(address, live.length);
|
|
946
|
-
const runs = Math.min(perOutput, machine.runs);
|
|
947
|
-
const said = runs < perOutput ? `${runs}:${machine.because}` : "";
|
|
948
|
-
if (said !== (this.#lastMachineBound.get(address) ?? "")) {
|
|
949
|
-
if (said) {
|
|
950
|
-
this.logger.info(
|
|
951
|
-
`encode: ${runs} encoder(s) on ${address} — the whole machine holds no more ` +
|
|
952
|
-
`(${machine.because}; this output alone would be allowed ${perOutput})`
|
|
953
|
-
);
|
|
954
|
-
this.#lastMachineBound.set(address, said);
|
|
955
|
-
} else {
|
|
956
|
-
this.#lastMachineBound.delete(address);
|
|
957
|
-
}
|
|
958
|
-
}
|
|
959
|
-
return runs;
|
|
960
|
-
}
|
|
961
|
-
|
|
962
|
-
/**
|
|
963
|
-
* Take charge of a run this class did not start.
|
|
964
|
-
*
|
|
965
|
-
* The browser asks for a stream and a run begins for it, long before this
|
|
966
|
-
* class has an opinion. Left unknown, that run would be invisible to the plan
|
|
967
|
-
* — which would then start a second encoder over the same numbers, believing
|
|
968
|
-
* nothing was being made there. So whoever starts one hands it over, and from
|
|
969
|
-
* then on it is planned like any other.
|
|
970
|
-
*
|
|
971
|
-
* @param {string} address
|
|
972
|
-
* @param {{ id: string, from: number, to: number, head: number, speedX: number, isAlive: boolean, stop: (because: string) => void }} run
|
|
973
|
-
*/
|
|
974
|
-
adopt(address, run) {
|
|
975
|
-
if (!run) {
|
|
976
|
-
return;
|
|
977
|
-
}
|
|
978
|
-
const onThisOutput = this.#runs.get(address) ?? [];
|
|
979
|
-
if (onThisOutput.includes(run)) {
|
|
980
|
-
return;
|
|
981
|
-
}
|
|
982
|
-
onThisOutput.push(run);
|
|
983
|
-
this.#runs.set(address, onThisOutput);
|
|
984
|
-
this.coverageOf(address).claim(run, run.from, endOfRun(run));
|
|
985
|
-
}
|
|
986
|
-
|
|
987
|
-
/**
|
|
988
|
-
* @param {object} run
|
|
989
|
-
* @param {string} because
|
|
990
|
-
*/
|
|
991
|
-
#stop(run, because) {
|
|
992
|
-
run.stop(because);
|
|
993
|
-
}
|
|
994
|
-
|
|
995
|
-
/**
|
|
996
|
-
* A run has ended, however it ended. Its stretch goes back to the map — what
|
|
997
|
-
* it finished stays made — and the ending is counted.
|
|
998
|
-
*
|
|
999
|
-
* Wired by whoever builds the run, so that a run built outside this class is
|
|
1000
|
-
* still accounted for.
|
|
1001
|
-
*
|
|
1002
|
-
* @param {import("../encode/EncodeRun.js").RunEnded} ended
|
|
1003
|
-
*/
|
|
1004
|
-
noteEnded(ended) {
|
|
1005
|
-
this.#lastState.set(ended.address, ended.run?.state ?? "IDLE");
|
|
1006
|
-
if (ended.lastError) {
|
|
1007
|
-
this.#lastError.set(ended.address, ended.lastError);
|
|
1008
|
-
} else if (ended.because && ended.ending !== ENCODE_EXIT.COMPLETE) {
|
|
1009
|
-
this.#lastError.set(ended.address, ended.because);
|
|
1010
|
-
}
|
|
1011
|
-
const progress = ended.run?.progress?.snapshot?.();
|
|
1012
|
-
if (progress) {
|
|
1013
|
-
this.#lastProgress.set(ended.address, progress);
|
|
1014
|
-
}
|
|
1015
|
-
this.#costs.note(ended);
|
|
1016
|
-
// Exactly one ending is normal — the run reached the end of the stretch it
|
|
1017
|
-
// was given and closed its last file. Every other leaves a piece open, and
|
|
1018
|
-
// that piece is under this run's OWN working name, so clearing up after it
|
|
1019
|
-
// is a name match: no stretch to search and no bytes to judge. Done for
|
|
1020
|
-
// every ending, the normal one included, since a run that finished cleanly
|
|
1021
|
-
// has nothing under a working name and the sweep then removes nothing.
|
|
1022
|
-
this.#segmentCoverage?.clearUpAfter(ended.address, ended.from);
|
|
1023
|
-
this.coverageOf(ended.address).release(ended.run);
|
|
1024
|
-
const remaining = this.runsOn(ended.address).filter((run) => run !== ended.run);
|
|
1025
|
-
if (remaining.length === 0) {
|
|
1026
|
-
this.#runs.delete(ended.address);
|
|
1027
|
-
} else {
|
|
1028
|
-
this.#runs.set(ended.address, remaining);
|
|
1029
|
-
}
|
|
1030
|
-
this.#endings.set(ended.ending, (this.#endings.get(ended.ending) ?? 0) + 1);
|
|
1031
|
-
this.#noteInputAvailability(ended);
|
|
1032
|
-
}
|
|
1033
|
-
|
|
1034
|
-
/**
|
|
1035
|
-
* Remember, per output, that its input was not there — and until when nothing
|
|
1036
|
-
* is to be placed on it.
|
|
1037
|
-
*
|
|
1038
|
-
* **Why the plan has to hold this.** A run whose input has gone is not alive,
|
|
1039
|
-
* so the plan sees the stretch it held as free and places another run there at
|
|
1040
|
-
* once — and the next one dies the same way, because nothing about the state
|
|
1041
|
-
* has changed. There WAS a delay for exactly this, doubling from 2 s to 15 s,
|
|
1042
|
-
* and it governed only the dead run's own retry while the plan went on placing
|
|
1043
|
-
* fresh ones beside it. The comment beside that timer predicted this in as
|
|
1044
|
-
* many words and the code did not prevent it. Field 2026-09-12: 2432 ffmpeg
|
|
1045
|
-
* starts in 23 minutes, one every 0.57 s, for 61 minutes, against a delay that
|
|
1046
|
-
* had long since reached its 15 s cap.
|
|
1047
|
-
*
|
|
1048
|
-
* So the delay lives where the decision is taken. It is not a cure for the
|
|
1049
|
-
* input being away — that is the store's claim being withdrawn and the piece
|
|
1050
|
-
* being fetched again — it is what stops one absent input from costing a
|
|
1051
|
-
* thousand processes and a quarter of a million log lines while it is away.
|
|
1052
|
-
*
|
|
1053
|
-
* @param {{ address: string, ending: string }} ended
|
|
1054
|
-
* @returns {void}
|
|
1055
|
-
*/
|
|
1056
|
-
#noteInputAvailability(ended) {
|
|
1057
|
-
if (ended.ending !== ENCODE_EXIT.INPUT_LOST) {
|
|
1058
|
-
// CLEARED ONLY BY PROOF THAT THE INPUT WAS THERE, which is a segment
|
|
1059
|
-
// having come out of it. Any other ending was nearly the rule here and is
|
|
1060
|
-
// wrong: at the moment of failure several runs end at once, and one of
|
|
1061
|
-
// them ending `gone` or `stopped` without producing a thing says nothing
|
|
1062
|
-
// about the input — it would have lifted the wait the one beside it had
|
|
1063
|
-
// just set, which is the storm again with an extra step.
|
|
1064
|
-
const produced =
|
|
1065
|
-
ended.firstOutputMs !== null && ended.firstOutputMs !== undefined
|
|
1066
|
-
? true
|
|
1067
|
-
: Number.isFinite(ended.reached) && Number.isFinite(ended.from) && ended.reached >= ended.from;
|
|
1068
|
-
if (produced) {
|
|
1069
|
-
this.#inputLostAttempts.delete(ended.address);
|
|
1070
|
-
this.#quietUntil.delete(ended.address);
|
|
1071
|
-
}
|
|
1072
|
-
return;
|
|
1073
|
-
}
|
|
1074
|
-
const attempts = (this.#inputLostAttempts.get(ended.address) ?? 0) + 1;
|
|
1075
|
-
this.#inputLostAttempts.set(ended.address, attempts);
|
|
1076
|
-
const delayMs = Math.min(
|
|
1077
|
-
INPUT_QUIET_MAX_MS,
|
|
1078
|
-
INPUT_QUIET_BASE_MS * 2 ** Math.min(attempts - 1, 6)
|
|
1079
|
-
);
|
|
1080
|
-
this.#quietUntil.set(ended.address, this.now() + delayMs);
|
|
1081
|
-
this.logger.info(
|
|
1082
|
-
`encode-plan on ${ended.address}: its input was not there (attempt ${attempts}) — ` +
|
|
1083
|
-
`placing nothing for ${Math.round(delayMs / 1000)}s`
|
|
1084
|
-
);
|
|
1085
|
-
// THE WAKE-UP, because a plan that refuses to act needs something to ask it
|
|
1086
|
-
// again: nothing about the state changes while the data is away, so no event
|
|
1087
|
-
// would arrive to reconsider it.
|
|
1088
|
-
const timer = setTimeout(() => {
|
|
1089
|
-
this.#quietTimers.delete(ended.address);
|
|
1090
|
-
this.planSoon();
|
|
1091
|
-
}, delayMs);
|
|
1092
|
-
timer.unref?.();
|
|
1093
|
-
const previous = this.#quietTimers.get(ended.address);
|
|
1094
|
-
if (previous) {
|
|
1095
|
-
clearTimeout(previous);
|
|
1096
|
-
}
|
|
1097
|
-
this.#quietTimers.set(ended.address, timer);
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
/**
|
|
1101
|
-
* Whether this output is still waiting before anything may be placed on it.
|
|
1102
|
-
*
|
|
1103
|
-
* A boolean rather than the milliseconds left: the figure had one reader and
|
|
1104
|
-
* that reader only asked whether it was above zero, so it was a number
|
|
1105
|
-
* computed and thrown away. How long the wait is was said when it began.
|
|
1106
|
-
*
|
|
1107
|
-
* @param {string} address
|
|
1108
|
-
* @returns {boolean}
|
|
1109
|
-
*/
|
|
1110
|
-
#isQuiet(address) {
|
|
1111
|
-
const until = this.#quietUntil.get(address);
|
|
1112
|
-
if (!Number.isFinite(until)) {
|
|
1113
|
-
return false;
|
|
1114
|
-
}
|
|
1115
|
-
if (until - this.now() <= 0) {
|
|
1116
|
-
this.#quietUntil.delete(address);
|
|
1117
|
-
return false;
|
|
1118
|
-
}
|
|
1119
|
-
return true;
|
|
1120
|
-
}
|
|
1121
|
-
|
|
1122
|
-
/**
|
|
1123
|
-
* Forget everything remembered about an output nobody is producing for.
|
|
1124
|
-
*
|
|
1125
|
-
* Three maps are keyed by address, and an output that goes away leaves an
|
|
1126
|
-
* entry in each for the life of the process. Small, and exactly the shape of
|
|
1127
|
-
* accumulation this layer was built to remove from the session.
|
|
1128
|
-
*
|
|
1129
|
-
* @param {string} address
|
|
1130
|
-
* @returns {void}
|
|
1131
|
-
*/
|
|
1132
|
-
#forgetInputState(address) {
|
|
1133
|
-
this.#inputLostAttempts.delete(address);
|
|
1134
|
-
this.#quietUntil.delete(address);
|
|
1135
|
-
const timer = this.#quietTimers.get(address);
|
|
1136
|
-
if (timer) {
|
|
1137
|
-
clearTimeout(timer);
|
|
1138
|
-
this.#quietTimers.delete(address);
|
|
1139
|
-
}
|
|
1140
|
-
}
|
|
1141
|
-
|
|
1142
|
-
/**
|
|
1143
|
-
* How runs have ended over the life of this process, by cause.
|
|
1144
|
-
*
|
|
1145
|
-
* The abnormal classes are meant to stand at zero. Without the count,
|
|
1146
|
-
* "we understand why it ended" is indistinguishable from "we noticed it once".
|
|
1147
|
-
*
|
|
1148
|
-
* @returns {Record<string, number>}
|
|
1149
|
-
*/
|
|
1150
|
-
endings() {
|
|
1151
|
-
/** @type {Record<string, number>} */
|
|
1152
|
-
const tally = {};
|
|
1153
|
-
for (const ending of Object.values(ENCODE_EXIT)) {
|
|
1154
|
-
tally[ending] = this.#endings.get(ending) ?? 0;
|
|
1155
|
-
}
|
|
1156
|
-
return tally;
|
|
1157
|
-
}
|
|
1158
|
-
|
|
1159
|
-
/**
|
|
1160
|
-
* One line saying what this proxy is encoding and whether anybody is waiting.
|
|
1161
|
-
*
|
|
1162
|
-
* `waiting` is the point of it: a proxy with encoders running and a viewer
|
|
1163
|
-
* still stopped at a segment nobody is making is the failure this layer was
|
|
1164
|
-
* built to remove, and it is visible here rather than inferred from a log.
|
|
1165
|
-
*
|
|
1166
|
-
* @returns {string}
|
|
1167
|
-
*/
|
|
1168
|
-
describe() {
|
|
1169
|
-
const parts = [];
|
|
1170
|
-
for (const address of new Set([...this.#demand.addresses(), ...this.#runs.keys()])) {
|
|
1171
|
-
const coverage = this.coverageOf(address);
|
|
1172
|
-
const stated = this.#demand.mapOn(address);
|
|
1173
|
-
const windows = stated.map((zone) => ({ from: zone.from, to: zone.to }));
|
|
1174
|
-
const waiting = firstUnmetWant(coverage, windows);
|
|
1175
|
-
const runs = this.runsOn(address)
|
|
1176
|
-
.map((run) => `#${run.head}..#${run.to}@${run.speedX.toFixed(1)}x`)
|
|
1177
|
-
.join(" ");
|
|
1178
|
-
// The zones as they were stated, with their order, so a plan that is
|
|
1179
|
-
// working at the wrong end of the film is visible rather than inferred.
|
|
1180
|
-
const zones = [...stated]
|
|
1181
|
-
.sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0) || left.from - right.from)
|
|
1182
|
-
.map((w) => `p${w.priority ?? 0}:#${w.from}..#${w.to}`)
|
|
1183
|
-
.join(" ");
|
|
1184
|
-
// THE WHOLE ADDRESS. Cut to sixty characters, every output of one film
|
|
1185
|
-
// printed the same string — the picture, its quality steps and each
|
|
1186
|
-
// soundtrack are told apart only by the tail — so three lines of this
|
|
1187
|
-
// could not be matched to the three things they describe. Read on
|
|
1188
|
-
// 2026-09-07 while accounting for a session that produced nothing, and
|
|
1189
|
-
// the accounting had to be done by which line carried a run.
|
|
1190
|
-
//
|
|
1191
|
-
// And WHAT THE DISK HOLDS beside what is proven closed. They are two
|
|
1192
|
-
// different statements: files with nothing proving them closed reads as a
|
|
1193
|
-
// reporting fault, no files at all reads as an output yet to be made, and
|
|
1194
|
-
// the difference decides where to look.
|
|
1195
|
-
const held = this.#segmentCoverage ? this.#segmentCoverage.filesHeld(address) : -1;
|
|
1196
|
-
// AND WHETHER THE MAP IS BEING SERVED IN ITS OWN ORDER. The zones say
|
|
1197
|
-
// what matters most; this says what the viewer actually waited for, by
|
|
1198
|
-
// band. Waits at `now` are the ones that cost a spinner, and until this
|
|
1199
|
-
// existed there was no figure anywhere saying whether the urgent zone was
|
|
1200
|
-
// served first — the map could have been read backwards and every line
|
|
1201
|
-
// above would have looked the same.
|
|
1202
|
-
const served = this.#describeWaits(address);
|
|
1203
|
-
parts.push(
|
|
1204
|
-
`${address} ready=${coverage.stats().ready}` +
|
|
1205
|
-
`${held < 0 ? "" : ` of ${held} file(s) on disk`} ` +
|
|
1206
|
-
`zones=[${zones}] runs=[${runs}] ` +
|
|
1207
|
-
`waiting=${waiting === null ? "nobody" : `#${waiting}`}` +
|
|
1208
|
-
`${served ? ` served[${served}]` : ""}`
|
|
1209
|
-
);
|
|
1210
|
-
}
|
|
1211
|
-
const tally = this.endings();
|
|
1212
|
-
const endings = Object.entries(tally)
|
|
1213
|
-
.map(([cause, count]) => `${cause}=${count}`)
|
|
1214
|
-
.join(" ");
|
|
1215
|
-
return `encode: ${parts.length === 0 ? "nothing wanted" : parts.join(" | ")} :: endings ${endings}`;
|
|
1216
|
-
}
|
|
1217
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* @file How many encoders run on this proxy, on which outputs, and over which
|
|
3
|
+
* stretches.
|
|
4
|
+
*
|
|
5
|
+
* The application layer over `encode/`. It holds four things and owns none of
|
|
6
|
+
* their rules:
|
|
7
|
+
*
|
|
8
|
+
* 1. what viewers want (`SegmentDemand`), stated once each and read as a union;
|
|
9
|
+
* 2. what has been made and what is being made, one `CoverageMap` per output;
|
|
10
|
+
* 3. the encoders that are running (`EncodeRun`), each over a stretch;
|
|
11
|
+
* 4. a tally of how every run ended, which is what makes "abnormal endings do
|
|
12
|
+
* not happen" a number rather than an impression.
|
|
13
|
+
*
|
|
14
|
+
* The decision itself is `EncodePlan.planEncoders`, from numbers alone. This
|
|
15
|
+
* carries it out, and everything it cannot know is injected: how many encoders
|
|
16
|
+
* this machine can afford, how a run is built for a given stretch, and which
|
|
17
|
+
* segments already exist.
|
|
18
|
+
*
|
|
19
|
+
* **No viewer reaches the decision.** A viewer states a window and is forgotten
|
|
20
|
+
* as a name; what the plan sees is a union of windows. That is the rule the
|
|
21
|
+
* layer exists for, stated by the user 2026-09-04: requests come from any
|
|
22
|
+
* viewers in any number, encoders are managed to suit them, and viewers get the
|
|
23
|
+
* result when it is ready.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { CoverageMap } from "./CoverageMap.js";
|
|
27
|
+
import { firstUnmetWant, planEncoders } from "./EncodePlan.js";
|
|
28
|
+
import { endOfRun } from "./EncodeRun.js";
|
|
29
|
+
import { ENCODE_EXIT } from "./encode-exit.js";
|
|
30
|
+
import { affordableRuns } from "./run-budget.js";
|
|
31
|
+
import { RunCosts } from "./run-costs.js";
|
|
32
|
+
import { contentionPenalty } from "./contention.js";
|
|
33
|
+
import { SegmentDemand } from "./SegmentDemand.js";
|
|
34
|
+
import { runStateOf } from "./encode-run-state.js";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @typedef {Object} SegmentCoverage
|
|
38
|
+
* @property {(address: string) => number[]} provenNumbers
|
|
39
|
+
* @property {(address: string, index: number) => void} announce
|
|
40
|
+
* @property {(address: string) => number} filesHeld
|
|
41
|
+
* @property {(address: string, startedAt: number) => number} clearUpAfter
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* How long nothing is placed on an output whose input has just gone, and the
|
|
46
|
+
* ceiling that doubling reaches.
|
|
47
|
+
*
|
|
48
|
+
* Both are chosen, and are named here as chosen rather than dressed as
|
|
49
|
+
* measurements: what they bound is not how long the data takes to come back —
|
|
50
|
+
* that is the swarm's business and nobody here can know it — but how often it
|
|
51
|
+
* is worth asking. They replace the same two figures, with the same values,
|
|
52
|
+
* that lived in the session manager and governed only the dead run's own retry
|
|
53
|
+
* while the plan placed fresh runs beside it every half second.
|
|
54
|
+
*/
|
|
55
|
+
const INPUT_QUIET_BASE_MS = 2_000;
|
|
56
|
+
const INPUT_QUIET_MAX_MS = 15_000;
|
|
57
|
+
const START_FAST_FAIL_MS = 2_000;
|
|
58
|
+
const MAX_FAILED_STARTS = 3;
|
|
59
|
+
|
|
60
|
+
export class EncodeOrchestrator {
|
|
61
|
+
/** Derived priority maps. Discardable and rebuilt from viewer state. */
|
|
62
|
+
#demand = new SegmentDemand();
|
|
63
|
+
/** @type {(address: string) => string} */
|
|
64
|
+
#describeWaits = () => "";
|
|
65
|
+
|
|
66
|
+
/** Output address to what has been made of it. @type {Map<string, CoverageMap>} */
|
|
67
|
+
#coverage = new Map();
|
|
68
|
+
|
|
69
|
+
/** @type {SegmentCoverage | null} */
|
|
70
|
+
#segmentCoverage = null;
|
|
71
|
+
|
|
72
|
+
/** Output address to the runs on it. @type {Map<string, import("../encode/EncodeRun.js").EncodeRun[]>} */
|
|
73
|
+
#runs = new Map();
|
|
74
|
+
|
|
75
|
+
/** Last state after an output has no registered run. @type {Map<string, string>} */
|
|
76
|
+
#lastState = new Map();
|
|
77
|
+
|
|
78
|
+
/** Last encoder error per output. @type {Map<string, string>} */
|
|
79
|
+
#lastError = new Map();
|
|
80
|
+
|
|
81
|
+
/** Last progress snapshot per output after its run ends. @type {Map<string, object>} */
|
|
82
|
+
#lastProgress = new Map();
|
|
83
|
+
|
|
84
|
+
/** Consecutive fast failures at one position, per output. @type {Map<string, { at: number, count: number }>} */
|
|
85
|
+
#failedStarts = new Map();
|
|
86
|
+
|
|
87
|
+
/** The fastest speed measured on one output, kept across restarts. @type {Map<string, number>} */
|
|
88
|
+
#lastSpeed = new Map();
|
|
89
|
+
|
|
90
|
+
/** How runs have ended, by cause. @type {Map<string, number>} */
|
|
91
|
+
#endings = new Map();
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* How many times in a row an output's run has died because its input was not
|
|
95
|
+
* there. Cleared only by a run that PRODUCED something, which is the only
|
|
96
|
+
* proof that the input can be read — see `#noteInputAvailability`.
|
|
97
|
+
*
|
|
98
|
+
* @type {Map<string, number>}
|
|
99
|
+
*/
|
|
100
|
+
#inputLostAttempts = new Map();
|
|
101
|
+
|
|
102
|
+
/** Output address to the time before which nothing is placed on it. @type {Map<string, number>} */
|
|
103
|
+
#quietUntil = new Map();
|
|
104
|
+
|
|
105
|
+
/** The wake-up per output, so a quiet output is reconsidered. @type {Map<string, NodeJS.Timeout>} */
|
|
106
|
+
#quietTimers = new Map();
|
|
107
|
+
|
|
108
|
+
/** The last state said out loud, so an unchanged state is not repeated. */
|
|
109
|
+
#lastDescribed = "";
|
|
110
|
+
|
|
111
|
+
/** What a stop and a start have cost on this host. */
|
|
112
|
+
#costs = new RunCosts();
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* What starting and stopping an encoder was measured to cost on this host,
|
|
116
|
+
* before any viewer existed. Told once; real runs replace it as they end.
|
|
117
|
+
*
|
|
118
|
+
* @param {{ firstByteWaitSec: number, killCostSec: number } | null} measured
|
|
119
|
+
* @returns {void}
|
|
120
|
+
*/
|
|
121
|
+
noteStartupCosts(measured) {
|
|
122
|
+
this.#costs.noteStartup(measured);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Derived segment demand for one output, copied from the discardable cache. */
|
|
126
|
+
wantedSegmentsOn(address) {
|
|
127
|
+
return [...this.#demand.mapOn(address)];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The last reason a budget was cut, so the same one is not said twice. */
|
|
131
|
+
#lastBudgetReason = new Map();
|
|
132
|
+
|
|
133
|
+
/** The last unmet want said out loud, so a stuck one is said once. */
|
|
134
|
+
#lastUnmet = new Map();
|
|
135
|
+
|
|
136
|
+
/** The last time the machine bound an output's encoders, so it is said once. */
|
|
137
|
+
#lastMachineBound = new Map();
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* @param {object} params
|
|
141
|
+
* @param {(address: string) => number} params.maxRunsFor - How many encoders
|
|
142
|
+
* this machine can afford on one output. The same arithmetic that decides
|
|
143
|
+
* the quality offer; measured per host, never chosen here.
|
|
144
|
+
* @param {(params: { address: string, from: number, to: number, because: string }) =>
|
|
145
|
+
* import("../encode/EncodeRun.js").EncodeRun} params.makeRun - Build a run
|
|
146
|
+
* for a stretch. What to read, what to map and how to cut belong to whoever
|
|
147
|
+
* knows the source.
|
|
148
|
+
* @param {number} params.segmentSeconds
|
|
149
|
+
* @param {import("../encode/contention.js").ContentionPenalties | null}
|
|
150
|
+
* [params.contentionPenalties] - How much slower one encoder runs beside
|
|
151
|
+
* others, MEASURED on this host at startup and keyed by how many others
|
|
152
|
+
* there are. Null until something has measured it, and then the penalty is
|
|
153
|
+
* 1 — a number invented here would be the same mistake as an invented
|
|
154
|
+
* encoding speed.
|
|
155
|
+
* @param {{ info: (line: string) => void, warn: (line: string) => void }} params.logger
|
|
156
|
+
* @param {() => number} [params.now]
|
|
157
|
+
* @param {(address: string) => string} [params.describeWaits] - What the
|
|
158
|
+
* viewers actually waited for on this output, by band, for the state line.
|
|
159
|
+
* HANDED IN: what anybody waited for belongs to the priority layer, and
|
|
160
|
+
* this one may be asked about encoders with no ledger, no clock and no
|
|
161
|
+
* viewer anywhere. Absent, the line simply does not carry it.
|
|
162
|
+
* @param {SegmentCoverage | null} [params.segmentCoverage]
|
|
163
|
+
*/
|
|
164
|
+
constructor({
|
|
165
|
+
maxRunsFor,
|
|
166
|
+
makeRun,
|
|
167
|
+
segmentSeconds,
|
|
168
|
+
contentionPenalties = null,
|
|
169
|
+
refetchSecPerFilmSecond = () => 0,
|
|
170
|
+
startingSpeedFor = () => 0,
|
|
171
|
+
segmentCoverage = null,
|
|
172
|
+
planSoon = null,
|
|
173
|
+
describeWaits = null,
|
|
174
|
+
admission = null,
|
|
175
|
+
logger,
|
|
176
|
+
now
|
|
177
|
+
}) {
|
|
178
|
+
// HOW MANY ENCODERS THE WHOLE MACHINE HOLDS, across outputs. `maxRunsFor`
|
|
179
|
+
// answers for one output as though it were alone; this is what refuses the
|
|
180
|
+
// encoder that would slow everybody below realtime. Absent in a check that
|
|
181
|
+
// is about one output, and then only the per-output limit binds.
|
|
182
|
+
this.admission = admission && typeof admission.placesFor === "function" ? admission : null;
|
|
183
|
+
this.#describeWaits = typeof describeWaits === "function" ? describeWaits : () => "";
|
|
184
|
+
// Only the segment facts and operations this planner needs cross the
|
|
185
|
+
// storage boundary; the concrete storage remains in the server assembly.
|
|
186
|
+
this.#segmentCoverage = segmentCoverage;
|
|
187
|
+
this.maxRunsFor = maxRunsFor;
|
|
188
|
+
// Seconds of swarm time per second of film: what re-encoding material that
|
|
189
|
+
// already exists costs the download, over and above the encoder's own time.
|
|
190
|
+
// Injected, because the film's byte rate and the swarm's are measured
|
|
191
|
+
// elsewhere and this class must not reach for them.
|
|
192
|
+
this.refetchSecPerFilmSecond = refetchSecPerFilmSecond;
|
|
193
|
+
// Measured per host: what a second encoder costs the first. Unmeasured is 1,
|
|
194
|
+
// and then only the budget bounds how many there are.
|
|
195
|
+
this.contentionPenalties = contentionPenalties instanceof Map ? contentionPenalties : null;
|
|
196
|
+
// WHAT THIS HOST ENCODES AT BEFORE ANY RUN HAS REPORTED. The startup
|
|
197
|
+
// benchmark measures it — a real pipeline over real clips, before a viewer
|
|
198
|
+
// exists — so the plan is never asked to compare arrivals with no speed to
|
|
199
|
+
// compute them from. Every run that then works refines it.
|
|
200
|
+
this.startingSpeedFor = startingSpeedFor;
|
|
201
|
+
this.makeRun = makeRun;
|
|
202
|
+
this.segmentSeconds = segmentSeconds;
|
|
203
|
+
this.logger = logger;
|
|
204
|
+
this.now = typeof now === "function" ? now : Date.now;
|
|
205
|
+
// Asked for, not commanded: this class decides, and something else owns the
|
|
206
|
+
// loop that calls it. Absent in a test, where the clock is the test's own.
|
|
207
|
+
this.planSoon = typeof planSoon === "function" ? planSoon : () => undefined;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The map of one output, made on first mention.
|
|
212
|
+
*
|
|
213
|
+
* @param {string} address
|
|
214
|
+
* @returns {CoverageMap}
|
|
215
|
+
*/
|
|
216
|
+
coverageOf(address) {
|
|
217
|
+
let map = this.#coverage.get(address);
|
|
218
|
+
if (!map) {
|
|
219
|
+
map = new CoverageMap();
|
|
220
|
+
this.#coverage.set(address, map);
|
|
221
|
+
}
|
|
222
|
+
return map;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Put the map's picture of what is ready back in step with the disk.
|
|
227
|
+
*
|
|
228
|
+
* ONE AUTHORITY ON WHAT EXISTS, AND IT IS THE STORE. The map holds no memory
|
|
229
|
+
* of readiness between calls: it is handed the whole answer, replacing
|
|
230
|
+
* whatever it had, immediately before anything is decided from it. So a
|
|
231
|
+
* segment whose file was discarded with the run that had it open, dropped to
|
|
232
|
+
* make room, or reopened by a run restarting on it stops being ready in the
|
|
233
|
+
* same breath — without anything having to notice and say so.
|
|
234
|
+
*
|
|
235
|
+
* The map used to be filled from outside, by the session manager, with a
|
|
236
|
+
* method that only ever added. Nothing anywhere took a number back. Field
|
|
237
|
+
* 2026-09-07: the map claimed all 482 segments of a film while the directory
|
|
238
|
+
* held nothing, so every arrangement scored perfect, the only encoder was
|
|
239
|
+
* stopped as unnecessary and none was placed again — two sessions in a row
|
|
240
|
+
* with no picture at all.
|
|
241
|
+
*
|
|
242
|
+
* A store is optional here only in the sense that an authority which was not
|
|
243
|
+
* supplied cannot be consulted: without one the map keeps what it was told
|
|
244
|
+
* directly, which is how this class is exercised with plain numbers.
|
|
245
|
+
*
|
|
246
|
+
* @param {string} address
|
|
247
|
+
* @returns {CoverageMap}
|
|
248
|
+
*/
|
|
249
|
+
#upToDateCoverage(address) {
|
|
250
|
+
const coverage = this.coverageOf(address);
|
|
251
|
+
if (this.#segmentCoverage) {
|
|
252
|
+
coverage.setReady(this.#segmentCoverage.provenNumbers(address));
|
|
253
|
+
}
|
|
254
|
+
return coverage;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Where a run started here must stop: the free stretch in front of it.
|
|
259
|
+
*
|
|
260
|
+
* Asked of the one map, brought up to date first. It used to be worked out by
|
|
261
|
+
* the session manager, which reached into this layer for the map and into the
|
|
262
|
+
* store for what was on the disk and put the two together itself — one fact
|
|
263
|
+
* with two owners and a third party carrying it between them, which is how the
|
|
264
|
+
* two came to disagree.
|
|
265
|
+
*
|
|
266
|
+
* @param {object} params
|
|
267
|
+
* @param {string} params.address
|
|
268
|
+
* @param {number} params.from - Where the run will start.
|
|
269
|
+
* @param {object | null} [params.exceptRun] - The run being replaced, whose
|
|
270
|
+
* own claim is not somebody else's material.
|
|
271
|
+
* @param {number} [params.segmentCount] - The output's length, when known.
|
|
272
|
+
* @returns {number} The last number to work through, or `-1` for the end of
|
|
273
|
+
* the film.
|
|
274
|
+
*/
|
|
275
|
+
freeStretchEnd({ address, from, exceptRun = null, segmentCount = 0 }) {
|
|
276
|
+
if (!address) {
|
|
277
|
+
return -1;
|
|
278
|
+
}
|
|
279
|
+
if (segmentCount > 0) {
|
|
280
|
+
this.coverageOf(address).setSegmentCount(segmentCount);
|
|
281
|
+
}
|
|
282
|
+
const coverage = this.#upToDateCoverage(address);
|
|
283
|
+
const start = Math.max(0, from);
|
|
284
|
+
const free = coverage.freeRunFrom(start, exceptRun);
|
|
285
|
+
if (!Number.isFinite(free)) {
|
|
286
|
+
return -1;
|
|
287
|
+
}
|
|
288
|
+
const end = start + Math.max(1, free) - 1;
|
|
289
|
+
return segmentCount > 0 && end >= segmentCount - 1 ? -1 : end;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* @param {string} address
|
|
294
|
+
* @returns {import("../encode/EncodeRun.js").EncodeRun[]}
|
|
295
|
+
*/
|
|
296
|
+
runsOn(address) {
|
|
297
|
+
return this.#runs.get(address) ?? [];
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* The aggregate state of this output's current runs, or its last ending when
|
|
302
|
+
* none remain.
|
|
303
|
+
*
|
|
304
|
+
* @param {string} address
|
|
305
|
+
* @returns {string}
|
|
306
|
+
*/
|
|
307
|
+
stateOf(address) {
|
|
308
|
+
const runs = this.runsOn(address);
|
|
309
|
+
return runs.length > 0 ? runStateOf(runs) : (this.#lastState.get(address) ?? runStateOf([]));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** @param {string} address @returns {string} */
|
|
313
|
+
errorOf(address) {
|
|
314
|
+
return this.#lastError.get(address) ?? "";
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Progress of the live run that covers a position, or the most recently
|
|
319
|
+
* updated run when no position is supplied.
|
|
320
|
+
*
|
|
321
|
+
* @param {string} address
|
|
322
|
+
* @param {number | null} [index]
|
|
323
|
+
* @returns {object | null}
|
|
324
|
+
*/
|
|
325
|
+
progressOf(address, index = null) {
|
|
326
|
+
const live = this.runsOn(address).filter((run) => run.isAlive);
|
|
327
|
+
const covering = Number.isInteger(index)
|
|
328
|
+
? live.filter((run) => run.from <= index && endOfRun(run) >= index)
|
|
329
|
+
: live;
|
|
330
|
+
const candidates = covering.length > 0 ? covering : live;
|
|
331
|
+
const run = [...candidates].sort(
|
|
332
|
+
(left, right) => (right.progress?.updatedAt ?? 0) - (left.progress?.updatedAt ?? 0)
|
|
333
|
+
)[0];
|
|
334
|
+
return run?.progress?.snapshot?.() ?? this.#lastProgress.get(address) ?? null;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Whether another start at this position is allowed.
|
|
339
|
+
*
|
|
340
|
+
* @param {string} address
|
|
341
|
+
* @param {number} from
|
|
342
|
+
* @returns {boolean}
|
|
343
|
+
*/
|
|
344
|
+
mayStartAt(address, from) {
|
|
345
|
+
const failed = this.#failedStarts.get(address);
|
|
346
|
+
return failed?.at !== from || failed.count < MAX_FAILED_STARTS;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Record whether a failed run died before it performed sustained work.
|
|
351
|
+
*
|
|
352
|
+
* @param {string} address
|
|
353
|
+
* @param {number} from
|
|
354
|
+
* @param {number} livedMs
|
|
355
|
+
* @returns {{ at: number, count: number, blocked: boolean, limit: number, fastMs: number }}
|
|
356
|
+
*/
|
|
357
|
+
noteStartFailure(address, from, livedMs) {
|
|
358
|
+
if (!(livedMs < START_FAST_FAIL_MS)) {
|
|
359
|
+
this.#failedStarts.delete(address);
|
|
360
|
+
return { at: -1, count: 0, blocked: false, limit: MAX_FAILED_STARTS, fastMs: START_FAST_FAIL_MS };
|
|
361
|
+
}
|
|
362
|
+
const previous = this.#failedStarts.get(address);
|
|
363
|
+
const next = {
|
|
364
|
+
at: from,
|
|
365
|
+
count: previous?.at === from ? previous.count + 1 : 1
|
|
366
|
+
};
|
|
367
|
+
this.#failedStarts.set(address, next);
|
|
368
|
+
return {
|
|
369
|
+
...next,
|
|
370
|
+
blocked: next.count >= MAX_FAILED_STARTS,
|
|
371
|
+
limit: MAX_FAILED_STARTS,
|
|
372
|
+
fastMs: START_FAST_FAIL_MS
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* An output that no longer exists and has no run left on it.
|
|
378
|
+
*
|
|
379
|
+
* How its last run ended, what went wrong and which position kept failing are
|
|
380
|
+
* facts about THAT output. The name follows from the key, so the next output
|
|
381
|
+
* with the same parameters has the same address; kept, those facts would
|
|
382
|
+
* greet it as its own — a failed ending makes its first wait throw before any
|
|
383
|
+
* encoder is placed, and nothing would ever clear it.
|
|
384
|
+
*
|
|
385
|
+
* @param {string} address
|
|
386
|
+
*/
|
|
387
|
+
forgetOutput(address) {
|
|
388
|
+
if (this.runsOn(address).length > 0) {
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
this.#lastState.delete(address);
|
|
392
|
+
this.#lastError.delete(address);
|
|
393
|
+
this.#lastProgress.delete(address);
|
|
394
|
+
this.#failedStarts.delete(address);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** @param {string} address @returns {{ at: number, count: number, blocked: boolean, limit: number }} */
|
|
398
|
+
startFailureOf(address) {
|
|
399
|
+
const failure = this.#failedStarts.get(address) ?? { at: -1, count: 0 };
|
|
400
|
+
return { ...failure, blocked: failure.count >= MAX_FAILED_STARTS, limit: MAX_FAILED_STARTS };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* What the map says about one segment of one output, and about the map it
|
|
405
|
+
* sits in.
|
|
406
|
+
*
|
|
407
|
+
* Asked by whoever is holding a request and measuring the wait: a wait means
|
|
408
|
+
* one thing at the top rank and another at the bottom, and telling them apart
|
|
409
|
+
* needs both the segment's own rank and the highest rank stated, since a rank
|
|
410
|
+
* is a position in this map and not an absolute number.
|
|
411
|
+
*
|
|
412
|
+
* AND AN EMPTY MAP IS NOT AN ANSWER. A top rank of zero says the map has not
|
|
413
|
+
* been built yet — a session created a moment ago, before the first pass —
|
|
414
|
+
* and that is a different thing from "nobody is coming". Conflated, every
|
|
415
|
+
* request behind the run reads as absent for as long as a fresh session has
|
|
416
|
+
* no map.
|
|
417
|
+
*
|
|
418
|
+
* It exists because the one caller reached through this object into the store
|
|
419
|
+
* behind it. A reach like that is how a store becomes the surface: the day it
|
|
420
|
+
* changes, a caller two layers away breaks for a reason it cannot see.
|
|
421
|
+
*
|
|
422
|
+
* @param {string} address
|
|
423
|
+
* @param {number} index
|
|
424
|
+
* @returns {{ rank: number, topRank: number }}
|
|
425
|
+
*/
|
|
426
|
+
rankAt(address, index) {
|
|
427
|
+
return this.#demand.rankOf(address, index);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* How many encoder processes are running on this machine right now, over
|
|
432
|
+
* every output.
|
|
433
|
+
*
|
|
434
|
+
* ONE COUNT, and it is this class's because this class is what makes and
|
|
435
|
+
* unmakes runs: adopted the moment one exists and forgotten the moment one
|
|
436
|
+
* ends, both synchronously. The session manager answered the same question
|
|
437
|
+
* twice by walking its own registry — once per SESSION and once per RUN —
|
|
438
|
+
* which agreed only while a session held at most one run. It has held several
|
|
439
|
+
* since the plan was allowed to place them, and the per-session count is the
|
|
440
|
+
* wrong one of the two wherever they differ: what a second encoder costs was
|
|
441
|
+
* measured between ffmpeg PROCESSES, not between outputs.
|
|
442
|
+
*
|
|
443
|
+
* A SUSPENDED run is not counted. It is stopped where it stands and is using
|
|
444
|
+
* nothing; a cost measured beside it is a cost measured alone.
|
|
445
|
+
*
|
|
446
|
+
* @returns {number}
|
|
447
|
+
*/
|
|
448
|
+
/**
|
|
449
|
+
* How many encoders are alive on each output, suspended ones included.
|
|
450
|
+
*
|
|
451
|
+
* What the machine-wide admission counts as holding a place. A suspended
|
|
452
|
+
* encoder is counted here, unlike in {@link runningCount}: that one answers
|
|
453
|
+
* what is competing for the processor this instant, this one what the
|
|
454
|
+
* machine is committed to — and a parked encoder resumes the moment the
|
|
455
|
+
* viewer catches up.
|
|
456
|
+
*
|
|
457
|
+
* @returns {Map<string, number>}
|
|
458
|
+
*/
|
|
459
|
+
liveRunsByAddress() {
|
|
460
|
+
/** @type {Map<string, number>} */
|
|
461
|
+
const byAddress = new Map();
|
|
462
|
+
for (const [address, runs] of this.#runs) {
|
|
463
|
+
const alive = runs.filter((run) => run.isAlive).length;
|
|
464
|
+
if (alive > 0) {
|
|
465
|
+
byAddress.set(address, alive);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return byAddress;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
runningCount() {
|
|
472
|
+
let running = 0;
|
|
473
|
+
for (const runs of this.#runs.values()) {
|
|
474
|
+
for (const run of runs) {
|
|
475
|
+
if (run.isAlive && !run.isSuspended) {
|
|
476
|
+
running += 1;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return running;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* How long an output is, once its playlist is known.
|
|
485
|
+
*
|
|
486
|
+
* @param {string} address
|
|
487
|
+
* @param {number} segmentCount
|
|
488
|
+
*/
|
|
489
|
+
setSegmentCount(address, segmentCount) {
|
|
490
|
+
this.coverageOf(address).setSegmentCount(segmentCount);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Segments that already exist — from a previous life of this process, or
|
|
495
|
+
* because somebody else made them. Told to the map, which is what stops an
|
|
496
|
+
* encoder being started to make them again.
|
|
497
|
+
*
|
|
498
|
+
* @param {string} address
|
|
499
|
+
* @param {Iterable<number>} indexes
|
|
500
|
+
*/
|
|
501
|
+
noteAlreadyMade(address, indexes) {
|
|
502
|
+
for (const index of indexes) {
|
|
503
|
+
this.noteProduced(address, index);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* A viewer states what it is waiting for. Replaces whatever it said before.
|
|
509
|
+
*
|
|
510
|
+
* @param {object} params
|
|
511
|
+
* @param {string} params.claimant
|
|
512
|
+
* @param {string} params.address
|
|
513
|
+
* @param {number} params.from
|
|
514
|
+
* @param {number} params.to
|
|
515
|
+
* @param {number} [params.priority] - Higher is sooner. One viewer states
|
|
516
|
+
* several stretches at once — what must be ready before they set off, what
|
|
517
|
+
* is reachable while they watch it, the rest of the track — and the filling
|
|
518
|
+
* takes them in this order. Absent means one undifferentiated want, which
|
|
519
|
+
* is what a caller that knows only a position states.
|
|
520
|
+
*/
|
|
521
|
+
/**
|
|
522
|
+
* What is wanted of one output, in its own segment numbers.
|
|
523
|
+
*
|
|
524
|
+
* ONE MAP, ALREADY MERGED, AND WITH NOBODY'S NAME ON IT. It is built once per
|
|
525
|
+
* film by the layer that knows where the viewers are; this layer receives it
|
|
526
|
+
* converted into an output's own numbering and never asks who is in it.
|
|
527
|
+
*
|
|
528
|
+
* That replaced a window per viewer per band stated here and merged here,
|
|
529
|
+
* which was the same work done twice in two layers, with the viewer's name as
|
|
530
|
+
* the key of a claim — against the rule that the encoding and the viewer are
|
|
531
|
+
* not connected at all.
|
|
532
|
+
*
|
|
533
|
+
* An empty map says nobody is coming anywhere in this output, and the plan
|
|
534
|
+
* stops its encoders for it. Nothing has to be released when somebody leaves:
|
|
535
|
+
* the map that arrives next simply does not have them in it.
|
|
536
|
+
*
|
|
537
|
+
* @param {string} address
|
|
538
|
+
* @param {{ from: number, to: number, priority: number, withinSeconds: number }[]} zones
|
|
539
|
+
*/
|
|
540
|
+
notePriorityMap(address, zones) {
|
|
541
|
+
this.#demand.state(address, zones);
|
|
542
|
+
// NOBODY IS COMING HERE, so what was remembered about this output's input
|
|
543
|
+
// is about nobody. Kept, those three entries would stay for the life of the
|
|
544
|
+
// process, and the wait they describe would greet whoever opens this output
|
|
545
|
+
// next — a viewer arriving is new information, and one attempt for them is
|
|
546
|
+
// right whatever the last one met.
|
|
547
|
+
if (!Array.isArray(zones) || zones.length === 0) {
|
|
548
|
+
this.#forgetInputState(address);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* A segment has been finished, by whichever run made it.
|
|
554
|
+
*
|
|
555
|
+
* @param {string} address
|
|
556
|
+
* @param {number} index
|
|
557
|
+
*/
|
|
558
|
+
noteProduced(address, index) {
|
|
559
|
+
// Nothing is told to the store: by the time this is called the piece is
|
|
560
|
+
// ALREADY under its served name, because the rename is what closing it
|
|
561
|
+
// means. A statement kept beside the disk would be a second owner of one
|
|
562
|
+
// fact, which removed the stale second owner from the coverage map.
|
|
563
|
+
this.coverageOf(address).markReady(index);
|
|
564
|
+
this.#segmentCoverage?.announce?.(address, index);
|
|
565
|
+
for (const run of this.runsOn(address)) {
|
|
566
|
+
run.noteProduced(index);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* @param {string} address
|
|
572
|
+
* @param {object} run
|
|
573
|
+
* @param {number} speedX
|
|
574
|
+
*/
|
|
575
|
+
noteSpeed(address, wanted, speedX) {
|
|
576
|
+
for (const run of this.runsOn(address)) {
|
|
577
|
+
if (run === wanted) {
|
|
578
|
+
run.noteSpeed(speedX);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
// HOW FAST THIS MACHINE ENCODES THIS OUTPUT is a property of the machine and
|
|
582
|
+
// the material, not of one process. Read off `run.speedX` alone it was lost
|
|
583
|
+
// at every restart: a moved encoder is a new object that has measured
|
|
584
|
+
// nothing, so the plan fell back to "nothing is known" and stopped comparing
|
|
585
|
+
// arrivals at all — which is every decision in this layer.
|
|
586
|
+
if (speedX > 0 && speedX > (this.#lastSpeed.get(address) ?? 0)) {
|
|
587
|
+
this.#lastSpeed.set(address, speedX);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Decide and act, for every output anybody wants anything of and every output
|
|
593
|
+
* that still has an encoder on it.
|
|
594
|
+
*
|
|
595
|
+
* Safe to call as often as anything changes: the plan is a function of the
|
|
596
|
+
* state, so a pass that finds nothing to change does nothing.
|
|
597
|
+
*/
|
|
598
|
+
reconcile() {
|
|
599
|
+
const addresses = new Set([...this.#demand.addresses(), ...this.#runs.keys()]);
|
|
600
|
+
for (const address of addresses) {
|
|
601
|
+
this.#reconcileOne(address);
|
|
602
|
+
}
|
|
603
|
+
// WHAT THIS CLASS BELIEVES, said by this class. `describe()` was written
|
|
604
|
+
// and called from nowhere, so on 2026-09-05 the question "why did the plan
|
|
605
|
+
// not see the gap the viewer was stopped at" had to be answered by
|
|
606
|
+
// inference from start and stop lines, and was not answered at all.
|
|
607
|
+
//
|
|
608
|
+
// Printed on CHANGE rather than on a timer: a quiet session says nothing, a
|
|
609
|
+
// session that is deciding something says what it decided, and there is no
|
|
610
|
+
// interval to choose.
|
|
611
|
+
const state = this.describe();
|
|
612
|
+
if (state !== this.#lastDescribed) {
|
|
613
|
+
this.#lastDescribed = state;
|
|
614
|
+
this.logger.info(state);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* @param {string} address
|
|
620
|
+
*/
|
|
621
|
+
#reconcileOne(address) {
|
|
622
|
+
// WHAT EXISTS IS ASKED OF THE DISK, HERE, EVERY TIME. The plan is arithmetic
|
|
623
|
+
// over what is made, what is being made and what is wanted, and the first of
|
|
624
|
+
// those is not this layer's to remember.
|
|
625
|
+
const coverage = this.#upToDateCoverage(address);
|
|
626
|
+
// A run that has ended and said nothing. One built here reports its own
|
|
627
|
+
// ending and is released by `noteEnded`; one ADOPTED from elsewhere — a
|
|
628
|
+
// session whose encoder stopped — has no such promise, and its claim would
|
|
629
|
+
// otherwise sit in the map for the life of the process, telling the plan
|
|
630
|
+
// that a stretch nobody is making is being made. Nothing would ever be
|
|
631
|
+
// started there again.
|
|
632
|
+
for (const run of this.runsOn(address)) {
|
|
633
|
+
if (!run.isAlive && !run.isStopping) {
|
|
634
|
+
this.noteEnded({
|
|
635
|
+
address,
|
|
636
|
+
run,
|
|
637
|
+
ending: ENCODE_EXIT.GONE,
|
|
638
|
+
because: "it is no longer running, and it did not say so"
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
// ITS INPUT WAS NOT THERE A MOMENT AGO, so nothing is placed yet. The sweep
|
|
643
|
+
// above still runs — a claim left by a dead run must be released whatever
|
|
644
|
+
// the reason — and only the placing waits. This is the one thing that makes
|
|
645
|
+
// the delay bind: it used to be timed against the dead run, which the plan
|
|
646
|
+
// does not consult, so a fresh run went to the same place as fast as ffmpeg
|
|
647
|
+
// could fail there.
|
|
648
|
+
//
|
|
649
|
+
// AND ONLY WHILE NOTHING IS PRODUCING THERE. A run still alive on this
|
|
650
|
+
// output is proof the input can be read, whatever a run beside it met, so
|
|
651
|
+
// the wait must not silence an output that is working.
|
|
652
|
+
if (this.#isQuiet(address) && this.runsOn(address).every((run) => !run.isAlive)) {
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
// ONE MAP, NOT ONE WINDOW PER VIEWER PER ZONE.
|
|
656
|
+
//
|
|
657
|
+
// Two viewers a few seconds apart state stretches that overlap, and the plan
|
|
658
|
+
// puts one encoder on each stretch it is given — so unmerged windows buy an
|
|
659
|
+
// encoder per viewer for film they both want, which is the opposite of what
|
|
660
|
+
// sharing the output is for. Merged, the highest rank and the soonest time
|
|
661
|
+
// per number win and the stretches do not overlap, so one encoder serves
|
|
662
|
+
// everyone standing in front of it.
|
|
663
|
+
//
|
|
664
|
+
// Asked of the register, which is the thing that holds the windows. This
|
|
665
|
+
// used to reach into the layer that STATES them for the same arithmetic,
|
|
666
|
+
// which is the coupling the layer rule forbids; the arithmetic itself now
|
|
667
|
+
// lives where it belongs to nobody.
|
|
668
|
+
const windows = this.#demand.mapOn(address);
|
|
669
|
+
const live = this.runsOn(address).filter((run) => run.isAlive);
|
|
670
|
+
// Asked ONCE. It is arithmetic over measurements, but it also says out loud
|
|
671
|
+
// when the reason it cuts the budget changes, so asking it three times in
|
|
672
|
+
// one pass is three chances to say a thing that happened once.
|
|
673
|
+
const maxRuns = this.#withinMachine(address, live, this.#affordableOn(address, live));
|
|
674
|
+
// THE TERMS EVERY ARRIVAL IS COMPUTED FROM, named here so the line below can
|
|
675
|
+
// print them. A decision of this plan is `delay + (index - at) / rate +
|
|
676
|
+
// madeBetween * refetch` against a deadline, so without the rate and the two
|
|
677
|
+
// prices no recorded decision can be reproduced — which is what happened
|
|
678
|
+
// with the one-piece intervals of 2026-09-08: the rate was substituted six
|
|
679
|
+
// times from the speeds the session reported elsewhere and none of them gave
|
|
680
|
+
// the answer the plan had given.
|
|
681
|
+
const costs = this.#costs.seconds();
|
|
682
|
+
const refetchSecPerFilmSecond = this.refetchSecPerFilmSecond(address);
|
|
683
|
+
// The best figure this host has: what a run here is doing now, what one was
|
|
684
|
+
// last measured doing, or what the startup benchmark predicted. The first
|
|
685
|
+
// two are this output's own; the third exists before either.
|
|
686
|
+
const speedX = Math.max(
|
|
687
|
+
live.reduce((best, run) => Math.max(best, run.speedX || 0), 0),
|
|
688
|
+
this.#lastSpeed.get(address) ?? 0,
|
|
689
|
+
this.startingSpeedFor(address) || 0
|
|
690
|
+
);
|
|
691
|
+
const actions = planEncoders({
|
|
692
|
+
coverage,
|
|
693
|
+
windows,
|
|
694
|
+
// The runs themselves. The plan is arithmetic and reads four numbers off
|
|
695
|
+
// each; what it hands back names the run by BEING it, so nothing has to
|
|
696
|
+
// invent a token to refer to one by.
|
|
697
|
+
runs: live,
|
|
698
|
+
maxRuns,
|
|
699
|
+
segmentSeconds: this.segmentSeconds,
|
|
700
|
+
// What a start and a kill cost, measured from this host's own runs rather
|
|
701
|
+
// than written into the code from one machine's reading. Zero until
|
|
702
|
+
// something has been measured, which is the same convention as the
|
|
703
|
+
// refetch price and is stated so the bias is known.
|
|
704
|
+
...costs,
|
|
705
|
+
// What a second of film costs to fetch again, in seconds of swarm time.
|
|
706
|
+
// Answered by whoever measures the film's own byte rate and the swarm's;
|
|
707
|
+
// zero until they have, which makes driving through look cheaper than it
|
|
708
|
+
// is and is stated here so the bias is known.
|
|
709
|
+
refetchSecPerFilmSecond,
|
|
710
|
+
// How much slower one encoder runs beside others, read off this host's own
|
|
711
|
+
// startup measurement. A pure function over a measured table: beyond what
|
|
712
|
+
// was measured it holds the largest reading rather than continuing a curve
|
|
713
|
+
// nothing observed.
|
|
714
|
+
contentionPenaltyFor: (others) => contentionPenalty(others, this.contentionPenalties).penalty,
|
|
715
|
+
speedX
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
// A move is the plan taking a running encoder away from where it already
|
|
719
|
+
// stands, which is exactly the decision that was found wandering back and
|
|
720
|
+
// forth in the field on 2026-09-07 with no way to see why: the "because"
|
|
721
|
+
// line names the comparison in words, never the numbers it was decided
|
|
722
|
+
// from. Said here, once per reconcile, and only when a move actually
|
|
723
|
+
// happens — everything a rerun of the same decision needs: the windows
|
|
724
|
+
// this call saw (priority, the real time, which side of the viewers),
|
|
725
|
+
// the budget, and where every live run stood.
|
|
726
|
+
// WHAT WAS DECIDED AND WITH WHAT INTERVAL, said whenever anything is placed
|
|
727
|
+
// or taken away — not only on a move.
|
|
728
|
+
//
|
|
729
|
+
// Field 2026-09-08 could not be diagnosed from this line: it printed the
|
|
730
|
+
// windows, the budget and where the live runs stood, and NOT the intervals
|
|
731
|
+
// the actions carried. What the session actually did was give every encoder
|
|
732
|
+
// an interval of exactly ONE segment — 63 runs, each making one piece and
|
|
733
|
+
// exiting, twelve of them normally — and the reasons printed beside them
|
|
734
|
+
// read as moves, so the fault was diagnosed three times as something it was
|
|
735
|
+
// not. An interval is what a run is, and it was the one thing missing.
|
|
736
|
+
//
|
|
737
|
+
// AND THE TERMS, for the same reason one step further: an interval says what
|
|
738
|
+
// was decided and the terms say why. `speed` is what every arrival is
|
|
739
|
+
// divided by, so a decision recorded without it can be re-read and not
|
|
740
|
+
// recomputed; `firstByte` and `kill` are what a start and a stop cost here;
|
|
741
|
+
// `refetch` is what a second of film costs to fetch again. A zero in the
|
|
742
|
+
// last three is a measurement nobody has taken, not a free operation, and it
|
|
743
|
+
// is printed so that reading it as free is a choice rather than an accident.
|
|
744
|
+
if (actions.some((action) => action.type !== "keep")) {
|
|
745
|
+
this.logger.info(
|
|
746
|
+
`encode-plan on ${address}: ` +
|
|
747
|
+
`${actions.map((action) => `${action.type} #${action.from ?? "?"}..#${action.to ?? "?"}`).join(", ")}` +
|
|
748
|
+
` [speed=${speedX.toFixed(2)}x firstByte=${costs.firstByteWaitSec.toFixed(2)}s ` +
|
|
749
|
+
`kill=${costs.killCostSec.toFixed(2)}s refetch=${refetchSecPerFilmSecond.toFixed(3)}s/s ` +
|
|
750
|
+
`maxRuns=${maxRuns} live=${live.length}]`
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
if (actions.some((action) => action.type === "move")) {
|
|
754
|
+
this.logger.info(
|
|
755
|
+
`encode-plan move on ${address}: windows=${JSON.stringify(windows)} ` +
|
|
756
|
+
`maxRuns=${maxRuns} ` +
|
|
757
|
+
`live=${JSON.stringify(live.map((run) => ({ from: run.from, to: run.to, head: run.head, speedX: run.speedX })))}`
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
for (const action of actions) {
|
|
761
|
+
if (action.type === "stop") {
|
|
762
|
+
this.#stop(action.run, action.because);
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
if (action.type === "move") {
|
|
766
|
+
// A running encoder's position cannot be changed — it is fixed when the
|
|
767
|
+
// process starts — so a move is this one ending and another beginning
|
|
768
|
+
// where the material is missing. Both halves are recorded as what they
|
|
769
|
+
// are, which is why the ending of a moved run is not called normal.
|
|
770
|
+
this.#stop(action.run, action.because);
|
|
771
|
+
this.#start(address, action.from, action.to, action.because);
|
|
772
|
+
continue;
|
|
773
|
+
}
|
|
774
|
+
if (action.type === "start") {
|
|
775
|
+
this.#start(address, action.from, action.to, action.because);
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
// A run that stays keeps its claim current: the free stretch ahead of it
|
|
779
|
+
// may have shrunk since it was given one.
|
|
780
|
+
//
|
|
781
|
+
// THE CLAIM IS THE STRETCH IT WAS GIVEN, and there is one rule for that
|
|
782
|
+
// everywhere. It used to be narrowed here to what the run had already
|
|
783
|
+
// MADE whenever the run had no end, which meant a run claimed the single
|
|
784
|
+
// number it was writing and nothing beyond. The plan then read the road
|
|
785
|
+
// in front of a working encoder as free and started more encoders on it:
|
|
786
|
+
// three processes writing one directory with the same names, field
|
|
787
|
+
// 2026-09-06, and a piece of the film lost for good when the first of
|
|
788
|
+
// them was cleaned up after.
|
|
789
|
+
//
|
|
790
|
+
// The worry that narrowing was written for is real and is answered where
|
|
791
|
+
// it belongs — a viewer opening the same film further in must not find
|
|
792
|
+
// every number taken. That is the plan's business, and the plan can take
|
|
793
|
+
// road away from a run that has no end, because such a run carries no
|
|
794
|
+
// `-to` and simply stops when its head meets somebody else's claim.
|
|
795
|
+
coverage.claim(action.run, action.from, endOfRun({ from: action.from, to: action.to }));
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// NOBODY IS MAKING WHAT SOMEBODY IS WAITING FOR. Said here, with the numbers
|
|
799
|
+
// the decision was taken from, because it is the one state in which a viewer
|
|
800
|
+
// waits for ever and every line above it reads as a healthy proxy.
|
|
801
|
+
//
|
|
802
|
+
// Field 2026-09-07, twice in one evening: the last word about an output was
|
|
803
|
+
// "the film is no worse off without it", and after it nothing — no run, no
|
|
804
|
+
// refusal, no answer to the browser's request for the header. The wait ended
|
|
805
|
+
// at the browser's own timeout with a message naming no cause, and the
|
|
806
|
+
// proxy's log named none either.
|
|
807
|
+
//
|
|
808
|
+
// SAID ONCE PER STATE, not once per pass. A stuck output is reconciled on
|
|
809
|
+
// every event that touches it, and a line repeated for as long as the state
|
|
810
|
+
// lasts is what buried the last one: 49 295 copies of `send queue stuck` in
|
|
811
|
+
// a 159 090-line file, 31 % of the log, all of one wedge.
|
|
812
|
+
const wanting = firstUnmetWant(coverage, windows);
|
|
813
|
+
const stillRunning = this.runsOn(address).filter((run) => run.isAlive);
|
|
814
|
+
if (wanting === null || stillRunning.length > 0) {
|
|
815
|
+
this.#lastUnmet.delete(address);
|
|
816
|
+
} else if (this.#lastUnmet.get(address) !== wanting) {
|
|
817
|
+
this.#lastUnmet.set(address, wanting);
|
|
818
|
+
const held = this.#segmentCoverage ? this.#segmentCoverage.filesHeld(address) : -1;
|
|
819
|
+
this.logger.warn(
|
|
820
|
+
`encode: #${wanting} of ${address} is wanted and NO ENCODER IS MAKING IT — ` +
|
|
821
|
+
`ready=${coverage.stats().ready} of ${coverage.segmentCount} ` +
|
|
822
|
+
`files=${held < 0 ? "?" : held} maxRuns=${maxRuns} ` +
|
|
823
|
+
`windows=${JSON.stringify(windows)}`
|
|
824
|
+
);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* @param {string} address
|
|
830
|
+
* @param {number} from
|
|
831
|
+
* @param {number} to
|
|
832
|
+
* @param {string} because
|
|
833
|
+
*/
|
|
834
|
+
#start(address, from, to, because) {
|
|
835
|
+
// The encoder is built here and now: whoever builds one waits for nothing,
|
|
836
|
+
// so it exists by the time this line returns. That is what makes the
|
|
837
|
+
// stretch held from this instant — this class knows what it is making
|
|
838
|
+
// because it has just made it, and no second encoder can be started for the
|
|
839
|
+
// same stretch on the next pass.
|
|
840
|
+
//
|
|
841
|
+
// It was not always so. The builder used to answer with nothing and start
|
|
842
|
+
// the encoder behind the answer, so the stretch stayed FREE for as long as
|
|
843
|
+
// that took, and every pass in between started another one: 684 starts in
|
|
844
|
+
// 482 seconds of field 2026-09-05, of which 973 answers said the encoder
|
|
845
|
+
// was not there yet — every start without exception.
|
|
846
|
+
//
|
|
847
|
+
// The run names itself: identity is a property of the thing, and two
|
|
848
|
+
// places minting names is how one stops being unique.
|
|
849
|
+
const run = this.makeRun({ address, from, to, because });
|
|
850
|
+
if (!run) {
|
|
851
|
+
// A refusal, not a wait: no session serves this output, or this position
|
|
852
|
+
// has failed to start too many times running.
|
|
853
|
+
this.logger.warn(`encode: no encoder could be made for #${from}..#${to} of ${address}`);
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
// What this machine has been measured to do on this output, carried over.
|
|
857
|
+
// A restart does not make the machine slower, and without this every moved
|
|
858
|
+
// encoder began as one whose speed nothing had measured — which the plan
|
|
859
|
+
// reads as "no arrival can be computed" and answers by comparing nothing.
|
|
860
|
+
const known = this.#lastSpeed.get(address) ?? 0;
|
|
861
|
+
if (known > 0) {
|
|
862
|
+
run.noteSpeed(known);
|
|
863
|
+
}
|
|
864
|
+
const onThisOutput = this.#runs.get(address) ?? [];
|
|
865
|
+
onThisOutput.push(run);
|
|
866
|
+
this.#runs.set(address, onThisOutput);
|
|
867
|
+
// Nothing has to be un-proved when a run takes a stretch that has already
|
|
868
|
+
// been written. A piece is only ever NAMED as served once it is closed, and
|
|
869
|
+
// a run writing that number again writes under a working name until it
|
|
870
|
+
// closes its own — so the file standing there is a complete piece made by
|
|
871
|
+
// somebody, and serving it is right until the newer one replaces it whole.
|
|
872
|
+
// The claim comes after the run exists, and that is not a race: everything
|
|
873
|
+
// between is synchronous, and a process cannot say a word before the next
|
|
874
|
+
// tick. What it removes is the second act — building a run and starting it
|
|
875
|
+
// were two steps, and two owners each performed the second one.
|
|
876
|
+
this.coverageOf(address).claim(run, from, endOfRun({ from, to }));
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* How many encoders may run on this output, from every limit at once.
|
|
881
|
+
*
|
|
882
|
+
* The processor is one of them and is answered from outside, where the
|
|
883
|
+
* machine is measured. The other two are known here: what the swarm delivers,
|
|
884
|
+
* through the seconds of swarm time a second of film costs, and — once it is
|
|
885
|
+
* supplied — the memory the piece store may hold against what one encoder's
|
|
886
|
+
* reader keeps.
|
|
887
|
+
*
|
|
888
|
+
* Said out loud when it is not the processor that decided, because "why is
|
|
889
|
+
* there only one encoder" is otherwise a question no log can answer.
|
|
890
|
+
*
|
|
891
|
+
* @param {string} address
|
|
892
|
+
* @param {{ speedX: number }[]} live
|
|
893
|
+
* @returns {number}
|
|
894
|
+
*/
|
|
895
|
+
#affordableOn(address, live) {
|
|
896
|
+
const byProcessor = Math.max(0, this.maxRunsFor(address));
|
|
897
|
+
// The best figure this host has: what a run here is doing now, what one was
|
|
898
|
+
// last measured doing, or what the startup benchmark predicted. The first
|
|
899
|
+
// two are this output's own; the third exists before either, so the budget
|
|
900
|
+
// is never asked to price encoders at a speed of zero.
|
|
901
|
+
const fastest = Math.max(
|
|
902
|
+
live.reduce((best, run) => Math.max(best, run.speedX || 0), 0),
|
|
903
|
+
this.#lastSpeed.get(address) ?? 0,
|
|
904
|
+
this.startingSpeedFor(address) || 0
|
|
905
|
+
);
|
|
906
|
+
const budget = affordableRuns({
|
|
907
|
+
byProcessor,
|
|
908
|
+
speedX: fastest,
|
|
909
|
+
refetchSecPerFilmSecond: this.refetchSecPerFilmSecond(address),
|
|
910
|
+
// How much slower one encoder runs beside others, read off this host's own
|
|
911
|
+
// startup measurement. A pure function over a measured table: beyond what
|
|
912
|
+
// was measured it holds the largest reading rather than continuing a curve
|
|
913
|
+
// nothing observed.
|
|
914
|
+
contentionPenaltyFor: (others) => contentionPenalty(others, this.contentionPenalties).penalty
|
|
915
|
+
});
|
|
916
|
+
if (budget.runs !== byProcessor && budget.because !== this.#lastBudgetReason.get(address)) {
|
|
917
|
+
this.#lastBudgetReason.set(address, budget.because);
|
|
918
|
+
this.logger.info(
|
|
919
|
+
`encode: ${budget.runs} encoder(s) on ${address.slice(0, 60)} — ${budget.because} ` +
|
|
920
|
+
`(the processor alone would allow ${byProcessor})`
|
|
921
|
+
);
|
|
922
|
+
}
|
|
923
|
+
return budget.runs;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* The per-output limit, bounded by what the whole machine can still hold.
|
|
928
|
+
*
|
|
929
|
+
* The per-output answer is at least one whatever else is running; the
|
|
930
|
+
* machine's may be none, and that is the refusal: the output gets no encoder
|
|
931
|
+
* until one elsewhere ends. It is never below what already runs here, so a
|
|
932
|
+
* refusal stops a start and never takes an encoder away.
|
|
933
|
+
*
|
|
934
|
+
* Said out loud when the machine is what bound it, once per change.
|
|
935
|
+
*
|
|
936
|
+
* @param {string} address
|
|
937
|
+
* @param {object[]} live
|
|
938
|
+
* @param {number} perOutput
|
|
939
|
+
* @returns {number}
|
|
940
|
+
*/
|
|
941
|
+
#withinMachine(address, live, perOutput) {
|
|
942
|
+
if (!this.admission) {
|
|
943
|
+
return perOutput;
|
|
944
|
+
}
|
|
945
|
+
const machine = this.admission.placesFor(address, live.length);
|
|
946
|
+
const runs = Math.min(perOutput, machine.runs);
|
|
947
|
+
const said = runs < perOutput ? `${runs}:${machine.because}` : "";
|
|
948
|
+
if (said !== (this.#lastMachineBound.get(address) ?? "")) {
|
|
949
|
+
if (said) {
|
|
950
|
+
this.logger.info(
|
|
951
|
+
`encode: ${runs} encoder(s) on ${address} — the whole machine holds no more ` +
|
|
952
|
+
`(${machine.because}; this output alone would be allowed ${perOutput})`
|
|
953
|
+
);
|
|
954
|
+
this.#lastMachineBound.set(address, said);
|
|
955
|
+
} else {
|
|
956
|
+
this.#lastMachineBound.delete(address);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
return runs;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* Take charge of a run this class did not start.
|
|
964
|
+
*
|
|
965
|
+
* The browser asks for a stream and a run begins for it, long before this
|
|
966
|
+
* class has an opinion. Left unknown, that run would be invisible to the plan
|
|
967
|
+
* — which would then start a second encoder over the same numbers, believing
|
|
968
|
+
* nothing was being made there. So whoever starts one hands it over, and from
|
|
969
|
+
* then on it is planned like any other.
|
|
970
|
+
*
|
|
971
|
+
* @param {string} address
|
|
972
|
+
* @param {{ id: string, from: number, to: number, head: number, speedX: number, isAlive: boolean, stop: (because: string) => void }} run
|
|
973
|
+
*/
|
|
974
|
+
adopt(address, run) {
|
|
975
|
+
if (!run) {
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
const onThisOutput = this.#runs.get(address) ?? [];
|
|
979
|
+
if (onThisOutput.includes(run)) {
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
onThisOutput.push(run);
|
|
983
|
+
this.#runs.set(address, onThisOutput);
|
|
984
|
+
this.coverageOf(address).claim(run, run.from, endOfRun(run));
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* @param {object} run
|
|
989
|
+
* @param {string} because
|
|
990
|
+
*/
|
|
991
|
+
#stop(run, because) {
|
|
992
|
+
run.stop(because);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* A run has ended, however it ended. Its stretch goes back to the map — what
|
|
997
|
+
* it finished stays made — and the ending is counted.
|
|
998
|
+
*
|
|
999
|
+
* Wired by whoever builds the run, so that a run built outside this class is
|
|
1000
|
+
* still accounted for.
|
|
1001
|
+
*
|
|
1002
|
+
* @param {import("../encode/EncodeRun.js").RunEnded} ended
|
|
1003
|
+
*/
|
|
1004
|
+
noteEnded(ended) {
|
|
1005
|
+
this.#lastState.set(ended.address, ended.run?.state ?? "IDLE");
|
|
1006
|
+
if (ended.lastError) {
|
|
1007
|
+
this.#lastError.set(ended.address, ended.lastError);
|
|
1008
|
+
} else if (ended.because && ended.ending !== ENCODE_EXIT.COMPLETE) {
|
|
1009
|
+
this.#lastError.set(ended.address, ended.because);
|
|
1010
|
+
}
|
|
1011
|
+
const progress = ended.run?.progress?.snapshot?.();
|
|
1012
|
+
if (progress) {
|
|
1013
|
+
this.#lastProgress.set(ended.address, progress);
|
|
1014
|
+
}
|
|
1015
|
+
this.#costs.note(ended);
|
|
1016
|
+
// Exactly one ending is normal — the run reached the end of the stretch it
|
|
1017
|
+
// was given and closed its last file. Every other leaves a piece open, and
|
|
1018
|
+
// that piece is under this run's OWN working name, so clearing up after it
|
|
1019
|
+
// is a name match: no stretch to search and no bytes to judge. Done for
|
|
1020
|
+
// every ending, the normal one included, since a run that finished cleanly
|
|
1021
|
+
// has nothing under a working name and the sweep then removes nothing.
|
|
1022
|
+
this.#segmentCoverage?.clearUpAfter(ended.address, ended.from);
|
|
1023
|
+
this.coverageOf(ended.address).release(ended.run);
|
|
1024
|
+
const remaining = this.runsOn(ended.address).filter((run) => run !== ended.run);
|
|
1025
|
+
if (remaining.length === 0) {
|
|
1026
|
+
this.#runs.delete(ended.address);
|
|
1027
|
+
} else {
|
|
1028
|
+
this.#runs.set(ended.address, remaining);
|
|
1029
|
+
}
|
|
1030
|
+
this.#endings.set(ended.ending, (this.#endings.get(ended.ending) ?? 0) + 1);
|
|
1031
|
+
this.#noteInputAvailability(ended);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/**
|
|
1035
|
+
* Remember, per output, that its input was not there — and until when nothing
|
|
1036
|
+
* is to be placed on it.
|
|
1037
|
+
*
|
|
1038
|
+
* **Why the plan has to hold this.** A run whose input has gone is not alive,
|
|
1039
|
+
* so the plan sees the stretch it held as free and places another run there at
|
|
1040
|
+
* once — and the next one dies the same way, because nothing about the state
|
|
1041
|
+
* has changed. There WAS a delay for exactly this, doubling from 2 s to 15 s,
|
|
1042
|
+
* and it governed only the dead run's own retry while the plan went on placing
|
|
1043
|
+
* fresh ones beside it. The comment beside that timer predicted this in as
|
|
1044
|
+
* many words and the code did not prevent it. Field 2026-09-12: 2432 ffmpeg
|
|
1045
|
+
* starts in 23 minutes, one every 0.57 s, for 61 minutes, against a delay that
|
|
1046
|
+
* had long since reached its 15 s cap.
|
|
1047
|
+
*
|
|
1048
|
+
* So the delay lives where the decision is taken. It is not a cure for the
|
|
1049
|
+
* input being away — that is the store's claim being withdrawn and the piece
|
|
1050
|
+
* being fetched again — it is what stops one absent input from costing a
|
|
1051
|
+
* thousand processes and a quarter of a million log lines while it is away.
|
|
1052
|
+
*
|
|
1053
|
+
* @param {{ address: string, ending: string }} ended
|
|
1054
|
+
* @returns {void}
|
|
1055
|
+
*/
|
|
1056
|
+
#noteInputAvailability(ended) {
|
|
1057
|
+
if (ended.ending !== ENCODE_EXIT.INPUT_LOST) {
|
|
1058
|
+
// CLEARED ONLY BY PROOF THAT THE INPUT WAS THERE, which is a segment
|
|
1059
|
+
// having come out of it. Any other ending was nearly the rule here and is
|
|
1060
|
+
// wrong: at the moment of failure several runs end at once, and one of
|
|
1061
|
+
// them ending `gone` or `stopped` without producing a thing says nothing
|
|
1062
|
+
// about the input — it would have lifted the wait the one beside it had
|
|
1063
|
+
// just set, which is the storm again with an extra step.
|
|
1064
|
+
const produced =
|
|
1065
|
+
ended.firstOutputMs !== null && ended.firstOutputMs !== undefined
|
|
1066
|
+
? true
|
|
1067
|
+
: Number.isFinite(ended.reached) && Number.isFinite(ended.from) && ended.reached >= ended.from;
|
|
1068
|
+
if (produced) {
|
|
1069
|
+
this.#inputLostAttempts.delete(ended.address);
|
|
1070
|
+
this.#quietUntil.delete(ended.address);
|
|
1071
|
+
}
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
const attempts = (this.#inputLostAttempts.get(ended.address) ?? 0) + 1;
|
|
1075
|
+
this.#inputLostAttempts.set(ended.address, attempts);
|
|
1076
|
+
const delayMs = Math.min(
|
|
1077
|
+
INPUT_QUIET_MAX_MS,
|
|
1078
|
+
INPUT_QUIET_BASE_MS * 2 ** Math.min(attempts - 1, 6)
|
|
1079
|
+
);
|
|
1080
|
+
this.#quietUntil.set(ended.address, this.now() + delayMs);
|
|
1081
|
+
this.logger.info(
|
|
1082
|
+
`encode-plan on ${ended.address}: its input was not there (attempt ${attempts}) — ` +
|
|
1083
|
+
`placing nothing for ${Math.round(delayMs / 1000)}s`
|
|
1084
|
+
);
|
|
1085
|
+
// THE WAKE-UP, because a plan that refuses to act needs something to ask it
|
|
1086
|
+
// again: nothing about the state changes while the data is away, so no event
|
|
1087
|
+
// would arrive to reconsider it.
|
|
1088
|
+
const timer = setTimeout(() => {
|
|
1089
|
+
this.#quietTimers.delete(ended.address);
|
|
1090
|
+
this.planSoon();
|
|
1091
|
+
}, delayMs);
|
|
1092
|
+
timer.unref?.();
|
|
1093
|
+
const previous = this.#quietTimers.get(ended.address);
|
|
1094
|
+
if (previous) {
|
|
1095
|
+
clearTimeout(previous);
|
|
1096
|
+
}
|
|
1097
|
+
this.#quietTimers.set(ended.address, timer);
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/**
|
|
1101
|
+
* Whether this output is still waiting before anything may be placed on it.
|
|
1102
|
+
*
|
|
1103
|
+
* A boolean rather than the milliseconds left: the figure had one reader and
|
|
1104
|
+
* that reader only asked whether it was above zero, so it was a number
|
|
1105
|
+
* computed and thrown away. How long the wait is was said when it began.
|
|
1106
|
+
*
|
|
1107
|
+
* @param {string} address
|
|
1108
|
+
* @returns {boolean}
|
|
1109
|
+
*/
|
|
1110
|
+
#isQuiet(address) {
|
|
1111
|
+
const until = this.#quietUntil.get(address);
|
|
1112
|
+
if (!Number.isFinite(until)) {
|
|
1113
|
+
return false;
|
|
1114
|
+
}
|
|
1115
|
+
if (until - this.now() <= 0) {
|
|
1116
|
+
this.#quietUntil.delete(address);
|
|
1117
|
+
return false;
|
|
1118
|
+
}
|
|
1119
|
+
return true;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
/**
|
|
1123
|
+
* Forget everything remembered about an output nobody is producing for.
|
|
1124
|
+
*
|
|
1125
|
+
* Three maps are keyed by address, and an output that goes away leaves an
|
|
1126
|
+
* entry in each for the life of the process. Small, and exactly the shape of
|
|
1127
|
+
* accumulation this layer was built to remove from the session.
|
|
1128
|
+
*
|
|
1129
|
+
* @param {string} address
|
|
1130
|
+
* @returns {void}
|
|
1131
|
+
*/
|
|
1132
|
+
#forgetInputState(address) {
|
|
1133
|
+
this.#inputLostAttempts.delete(address);
|
|
1134
|
+
this.#quietUntil.delete(address);
|
|
1135
|
+
const timer = this.#quietTimers.get(address);
|
|
1136
|
+
if (timer) {
|
|
1137
|
+
clearTimeout(timer);
|
|
1138
|
+
this.#quietTimers.delete(address);
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/**
|
|
1143
|
+
* How runs have ended over the life of this process, by cause.
|
|
1144
|
+
*
|
|
1145
|
+
* The abnormal classes are meant to stand at zero. Without the count,
|
|
1146
|
+
* "we understand why it ended" is indistinguishable from "we noticed it once".
|
|
1147
|
+
*
|
|
1148
|
+
* @returns {Record<string, number>}
|
|
1149
|
+
*/
|
|
1150
|
+
endings() {
|
|
1151
|
+
/** @type {Record<string, number>} */
|
|
1152
|
+
const tally = {};
|
|
1153
|
+
for (const ending of Object.values(ENCODE_EXIT)) {
|
|
1154
|
+
tally[ending] = this.#endings.get(ending) ?? 0;
|
|
1155
|
+
}
|
|
1156
|
+
return tally;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/**
|
|
1160
|
+
* One line saying what this proxy is encoding and whether anybody is waiting.
|
|
1161
|
+
*
|
|
1162
|
+
* `waiting` is the point of it: a proxy with encoders running and a viewer
|
|
1163
|
+
* still stopped at a segment nobody is making is the failure this layer was
|
|
1164
|
+
* built to remove, and it is visible here rather than inferred from a log.
|
|
1165
|
+
*
|
|
1166
|
+
* @returns {string}
|
|
1167
|
+
*/
|
|
1168
|
+
describe() {
|
|
1169
|
+
const parts = [];
|
|
1170
|
+
for (const address of new Set([...this.#demand.addresses(), ...this.#runs.keys()])) {
|
|
1171
|
+
const coverage = this.coverageOf(address);
|
|
1172
|
+
const stated = this.#demand.mapOn(address);
|
|
1173
|
+
const windows = stated.map((zone) => ({ from: zone.from, to: zone.to }));
|
|
1174
|
+
const waiting = firstUnmetWant(coverage, windows);
|
|
1175
|
+
const runs = this.runsOn(address)
|
|
1176
|
+
.map((run) => `#${run.head}..#${run.to}@${run.speedX.toFixed(1)}x`)
|
|
1177
|
+
.join(" ");
|
|
1178
|
+
// The zones as they were stated, with their order, so a plan that is
|
|
1179
|
+
// working at the wrong end of the film is visible rather than inferred.
|
|
1180
|
+
const zones = [...stated]
|
|
1181
|
+
.sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0) || left.from - right.from)
|
|
1182
|
+
.map((w) => `p${w.priority ?? 0}:#${w.from}..#${w.to}`)
|
|
1183
|
+
.join(" ");
|
|
1184
|
+
// THE WHOLE ADDRESS. Cut to sixty characters, every output of one film
|
|
1185
|
+
// printed the same string — the picture, its quality steps and each
|
|
1186
|
+
// soundtrack are told apart only by the tail — so three lines of this
|
|
1187
|
+
// could not be matched to the three things they describe. Read on
|
|
1188
|
+
// 2026-09-07 while accounting for a session that produced nothing, and
|
|
1189
|
+
// the accounting had to be done by which line carried a run.
|
|
1190
|
+
//
|
|
1191
|
+
// And WHAT THE DISK HOLDS beside what is proven closed. They are two
|
|
1192
|
+
// different statements: files with nothing proving them closed reads as a
|
|
1193
|
+
// reporting fault, no files at all reads as an output yet to be made, and
|
|
1194
|
+
// the difference decides where to look.
|
|
1195
|
+
const held = this.#segmentCoverage ? this.#segmentCoverage.filesHeld(address) : -1;
|
|
1196
|
+
// AND WHETHER THE MAP IS BEING SERVED IN ITS OWN ORDER. The zones say
|
|
1197
|
+
// what matters most; this says what the viewer actually waited for, by
|
|
1198
|
+
// band. Waits at `now` are the ones that cost a spinner, and until this
|
|
1199
|
+
// existed there was no figure anywhere saying whether the urgent zone was
|
|
1200
|
+
// served first — the map could have been read backwards and every line
|
|
1201
|
+
// above would have looked the same.
|
|
1202
|
+
const served = this.#describeWaits(address);
|
|
1203
|
+
parts.push(
|
|
1204
|
+
`${address} ready=${coverage.stats().ready}` +
|
|
1205
|
+
`${held < 0 ? "" : ` of ${held} file(s) on disk`} ` +
|
|
1206
|
+
`zones=[${zones}] runs=[${runs}] ` +
|
|
1207
|
+
`waiting=${waiting === null ? "nobody" : `#${waiting}`}` +
|
|
1208
|
+
`${served ? ` served[${served}]` : ""}`
|
|
1209
|
+
);
|
|
1210
|
+
}
|
|
1211
|
+
const tally = this.endings();
|
|
1212
|
+
const endings = Object.entries(tally)
|
|
1213
|
+
.map(([cause, count]) => `${cause}=${count}`)
|
|
1214
|
+
.join(" ");
|
|
1215
|
+
return `encode: ${parts.length === 0 ? "nothing wanted" : parts.join(" | ")} :: endings ${endings}`;
|
|
1216
|
+
}
|
|
1217
|
+
}
|