Skip to content

Load CDK: Improved new interface bookkeeping - #56346

Merged
Johnny Schmidt (johnny-schmidt) merged 1 commit into
masterfrom
jschmidt/load-cdk/improved-new-interface-bookkeeping
Mar 27, 2025
Merged

Johnny Schmidt (johnny-schmidt) merged 1 commit into
masterfrom
jschmidt/load-cdk/improved-new-interface-bookkeeping

Conversation

@johnny-schmidt

@johnny-schmidt Johnny Schmidt (johnny-schmidt) commented Mar 22, 2025 •

Copy link
Copy Markdown
Contributor

What

This is all quality-of-life. Before we were just counting COMPLETE and PERSISTED (ackable) records. Now we keep detailed information per stage/partition about exactly what we've counted. Additionally I cleaned up Batch.State (now BatchState) and deprecated all the old stuff.

It also adds some detailed opt-in debug logging that can be turned on when needed. Similar to the detailed logging in the old interface, but more user-friendly. Ie:

Stream test20250322ozzl:testInsertRecords: Records Read: 1000000 (done: true)
ObjectLoaderPartFormatter[0](PROCESSED): 500000 records (done: true, saw eos at input 500000)
ObjectLoaderPartFormatter[1](PROCESSED): 500000 records (done: true, saw eos at input 500000)
ObjectLoaderPartFormatter[TOTAL](PROCESSED): 1000000 records (done: true, saw eos at input 1000000)
ObjectLoaderPartLoader[0](STAGED): 148798 records  (inputs: 6, done: false)
ObjectLoaderPartLoader[1](STAGED): 198378 records  (inputs: 8, done: true, saw eos at input 8)
ObjectLoaderPartLoader[2](STAGED): 202489 records  (inputs: 9, done: true, saw eos at input 9)
ObjectLoaderPartLoader[3](STAGED): 148740 records  (inputs: 6, done: false)
ObjectLoaderPartLoader[4](STAGED): 198378 records  (inputs: 8, done: true, saw eos at input 8)
ObjectLoaderPartLoader[TOTAL](STAGED): 896783 records  (inputs: 37, done: true, saw eos at input 25)
ObjectLoaderUploadCompleter[0](COMPLETE): 4084 records  (inputs: 1, done: false)
ObjectLoaderUploadCompleter[TOTAL](COMPLETE): 4084 records  (inputs: 1, done: false)

