Widen sizeInBytes beyond 32-bit Int so files over 2 GB are queryable — Closes #83 - #99
Merged
Merged
Conversation
conradbzura
marked this pull request as ready for review
August 10, 2026 16:51
schema.graphql is a generated artifact, but nothing in the repo produced it — the strawberry CLI is not an installed extra, so the only way to refresh it was to reconstruct the print_schema incantation by hand. Wrap it in a script so the SDL stays reproducible from the Python types.
GraphQL fixes Int at 32 bits, so any file over ~2.1 GB failed to serialize: the field resolved to null and the response carried a per-field error, degrading a whole page of results to a partial one. Every ENCODE .hic file (6-51 GB) and the larger 4DN mcools hit this, so it is the common case for contact maps rather than an edge case. BigInt serializes as a JSON number rather than a string so sizeInBytes stays usable in client arithmetic with no parsing step. The usual objection to that — values above 2^53-1 lose precision in JavaScript — does not bind, because 2^53 bytes is roughly 9 PB. The input filter carries the same scalar. Widening only the output would leave exactly the files it newly exposes unfilterable by size. The override is keyed by (model, field name) rather than by annotation so that widening one model's int does not silently widen every other int in the schema. BREAKING CHANGE: sizeInBytes is typed BigInt instead of Int on both FileMetadataType and FileMetadataInput. A query declaring a variable as Int for that argument now fails validation and must declare BigInt, and generated clients must be regenerated. Clients that only read sizeInBytes out of the response are unaffected — the wire form is still a JSON number.
Record the wire-representation decision where a client author will look for it — number over string, and why the JavaScript precision ceiling does not bind on byte sizes — alongside the three concrete ways a client that hard-codes Int breaks.
Pins the reported symptom (a 6.2 GB file resolving to null plus a per-field error), the round trip across the declared 64-bit range, and the input filter matching a size the old Int could not name. Also pins two things that are easy to lose silently: the widening reached the input filter as well as the output type, and it did not spread to the neighbouring counts and pagination arguments, which must stay Int. The SDL drift test closes a gap that predates this change — schema.graphql is generated but nothing failed when it went stale, so a type change that skipped regeneration could ship a wrong public contract.
Whether a client receives a number or a string is the decision this scalar makes, and schema.execute never touches json.dumps or an HTTP response. Asserting on the raw body catches a stringified value that response.json() would silently accept, and the mongomock-backed insert puts a real BSON encode and decode between the filter value and the match, which the FakeCollection double cannot.
_substitute_scalar handled T and Optional[T] and silently approximated everything else, so an override on a list-shaped field would publish a list as a bare scalar. The drift test does not catch that — the contributor's fix is to run make schema and commit the wrong shape — so an unsupported annotation has to raise at import instead. Raise GraphQLError rather than ValueError from the coercion. graphql-core logs a non-GraphQLError cause with its traceback, so on an unauthenticated endpoint every malformed filter value was writing a stack trace at ERROR. Correct the comment on the JavaScript safe-integer bound, which described a guard that does not exist. The constant is surfaced in the scalar description only; the 64-bit bound is the enforced one, because it is where BSON itself stops. Staying under 2^53 is an admission criterion for routing a field through BigInt, not something the scalar checks.
The drift test compares the artifact byte for byte, so its encoding must not depend on the locale of whoever ran make schema. The SDL is ASCII today, but descriptions are authored as Python strings in prose that uses em-dashes freely.
Six hand-picked values were all non-negative, so the lower bound of the advertised range was never exercised in the accepting direction — had it been mistyped, the suite would still have passed. Add a Hypothesis round trip across the range and a rejection property beyond it, plus both inclusive bounds as named cases. Have the drift guard call the generator it names instead of re-deriving the SDL a second way, so its failure message stays true: the two expressions could otherwise disagree about what up to date means, and the message would send the reader back to the command that caused the failure. Split the ExtraFileType.fileSize assertion out of the counts test. Counts cannot overflow; a byte size can, so grouping them read as a design rule when it is a deliberate, revisitable deferral. Also pin the integral float that Int used to coerce, drop three Arrange blocks whose database state no resolver ever reads, loosen an assertion that pinned graphql-core's exact phrasing, and use the mocker fixture rather than unittest.mock.
Telling a typed client to re-run codegen understates the work: an unrecognised custom scalar widens to any without failing the build, so the scalar mapping is the load-bearing step. Name it, and name the two other scalars a client must map for the same reason. Record the migration order, which is the non-obvious part. A rolling deploy serves both schemas at once, dev and prod publish different schemas by design, and a SHA rollback reverts the contract — but the leaf type only has to be named when a client declares a variable for it, so moving consumers to an inline literal or a whole-input variable first makes the deploy a non-event in either direction. Also qualify the filtering claim: size filtering is exact-match, and 4DN and HuBMAP store the size as a string through the C2M2 TSV path, so a numeric predicate does not match their documents. That gap is independent of the scalar's width and is tracked separately.
conradbzura
force-pushed
the
83-widen-size-in-bytes-beyond-int32
branch
from
August 11, 2026 14:58
eb3cc36 to
6e47966
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Introduce a
BigIntcustom scalar — a signed 64-bit integer serialized as a JSON number — and apply it tosizeInByteson bothFileMetadataType(output) andFileMetadataInput(input filter).GraphQL's specification fixes
Intat 32 bits, so any file over 2,147,483,647 bytes could not be represented: the field resolved tonulland the response carried anInt cannot represent non 32-bit signed integer valueentry inerrors, degrading a whole page of results to a partial one. That is the common case rather than an edge case — every one of the 3,581 ENCODE.hicfiles runs 6–51 GB, and the larger 4DN mcools exceed the ceiling too.The wire representation is the real decision here, so it is worth stating explicitly. GraphQL has no 64-bit
Int, and the two realistic options are aBigIntthat serializes as a JSON number and one that serializes as a String. This PR chooses the number.The case against a number is real but does not bind here: a JSON number above
Number.MAX_SAFE_INTEGER(2^53-1) is not exactly representable in JavaScript, and the consumer is a browser client (Gosling Designer), so "fits in 64 bits" is genuinely not the same as "the client can read it". But 2^53 bytes is roughly 9 PB. No file this API serves — or plausibly ever will — comes within five orders of magnitude of that ceiling, so the precision hazard is theoretical while the ergonomic cost of a String is immediate: every consumer ofsizeInByteswould have to parse before comparing or summing, and a client that forgot would get silently wrong arithmetic ("6262125716" + 1is"62621257161"in JavaScript) rather than a loud failure. A number keepssizeInBytesdirectly usable and keeps the JSON body byte-for-byte what a client already expected, only correct.The scalar's schema description records the 2^53 caveat so the reasoning survives in the published contract, and
_coerce_big_intrejects non-integers (includingtrue/false, sinceboolsubclassesintin Python) and anything outside the signed 64-bit range, in both directions. The 64-bit bound is not arbitrary: it is exactly where BSON stops, so no value the scalar admits can fail on the way to MongoDB. Staying under 2^53 is deliberately not enforced — that would invent a second, softer limit inside a type whose name promises 64 bits — so it is documented as an admission criterion for routing any future field throughBigInt.What breaks for a client that assumed
Int. Four things. A query declaringquery Q($s: [Int!])and passing it tosizeInBytesnow fails variable-type validation and must declare[BigInt!]. Generated clients must re-run codegen against the new SDL and add a scalar mapping —graphql-codegensilently widens an unrecognised custom scalar toany, so withoutscalars: { BigInt: 'number' }the field loses its type with no build failure. Any client validating responses against a stored copy of the schema must refresh it. And a client sending an integral float (1234.0) is now rejected, whereIntcoerced it. A client that merely readssizeInBytesout of the JSON response needs no change at all — it was already receiving a JSON number, and now receives a correct one instead ofnull.The break is also not atomic on the server side, and the README now records the way around that: the leaf type only has to be named when a client declares a variable for it, so a consumer that passes the filter as an inline literal or hoists the variable to the whole
[FileMetadataInput!]validates against the old schema and the new one alike — which makes a rolling deploy, the dev/prod schema skew, and a SHA rollback all non-events.The widening lands on the input filter as well as the output field. Widening only the output would leave exactly the files it newly exposes unfilterable by size, which is half the bug.
Closes #83
Proposed changes
BigIntscalar and a scoped override mechanismsrc/cfdb/api/gql/types.pydefinesBigIntalongside the existingObjectIdScalar, following the same module-level@strawberry.scalarstructure. One coercion function serves bothserializeandparse_value, because the wire form is symmetric — the value that goes out is the value that comes back.The output types are generated from the Pydantic models by runtime introspection, so a bare
intmaps to GraphQLInt. Rather than widening everyintin the schema,_SCALAR_OVERRIDESmaps(model, field name)to a replacement scalar andannotate()consults it first. Keying on the pair rather than the field name matters:ExtraFile.file_sizeis a same-named-shaped sibling on a different model that must stayInt, and so musttotalCount,fileCount, and thepage/pageSizearguments._substitute_scalarpreserves theOptionalwrapper soOptional[int]becomesOptional[BigInt].src/cfdb/api/gql/inputs.pyis hand-written, soFileMetadataInput.size_in_byteschanges type directly.Regenerate
schema.graphql, and keep it that wayschema.graphqlis a generated artifact that nothing in the repo regenerated or verified — thestrawberryCLI is not an installed extra, so refreshing it meant reconstructing theprint_schemaincantation by hand, and nothing failed when the checked-in copy went stale. Addscripts/export_schema.pyand amake schematarget, plus a test asserting the file on disk matches what the live schema renders. That gap predates this change but is worth closing in the PR that first exercises it: the SDL is what clients codegen against, so a stale copy ships a wrong public contract silently.Documentation
Add a Custom Scalars section to
README.mdrecording the wire-representation decision and the three concrete client breakages, and notemake schemain the Makefile targets table.Test cases
TestSizeInBytesScalarfilesquery selectssizeInBytesTestSizeInBytesScalarIntceiling, the JavaScript safe-integer maximum, or the 64-bit maximumfilesquery selectssizeInBytesTestSizeInBytesScalarfilesquery selectssizeInBytesnullwith no errorsTestSizeInBytesScalarfilesquery selectssizeInBytesalongside other fieldssizeInBytes, reports the failure at that field's path, and leaves every other field and the sibling file intactTestSizeInBytesScalarfilesquery filters on the large size as a query literalTestSizeInBytesScalarfilesquery filters through a[BigInt!]variableTestSizeInBytesScalar[Int!], as a pre-BigIntclient would[BigInt!]type rather than truncatingTestSizeInBytesScalarfilesquery is executedBigInterror rather than coercingbooltrapTestSizeInBytesScalar[BigInt!]variable carrying a numeric string, a fractional number, or an integral floatfilesquery is executedBigInterrorTestSizeInBytesScalarFileMetadataTypeandFileMetadataInputare introspectedsizeInBytesasBigIntTestSizeInBytesScalarFileList.totalCount,fileCount, and thepage/pageSizearguments are introspectedIntTestSizeInBytesScalarExtraFileType.fileSize, the other byte-size fieldInt, recorded as a deliberate deferral rather than a ruleTestSizeInBytesScalarfilesquery selectssizeInBytesTestSizeInBytesScalarfilesquery selectssizeInBytesBigInterrortest_schema.pyschema.graphqlmake schemawrites withtest_metadata_endpoint.py/metadatatest_metadata_endpoint.py/metadataquery filters on its size through a[BigInt!]variableReview round 1
Seven independent principal-engineer reviewers. No reviewer found a correctness, data-integrity, concurrency, or security defect; both blocking findings were Python test-guide MUST violations. Remediated: the new HTTP test now uses the
mockerfixture rather thanunittest.mock; the declared range is pinned by a Hypothesis round-trip property plus a rejection property, and both inclusive bounds are named cases (the six original examples were all non-negative, so_INT64_MINwas never exercised in the accepting direction);_substitute_scalarraises on any annotation it cannot express faithfully, instead of silently flattening a list into a scalar;_coerce_big_intraisesGraphQLErrorrather thanValueError, so a malformed filter value on an unauthenticated endpoint no longer writes a stack trace at ERROR; the drift test calls the generator it names; theExtraFileType.fileSizeassertion is split out and reworded as a deferral rather than a rule; and the_JS_SAFE_INTEGER_MAXcomment no longer describes a guard the code does not implement.Four findings were rejected, with reasons recorded in the review document: migrating both scalars off the deprecated
strawberry.scalarclass form (would have to moveObjectIdScalarand theSchema(...)call — scope this issue does not carry; five of the seven reviewers who raised it agreed no change was needed here); wideningExtraFile.file_sizetoBigInt(a different model, and #83 scopes this tosizeInBytes— the test that would otherwise have defended the bug is reworded instead); renaming or relocatingtests/test_metadata_endpoint.py(tests/test_cors.pyis the in-repo precedent for this exact shape, and with the datastore mocked it is not a true integration test either); and a registry-walking parity test over_SCALAR_OVERRIDES(would have to import a private dict and would restate the implementation; the new import-timeTypeErrorcovers the realistic failure).Two adjacent defects are noted and left for follow-ups:
size_in_bytesis stored as a BSON string for 4DN and HuBMAP because the C2M2 TSV path never narrows it, so exact-match size filtering is inert for those DCCs regardless of the scalar's width (the README now says so); and the field is indexed on the rawfilecollection but not on the materializedfilescollection the resolvers actually query.