framework icon indicating copy to clipboard operation
framework copied to clipboard

[11.x] Introduce "Context"

Open timacdonald opened this issue 1 year ago • 31 comments
trafficstars

Context

Context allows you to track current and historic "context" (information about the world) throughout a single request / command and across logical boundaries, such as queued jobs.

Context is mostly useful for logging, both general application logging and exception logging. It does offer some non-logging specific features that are novel.

At first you will see simiarities with Log::shareContext, but hopefully by the time you have read this you will see is not that and opens the doors to some interesting possibilities.

Logging

The most basic usage of Context is for logging. You can capture context and it will appear as metadata in your individual log writes.

// In a middleware...

Context::add('hostname', gethostname());
Context::add('trace_id', (string) Str::uuid());

// In a controller...

Log::info('Retrieving commit messages for repository [{repository}].', [
    'repository' => $repo,
]);

Http::get('https://github.com/...');

The resulting log entry...

[2024-01-19 04:20:06] production.INFO: Retrieving commit messages for repository [laravel/framework]. {"repository":"laravel/framework"} {"hostname":"prod-web-1","trace_id":"a158c456-d277-4214-badd-0f4c8e84df79"}

You will note that the hostname and trace_id are not combined with the log's context for this specific log message. Context's data is appended as metadata to the log write instance by leaning on Monolog's "extra" data.

This separation creates a nice distinction between context for this specific log write, e.g., the GitHub repository that is being queried, vs the information about the world in which the log is being written, e.g., on the host prod-web-1. This distinction is not obvious with the Log::shareContext method.

Exception logging

Exception logging is also a great place for Context to help out. Error trackers generally allow you to configure "global" context and "instance" context.

Using Sentry as an example you can set global context with the following...

// In a service provider...

configureScope(fn ($scope) $scope->setContext('extra', Context::all()));

Context would then be added to all logs and exception reports and both of these still allow for "instance" context to be appended.

[!NOTE] I imagine exception trackers would integrate directly with Context to make this even more seamless and a better experience for developers.

Context across logical boundaries

This is where things get interesting.

Context bridges the request <=> queued job boundary. What does this mean? Imagine a request comes in and you set the following context...

// Request::url() === 'https://forge.laravel.com/servers'

Context::add('initiating_url', Request::url());

Then you dispatch a job to a background queue, such as Redis.

CalculateStats::dispatch();

When this job executes ON THE QUEUE it will retain all the Context it had during the request.

class CalculateStats
{
    public function handle()
    {
        Log::info('Hello from the queue.');
    }
}

The resulting log entry...

[2024-01-19 04:20:06] production.INFO: Hello from the queue.  {"initiating_url":"https://forge.laravel.com/servers"}

Notice that the initiating_url is written as "extra" context on the log. It would also be included in exception reports!

This means that you can add to Context during a request and it will be "remembered" when you are in a queued job, even if that queued job is executed on a completely different machine to where the request was handled.

The trace_id we added in our initial middleware example will now link all interactions together. The trace_id will be present in all execution "forks" and allow us to link execution across different logical boundaries of our application.

Other logging

Some apps may find it useful to append Context values to outgoing HTTP requests or other actions for logging and tracing across systems.

Context::add('trace_id', (string) Str::uuid());

// ...

Http::globalOptions([
    'headers' => [
        'X-Trace-Id' => Context::get('trace_id'),
    ],
]);

Chained jobs

Chained jobs can be see as a single logical execution flow.

Bus::chain([
    new MyJobOne,
    new MyJobTwo,
    new MyJobThree,
]);

If MyJobOne adds context it becomes available in MyJobTwo. If MyJobTwo adds additional context both the context from MyJobOne and MyJobTwo become available in MyJobThree.

Batched Jobs

These are really independent execution flows that run in their own "fork". This means that context added in one job is not shared with the other jobs in the batch.

[!Warning] The context available in the then and finally callbacks of a batched job will be the Context from the last executed job. I feel that we should document, similar to what we already do with referencing $this.

Screenshot 2024-01-30 at 10 33 16 am

Intercepting Hydration

Whenever a job is dispatched to the queue we "dehydrate" the current context and send it along in the job's payload. This means that the Context is "rehydrated" on the queue.

This allows for some additional functionality that can be useful.

Imagine you want to track what type of environment your logs are created in. We can use Context for that!

// In a middleware...

Context::add('running_via', 'http');

Now all our logs and exceptions will indicate that they occurred during a HTTP interaction. That is no longer true if we dispatch a job, though, and as we have seen above, the context is remembered in dispatched jobs.

Context exposes a hydrated hook that allows you to modify the context just after it has been hydrated on the queue.

// In a service provider...

Context::hydrated(function () {
    Context::add('running_via', 'cli');
});

Now all our logs and exceptions will correctly indicate which environment they occurred in. This could also be useful for the hostname.

// In a service provider...

Context::hydrated(function () {
    Context::add('hostname', gethostname());
    Context::add('running_via', 'cli');
});

In this example we wouldn't override the trace_id, as we want to inherit that from the request without changing it. We may want to add an invocation_id that does change between logical boundaries - but that is up to the app.

Hidden Context

All the context we have seen so far is automatically added to logs and expose via Context::all and handful of other methods, however Context also supports "hidden" data.

Hidden data is passed across boundaries but is not automatically exposed to logs, etc. Instead, hidden data needs to be explicitly requested.

This allows us to pass context around and control how it is used. We will see how this could be used in the next section.

~Automatically restoring the authenticated user on the queue~

[!IMPORTANT]
I've removed this feature to make sure it isn't a blocker for Context as a whole. Context still enables this functionality in applications, it just does not ship with it as a first party feature.

~In a request we can access the current user with the Auth::user method.~

~On the queue, we cannot.~

~Although this makes sense, the user was authenticated when the job was dispatched so why isn't the user authenticated when the job is executed. If we had threading in PHP this would likely be the case, however because of an implementation detail we don't have an authenticated user.~

~Until now.~

// In a service provider...

Context::rememberAuthUser();

~This will now remember and restore the authenticated user for queued jobs. The user is lazily loaded via a custom guard for Context. This guard is only applied on the queue and is stateless.~

Auth::login($user);

// Auth::user() == $user;

CalculateStats::dispatch();
class CalculateStats
{
    public function handle()
    {
        // Auth::user() == $user;

        $username = Auth::user()->name;
    }
}

~This data is added as a "hidden" and not automatically written to logs.~

~This is a first-party support affordance. I could see this pattern being used for multi-tenant applications where the current tenant is also hydrated on the queue.~

~Now, I'm not saying you should, I'm just saying that you COULD also restore the entire request object on the queue if you really wanted. This would allow you to Request::input('email'); to access the request's "email" input value.~

~Again, not saying you should. I'm just saying you could.~

@patrickomeara mentioned another excellent usecase of this functionality, which would be restoring the user's locale on the queue. This means that during the request the correct locale would be used and then it would be restored on the queue so that emails, notifications, etc., sent on the queue also use the correct locale for the user.

We could build a first-party affordance for this. There are a few ways to achieve it manually. Here is one version:

// In a service provider.

Context::dehydrating(function () {
    Context::addHidden('locale', Config::get('app.locale'));
});

Context::hydrated(function () {
    if (Context::hasHidden('locale')) {
        Config::set('app.locale', Config::getHidden('locale'));
    }
});

Stacks

Context allows you to create stacks. These are useful for capturing historic data. Historical data stacks are sometimes referred to as "breadcrumbs", but are not their only use.

Perhaps you want to keep track of all the jobs that have been dispatched for the current "context".

Event::listen(function (JobQueued $event) {
    Context::push('queued_job_history', "Job queued: {$event->job->displayName()}");
});

// After a few jobs have been queued...

Context::get('queued_job_history');

// [
//     "Job queued: App\Jobs\MyFirstJob",
//     "Job queued: App\Jobs\MySecondJob",
//     "Job queued: App\Jobs\MyThirdJob",
// ]

Key => Value

Context has been written in a way to keep it stupid simple. It is key value pairs, like Redis.

There is no dot notation (although you could use it as a convention like Redis).

Future ideas

So far we have seen that Context is always passed down to child forks, never upwards, and forks cannot communicate between each other. Realtime context would break this boundary further by sharing context across threads and machines via a shared cache.

// In a service provider.

// Declare a "realtime" context entry.
Context::realtime('foo', ttl: Interval::hour());
// In a web request.

// This job calls `Context::add('foo', 'bar');` while executing on the queue.
MyQueuedJob::dispatch(); 

// Do some work...
sleep(1);

// This will include the `{"foo":"bar"}` context that was added on the queue.
Log::info('Hello world.');

This context would not leak out of the execution forks. There would be a "hidden" context_id added and used in the cache key convention.

I did build a version of this but stripped it out as I was losing focus on the goal of Context. I think it would be rad, though, if we are keen on Context.

timacdonald avatar Jan 18 '24 04:01 timacdonald

Thanks for submitting a PR!

Note that draft PR's are not reviewed. If you would like a review, please mark your pull request as ready for review in the GitHub user interface.

Pull requests that are abandoned in draft may be closed due to inactivity.

github-actions[bot] avatar Jan 18 '24 04:01 github-actions[bot]

Windows build is failing cause of permission issues. I’ll figure that out…maybe.

timacdonald avatar Jan 19 '24 06:01 timacdonald

"Context exposes a hydrated hook that allows you to modify the context just after it has been hydrated on the queue."

Could it be enhanced so that we could hook into the hydration from other source ?

I see this case

  • current execution context (root)
  • create batch of jobs
  • each job does an http API call. HTTP call response is storing (has access to) the root context (and will also store the jobId received from endpoint)

receiving webhooks from the remote system with the jobId Could I "hydrate" each "job context" ? Can the "job context" have access to the "root" context

Because after ALL jobs have completed successfully (not successfully sending all HTTP calls but succcesfully handled all webhooks calls requested via HTTP API call)

continue common code.

A little bit like the ->when() method on a job batch. But adding support for webhook calls.

woodspire avatar Jan 19 '24 19:01 woodspire

@woodspire I'm not sure I totally follow, sorry. Could you provide some code examples of what you are thinking?

timacdonald avatar Jan 19 '24 23:01 timacdonald

I'm so excited about this!

inxilpro avatar Jan 20 '24 00:01 inxilpro

Small contribution. How about if the Context could only be assigned to specific operations in a closure?

Context::share('foo', 'bar')->with(function () {
    TestJob::dispatch();
    TestEvent::dispatch();
});

The main benefit of something like this would be to create the contexts for isolated executions.

devajmeireles avatar Jan 20 '24 01:01 devajmeireles

@devajmeireles I built that and ripped it out to make Context as simple as possible. I'll consider bringing that back, but can you think of a specific real-world example of when you would want do that?

The API I landed on was:

Context::with([
    'foo' => 'bar',
    'another' => 'one',
], function () {
    // ...
});

timacdonald avatar Jan 20 '24 01:01 timacdonald

Great feature @timacdonald, traces are a key use case I can see for this in particular. In large, distributed applications it can be tricky and we're looking at rolling out AWS X-ray to help us track everything between requests, queues, jobs....

Do you think Laravel's SQS driver for example could have native support for passing a trace ID from context into the SQS calls made?

aran112000 avatar Jan 20 '24 07:01 aran112000

I'd personally would just ditch the auth integration and leave the user info data for middleware. I get the appeal but it in most cases it will end up horribly in the wrong hands where the code will start relying on global auth in application code instead of properly passing/receiving a user instance, ending in half code using the user provided to the job and the other half from the context, and they might not be the same. Even now I encounter more situations where the global auth is used in helpers, and then those helpers are used in a job, and they start crashing with less experienced devs, and the user mismatch would be a terrible "bug" to debug, potentially leaking information.

donnysim avatar Jan 20 '24 07:01 donnysim

This feature has made my week! Cannot wait to see what the community comes up with for it!

Is there a possibility of retrieving all current context, like:

Context::all(); // will return an array of all currently stored context data

This would help me personally, so maybe a little selfish 😂

JustSteveKing avatar Jan 20 '24 11:01 JustSteveKing

Ok, ignore that last one. I re-read everything and it's already there 🙃

JustSteveKing avatar Jan 20 '24 11:01 JustSteveKing

@timacdonald

@woodspire I'm not sure I totally follow, sorry. Could you provide some code examples of what you are thinking?

I will try to answer here. But first, I want to make sure I understand what you wrote about "Batched jobs"

"These are really independent execution flows that run in their own "fork". This means that context added in one job is not shared with the other jobs in the batch."

