Repository navigation
[Bug]: The default config for FirestoreV1.write().batchWrite() on smaller java jobs has an abysmal throughput #27170
Description
What happened?
Firestore has an 500 write/sec limitation, the java implementation splits up that quota among workers. Although it has a write rampup, the throughput is being influenced by the intial value a lot. The problem is that the default split is 1/500. So smaller jobs with just a few workers, start with 1/s and ramp up very slowly. If the job isn't sufficiently big enough, the throughtput stays low through the whole execution.
Lines 119 to 120 in 69cd6ef
| writeRampUp = | |
| new WriteRampUp(500.0 / options.getHintMaxNumWorkers(), filteringDistributionFactory); |
The note is there that it should be set, but you can easily miss it:
Lines 258 to 275 in 69cd6ef
| /** | |
| * A hint to the QoS system for the intended max number of workers for a pipeline. The provided | |
| * value can be used to try to scale calculations to values appropriate for the pipeline as a | |
| * whole. | |
| * | |
| * <p>If you are running your pipeline on <a target="_blank" rel="noopener noreferrer" | |
| * href="https://cloud.google.com/dataflow">Cloud Dataflow</a> this parameter should be set to the | |
| * same value as <a target="_blank" rel="noopener noreferrer" | |
| * href="https://cloud.google.com/dataflow/docs/guides/specifying-exec-params#setting-other-cloud-dataflow-pipeline-options">{@code | |
| * maxNumWorkers}</a> | |
| * | |
| * <p><i>Default Value:</i> 500 | |
| * | |
| * @see RpcQosOptions.Builder#withBatchMaxCount(int) | |
| */ | |
| public int getHintMaxNumWorkers() { | |
| return hintMaxNumWorkers; | |
| } |
Lines 594 to 680 in 69cd6ef
| static final class WriteRampUp { | |
| private static final Duration RAMP_UP_INTERVAL = Duration.standardMinutes(5); | |
| private final double baseBatchBudget; | |
| private final long rampUpIntervalMinutes; | |
| private final MovingFunction writeCounts; | |
| private final LinearBackoff backoff; | |
| private final Distribution throttlingMs; | |
| private final Distribution availableWriteCountBudget; | |
| @SuppressWarnings("OptionalUsedAsFieldOrParameterType") | |
| private Optional<Instant> firstInstant = Optional.empty(); | |
| WriteRampUp(double baseBatchBudget, DistributionFactory distributionFactory) { | |
| this.baseBatchBudget = baseBatchBudget; | |
| this.rampUpIntervalMinutes = RAMP_UP_INTERVAL.getStandardMinutes(); | |
| this.writeCounts = | |
| createMovingFunction( | |
| // track up to one second of budget usage. | |
| // this determines the full duration of time we want to keep track of request counts | |
| Duration.standardSeconds(1), | |
| // refill the budget each second | |
| // this determines the sub-granularity the full duration will be broken into. So if | |
| // we wanted budget to refill twice per second, this could be passed | |
| // Duration.millis(500) | |
| Duration.standardSeconds(1)); | |
| this.backoff = new LinearBackoff(Duration.standardSeconds(1)); | |
| this.throttlingMs = | |
| distributionFactory.get(RpcQos.class.getName(), "qos_rampUp_throttlingMs"); | |
| this.availableWriteCountBudget = | |
| distributionFactory.get(RpcQos.class.getName(), "qos_rampUp_availableWriteCountBudget"); | |
| } | |
| int getAvailableWriteCountBudget(Instant instant) { | |
| if (!firstInstant.isPresent()) { | |
| firstInstant = Optional.of(instant); | |
| return (int) Math.max(1, baseBatchBudget); | |
| } | |
| Instant first = firstInstant.get(); | |
| double maxRequestBudget = calcMaxRequestBudget(instant, first); | |
| long writeCount = writeCounts.get(instant.getMillis()); | |
| double availableBudget = maxRequestBudget - writeCount; | |
| int budget = Ints.saturatedCast((long) availableBudget); | |
| availableWriteCountBudget.update(budget); | |
| return budget; | |
| } | |
| /** | |
| * Calculate the value relative to the growth line relative to the {@code first}. | |
| * | |
| * <p>For a {@code baseBatchBudget} of 500 the line would look like <a target="_blank" | |
| * rel="noopener noreferrer" | |
| * href="https://www.wolframalpha.com/input/?i=500+*+1.5%5Emax%280%2C+%28x-5%29%2F5%29+from+0+to+30">this</a> | |
| */ | |
| private double calcMaxRequestBudget(Instant instant, Instant first) { | |
| Duration durationSinceFirst = new Duration(first, instant); | |
| long calculatedGrowth = | |
| (durationSinceFirst.getStandardMinutes() - rampUpIntervalMinutes) / rampUpIntervalMinutes; | |
| long growth = Math.max(0, calculatedGrowth); | |
| return baseBatchBudget * Math.pow(1.5, growth); | |
| } | |
| void recordWriteCount(Instant instant, int numWrites) { | |
| writeCounts.add(instant.getMillis(), numWrites); | |
| } | |
| Optional<Duration> shouldThrottle(Instant instant) { | |
| int availableWriteCountBudget = getAvailableWriteCountBudget(instant); | |
| if (availableWriteCountBudget <= 0) { | |
| long nextBackOffMillis = backoff.nextBackOffMillis(); | |
| if (nextBackOffMillis > BackOff.STOP) { | |
| Duration throttleDuration = Duration.millis(nextBackOffMillis); | |
| throttlingMs.update(throttleDuration.getMillis()); | |
| return Optional.of(throttleDuration); | |
| } else { | |
| // we've exhausted our backoff, and have moved into the next time window try again | |
| backoff.reset(); | |
| return Optional.empty(); | |
| } | |
| } else { | |
| // budget is available reset backoff tracking | |
| backoff.reset(); | |
| return Optional.empty(); | |
| } | |
| } | |
| } |
IMO the 500 as the default value should be decreased to a sensible much smaller one.
Also due to how the check is implemented at the write DoFn even that 1 batch write / second doesn't have the maximum size it could have as it doesn't collect the new entries while "throttling" it's just sitting doing nothing. So instead of collecting messages until limit + blocking till throttling, it just collects some (many cases 1) message and blocks for the throttling.
I would suggest that the blocking should only happen when the write is ready to be executed.
Lines 293 to 325 in 69cd6ef
| private void flushBatch(boolean finishingBundle, ContextAdapter<OutT> context) | |
| throws InterruptedException { | |
| while (!writes.isEmpty()) { | |
| RpcWriteAttempt attempt = rpcQos.newWriteAttempt(getRpcAttemptContext()); | |
| Instant begin = clock.instant(); | |
| if (!attempt.awaitSafeToProceed(begin)) { | |
| continue; | |
| } | |
| FlushBuffer<WriteElement> flushBuffer = getFlushBuffer(attempt, begin); | |
| if (flushBuffer.isFull() || (finishingBundle && flushBuffer.isNonEmpty())) { | |
| DoFlushStatus flushStatus = doFlush(attempt, flushBuffer, context); | |
| if (flushStatus == DoFlushStatus.ONE_OR_MORE_FAILURES && !finishingBundle) { | |
| break; | |
| } | |
| } else { | |
| // since we're not going to perform a flush, we need to return the writes that were | |
| // preemptively removed | |
| flushBuffer.forEach(writes::offer); | |
| if (!finishingBundle) { | |
| // we're not on the final flush, so yield until more elements are delivered or | |
| // finishingBundle is true | |
| return; | |
| } | |
| } | |
| } | |
| if (writes.isEmpty()) { | |
| // now that the queue has been emptied reset our priority back to 0 to try and ensure | |
| // we won't run into overflow issues if a worker runs for a long time and processes | |
| // many writes. | |
| queueNextEntryPriority = 0; | |
| } | |
| } |
Issue Priority
Priority: 2 (default / most bugs should be filed as P2)
Issue Components
- Component: Python SDK
- Component: Java SDK
- Component: Go SDK
- Component: Typescript SDK
- Component: IO connector
- Component: Beam examples
- Component: Beam playground
- Component: Beam katas
- Component: Website
- Component: Spark Runner
- Component: Flink Runner
- Component: Samza Runner
- Component: Twister2 Runner
- Component: Hazelcast Jet Runner
- Component: Google Cloud Dataflow Runner
Activity
github-actions commentedon Oct 2, 2026
This issue has been marked as stale due to 150 days of inactivity. It will be closed in 30 days if no further activity occurs. If you think that’s incorrect or this issue still needs to be addressed, please simply write any comment. If closed, you can reopen the issue at any time. Thank you for your contributions.
nbali commentedon Oct 2, 2026
Still valid on current
master(15a7ce1). Compared to 69cd6ef, the code linked in the original report is unchanged apart from shifted line numbers and one cast (Math.pow(1.5, growth)→Math.pow(1.5, (double) growth)) that doesn't change behavior.Updated permalinks:
beam/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/firestore/RpcQosImpl.java
Lines 122 to 123 in 15a7ce1
beam/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/firestore/RpcQosOptions.java
Lines 258 to 275 in 15a7ce1
beam/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/firestore/RpcQosImpl.java
Lines 597 to 683 in 15a7ce1
beam/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/firestore/FirestoreV1WriteFn.java
Lines 328 to 360 in 15a7ce1
nbali commentedon Oct 2, 2026
Two PRs that alleviate this (they don't fix it): both copy existing DatastoreIO behavior into FirestoreIO.
RpcQosOptions.Builder.withRampupThrottlingDisabled(), mirroringDatastoreV1.Write.withRampupThrottlingDisabled(). With it, writes no longer wait for the 500/50/5 ramp-up budget and batches are no longer capped by it. Error-based throttling and retry backoff still apply.DatastoreV1.Mutate/RampupThrottlingFn, instead of from each DoFn instance's first write. DoFn instances created later (autoscaling, retried bundles) no longer start over at 1 write/s. It's skipped when--updateCompatibilityVersionis below 2.78.0.Neither changes the default: without the opt-out or
withHintMaxNumWorkers, a small job still starts at500 / 500 = 1write/s and grows 50% every 5 minutes.feat(firestore): add option to disable write ramp-up throttling (#40390)