If icon
Fail icon
Aggregate icon
Log icon
SlackIncomingWebhook icon
Pause icon
Update icon
Schedule icon

MongoDB Unbounded Array Growth Trim Guard

Detect MongoDB arrays growing toward the 16MB BSON limit, archive the overflow, trim to the newest entries behind an approval, then re-measure.

Categories
DataInfrastructure

A MongoDB document cannot exceed 16MB. An embedded array that every write $pushes to, the events list on a device, the history on an order, the audit trail on an account, has nothing stopping it. It grows for months, then one document crosses the limit and from that moment every write to it fails with BSONObjectTooLarge. The failure is per document, so it hits the busiest records first while the rest of the collection looks fine, which is why it is usually found in production rather than in review.

This blueprint measures the thing that actually matters: how many entries each array holds and how close the whole document is to the ceiling. It reports the documents over a length threshold, can copy the entries it is about to discard into an archive collection, trims each oversized array to its newest entries with one server-side $push carrying $each: [] and a negative $slice, then measures again and fails the run if anything is still oversized.

This blueprint was created by zkasuran.

Safety

  • Read-only by default. The first run with the defaults only measures and reports. A trim needs auto_remediate true, dry_run false, then a human answering the Pause.
  • The trim filter is the detection predicate, not the keep boundary. A document at or below length_threshold is never written to, so a short array cannot be clipped by a run that was aimed at a long one.
  • keep_n is refused before the first read when it is below 1, which would empty the array, or above length_threshold, which could never clear the detection.
  • With archive_overflow true the discarded entries are written out and counted first. The run then fails before the trim if the archive holds fewer entries than the detection says will be dropped. No entry is dropped without its copy already stored.
  • The re-measurement is the proof, not the modified count. If any targeted document is still oversized, the run fails rather than reporting success.
  • Trimming discards the oldest entries permanently when archive_overflow is false. The flow says so in its own log before it writes.

How it works

  1. check_inputs (io.kestra.plugin.core.flow.If) fails the run on a keep_n that is below 1 or above length_threshold, before any connection is made.
  2. detect (io.kestra.plugin.mongodb.Aggregate) matches documents where the array field is an array and the array index at length_threshold exists, which is how MongoDB answers "longer than N" without walking every entry. One $facet then returns the exact document count, the exact number of entries a trim would discard, the largest document in bytes with its percentage of the 16777216 byte limit and the worst scan_limit documents by length.
  3. report (io.kestra.plugin.core.log.Log) logs those numbers, because task outputs are not in the execution JSON.
  4. route (io.kestra.plugin.core.flow.If): zero oversized documents logs clean and stops. One or more sends alert to Slack and prints planned, which states what a remediation run would do on every run including a dry one.
  5. remediate_gate (io.kestra.plugin.core.flow.If) opens only with auto_remediate true and dry_run false. hold (io.kestra.plugin.core.flow.Pause) waits for an answer.
  6. On approval and with archive_overflow true, archive (io.kestra.plugin.mongodb.Aggregate) slices the entries about to be discarded, $unwinds them and $merges one document per entry into archive_collection, keyed by source document, execution id and array index. archive_check counts what landed and archive_gate fails the run if it is short.
  7. trim (io.kestra.plugin.mongodb.Update, UPDATE_MANY) applies $push: { <field>: { $each: [], $slice: -keep_n } }, the server-side way to keep the newest keep_n entries, to every document matching the detection predicate.
  8. reverify runs the same measurement and verify_gate fails the run with io.kestra.plugin.core.execution.Fail if any document is still over the threshold. Without approval declined records the decision and nothing changes.
  9. errors posts to Slack when any task fails, for example when MongoDB is unreachable.

What you get

  • The exact number of documents whose array is over the threshold, the entries a trim would drop and the largest document in bytes against the 16MB ceiling, so the headroom is a number rather than a guess.
  • A Slack alert while the documents are still writable, instead of a BSONObjectTooLarge incident on the busiest records.
  • A gated, approved, server-side trim that keeps the newest entries and touches only the documents that were reported.
  • An optional archive of every discarded entry, with the trim refused if the archive is short.
  • A re-measurement that fails the run when the collection is still unbounded, so a writer pushing faster than the trim is caught rather than hidden.

Who it's for

  • Teams with an append-only array inside a document: event lists, status history, audit trails.
  • SREs who would rather bound growth on a schedule than debug BSONObjectTooLarge at 3am.
  • Anyone migrating an embedded array to its own collection who needs the live documents kept under the limit while that work lands.

Why orchestrate this with Kestra

MongoDB has no server-side cap on array length. $slice on a push bounds it, but only if every writer remembers to use it. It also does nothing about the documents that already grew. This flow is the outside check. The schedule decides when growth is measured, the counts are logged, the write sits behind auto_remediate, dry_run and a Pause with the decision recorded on the execution, the archive step is proved before anything is discarded and the re-measurement turns the trim into a checked outcome. The Slack alert and the error path are the same ones the rest of your flows use.

