Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
3 changes: 2 additions & 1 deletion .github/workflows/push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ jobs:
- { name: "SummitSubmissionInvitationServiceTest", filter: "--filter SummitSubmissionInvitationServiceTest" }
- { name: "MemberServiceTest", filter: "--filter MemberServiceTest" }
- { name: "EntityModelUnitTests", filter: "tests/Unit/Entities/" }
- { name: "MigrationUnitTests", filter: "tests/Unit/Migrations/" }
- { name: "ModelUnitTests", filter: "tests/Unit/Models/" }
- { name: "AuditUnitTests", filter: "tests/Unit/Audit/" }
- { name: "AuditOtlpStrategyTest", filter: "--filter AuditOtlpStrategyTest" }
Expand All @@ -78,7 +79,7 @@ jobs:
- { name: "CacheOptimizations", filter: "--filter '(PresentationSpeakerCacheTest|ResourceServerContextTest)'" }
# Named by path because no job in this matrix runs the tests/ root, only its
# subdirectories - a file added there runs nowhere unless it is listed here.
- { name: "PresentationMediaUploads", filter: "tests/PresentationMediaUploadsTest.php tests/PresentationMediaUploadsVisibilityTest.php tests/PresentationSerializerCacheKeyTest.php tests/PresentationReopenModelTest.php tests/PresentationReopenApiTest.php tests/PresentationReopenAuthzTest.php tests/PresentationSubmissionReopenedEmailTest.php" }
- { name: "PresentationMediaUploads", filter: "tests/PresentationMediaUploadsTest.php tests/PresentationMediaUploadsVisibilityTest.php tests/PresentationSerializerCacheKeyTest.php tests/PresentationReopenModelTest.php tests/PresentationReopenApiTest.php tests/PresentationReopenAuthzTest.php tests/PresentationSubmissionReopenedEmailTest.php tests/CopyClonedMediaUploadFilesJobTest.php" }
- { name: "SpeakerSubmitterPublishedFilter", filter: "tests/ProcessSpeakersEmailRequestJobTest.php tests/ProcessSubmittersEmailRequestJobTest.php tests/SpeakerRepositoryTest.php tests/SubmitterRepositoryTest.php tests/SpeakerServiceOriginalFilterTest.php tests/SubmitterServiceOriginalFilterTest.php" }
- { name: "SpeakerBulkEmailChunkResume", filter: "tests/SpeakerServiceBulkSendChunkingTest.php tests/SpeakerServiceResumeSendEmailsTest.php tests/ProcessSpeakersEmailRequestJobResumeTest.php tests/ProcessSpeakersEmailRequestJobFailedHookTest.php" }
- { name: "AttendeeBulkEmailChunkResume", filter: "tests/AttendeeServiceBulkSendChunkingTest.php tests/AttendeeServiceResumeSendEmailsTest.php tests/ProcessAttendeesEmailRequestJobResumeTest.php tests/ProcessAttendeesEmailRequestJobFailedHookTest.php tests/SummitAttendeeAnnouncementEmailTest.php tests/DoctrineSummitAttendeeRepositoryTest.php tests/SummitExtraQuestionsMemoizationTest.php" }
Expand Down
81 changes: 81 additions & 0 deletions app/Jobs/CopyClonedMediaUploadFilesJob.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php namespace App\Jobs;
/**
* Copyright 2026 OpenStack Foundation
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
**/

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Log;
use services\model\ISummitService;

/**
* Copies a cloned media upload's files from the original's storage paths to the clone's.
* SummitService::cloneEvent copies the record only; the clone's path is built from the clone's
* presentation ID, where nothing exists until this job runs. If every try fails, failed()
* removes the clone's record so it never points at a missing file.
* See SummitService::copyClonedMediaUploadFiles and SummitService::removeUncopiedClonedMediaUpload.
* @package App\Jobs
*/
final class CopyClonedMediaUploadFilesJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable;

// the backoff adds up to more than ISummitService::MEDIA_UPLOAD_STORAGE_LOCK_LIFETIME, so a copy
// waiting on a storage lock (another copy, the upload processor, or one left by a killed run)
// is still retrying when that lock is released or expires
public $tries = 8;

public $backoff = [30, 60, 120, 300, 300, 300, 300];

// strictly below every queue connection's retry_after (1800, config/queue.php) and the
// database-fallback worker's --timeout, so a slow attempt is never re-served concurrently;
// ISummitService::MEDIA_UPLOAD_STORAGE_LOCK_LIFETIME stays above it
public $timeout = 1200;

public function __construct(
public readonly int $source_media_upload_id,
public readonly int $source_presentation_id,
public readonly int $target_media_upload_id,
public readonly int $target_presentation_id,
public readonly string $filename
) {}

public function handle(ISummitService $service): void
{
Log::debug(sprintf("CopyClonedMediaUploadFilesJob::handle source %s target %s", $this->source_media_upload_id, $this->target_media_upload_id));

$service->copyClonedMediaUploadFiles(
$this->source_media_upload_id, $this->source_presentation_id,
$this->target_media_upload_id, $this->target_presentation_id,
$this->filename
);
}

public function failed(\Throwable $exception): void
{
Log::error(sprintf(
"CopyClonedMediaUploadFilesJob::failed source %s target %s: %s",
$this->source_media_upload_id, $this->target_media_upload_id, $exception->getMessage()
));
Log::error($exception);

try {
App::make(ISummitService::class)->removeUncopiedClonedMediaUpload($this->target_media_upload_id, $this->target_presentation_id, $this->filename);
} catch (\Throwable $ex) {
Log::error(sprintf("CopyClonedMediaUploadFilesJob::failed could not remove clone media upload %s", $this->target_media_upload_id));
Log::error($ex);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,30 @@ public function getClassName(){
#[ORM\Column(name: 'LegacyPathFormat', type: 'boolean')]
private $legacy_path_format;

/**
* The "{summit}-{slug}/{presentation}-{slug}" folder segment of a modern-format upload,
* fixed when the row was backfilled so a later title or summit slug change cannot move it.
* @var string|null
*/
#[ORM\Column(name: 'FrozenPathSegment', type: 'string', length: 1024, nullable: true)]
private $frozen_path_segment;

public function getFrozenPathSegment(): ?string
{
return $this->frozen_path_segment;
}

public static function buildModernPathSegment(int $summitId, ?string $summitRawSlug, int $presentationId, ?string $presentationSlug): string
{
return sprintf(
'%s-%s/%s-%s',
$summitId,
filter_var($summitRawSlug, FILTER_SANITIZE_ENCODED),
$presentationId,
filter_var($presentationSlug, FILTER_SANITIZE_ENCODED)
);
}

/**
* @var SummitMediaUploadType
*/
Expand Down Expand Up @@ -141,13 +165,17 @@ public function getPath(string $storageType = IStorageTypesConstants::PublicType

$summit = $this->getPresentation()->getSummit();
$presentation = $this->getPresentation();
$format = $storageType == IStorageTypesConstants::PublicType ? '%s/%s/%s': '%s/'.IStorageTypesConstants::PrivateType.'/%s/%s';
$prefix = $storageType == IStorageTypesConstants::PublicType ?
$mountingFolder : sprintf('%s/%s', $mountingFolder, IStorageTypesConstants::PrivateType);

if($this->legacy_path_format)
return sprintf($format, $mountingFolder, $summit->getId(), $presentation->getId());
return sprintf('%s/%s/%s', $prefix, $summit->getId(), $presentation->getId());

if(!empty($this->frozen_path_segment))
return sprintf('%s/%s', $prefix, $this->frozen_path_segment);

$presentation->generateSlug();
return sprintf($format, $mountingFolder, sprintf("%s-%s", $summit->getId(), filter_var($summit->getRawSlug(), FILTER_SANITIZE_ENCODED)), sprintf("%s-%s", $presentation->getId(), filter_var($presentation->getSlug(), FILTER_SANITIZE_ENCODED)));
return sprintf('%s/%s', $prefix, self::buildModernPathSegment($summit->getId(), $summit->getRawSlug(), $presentation->getId(), $presentation->getSlug()));
}

public function __construct()
Expand Down
14 changes: 10 additions & 4 deletions app/Models/Foundation/Summit/Events/Presentations/Presentation.php
Original file line number Diff line number Diff line change
Expand Up @@ -1389,14 +1389,20 @@ public function setSlug(string $slug): void
'/^[\.\-_]+/' => '', // Remove all leading dots, dashes or underscores
];

public function generateSlug(): void
public static function slugify(string $title): string
{
if (empty($this->title)) return;
$this->slug = trim(Transliterator::utf8ToAscii($this->title));
$slug = trim(Transliterator::utf8ToAscii($title));

foreach (self::$default_replacements as $regex => $replace) {
$this->slug = preg_replace($regex, $replace, $this->slug);
$slug = preg_replace($regex, $replace, $slug);
}
return $slug;
}

public function generateSlug(): void
{
if (empty($this->title)) return;
$this->slug = self::slugify($this->title);
}


Expand Down
8 changes: 8 additions & 0 deletions app/Models/Foundation/Summit/PendingMediaUpload.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ class PendingMediaUpload extends SilverstripeBaseModel
const STATUS_PUBLIC_STORAGE_UPLOADED = 'PublicStorageUploaded';
const STATUS_PRIVATE_STORAGE_UPLOADED = 'PrivateStorageUploaded';

// statuses whose file is not fully written to storage yet
const IN_FLIGHT_STATUSES = [
self::STATUS_PENDING,
self::STATUS_PROCESSING,
self::STATUS_PUBLIC_STORAGE_UPLOADED,
self::STATUS_PRIVATE_STORAGE_UPLOADED,
];

/**
* @var int
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,20 @@ public function deleteCompletedOlderThan(int $days = 7, int $limit = 1000): int;
* @return int Number of rows deleted
*/
public function deleteByMediaUpload(PresentationMediaUpload $mediaUpload): int;

/**
* True if any row, in any status, references the given media upload. Every upload and
* re-upload through PresentationService creates one; a cloned media upload never has one.
* @param int $media_upload_id
* @return bool
*/
public function hasAnyForMediaUpload(int $media_upload_id): bool;

/**
* True if a row whose file is not fully written yet (Pending, Processing,
* PublicStorageUploaded, PrivateStorageUploaded) references the given media upload.
* @param int $media_upload_id
* @return bool
*/
public function hasInFlightForMediaUpload(int $media_upload_id): bool;
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,18 @@
**/


use models\summit\PresentationMediaUpload;
use models\utils\IBaseRepository;

interface IPresentationMediaUploadRepository extends IBaseRepository
{
/**
* Reads the row from the database even when the entity is already managed, so a row another
* process deleted or changed is seen as it is now. Null when the row is gone.
* @param int $id
* @param bool $exclusive_lock lock the row FOR UPDATE; needs an open transaction
* @return PresentationMediaUpload|null
*/
public function getByIdFresh(int $id, bool $exclusive_lock = false): ?PresentationMediaUpload;

}
38 changes: 32 additions & 6 deletions app/Repositories/Summit/DoctrinePendingMediaUploadRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -132,13 +132,39 @@ public function deleteByMediaUpload(PresentationMediaUpload $mediaUpload): int
"DELETE FROM models\\summit\\PendingMediaUpload p WHERE p.media_upload = :mediaUpload AND p.status IN (:statuses)"
);
$query->setParameter('mediaUpload', $mediaUpload->getId());
$query->setParameter('statuses', [
PendingMediaUpload::STATUS_PENDING,
PendingMediaUpload::STATUS_PROCESSING,
PendingMediaUpload::STATUS_PUBLIC_STORAGE_UPLOADED,
PendingMediaUpload::STATUS_PRIVATE_STORAGE_UPLOADED
]);
$query->setParameter('statuses', PendingMediaUpload::IN_FLIGHT_STATUSES);

return $query->execute();
}

/**
* @inheritDoc
*/
public function hasAnyForMediaUpload(int $media_upload_id): bool
{
$count = $this->getEntityManager()
->createQuery(
"SELECT COUNT(p.id) FROM models\\summit\\PendingMediaUpload p WHERE p.media_upload = :mediaUpload"
)
->setParameter('mediaUpload', $media_upload_id)
->getSingleScalarResult();

return intval($count) > 0;
}

/**
* @inheritDoc
*/
public function hasInFlightForMediaUpload(int $media_upload_id): bool
{
$count = $this->getEntityManager()
->createQuery(
"SELECT COUNT(p.id) FROM models\\summit\\PendingMediaUpload p WHERE p.media_upload = :mediaUpload AND p.status IN (:statuses)"
)
->setParameter('mediaUpload', $media_upload_id)
->setParameter('statuses', PendingMediaUpload::IN_FLIGHT_STATUSES)
->getSingleScalarResult();

return intval($count) > 0;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@

use App\Models\Foundation\Summit\Repositories\IPresentationMediaUploadRepository;
use App\Repositories\SilverStripeDoctrineRepository;
use Doctrine\DBAL\LockMode;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
use models\summit\PresentationMediaUpload;
use utils\Filter;
Expand Down Expand Up @@ -62,4 +64,18 @@ protected function applyExtraJoins(QueryBuilder $query, ?Filter $filter = null,
$query = $query->innerJoin("e.media_upload_type", "t");
return $query;
}

/**
* @inheritDoc
*/
public function getByIdFresh(int $id, bool $exclusive_lock = false): ?PresentationMediaUpload
{
// find() answers from the identity map without a query; a refreshing DQL read does not
$query = $this->getEntityManager()
->createQuery(sprintf('SELECT e FROM %s e WHERE e.id = :id', PresentationMediaUpload::class))
->setParameter('id', $id)
->setHint(Query::HINT_REFRESH, true);
if ($exclusive_lock) $query->setLockMode(LockMode::PESSIMISTIC_WRITE);
return $query->getOneOrNullResult();
}
}
44 changes: 44 additions & 0 deletions app/Services/Model/ISummitService.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,20 @@
*/
interface ISummitService
{
// lifetime of the media upload storage locks (SummitService::mediaUploadStorageLockName) a clone
// copy holds; above CopyClonedMediaUploadFilesJob::$timeout, so a killed copy's locks expire on
// their own, and below the job's total backoff, so a waiting copy outlasts them
// the key builder itself stays on SummitService, not here: it's static, and an interface
// cannot hold a static implementation
const MEDIA_UPLOAD_STORAGE_LOCK_LIFETIME = 1300;

// minutes after which processPendingMediaUploads treats a Processing row as left by a crashed run
const PENDING_MEDIA_UPLOAD_STUCK_MINUTES = 10;

// lifetime of the storage lock processPendingMediaUploads holds per row: a killed run's lock
// expires by the time its stuck row is reset, so the row is never reset yet still locked
const PENDING_MEDIA_UPLOAD_STORAGE_LOCK_LIFETIME = self::PENDING_MEDIA_UPLOAD_STUCK_MINUTES * 60;

/**
* @param Summit $summit
* @param array $data
Expand Down Expand Up @@ -557,6 +571,36 @@ public function regenerateTemporalUrlsForMediaUploads(int $summit_id):void;
*/
public function processPendingMediaUploads(int $max_retries = 3): array;

/**
* Copies a cloned media upload's files from the original's storage paths to the clone's, holding
* the storage locks of both locations (the ones processPendingMediaUploads takes), so neither
* side is written while it is read or copied. The presentation IDs come from the caller so the
* locks are taken before anything is read from the database. A clone that has been re-uploaded
* since is never written to. A leg whose source file is missing is skipped; a source missing
* on every enabled leg throws. If the clone's upload is deleted while the copy runs, the copied
* files are marked deleted.
*
* @param int $source_media_upload_id
* @param int $source_presentation_id
* @param int $target_media_upload_id
* @param int $target_presentation_id
* @param string $filename the clone's filename when the copy was queued
* @throws \App\Services\Utils\Exceptions\UnacquiredLockException when either storage location is locked
* @throws \RuntimeException when the source is gone, has a re-upload in flight, or has no file
*/
public function copyClonedMediaUploadFiles(int $source_media_upload_id, int $source_presentation_id, int $target_media_upload_id, int $target_presentation_id, string $filename): void;

/**
* Removes a cloned media upload whose files could not be copied, so the clone never points at a
* missing file. A clone that has been re-uploaded since is kept. Removal failures are logged
* for the operator, not thrown.
*
* @param int $target_media_upload_id
* @param int $target_presentation_id
* @param string $filename the clone's filename when the copy was queued
*/
public function removeUncopiedClonedMediaUpload(int $target_media_upload_id, int $target_presentation_id, string $filename): void;

/**
* @param Summit $summit
* @param UploadedFile $csv_file
Expand Down
Loading
Loading