feat(form-builder): wire D1 building blocks into ApplyBindings + add deadline wrapper
Per RFC-WS-6 §Q1 v1.3 addition 1, 4 + §Q3 v1.3 addition 2 + ARCH-BINDINGS §5.3.
- FormBindingApplicator::withDeadline(int) returns a clone configured to
throw FormBindingApplicatorTimeoutException if apply() exceeds the
deadline. Soft post-call microtime check; cannot interrupt mid-query
but catches the long tail. apply() refactored to single-return so the
deadline check sits at one site instead of duplicated.
- ApplyBindingsOnFormSubmit::handle:
- Initial identity_match_status='pending' write inside inner
transaction (when subject is or becomes a person) so HTTP response
carries the right state for the IdentityMatchBanner first-paint
copy. Final state comes from the queued TriggerPersonIdentityMatch
(D2 Phase C).
- Wraps apply() with config('form_builder.apply_deadline_seconds', 5).
- Catch block uses FormBindingExceptionClassifier::classify to write
failure_response_code in the outer transaction alongside
apply_status=FAILED. submission_id from the exception (when in the
binding-applicator hierarchy) is also captured in context JSON.
Tests added in Phase I.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ namespace App\FormBuilder\Bindings;
|
|||||||
use App\Enums\FormBuilder\BindingTargetType;
|
use App\Enums\FormBuilder\BindingTargetType;
|
||||||
use App\Enums\FormBuilder\FormFieldBindingMergeStrategy;
|
use App\Enums\FormBuilder\FormFieldBindingMergeStrategy;
|
||||||
use App\Exceptions\FormBuilder\FormBindingApplicatorException;
|
use App\Exceptions\FormBuilder\FormBindingApplicatorException;
|
||||||
|
use App\Exceptions\FormBuilder\FormBindingApplicatorTimeoutException;
|
||||||
use App\Exceptions\FormBuilder\FormBindingInfraException;
|
use App\Exceptions\FormBuilder\FormBindingInfraException;
|
||||||
use App\Exceptions\FormBuilder\FormBindingSchemaConfigException;
|
use App\Exceptions\FormBuilder\FormBindingSchemaConfigException;
|
||||||
use App\FormBuilder\Purposes\PurposeRegistry;
|
use App\FormBuilder\Purposes\PurposeRegistry;
|
||||||
@@ -28,6 +29,10 @@ use Throwable;
|
|||||||
* - Q9: subject resolution via per-purpose PurposeSubjectResolver.
|
* - Q9: subject resolution via per-purpose PurposeSubjectResolver.
|
||||||
* - Q10: optional sectionId for future section-level apply.
|
* - Q10: optional sectionId for future section-level apply.
|
||||||
* - Q12: hierarchical activity log via BindingActivityLogger.
|
* - Q12: hierarchical activity log via BindingActivityLogger.
|
||||||
|
* - v1.3 Q1 add 4: optional deadline (withDeadline()) — soft post-call
|
||||||
|
* microtime check throwing FormBindingApplicatorTimeoutException.
|
||||||
|
* Cannot interrupt mid-query; intended to catch the long-tail of
|
||||||
|
* slow applies before they hang the public flow.
|
||||||
*/
|
*/
|
||||||
// Not final + not readonly: listener tests need to override `apply()` for
|
// Not final + not readonly: listener tests need to override `apply()` for
|
||||||
// throw-path coverage (Mockery can't mock final classes; PHP doesn't allow
|
// throw-path coverage (Mockery can't mock final classes; PHP doesn't allow
|
||||||
@@ -35,6 +40,15 @@ use Throwable;
|
|||||||
// individually to preserve immutability.
|
// individually to preserve immutability.
|
||||||
class FormBindingApplicator
|
class FormBindingApplicator
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Per RFC-WS-6 §Q1 v1.3 addition 4 — soft deadline (seconds). NULL
|
||||||
|
* means "no deadline check" (default). Set via withDeadline() so the
|
||||||
|
* value travels with a clone and the original instance stays
|
||||||
|
* deadline-free for other callers (e.g. the retry-service path,
|
||||||
|
* which currently does not bound apply() — see ARCH-BINDINGS §5.3).
|
||||||
|
*/
|
||||||
|
private ?int $deadlineSeconds = null;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly PurposeRegistry $purposeRegistry,
|
private readonly PurposeRegistry $purposeRegistry,
|
||||||
private readonly BindingConflictResolver $conflictResolver,
|
private readonly BindingConflictResolver $conflictResolver,
|
||||||
@@ -42,11 +56,34 @@ class FormBindingApplicator
|
|||||||
private readonly BindingActivityLogger $activityLogger,
|
private readonly BindingActivityLogger $activityLogger,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a clone configured to throw FormBindingApplicatorTimeoutException
|
||||||
|
* if apply() exceeds the given deadline.
|
||||||
|
*
|
||||||
|
* Per RFC-WS-6 §Q1 v1.3 addition 4 + ARCH-BINDINGS §5.3.
|
||||||
|
*
|
||||||
|
* Implementation note: this is a soft post-call deadline check via
|
||||||
|
* microtime. It cannot interrupt mid-query — for that, configure MySQL
|
||||||
|
* connection timeouts at the database driver level. The soft deadline
|
||||||
|
* is sufficient to prevent runaway apply() calls from hanging the
|
||||||
|
* public flow indefinitely; a typical apply() takes <100ms, so a 5s
|
||||||
|
* deadline catches the long tail.
|
||||||
|
*/
|
||||||
|
public function withDeadline(int $seconds): self
|
||||||
|
{
|
||||||
|
$clone = clone $this;
|
||||||
|
$clone->deadlineSeconds = $seconds;
|
||||||
|
|
||||||
|
return $clone;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws FormBindingApplicatorException
|
* @throws FormBindingApplicatorException
|
||||||
*/
|
*/
|
||||||
public function apply(FormSubmission $submission, ?string $sectionId = null): BindingPassResult
|
public function apply(FormSubmission $submission, ?string $sectionId = null): BindingPassResult
|
||||||
{
|
{
|
||||||
|
$start = microtime(true);
|
||||||
|
|
||||||
if (DB::transactionLevel() < 1) {
|
if (DB::transactionLevel() < 1) {
|
||||||
throw new FormBindingInfraException(
|
throw new FormBindingInfraException(
|
||||||
submissionId: (string) $submission->id,
|
submissionId: (string) $submission->id,
|
||||||
@@ -81,38 +118,61 @@ class FormBindingApplicator
|
|||||||
provisionedSubjectId: null,
|
provisionedSubjectId: null,
|
||||||
applications: [],
|
applications: [],
|
||||||
);
|
);
|
||||||
$this->activityLogger->logPass($submission, $result);
|
} else {
|
||||||
|
$resolved = $this->conflictResolver->resolve($submission, $sectionId);
|
||||||
|
|
||||||
return $result;
|
// Persist subject identity for the result + apply each binding.
|
||||||
}
|
$applications = [];
|
||||||
|
foreach ($resolved as $binding) {
|
||||||
$resolved = $this->conflictResolver->resolve($submission, $sectionId);
|
// Skip identity-key bindings — the resolver already used them
|
||||||
|
// for subject lookup in EventRegistration's PersonProvisioner
|
||||||
// Persist subject identity for the result + apply each binding.
|
// path. Writing them again is a no-op at best, a clobber at
|
||||||
$applications = [];
|
// worst.
|
||||||
foreach ($resolved as $binding) {
|
if ($binding->isIdentityKey) {
|
||||||
// Skip identity-key bindings — the resolver already used them
|
continue;
|
||||||
// for subject lookup in EventRegistration's PersonProvisioner
|
}
|
||||||
// path. Writing them again is a no-op at best, a clobber at
|
$applications[] = $this->applyOne($subject, $binding);
|
||||||
// worst.
|
|
||||||
if ($binding->isIdentityKey) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
$applications[] = $this->applyOne($subject, $binding);
|
|
||||||
}
|
|
||||||
|
|
||||||
$result = new BindingPassResult(
|
$result = new BindingPassResult(
|
||||||
formSubmissionId: (string) $submission->id,
|
formSubmissionId: (string) $submission->id,
|
||||||
provisionedSubjectType: $this->morphAlias($subject),
|
provisionedSubjectType: $this->morphAlias($subject),
|
||||||
provisionedSubjectId: (string) $subject->getKey(),
|
provisionedSubjectId: (string) $subject->getKey(),
|
||||||
applications: $applications,
|
applications: $applications,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$this->activityLogger->logPass($submission, $result);
|
$this->activityLogger->logPass($submission, $result);
|
||||||
|
|
||||||
|
$this->checkDeadline((string) $submission->id, $start);
|
||||||
|
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throws FormBindingApplicatorTimeoutException if a deadline is
|
||||||
|
* configured and the elapsed wall-clock time exceeds it.
|
||||||
|
*/
|
||||||
|
private function checkDeadline(string $submissionId, float $startMicrotime): void
|
||||||
|
{
|
||||||
|
if ($this->deadlineSeconds === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$elapsed = microtime(true) - $startMicrotime;
|
||||||
|
if ($elapsed > $this->deadlineSeconds) {
|
||||||
|
throw new FormBindingApplicatorTimeoutException(
|
||||||
|
submissionId: $submissionId,
|
||||||
|
message: sprintf(
|
||||||
|
'FormBindingApplicator exceeded deadline of %ds (elapsed: %.2fs) for submission %s',
|
||||||
|
$this->deadlineSeconds,
|
||||||
|
$elapsed,
|
||||||
|
$submissionId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function applyOne(Model $subject, ResolvedBinding $binding): BindingApplicationResult
|
private function applyOne(Model $subject, ResolvedBinding $binding): BindingApplicationResult
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ namespace App\Listeners\FormBuilder;
|
|||||||
|
|
||||||
use App\Enums\FormBuilder\ApplyStatus;
|
use App\Enums\FormBuilder\ApplyStatus;
|
||||||
use App\Events\FormBuilder\FormSubmissionSubmitted;
|
use App\Events\FormBuilder\FormSubmissionSubmitted;
|
||||||
|
use App\Exceptions\FormBuilder\FormBindingApplicatorException;
|
||||||
use App\FormBuilder\Bindings\FormBindingApplicator;
|
use App\FormBuilder\Bindings\FormBindingApplicator;
|
||||||
|
use App\FormBuilder\Bindings\FormBindingExceptionClassifier;
|
||||||
use App\Models\FormBuilder\FormSchema;
|
use App\Models\FormBuilder\FormSchema;
|
||||||
use App\Models\FormBuilder\FormSubmission;
|
use App\Models\FormBuilder\FormSubmission;
|
||||||
use App\Models\FormBuilder\FormSubmissionActionFailure;
|
use App\Models\FormBuilder\FormSubmissionActionFailure;
|
||||||
@@ -21,10 +23,24 @@ use Throwable;
|
|||||||
* FormSubmissionActionFailure in a separate transaction (survives
|
* FormSubmissionActionFailure in a separate transaction (survives
|
||||||
* inner rollback).
|
* inner rollback).
|
||||||
*
|
*
|
||||||
|
* v1.3 changes (per RFC v1.3.1 + ARCH-BINDINGS v1.2):
|
||||||
|
* - Q1 addition 1: writes identity_match_status='pending' inside the
|
||||||
|
* inner transaction so the HTTP response carries the right state
|
||||||
|
* for the IdentityMatchBanner first-paint copy. Final state is
|
||||||
|
* written by the queued TriggerPersonIdentityMatch.
|
||||||
|
* - Q1 addition 4: wraps applicator with config-driven deadline so
|
||||||
|
* a runaway apply() throws FormBindingApplicatorTimeoutException
|
||||||
|
* instead of hanging the public flow.
|
||||||
|
* - Q3 addition 2: outer-transaction catch uses
|
||||||
|
* FormBindingExceptionClassifier::classify to write
|
||||||
|
* failure_response_code on the parent submission. The action-failure
|
||||||
|
* row is the canonical machine-replayable artefact; the column is
|
||||||
|
* the response-shape driver.
|
||||||
|
*
|
||||||
* Throws are swallowed (RFC Q3) — sibling listeners must keep running.
|
* Throws are swallowed (RFC Q3) — sibling listeners must keep running.
|
||||||
*
|
*
|
||||||
* SYNCHRONOUS by design — does NOT implement ShouldQueue. Identity
|
* SYNCHRONOUS by design — does NOT implement ShouldQueue. Identity-match
|
||||||
* match runs after this in the registered listener order.
|
* runs queued (post-v1.3) and is gated on apply_status=COMPLETED.
|
||||||
*/
|
*/
|
||||||
final readonly class ApplyBindingsOnFormSubmit
|
final readonly class ApplyBindingsOnFormSubmit
|
||||||
{
|
{
|
||||||
@@ -37,28 +53,40 @@ final readonly class ApplyBindingsOnFormSubmit
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
$deadlineSeconds = (int) config('form_builder.apply_deadline_seconds', 5);
|
||||||
DB::transaction(function () use ($submission): void {
|
|
||||||
$result = $this->applicator->apply($submission);
|
|
||||||
|
|
||||||
FormSubmission::query()
|
try {
|
||||||
->whereKey($submission->id)
|
DB::transaction(function () use ($submission, $deadlineSeconds): void {
|
||||||
->update([
|
$result = $this->applicator
|
||||||
'apply_status' => $result->applyStatus()->value,
|
->withDeadline($deadlineSeconds)
|
||||||
'apply_completed_at' => now(),
|
->apply($submission);
|
||||||
]);
|
|
||||||
|
$updates = [
|
||||||
|
'apply_status' => $result->applyStatus()->value,
|
||||||
|
'apply_completed_at' => now(),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Initial identity_match_status='pending' write (RFC §Q1
|
||||||
|
// v1.3 addition 1). Only meaningful when ApplyBindings
|
||||||
|
// produced a person subject; non-person purposes leave
|
||||||
|
// the column NULL per ARCH-BINDINGS §7.3 contract.
|
||||||
|
if ($result->provisionedSubjectType === 'person'
|
||||||
|
|| $submission->subject_type === 'person') {
|
||||||
|
$updates['identity_match_status'] = 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
if ($result->provisionedSubjectType !== null && $submission->subject_type === null) {
|
if ($result->provisionedSubjectType !== null && $submission->subject_type === null) {
|
||||||
// ApplyBindings just provisioned a Person; reflect it
|
// ApplyBindings just provisioned a Person; reflect it
|
||||||
// on the submission so TriggerPersonIdentityMatch (next
|
// on the submission so TriggerPersonIdentityMatch (next
|
||||||
// sync listener) can find it.
|
// queued listener) can find it and the gating-invariant
|
||||||
FormSubmission::query()
|
// sees a coherent (subject_type, subject_id) pair.
|
||||||
->whereKey($submission->id)
|
$updates['subject_type'] = $result->provisionedSubjectType;
|
||||||
->update([
|
$updates['subject_id'] = $result->provisionedSubjectId;
|
||||||
'subject_type' => $result->provisionedSubjectType,
|
|
||||||
'subject_id' => $result->provisionedSubjectId,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FormSubmission::query()
|
||||||
|
->whereKey($submission->id)
|
||||||
|
->update($updates);
|
||||||
});
|
});
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
// OUTSIDE the failed transaction — survives rollback.
|
// OUTSIDE the failed transaction — survives rollback.
|
||||||
@@ -74,6 +102,9 @@ final readonly class ApplyBindingsOnFormSubmit
|
|||||||
'exception_trace' => $e->getTraceAsString(),
|
'exception_trace' => $e->getTraceAsString(),
|
||||||
'context' => [
|
'context' => [
|
||||||
'purpose' => $purposeValue,
|
'purpose' => $purposeValue,
|
||||||
|
'submission_id' => $e instanceof FormBindingApplicatorException
|
||||||
|
? $e->submissionId
|
||||||
|
: (string) $submission->id,
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
FormSubmission::query()
|
FormSubmission::query()
|
||||||
@@ -81,6 +112,7 @@ final readonly class ApplyBindingsOnFormSubmit
|
|||||||
->update([
|
->update([
|
||||||
'apply_status' => ApplyStatus::FAILED->value,
|
'apply_status' => ApplyStatus::FAILED->value,
|
||||||
'apply_completed_at' => now(),
|
'apply_completed_at' => now(),
|
||||||
|
'failure_response_code' => FormBindingExceptionClassifier::classify($e),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
Log::error('form-builder.apply.transaction_rolled_back', [
|
Log::error('form-builder.apply.transaction_rolled_back', [
|
||||||
|
|||||||
Reference in New Issue
Block a user