Tested end to end

On Kestra 2.0.5 OSS against a MongoDB 7.0 container, on the final file. The collection held three documents: device-hot with 10050 entries in events, device-quiet with 5 entries plus device-noarray with no events field at all. Every before and after length was read back with mongosh, not only from the Kestra log.

Run Inputs Result
Bad config keep_n: 6000, length_threshold: 5000 check_inputs failed the run before any read, errors alerted
Clean length_threshold: 20000 0 oversized, clean logged, no alert, SUCCESS. mongosh 10050 / 5
Detect and dry run length_threshold: 5000, other defaults 1 oversized, 9050 entries over, largest 1426096 bytes which is 8.5 percent of the limit, Slack alert, nothing written. mongosh still 10050 / 5
Armed but dry auto_remediate: true, dry_run: true Still no write. mongosh still 10050 / 5
Declined auto_remediate: true, dry_run: false, resumed approved: false Paused, then declined. mongosh still 10050 / 5
Remediate the same, resumed approved: true Matched 1, modified 1, re-check 0 oversized, SUCCESS. mongosh 1000 / 5, the document 1426096 bytes to 140946
Archive then trim the same plus archive_overflow: true 9050 entries archived and counted before the trim, then matched 1, re-check 0. mongosh archive 9050, collection 1000 / 5
Still unbounded remediate with a writer inserting oversized documents through the run Trim matched 48, re-check still found 1 oversized, verify_gate failed the run, errors alerted
Unreachable connection_uri pointed at a dead host detect failed, errors posted to Slack, execution FAILED

The trim kept the newest entries, not an arbitrary 1000: mongosh read back sequence numbers 9050 to 10049 out of the original 0 to 10049. The archive held the other side exactly, sequence 0 to 9049, one document per entry. device-quiet stayed at 5 entries and device-noarray was never matched in any run, which is the precision that matters: a trim aimed at one document must not clip the others.

Does the detector actually fail on growth

The clean run and the detect run isolate it. Nothing changed on the collection between them except the threshold: 20000 returned 0 oversized, 5000 returned exactly 1, the same device-hot, on the same three documents. mongosh confirmed both counts directly. The still-unbounded run proved the gate the other way: with a writer inserting oversized documents through the run, the trim landed on 48 documents yet the re-check still found 1, so the run failed instead of reporting a clean collection.

Prerequisites

  • A reachable MongoDB instance and a user that can read the collection and update it. The archive option also needs write access to the archive collection.
  • A document shape with an embedded array that grows by append. MongoDB 4.4 or later, because the report uses $bsonSize.
  • Local testing: docker run -d --name mdb --network mynet mongo:7.0, seed one document with a long array and one with a short one, then run with connection_uri: mongodb://mdb:27017.

Secrets

  • MONGODB_URI: the MongoDB connection string. Read when connection_uri is left empty, which is how the flow should run in production. In Kestra OSS, provide it as the base64-encoded environment variable SECRET_MONGODB_URI.
  • SLACK_WEBHOOK_URL: used by alert and the errors block. Provide it as SECRET_SLACK_WEBHOOK_URL.

Inputs

Input Default Purpose
connection_uri empty Plain connection string for a local test. Empty reads MONGODB_URI
database, collection appdata, activity The collection to guard
array_field events The embedded array that grows by append
length_threshold 5000 More entries than this counts as oversized. Also the trim filter
keep_n 1000 Entries kept when trimming, the newest ones
scan_limit 25 How many documents the report names. Counts stay exact
archive_overflow false Copy discarded entries out first, then verify before trimming
archive_collection activity_overflow_archive Where archived entries land
approval_timeout PT2H How long the pause waits for an answer
auto_remediate false Allow the trim
dry_run true Must be false for a trim to apply

Quick start

  1. Add the MONGODB_URI and SLACK_WEBHOOK_URL secrets.
  2. Set database, collection and array_field, then run the flow. It reports the oversized count and the headroom. It writes nothing.
  3. Pick keep_n from that report, set archive_overflow to true if the old entries matter, set auto_remediate: true and dry_run: false, run again, then answer the pause.
  4. Enable the daily schedule, which always runs in safe mode.

How to extend

  • Alert on the headroom rather than the length: fail the run when ceiling_pct crosses a number, so one threshold covers arrays of very different entry sizes.
  • Guard several collections by wrapping the tasks in a io.kestra.plugin.core.flow.Loop over a list, with concurrencyLimit: 1 on the part that writes.
  • Put a TTL index on archived_at in the archive collection so the archive itself stays bounded.
  • Once the documents are bounded, make the writers push with $slice themselves, so this flow becomes the check rather than the cleanup.
  • Replace the Pause with a Slack approval. Or drop it for a collection you are sure about.

Links

See How

New to Kestra?

Use blueprints to kickstart your first workflows.