I found this critical for debugging an issue exposed by the bulk loader work. It also enables asserting against bad counts / out-of-order processing, which caused a specific issue during testing. (Bug-fix/guards for that impending. The issue doesn't effect anyone using DirectLoader, which is currently all our production connectors.)

Review guide

Mostly name changes. The functional stuff & new logging is in the StreamManager. Also LoadPipelineStepTask and UpdateBatchStateTask are tweaked to collect/pass the new information, and I added BatchState to all of the steps in ObjectLoader.

StreamManager already had rigorous tests, which still pass. I also re-tested S3, S3DataLake, and the (unmerged) MSSQL changes with this, both ITs and Perf Tests.

(NOTE: This is on top of the S3 Data Lake setup fix because the impending bulk load PR depends on both.)

@vercel

vercel Bot commented Mar 22, 2025 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Comments Updated (UTC)
airbyte-docs ✅ Ready (Inspect) Visit Preview 💬 Add feedback Mar 27, 2025 0:07am

@johnny-schmidt
Johnny Schmidt (johnny-schmidt) force-pushed the jschmidt/load-cdk/improved-new-interface-bookkeeping branch 2 times, most recently from e06a1c7 to 5c6789f Compare March 23, 2025 00:27
@johnny-schmidt
Johnny Schmidt (johnny-schmidt) changed the base branch from master to jschmidt/dest-s3-datalake/bugfix-setup-doesn-not-await-start March 23, 2025 00:27
@johnny-schmidt
Johnny Schmidt (johnny-schmidt) force-pushed the jschmidt/dest-s3-datalake/bugfix-setup-doesn-not-await-start branch from 92acee8 to 3677a7e Compare March 24, 2025 19:19
@johnny-schmidt
Johnny Schmidt (johnny-schmidt) force-pushed the jschmidt/load-cdk/improved-new-interface-bookkeeping branch from 5c6789f to 2bd4f8c Compare March 24, 2025 19:23
Base automatically changed from jschmidt/dest-s3-datalake/bugfix-setup-doesn-not-await-start to master March 24, 2025 19:34

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2025

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Haha, that was added automatically by the formatter, which I guess doesn't know what year it is?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I have seen that before

if (taskCompletionCounts.containsKey(taskKey)) {
throw IllegalStateException(
""""$taskKey received input after seeing end-of-stream
(checkpointCounts=$checkpointCounts, inputCount=$inputCount, sawEosAt=${taskCompletionCounts[taskKey]}) if (stateStore.streamsEnded.contains(input.key.stream)) {\n" +

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this formatting intended?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No :(

val records: Long,
// TODO: File bytes moved
) {
operator fun plus(other: CheckpointValue): CheckpointValue {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It overloads +

acc.third + checkpointCount.recordsCompleted.get(),
)
}
val readCount = counts.map { it.value.records }.sum()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

anyway we can do something like this intsead?

fun countRecordsInState(state: BatchState): Long {
        return namedCheckpointCounts
            .filter { (key, _) -> key.second == state }
            .values
            .fold(0L) { accumulator, checkpointMap ->
                accumulator + checkpointMap
                    .filterKeys { it.id <= checkpointId.id }
                    .values
                    .sumOf { it.records }
            }
    }

and then:

    val persistedCount = countRecordsInState(BatchState.PERSISTED)
    if (persistedCount == totalRecordsRead) {
        return true
    }
    
    val completedCount = countRecordsInState(BatchState.COMPLETE)
    return completedCount == totalRecordsRead

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll try it

} else {
stateStore.remove(input.key)?.close()
stateStore.stateWithCounts.remove(input.key)?.let {
if (inputCount > 0 || it.checkpointCounts.isNotEmpty()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

check?

val batchSizeOverride: Long? = null,
) {
// A map of (TaskIndex, Stream) -> Count_of_closed streams to ensure eos is not forwared from
// A map of (TaskIndex, Stream) -> streams to ensure eos is not forwared from

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

forwarded

)
}
manager.incrementCheckpointCounts(
message.taskName,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make more sense to pass the message instead of individual attributes?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, at this point it's coupled enough i might as well


// @Singleton
class S3V2RoundRobinInputPartitioner : RoundRobinInputPartitioner()
// @Singleton class S3V2RoundRobinInputPartitioner : RoundRobinInputPartitioner()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this just be removed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry I meant to add an explanation lol. This is here so that S3 is ready to have the object loader just switched on when I have bandwidth to release it.

fileName: glue_assume_role.json
secretStore:
type: GSM
type: GSMs

@frifriSF59 Francis Genet (frifriSF59) Mar 24, 2025 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this change intended?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

definitely not

@edgao Edward Gao (edgao) left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic seems right, though I only skimmed the debug log piece. Some assorted comments on readability.

val output = batchAccumulator.finish(stored.accumulatorState).output
handleOutput(key, stored.checkpointCounts, output)
stored.close()
log.warn { "Finishing state for $key remaining after end-of-stream" }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

checking: this is the case where we hit end of input, but there's still some not-yet-persisted data sitting around?

(if yes, nit: let's use INFO, since this is likely to happen on every stream)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it's actually normal, it shouldn't be a warning.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also s/after/at, so it sounds less like an error/edge case

* end (it is a critical error to receive input for a stream that has ended, as it means that
* something is likely wrong with our bookkeeping.)
*/
data class StateStore<K1, S : AutoCloseable>(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nonblocking: does this bookkeeping have noticeable performance impact? since we're doing some map lookups per record

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, map lookups in non-concurrent containers in java are pretty heavily optimized. What matters is that we're avoiding concurrent lookups (by passing this data structure through the fold).

* something is likely wrong with our bookkeeping.)
*/
data class StateStore<K1, S : AutoCloseable>(
val stateWithCounts: MutableMap<K1, StateWithCounts<S>> = mutableMapOf(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do these need to be concurrent maps? (I think not? afaict StateStore is only ever used within a single thread)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no. because they're passed as an argument to the fold operation, we shouldn't need to do anything fancy

sealed interface PartResult : WithBatchState {
val objectKey: String
override val state: BatchState
get() = BatchState.STAGED

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this can probably just be override val state = STAGED? (i.e. it doesn't need to be recomputed on every access)

(the compiler probably optimizes it out anyway 🤷 but might as well make it more explicit + less verbose)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah probably better to move it to the class level

fileName: glue_assume_role.json
secretStore:
type: GSM
type: GSMs

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is probably a typo?

val records: Long,
// TODO: File bytes moved
) {
operator fun plus(other: CheckpointValue): CheckpointValue {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm pretty impressed by intellij's ability to cmd-click on a + and have it Just Work (i.e. correctly jump to here)!

) {
checkpointCounts.forEach { (checkpointId, recordCount) ->
val taskKey = TaskKey(taskName, part)
if (taskCompletionCounts.containsKey(taskKey)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this happening inside the .forEach loop?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oof, good question.

This indicates data was processed out of order and future bookkeeping might be corrupt. Failing hard."""
)
}
namedCheckpointCounts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I think this would be easier to read if you pulled the getOrPut out of the loop i.e.

val x = namedCheckpointCounts.getOrPut() {}
checkpointCounts.forEach { x.merge() }

(at least I think that's equivalent?)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah i agree that's better and probably more efficient also

@johnny-schmidt
Johnny Schmidt (johnny-schmidt) force-pushed the jschmidt/load-cdk/improved-new-interface-bookkeeping branch from 93e8c2f to 00d7882 Compare March 27, 2025 00:01
@johnny-schmidt
Johnny Schmidt (johnny-schmidt) deleted the jschmidt/load-cdk/improved-new-interface-bookkeeping branch March 27, 2025 00:15

This branch was successfully deployed

1 active deployment
Preview — 00d78821 Deployed Mar 27, 2025 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants