Repository: archtechx/tenancy (language: php)

Issue:
Using RedisTenancyBoostrapper causes problems with queue worker
### Bug description

When calling `queue:restart`, it will cause all future `queue:work` commands to exit after only one job, if the job was dispatched in tenant context.

### Steps to reproduce

See this demo repository: https://github.com/pr4xx/tenancy-problem-demo

### Expected behavior

`queue:work` does not stop itself

### Laravel version

11.9

### stancl/tenancy version

3.8
Using RedisTenancyBoostrapper causes problems with queue worker
### Bug description

When calling `queue:restart`, it will cause all future `queue:work` commands to exit after only one job, if the job was dispatched in tenant context.

### Steps to reproduce

See this demo repository: https://github.com/pr4xx/tenancy-problem-demo

### Expected behavior

`queue:work` does not stop itself

### Laravel version

11.9

### stancl/tenancy version

3.8


Discussion / hints:
For context, we are currently looking into a way to solve this nicely without removing the optimizations in the queue bootstrapper. As a quick fix, you can either avoid using the Redis bootstrapper (and only use e.g. the cache bootstrapper) or you can use your own `QueueTenancyBootstrapper` with this line changed to `if (true) {`:
https://github.com/archtechx/tenancy/blob/8f9c7efa4584007f41048448d0a11f572a1d3239/src/Bootstrappers/QueueTenancyBootstrapper.php#L74
The more I think about this the more I feel it's best to just remove the optimization altogether, since optimizations that cause issues aren't good optimizations. The overhead of initializing tenancy is also very low compared to how long it takes the queue worker to pick up a job, so it might be worth just getting rid of this logic altogether. If we do that, the bootstrapper also becomes a lot simpler:

