delete()` convention; neither outcome is an error. * * @package Affilun\Infrastructure\Persistence */ declare(strict_types=1); namespace Affilun\Infrastructure\Persistence; use DateTimeImmutable; use InvalidArgumentException; use RuntimeException; /** * Persistence port for `affilun_sync_jobs` rows. * * See the file-level docblock for the contract's scope, the record * type it exchanges, the reason `findByUuid()` is intentionally * absent, and its insert-vs-update / not-found / delete semantics. */ interface SyncJobRepository { /** * Persist the given record. * * Implementations decide insert vs. update based on whether the * supplied record carries an id: * * - Null id: insert. The implementation assigns the auto- * increment primary key and returns a new record carrying * the assigned id. The returned record reflects any other * column defaults the database applied (for example * timestamps normalized to the storage precision). * * - Non-null id: update. The implementation writes every column * on the record to the matching row. The returned record is * the canonical post-save shape; callers MUST use the returned * record (not the input) for any subsequent operation so * `updated_at` and any implementation-normalized values are * not silently dropped. * * A save that targets a non-null id which no longer exists in the * database MUST raise a {@see RuntimeException} rather than * silently inserting a new row with a caller-controlled id — the * application's update-after-delete race should be a visible * error, not a data-integrity surprise. * * @param SyncJobRecord $record Record to persist. * * @return SyncJobRecord The post-save record, always carrying a * non-null id. * * @throws RuntimeException When the underlying driver reports an * error or when an update targets a row * that no longer exists. */ public function save( SyncJobRecord $record ): SyncJobRecord; /** * Look up a record by its auto-increment primary key. * * Returns null when no row matches. Non-positive ids are a * programming error and implementations MAY raise * {@see \InvalidArgumentException}; callers that only have an * optional id in hand should filter before calling. * * @param int $id Primary key to look up. * * @throws RuntimeException When the underlying driver reports an * error. */ public function findById( int $id ): ?SyncJobRecord; /** * Delete the row identified by the given id. * * Idempotent: returns true when a row was removed, false when no * row matched. Neither outcome is an error. * * Non-positive ids are a programming error and implementations * MAY raise {@see \InvalidArgumentException}. * * @param int $id Primary key of the row to delete. * * @throws RuntimeException When the underlying driver reports an * error. */ public function delete( int $id ): bool; /** * Total number of rows currently stored in `affilun_sync_jobs`. * * Used by diagnostics and integration tests to assert on the * pipeline outcome without enumerating rows. Implementations * SHOULD back this with a `SELECT COUNT(*)` query rather than * materializing every row. * * @throws RuntimeException When the underlying driver reports an * error. */ public function count(): int; /** * Look up a paginated, filtered slice of stored rows. * * Implements the `GET /affilun/v1/sync/jobs` query surface described * in `specifications/rest-api-spec.md` §11.3. The query VO carries * the optional column-level filter axis (`status`, `jobType`) and * the pagination axis (`page`, `perPage`); see * {@see SyncJobListQuery} for the exhaustive validation rules and * the wire ⇔ DB status vocabulary mapping (wire `queued` ⇔ DB * `pending` per `rest-api-spec.md` §6.0). The REST controller in * slice 11e is responsible for translating the wire-side * vocabulary before constructing the query VO; the persistence * layer speaks DB vocabulary throughout. * * The returned slice is ordered newest-first * (`ORDER BY created_at DESC, id DESC`); spec §11.3 does not * expose a sort parameter, so the ordering is fixed at the * repository boundary. The total reported on the result envelope * reflects the filter set BEFORE pagination so REST consumers can * compute the page count without re-issuing the query. * * @param SyncJobListQuery $query Validated list-query inputs. * * @throws RuntimeException When the underlying driver reports an * error or when a returned row cannot be * decoded into a {@see SyncJobRecord}. */ public function findPaginated( SyncJobListQuery $query ): SyncJobListResult; /** * Group every stored row by DB-vocabulary status and return the * per-status counts. * * Implements the status-histogram surface consumed by the * sync-jobs admin page. Statuses with zero occurrences are * OMITTED from the returned map; callers that need a fully- * populated histogram (every status with a numeric count) should * default-fill against {@see SyncJobListQuery::VALID_STATUSES}. * * Implementations SHOULD back this with a single * `SELECT status, COUNT(*) ... GROUP BY status` query rather than * issuing one count per status. * * The returned keys use DB vocabulary (`pending|running|...`); the * REST controller in slice 11e maps DB `pending` to wire `queued` * before responding to clients per `rest-api-spec.md` §6.0. * * @return array Map of DB-vocabulary status => count. * Statuses with zero occurrences are * omitted. * * @throws RuntimeException When the underlying driver reports an * error. */ public function countByStatus(): array; /** * Find an unfinished `sync_product` job for the supplied product id. * * Used by periodic/manual enqueue paths to avoid stacking duplicate * work for the same product while an existing row is still queued * or running. Terminal rows (`succeeded`, `failed`, etc.) are not * considered open and MUST NOT suppress a future enqueue. * * @param int $productId Local `affilun_products.id` carried in the job payload. * * @throws InvalidArgumentException When `$productId` is non-positive. * @throws RuntimeException When the underlying driver reports an error. */ public function findOpenProductSync( int $productId ): ?SyncJobRecord; /** * Atomically claim the next runnable job for the supplied worker * and return the post-claim record, or null when no job is * eligible. * * Eligibility (per `database-schema.md` §8.5 / ADR-0021 §8.2): * * - A `pending` row whose `available_at <= $now` is eligible * for a fresh claim. * * - A `running` row whose `locked_at` is non-null AND strictly * older than `$lockTimeoutCutoff` is eligible for * reclamation. The §8.5 "一定時間以上 `running` のままなら * タイムアウト回収対象" rule is enforced here so the worker * loop does not have to drive a separate sweep. * * Among the eligible candidates, implementations MUST honor the * §13.2 priority ordering — `priority ASC` first (smaller is * higher priority), then `available_at ASC` (oldest first), then * `id ASC` (stable tiebreak). The selected row is transitioned * to `status='running'`, `locked_at = $now`, `locked_by = * $workerId`, and `updated_at = $now`. The `attempts` counter is * NOT incremented on claim — the handler increments it on the * terminal transition so that a worker crash mid-flight does not * burn an attempt the handler never observed. * * Atomicity: * * - The claim MUST be safe against concurrent workers. The * `WpdbSyncJobRepository` implementation uses a `SELECT` * candidate scan plus a conditional `UPDATE` that only * succeeds when the row is still in the eligible state, and * advances to the next candidate when the conditional update * reports zero affected rows. Implementations SHOULD attempt * a small fixed number of candidates before returning null * so the loop cannot wedge on a hot row. * * - When no eligible row exists (or every candidate was * stolen by a competing worker before the conditional update * landed), the method returns null. Returning null is the * normal idle path; callers SHOULD NOT treat it as an error. * * @param string $workerId Lock-holder identifier * stamped onto * `locked_by`. Must be * non-empty and at most * 64 bytes (matching the * column width). * @param DateTimeImmutable $now Current UTC instant; * stamped onto * `locked_at` and * `updated_at`, and used * as the upper bound for * the `pending` / * `available_at` * eligibility check. * @param DateTimeImmutable $lockTimeoutCutoff Threshold for the * `running` reclamation * branch: rows whose * `locked_at` is * strictly older than * this instant are * eligible for re-claim. * To disable * reclamation entirely, * pass an instant equal * to or earlier than * every possible * `locked_at` value * (for example * `1970-01-01T00:00:00Z`). * * @throws InvalidArgumentException When `$workerId` is empty or * exceeds 64 bytes. * @throws RuntimeException When the underlying driver * reports an error. */ public function claimNext( string $workerId, DateTimeImmutable $now, DateTimeImmutable $lockTimeoutCutoff ): ?SyncJobRecord; }