LGV_TZ_Lookup 1.4.2
Time zone lookup for PHP applications and HTTP clients
Loading...
Searching...
No Matches
demo.php
Go to the documentation of this file.
1<?php
2/***************************************************************************************************************************/
3/**
4 © Copyright 2023-2026, [Little Green Viper Software Development LLC](https://littlegreenviper.com)
5
6 LICENSE:
7
8 MIT License
9
10 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation
11 files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy,
12 modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
13 Software is furnished to do so, subject to the following conditions:
14
15 The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
16
17 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
18 OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
19 IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
20 CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21
22 [Little Green Viper Software Development LLC](https://littlegreenviper.com)
23*/
24/***************************************************************************************************************************/
25/**
26 \file
27 \brief This is the PHP worker for the command-line Composer demo.
28
29 Call demo/run.sh to run the demo. The shell creates a temporary working directory, and calls this file in either
30 "run" or "cleanup" mode. In "run" mode, we install the released package through Composer, load the latest boundary
31 data, check the resulting database, run location tests, and display metrics. The lookup classes come from the
32 temporary Composer application, so this is a test of the installed package.
33
34 The separate "cleanup" invocation lets the shell remove the database and downloads, even if the loader's PHP
35 process runs out of memory. The shell removes the remaining working directory after database cleanup succeeds.
36*/
37
38declare(strict_types=1);
39require_once dirname(__DIR__).'/tools/Setup.php';
40
41/***************************************************************************************************************************/
42/** \brief This is the package installed into the temporary Composer application. */
43const DEMO_PACKAGE = 'littlegreenviper/lgv_tz_lookup';
44/***************************************************************************************************************************/
45/** \brief This GitHub endpoint identifies the latest published Timezone Boundary Builder release. */
46const DEMO_RELEASE_API = 'https://api.github.com/repos/evansiroky/timezone-boundary-builder/releases/latest';
47
48/***************************************************************************************************************************/
49/**
50 \brief These are the measurements collected by the worker.
51
52 "started" contains a monotonic timestamp in nanoseconds. "timings" contains phase durations in seconds.
53 The lookup arrays contain individual call durations in milliseconds. Fields ending in "bytes" describe data sizes
54 or PHP memory usage. "largest" contains only the small description records needed for the final report.
55
56 We retain measurements from completed phases, so a failed run can still print the information available so far.
57*/
58$demoMetrics = ['started' => hrtime(true), 'timings' => [], 'lookup_ms' => [], 'known_lookup_ms' => [], 'generated_lookup_ms' => [],
59 'lookup_extra_bytes' => 0, 'lookup_peak_bytes' => 0, 'phase_memory_available' => function_exists('memory_reset_peak_usage')];
60
61/***************************************************************************************************************************/
62/**
63 \brief This records the elapsed time for one completed phase.
64*/
65function demoTiming(string $label, ///< The label displayed in the metrics report.
66 $start ///< The phase's starting hrtime(true) value, in nanoseconds.
67 ): void {
68 global $demoMetrics;
69 $demoMetrics['timings'][$label] = (hrtime(true) - $start) / 1e9;
70}
71
72/***************************************************************************************************************************/
73/**
74 \brief This starts a new peak-memory measurement, when the PHP version supports it.
75
76 PHP 8.2 and later can reset the peak between phases and lookup calls. Earlier versions leave the lifetime peak
77 intact; the report labels those measurements accordingly.
78*/
79function demoResetMemoryPeak(): void {
80 if (function_exists('memory_reset_peak_usage')) { memory_reset_peak_usage(); }
81}
82
83/***************************************************************************************************************************/
84/**
85 \brief This formats the timing statistics for one group of lookup calls.
86
87 We report the arithmetic mean, the median, the nearest-rank 95th percentile, and the slowest call.
88
89 \returns: A printable string. An empty group reports that no lookup measurements are available.
90*/
91function demoLatency(array $values ///< Individual lookup durations, in milliseconds.
92 ): string {
93 sort($values);
94 $count = count($values);
95 if ($count === 0) { return 'No lookups completed'; }
96 $median = ($values[(int)floor(($count - 1) / 2)] + $values[(int)floor($count / 2)]) / 2;
97 $p95 = $values[max(0, (int)ceil($count * 0.95) - 1)];
98 return sprintf('mean %.3f ms; median %.3f ms; p95 %.3f ms; max %.3f ms', array_sum($values) / $count, $median, $p95, max($values));
99}
100
101/***************************************************************************************************************************/
102/**
103 \brief This prints the collected durations, data sizes, memory measurements, and test coverage.
104
105 Lookup timings cover calls to the package API, using an existing connection. They exclude downloading, loading,
106 and reference-geometry calculations. PHP memory measurements describe this worker, and do not include the database server
107 or the separate Composer process. Large-polygon audit counters show which geometry was actually evaluated.
108*/
109function demoPrintMetrics(): void {
110 global $demoMetrics;
111 echo "\nMetrics\n";
112 foreach ($demoMetrics['timings'] as $label => $seconds) {
113 printf(" %-35s %9.2f s\n", $label, $seconds);
114 }
115 printf(" %-35s %9.2f s\n", 'Total before cleanup', (hrtime(true) - $demoMetrics['started']) / 1e9);
116 foreach (['download_bytes' => 'Compressed download', 'extracted_bytes' => 'Extracted GeoJSON',
117 'database_bytes' => 'Stored polygon payload',
118 'load_peak_bytes' => $demoMetrics['phase_memory_available'] ? 'PHP peak: load + source capture' : 'PHP lifetime peak after loading',
119 'reference_peak_bytes' => $demoMetrics['phase_memory_available'] ? 'PHP peak: independent validation' : 'PHP lifetime peak after validation'] as $field => $label) {
120 if (isset($demoMetrics[$field])) { printf(" %-35s %9.2f MiB\n", $label, $demoMetrics[$field] / 1048576); }
121 }
122 if (!empty($demoMetrics['lookup_ms'])) {
123 echo ' All lookup calls: '.demoLatency($demoMetrics['lookup_ms'])."\n";
124 echo ' Known-location calls: '.demoLatency($demoMetrics['known_lookup_ms'])."\n";
125 echo ' Largest-shape calls: '.demoLatency($demoMetrics['generated_lookup_ms'])."\n";
126 printf(" Lookup throughput (API time only): %.1f calls/s\n", count($demoMetrics['lookup_ms']) * 1000 / array_sum($demoMetrics['lookup_ms']));
127 if ($demoMetrics['phase_memory_available']) {
128 printf(" Peak extra PHP memory per lookup: %.2f MiB (total peak %.2f MiB)\n", $demoMetrics['lookup_extra_bytes'] / 1048576, $demoMetrics['lookup_peak_bytes'] / 1048576);
129 } else {
130 echo " Per-phase memory measurement requires PHP 8.2 or later.\n";
131 }
132 }
133 if (isset($demoMetrics['largest'])) {
134 if (isset($demoMetrics['polygon_rows'])) {
135 printf(" Actual polygon rows evaluated: %d (%.2f MiB processed across lookup calls)\n", $demoMetrics['polygon_rows'], $demoMetrics['polygon_bytes_processed'] / 1048576);
136 printf(" Largest polygons decoded in generated tests: %d/%d\n", $demoMetrics['largest_decoded'], count($demoMetrics['largest']));
137 }
138 if (isset($demoMetrics['boundary_pairs'])) {
139 printf(" Boundary pairs with distinct source zones: %d/%d (%d named-to-named)\n", $demoMetrics['crossing_pairs'], $demoMetrics['boundary_pairs'], $demoMetrics['named_pairs']);
140 }
141 echo " Largest polygons tested (ranked by vertex count):\n";
142 foreach ($demoMetrics['largest'] as $shape) {
143 printf(" %-28s id %-5d %8d vertices %6.2f MiB\n", $shape['tzname'], $shape['id'], $shape['vertices'], $shape['bytes'] / 1048576);
144 }
145 }
146 if (isset($demoMetrics['lookup_ms']) && !empty($demoMetrics['lookup_ms'])) {
147 echo " Reference geometry is freed before measuring package lookup memory.\n";
148 }
149 echo " PHP memory describes this worker; the database server and Composer run separately.\n\n";
150}
151
152/***************************************************************************************************************************/
153/**
154 \brief This reads a demo setting from the environment.
155
156 The "LGV_TZ_DEMO_" prefix is added to the provided name. An explicitly set empty value is retained, which is
157 useful for a MySQL user with an empty password.
158
159 \returns: The environment value, or the supplied default when the variable is not set.
160*/
161function demoEnvironment(string $name, ///< The setting name, without the LGV_TZ_DEMO_ prefix.
162 string $default ///< The value to use when the setting is absent.
163 ): string {
164 $value = getenv('LGV_TZ_DEMO_'.$name);
165 return false === $value ? $default : $value;
166}
167
168/***************************************************************************************************************************/
169/**
170 \brief This validates the selected database driver.
171 \returns: "mysql" (the default) or "pgsql".
172 \throws RuntimeException if another driver is requested.
173*/
174function demoDriver(): string {
175 $driver = strtolower(demoEnvironment('DRIVER', 'mysql'));
176 if (!in_array($driver, ['mysql', 'pgsql'], true)) {
177 throw new RuntimeException('LGV_TZ_DEMO_DRIVER must be mysql or pgsql.');
178 }
179 return $driver;
180}
181
182/***************************************************************************************************************************/
183/**
184 \brief This opens an administrative or test-database connection for the selected backend.
185 Connection settings come from DRIVER, HOST, PORT, USER, PASSWORD, and ADMIN_DATABASE. PostgreSQL administration
186 connects to the existing "postgres" database by default; loaded data always goes into a separate temporary database.
187 \returns: A PDO connection with exception reporting enabled.
188 \throws RuntimeException if the configured port is invalid.
189 \throws PDOException if the connection fails.
190*/
191function demoConnection(?string $database = null, ///< Test database, or NULL for the administrative database.
192 ?string $driver = null ///< Recorded cleanup driver, or NULL for the environment setting.
193 ): PDO {
195 $port = filter_var(demoEnvironment('PORT', $driver === 'pgsql' ? '5432' : '3306'), FILTER_VALIDATE_INT, [
196 'options' => ['min_range' => 1, 'max_range' => 65535],
197 ]);
198 if (false === $port) {
199 throw new RuntimeException('LGV_TZ_DEMO_PORT must be between 1 and 65535.');
200 }
201 $dsn = $driver.':host='.demoEnvironment('HOST', 'localhost').';port='.$port;
202 if ($driver === 'pgsql') {
203 $dsn .= ';dbname='.($database ?? demoEnvironment('ADMIN_DATABASE', 'postgres')).';options=--client_encoding=UTF8';
204 } else {
205 $dsn .= ';charset=utf8mb4'.($database === null ? '' : ';dbname='.$database);
206 }
207 return new PDO($dsn, demoEnvironment('USER', $driver === 'pgsql' ? (getenv('USER') ?: get_current_user()) : 'root'),
208 demoEnvironment('PASSWORD', ''), [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
209}
210
211/***************************************************************************************************************************/
212/**
213 \brief This downloads one HTTPS resource directly to a file.
214
215 Redirects must also use HTTPS. We stream the response to disk, so the large boundary archive is never read into
216 a single PHP string. A partial download remains in the working directory for the shell's cleanup to remove.
217
218 \throws RuntimeException if the destination cannot be opened, or the transfer fails.
219*/
220function demoDownload(string $url, ///< The HTTPS resource to download.
221 string $destination ///< The local filename to create in the demo's working directory.
222 ): void {
223 LGV_TZ_Lookup_Setup::download($url, $destination);
224}
225
226/***************************************************************************************************************************/
227/**
228 \brief This extracts the archive's single GeoJSON file into a controlled local filename.
229
230 The ZIP may contain other files, but must contain exactly one .json or .geojson entry. We stream that entry into
231 our own destination, instead of using paths from the archive. The extracted size must match the ZIP entry's size.
232
233 \throws RuntimeException if the archive cannot be read, its contents are unexpected, or extraction fails.
234*/
235function demoExtract(string $archive, ///< The downloaded boundary ZIP file.
236 string $destination ///< The local GeoJSON filename to create.
237 ): void {
238 LGV_TZ_Lookup_Setup::extract($archive, $destination);
239}
240
241/***************************************************************************************************************************/
242/**
243 \brief This builds the temporary Composer application and loads its autoloader.
244
245 We download and verify Composer, write the application's composer.json, and install the published lookup
246 package and its optional JSON parser. Composer's home and cache are placed in the working directory, as well.
247 Installation does not execute package scripts or plugins, and does not change the user's installed Composer.
248
249 \throws RuntimeException if checksum verification or Composer installation fails.
250*/
251function demoInstall(string $directory ///< The demo's temporary application directory.
252 ): void {
253 LGV_TZ_Lookup_Setup::install($directory, demoDriver(), demoEnvironment('PACKAGE_PATH', ''), demoEnvironment('PACKAGE_VERSION', '^1.4'));
254}
255
256/***************************************************************************************************************************/
257/**
258 \brief This removes the compressed boundary archive and the extracted GeoJSON.
259
260 We call this as soon as loading finishes, and again during cleanup. Already-removed files are harmless.
261
262 \throws RuntimeException if a remaining boundary file cannot be removed.
263*/
264function demoRemoveDownloads(string $directory ///< The working directory containing the downloaded files.
265 ): void {
266 foreach (['boundaries.zip', 'boundaries.json'] as $file) {
267 if (is_file($directory.'/'.$file) && !unlink($directory.'/'.$file)) {
268 throw new RuntimeException('Could not delete '.$directory.'/'.$file);
269 }
270 }
271}
272
273/***************************************************************************************************************************/
274/**
275 \brief This removes the downloads and drops the database owned by this demo run.
276
277 The database name comes from database.json, and must match the generated demo naming pattern. We delete large
278 files before connecting to the database server, so they are removed even when the server is unavailable. If dropping the
279 database fails, its cleanup record remains for recovery. The shell removes the working directory only on success.
280
281 \returns: True, if cleanup succeeded, or no database had been created.
282 \throws RuntimeException if a cleanup record is unexpected, or a file cannot be removed.
283 \throws PDOException if database cleanup fails.
284*/
285function demoCleanup(string $directory ///< The working directory owned by this demo run.
286 ): bool {
287 $start = hrtime(true);
288 // Delete the large files even if the database server is temporarily unavailable during cleanup.
290 $state = $directory.'/database.json';
291 if (!is_file($state)) {
292 printf("Cleanup: no temporary database to remove (%.3f s).\n", (hrtime(true) - $start) / 1e9);
293 return true;
294 }
295 $record = json_decode(file_get_contents($state), true, 512, JSON_THROW_ON_ERROR);
296 $database = $record['database'];
297 if (!preg_match('/^lgv_tz_demo_[a-f0-9]{24}$/D', $database)) {
298 throw new RuntimeException('Unexpected demo database name; refusing to drop it.');
299 }
300 echo 'Removing temporary database '.$database."...\n";
301 $driver = $record['driver'] ?? 'mysql';
302 if (!in_array($driver, ['mysql', 'pgsql'], true)) {
303 throw new RuntimeException('Unexpected recorded database driver; refusing to drop it.');
304 }
305 $quote = $driver === 'pgsql' ? '"' : '`';
306 demoConnection(null, $driver)->exec('DROP DATABASE IF EXISTS '.$quote.$database.$quote);
307 if (!unlink($state)) {
308 throw new RuntimeException('Could not remove the database cleanup record.');
309 }
310 printf("Database/download cleanup: %.3f s.\n", (hrtime(true) - $start) / 1e9);
311 return true;
312}
313
314/***************************************************************************************************************************/
315/**
316 \brief This creates a uniquely named database and records it for cleanup.
317
318 CREATE does not use IF NOT EXISTS, so a name collision cannot adopt an existing database. Where pcntl is
319 available, interruption signals are blocked until the cleanup record has been written. If the record cannot be
320 written, we drop the new database before reporting the failure.
321
322 \returns: The new database name, beginning with "lgv_tz_demo_".
323 \throws RuntimeException if the cleanup record cannot be written.
324 \throws PDOException if database creation or rollback fails.
325*/
326function demoCreateDatabase(PDO $admin, ///< An administrative connection with CREATE and DROP permissions.
327 string $directory ///< The working directory in which database.json is saved.
328 ): string {
329 $name = 'lgv_tz_demo_'.bin2hex(random_bytes(12));
330 $driver = $admin->getAttribute(PDO::ATTR_DRIVER_NAME);
331 $quote = $driver === 'pgsql' ? '"' : '`';
332 // Do not deliver an interruption between creating the database and saving its cleanup record.
333 $previousMask = [];
334 $blocked = function_exists('pcntl_sigprocmask') && pcntl_sigprocmask(SIG_BLOCK, [SIGHUP, SIGINT, SIGTERM], $previousMask);
335 try {
336 // CREATE without IF NOT EXISTS ensures this run can never adopt or reset an existing database.
337 $admin->exec('CREATE DATABASE '.$quote.$name.$quote);
338 if (false === file_put_contents($directory.'/database.json', json_encode(['database' => $name, 'driver' => $driver], JSON_THROW_ON_ERROR))) {
339 $admin->exec('DROP DATABASE '.$quote.$name.$quote);
340 throw new RuntimeException('Could not record the temporary database for cleanup.');
341 }
342 } finally {
343 if ($blocked) { pcntl_sigprocmask(SIG_SETMASK, $previousMask); }
344 }
345 return $name;
346}
347
348/***************************************************************************************************************************/
349/**
350 \brief This performs the complete Composer installation, data load, system test, and metrics run.
351
352 We first check PHP and the database server, then install the package and obtain the latest boundary release. The package
353 performs the actual load while a listener retains independent source geometry for storage and lookup checks.
354 The large files are deleted after loading, and the reference geometry is freed before lookup measurements.
355
356 The known locations come from the installed package. Generated tests sample the largest polygons, including
357 points on both sides of boundaries. Coverage must show that those polygons were actually decoded by the API.
358 The parent shell handles database and directory cleanup after this function returns or the worker fails.
359
360 \returns: 0, if every check passes; 1, if a storage, lookup, or coverage check fails.
361 \throws RuntimeException if setup, download, loading, or test preparation cannot complete.
362*/
363function demoRun(string $directory ///< The private working directory created by run.sh.
364 ): int {
365 global $demoMetrics;
366 if (PHP_VERSION_ID < 80000) {
367 throw new RuntimeException('PHP 8.0 or later is required.');
368 }
370 foreach (['pdo_'.$driver, 'curl', 'zip', 'mbstring', 'ctype'] as $extension) {
371 if (!extension_loaded($extension)) {
372 throw new RuntimeException('The PHP '.$extension.' extension is required.');
373 }
374 }
375 if (!function_exists('proc_open')) {
376 throw new RuntimeException('PHP proc_open must be enabled to run Composer.');
377 }
378 // Check credentials before downloading anything. The connection must be able to create and drop a database.
380 printf("Database: %s %s.\n", $driver, $admin->getAttribute(PDO::ATTR_SERVER_VERSION));
382 $start = hrtime(true);
384 demoTiming('Composer setup', $start);
385
386 echo "Finding the latest timezone boundary release...\n";
387 $start = hrtime(true);
388 demoDownload(DEMO_RELEASE_API, $directory.'/release.json');
389 $release = json_decode(file_get_contents($directory.'/release.json'), true, 512, JSON_THROW_ON_ERROR);
390 $asset = null;
391 foreach ($release['assets'] ?? [] as $candidate) {
392 if ($candidate['name'] === 'timezones-with-oceans.geojson.zip') {
393 $asset = $candidate;
394 break;
395 }
396 }
397 if (null === $asset) {
398 throw new RuntimeException('The latest release does not contain timezones-with-oceans.geojson.zip.');
399 }
400 printf("Downloading boundary release %s (%.1f MiB compressed)...\n", $release['tag_name'], $asset['size'] / 1048576);
401 $archive = $directory.'/boundaries.zip';
402 $boundary = $directory.'/boundaries.json';
403 demoDownload($asset['browser_download_url'], $archive);
404 if (isset($asset['digest']) && str_starts_with($asset['digest'], 'sha256:') &&
405 !hash_equals(substr($asset['digest'], 7), hash_file('sha256', $archive))) {
406 throw new RuntimeException('Boundary archive checksum did not match the release metadata.');
407 }
408 $demoMetrics['download_bytes'] = filesize($archive);
409 demoTiming('Release discovery + download', $start);
410 $start = hrtime(true);
411 demoExtract($archive, $boundary);
412 $demoMetrics['extracted_bytes'] = filesize($boundary);
413 demoTiming('ZIP extraction', $start);
414 printf("Extracted %.1f MiB of GeoJSON.\n", filesize($boundary) / 1048576);
415
416 echo 'Loading boundaries into temporary database '.$name."...\n";
417 require_once __DIR__.'/SystemTests.php';
419 demoEnvironment('USER', $driver === 'pgsql' ? (getenv('USER') ?: get_current_user()) : 'root'),
420 demoEnvironment('PASSWORD', ''), $driver, demoEnvironment('HOST', 'localhost'),
421 (int)demoEnvironment('PORT', $driver === 'pgsql' ? '5432' : '3306'));
422 $stream = fopen($boundary, 'rb');
423 if (false === $stream) {
424 throw new RuntimeException('Could not open the boundary file.');
425 }
426 $start = hrtime(true);
429 try {
430 $listener = new LGV_TZ_Lookup_Loader($database);
431 $inspector = new DemoInspectingListener($listener, $source);
432 $parser = new JsonStreamingParser\Parser($stream, $inspector);
433 $parser->parse();
434 $demoMetrics['load_peak_bytes'] = memory_get_peak_usage();
435 } finally {
436 fclose($stream);
437 unset($parser, $listener, $inspector);
439 }
440 demoTiming('Load + capture reference geometry', $start);
441 $validation = demoConnection($name);
442 if ($driver === 'pgsql') {
443 // Give PostgreSQL current statistics before its first bounding-box queries.
444 $validation->exec('ANALYZE timezones');
445 }
446 $polygons = $validation->query('SELECT COUNT(*) FROM timezones')->fetchColumn();
447 printf("Loaded %s polygons in %.2f seconds. Download and extracted file deleted.\n", $polygons, (hrtime(true) - $start) / 1e9);
448 if ((int)$polygons === 0) {
449 throw new RuntimeException('No polygons were loaded.');
450 }
451
452 $start = hrtime(true);
454 echo "Checking stored polygons against the decoded source GeoJSON...\n";
455 $storageFailures = $source->validateStorage($validation);
456 foreach ($storageFailures as $failure) { echo 'FAIL '.$failure."\n"; }
457 printf("Source/storage validation: %d polygons checked, %d failures.\n", count($source->shapes), count($storageFailures));
458 $demoMetrics['database_bytes'] = $source->polygonBytes;
459 $largestCount = filter_var(demoEnvironment('LARGEST_SHAPES', '10'), FILTER_VALIDATE_INT,
460 ['options' => ['min_range' => 1, 'max_range' => 100]]);
461 if (false === $largestCount) { throw new RuntimeException('LGV_TZ_DEMO_LARGEST_SHAPES must be between 1 and 100.'); }
462 $demoMetrics['largest'] = array_map(static fn($shape) => array_intersect_key($shape, array_flip(['id', 'tzname', 'vertices', 'bytes'])), $source->largest($largestCount));
463 echo 'Generating independent interior and two-sided boundary probes for the '.$largestCount." largest polygons...\n";
464 $generated = $source->casesForLargest($largestCount);
465 $pairs = [];
466 foreach ($generated as $case) {
467 if (isset($case['pair'])) { $pairs[$case['pair']][$case['side']] = $case['expected']; }
468 }
469 $demoMetrics['boundary_pairs'] = count($pairs);
470 $demoMetrics['crossing_pairs'] = 0;
471 $demoMetrics['named_pairs'] = 0;
472 foreach ($pairs as $sides) {
473 if (isset($sides[-1], $sides[1]) && $sides[-1] !== $sides[1]) {
474 ++$demoMetrics['crossing_pairs'];
475 $zones = array_merge($sides[-1], $sides[1]);
476 if (!empty($sides[-1]) && !empty($sides[1]) &&
477 count(array_filter($zones, static fn($z) => str_starts_with($z, 'Etc/'))) === 0) {
478 ++$demoMetrics['named_pairs'];
479 }
480 }
481 }
482 $demoMetrics['reference_peak_bytes'] = memory_get_peak_usage();
483 unset($source);
484 gc_collect_cycles();
485 if (function_exists('gc_mem_caches')) { gc_mem_caches(); }
486 demoTiming('Independent source/storage validation', $start);
487
488 $package = Composer\InstalledVersions::getInstallPath(DEMO_PACKAGE);
489 require $package.'/src/TestLocations.php';
490 if (empty($test_locations_param_array)) {
491 throw new RuntimeException('The installed package contains no location tests.');
492 }
494 $cases = [];
495 foreach ($test_locations_param_array as $test) {
496 $test['expected'] = [$test['result']];
497 $test['kind'] = 'known location';
498 $cases[] = $test;
499 }
500 $cases = array_merge($cases, $generated);
501 $failures = count($storageFailures);
502 $groups = [];
503 $start = hrtime(true);
504 echo 'Running '.count($test_locations_param_array).' known locations and '.count($generated)." generated system tests through the Composer lookup API...\n";
505 foreach ($cases as $index => $test) {
506 $lng = $test['params']['lng'];
507 $lat = $test['params']['lat'];
508 while ($lng < -180) { $lng += 360; }
509 while ($lng > 180) { $lng -= 360; }
510 while ($lat < -90) { $lat += 180; }
511 while ($lat > 90) { $lat -= 180; }
513 $baselineMemory = memory_get_usage();
514 $database->generatedCall = $test['kind'] !== 'known location';
515 $callStart = hrtime(true);
516 $actual = $lookup->get_tz($lng, $lat);
517 $elapsed = (hrtime(true) - $callStart) / 1e6;
518 $demoMetrics['lookup_ms'][] = $elapsed;
519 if ($test['kind'] !== 'known location') { $demoMetrics['generated_lookup_ms'][] = $elapsed; }
520 else { $demoMetrics['known_lookup_ms'][] = $elapsed; }
521 $demoMetrics['lookup_extra_bytes'] = max($demoMetrics['lookup_extra_bytes'], memory_get_peak_usage() - $baselineMemory);
522 $demoMetrics['lookup_peak_bytes'] = max($demoMetrics['lookup_peak_bytes'], memory_get_peak_usage());
523 $pass = in_array($actual, empty($test['expected']) ? [''] : $test['expected'], true);
524 $groups[$test['kind']]['total'] = ($groups[$test['kind']]['total'] ?? 0) + 1;
525 $groups[$test['kind']]['passed'] = ($groups[$test['kind']]['passed'] ?? 0) + (int)$pass;
526 if (!$pass) {
527 ++$failures;
528 printf("FAIL %s [%.6f, %.6f]: expected %s, received %s\n", $test['title'], $lng, $lat,
529 empty($test['expected']) ? '(no match)' : implode(' or ', $test['expected']), $actual === '' ? '(no match)' : $actual);
530 } elseif ($index < 5) {
531 printf("PASS %s: %s\n", $test['title'], $actual);
532 }
533 }
534 demoTiming('Package lookup tests', $start);
535 $demoMetrics['polygon_rows'] = $database->polygonRows;
536 $demoMetrics['polygon_bytes_processed'] = $database->polygonBytes;
537 $demoMetrics['largest_decoded'] = 0;
538 foreach ($demoMetrics['largest'] as $shape) {
539 if (isset($database->generatedPolygons[$shape['id']])) { ++$demoMetrics['largest_decoded']; }
540 else {
541 ++$failures;
542 echo 'FAIL coverage: generated probes did not decode largest polygon '.$shape['id'].' ('.$shape['tzname'].").\n";
543 }
544 }
545 foreach ($groups as $kind => $group) { printf("%s: %d/%d passed.\n", ucfirst($kind), $group['passed'], $group['total']); }
547 if ($failures > 0) {
548 fwrite(STDERR, "System tests found mismatches. Generated expectations come from GeoJSON, not package output.\n");
549 }
550 return $failures === 0 ? 0 : 1;
551}
552
553/***************************************************************************************************************************/
554/**
555 \brief This is the worker entrypoint.
556
557 run.sh supplies the mode and working directory. An invalid invocation exits with 2. A caught worker error exits
558 with 1, after printing any collected metrics. The shell preserves the worker's exit status unless cleanup fails.
559*/
560if (realpath($_SERVER['SCRIPT_FILENAME'] ?? '') !== __FILE__) { return; }
561
562if (PHP_SAPI !== 'cli' || $argc !== 3 || !in_array($argv[1], ['run', 'cleanup'], true) || !is_dir($argv[2])) {
563 fwrite(STDERR, "Run this demo with: ./demo/run.sh\n");
564 exit(2);
565}
566// On PHP CLI builds with pcntl, allow the shell's cleanup trap to run promptly on interruption.
567if (function_exists('pcntl_async_signals')) {
568 pcntl_async_signals(true);
569 foreach ([SIGHUP, SIGINT, SIGTERM] as $signal) {
570 pcntl_signal($signal, static function(int $signal): void {
571 throw new RuntimeException('Demo interrupted by signal '.$signal.'.');
572 });
573 }
574}
575try {
576 exit($argv[1] === 'cleanup' ? (demoCleanup($argv[2]) ? 0 : 1) : demoRun($argv[2]));
577} catch (Throwable $error) {
578 fwrite(STDERR, 'Demo error: '.$error->getMessage().PHP_EOL);
579 if ($argv[1] === 'run') { demoPrintMetrics(); }
580 exit(1);
581}
$test_locations_param_array
This file has a list of long/lat pairs, along with the expected results, when presented to the server...
$start
Definition benchmark.php:84
$failures
Definition benchmark.php:79
for($run=0; $run< max(1,(int)($options['runs'] ?? 3));++$run) $seconds
This forwards parser events to the installed loader and observes the decoded source features.
This retains a compact reference copy of each polygon in the decoded GeoJSON.
This adds audit counters around the installed package's normal polygon reader.
This prepares a Composer application and its boundary data.
Definition Setup.php:37
$database
Definition composer.php:52
$lookup
Definition composer.php:54
$quote
$driver
Definition databases.php:38
$dsn
$port
$name
if($driver==='pgsql') $admin
demoCreateDatabase(PDO $admin, string $directory)
This creates a uniquely named database and records it for cleanup.
Definition demo.php:326
demoLatency(array $values)
This formats the timing statistics for one group of lookup calls.
Definition demo.php:91
$demoMetrics
These are the measurements collected by the worker.
Definition demo.php:58
demoRemoveDownloads(string $directory)
This removes the compressed boundary archive and the extracted GeoJSON.
Definition demo.php:264
demoConnection(?string $database=null, ?string $driver=null)
This opens an administrative or test-database connection for the selected backend....
Definition demo.php:191
demoDriver()
This validates the selected database driver.
Definition demo.php:174
demoResetMemoryPeak()
This starts a new peak-memory measurement, when the PHP version supports it.
Definition demo.php:79
demoTiming(string $label, $start)
This records the elapsed time for one completed phase.
Definition demo.php:65
const DEMO_PACKAGE
This is the package installed into the temporary Composer application.
Definition demo.php:43
demoPrintMetrics()
This prints the collected durations, data sizes, memory measurements, and test coverage.
Definition demo.php:109
demoCleanup(string $directory)
This removes the downloads and drops the database owned by this demo run.
Definition demo.php:285
demoDownload(string $url, string $destination)
This downloads one HTTPS resource directly to a file.
Definition demo.php:220
const DEMO_RELEASE_API
This GitHub endpoint identifies the latest published Timezone Boundary Builder release.
Definition demo.php:46
demoInstall(string $directory)
This builds the temporary Composer application and loads its autoloader.
Definition demo.php:251
demoExtract(string $archive, string $destination)
This extracts the archive's single GeoJSON file into a controlled local filename.
Definition demo.php:235
demoEnvironment(string $name, string $default)
This reads a demo setting from the environment.
Definition demo.php:161
demoRun(string $directory)
This performs the complete Composer installation, data load, system test, and metrics run.
Definition demo.php:363
if(!defined( '__CONFIG_FILE_'))
Definition index.php:51
$directory
Definition demo.php:107
$cases
Definition demo.php:82
$source
Definition demo.php:67
$error
Definition demo.php:116