feat: publish RBS and RBI types with Gem#410
Conversation
5e70a0f to
68ce2da
Compare
jazev-stripe
left a comment
There was a problem hiding this comment.
This looks really promising, and I think would already be pretty useful to us. The approach of applying the runtime checks during tests is new to me--seems quite cool!
I left a few comments; we also have a thread in the shared Stripe <> Temporal Slack channel (#support-stripe-temporal) about this.
| handle.execute_update(MyWorkflow.my_update, 'value') | ||
|
|
||
| # Result | ||
| _result = handle.result |
There was a problem hiding this comment.
For some expressions like this (and in various other places in this file), could we add T.assert_type!(...) calls? https://sorbet.org/docs/type-assertions#tassert_type
The runtime typechecking that assertion performs wouldn't run, but the static typechecking would
| **What NOT to include in the RBI:** | ||
| * `Temporalio::Internal::*` types (excluded entirely) | ||
| * Methods prefixed with `_` (internal use only) | ||
| * `Temporalio::Api::*` protobuf types (except `Api::Common::V1::Payload` and `Payloads` which users reference in |
There was a problem hiding this comment.
Would it be possible to include the RBI types for these as well? At Stripe we maintain https://github.com/sorbet/protoc-gen-rbi, which I'd recommend. Tapioca looks like it also has its own support: https://github.com/Shopify/tapioca/blob/main/manual/compiler_protobuf.md, though I'm not sure how it compares.
To give some motivation, this is the most common source of needing to do T.unsafe(...) in our integration with sdk-ruby at Stripe today (which is still in its early stages). We have code that looks like this:
workflow_service_client = T.let(
temporal_client.workflow_service,
Temporalio::Client::Connection::WorkflowService
)
namespaces_result = workflow_service_client.list_namespaces(
T.unsafe(Temporalio::Api::WorkflowService::V1::ListNamespacesRequest).new(
page_size: page_size,
next_page_token: next_page_token
)
)The existing types being added in this PR would let us remove the T.let(...) cast on the first statement [0], but we'd need to keep the T.unsafe(cls).new(...) on the second statement unless there were RBI stubs for the Protobuf types.
It's understandable if the SigApplicator system wouldn't work for these (since I think the methods might not actually exist on the classes?
[0]: (we could remove that today, but we'd be calling a method on a T.untyped)
There was a problem hiding this comment.
If sdk-ruby weren't able to publish the RBI types for the protobufs, I think we'd strongly consider setting up our own additional RBI generation to cover the protobuf classes, at Stripe.
I think it's still globally preferable if this is provided by the upstream, though (since all users will get them).
| module Code | ||
| OK: 0 | ||
| CANCELLED: 1 | ||
| CANCELED: 1 |
There was a problem hiding this comment.
Oh neat, was this a true positive error that Sorbet caught?
temporalio/rbi/temporalio.rbi
Outdated
| end | ||
|
|
||
| class Temporalio::Client::Connection::Service | ||
| sig { params(connection: Temporalio::Client::Connection, service: T.untyped).void } |
There was a problem hiding this comment.
Could we have non-T.untyped type annotations for the service stack as well?
At Stripe we currently have a bespoke interceptor stack inside Temporalio::Client::Connection that sits between the bridge RPC stubs and the rest of the SDK / the Temporalio::Client (so that we can apply RPC metadata to client calls not supported by Temporalio::Client::Interceptor). Having type annotations on these classes/methods would be useful for us.
temporalio/rbi/temporalio.rbi
Outdated
| id: String, | ||
| workflow_id: String, | ||
| workflow_run_id: T.nilable(String), | ||
| known_outcome: T.untyped, |
There was a problem hiding this comment.
If this type is unknown and can't be easily constrained using a generic (or if it's not worth it), using Object or T.anything would be preferable:
| known_outcome: T.untyped, | |
| known_outcome: T.anything, |
temporalio/rbi/temporalio.rbi
Outdated
| sig { returns(String) } | ||
| def first_execution_run_id; end | ||
|
|
||
| sig { returns(T::Hash[String, T.untyped]) } |
There was a problem hiding this comment.
Can this provide a more specific type (are decoded headers always strings? or does it depend on the converter behavior?). If there is none, T.anything would be preferable, I think
There was a problem hiding this comment.
Would you consider minimizing the number of T.untyped throughout this file as much as possible? (except some of the things like the .new types on the Data classes)
If you were able to minimize the instances of that, adding a ratchet to prevent future changes to the RBI types (when new APIs are added to the SDK) from being added as T.untyped might also be prudent.
jez
left a comment
There was a problem hiding this comment.
@jazev-stripe mentioned that you might want some feedback on this approach, don't mean to intrude, very excited to see this in any form it takes, it's extremely cool!
|
|
||
| return :skipped if skip_method?(original, method_node, method_name) | ||
|
|
||
| target.extend(T::Sig) |
There was a problem hiding this comment.
Something you might consider is monkey patching Module to have this, rather than doing one extend per method
class ::Module
include T::Sig
endor do something like
::Module.include(::T::Sig)in the start of apply_all!. Just avoids doing extra work once-per-method when it only needs to be done once-per-codebase.
| sig_source = sig.string | ||
| begin | ||
| target.class_eval(sig_source) | ||
| target.send(:define_method, method_name, original) |
There was a problem hiding this comment.
I don't quite know what effects this could have, it's akin to doing this:
def foo
my_logic
end
sig { void }
def foo
my_logic
endFor example, some places where this could go haywire:
- At load time, someone could do something to grab a method object for a method:
FooMethod = MyClass.instance_method(:foo). After redefining the method, the method object still holds a reference to the not-wrapped method. - I believe that stack trace lines will show the definition of
fooat this line in sig_applicator, instead of in its original file. That would have already been the case due to thesignature_for_methodcall which wraps the method, but anyone who then uses reflection to do something likesignature.method.source_locationin an attempt to get a reference to the "original" method's source location will see this file, instead of the file wherefoois actually defined (haven't confirmed this, I'm just guessing)
Not sure if either of those matter in practice for this use case.
The other thing which you might be able to try is to "fake" the logic that Sorbet currently does in its method_added hook. Normally the way that
sig { returns(Integer) }
def foo = 0works is that sig runs, it stores a T::Private::Methods::DeclarationBlock in some thread-local variable, then Ruby runs the def, it evaluates, and fires the method_added hook. In that, Sorbet looks up the sig from the thread local and uses it to redefine the method with the signature. So if you really wanted to do something as close as possible to what sorbet-runtime would have done, you might be able to write your own method_added hook which
- Figures out which method was just defined
- Looks it up in the parsed RBI
- Evaluates the sig with
class_evallike above (i.e. "just in time"), which should populate the same thread local that sorbet-runtime would have populated, had thesigbeen evaluated like normal - Calls Sorbet's
T::Private::Methods._on_method_addedhook, which will read that thread local and do the rest of the wrapping logic it would have done for any method definition. That would be a way to get rid of thisdefine_methodthat you have here yourself.
Again, that might be overkill for this use case, it's possible that the double define_method (the one here, and the one inside signature_for_method) is actually fine.
I believe that this would also mean you could get rid of or at least simplify the define_method below to "Restore the original method without the sig wrapper" because you wouldn't have redefined it in the first place.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Generated via `tapioca gem temporalio` against SDK v1.3.0. This file is used by CI to detect when the public API surface changes and the enriched RBI needs updating. It is not shipped to users. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Hand-maintained RBI file with typed sig blocks covering the full public API surface (~8,250 lines). Types are derived from the existing RBS signatures in sig/. Internal APIs are excluded. Also adds rbi/temporalio.rbi to the gemspec so it ships with the gem. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Move tapioca baseline from rbi/ to extra/ to prevent Sorbet from loading it alongside the enriched RBI via path: gem references - Fix parameter ordering in list_workflow_page sig - Fix Future Elem type_member variance (remove :out since result= uses it as input) - Replace T.nilable(T.untyped) with T.untyped - Fix unresolved bare Worker:: references to use full qualification Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
These classes inherit from ::Mutex, ::Queue, ::SizedQueue respectively. Without the inheritance, methods like `synchronize` are not available to Sorbet users. Found while porting message_passing_simple sample to Sorbet. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Custom PayloadCodec implementations reference these protobuf types directly. Without stubs, Sorbet cannot resolve them. Found while porting encryption sample to Sorbet. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds extra/sorbet_check/ — a self-contained Sorbet project that validates the enriched RBI against realistic SDK usage patterns (activities, workflows, client, worker, codecs, errors, cancellation, search attributes, mutex). Runs `srb tc` on the checkTarget CI matrix. Also removes the tapioca baseline (extra/tapioca_baseline/) — the Sorbet type check provides better validation than diffing tapioca output, which is fragile across tapioca versions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a "Maintaining Type Signatures" section under Development with instructions on keeping RBS and RBI in sync, type mapping reference, and guidance on what to include/exclude from the RBI. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
68ce2da to
76e1c77
Compare
What was changed
Include RBS types in published Gem along with newly added RBI types.
RBS
The codebase is already type checked with RBS, we just never included these type definitions as part of the gem. We now include these in the gem with experimental support.
RBI
These are net new, initially generated with Tapioca, narrowed via Claude/myself, and validated using
srb tcand enablingsobet-runtimein our tests.extras/sorbet_checkA minimal file that is type checked with Sorbet to validate usage of the RBI.
SigApplicator
To better verify that the RBI is correct, we run our tests with each method redefined with
sorbet-runtimesignatures generated from our RBI file. These will also be released as experimental.We cannot apply this to every class as some:
Temporalio::Workflow::FutureUsing
SigApplicatoris controlled by theTEMPORAL_SORBET_RUNTIME_CHECKenvironment variable.We did have to update some tests where we intentionally pass in mistyped data.
Review Guidelines
I would focus highly on the
SigApplicatormodule as this is our primary tool for verifying our RBI file. Looking at the RBI definitions for Workflow is worthwhile, but unsure the value of crawling through those 7k lines of type definitions.Why?
Community ask
Checklist
Closes [Feature Request] Publish RBI types #412
How was this tested:
See RBI section above.
Any docs updates needed?
Updated README, further docs will be updated as we move this out of experimental.