```diff
diff --git a/src/Bootstrappers/QueueTenancyBootstrapper.php b/src/Bootstrappers/QueueTenancyBootstrapper.php
index f747faea..5297509c 100644
--- a/src/Bootstrappers/QueueTenancyBootstrapper.php
+++ b/src/Bootstrappers/QueueTenancyBootstrapper.php
@@ -4,7 +4,6 @@ declare(strict_types=1);
 
 namespace Stancl\Tenancy\Bootstrappers;
 
-use Illuminate\Support\Str;
 use Illuminate\Config\Repository;
 use Illuminate\Queue\QueueManager;
 use Stancl\Tenancy\Contracts\Tenant;
@@ -25,16 +24,6 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper
     /** @var QueueManager */
     protected $queue;
 
-    /**
-     * Don't persist the same tenant across multiple jobs even if they have the same tenant ID.
-     *
-     * This is useful when you're changing the tenant's state (e.g. properties in the `data` column) and want the next job to initialize tenancy again
-     * with the new data. Features like the Tenant Config are only executed when tenancy is initialized, so the re-initialization is needed in some cases.
-     *
-     * @var bool
-     */
-    public static $forceRefresh = false;
-
     /**
      * The normal constructor is only executed after tenancy is bootstrapped.
      * However, we're registering a hook to initialize tenancy. Therefore,
@@ -42,7 +31,7 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper
      */
     public static function __constructStatic(Application $app)
     {
-        static::setUpJobListener($app->make(Dispatcher::class), $app->runningUnitTests());
+        static::setUpJobListener($app->make(Dispatcher::class));
     }
 
     public function __construct(Repository $config, QueueManager $queue)
@@ -53,87 +42,30 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper
         $this->setUpPayloadGenerator();
     }
 
-    protected static function setUpJobListener($dispatcher, $runningTests)
+    protected static function setUpJobListener($dispatcher)
     {
-        $previousTenant = null;
+        $dispatcher->listen(JobProcessing::class, function ($event) {
+            $tenant = $event->job->payload()['tenant_id'] ?? null;
 
-        $dispatcher->listen(JobProcessing::class, function ($event) use (&$previousTenant) {
-            $previousTenant = tenant();
-
-            static::initializeTenancyForQueue($event->job->payload()['tenant_id'] ?? null);
+            if ($tenant) {
+                tenancy()->initialize($tenant);
+            }
         });
 
-        $dispatcher->listen(JobRetryRequested::class, function ($event) use (&$previousTenant) {
-            $previousTenant = tenant();
+        $dispatcher->listen(JobRetryRequested::class, function ($event) {
+            $tenant = $event->payload()['tenant_id'] ?? null;
 
-            static::initializeTenancyForQueue($event->payload()['tenant_id'] ?? null);
+            if ($tenant) {
+                tenancy()->initialize($tenant);
+            }
         });
 
-        // If we're running tests, we make sure to clean up after any artisan('queue:work') calls
-        $revertToPreviousState = function ($event) use (&$previousTenant, $runningTests) {
-            if ($runningTests) {
-                static::revertToPreviousState($event, $previousTenant);
-            }
+        $revertToCentralContext = function () {
+            tenancy()->end();
         };
 
-        $dispatcher->listen(JobProcessed::class, $revertToPreviousState); // artisan('queue:work') which succeeds
-        $dispatcher->listen(JobFailed::class, $revertToPreviousState); // artisan('queue:work') which fails
-    }
-
-    protected static function initializeTenancyForQueue($tenantId)
-    {
-        if (! $tenantId) {
-            // The job is not tenant-aware
-            if (tenancy()->initialized) {
-                // Tenancy was initialized, so we revert back to the central context
-                tenancy()->end();
-            }
-
-            return;
-        }
-
-        if (static::$forceRefresh) {
-            // Re-initialize tenancy between all jobs
-            if (tenancy()->initialized) {
-                tenancy()->end();
-            }
-
-            tenancy()->initialize(tenancy()->find($tenantId));
-
-            return;
-        }
-
-        if (tenancy()->initialized) {
-            // Tenancy is already initialized
-            if (tenant()->getTenantKey() === $tenantId) {
-                // It's initialized for the same tenant (e.g. dispatchNow was used, or the previous job also ran for this tenant)
-                return;
-            }
-        }
-
-        // Tenancy was either not initialized, or initialized for a different tenant.
-        // Therefore, we initialize it for the correct tenant.
-        tenancy()->initialize(tenancy()->find($tenantId));
-    }
-
-    protected static function revertToPreviousState($event, &$previousTenant)
-    {
-        $tenantId = $event->job->payload()['tenant_id'] ?? null;
-
-        // The job was not tenant-aware
-        if (! $tenantId) {
-            return;
-        }
-
-        // Revert back to the previous tenant
-        if (tenant() && $previousTenant && $previousTenant->isNot(tenant())) {
-            tenancy()->initialize($previousTenant);
-        }
-
-        // End tenancy
-        if (tenant() && (! $previousTenant)) {
-            tenancy()->end();
-        }
+        $dispatcher->listen(JobProcessed::class, $revertToCentralContext); // artisan('queue:work') which succeeds
+        $dispatcher->listen(JobFailed::class, $revertToCentralContext); // artisan('queue:work') which fails
     }
 
     protected function setUpPayloadGenerator()
```

Only issue is that removing `$forceRefresh` is a breaking change, so I should probably keep it even if it doesn't do anything.
@pr4xx If you could test if this version of the bootstrapper works for you:

```php
<?php

declare(strict_types=1);

namespace Stancl\Tenancy\Bootstrappers;

use Illuminate\Config\Repository;
use Illuminate\Queue\QueueManager;
use Stancl\Tenancy\Contracts\Tenant;
use Illuminate\Queue\Events\JobFailed;
use Illuminate\Queue\Events\JobProcessed;
use Illuminate\Queue\Events\JobProcessing;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Queue\Events\JobRetryRequested;
use Illuminate\Support\Testing\Fakes\QueueFake;
use Illuminate\Contracts\Foundation\Application;
use Stancl\Tenancy\Contracts\TenancyBootstrapper;

class QueueTenancyBootstrapper implements TenancyBootstrapper
{
    /** @var Repository */
    protected $config;

    /** @var QueueManager */
    protected $queue;

    /**
     * The normal constructor is only executed after tenancy is bootstrapped.
     * However, we're registering a hook to initialize tenancy. Therefore,
     * we need to register the hook at service provider execution time.
     */
    public static function __constructStatic(Application $app)
    {
        static::setUpJobListener($app->make(Dispatcher::class));
    }

    public function __construct(Repository $config, QueueManager $queue)
    {
        $this->config = $config;
        $this->queue = $queue;

        $this->setUpPayloadGenerator();
    }

    protected static function setUpJobListener($dispatcher)
    {
        $dispatcher->listen(JobProcessing::class, function ($event) {
            $tenant = $event->job->payload()['tenant_id'] ?? null;

            if ($tenant) {
                tenancy()->initialize($tenant);
            }
        });

        $dispatcher->listen(JobRetryRequested::class, function ($event) {
            $tenant = $event->payload()['tenant_id'] ?? null;

            if ($tenant) {
                tenancy()->initialize($tenant);
            }
        });

        $revertToCentralContext = function () {
            tenancy()->end();
        };

        $dispatcher->listen(JobProcessed::class, $revertToCentralContext); // artisan('queue:work') which succeeds
        $dispatcher->listen(JobFailed::class, $revertToCentralContext); // artisan('queue:work') which fails
    }

    protected function setUpPayloadGenerator()
    {
        $bootstrapper = &$this;

        if (! $this->queue instanceof QueueFake) {
            $this->queue->createPayloadUsing(function ($connection) use (&$bootstrapper) {
                return $bootstrapper->getPayload($connection);
            });
        }
    }

    public function bootstrap(Tenant $tenant)
    {
        //
    }

    public function revert()
    {
        //
    }

    public function getPayload(string $connection)
    {
        if (! tenancy()->initialized) {
            return [];
        }

        if ($this->config["queue.connections.$connection.central"]) {
            return [];
        }

        $id = tenant()->getTenantKey();

        return [
            'tenant_id' => $id,
        ];
    }
}
```
@stancl Hey there, thanks for the update!

I tried your code and have an issue: since the new code does not make use of a `$previousTenant` field, my short test failed when dispatching a job within tenant context and having `QUEUE_CONNECTION` set to `sync`. It reverts back to central context but my controller code is not done yet in this scenario. It tries to hit the tenant database (in controller, outside job which has completed) and fails because the provider called `tenancy()->end()`.

Might not be the best test case because I use redis as my queue connection in production but would be nice to have for local development. I guess the restoring of any previous tenant cannot be skipped?
Thanks for testing the code and letting me know about this. I see, so it seems like we want to do this?
- If queue connection is `sync`, we *do* want to revert to the previous tenant (but we can do so unconditionally, removing a lot of the original optimizations)
- If queue connection is not sync, we revert to the *central* context after each job
It seems like we should revert back to any previous state. I am not sure about that split between sync and not sync. Though I cannot think of any case outside of sync where it would matter tbh.
Is the overhead of storing any previous tenant, unaware of the queue driver, problematic? 
It's not, but then if we reverted to the previous tenant in a queue worker context we'd just get back to the original issue here - the queue worker remaining in the central context. This *could* be solved by keeping track of the original state properly, since it'd be central in the queue worker but tenant in your sync example, but that might be error prone so I'd prefer to simplify or remove the optimizations as much as possible.
We can try checking for `sync` and then not revert to central. Doing nothing would probably be enough I think?
> @pr4xx If you could test if this version of the bootstrapper works for you:
> 
> ```
> <?php
> 
> declare(strict_types=1);
> 
> namespace Stancl\Tenancy\Bootstrappers;
> 
> use Illuminate\Config\Repository;
> use Illuminate\Queue\QueueManager;
> use Stancl\Tenancy\Contracts\Tenant;
> use Illuminate\Queue\Events\JobFailed;
> use Illuminate\Queue\Events\JobProcessed;
> use Illuminate\Queue\Events\JobProcessing;
> use Illuminate\Contracts\Events\Dispatcher;
> use Illuminate\Queue\Events\JobRetryRequested;
> use Illuminate\Support\Testing\Fakes\QueueFake;
> use Illuminate\Contracts\Foundation\Application;
> use Stancl\Tenancy\Contracts\TenancyBootstrapper;
> 
> class QueueTenancyBootstrapper implements TenancyBootstrapper
> {
>     /** @var Repository */
>     protected $config;
> 
>     /** @var QueueManager */
>     protected $queue;
> 
>     /**
>      * The normal constructor is only executed after tenancy is bootstrapped.
>      * However, we're registering a hook to initialize tenancy. Therefore,
>      * we need to register the hook at service provider execution time.
>      */
>     public static function __constructStatic(Application $app)
>     {
>         static::setUpJobListener($app->make(Dispatcher::class));
>     }
> 
>     public function __construct(Repository $config, QueueManager $queue)
>     {
>         $this->config = $config;
>         $this->queue = $queue;
> 
>         $this->setUpPayloadGenerator();
>     }
> 
>     protected static function setUpJobListener($dispatcher)
>     {
>         $dispatcher->listen(JobProcessing::class, function ($event) {
>             $tenant = $event->job->payload()['tenant_id'] ?? null;
> 
>             if ($tenant) {
>                 tenancy()->initialize($tenant);
>             }
>         });
> 
>         $dispatcher->listen(JobRetryRequested::class, function ($event) {
>             $tenant = $event->payload()['tenant_id'] ?? null;
> 
>             if ($tenant) {
>                 tenancy()->initialize($tenant);
>             }
>         });
> 
>         $revertToCentralContext = function () {
>             tenancy()->end();
>         };
> 
>         $dispatcher->listen(JobProcessed::class, $revertToCentralContext); // artisan('queue:work') which succeeds
>         $dispatcher->listen(JobFailed::class, $revertToCentralContext); // artisan('queue:work') which fails
>     }
> 
>     protected function setUpPayloadGenerator()
>     {
>         $bootstrapper = &$this;
> 
>         if (! $this->queue instanceof QueueFake) {
>             $this->queue->createPayloadUsing(function ($connection) use (&$bootstrapper) {
>                 return $bootstrapper->getPayload($connection);
>             });
>         }
>     }
> 
>     public function bootstrap(Tenant $tenant)
>     {
>         //
>     }
> 
>     public function revert()
>     {
>         //
>     }
> 
>     public function getPayload(string $connection)
>     {
>         if (! tenancy()->initialized) {
>             return [];
>         }
> 
>         if ($this->config["queue.connections.$connection.central"]) {
>             return [];
>         }
> 
>         $id = tenant()->getTenantKey();
> 
>         return [
>             'tenant_id' => $id,
>         ];
>     }
> }
> ```

Just wanted to confirm, this had solved the issue in production using the redis tenancy bootstrapper and this version of the queue tenancy bootstrapper.
I guess the easiest way to make this work in both contexts is to keep the existing logic for reverting to to the previous context since it's well written and tested, and I'll just make the logic for reverting to the previous context execute every time, rendering forceRefresh deprecated and ignored.
For context, we are currently looking into a way to solve this nicely without removing the optimizations in the queue bootstrapper. As a quick fix, you can either avoid using the Redis bootstrapper (and only use e.g. the cache bootstrapper) or you can use your own `QueueTenancyBootstrapper` with this line changed to `if (true) {`:
https://github.com/archtechx/tenancy/blob/8f9c7efa4584007f41048448d0a11f572a1d3239/src/Bootstrappers/QueueTenancyBootstrapper.php#L74
The more I think about this the more I feel it's best to just remove the optimization altogether, since optimizations that cause issues aren't good optimizations. The overhead of initializing tenancy is also very low compared to how long it takes the queue worker to pick up a job, so it might be worth just getting rid of this logic altogether. If we do that, the bootstrapper also becomes a lot simpler:

```diff
diff --git a/src/Bootstrappers/QueueTenancyBootstrapper.php b/src/Bootstrappers/QueueTenancyBootstrapper.php
index f747faea..5297509c 100644
--- a/src/Bootstrappers/QueueTenancyBootstrapper.php
+++ b/src/Bootstrappers/QueueTenancyBootstrapper.php
@@ -4,7 +4,6 @@ declare(strict_types=1);
 
 namespace Stancl\Tenancy\Bootstrappers;
 
-use Illuminate\Support\Str;
 use Illuminate\Config\Repository;
 use Illuminate\Queue\QueueManager;
 use Stancl\Tenancy\Contracts\Tenant;
@@ -25,16 +24,6 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper
     /** @var QueueManager */
     protected $queue;
 
-    /**
-     * Don't persist the same tenant across multiple jobs even if they have the same tenant ID.
-     *
-     * This is useful when you're changing the tenant's state (e.g. properties in the `data` column) and want the next job to initialize tenancy again
-     * with the new data. Features like the Tenant Config are only executed when tenancy is initialized, so the re-initialization is needed in some cases.
-     *
-     * @var bool
-     */
-    public static $forceRefresh = false;
-
     /**
      * The normal constructor is only executed after tenancy is bootstrapped.
      * However, we're registering a hook to initialize tenancy. Therefore,
@@ -42,7 +31,7 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper
      */
     public static function __constructStatic(Application $app)
     {
-        static::setUpJobListener($app->make(Dispatcher::class), $app->runningUnitTests());
+        static::setUpJobListener($app->make(Dispatcher::class));
     }
 
     public function __construct(Repository $config, QueueManager $queue)
@@ -53,87 +42,30 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper
         $this->setUpPayloadGenerator();
     }
 
-    protected static function setUpJobListener($dispatcher, $runningTests)
+    protected static function setUpJobListener($dispatcher)
     {
-        $previousTenant = null;
+        $dispatcher->listen(JobProcessing::class, function ($event) {
+            $tenant = $event->job->payload()['tenant_id'] ?? null;
 
-        $dispatcher->listen(JobProcessing::class, function ($event) use (&$previousTenant) {
-            $previousTenant = tenant();
-
-            static::initializeTenancyForQueue($event->job->payload()['tenant_id'] ?? null);
+            if ($tenant) {
+                tenancy()->initialize($tenant);
+            }
         });
 
-        $dispatcher->listen(JobRetryRequested::class, function ($event) use (&$previousTenant) {
-            $previousTenant = tenant();
+        $dispatcher->listen(JobRetryRequested::class, function ($event) {
+            $tenant = $event->payload()['tenant_id'] ?? null;
 
-            static::initializeTenancyForQueue($event->payload()['tenant_id'] ?? null);
+            if ($tenant) {
+                tenancy()->initialize($tenant);
+            }
         });
 
-        // If we're running tests, we make sure to clean up after any artisan('queue:work') calls
-        $revertToPreviousState = function ($event) use (&$previousTenant, $runningTests) {
-            if ($runningTests) {
-                static::revertToPreviousState($event, $previousTenant);
-            }
+        $revertToCentralContext = function () {
+            tenancy()->end();
         };
 
-        $dispatcher->listen(JobProcessed::class, $revertToPreviousState); // artisan('queue:work') which succeeds
-        $dispatcher->listen(JobFailed::class, $revertToPreviousState); // artisan('queue:work') which fails
-    }
-
-    protected static function initializeTenancyForQueue($tenantId)
-    {
-        if (! $tenantId) {
-            // The job is not tenant-aware
-            if (tenancy()->initialized) {
-                // Tenancy was initialized, so we revert back to the central context
-                tenancy()->end();
-            }
-
-            return;
-        }
-
-        if (static::$forceRefresh) {
-            // Re-initialize tenancy between all jobs
-            if (tenancy()->initialized) {
-                tenancy()->end();
-            }
-
-            tenancy()->initialize(tenancy()->find($tenantId));
-
-            return;
-        }
-
-        if (tenancy()->initialized) {
-            // Tenancy is already initialized
-            if (tenant()->getTenantKey() === $tenantId) {
-                // It's initialized for the same tenant (e.g. dispatchNow was used, or the previous job also ran for this tenant)
-                return;
-            }
-        }
-
-        // Tenancy was either not initialized, or initialized for a different tenant.
-        // Therefore, we initialize it for the correct tenant.
-        tenancy()->initialize(tenancy()->find($tenantId));
-    }
-
-    protected static function revertToPreviousState($event, &$previousTenant)
-    {
-        $tenantId = $event->job->payload()['tenant_id'] ?? null;
-
-        // The job was not tenant-aware
-        if (! $tenantId) {
-            return;
-        }
-
-        // Revert back to the previous tenant
-        if (tenant() && $previousTenant && $previousTenant->isNot(tenant())) {
-            tenancy()->initialize($previousTenant);
-        }
-
-        // End tenancy
-        if (tenant() && (! $previousTenant)) {
-            tenancy()->end();
-        }
+        $dispatcher->listen(JobProcessed::class, $revertToCentralContext); // artisan('queue:work') which succeeds
+        $dispatcher->listen(JobFailed::class, $revertToCentralContext); // artisan('queue:work') which fails
     }
 
     protected function setUpPayloadGenerator()
```

Only issue is that removing `$forceRefresh` is a breaking change, so I should probably keep it even if it doesn't do anything.
@pr4xx If you could test if this version of the bootstrapper works for you:

```php
<?php

declare(strict_types=1);

namespace Stancl\Tenancy\Bootstrappers;

use Illuminate\Config\Repository;
use Illuminate\Queue\QueueManager;
use Stancl\Tenancy\Contracts\Tenant;
use Illuminate\Queue\Events\JobFailed;
use Illuminate\Queue\Events\JobProcessed;
use Illuminate\Queue\Events\JobProcessing;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Queue\Events\JobRetryRequested;
use Illuminate\Support\Testing\Fakes\QueueFake;
use Illuminate\Contracts\Foundation\Application;
use Stancl\Tenancy\Contracts\TenancyBootstrapper;

class QueueTenancyBootstrapper implements TenancyBootstrapper
{
    /** @var Repository */
    protected $config;

    /** @var QueueManager */
    protected $queue;

    /**
     * The normal constructor is only executed after tenancy is bootstrapped.
     * However, we're registering a hook to initialize tenancy. Therefore,
     * we need to register the hook at service provider execution time.
     */
    public static function __constructStatic(Application $app)
    {
        static::setUpJobListener($app->make(Dispatcher::class));
    }

    public function __construct(Repository $config, QueueManager $queue)
    {
        $this->config = $config;
        $this->queue = $queue;

        $this->setUpPayloadGenerator();
    }

    protected static function setUpJobListener($dispatcher)
    {
        $dispatcher->listen(JobProcessing::class, function ($event) {
            $tenant = $event->job->payload()['tenant_id'] ?? null;

            if ($tenant) {
                tenancy()->initialize($tenant);
            }
        });

        $dispatcher->listen(JobRetryRequested::class, function ($event) {
            $tenant = $event->payload()['tenant_id'] ?? null;

            if ($tenant) {
                tenancy()->initialize($tenant);
            }
        });

        $revertToCentralContext = function () {
            tenancy()->end();
        };

        $dispatcher->listen(JobProcessed::class, $revertToCentralContext); // artisan('queue:work') which succeeds
        $dispatcher->listen(JobFailed::class, $revertToCentralContext); // artisan('queue:work') which fails
    }

    protected function setUpPayloadGenerator()
    {
        $bootstrapper = &$this;

        if (! $this->queue instanceof QueueFake) {
            $this->queue->createPayloadUsing(function ($connection) use (&$bootstrapper) {
                return $bootstrapper->getPayload($connection);
            });
        }
    }

    public function bootstrap(Tenant $tenant)
    {
        //
    }

    public function revert()
    {
        //
    }

    public function getPayload(string $connection)
    {
        if (! tenancy()->initialized) {
            return [];
        }

        if ($this->config["queue.connections.$connection.central"]) {
            return [];
        }

        $id = tenant()->getTenantKey();

        return [
            'tenant_id' => $id,
        ];
    }
}
```
@stancl Hey there, thanks for the update!

I tried your code and have an issue: since the new code does not make use of a `$previousTenant` field, my short test failed when dispatching a job within tenant context and having `QUEUE_CONNECTION` set to `sync`. It reverts back to central context but my controller code is not done yet in this scenario. It tries to hit the tenant database (in controller, outside job which has completed) and fails because the provider called `tenancy()->end()`.

Might not be the best test case because I use redis as my queue connection in production but would be nice to have for local development. I guess the restoring of any previous tenant cannot be skipped?
Thanks for testing the code and letting me know about this. I see, so it seems like we want to do this?
- If queue connection is `sync`, we *do* want to revert to the previous tenant (but we can do so unconditionally, removing a lot of the original optimizations)
- If queue connection is not sync, we revert to the *central* context after each job
It seems like we should revert back to any previous state. I am not sure about that split between sync and not sync. Though I cannot think of any case outside of sync where it would matter tbh.
Is the overhead of storing any previous tenant, unaware of the queue driver, problematic? 
It's not, but then if we reverted to the previous tenant in a queue worker context we'd just get back to the original issue here - the queue worker remaining in the central context. This *could* be solved by keeping track of the original state properly, since it'd be central in the queue worker but tenant in your sync example, but that might be error prone so I'd prefer to simplify or remove the optimizations as much as possible.
We can try checking for `sync` and then not revert to central. Doing nothing would probably be enough I think?
> @pr4xx If you could test if this version of the bootstrapper works for you:
> 
> ```
> <?php
> 
> declare(strict_types=1);
> 
> namespace Stancl\Tenancy\Bootstrappers;
> 
> use Illuminate\Config\Repository;
> use Illuminate\Queue\QueueManager;
> use Stancl\Tenancy\Contracts\Tenant;
> use Illuminate\Queue\Events\JobFailed;
> use Illuminate\Queue\Events\JobProcessed;
> use Illuminate\Queue\Events\JobProcessing;
> use Illuminate\Contracts\Events\Dispatcher;
> use Illuminate\Queue\Events\JobRetryRequested;
> use Illuminate\Support\Testing\Fakes\QueueFake;
> use Illuminate\Contracts\Foundation\Application;
> use Stancl\Tenancy\Contracts\TenancyBootstrapper;
> 
> class QueueTenancyBootstrapper implements TenancyBootstrapper
> {
>     /** @var Repository */
>     protected $config;
> 
>     /** @var QueueManager */
>     protected $queue;
> 
>     /**
>      * The normal constructor is only executed after tenancy is bootstrapped.
>      * However, we're registering a hook to initialize tenancy. Therefore,
>      * we need to register the hook at service provider execution time.
>      */
>     public static function __constructStatic(Application $app)
>     {
>         static::setUpJobListener($app->make(Dispatcher::class));
>     }
> 
>     public function __construct(Repository $config, QueueManager $queue)
>     {
>         $this->config = $config;
>         $this->queue = $queue;
> 
>         $this->setUpPayloadGenerator();
>     }
> 
>     protected static function setUpJobListener($dispatcher)
>     {
>         $dispatcher->listen(JobProcessing::class, function ($event) {
>             $tenant = $event->job->payload()['tenant_id'] ?? null;
> 
>             if ($tenant) {
>                 tenancy()->initialize($tenant);
>             }
>         });
> 
>         $dispatcher->listen(JobRetryRequested::class, function ($event) {
>             $tenant = $event->payload()['tenant_id'] ?? null;
> 
>             if ($tenant) {
>                 tenancy()->initialize($tenant);
>             }
>         });
> 
>         $revertToCentralContext = function () {
>             tenancy()->end();
>         };
> 
>         $dispatcher->listen(JobProcessed::class, $revertToCentralContext); // artisan('queue:work') which succeeds
>         $dispatcher->listen(JobFailed::class, $revertToCentralContext); // artisan('queue:work') which fails
>     }
> 
>     protected function setUpPayloadGenerator()
>     {
>         $bootstrapper = &$this;
> 
>         if (! $this->queue instanceof QueueFake) {
>             $this->queue->createPayloadUsing(function ($connection) use (&$bootstrapper) {
>                 return $bootstrapper->getPayload($connection);
>             });
>         }
>     }
> 
>     public function bootstrap(Tenant $tenant)
>     {
>         //
>     }
> 
>     public function revert()
>     {
>         //
>     }
> 
>     public function getPayload(string $connection)
>     {
>         if (! tenancy()->initialized) {
>             return [];
>         }
> 
>         if ($this->config["queue.connections.$connection.central"]) {
>             return [];
>         }
> 
>         $id = tenant()->getTenantKey();
> 
>         return [
>             'tenant_id' => $id,
>         ];
>     }
> }
> ```

