diff --git a/.env.example b/.env.example index af492e576..20415d125 100644 --- a/.env.example +++ b/.env.example @@ -288,3 +288,6 @@ PAYMENTS_SERVICE_OAUTH2_SCOPES=payment-profile/read L5_SWAGGER_OPEN_API_SPEC_VERSION=3.1.0 SCHEDULER_OUTPUT_TO_STDOUT=false + +# registration stats response cache lifetime in seconds (0 disables the cache) +CACHE_API_RESPONSE_REGISTRATION_STATS_LIFETIME=600 diff --git a/app/ModelSerializers/Summit/SummitRegistrationStatsSerializer.php b/app/ModelSerializers/Summit/SummitRegistrationStatsSerializer.php index 7f148b63b..2e6bb1e58 100644 --- a/app/ModelSerializers/Summit/SummitRegistrationStatsSerializer.php +++ b/app/ModelSerializers/Summit/SummitRegistrationStatsSerializer.php @@ -13,6 +13,8 @@ **/ use App\Http\Exceptions\HTTP403ForbiddenException; use App\Utils\FilterUtils; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Log; use libs\utils\JsonUtils; use models\summit\Summit; @@ -36,6 +38,45 @@ public function serialize($expand = null, array $fields = [], array $relations = { $summit = $this->object; if (!$summit instanceof Summit) return []; + + $ttl = intval(Config::get('cache_api_response.registration_stats_response_lifetime', 600)); + // the endpoints are admin only, so the key does not depend on the caller + if ($ttl <= 0) + return $this->buildValues($summit, $expand, $fields, $relations, $params); + + $filter = $params['filter'] ?? null; + $start_date = null; + $end_date = null; + if ($filter instanceof Filter) list($start_date, $end_date) = FilterUtils::parseDateRangeUTC($filter); + + $key = sprintf + ( + 'registration_stats_%s_%s_%s_%s', + $summit->getId(), + $start_date?->getTimestamp() ?? 'null', + $end_date?->getTimestamp() ?? 'null', + md5(json_encode([$expand, $fields, $relations])) + ); + + $values = Cache::get($key); + if (!is_array($values)) { + $values = $this->buildValues($summit, $expand, $fields, $relations, $params); + Cache::put($key, $values, $ttl); + } + return $values; + } + + /** + * @param Summit $summit + * @param null $expand + * @param array $fields + * @param array $relations + * @param array $params + * @return array + * @throws \Exception + */ + private function buildValues(Summit $summit, $expand, array $fields, array $relations, array $params): array + { $values = parent::serialize($expand, $fields, $relations, $params); $filter = $params['filter'] ?? null; diff --git a/app/Models/Foundation/Summit/SummitRegistrationStats.php b/app/Models/Foundation/Summit/SummitRegistrationStats.php index 37aad51aa..e0d423567 100644 --- a/app/Models/Foundation/Summit/SummitRegistrationStats.php +++ b/app/Models/Foundation/Summit/SummitRegistrationStats.php @@ -50,6 +50,12 @@ private static function getDefaultTimeZoneOffset(?DateTime $now = null){ return $defaultSSTimeZone->getOffset($now) / 3600; } + /** + * Known limitation: a single fixed offset is used for the whole summit, the one in effect on its begin date. + * Tickets bought under a different offset (i.e. across a DST change) can fall in the neighbouring + * hour/day bucket of the purchased tickets histogram. Kept as is on purpose, see ClickUp task 86bcjg0u6. + * @return float|int + */ private function getTimeZoneOffset(){ try { $tz = $this->getTimeZone(); @@ -105,8 +111,7 @@ public function getActiveTicketsCount(?DateTime $startDate = null, ?DateTime $e try { $sql = <<prepareRawSQL($count_sql, ['summit_id' => $this->id]); - $res = $stmt->executeQuery(); - $res = $res->fetchFirstColumn(); - - $total = count($res) > 0 ? intval($res[0]) : 0; - - $sql .= " LIMIT {$pagingInfo->getPerPage()} OFFSET {$pagingInfo->getOffset()} "; Log::debug(sprintf("Summit::getTicketsPurchasedGroupedBy sql %s", $sql)); + // the histogram is computed once; the total is the number of buckets and the page is sliced here $stmt = $this->prepareRawSQL($sql, ['summit_id' => $this->id]); $res = $stmt->executeQuery(); - $res = $res->fetchAllAssociative(); - $res = count($res) > 0 ? $res : []; + $buckets = $res->fetchAllAssociative(); + + $total = count($buckets); + $res = array_slice($buckets, $pagingInfo->getOffset(), $pagingInfo->getPerPage()); return new PagingResponse ( diff --git a/config/cache_api_response.php b/config/cache_api_response.php index cb728400e..6ded09216 100644 --- a/config/cache_api_response.php +++ b/config/cache_api_response.php @@ -18,6 +18,7 @@ 'get_event_feedback_response_lifetime' => env('CACHE_API_RESPONSE_GET_EVENT_FEEDBACK_LIFETIME', 600), 'get_published_event_response_lifetime' => env('CACHE_API_RESPONSE_GET_PUBLISHED_EVENT_LIFETIME', 600), 'get_summits_response_lifetime' => env('CACHE_API_RESPONSE_GET_SUMMITS_LIFETIME', 600), + 'registration_stats_response_lifetime' => env('CACHE_API_RESPONSE_REGISTRATION_STATS_LIFETIME', 600), 'file_url_lifetime' => env('CACHE_API_RESPONSE_FILE_URL_LIFETIME', 3600), 'event_has_access_lifetime' => env('CACHE_API_RESPONSE_EVENT_HAS_ACCESS_LIFETIME', 600), ]; \ No newline at end of file diff --git a/database/migrations/model/Version20261021120000.php b/database/migrations/model/Version20261021120000.php new file mode 100644 index 000000000..bc394817e --- /dev/null +++ b/database/migrations/model/Version20261021120000.php @@ -0,0 +1,36 @@ +addSql($sql); + } + + public function down(Schema $schema): void + { + } +} diff --git a/tests/Repositories/RegistrationStatsCacheTest.php b/tests/Repositories/RegistrationStatsCacheTest.php new file mode 100644 index 000000000..ba5cd1fa6 --- /dev/null +++ b/tests/Repositories/RegistrationStatsCacheTest.php @@ -0,0 +1,152 @@ +current_group = IGroup::TrackChairs; + parent::setUp(); + self::insertSummitTestData(); + self::InsertOrdersTestData(); + + $this->store = []; + $this->ttls = []; + // a stateful fake keeps the test off redis; clearing $store stands for the entry expiring + Cache::shouldReceive('put')->andReturnUsing(function ($key, $value, $ttl = null) { + $this->store[$key] = $value; + $this->ttls[] = $ttl; + return true; + }); + Cache::shouldReceive('get')->andReturnUsing(function ($key, $default = null) { + return $this->store[$key] ?? $default; + }); + } + + protected function tearDown(): void + { + Mockery::close(); + self::clearOrdersTestData(); + self::clearSummitTestData(); + parent::tearDown(); + } + + private function filter(?int $start = null, ?int $end = null): Filter + { + if (is_null($start)) return new Filter(); + $expression = ["start_date>=$start"]; + if (!is_null($end)) $expression[] = "end_date<=$end"; + return FilterParser::parse($expression, ['start_date' => ['>='], 'end_date' => ['<=']]); + } + + /** + * @return array [payload, number of queries the request ran] + */ + private function request(?Filter $filter = null): array + { + $serializer = SerializerRegistry::getInstance() + ->getSerializer(self::$summit, SerializerRegistry::SerializerType_Admin_Registration_Stats); + QueryTimingCollector::reset(); + $payload = $serializer->serialize(null, [], [], ['filter' => $filter ?? new Filter()]); + return [$payload, QueryTimingCollector::$count]; + } + + public function testSecondRequestInsideTheLifetimeDoesNotRunTheQueries(): void + { + Config::set(self::Lifetime, 600); + + [$first, $first_queries] = $this->request(); + [$second, $second_queries] = $this->request(); + + $this->assertGreaterThan(0, $first_queries, 'the first request must run the stats queries'); + $this->assertSame(0, $second_queries); + $this->assertSame($first, $second); + $this->assertSame([600], $this->ttls); + } + + public function testRequestAfterTheLifetimeRunsTheQueriesAgain(): void + { + Config::set(self::Lifetime, 600); + + $this->request(); + $this->store = []; // the entry expired + + [, $queries] = $this->request(); + + $this->assertGreaterThan(0, $queries); + $this->assertCount(2, $this->ttls); + } + + public function testZeroLifetimeDisablesTheCache(): void + { + Config::set(self::Lifetime, 0); + + [, $first_queries] = $this->request(); + [, $second_queries] = $this->request(); + + $this->assertGreaterThan(0, $first_queries); + $this->assertSame($first_queries, $second_queries); + $this->assertSame([], $this->ttls, 'nothing is stored when the cache is off'); + } + + public function testDifferentDateRangesDoNotShareAnEntry(): void + { + Config::set(self::Lifetime, 600); + $start = time() - 86400; + + [, $unfiltered] = $this->request(); + [, $filtered] = $this->request($this->filter($start, $start + 3600)); + [, $filtered_again] = $this->request($this->filter($start, $start + 3600)); + [, $other_range] = $this->request($this->filter($start, $start + 7200)); + + $this->assertGreaterThan(0, $unfiltered); + $this->assertGreaterThan(0, $filtered, 'a date range must not read the unfiltered entry'); + $this->assertSame(0, $filtered_again); + $this->assertGreaterThan(0, $other_range); + $this->assertCount(3, $this->store); + } +} diff --git a/tests/Repositories/RegistrationStatsPayloadTest.php b/tests/Repositories/RegistrationStatsPayloadTest.php new file mode 100644 index 000000000..12ea5de6a --- /dev/null +++ b/tests/Repositories/RegistrationStatsPayloadTest.php @@ -0,0 +1,189 @@ +current_group = IGroup::TrackChairs; + parent::setUp(); + self::insertSummitTestData(); + self::InsertOrdersTestData(); + + self::$summit->setBeginDate(new DateTime(self::SummitBeginDate, new DateTimeZone('UTC'))); + self::$em->flush(); + + foreach (self::$summit_orders as $order) { + $this->ticket_ids[] = $order->getTickets()->first()->getId(); + } + + // [0] inactive, [2] bought at 23:30 local on Jul 15, [3] bought 23:30 local on the day DST ends + DB::table('SummitAttendeeTicket')->where('ID', $this->ticket_ids[0])->update(['IsActive' => 0]); + DB::table('SummitAttendeeTicket')->whereIn('ID', array_slice($this->ticket_ids, 1)) + ->update(['TicketBoughtDate' => '2026-07-20 15:00:00']); + DB::table('SummitAttendeeTicket')->where('ID', $this->ticket_ids[2]) + ->update(['TicketBoughtDate' => '2026-07-16 04:30:00']); + DB::table('SummitAttendeeTicket')->where('ID', $this->ticket_ids[3]) + ->update(['TicketBoughtDate' => '2026-11-02 05:30:00']); + + $id = self::$summit->getId(); + self::$em->clear(); + $this->stats_summit = self::$em->find(Summit::class, $id); + } + + protected function tearDown(): void + { + self::clearOrdersTestData(); + self::clearSummitTestData(); + parent::tearDown(); + } + + private function legacyScalar(string $sql): int + { + $rows = DB::connection('model')->select($sql, ['summit_id' => $this->stats_summit->getId()]); + return intval(array_values((array)$rows[0])[0]); + } + + public function testTicketCountsMatchTheOrderJoin(): void + { + $join = 'FROM SummitAttendeeTicket t INNER JOIN SummitOrder o ON o.ID = t.OrderID WHERE o.SummitID = :summit_id'; + + $this->assertSame(9, $this->stats_summit->getActiveTicketsCount()); + $this->assertSame( + $this->legacyScalar("SELECT COUNT(t.ID) $join AND t.IsActive = 1 AND t.Status = 'Paid'"), + $this->stats_summit->getActiveTicketsCount() + ); + $this->assertSame(1, $this->stats_summit->getInactiveTicketsCount()); + $this->assertSame( + $this->legacyScalar("SELECT COUNT(t.ID) $join AND t.IsActive = 0 AND t.Status = 'Paid'"), + $this->stats_summit->getInactiveTicketsCount() + ); + $this->assertSame( + $this->legacyScalar( + "SELECT COUNT(t.ID) FROM SummitAttendeeTicket t INNER JOIN SummitOrder o ON o.ID = t.OrderID + INNER JOIN SummitAttendee a ON a.ID = t.OwnerID + WHERE o.SummitID = :summit_id AND t.IsActive = 1 AND t.Status = 'Paid'" + ), + $this->stats_summit->getActiveAssignedTicketsCount() + ); + } + + public function testTicketsPerTypeMatchTheOrderJoin(): void + { + $legacy = DB::connection('model')->select( + "SELECT tt.Name AS type, COUNT(t.ID) AS qty FROM SummitAttendeeTicket t + INNER JOIN SummitOrder o ON o.ID = t.OrderID + INNER JOIN SummitTicketType tt ON t.TicketTypeID = tt.ID + WHERE o.SummitID = :summit_id AND t.IsActive = 1 AND t.Status = 'Paid' GROUP BY tt.Name", + ['summit_id' => $this->stats_summit->getId()] + ); + $legacy = array_map(fn($r) => ['type' => $r->type, 'qty' => intval($r->qty)], $legacy); + $current = array_map( + fn($r) => ['type' => $r['type'], 'qty' => intval($r['qty'])], + $this->stats_summit->getActiveTicketsCountPerTicketType() + ); + + $this->assertSame([['type' => 'TICKET TYPE 1', 'qty' => 9]], $current); + $this->assertSame($legacy, $current); + } + + /** + * Same buckets as the query that joined SummitOrder, including the ticket bought at 23:30 local + * time (still Jul 15 although it is already Jul 16 in UTC). + */ + public function testPurchasedTicketsHistogramMatchesTheOrderJoin(): void + { + $offset = self::LegacyOffset; + $legacy = DB::connection('model')->select( + "SELECT COUNT(t.ID) AS qty, DATE_FORMAT(CONVERT_TZ(t.TicketBoughtDate,'+00:00','{$offset}'), '%b %D') AS label + FROM SummitAttendeeTicket t INNER JOIN SummitOrder o ON o.ID = t.OrderID + WHERE o.SummitID = :summit_id AND t.IsActive = 1 AND t.Status = 'Paid' + GROUP BY DATE_FORMAT(CONVERT_TZ(t.TicketBoughtDate,'+00:00','{$offset}'), '%b %D') + ORDER BY t.TicketBoughtDate ASC", + ['summit_id' => $this->stats_summit->getId()] + ); + $legacy = array_map(fn($r) => ['qty' => intval($r->qty), 'label' => $r->label], $legacy); + + $response = $this->stats_summit->getPurchasedTicketsGroupedBy(IStatsConstants::GroupByDay, new PagingInfo(1, 100)); + $current = array_map( + fn($r) => ['qty' => intval($r['qty']), 'label' => $r['label']], + $response->getItems() + ); + + $this->assertSame($legacy, $current); + $this->assertSame(count($legacy), $response->getTotal()); + $this->assertSame('Jul 15th', $current[0]['label'], 'the 23:30 local purchase belongs to Jul 15'); + $this->assertSame(1, $current[0]['qty']); + } + + /** + * Pins the known limitation documented on getTimeZoneOffset(): one fixed offset for the whole + * summit. The ticket bought at 23:30 local time on Nov 1 (UTC-06:00 after DST ended) is bucketed + * with the July offset (UTC-05:00) and lands on Nov 2. Not a bug of this change; if the offset + * is ever fixed, this expectation changes with it. + */ + public function testHistogramKeepsTheFixedSummitOffsetAcrossDst(): void + { + $response = $this->stats_summit->getPurchasedTicketsGroupedBy(IStatsConstants::GroupByDay, new PagingInfo(1, 100)); + $labels = array_column($response->getItems(), 'label'); + + $this->assertContains('Nov 2nd', $labels); + $this->assertNotContains('Nov 1st', $labels); + } + + public function testHistogramPaginatesTheBucketsInMemory(): void + { + $page1 = $this->stats_summit->getPurchasedTicketsGroupedBy(IStatsConstants::GroupByDay, new PagingInfo(1, 2)); + $page2 = $this->stats_summit->getPurchasedTicketsGroupedBy(IStatsConstants::GroupByDay, new PagingInfo(2, 2)); + + $this->assertSame(3, $page1->getTotal()); + $this->assertSame(3, $page2->getTotal()); + $this->assertSame(['Jul 15th', 'Jul 20th'], array_column($page1->getItems(), 'label')); + $this->assertSame(['Nov 2nd'], array_column($page2->getItems(), 'label')); + $this->assertSame(2, $page1->getLastPage()); + } +}