Skip to content
This repository has been archived by the owner on Dec 12, 2024. It is now read-only.

✨ Implement MessageWriter trait #20

Merged
merged 14 commits into from
Dec 12, 2024

Conversation

christeefy
Copy link
Collaborator

@christeefy christeefy commented Dec 6, 2024

Migrate lib.rs's PipesFileMessageWriter implementation into MessageWriter and MessageWriterChannel traits, following the PipesMessageWriter and PipesMessageWriterChannel Python base classes.

Note

MessageWriter::open differs from the Python's PipesMessageWriter.open implementation. Rust has no simple way to yield (afaik). Python's implementation yields so that it can perform cleanup. Instead, in the Rust implementation, we delegate the cleanup responsibility to the Drop trait — loosely analogous to a C destructor.

This is a simplified implementation that ignores lower-level LogWriter and LogWriterChannel abstractions in the Python implementation, to prevent this PR from getting larger than it already is.

This PR ended up using two advanced Rust functionalities, so I've included write-ups and inline documentation for posterity.

1. MessageWriter and MessageWriterChannel as associated types

After a few attempts of implementing it in Rust (and with help from ChatGPT), I've settled on using Associated Types:

trait MessageWriterChannel;

trait MessageWriter {
    type Channel: MessageWriterChannel;
    ...
};

The original reason for me doing this is to limit the "blast radius" caused by the PipesDefaultMessageWriter struct.
That struct returns different types of MessageWriterChannels, decided at runtime (see Python implementation). In Rust, every type must be known at compile-time (allowing for static dispatch and optimizations), otherwise they need to be wrapped in Box (a smart pointer) for dynamic dispatch which incurs a runtime cost.

Crucially, PipesDefaultMessageWriter's implementation would alter MessageWriter's trait signature:

// Initial, non-associated type implementation
trait MessageWriter {
-    fn open(&self, params: Map<String, Value>) -> impl MessageWriterChannel;
+    fn open(&self, params: Map<String, Value>) -> Box<dyn MessageWriterChannel>;
}

forcing all future (community) MessageWriter implementations to incur this type complexity (needing to understand Box and dynamic dispatch) & performance penalty.

By using associated types, each concrete MessageWriter implementation is free to return whatever Channel it needs without affecting other implementations, so long as that Channel implements the MessageWriterChannel type:

struct DefaultWriter { ... };
impl MessageWriter for DefaultWriter {
    type Channel = Box<dyn MessageWriterChannel>;
    ...
}

// Other implementations' `Channel`s don't have to be boxed
struct GCSWriter { ...};
impl MessageWriter for GCSWriter {
    type Channel = GCSMessageChannel;  // Statically-dispatched and can be optimized by the compiler
    ...
}

Additional Benefits

Using associated types also led to other unexpected benefits:

  • The two given traits are "linked" — any MessageWriter implementation must have a corresponding and specific MessageWriterChannel implementation. In this PR, PipesDefaultMessageWriter must be used with Box<dyn MessageWriterChannel> and Box<dyn MessageWriterChannel> only, even if other MessageWriterChannel implementations exist1.
  • This means that the compiler will ensure that anytime we use PipesContext with a specific MessageWriter, the correct and corresponding MessageWriterChannel is used:
pub struct PipesContext<W>
where
    W: MessageWriter,
{
    data: PipesContextData,
    // Compiler enforces the correct `Channel` associated with a concrete `MessageWriter` at compile time
    message_channel: W::Channel, 
}
  • This pattern can be reused when implementing LogWriter and LogWriterChannel in the future

2. Sealing trait methods

The PipesMessageWriter.get_opened_payload abstract method in Python is denoted with @final, preventing overrides. Stable Rust doesn't have a #[final] attribute yet, and so we'll have to manually seal that method.

This is done using a private::Token that's only accessible within that module. Callers outside that module cannot access this dummy struct, and therefore cannot override its implementation!

However, external callers also cannot call the function without private::Token, so a public function is provided.

// message_writer.rs
mod private { 
    pub struct Token;
}

struct DefaultWriter { ... };
impl MessageWriter for DefaultWriter {
    fn get_opened_payload(&self, _: private::Token);
}

/// Public accessor to the sealed method
pub fn get_opened_payload(writer: &impl MessageWriter) -> HashMap<String, Option<Value>> {
    writer.get_opened_payload(private::Token)
}

User-facing documentation is also included in the implementation's documentation!
image

TODOs

  • Unit tests
  • Error handling (can be done in a subsequent PR) to not make this PR any larger

Footnotes

  1. For a generalized version of this, there's Generic Associated Types (GAT)

@christeefy christeefy force-pushed the message-writer-trait branch from 04fc794 to ad3da5d Compare December 6, 2024 20:58
@christeefy christeefy force-pushed the message-writer-trait branch from 451b5e9 to 4dc6dbb Compare December 6, 2024 21:23
@christeefy
Copy link
Collaborator Author

Fixes #13

@christeefy christeefy linked an issue Dec 11, 2024 that may be closed by this pull request
Copy link
Owner

@cmpadden cmpadden left a comment

Choose a reason for hiding this comment

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

Thank you @christeefy -- very appreciative that you took the time to outline the decision in the PR.

I'm going to merge this for now, as I plan to move this code base to the community-integration repo soon.

Side note, I plan on persisting the Git history in migration, so attribution will not be lost.

@cmpadden cmpadden merged commit dcf81cb into cmpadden:main Dec 12, 2024
2 checks passed
@christeefy christeefy deleted the message-writer-trait branch December 12, 2024 20:46
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Implement the PipesMessageWriter trait
2 participants