Just wanted to confirm, this had solved the issue in production using the redis tenancy bootstrapper and this version of the queue tenancy bootstrapper.
I guess the easiest way to make this work in both contexts is to keep the existing logic for reverting to to the previous context since it's well written and tested, and I'll just make the logic for reverting to the previous context execute every time, rendering forceRefresh deprecated and ignored.

Fix patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7caa8889c..b9e2ff554 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -26,8 +26,8 @@ jobs:
     steps:
     - uses: actions/checkout@v2
     - name: Start docker containers
-      run: PHP_VERSION=${{ matrix.php }} docker-compose up -d
+      run: PHP_VERSION=${{ matrix.php }} docker compose up -d
     - name: Install dependencies
-      run: docker-compose exec -T test composer require --no-interaction "laravel/framework:^${{ matrix.laravel }}.0"
+      run: docker compose exec -T test composer require --no-interaction "laravel/framework:^${{ matrix.laravel }}.0"
     - name: Run tests
       run: ./test
diff --git a/src/Bootstrappers/QueueTenancyBootstrapper.php b/src/Bootstrappers/QueueTenancyBootstrapper.php
index f747faea9..1cac1893d 100644
--- a/src/Bootstrappers/QueueTenancyBootstrapper.php
+++ b/src/Bootstrappers/QueueTenancyBootstrapper.php
@@ -31,6 +31,8 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper
      * This is useful when you're changing the tenant's state (e.g. properties in the `data` column) and want the next job to initialize tenancy again
      * with the new data. Features like the Tenant Config are only executed when tenancy is initialized, so the re-initialization is needed in some cases.
      *
