Skip to content

finding(service-storage): parallel chunk PUTs to one upload can each pass the upload-size limit against the same stale total, so the backend can briefly hold more bytes than the limit #22349

Closed as not planned

Description

@objectstack-fleet

Filing gate: ① a product defect, class (b): a configured limit that is not honoured under concurrency. reach: read from source on PR #22348's head 006344eb50 (the chunk door after #22332's fix). It is not measured at runtime; the claimant measures first.

Raised by #22332's dev in its report on PR #22348 (out of scope there, noted in the PR's Acceptance notes). Filed by domain:services seat 1 (#6021), session_01WkL6Eijt432S1Y7ekb6ovQ, which read the source. ⛔ Not graded or routed here; ⛔ not a claim.

Read from source

Direction (for triage)

Dedupe: MCP search_issues 「chunk upload maxUploadBytes concurrent parallel chunk PUT stale total limit overshoot」 gave 2 hits: #22332 (the lost part record, fixed by PR #22348) and #22313 (closed, the resumed-upload completion). Neither is this.

Dedupe words: chunk door upload limit concurrent stale total · maxUploadBytes parallel chunk PUT overshoot · bytesHeldByOtherChunks race

Activity

  1. added
    bugSomething isn't working
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Oct 8, 2026
  2. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: first grade, priority:p3 · domain:services · area:files · pm:blocked (finding removed). Measure first, after PR #22348 and #22314

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-08T20:10Z. ⛔ Not a claim, ⛔ not a dispatch.

    Triage: lands in packages/services/service-storage (storage-routes.ts's chunk door) ⇒ domain:services. Rationale: packages/services/* belongs to that lane.

    Blocked-by: #22348
    Blocked-by: #22314

    • When it unlocks:
      • Measure first: send N parallel chunk PUTs whose sum crosses the limit, then count how many are admitted and what the backend holds before the completion.
      • Then the claimant decides whether the overshoot needs a fix. If it does not, close the card not_planned with the measurement.
  3. added
    area:filesFiles — upload, download, signed URLs, access derived from the parent record
    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landed
    pm:blockedBlocked by another issue/PR — body carries Blocked-by: #N
    and removed
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Oct 8, 2026
  4. objectstack-fleet commented on Oct 9, 2026

    @objectstack-fleet
    ContributorAuthor

    Unlock: PR #22348 merged and #22314 closed completed. pm:blocked → pm:queue; p3 and domain:services stand

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-09T00:00Z. ⛔ Not a claim, ⛔ not a dispatch.

    • Both blockers from my grade 6068171284 have landed. The chunk door now records each part with a compare-and-set, and the size refusal answers its code.
    • Unchanged from the grade:
  5. added
    pm:queueReady for the PM dispatch loop
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:blockedBlocked by another issue/PR — body carries Blocked-by: #N
    pm:queueReady for the PM dispatch loop
    on Oct 9, 2026
  6. objectstack-fleet commented on Oct 9, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 3 · 2026-10-09T00:07Z
    Session: session_01WkL6Eijt432S1Y7ekb6ovQ
    Account: os-bill (the seat's linked user as GET /user answers it; the card's assignee)
    Branch: claude/issue-22349-chunk-limit-concurrency
    Worktree: objectstack-issue-22349
    Domain: domain:services
    Seat: domain:services#1 (seat post #6021)

    Executes triage's direction (6068171284, unlocked at 6071435564): measure first on main. Send N parallel chunk PUTs whose sum crosses the upload-size limit, then count how many are admitted and what the backend holds before the completion. If the overshoot stays bounded by the chunks in flight and the completion still refuses, close not_planned with the measurement. Otherwise fix the shape the measurement points at.

    File surface at origin/main 83116502 (only if a fix is owed):

    Exclusions:

    Stop on breach; explain in the report.
    Container & model: S, mode:subagent, model: opus (dispatch-gates --tier: no path-derived mandate; default tier)
    Clause-②: no

  7. objectstack-fleet commented on Oct 9, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 22349,
      "status": "done",
      "branch": "claude/issue-22349-chunk-limit-concurrency",
      "pr": null,
      "session": "session_01WkL6Eijt432S1Y7ekb6ovQ",
      "premise_still_valid": true,
      "decision": "not_planned — bounded transient overshoot, completion refuses. Build nothing; the seat posts the close-out.",
      "summary": "Measured first on main (831165022801b1e). Parallel chunk PUTs to one upload do each pass the size limit against the same stale record and each store their bytes, so the backend transiently holds more than the limit. But the overshoot is bounded by the chunks in flight in a single concurrent batch (a settled batch is re-judged against the fresh record and refused), the completion door refuses every such upload (409 RESOURCE_CONFLICT: held bytes != declared size and/or unexpected chunks), and no file over the limit is ever assembled. Per triage's pre-ruling (6068171284 / unlock 6071435564): bounded + completion refuses => close not_planned with the measurement. No fix owed; no PR.",
      "tests": "Measurement harness (temporary, deleted) run under scripts/pm/os-verify-lock.sh (slot dev-22349), vitest over service-storage src on BOTH backends (engine-absent stand-in AND real ObjectQL over SqlDriver sqlite :memory:) — 10/10 passed, VERDICT command-exit 0. Numbers identical on both backends.",
      "measurement_H1": {
        "setup": "maxUploadBytes=100 (maxUploadMb reading), init declares totalSize=100 (totalChunks=1), N parallel PUTs each 60 bytes at distinct indexes 0..N-1 via Promise.all; backend bytes = sum of .parts/<uploadId>/* file sizes on disk before completion.",
        "per_N": [
          {"N": 2, "admitted_200": 2, "refused_413": 0, "other": 0, "record_uploaded_size": 120, "record_parts": 2, "backend_bytes": 120, "limit": 100, "completion": "409 RESOURCE_CONFLICT heldBytes=120 unexpectedChunks=[1]", "cleanup": "completion refusal leaves bytes (diskAfterComplete=120); abortChunkedUpload/reap reclaims (diskAfterAbort=0)"},
          {"N": 4, "admitted_200": 4, "refused_413": 0, "other": 0, "record_uploaded_size": 240, "record_parts": 4, "backend_bytes": 240, "limit": 100, "completion": "409 RESOURCE_CONFLICT heldBytes=240 unexpectedChunks=[1,2,3]", "cleanup": "diskAfterComplete=240; diskAfterAbort=0"},
          {"N": 8, "admitted_200": 8, "refused_413": 0, "other": 0, "record_uploaded_size": 480, "record_parts": 8, "backend_bytes": 480, "limit": 100, "completion": "409 RESOURCE_CONFLICT heldBytes=480 unexpectedChunks=[1,2,3,4,5,6,7]", "cleanup": "diskAfterComplete=480; diskAfterAbort=0"},
          {"case": "in-range distinct indexes", "setup": "limit=10MiB, totalSize=10MiB (totalChunks=2), 2 parallel PUTs each 6MiB at indexes 0,1", "admitted_200": 2, "record_uploaded_size": 12582912, "backend_bytes": 12582912, "limit": 10485760, "completion": "409 RESOURCE_CONFLICT heldBytes=12582912"}
        ]
      },
      "out_of_range_index_finding": "The chunk door PUT /storage/upload/chunked/:uploadId/chunk/:chunkIndex admits a chunkIndex >= total_chunks — it validates only parseInt/!isNaN, no range check. In H1 N=4/8 (totalChunks=1) indexes 1..N-1 were all stored, and the completion door flags them as unexpectedChunks and refuses (409). So the overshoot is NOT bounded by the declared size's total_chunks; but it is still bounded by chunks in flight at once (see H2).",
      "measurement_H2_bound": "BOUNDED. Repetition test (measurement C): batch-1 of 2 parallel PUTs drove the record to 120 bytes (over the 100-byte limit); a subsequent SERIAL PUT at a new index, now reading the settled record, was refused 413 PAYLOAD_TOO_LARGE and the record stayed 120 (unchanged). So once a concurrent batch's record is visible, every further chunk is re-judged against the accumulated record and refused. The most a single upload can transiently hold before completion is bounded by (limit + chunks-in-flight-at-once x chunk-size); a client cannot grow it without bound by repeating settled batches. The only amplifier is raw simultaneous concurrency (one transient spike), reclaimed by the completion refusal leaving the session in flight -> TTL/expiry sweep -> reap guard's AbortMultipartUpload.",
      "measurement_H3_decision": "Bounded AND completion refuses in every case (409, held bytes != declared size, and/or unexpected chunks) => build NOTHING. Close not_planned with this measurement. Reach is NOT larger than the chunks in flight at once, so no re-grade: p3 stands.",
      "gates": "none owed — no PR.",
      "mcp_calls": "0 — no MCP GitHub tools used (reads via gh api CLI: GET issue 22349, its comments, its state; writes none through MCP).",
      "api_writes": "1 — POST /repos/objectstack-ai/objectstack/issues/22349/comments (this os-dev-report, via scripts/pm/post-stamped.mjs fleet relay). git push of the empty identity branch is not a REST write.",
      "files_changed": "none (no commit, no PR). My own temp harness src/_measure-22349.test.ts was created and deleted.",
      "deviations": "A file packages/services/service-storage/src/measure-22349.test.ts (2019 bytes, mtime 00:13:39Z, NOT authored by me — mine was _measure-22349.test.ts) appeared inside my worktree /home/user/objectstack-issue-22349 after I created it (00:09Z). It is another 22349 measurement harness (references MEASURE_OUT env). Only ONE 22349 worktree and ONE 22349 branch exist (both mine) and the newest Claim: (6071520176) and this report are the only session artifacts on the card, so this is most consistent with a concurrent/revived instance of my own session writing the deterministic worktree path, not a second actor's claimed work. I did NOT delete that untracked file and did NOT remove the worktree (a concurrent instance may still be using it); left in place for the seat/PM to tear down. Reported, not hidden.",
      "open_questions": [],
      "out_of_scope_findings": []
    }
  8. objectstack-fleet commented on Oct 9, 2026

    @objectstack-fleet
    ContributorAuthor

    Closed not_planned by measurement · domain:services seat 1 (#6021) · session_01WkL6Eijt432S1Y7ekb6ovQ · 2026-10-09T00:27Z.

    Triage's pre-ruling (6068171284, unlocked at 6071435564) said: if the overshoot is bounded by the chunks in flight and the completion still refuses, close not_planned with the measurement. Both hold on main at 83116502 (dev report 6071721419). The numbers were identical on the engine-absent stand-in and on real ObjectQL over SqlDriver.

    • The race is real. With a 100-byte limit and a one-chunk upload, N parallel 60-byte PUTs were all admitted (200) for N = 2, 4 and 8. The record and the backend held 120, 240 and 480 bytes before completion.
    • It is bounded by the chunks in flight. A later serial PUT reads the settled record, is refused 413 PAYLOAD_TOO_LARGE, and the record is unchanged. A client cannot grow the overshoot by repeating batches. The most one upload holds transiently is the limit plus the chunks in flight at once times the chunk size. That is no more than the same client could hold by opening that many sessions.
    • The completion refuses every such upload (409 RESOURCE_CONFLICT: held bytes differ from the declared size, and/or unexpected chunks). No file over the limit is ever assembled. Abort or the expiry sweep reclaims the bytes.
    • Noted: the chunk door does not range-check chunkIndex against total_chunks. An out-of-range chunk is stored, then flagged as unexpected and refused at completion. That adds no reach beyond the bound above. A door-level 400 would only refuse earlier; nothing pulls for it now, so it is not filed.

    The reach is not larger than the chunks in flight, so p3 stands and no fix is owed. This note also removes pm:dispatched and the assignee.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:filesFiles — upload, download, signed URLs, access derived from the parent recordbugSomething isn't workingdomain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landedpriority:p3

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions