Skip to content

Commit b1f3abe

Browse files
committed
feat(promo-codes): add SummitPromoCodeMemberReservation entity (data layer)
Schema + data layer for the per-member QuantityPerAccount counter that will fix the TOCTOU race smarcet flagged in PR #525 (and reproduced in PR #530). This commit is intentionally NOT wired into the order-reserve saga yet — the table sits unused until the follow-up commit that modifies PreProcessReservationTask. - Entity SummitPromoCodeMemberReservation (SilverstripeBaseModel) with unique (PromoCodeID, MemberID) and ManyToOne FKs cascading on delete. - ISummitPromoCodeMemberReservationRepository + Doctrine impl. Readers from the reservation path rely on the outer row lock already held on the parent promo code (getByValueExclusiveLock) for serialization, so the repo does not take its own PESSIMISTIC_WRITE. - Two migrations, split so CREATE TABLE commits before the backfill INSERT runs (Builder defers schema diff to end-of-migration, so INSERT-in-same-migration hits "table doesn't exist"): Version20260415191521 — create table via Builder/Table API. Version20260415191522 — backfill from existing committed tickets (ON DUPLICATE KEY UPDATE for idempotency). - RepositoriesProvider binding. - Verified: down/up round-trip on docker MySQL; php -l clean.
1 parent c62c0f7 commit b1f3abe

6 files changed

Lines changed: 360 additions & 0 deletions

File tree

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
<?php namespace models\summit;
2+
/**
3+
* Copyright 2026 OpenStack Foundation
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
**/
14+
15+
use Doctrine\ORM\Mapping as ORM;
16+
use InvalidArgumentException;
17+
use models\main\Member;
18+
use models\utils\SilverstripeBaseModel;
19+
20+
/**
21+
* Durable per-member usage counter for a single promo code.
22+
*
23+
* Written atomically inside the exclusive row lock held on the parent
24+
* SummitRegistrationPromoCode (via getByValueExclusiveLock) so that
25+
* concurrent order reservations serialize their QuantityPerAccount
26+
* check-and-increment.
27+
*
28+
* @package models\summit
29+
*/
30+
#[ORM\Table(name: 'SummitPromoCodeMemberReservation')]
31+
#[ORM\UniqueConstraint(name: 'UQ_PromoCode_Member', columns: ['PromoCodeID', 'MemberID'])]
32+
#[ORM\Entity(repositoryClass: \App\Repositories\Summit\DoctrineSummitPromoCodeMemberReservationRepository::class)]
33+
class SummitPromoCodeMemberReservation extends SilverstripeBaseModel
34+
{
35+
/**
36+
* @var int
37+
*/
38+
#[ORM\Column(name: 'QtyUsed', type: 'integer', nullable: false)]
39+
private $qty_used;
40+
41+
/**
42+
* @var SummitRegistrationPromoCode
43+
*/
44+
#[ORM\ManyToOne(targetEntity: \models\summit\SummitRegistrationPromoCode::class)]
45+
#[ORM\JoinColumn(name: 'PromoCodeID', referencedColumnName: 'ID', nullable: false, onDelete: 'CASCADE')]
46+
private $promo_code;
47+
48+
/**
49+
* @var Member
50+
*/
51+
#[ORM\ManyToOne(targetEntity: \models\main\Member::class)]
52+
#[ORM\JoinColumn(name: 'MemberID', referencedColumnName: 'ID', nullable: false, onDelete: 'CASCADE')]
53+
private $member;
54+
55+
public function __construct(SummitRegistrationPromoCode $promo_code, Member $member, int $qty_used = 0)
56+
{
57+
parent::__construct();
58+
if ($qty_used < 0) {
59+
throw new InvalidArgumentException('qty_used must be non-negative');
60+
}
61+
$this->promo_code = $promo_code;
62+
$this->member = $member;
63+
$this->qty_used = $qty_used;
64+
}
65+
66+
public function getQtyUsed(): int
67+
{
68+
return (int)$this->qty_used;
69+
}
70+
71+
public function getPromoCode(): SummitRegistrationPromoCode
72+
{
73+
return $this->promo_code;
74+
}
75+
76+
public function getMember(): Member
77+
{
78+
return $this->member;
79+
}
80+
81+
/**
82+
* Atomically raise qty_used by $by.
83+
*
84+
* @throws InvalidArgumentException when $by is negative.
85+
*/
86+
public function increment(int $by): void
87+
{
88+
if ($by < 0) {
89+
throw new InvalidArgumentException('increment amount must be non-negative');
90+
}
91+
$this->qty_used += $by;
92+
}
93+
94+
/**
95+
* Lower qty_used by $by, clamping at zero. Used by saga undo paths.
96+
*
97+
* @throws InvalidArgumentException when $by is negative.
98+
*/
99+
public function decrement(int $by): void
100+
{
101+
if ($by < 0) {
102+
throw new InvalidArgumentException('decrement amount must be non-negative');
103+
}
104+
$this->qty_used = max(0, $this->qty_used - $by);
105+
}
106+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php namespace models\summit;
2+
/**
3+
* Copyright 2026 OpenStack Foundation
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
**/
14+
15+
use models\main\Member;
16+
use models\utils\IBaseRepository;
17+
18+
/**
19+
* Interface ISummitPromoCodeMemberReservationRepository
20+
* @package models\summit
21+
*/
22+
interface ISummitPromoCodeMemberReservationRepository extends IBaseRepository
23+
{
24+
/**
25+
* Look up the per-member reservation row for a given promo code.
26+
*
27+
* Callers invoking this from the reservation path must already hold an
28+
* exclusive row lock on the parent SummitRegistrationPromoCode (via
29+
* ISummitRegistrationPromoCodeRepository::getByValueExclusiveLock). That
30+
* outer lock is what serializes concurrent access to this row — no
31+
* separate PESSIMISTIC_WRITE is taken here.
32+
*
33+
* @param SummitRegistrationPromoCode $code
34+
* @param Member $member
35+
* @return SummitPromoCodeMemberReservation|null
36+
*/
37+
public function getByPromoCodeAndMember(
38+
SummitRegistrationPromoCode $code,
39+
Member $member
40+
): ?SummitPromoCodeMemberReservation;
41+
}

app/Repositories/RepositoriesProvider.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@
133133
use models\summit\ISummitProposedScheduleEventRepository;
134134
use models\summit\ISummitProposedScheduleLockRepository;
135135
use models\summit\ISummitProposedScheduleRepository;
136+
use models\summit\ISummitPromoCodeMemberReservationRepository;
136137
use models\summit\ISummitRegistrationPromoCodeRepository;
137138
use models\summit\ISummitTicketTypeRepository;
138139
use models\summit\PaymentGatewayProfile;
@@ -176,6 +177,7 @@
176177
use models\summit\SummitRefundPolicyType;
177178
use models\summit\SummitRefundRequest;
178179
use models\summit\SummitRegistrationInvitation;
180+
use models\summit\SummitPromoCodeMemberReservation;
179181
use models\summit\SummitRegistrationPromoCode;
180182
use models\summit\SummitRoomReservation;
181183
use models\summit\SummitScheduleConfig;
@@ -424,6 +426,13 @@ function () {
424426
}
425427
);
426428

