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
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
72 changes: 72 additions & 0 deletions app/Jobs/CopyClonedMediaUploadFilesJob.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?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;

public $tries = 5;

public $backoff = [30, 60, 120, 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 $target_media_upload_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->target_media_upload_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->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;
}
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;
}
}
31 changes: 31 additions & 0 deletions app/Services/Model/ISummitService.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@
*/
interface ISummitService
{
// lifetime of the media upload storage lock (SummitService::mediaUploadStorageLockName); above
// CopyClonedMediaUploadFilesJob::$timeout, so a killed copy's lock expires on its own before
// anything waits on it long
// 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;

/**
* @param Summit $summit
* @param array $data
Expand Down Expand Up @@ -557,6 +564,30 @@ 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, under
* the same storage lock processPendingMediaUploads takes. 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.
*
* @param int $source_media_upload_id
* @param int $target_media_upload_id
* @param string $filename the clone's filename when the copy was queued
* @throws \App\Services\Utils\Exceptions\UnacquiredLockException when the 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 $target_media_upload_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 string $filename the clone's filename when the copy was queued
*/
public function removeUncopiedClonedMediaUpload(int $target_media_upload_id, string $filename): void;

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