+     * @deprecated This now has no effect, tenancy is always ended between queued jobs.
+     *
      * @var bool
      */
     public static $forceRefresh = false;
@@ -42,7 +44,7 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper
      */
     public static function __constructStatic(Application $app)
     {
-        static::setUpJobListener($app->make(Dispatcher::class), $app->runningUnitTests());
+        static::setUpJobListener($app->make(Dispatcher::class));
     }
 
     public function __construct(Repository $config, QueueManager $queue)
@@ -53,7 +55,7 @@ public function __construct(Repository $config, QueueManager $queue)
         $this->setUpPayloadGenerator();
     }
 
-    protected static function setUpJobListener($dispatcher, $runningTests)
+    protected static function setUpJobListener($dispatcher)
     {
         $previousTenant = null;
 
@@ -69,11 +71,8 @@ protected static function setUpJobListener($dispatcher, $runningTests)
             static::initializeTenancyForQueue($event->payload()['tenant_id'] ?? null);
         });
 
-        // If we're running tests, we make sure to clean up after any artisan('queue:work') calls
-        $revertToPreviousState = function ($event) use (&$previousTenant, $runningTests) {
-            if ($runningTests) {
-                static::revertToPreviousState($event, $previousTenant);
-            }
+        $revertToPreviousState = function ($event) use (&$previousTenant) {
+            static::revertToPreviousState($event, $previousTenant);
         };
 
         $dispatcher->listen(JobProcessed::class, $revertToPreviousState); // artisan('queue:work') which succeeds
@@ -83,36 +82,14 @@ protected static function setUpJobListener($dispatcher, $runningTests)
     protected static function initializeTenancyForQueue($tenantId)
     {
         if (! $tenantId) {
-            // The job is not tenant-aware
+            // The job is not tenant-aware, so we make sure tenancy isn't initialized.
             if (tenancy()->initialized) {
-                // Tenancy was initialized, so we revert back to the central context
                 tenancy()->end();
             }
 
             return;
         }
 
-        if (static::$forceRefresh) {
-            // Re-initialize tenancy between all jobs
-            if (tenancy()->initialized) {
-                tenancy()->end();
-            }
-
-            tenancy()->initialize(tenancy()->find($tenantId));
-
-            return;
-        }
-
-        if (tenancy()->initialized) {
-            // Tenancy is already initialized
-            if (tenant()->getTenantKey() === $tenantId) {
-                // It's initialized for the same tenant (e.g. dispatchNow was used, or the previous job also ran for this tenant)
-                return;
-            }
-        }
-
-        // Tenancy was either not initialized, or initialized for a different tenant.
-        // Therefore, we initialize it for the correct tenant.
         tenancy()->initialize(tenancy()->find($tenantId));
     }
 
@@ -120,18 +97,25 @@ protected static function revertToPreviousState($event, &$previousTenant)
     {
         $tenantId = $event->job->payload()['tenant_id'] ?? null;
 
-        // The job was not tenant-aware
         if (! $tenantId) {
+            // The job was not tenant-aware, so there's nothing to revert
+            return;
+        }
+
+        if (tenant() && $previousTenant && $previousTenant->is(tenant())) {
+            // dispatchNow() was used and the tenant in the job is the same as the previous tenant
             return;
         }
 
-        // Revert back to the previous tenant
         if (tenant() && $previousTenant && $previousTenant->isNot(tenant())) {
+            // Revert back to the previous tenant (since Tenancy v3.8.5 this should should *likely* not happen)
             tenancy()->initialize($previousTenant);
+            return;
         }
 
-        // End tenancy
         if (tenant() && (! $previousTenant)) {
+            // No previous tenant = previous context was central
+            // NOTE: Since Tenancy v3.8.5 this should *likely* not happen
             tenancy()->end();
         }
     }


Test patch:
diff --git a/test b/test
index e064bbfeb..57b7489c1 100755
--- a/test
+++ b/test
@@ -1,4 +1,4 @@
 #!/bin/bash
 
 cat vendor/laravel/framework/src/Illuminate/Foundation/Application.php | grep 'const VERSION'
-docker-compose exec -T test vendor/bin/phpunit "$@"
+docker compose exec -T test vendor/bin/phpunit "$@"