429+
App::singleton(
430+
ISummitPromoCodeMemberReservationRepository::class,
431+
function () {
432+
return EntityManager::getRepository(SummitPromoCodeMemberReservation::class);
433+
}
434+
);
435+
427436
App::singleton(
428437
ISpeakersSummitRegistrationPromoCodeRepository::class,
429438
function () {
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php namespace App\Repositories\Summit;
2+
/**
3+
* Copyright 2026 OpenStack Foundation
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
**/
14+
15+
use App\Repositories\SilverStripeDoctrineRepository;
16+
use models\main\Member;
17+
use models\summit\ISummitPromoCodeMemberReservationRepository;
18+
use models\summit\SummitPromoCodeMemberReservation;
19+
use models\summit\SummitRegistrationPromoCode;
20+
21+
/**
22+
* Class DoctrineSummitPromoCodeMemberReservationRepository
23+
* @package App\Repositories\Summit
24+
*/
25+
final class DoctrineSummitPromoCodeMemberReservationRepository
26+
extends SilverStripeDoctrineRepository
27+
implements ISummitPromoCodeMemberReservationRepository
28+
{
29+
protected function getBaseEntity()
30+
{
31+
return SummitPromoCodeMemberReservation::class;
32+
}
33+
34+
/**
35+
* @inheritDoc
36+
*/
37+
public function getByPromoCodeAndMember(
38+
SummitRegistrationPromoCode $code,
39+
Member $member
40+
): ?SummitPromoCodeMemberReservation
41+
{
42+
return $this->findOneBy([
43+
'promo_code' => $code,
44+
'member' => $member,
45+
]);
46+
}
47+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
<?php namespace Database\Migrations\Model;
2+
/**
3+
* Copyright 2026 OpenStack Foundation
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
**/
14+
15+
use Doctrine\Migrations\AbstractMigration;
16+
use Doctrine\DBAL\Schema\Schema as Schema;
17+
use LaravelDoctrine\Migrations\Schema\Builder;
18+
use LaravelDoctrine\Migrations\Schema\Table;
19+
20+
/**
21+
* Class Version20260415191521
22+
* @package Database\Migrations\Model
23+
*
24+
* Creates the SummitPromoCodeMemberReservation table used to atomically
25+
* track per-member QuantityPerAccount usage for domain-authorized promo
26+
* codes. Fixes the TOCTOU race documented in smarcet's PR #530. A
27+
* companion migration (Version20260415191522) backfills the table from
28+
* existing committed tickets — it runs after this one so the CREATE
29+
* TABLE has fully committed before the INSERT executes.
30+
*/
31+
final class Version20260415191521 extends AbstractMigration
32+
{
33+
private const TableName = 'SummitPromoCodeMemberReservation';
34+
35+
public function getDescription(): string
36+
{
37+
return 'Create SummitPromoCodeMemberReservation counter table.';
38+
}
39+
40+
public function up(Schema $schema): void
41+
{
42+
$builder = new Builder($schema);
43+
44+
if (!$builder->hasTable(self::TableName)) {
45+
$builder->create(self::TableName, function (Table $table) {
46+
$table->integer('ID', true, false);
47+
$table->primary('ID');
48+
49+
$table->timestamp('Created')->setNotnull(true);
50+
$table->timestamp('LastEdited')->setNotnull(true);
51+
52+
$table->integer('QtyUsed')->setNotnull(true)->setDefault(0);
53+
54+
$table->integer('PromoCodeID', false, false)->setNotnull(true);
55+
$table->index('PromoCodeID', 'PromoCodeID');
56+
$table->foreign(
57+
'SummitRegistrationPromoCode',
58+
'PromoCodeID',
59+
'ID',
60+
['onDelete' => 'CASCADE'],
61+
'FK_PromoCodeMemberReservation_PromoCode'
62+
);
63+
64+
$table->integer('MemberID', false, false)->setNotnull(true);
65+
$table->index('MemberID', 'MemberID');
66+
$table->foreign(
67+
'Member',
68+
'MemberID',
69+
'ID',
70+
['onDelete' => 'CASCADE'],
71+
'FK_PromoCodeMemberReservation_Member'
72+
);
73+
74+
$table->unique(['PromoCodeID', 'MemberID'], 'UQ_PromoCode_Member');
75+
});
76+
}
77+
}
78+
79+
public function down(Schema $schema): void
80+
{
81+
$builder = new Builder($schema);
82+
if ($builder->hasTable(self::TableName)) {
83+
$builder->dropIfExists(self::TableName);
84+
}
85+
}
86+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php namespace Database\Migrations\Model;
2+
/**
3+
* Copyright 2026 OpenStack Foundation
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
**/
14+
15+
use Doctrine\Migrations\AbstractMigration;
16+
use Doctrine\DBAL\Schema\Schema as Schema;
17+
use LaravelDoctrine\Migrations\Schema\Builder;
18+
19+
/**
20+
* Class Version20260415191522
21+
* @package Database\Migrations\Model
22+
*
23+
* Backfills SummitPromoCodeMemberReservation from existing committed tickets
24+
* so the per-member QuantityPerAccount counter starts consistent with
25+
* history. Must run AFTER Version20260415191521, which creates the table.
26+
*
27+
* Deployment note: orders in flight during the backfill window may be
28+
* miscounted by at most their own qty — prefer a quiet window.
29+
*/
30+
final class Version20260415191522 extends AbstractMigration
31+
{
32+
public function getDescription(): string
33+
{
34+
return 'Backfill SummitPromoCodeMemberReservation from existing committed tickets.';
35+
}
36+
37+
public function up(Schema $schema): void
38+
{
39+
$builder = new Builder($schema);
40+
if (!$builder->hasTable('SummitPromoCodeMemberReservation')) {
41+
// Upstream migration didn't run — skip rather than crash.
42+
return;
43+
}
44+
45+
$this->addSql(<<<SQL
46+
INSERT INTO SummitPromoCodeMemberReservation (PromoCodeID, MemberID, QtyUsed, Created, LastEdited)
47+
SELECT t.PromoCodeID,
48+
o.OwnerID,
49+
COUNT(*) AS qty,
50+
NOW(),
51+
NOW()
52+
FROM SummitAttendeeTicket t
53+
INNER JOIN SummitOrder o ON o.ID = t.OrderID
54+
WHERE t.PromoCodeID IS NOT NULL
55+
AND o.OwnerID IS NOT NULL
56+
AND o.Status IN ('Reserved', 'Paid', 'Confirmed')
57+
AND t.Status != 'Cancelled'
58+
GROUP BY t.PromoCodeID, o.OwnerID
59+
ON DUPLICATE KEY UPDATE QtyUsed = VALUES(QtyUsed), LastEdited = NOW()
60+
SQL
61+
);
62+
}
63+
64+
public function down(Schema $schema): void
65+
{
66+
$builder = new Builder($schema);
67+
if ($builder->hasTable('SummitPromoCodeMemberReservation')) {
68+
$this->addSql('TRUNCATE TABLE SummitPromoCodeMemberReservation');
69+
}
70+
}
71+
}

0 commit comments

Comments
 (0)