Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 76 additions & 28 deletions lib/Service/DemoShowcasesService.php
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,32 @@ public function installShowcase(
string $lang = 'nl',
bool $force = false,
): array {
$this->assertInstallable(showcaseId: $showcaseId);

// M8: acquire an exclusive advisory lock keyed on the showcase ID
// before the existence check to serialise concurrent installs.
// Two simultaneous admin requests both pass the existence check
// before either inserts without this guard.
$lockKey = 'launchpad-showcase-install-' . $showcaseId;
$this->acquireInstallLock(lockKey: $lockKey, showcaseId: $showcaseId);

try {
return $this->installLocked(showcaseId: $showcaseId, options: new ShowcaseInstallOptions(lang: $lang, force: $force));
} finally {
$this->releaseInstallLock(lockKey: $lockKey);
}
}//end installShowcase()

/**
* Refuse an unknown showcase or one whose archive is missing.
*
* @param string $showcaseId The showcase ID.
*
* @return void
*
* @throws ShowcaseNotFoundException When the showcase cannot be installed.
*/
private function assertInstallable(string $showcaseId): void {
$this->assertKnownShowcase(showcaseId: $showcaseId);

$zipPath = $this->getZipPath(showcaseId: $showcaseId);
Expand All @@ -299,12 +325,19 @@ public function installShowcase(
message: 'Showcase ZIP missing for ' . $showcaseId
);
}
}//end assertInstallable()

// M8: acquire an exclusive advisory lock keyed on the showcase ID
// before the existence check to serialise concurrent installs.
// Two simultaneous admin requests both pass the existence check
// before either inserts without this guard.
$lockKey = 'launchpad-showcase-install-' . $showcaseId;
/**
* Take the exclusive install lock of a showcase.
*
* @param string $lockKey The lock path.
* @param string $showcaseId The showcase ID (for the message).
*
* @return void
*
* @throws RuntimeException When another install holds the lock.
*/
private function acquireInstallLock(string $lockKey, string $showcaseId): void {
try {
$this->stores->lockingProvider->acquireLock(
path: $lockKey,
Expand All @@ -317,32 +350,47 @@ public function installShowcase(
previous: $e
);
}
}//end acquireInstallLock()

try {
$existingUuid = $this->getInstalledUuid(showcaseId: $showcaseId);
if ($existingUuid !== '' && $force === false) {
return [
'installedDashboardUuid' => $existingUuid,
'skippedWidgets' => [],
'alreadyInstalled' => true,
];
}
/**
* Release the exclusive install lock of a showcase.
*
* @param string $lockKey The lock path.
*
* @return void
*/
private function releaseInstallLock(string $lockKey): void {
$this->stores->lockingProvider->releaseLock(
path: $lockKey,
type: ILockingProvider::LOCK_EXCLUSIVE
);
}//end releaseInstallLock()

if ($existingUuid !== '' && $force === true) {
$this->uninstallShowcase(showcaseId: $showcaseId);
}
/**
* Install while the lock is held: reuse an existing install, or
* replace it when forced, else install fresh.
*
* @param string $showcaseId The showcase ID.
* @param ShowcaseInstallOptions $options The language and force flag.
*
* @return array{installedDashboardUuid:string, skippedWidgets:array<int, string>, alreadyInstalled:bool}
*/
private function installLocked(string $showcaseId, ShowcaseInstallOptions $options): array {
$existingUuid = $this->getInstalledUuid(showcaseId: $showcaseId);
if ($existingUuid !== '' && $options->force === false) {
return [
'installedDashboardUuid' => $existingUuid,
'skippedWidgets' => [],
'alreadyInstalled' => true,
];
}

return $this->doInstallShowcase(
showcaseId: $showcaseId,
lang: $lang
);
} finally {
$this->stores->lockingProvider->releaseLock(
path: $lockKey,
type: ILockingProvider::LOCK_EXCLUSIVE
);
}//end try
}//end installShowcase()
if ($existingUuid !== '') {
$this->uninstallShowcase(showcaseId: $showcaseId);
}

return $this->doInstallShowcase(showcaseId: $showcaseId, lang: $options->lang);
}//end installLocked()

/**
* Internal install logic (called while lock is held).
Expand Down
44 changes: 44 additions & 0 deletions lib/Service/ShowcaseInstallOptions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

/**
* ShowcaseInstallOptions
*
* The source language and the force flag of one showcase install
* (REQ-DEMO-007, REQ-DEMO-009).
*
* @category Service
* @package OCA\LaunchPad\Service
* @author Conduction b.v. <info@conduction.nl>
* @copyright 2026 Conduction b.v.
* @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
* @version GIT:auto
* @link https://conduction.nl
*
* SPDX-FileCopyrightText: 2026 Conduction B.V. <info@conduction.nl>
* SPDX-License-Identifier: EUPL-1.2
*
* @spec openspec/specs/demo-data-showcases/spec.md
*/

declare(strict_types=1);

namespace OCA\LaunchPad\Service;

/**
* The source language and the force flag of one showcase install.
*
* @spec openspec/specs/demo-data-showcases/spec.md
*/
final class ShowcaseInstallOptions {
/**
* Constructor.
*
* @param string $lang The source language (always `nl` in v1).
* @param bool $force Whether an existing install is replaced.
*/
public function __construct(
public readonly string $lang,
public readonly bool $force,
) {
}//end __construct()
}//end class
Loading