My understanding is that each batched job would be able to access the "global" context of the creator of the batch jobs (their parent) but if each job decide to add/modify/delete context data, it will not be reflected in either of their siblings or the creator "when" method. Am I correct on this ? How about the creator "processing" method ? Which context will be available there ? The creator context or the specific job context (which could have been modified) ?

A batch job is "related" to a "parent" context. We could have decided to do each job of the batch job inside the "batch job creator", but it would have impacted the execution time/memory of that creator. So we are creating a batch job to "offload" processing. And if we need the result of the batch jobs to continue our execution, we use the batch job "processing/then" method to support these cases.

That why you implemented a queue hydrator to be able to rebuild the context in each job of the batch (so they can be ran "as if" they were in the creator context).

To answer your question by better explaining my use case:

I have a example where I would like to be able to run code "in the same context" but that code might not be running inside "a queue".

First, some explaining. I have a batch of jobs. Each of these job call an external API asking for data. In a perfect world, I would get my data right away in the external API response and process the data inside each job.

But the external API execute time is limited (running on AWS lambda); sometimes I would get my data back, but usually it wouldn't because the execution of the external API would reach the timeout limit of lambda code.

I solved this problem by adding webhooks support to my app. Instead of calling the external API and expect the response to contain the "answer to my request data", the external API save my request and return my a unique identifier bound to my request. I store that unique identifier in my database with the current context (user, locale, etc...).

