-
Notifications
You must be signed in to change notification settings - Fork 5.6k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[PRFC] Introduce the backend-common Context #8042
Conversation
🦋 Changeset detectedLatest commit: c35e52c The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
4dde5a3
to
60020e8
Compare
* | ||
* @public | ||
*/ | ||
export interface Context { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm still a fan of the much more minimal Context
interface in https://github.com/backstage/backstage/pull/5312/files#diff-89a2ef91b1efe5c84c025ee2db34aba2f91a665f2cc12856f70e915209a205f1
The trouble with a fat context interface is that we can't add or remove any required methods or parameters without a breaking change, making it a poor place to put utility methods.
For example, the current interface doesn't have a withDeadline
method, and we couldn't add that without a breaking change. In that case an optional withDeadline
method would be pretty much useless as well, as you would have to always be able to fall back to computing the delta instead. What would instead happen in practice is that me ship it as Contexts.withDeadline
, contextWithDeadline
or something like that, leaving people wondering why there are two different ways to access things.
I think the Go Context interface is pretty well proven, and translated to TypeScript it would just be something like
interface Context {
readonly deadline?: Date // I think it's best to avoid luxon in core interfaces
readonly abortSignal: AbortSignal;
getValue<T>(key: unknown): T;
}
The only missing piece there is the reason the context was aborted, which is actually being worked on
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I know it's not a huge deal really, but one thing that grinds my gears a bit is the mutation patterns not being chainable. :) I think we mentioned that briefly when making the original context implementation.
callFunction(
Contexts.withLabels(
Contexts.withRequestData(
Contexts.withBlah(ctx, a),
request
),
{ label: 'value' })
);
);
or alternatively
let derived = Contexts.withBlah(ctx, a);
derived = Contexts.withRequestData(derived, request); // god forbid you accidentally do ctx here instead of derived!
derived = Contexts.withLabels(derived, { label: 'value' });
callFunction(ctx);
so would be nice to have something like the with
.
callFunction(
ctx.with(
Contexts.withBlah(a),
Contexts.withRequestData(request),
Contexts.withLabels({ label: 'value' }),
),
// or maybe this reads better and pairs well with Contexts.getX(ctx)?
ctx.use(
Contexts.setBlah(a),
Contexts.setRequestData(request),
Contexts.setLabels({ label: 'value' }),
),
// or maybe
Contexts.extend(ctx, builder -> builder
.setBlah(a)
.setRequestData(request)
.setLabels({ label: 'value' }));
);
The setters would boil down to just handling Context => Context functions anyway.
I'd totally be up for condensing the context down as proposed, in terms of data, to the deadline
+ abortSignal
+ getValue
though apart from that. I mean we could of course even remove deadline
and abortSignal
but I think golang does strike a good balance there in terms of having a really minimal essential set of things. And besides, the abort signal being in there, is also related directly to other things (it interacts with manual cancellation, timeouts, AND shutdown mechanisms) so having it "outside" the core while being effectlively 100% essential may not be helpful.
I guess doing it with a Contexts
helper or even any small set of some kind of common helpers, means that you shift the migration complexity / risk over to those classes instead. When we want to iterate on those builders and extractors of values, people's producing-side code is going to break just as much, and for those consumers that consume anything more than a deadline, abort signal or value, will break too. But I guess that's likely to be a minority of consumers.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Another way to get chainability if we feel it's a thing that we should have would be this
callFunction(
Contexts.decorate(ctx).withLabels({ label: 'value' }).withRequestData(request).withBlah(a).get()
);
Alternatively even having the returned type of all of the with*
methods be Context & ContextDecorators
, then you could do
callFunction(
Contexts.withLabels(ctx, { label: 'value' }).withRequestData(request).withBlah(a)
);
Another pattern:
callFunction(
decorateContext(ctx).withLabels({ label: 'value' }).withRequestData(request).withBlah(a)
);
Or because I think chaining stuff end up being pretty meh in JS very often, tricky to test etc, perhaps just
callFunction(
Contexts.wrap(ctx, {
labels: { label: 'value' },
requestData: request,
blah: a,
})
);
Biggest difference here is that it is much easier to evolve an interface that is only consumed, rather than one that is both produced and consumed by user code
I have to say I'm not quite sure on the approach of adding explicit ctx as the first argument to downstream methods. There are few cases in the core packages that this will work because those will be added quickly and would be the default approach in there but when this starts to (slowly) propagate to other managers/services/stores/repositories/clients we will be seeing a lot of bumping happening. Let's say we want to add possible caching to a DB call -> possible semver major. Or if we want to add trace id to a debug log -> breaking change -> possible semver major. That being said, we are stuck with the ecosystem and without possibility to thread locals it gets very tricky. Using an unstable API as well might be too risky, especially if the side effect of that is an additional perf hit (albeit, I'm not sure if it would be significant?). Some spitballing on possible alternative approaches: I need to codify the idea a bit more to see if that would even be feasible but it could be something to think about.. |
60020e8
to
739b081
Compare
@Xantier Yep the Tbh, regarding thread local storage, I've never seen that work. Any time I've seen that used in application code it's been a mess and ended up backtracking and removing it. And that's in runtimes that can actually handle it pretty well. As you say it's also experimental in JS. Proxy object is a bit interesting, especially if we end up with some form of DI where we're already able to decorate implementations. Just like thread local storage I worry it's too much magic and hard to debug, but I think we could explore it a bit. Seems a bit tricky to implement though since JS doesn't have any runtime introspection of parameters other than |
Yeah, good points. This whole context thing is something that would be very worthwhile adding, good stuff doing the initial legwork on this @freben. I'm not certain if proxies will work at all since I haven't looked at them in anger around this area yet. The effort to take a closer look is probably good to go through though, maybe there could be an avenue (or partial avenue) worth taking. There are also concerns around performance on those as well that might come into play. I like the explicitness of just passing in another param, but fear the surface area that any change will need to touch when this is introduced and ctx param-drilling is put in place. On the other the magic other solutions might introduce will frustrate many developers when trying to figure out what is going on. I'm struggling to see any "good" solutions yet at least around this, so minimizing tradeoffs I guess is the name of the game. :/ |
Agreed, this feels like a "find the lesser of all evils" type of thing. It's an unfortunately tricky problem at a high level - introducing cross cutting, layer busting concepts is inevitably tricky, it seems to me.
|
Updated implementation to have the proposed API surface on the Still have not added anything like |
@Xantier Let me also highlight that most of the legwork actually comes from @Rugvip in #5312 - It was retracted later though because the timing didn't feel right and we didn't have an active use for it then. So this is a bit of an extension borrowing more from go and leaning on cancelation as a first class thing. Feel free to check out that context variant too, it's actually even leaner than this one! |
4525e01
to
1a57c06
Compare
I'm trying to understand what are the benefits of the example reported in the description. For example, given that all the clients support timeouts as options, the following snippet produce the same results of the one in the description, with less verbosity. Or am I missing something? /*
* packages/backend/src/index.ts
*/
function main() {
const ctx: Context = {
backstageToken: req.blablabla,
labels: { feature: 'songs' }
};
router.get('/songs', async (req, res) => {
const songs = await Promise.race([songStore.read(ctx), rejectAfter(2000)]);
res.json(songs);
});
}
/*
* plugins/song-storage/src/store.ts
*/
export class SongStore {
async read(ctx: Context) {
const user = ctx.backstageToken;
const labels = ctx.labels;
const cached = await this.cacheRead();
if (cached) {
this.logger.info('Cache hit for songs', labels);
return cached;
}
return this.serviceFetch()
.then(data => this.cacheWrite(data));
}
private async cacheRead() {
const value = await memcacheClient.read('songs', { timeout: 50 });
return value ? JSON.parse(value) : undefined;
}
private cacheWrite(data: Song[]) {
return memcacheClient.write('songs', JSON.stringify(data), { timeout: 50 });
}
private serviceFetch() {
return fetch('https://songs.example.net', {
timeout: 300,
}).then(r => r.json());
}
} |
@vinzscam the Try for example the following snippet: function after(ms) { return new Promise(r => setTimeout(r, ms)) }
await Promise.race([
after(2000).then(() => console.log('done 1') ?? 1),
after(1000).then(() => console.log('done 2') ?? 2),
]) It'll log This becomes especially important when we can start considering expensive database operations or upstream requests. If the user disappears, someone else replied faster than we could, or we hit the deadline, why continue doing the work? 😁 So looking at your example, let's imagine the await Promise.race([songStore.read(ctx), rejectAfter(2000)]) We'll of course have the promise rejected after the timeout, but the request will still keep going until we hit some form of timeout, network error, or get a reply. In the case of a late successful reply we'll also go on to |
I'll add that if what you mean is that we could just set timeouts appropriately, that ends up being a bit of a pain to maintain. You can imagine wanting to change the timeout we allow for a request in one place creating a huge cascade of changes. You'll really end up wanting some good way to communicate deadlines of your async work in that case, which is what the There's not only the timeout use-case to consider though, the race is actually an interesting use-case where we don't really have an explicit timeout. It might just be that whoever replies first wins and we want to cancel the other request(s). That kind of cancellation can't really be handled with timeouts, so you'll end up needing an abort signal or something similar |
sorry but the case you mentioned isn't a real example. There isn't such case in backstage at the moment and I doubt there would be anything similar in the future, where you want two promise run concurrently, letting race between each other... const controller = new AbortController();
await Promise.race([
something({ signal: controller.signal }),
somethingElse({ signal: controller.signal })
]); the await will block the execution there, so I don't think there is a need of having a common AbortController defined elsewhere. |
@vinzscam The timeout example was just to explain the behavior, the We can of course manually pass around |
You could certainly do that, and as a matter of fact that's effectively what It is a problem space that is hard to not explicitly solve for in some way. I've encountered several incident scenarios in other services where resources get starved because there is no explicit cancellation - so different pieces of ongoing work stack up infinitely when for example an upstream starts responding too slowly, giving a cascading effect. And we want graceful shutdown to work, etc etc - some form of common solution to these concerns will be needed. |
yeah but the example above is not covering that scenario: a just see random timeouts in the code where basically all the abortController are connected together without any specific reason since everything is still sequential. I would scope out this specific "problem". Regarding the "top to bottom" cancellation, where let's say an express' request connection is closed I totally agree and we have to act on that. My question is: is this the correct approach? Our goal is to FORCE the user thinking about this problem, and as I currently see everything is optional in the fat Context object. For example the following: function cacheRead(ctx: Context) { ... } could become: function cacheRead(signal: () => any) { ... } now the signal function is explicitly defined in the client, it would be harder to make mistakes and the user don't need to wasting time digging into documentation, learning about a new "Context" concept used only on this project. |
Honestly that example may not be awesomely constructed; let's not focus too much on it. I'll see if I can make a more thorough one.
Not really seeing how it's fat! It holds the signal that you talk of, plus the deadline that we (and the golang team) considered important to convey in some cases. That can even be passed on to upstream services where needed. The signal is the standard The signal is not optional; the deadline is, if you want to run entirely unbounded. Either way the optionality doesn't affect the typical consumer much since they pass on or read contexts, they don't construct them. So is the concern about the
So now consider that the inner code that you in turn call out to, and which is written by another plugin maker at another company, is interested in the deadline to deduce how to best proceed. Oh and also they want to consume the trace ID from the original request to send along. Not sure how this is less conducive to mistakes either. You generally pass on the parent context if you don't have anything that you want to add to it, which you usually don't. I think you may be glossing over some of the things that this intends to address. I mean yeah you could make every intermediate function function cacheRead(signal: AbortSignal, deadline: Date, requestState: Record<string, any>) { ... }
// or
function cacheRead(signal: AbortSignal, deadline: Date, requestState: Request) { ... } because you aren't sure if the lower layers will need it or not. |
This PR has been automatically marked as stale because it has not had recent activity from the author. It will be closed if no further activity occurs. If you are the author and the PR has been closed, feel free to re-open the PR and continue the contribution! |
This PR has been automatically marked as stale because it has not had recent activity from the author. It will be closed if no further activity occurs. If you are the author and the PR has been closed, feel free to re-open the PR and continue the contribution! |
1a57c06
to
5d97b69
Compare
export interface Context { | ||
readonly abortSignal: AbortSignal_2; | ||
readonly deadline: Date | undefined; | ||
use(...decorators: ContextDecorator[]): Context; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thoughts on dropping this and merging as experimental? 😁
Along with setAbort(src)
-> withAbort(ctx, src)
etc.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd vote to leave them in, mostly based on wanting to start to strongly encourage their use all over the place, with a low discoverability / code size cost at the use site. Feels like the responsible thing to do :D And so easy to pass through anyway that I don't mind.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Leave what in? I'm not suggesting removing anything else than use()
, it's just that we'd need to tweak the static methods.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah I thought you meant the two above it too, since I didn't see how the set
/with
thing might come into play otherwise. Hm yeah I could totally go for removing use
for now but, then what did you mean by the "Along with" thing? Should we add those setters on the Context
interface?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The current Contexts.setAbort(src)(ctx)
is pretty awkward without use()
, so I'm suggesting we make it Contexts.withAbort(ctx, src)
, and same for the rest of the decorator creators.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
aight, updated! should all be ready for a second look i think
Signed-off-by: Fredrik Adelöw <freben@gmail.com>
Signed-off-by: Fredrik Adelöw <freben@gmail.com>
Signed-off-by: Fredrik Adelöw <freben@gmail.com>
Signed-off-by: Fredrik Adelöw <freben@gmail.com>
Signed-off-by: Fredrik Adelöw <freben@gmail.com>
488dd27
to
90afc6b
Compare
Signed-off-by: Fredrik Adelöw <freben@gmail.com>
90afc6b
to
c35e52c
Compare
Hi, I thought I'd leave my feedback on this PR here with the caveat that I'm not an expert in dependency injection or the TS/JS ecosystem, but these are my thoughts:
|
Summary
Introduce a
Context
type and implementation class, that can be used for explicitly passing contextual information (request / caller context data, deadline / timeout / cancellation, etc) across boundaries in the backend.Problem Statement
There are cases where you may want to pass contextual information between the different layers of the backend. Some examples are:
It is possible to construct bespoke solutions for passing all of these types of information through the stack, and some might be partially covered by using global state. However, it easily leads to bloated and hand-wavy APIs that also need possibly breaking changes when new general such features are added or your needs change.
Proposed Solution
Create a
Context
interface that handles cancellation and a value map, closely modeled in shape and purpose after the golang Context. It has native facilities for abort and timeout, and a general key-value map upon which all other extended features can be built. Each context is immutable but new derived contexts can be made from them with updated cancellation rules, timeouts, or values.The intended usage of this type is that the service itself makes a single root Context that is passed around, by convention as a first
ctx: Context
parameter to functions and methods. Every function can either pass thectx
down unchanged to lower levels where needed, or instead make a modified version of it using its mutation methods.Example (with the label and token APIs just made up to illustrate the idea):
Risks and Drawbacks
Since the Context has to be passed explicitly into each layer, it quickly permeates a large number of interfaces. This risks becoming a tedious chore to type out in code and pass on. Also, if you initially do not expect to need it, you may have to make a breaking change to your interface when you or something that you depend on starts to consume it.
The Context class therefore also becomes a very central interface, depended upon by both core and plugin APIs. It risks becoming frozen in time and very hard to evolve. It is therefore imperative that it is kept to a bare minimum, using types that are expected to last.
The context is very general in that you can store essentially any information on it. It is important that this mechanism is not abused essentially as a general variable argument list to the function. The golang Context documentation has clear warnings along those lines, and ours should as well.
Alternatives
In Node, it is possible to use continuation-local storage, using the
async_hooks
feature. This storage spans across callbacks and promises etc, and would suit the context purpose. However, theasync_hooks
API has been given the lowest stability rating of 1 (Experimental) ever since Node 8. And even enabling it at all - let alone using it - has very significant negative impact on the performance of the Node VM. Async hook state also arguably is harder to debug and inspect, being a more "hidden" feature, compared to having an explicit Context object to look at.