@cloudbitmaps/s3
S3 storage for CloudBitmaps. One bucket holds your generations and their pointers, so there is no second service to run.
ESM-only, Node ≥ 22.12. Use
import; forrequire(), Jest and TypeScript, see CommonJS, Jest and TypeScript.
Pre-1.0: the API and the on-disk format can still change. On npm these docs match the version they ship in; on GitHub they describe
main, and the changelog says what each release changed.
Install
pnpm add @cloudbitmaps/roaring @cloudbitmaps/s3
Install the same version of every @cloudbitmaps/* package: they release together, and each pins the exact version of core it was built with.
@aws-sdk/client-s3 (>=3.700.0 <4) is a real dependency of this package, so installing it is the whole step. A fresh install can resolve to the newest 3.x release, unless a lockfile pins it: install from a lockfile, run npm audit signatures, and review an SDK bump before you take it.
@cloudbitmaps/core, the engine underneath, arrives with it and you never name it.
Use
import { CloudRoaring } from '@cloudbitmaps/roaring';
import { S3Storage } from '@cloudbitmaps/s3';
const store = new CloudRoaring({
storage: new S3Storage({ bucket: 'bitmaps', prefix: 'cr', region: 'us-east-1' }),
});
await store.load({ segment: 'active-users' }, [1, 2, 3]);
await store.segment('active-users').has(2); // true
It builds its own client from your usual AWS credentials. Any other key is refused by name.
| Option | What it does |
|---|---|
bucket (required) | the bucket |
prefix | a key prefix for everything this store writes |
client | your own S3Client; it carries its own region, endpoint and credentials |
region, endpoint, pathStyle, credentials | build a client for you, such as one for MinIO; refused beside client |
maxSockets | most sockets the client you do not pass opens at once, for https and plain http alike; a positive integer, default 128 (the SDK's own is 50; if a future SDK changes its handler, the built client keeps the SDK's own limit); refused beside client |
partBytes, maxObjectBytes | multipart sizing: part size (default 8 MiB; the upload buffers one part at a time and copies it into one body to send it, so it holds up to about twice the part size at a flush, 16 MiB at the default, derived from the driver's code) and the largest object (about 78 GiB by default, up to S3's 5 TiB). A cap above 5 TiB, or a part above S3's 5 GiB part limit, is a ValidationError |
readTimeoutMs | how long one read (a GetObject or HeadObject, its body included) may take before it throws TransientError and the store retries it; 0, the default, sets no timeout. Writes are not timed |
conditionalDelete | whether the store applies If-Match on a DeleteObject: the registry then removes a deleted row rather than leaving a tombstone, and the storage half deletes an erasure's holder only while it is the object the erasure read, by its ETag. On by default when the host the client resolves is an AWS S3 host, however its endpoint was set (endpoint, AWS_ENDPOINT_URL_S3, AWS_ENDPOINT_URL, the shared config file); off for any other host: set it for an S3-compatible store only once you know it applies the header (MinIO ignores it) |
Before production
- Run
await store.preflight()once before the first load. It reads the registry, writes one probe object, writes the same key again (which must be refused), reads it back, lists it, deletes it and checks the lifecycle rules, withs3:ListBucket,s3:GetObject,s3:PutObjectands3:DeleteObject, ands3:GetLifecycleConfigurationfor the last. It does not checks3:AbortMultipartUpload, the registry'sIf-Match, or a read after a delete, among others: the full list. It shows an SDK of 3.640.0 or older as awrite-onceproblem, and cannot see one from 3.641.0 to 3.699.0, so the floor below stays yours to check. The probe sits at<prefix>cbm.due.preflight/, outside your application's namespaces: a grant scoped per namespace shows as awriteproblem, and as aregistryproblem too when the registry grant is scoped per namespace. - The bucket must honor conditional writes:
If-None-Match: *for a write-once generation andIf-Matchfor the pointer. AWS S3 does, and the test suite runs against MinIO. Another S3-compatible service that ignores the headers turns write-once into overwrite, so check yours before you depend on it. It must also be strongly consistent: a read or a listing made after a write or a delete returns sees it. AWS S3 is. - Use
@aws-sdk/client-s33.700.0 or later if you pin the SDK or pass your ownclient. This floor is a correctness floor, not a preference. An SDK sends only the conditional headers it models, and drops one it does not, without an error.If-None-Match, which write-once is built on, is modelled from 3.641.0: measured against MinIO, 3.640.0 silently overwrites an existing object, which loses a published generation.If-MatchonPutObject, which the registry's compare-and-swap is built on, is modelled from 3.700.0: 3.699.0's serializer omits it, so a fenced row write goes out unconditionally and can land over a concurrent writer's. This package's own range never resolves below 3.700.0. The registry also checks, before its first request, that the client it is given sendsIf-None-MatchandIf-MatchonPutObject, and refuses a registry write it would send without its precondition; a client that does not sendIf-MatchonDeleteObjectturnsconditionalDeleteoff. From 3.723.0 the SDK sends a part of 2 MiB or more (Expect: 100-continue) on its own connection outsidemaxSockets; before it those uploads share the pool, so the cap also limits them (expected, not measured: a lower upload rate at a low cap, nothing worse). - Grant
s3:GetObject,s3:PutObject,s3:DeleteObject,s3:AbortMultipartUploadands3:ListBucket. Withouts3:ListBucket, S3 answers a missing key with403instead of404. - Add a lifecycle rule that aborts incomplete multipart uploads, and never one that expires current objects or the
registry/prefix. An expired registry row takes the segment's pointer with it, and the token every publish is fenced on, so its generations look unreferenced; an expired generation makes a live segment unreadable (bucket lifecycle). - Check the bucket's lifecycle rules with
await backend.checkLifecycle(). It reads the configuration with one request (bounded byreadTimeoutMs, when set) and answersok,problem(an enabled, untagged rule over your prefix that expires current objects or moves them toGLACIER,DEEP_ARCHIVEor a class it does not know) orunchecked. It needss3:GetLifecycleConfiguration, which a load never uses andstore.preflight()'s lifecycle step does: without it the answer isunchecked, naming the permission. A missing abort-incomplete-upload rule is advice indetail, never aproblem(check the lifecycle rules from code). - A transient failure of a write throws
TransientError, and the write may or may not have landed. Re-run the call, or checkstore.generations(ref). The SDK's own retry is off for conditional writes, so a write that landed is not reported as a conflict. The driver sends each registry write once; a load that gets no answer reads the row and sends a fresh compare-and-swap from it, at most three times. A generation's object is sent again, up to three more times, only after a503(SlowDownis the usual one) or a bare429(why that is safe); it carries a random id in its user metadata (x-amz-meta-cbwid), so a first send that landed is told from another writer's object. A load whose row writes all go unanswered throwsTransientErrorand keeps its object. The waits before an object's re-sends use real timers: the store's injected clock does not reach them, andS3Storagetakes no clock, so a test that throttles one object write three times waits about 3.5 s at most (the publish's waits before a fresh row write do use the store's clock). A bare429(AWS S3 does not send one; some S3-compatible services do) is a throttle, handled exactly as a503is, and one that ends a call is aTransientError. - Reads can be timed, and writes are not. Nothing is timed unless you set
readTimeoutMs. Set, a read that has not finished in that many ms throwsTransientError, and the store runs it again; AWS's guidance is to retry a GET of under 512 KB after about 2 seconds. The timer covers the body as well, so a connection that sends its headers and then stalls is cut off too. It starts when the read is handed to the SDK, so a burst of reads larger than the built client's socket pool (128 by default) can time out while queued: set it above that queueing, or raisemaxSockets(aclientyou pass carries its own pool). For the writes and listings, give your client a timeout of its own.
The production checklist covers each item, with a sample IAM policy, and the ones every backend shares: a request timeout on your client for the requests the library does not time, backups of the data and the registry, and a schedule for your loads.
- The driver redacts the credentials it recognises from an error before it throws it. The AWS SDK keeps the
response it got on the error (
$response), and through its socket the request it sent, with the signedAuthorizationheader andx-amz-security-token. It also puts every field of the error body on the error, and S3 quotes the request back when it refuses it: its signature and canonical request (SignatureDoesNotMatch), or its session token (Token-0, onExpiredTokenandInvalidToken). In the error the driver throws, the value under a credential header or field name is replaced by[redacted](authorization,x-amz-security-token,x-amz-s3session-token, an SSE-C key,SignatureProvided,CanonicalRequest,Token-0and the like; the access key id S3 names is kept), and so are the value beside a credential's name in a list or a record (a table row that parses to{ th, td }with one name and one value, a{ name, value }pair,ArgumentNameandArgumentValue), a credential header line, a SigV4 signature (Signature=,X-Amz-Signature=) and a session token or SSE-C key after its header name, anywhere in its text. A credential in another form is not recognised, and a text too large for these patterns is replaced whole. An SDK error that carries its transport or shows a credential, and every error it reaches (itscausechain, anAggregateError'serrors), is thrown as a copy: a native error with the same prototype, name, message, stack,$metadata,$fault,Codeand request id, so classification is unchanged, without$response, the body the SDK could not parse ($responseBodyText), any function or accessor property, or anything nested deeper than ten levels. The live error is never changed, and an error with no transport and no credential in it is thrown as it is.
Documentation
License
Apache-2.0