Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.31% covered (warning)
85.31%
122 / 143
45.45% covered (danger)
45.45%
5 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
ConcurringSessionService
85.31% covered (warning)
85.31%
122 / 143
45.45% covered (danger)
45.45%
5 / 11
32.85
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 pauseActiveDeliveryExecutionsForUser
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 pauseConcurrentSessions
65.38% covered (warning)
65.38%
17 / 26
0.00% covered (danger)
0.00%
0 / 1
9.03
 isConcurringSession
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 clearConcurringSession
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setConcurringSession
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 adjustTimers
97.83% covered (success)
97.83%
45 / 46
0.00% covered (danger)
0.00%
0 / 1
6
 resetDeliveryExecutionState
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
4.13
 isDeliveryExecutionStateResetEnabled
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 storeItemDuration
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
2
 getContextByDeliveryExecution
72.73% covered (warning)
72.73%
16 / 22
0.00% covered (danger)
0.00%
0 / 1
2.08
1<?php
2
3/**
4 * This program is free software; you can redistribute it and/or
5 * modify it under the terms of the GNU General Public License
6 * as published by the Free Software Foundation; under version 2
7 * of the License (non-upgradable).
8 *
9 * This program is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 * GNU General Public License for more details.
13 *
14 * You should have received a copy of the GNU General Public License
15 * along with this program; if not, write to the Free Software
16 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
17 *
18 * Copyright (c) 2023-2024 (original work) Open Assessment Technologies SA;
19 */
20
21declare(strict_types=1);
22
23namespace oat\taoQtiTest\model\Service;
24
25use common_Exception;
26use oat\tao\model\featureFlag\FeatureFlagCheckerInterface;
27use oat\taoDelivery\model\execution\DeliveryExecution;
28use oat\taoDelivery\model\execution\DeliveryExecutionInterface;
29use oat\taoDelivery\model\execution\DeliveryExecutionService;
30use oat\taoDelivery\model\execution\StateServiceInterface;
31use oat\taoDelivery\model\RuntimeService;
32use oat\taoQtiTest\models\container\QtiTestDeliveryContainer;
33use oat\taoQtiTest\models\runner\QtiRunnerService;
34use oat\taoQtiTest\models\runner\QtiRunnerServiceContext;
35use oat\taoQtiTest\models\runner\time\TimerAdjustmentServiceInterface;
36use oat\taoQtiTest\models\TestSessionService;
37use PHPSession;
38use Psr\Log\LoggerInterface;
39use qtism\common\datatypes\QtiDuration;
40use qtism\data\AssessmentItemRef;
41use Throwable;
42
43class ConcurringSessionService
44{
45    private const PAUSE_REASON_CONCURRENT_TEST = 'PAUSE_REASON_CONCURRENT_TEST';
46
47    /**
48     * @var string Controls whether a delivery execution state should be kept as is or reset each time it starts.
49     *             `false` – the state will be reset on each restart.
50     *             `true` – the state will be maintained upon a restart.
51     *
52     * phpcs:disable Generic.Files.LineLength
53     */
54    private const FEATURE_FLAG_MAINTAIN_RESTARTED_DELIVERY_EXECUTION_STATE = 'FEATURE_FLAG_MAINTAIN_RESTARTED_DELIVERY_EXECUTION_STATE';
55    private LoggerInterface $logger;
56    private QtiRunnerService $qtiRunnerService;
57    private RuntimeService $runtimeService;
58    private DeliveryExecutionService $deliveryExecutionService;
59    private FeatureFlagCheckerInterface $featureFlagChecker;
60    private StateServiceInterface $stateService;
61    private TestSessionService $testSessionService;
62    private TimerAdjustmentServiceInterface $timerAdjustmentService;
63    private ?PHPSession $currentSession;
64
65    public function __construct(
66        LoggerInterface $logger,
67        QtiRunnerService $qtiRunnerService,
68        RuntimeService $runtimeService,
69        DeliveryExecutionService $deliveryExecutionService,
70        FeatureFlagCheckerInterface $featureFlagChecker,
71        StateServiceInterface $stateService,
72        TestSessionService $testSessionService,
73        TimerAdjustmentServiceInterface $timerAdjustmentService,
74        PHPSession $currentSession = null
75    ) {
76        $this->logger = $logger;
77        $this->qtiRunnerService = $qtiRunnerService;
78        $this->runtimeService = $runtimeService;
79        $this->deliveryExecutionService = $deliveryExecutionService;
80        $this->featureFlagChecker = $featureFlagChecker;
81        $this->stateService = $stateService;
82        $this->testSessionService = $testSessionService;
83        $this->timerAdjustmentService = $timerAdjustmentService;
84        $this->currentSession = $currentSession ?? PHPSession::singleton();
85    }
86
87    public function pauseActiveDeliveryExecutionsForUser($activeExecution): void
88    {
89        if ($activeExecution instanceof DeliveryExecution) {
90            $this->pauseConcurrentSessions($activeExecution);
91
92            if ($activeExecution->getState()->getUri() === DeliveryExecution::STATE_PAUSED) {
93                $this->adjustTimers($activeExecution);
94            }
95
96            $this->clearConcurringSession($activeExecution);
97            $this->resetDeliveryExecutionState($activeExecution);
98        }
99    }
100
101    public function pauseConcurrentSessions(DeliveryExecution $activeExecution): void
102    {
103        if (!$this->featureFlagChecker->isEnabled('FEATURE_FLAG_PAUSE_CONCURRENT_SESSIONS')) {
104            return;
105        }
106
107        $userIdentifier = $activeExecution->getUserIdentifier();
108
109        if (empty($userIdentifier) || $userIdentifier === 'anonymous') {
110            return;
111        }
112
113        /** @var DeliveryExecutionInterface[] $userExecutions */
114        $userExecutions = $this->deliveryExecutionService->getDeliveryExecutionsByStatus(
115            $activeExecution->getUserIdentifier(),
116            DeliveryExecutionInterface::STATE_ACTIVE
117        );
118
119        foreach ($userExecutions as $execution) {
120            $executionId = $execution->getOriginalIdentifier();
121
122            if ($executionId !== $activeExecution->getOriginalIdentifier()) {
123                try {
124                    $this->setConcurringSession($executionId);
125
126                    $context = $this->getContextByDeliveryExecution($execution);
127
128                    $this->storeItemDuration($context, $executionId);
129                    $this->qtiRunnerService->endTimer($context);
130                    $this->qtiRunnerService->pause($context);
131                } catch (Throwable $e) {
132                    $this->logger->warning(
133                        sprintf(
134                            '%s: Unable to pause delivery execution %s: %s',
135                            self::class,
136                            $executionId,
137                            $e->getMessage()
138                        )
139                    );
140                }
141            }
142        }
143    }
144
145    public function isConcurringSession(DeliveryExecution $execution): bool
146    {
147        $key = "pauseReason-{$execution->getOriginalIdentifier()}";
148
149        return $this->currentSession->hasAttribute($key)
150            && $this->currentSession->getAttribute($key) === self::PAUSE_REASON_CONCURRENT_TEST;
151    }
152
153    public function clearConcurringSession(DeliveryExecution $execution): void
154    {
155        $this->currentSession->removeAttribute("pauseReason-{$execution->getOriginalIdentifier()}");
156    }
157
158    public function setConcurringSession(string $executionId): void
159    {
160        $this->currentSession->setAttribute(
161            "pauseReason-{$executionId}",
162            self::PAUSE_REASON_CONCURRENT_TEST
163        );
164    }
165
166    public function adjustTimers(DeliveryExecution $execution): void
167    {
168        $this->logger->debug(
169            sprintf(
170                'Adjusting timers on execution %s restart',
171                $execution->getIdentifier()
172            )
173        );
174
175        $testSession = $this->testSessionService->getTestSession($execution);
176
177        if ($testSession && $testSession->getCurrentAssessmentItemRef()) {
178            $duration = $testSession->getTimerDuration(
179                $testSession->getCurrentAssessmentItemRef()->getIdentifier(),
180                $testSession->getTimerTarget()
181            );
182
183            $this->logger->debug(
184                sprintf(
185                    'Timer duration on execution %s timer adjustment = %f',
186                    $execution->getIdentifier(),
187                    $duration->getSeconds(true)
188                )
189            );
190
191            $ids = array_unique([
192                $execution->getIdentifier(),
193                $execution->getOriginalIdentifier()
194            ]);
195
196            foreach ($ids as $executionId) {
197                $key = "itemDuration-{$executionId}";
198
199                if (!$this->currentSession->hasAttribute($key)) {
200                    continue;
201                }
202
203                $oldDuration = $this->currentSession->getAttribute($key);
204                $this->currentSession->removeAttribute($key);
205
206                $this->logger->debug(
207                    sprintf(
208                        'Timer duration on execution %s pause was %f',
209                        $execution->getIdentifier(),
210                        $oldDuration
211                    )
212                );
213
214                $delta = (int) ceil($duration->getSeconds(true) - $oldDuration);
215
216                if ($delta > 0) {
217                    $this->logger->debug(sprintf('Adjusting timers by %d s', $delta));
218
219                    $this->timerAdjustmentService->increase(
220                        $testSession,
221                        $delta,
222                        TimerAdjustmentServiceInterface::TYPE_TIME_ADJUSTMENT
223                    );
224
225                    $testSession->suspend();
226                    $this->testSessionService->persist($testSession);
227                }
228            }
229        }
230    }
231
232    private function resetDeliveryExecutionState(DeliveryExecution $activeExecution = null): void
233    {
234        if (
235            null === $activeExecution
236            || !$this->isDeliveryExecutionStateResetEnabled()
237            || $activeExecution->getState()->getUri() === DeliveryExecution::STATE_PAUSED
238        ) {
239            return;
240        }
241
242        $this->stateService->pause($activeExecution);
243    }
244
245    private function isDeliveryExecutionStateResetEnabled(): bool
246    {
247        return !$this->featureFlagChecker->isEnabled(
248            static::FEATURE_FLAG_MAINTAIN_RESTARTED_DELIVERY_EXECUTION_STATE
249        );
250    }
251
252    private function storeItemDuration(
253        QtiRunnerServiceContext $context,
254        string $executionId
255    ): void {
256        $testSession = $context->getTestSession();
257        $itemRef = $testSession->getCurrentAssessmentItemRef();
258
259        if ($itemRef instanceof AssessmentItemRef) {
260            /** @var QtiDuration $duration */
261            $duration = $context->getTestSession()->getTimerDuration(
262                $itemRef->getIdentifier(),
263                $testSession->getTimerTarget()
264            );
265
266            $this->logger->debug(
267                sprintf(
268                    'duration when execution %s was paused = %f',
269                    $executionId,
270                    $duration->getSeconds(true)
271                )
272            );
273
274            $this->currentSession->setAttribute(
275                "itemDuration-{$executionId}",
276                $duration->getSeconds(true)
277            );
278        }
279    }
280
281    private function getContextByDeliveryExecution(DeliveryExecutionInterface $execution): QtiRunnerServiceContext
282    {
283        $delivery = $execution->getDelivery();
284        $container = $this->runtimeService->getDeliveryContainer($delivery->getUri());
285
286        if (!$container instanceof QtiTestDeliveryContainer) {
287            throw new common_Exception(
288                sprintf(
289                    'Non QTI test container %s in qti test runner',
290                    get_class($container)
291                )
292            );
293        }
294
295        $sessionId = $execution->getIdentifier();
296        $testDefinitionUri = $container->getSourceTest($execution);
297        $testCompilation = sprintf(
298            '%s|%s',
299            $container->getPrivateDirId($execution),
300            $container->getPublicDirId($execution)
301        );
302
303        return $this->qtiRunnerService->getServiceContext(
304            $testDefinitionUri,
305            $testCompilation,
306            $sessionId,
307            $execution->getUserIdentifier()
308        );
309    }
310}