The external API can process my request in any why it sees fit (spawning job, chunking processing, I don't care). When the external API has build the full response, it calls me back via a webhook. The provided payload is: job-identifier, response-data.

My webhook handler (which is a job BTW but it could NOT be) fetch the saved API request context, apply it and process the received data.

As you can see, the whole execution flow is split not in 2 distinct places:

  • main code
  • batch job handler

but in 3 distinct places:

  • main code
  • batch job handler
  • webhook handler

I was wondering if, using this "context" enhancement to the Laravel code that you are proposing, I could get rid of my own "context saver" system.

I would need to be able to hydrate the context that existed in the "batch job handler" inside each "webhook handler".

You are explaining that for queues, the context hydration is built-in. Could I add the same functionality for ANY class, not just queues ? It could be something like adding a trait to my class to would allow the same context hydration functionality.

I hope my explanation was cleared this time.

Thanks for taking the time to read my question. I really appreciate it. And awesome work BTW.

woodspire avatar Jan 20 '24 13:01 woodspire

@timacdonald

Sorry, I re-read your code and I think I found my answer inside the ServiceProvider.

You are using the already existing job payload system to save/read-back the context.

I originally taught that your context system was stored|hydrating the data by itself. That's why I asked if it could be applied to something other than queues.

In my case, when it's not related to a job, I will need to store|hydrate the payload myself.

I could still use your Context class as my stored payload structure.

Do you think I correctly answered myself ?

woodspire avatar Jan 20 '24 13:01 woodspire

Secret gives the impression that encryption is involved, which doesn't seem to be the case. I think a better name would be hidden or invisible (MySQL 8 calls this invisible).

I think Illuminate\Config or Illuminate\Context would make more sense than Log or Foundation.

deleugpn avatar Jan 20 '24 13:01 deleugpn

Overall, I quite love the idea of this, but I think some implementation details are very important to get a safe API out. Lots of programming languages has the concept of a Worker Mode for ages and static variables cross execution boundaries and you still hear horrors about the Singleton pattern.

PHP doesn't suffer much from this due to it's clean-slate nature, but it does come with a major drawback, specially running a Background Job from an authenticated context.

I work with a Multitenant Multidatabase application and I have suffered a lot with background jobs needing to establish a database connection contextualized by the authenticated user that dispatched the job. I never managed to land on a nice implementation using createPayloadUsing because of the hidden nature of it. Something completely invisible to developers adds context to the payload and something else invisible (a listener) tries to extract data out of the payload that was created and establish a global connection. This implementation seems to suffer a bit from the same problem, but in my experience there is no other way out of it besides explicitly making the Authenticatable object part of every the Job constructor.

deleugpn avatar Jan 20 '24 14:01 deleugpn

@deleugpn In any app, especially multitenant, I usually have a sort of a execution Context class, short version:

final class Context
{
    /**
     * Data placeholder mainly for serialization or lazy loading.
     */
    private array $data = [];
    private ?User $user = null;
    private ?Company $company = null;

    // public function getUser(): ?User
    // public function setUser(?User $user): self
    // public function getCompany(): ?Company
    // public function setCompany(?Company $company): self

    public function __serialize(): array
    {
        return [
            'user' => $this->user?->getKey() ?: ($this->data['user'] ?? null),
            'company' => $this->company?->getKey() ?: ($this->data['company'] ?? null),
        ];
    }

    public function __unserialize(array $data): void
    {
        $this->data = $data;
    }
}

This is setup with http middlewares and is always used throughout the whole application, including http requests, queues etc., no global auth or anything is used, only the context. This is accompanied by an abstract job with a middleware, short version:

abstract class AbstractJob implements ShouldQueue
{
    public function middleware(): array
    {
        return [
            new ContextMiddleware(app()),
        ];
    }
}

and this is responsible for applying the context or whatever needed, e.g:

    public function handle(object $job, Closure $next): void
    {
        if (method_exists($job, 'getContext')) {
            $this->app->scoped(Context::class, static fn () => $job->getContext());
        }

        $next($job);
    }

From our experience this reduces the magic and makes it more explicit on what is happening and easier to control.

donnysim avatar Jan 20 '24 16:01 donnysim

I'd personally would just ditch the auth integration and leave the user info data for middleware. I get the appeal but it in most cases it will end up horribly in the wrong hands where the code will start relying on global auth in application code instead of properly passing/receiving a user instance, ending in half code using the user provided to the job and the other half from the context, and they might not be the same. Even now I encounter more situations where the global auth is used in helpers, and then those helpers are used in a job, and they start crashing with less experienced devs, and the user mismatch would be a terrible "bug" to debug, potentially leaking information.

I'm not sure this would really be an issue in practice because you have to specifically opt into it with Context::rememberAuthUser(); and I think its safe to generally assume anyone opting into this knows what they are doing.

x7ryan avatar Jan 21 '24 01:01 x7ryan

I'm not sure this would really be an issue in practice because you have to specifically opt into it with Context::rememberAuthUser(); and I think its safe to generally assume anyone opting into this knows what they are doing.

That logic mostly only works on small projects. The moment business requirements start shifting and there's tens of thousands lines of logic, the last thing will be to remember that code you wrote for http context and now converting to a queue needs changes because somewhere in the code execution sequence a culprit function is used. And it is something you could say happens now, but with this enabled the probability of catching it without potentially grave consequences is a lot bigger. For example take SMTP per tenant config, it works fine when used in a request, but when moved to a job you have no idea that it starts leaking the config to others because you have to reset it after every job. And this is not an easy issue to debug first hand because there's multiple workers, and seemingly only some leak depending on jobs and whether that tenant has SMTP enabled which gives false impressions about the root of the problem.

As things are now, using auth functions would just fail the queue job instead of leaking the user information. And I understand this is an opt in thing, but I still don't agree of adding something that should not be supported in jobs to begin with. Not every thing we can add, should be.

donnysim avatar Jan 21 '24 07:01 donnysim

I think it's widely understood that queued jobs don't have access to request state, session(), auth(), etc., and although auto-seeding this data via a Context sounds useful, it would add a lot of complexity and potentially unreliableness compared to passing it through as a payload to the queued worker, much like @donnysim suggestion. I like the idea behind it, but understand why Laravel doesnt do it. For example, would this extend to Artisan::queue() commands, so it would auto seed auth() and request() state? It feels quite magical and obscure, rather than simply passing on the data you need as a payload.

garygreen avatar Jan 21 '24 15:01 garygreen

@aran112000, I'm not intimately familiar with SQS, but if the trace ID can be set already in Laravel then the answer is: yes!

If not, could be a good PR for the framework. I'm not going to include it here as it would be out of scope.

@deleugpn, I've renamed secret to hidden. Good call. "hidden" ties in well with Eloquent's hidden attributes.

I also thought about Illuminate\Context...but I didn't want to be so bold as to introduce an entirely new namespace into Laravel. I also think because you really need to have a Laravel app to use this feature it probably does make sense in Foundation.

@garygreen, it does apply to Artisan::queue by design. Request, Session, Auth, or any other state, is not exposed to the queued job unless you have explicitly opt into using Context.

timacdonald avatar Jan 22 '24 06:01 timacdonald

The idea is nice. And I have implemented something similar in the past. The most important thing is definitely standardizing a way to have a context for many third-party packages: many different debugging, logging and exception solutions currently force you to provide context in their way. A generic context framework would completely solve it.

Does the current context application also work easily when you switch context? Take for example an artisan console that calculates some e.g. tenant-specific for many tenants by doing the calculating directly in artisan or offloading to the job queue? I am not seeing currently a way to have something like savepoints (a closure?) to say: Run this code and let it add as many context information as it wants. But at some point I want to reset it to the old savepoint (before executing a closure).

This is in my experience a very essential feature once you start working with tenant stuff. And some other edge cases when you only want to do extensive logging for some code parts but the logs are only relevant for that part and wouldn't make any sense when that code has been executed.


For the other part about remembering the session context etc. I am very torn. It can simply some code but it also makes everything much more complex. Until now, a job etc. was an isolated thing. Now it can have state that is not defined in its constructor but in the Context. And that context can be changed by some line somewhere in the code. So, the job will execute differently based on the Context - which as said - can be changed even by some 3rd party package. How about using new attributes like #[AuthFromContext] to indicate that a job/event/etc. will use the auth information and sign-in the user for the execution. And if no auth is present it will fail with an error - or you can specify the error handling mode as a param.

tpetry avatar Jan 22 '24 07:01 tpetry

My gripe with auth in context is that it's not something you can just jump in or out whenever you want. If you opt in at the beginning of development and year into you realize it was not a good decision, it will not be as trivial as just removing the line. With opting in you decided to rely on this logic that auth is accessible inside a queue etc. so a lot of the code already accounts for that and switching means having to rewrite all the related parts of the code. I would like to avoid juniors and inexperienced devs using a feature that should never exist in the first place as it skews the understanding of inner working even more between a request and a queue. Also the cron will never have an auth or a request or a session, and because you opted in to the context session for the jobs, half of your code cannot be reused in a cron. Please don't give us more papercuts.

donnysim avatar Jan 22 '24 07:01 donnysim

@tpetry, you can perform work and reset all context with Context::flush().

You may also forget specific keys by calling Context::forget([/* ... */) and Context::forgetHidden([/* ... */).

If you wanted to reset work after a closure, you are probably thinking about something like Context::with([/* ... */], fn () => /* ... */). I mentioned this here. I'll look at bringing that back tomorrow as it seems like it might be useful.

timacdonald avatar Jan 22 '24 07:01 timacdonald

@tpetry, you can perform work and reset all context with Context::flush().

You may also forget specific keys by calling Context::forget([/* ... */) and Context::forgetHidden([/* ... */).

If you wanted to reset work after a closure, you are probably thinking about something like Context::with([/* ... */], fn () => /* ... */). I mentioned this here. I'll look at bringing that back tomorrow as it seems like it might be useful.

Thats what I mean, using flush(), all() and forget() to reset the state is a lot of work that can be simplified with Context::with() 👍

But Context::with() requires that you know the new context information before running some code. In the past I had code that would dynamically add some logging information and using multiple closure for these cases would make the code very hard to read.

I've built something like Context::scope(closure), which had the concept that I could dynamically add and remove new stuff within the closure, but after the closure, everything was reset to the state before the closure's execution. You can easily implement some logging barriers that way to have very detailed logging in some parts and have it just vanish when no longer needed (e.g., because the API synchronization stuff is done).

tpetry avatar Jan 22 '24 08:01 tpetry

Context::scope - dig it. Will do this instead of Context::with. Then you can add to context within the scope as needed. Serves both purposes well.

It would inherit the current scope (you could flush in the closure if you wanted it cleared).


Context::add('foo', 'before');
Context::add('bar', 'before');

Context::scope(function () {
    Context::add('foo', 'scoped');

    Context::get('foo'); // 'scoped'
    Context::get('bar'); // 'before'
});

Context::get('foo'); // 'before'
Context::get('bar'); // 'before'

timacdonald avatar Jan 22 '24 08:01 timacdonald

You could also combine it with your with approach if an optional key-value array is allowed as 2nd parameter. So many scoped contexts could also be easily added by the scope creator.

Context::add('foo', 'before');
Context::add('bar', 'before');
Context::get('abc'); // null

Context::scope(function () {
    Context::get('abc'); // def
    Context::set('abc', 'xyz');
    Context::get('abc'); // xyz

    Context::add('foo', 'scoped');

    Context::get('foo'); // 'scoped'
    Context::get('bar'); // 'before'
}, ['abc' => 'def']);

Context::get('foo'); // 'before'
Context::get('bar'); // 'before'
Context::get('abc'); // null

tpetry avatar Jan 22 '24 08:01 tpetry

@timacdonald

Context::scope - dig it. Will do this instead of Context::with. Then you can add to context within the scope as needed. Serves both purposes well.

It would inherit the current scope (you could flush in the closure if you wanted it cleared).

Context::add('foo', 'before');
Context::add('bar', 'before');

Context::scope(function () {
    Context::add('foo', 'scoped');

    Context::get('foo'); // 'scoped'
    Context::get('bar'); // 'before'
});

Context::get('foo'); // 'before'
Context::get('bar'); // 'before'

I think flush() inside a scope should not do impact outside of the scope. flush() inside a scope should only reset the context state as it was at the beginning of the scope execution; meaning when I return back from the scope the original context is still there.

So flush() in the main code completely empty the context for that scope (So we are ALWAYS running inside a scope. We can do scope inside a scope). flush() inside a scope only bring-back|delete|reset the keys I added|modified|deleted inside the scope.

woodspire avatar Jan 22 '24 10:01 woodspire

Gonna sit on this for a bit to make sure I'm happy with everything.

Also considering adding a way to indicate that a job expects an authenticated user. This will be either an interface, middleware, or trait - yet to be decided - probably a trait with some default methods for error handling.

Without this flag the user will not be resolved or accessible via the auth system. The auth context will still be passed along so child jobs' can flag that they expect a user.

This is to make sure that the job fails early if it is expecting the authenticated user to exist but it no longer does, e.g., the User record has been deleted from the DB since the job has been dispatched. Similar to the way jobs currently flag how to handle missing eloquent records that were serialized with the job.

timacdonald avatar Jan 23 '24 21:01 timacdonald

I've removed the first-party support for restoring the auth user on the queue. Although I still think this would be a great first-party feature, it was becoming a lot to make work perfectly and I didn't want it to be a blocker for Context as a whole.

End-user applications can still implement the feature themselves and we can look at adding the auth feature at a later date.

I'm also not going to add the scope feature just yet. There are a few things to consider there around mutating objects and other things. Gonna get the main PR merged with what makes sense and we can enhance the feature in the future.

timacdonald avatar Jan 29 '24 05:01 timacdonald

public function handle(object $job, Closure $next): void
    {
        if (method_exists($job, 'getContext')) {
            $this->app->scoped(Context::class, static fn () => $job->getContext());
        }

        $next($job);
    }
Context::share('foo', 'bar')->with(function () {
    TestJob::dispatch();
    TestEvent::dispatch();
});

Maybe, context sharing could be even handled by the service container itself:

class AppServiceContainer
{
    public function register()
    {
        $context = new Context();
        $this->app->context(Class::class, $context);
        $this->app->context(AnotherClass::class, $context);
    }
}

Does the current context application also work easily when you switch context? Take for example an artisan console that calculates some e.g. tenant-specific for many tenants by doing the calculating directly in artisan or offloading to the job queue? I am not seeing currently a way to have something like savepoints (a closure?) to say: Run this code and let it add as many context information as it wants. But at some point I want to reset it to the old savepoint (before executing a closure).

I've built something like Context::scope(closure), which had the concept that I could dynamically add and remove new stuff within the closure, but after the closure, everything was reset to the state before the closure's execution. You can easily implement some logging barriers that way to have very detailed logging in some parts and have it just vanish when no longer needed (e.g., because the API synchronization stuff is done).

Maybe, this could also be achieved in combination with stacks.

graph TD;
    Request-->|handled by|Controller;
    Controller-->|calls|Service;
    Service-->|calls|AnotherService-->|return|Controller;
    Controller-->|calls|AnotherService;
    Controller----->|sends|Response;

While one can try to keep track of the context (nesting) levels/hierarchies/layers, this can become quite complex. Especially, when you have global "request-wide" context, "class-wide" context, "method-local" context, "parent" and "child" context. Some context values needs to be reset, while others shouldn't. Additionally, they can be made available "historically", as explained in the "stacks" section of the proposal.

shaedrich avatar Jan 30 '24 09:01 shaedrich