fix(programs): recover historical program provenance
This commit is contained in:
parent
891c09179e
commit
dd6d2a180d
11 changed files with 268 additions and 5 deletions
|
|
@ -9,6 +9,10 @@
|
||||||
enriched only when every pre-existing field and execution identity matches.
|
enriched only when every pre-existing field and execution identity matches.
|
||||||
Android and Web consequently present the completed session and progress from
|
Android and Web consequently present the completed session and progress from
|
||||||
the same persisted identity without fabricating history.
|
the same persisted identity without fabricating history.
|
||||||
|
Historical acknowledged deliveries now complete that recovery on the
|
||||||
|
original session even when the preparation contained planned continuous
|
||||||
|
activities, and current sleep snapshots can initialize a fresh peer from a
|
||||||
|
non-root revision without discarding causal identity.
|
||||||
- Aligned the Sleep Diary Agenda ticks, interval bands and point markers on one
|
- Aligned the Sleep Diary Agenda ticks, interval bands and point markers on one
|
||||||
exact 18:00-to-18:00 timestamp projection. Selecting another night now clears
|
exact 18:00-to-18:00 timestamp projection. Selecting another night now clears
|
||||||
the previous entry immediately, adopts only an exact-date result and rejects
|
the previous entry immediately, adopts only an exact-date result and rejects
|
||||||
|
|
|
||||||
|
|
@ -541,6 +541,12 @@ internal class SyncGenerationCoordinator(
|
||||||
phase(currentRunId, "cancelled", result = "interrupted")
|
phase(currentRunId, "cancelled", result = "interrupted")
|
||||||
ForegroundGenerationResult.Cancelled(currentRunId)
|
ForegroundGenerationResult.Cancelled(currentRunId)
|
||||||
} catch (error: Exception) {
|
} catch (error: Exception) {
|
||||||
|
Log.e(
|
||||||
|
"TrainlogSyncGeneration",
|
||||||
|
"run_id=${currentRunId ?: "pending"} phase=error " +
|
||||||
|
"type=${error.javaClass.name} message=${error.message}",
|
||||||
|
error,
|
||||||
|
)
|
||||||
if (error.message == "generation retention capacity exhausted") {
|
if (error.message == "generation retention capacity exhausted") {
|
||||||
try {
|
try {
|
||||||
val request = JSONObject(File(directory, "request-v1.json").readText())
|
val request = JSONObject(File(directory, "request-v1.json").readText())
|
||||||
|
|
|
||||||
|
|
@ -841,7 +841,11 @@ class TrainlogRepository(
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (local != null && local.first != parent) return SleepDiaryImportResult.Rejected("concurrent medication revision")
|
if (local != null && local.first != parent) return SleepDiaryImportResult.Rejected("concurrent medication revision")
|
||||||
if (local == null && parent != null) return SleepDiaryImportResult.Rejected("unknown medication parent")
|
/* CONTRACT: this companion is a current-state snapshot, not a
|
||||||
|
* revision log. A fresh peer may therefore seed the current
|
||||||
|
* revision while retaining an unavailable parent identity;
|
||||||
|
* subsequent advances must still name the locally current
|
||||||
|
* revision exactly. */
|
||||||
if (local?.second == 1 && !medication.getBoolean("deleted")) return SleepDiaryImportResult.Rejected("medication resurrection")
|
if (local?.second == 1 && !medication.getBoolean("deleted")) return SleepDiaryImportResult.Rejected("medication resurrection")
|
||||||
val dose = if (medication.isNull("default_dose_value")) null else medication.getDouble("default_dose_value")
|
val dose = if (medication.isNull("default_dose_value")) null else medication.getDouble("default_dose_value")
|
||||||
val unit = if (medication.isNull("default_dose_unit")) null else medication.getString("default_dose_unit")
|
val unit = if (medication.isNull("default_dose_unit")) null else medication.getString("default_dose_unit")
|
||||||
|
|
@ -929,8 +933,9 @@ class TrainlogRepository(
|
||||||
}
|
}
|
||||||
if (local != null && parent != local.first)
|
if (local != null && parent != local.first)
|
||||||
return SleepDiaryImportResult.Rejected("concurrent sleep diary revision")
|
return SleepDiaryImportResult.Rejected("concurrent sleep diary revision")
|
||||||
if (local == null && parent != null)
|
/* The snapshot may start at a non-root current revision on a
|
||||||
return SleepDiaryImportResult.Rejected("unknown sleep diary parent")
|
* fresh peer. Preserve its causal parent identity even though
|
||||||
|
* the historical parent payload is intentionally absent. */
|
||||||
val deleted = item.getBoolean("deleted")
|
val deleted = item.getBoolean("deleted")
|
||||||
if (local?.second == 1 && !deleted)
|
if (local?.second == 1 && !deleted)
|
||||||
return SleepDiaryImportResult.Rejected("sleep diary resurrection")
|
return SleepDiaryImportResult.Rejected("sleep diary resurrection")
|
||||||
|
|
@ -4878,7 +4883,27 @@ class TrainlogRepository(
|
||||||
val sessionExerciseRowId = exerciseCursor.getLong(0)
|
val sessionExerciseRowId = exerciseCursor.getLong(0)
|
||||||
val recording = exerciseCursor.getString(3)
|
val recording = exerciseCursor.getString(3)
|
||||||
val tracking = exerciseCursor.getString(4)
|
val tracking = exerciseCursor.getString(4)
|
||||||
val exportPlan = readSessionPlan(exerciseCursor, 10)
|
val storedPlan = readSessionPlan(exerciseCursor, 10)
|
||||||
|
/* WHY: a delivered continuous occurrence can retain a
|
||||||
|
* local duration target, but mobile history represents
|
||||||
|
* its completed fact exclusively through `continuous`.
|
||||||
|
* CONTRACT: validate that local-only plan without
|
||||||
|
* inventing a set-based target the Desktop schema
|
||||||
|
* forbids. INVARIANT: performed duration, speed and
|
||||||
|
* distance are still exported unchanged below. */
|
||||||
|
if (recording == "continuous" && storedPlan != null) {
|
||||||
|
check(
|
||||||
|
tracking == "duration" &&
|
||||||
|
exerciseCursor.isNull(9) &&
|
||||||
|
storedPlan.sets == 0 &&
|
||||||
|
storedPlan.reps == null &&
|
||||||
|
storedPlan.durationSeconds in 1..MAX_PLAN_DURATION_SECONDS &&
|
||||||
|
storedPlan.weightKg == null &&
|
||||||
|
storedPlan.loadMode == SessionLoadMode.NONE &&
|
||||||
|
storedPlan.restSeconds == 0,
|
||||||
|
) { "Plan continu SQLite incohérent" }
|
||||||
|
}
|
||||||
|
val exportPlan = if (recording == "continuous") null else storedPlan
|
||||||
val hasPlan = exportPlan != null
|
val hasPlan = exportPlan != null
|
||||||
if (version < 3 && exportPlan != null) {
|
if (version < 3 && exportPlan != null) {
|
||||||
error("Un plan de séance exige l'export mobile V3.")
|
error("Un plan de séance exige l'export mobile V3.")
|
||||||
|
|
|
||||||
|
|
@ -72,4 +72,93 @@ class SleepDiaryRepositoryTest {
|
||||||
assertTrue(repository.listSleepDiary().isEmpty())
|
assertTrue(repository.listSleepDiary().isEmpty())
|
||||||
} finally { repository.close(); context.deleteDatabase(name) }
|
} finally { repository.close(); context.deleteDatabase(name) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun currentSnapshotSeedsFreshPeerFromNonRootRevisions() {
|
||||||
|
val sourceName = "sleep-source-${UUID.randomUUID()}.db"
|
||||||
|
val destinationName = "sleep-destination-${UUID.randomUUID()}.db"
|
||||||
|
val source = TrainlogRepository(context, sourceName)
|
||||||
|
val destination = TrainlogRepository(context, destinationName)
|
||||||
|
try {
|
||||||
|
val time = "2026-09-21T20:00:00+02:00"
|
||||||
|
val createdMedication =
|
||||||
|
source.saveSleepMedication(
|
||||||
|
SleepMedication("", "", time, time, "Medication", 5.0, "mg", "", "", true),
|
||||||
|
) as TrainlogRepository.SaveSleepDiaryResult.Saved
|
||||||
|
val medication = source.listSleepMedications().single()
|
||||||
|
source.saveSleepMedication(
|
||||||
|
medication.copy(name = "Medication revised", updatedAt = "2026-09-21T20:05:00+02:00"),
|
||||||
|
)
|
||||||
|
val createdEntry =
|
||||||
|
source.saveSleepDiary(
|
||||||
|
SleepDiaryDraft(
|
||||||
|
nightStartDate = "2026-09-20",
|
||||||
|
nightEndDate = "2026-09-21",
|
||||||
|
createdAt = time,
|
||||||
|
updatedAt = time,
|
||||||
|
sleepQuality = null,
|
||||||
|
wakeQuality = null,
|
||||||
|
dayForm = null,
|
||||||
|
treatmentAndNotes = "",
|
||||||
|
events = listOf(
|
||||||
|
SleepDiaryEvent(
|
||||||
|
"",
|
||||||
|
SleepEventType.SLEEP,
|
||||||
|
"2026-09-20T23:00:00+02:00",
|
||||||
|
"2026-09-21T07:00:00+02:00",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
intakes = listOf(
|
||||||
|
MedicationIntake(
|
||||||
|
"",
|
||||||
|
createdMedication.entryId,
|
||||||
|
"Medication",
|
||||||
|
"2026-09-20T22:00:00+02:00",
|
||||||
|
5.0,
|
||||||
|
"mg",
|
||||||
|
"",
|
||||||
|
time,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
) as TrainlogRepository.SaveSleepDiaryResult.Saved
|
||||||
|
val current = source.listSleepDiary().single()
|
||||||
|
val edited =
|
||||||
|
source.saveSleepDiary(
|
||||||
|
SleepDiaryDraft(
|
||||||
|
entryId = current.entryId,
|
||||||
|
expectedRevision = createdEntry.revisionId,
|
||||||
|
nightStartDate = current.nightStartDate,
|
||||||
|
nightEndDate = current.nightEndDate,
|
||||||
|
createdAt = current.createdAt,
|
||||||
|
updatedAt = "2026-09-21T20:10:00+02:00",
|
||||||
|
sleepQuality = current.sleepQuality,
|
||||||
|
wakeQuality = current.wakeQuality,
|
||||||
|
dayForm = SleepQuality.B,
|
||||||
|
treatmentAndNotes = current.treatmentAndNotes,
|
||||||
|
events = current.events,
|
||||||
|
intakes = current.intakes,
|
||||||
|
),
|
||||||
|
) as TrainlogRepository.SaveSleepDiaryResult.Saved
|
||||||
|
assertTrue(
|
||||||
|
source.validateSleepDiary(
|
||||||
|
current.entryId,
|
||||||
|
edited.revisionId,
|
||||||
|
"2026-09-21T20:15:00+02:00",
|
||||||
|
) is TrainlogRepository.SaveSleepDiaryResult.Saved,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
TrainlogRepository.SleepDiaryImportResult.Applied(1, 0),
|
||||||
|
destination.applySleepDiaryV1Json(source.buildSleepDiaryV1Json()),
|
||||||
|
)
|
||||||
|
assertEquals("Medication revised", destination.listSleepMedications().single().name)
|
||||||
|
assertEquals(SleepQuality.B, destination.listSleepDiary().single().dayForm)
|
||||||
|
} finally {
|
||||||
|
source.close()
|
||||||
|
destination.close()
|
||||||
|
context.deleteDatabase(sourceName)
|
||||||
|
context.deleteDatabase(destinationName)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2695,6 +2695,46 @@ class TrainlogRepositoryDraftTest {
|
||||||
} catch (_: IllegalStateException) { }
|
} catch (_: IllegalStateException) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun completedContinuousPlanKeepsPerformedFactAndExportsWithoutSetTarget() {
|
||||||
|
val repo = openRepository()
|
||||||
|
val exercise = createExercise(
|
||||||
|
repo,
|
||||||
|
"Marche planifiée",
|
||||||
|
RecordingMode.CONTINUOUS,
|
||||||
|
TrackingMode.DURATION,
|
||||||
|
)
|
||||||
|
val plan = SessionExercisePlan(sets = 0, durationSeconds = 600)
|
||||||
|
assertEquals(
|
||||||
|
ActiveDraftMutationResult.Saved,
|
||||||
|
repo.saveActiveSessionDraft(
|
||||||
|
ActiveSessionDraft(
|
||||||
|
exercises = listOf(
|
||||||
|
SessionExerciseDraft(
|
||||||
|
exercise = exercise,
|
||||||
|
plan = plan,
|
||||||
|
continuousDurationSeconds = 540,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val result = repo.finalizeActiveSessionDraft() as FinalizeActiveDraftResult.Saved
|
||||||
|
|
||||||
|
val detail = repo.getSessionDetail(result.sessionId)!!
|
||||||
|
assertEquals(plan, detail.exercises.single().plan)
|
||||||
|
val exported =
|
||||||
|
JSONObject(repo.buildMobileExportV4Json())
|
||||||
|
.getJSONArray("sessions")
|
||||||
|
.getJSONObject(0)
|
||||||
|
.getJSONArray("exercises")
|
||||||
|
.getJSONObject(0)
|
||||||
|
assertTrue(exported.isNull("target"))
|
||||||
|
assertEquals(540, exported.getJSONObject("continuous").getInt("duration_seconds"))
|
||||||
|
assertTrue(!exported.getJSONObject("continuous").has("speed_kmh"))
|
||||||
|
assertTrue(!exported.getJSONObject("continuous").has("distance_km"))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun targetOnlyDraftPersistsButCannotFinalizeWithoutActualWork() {
|
fun targetOnlyDraftPersistsButCannotFinalizeWithoutActualWork() {
|
||||||
val repo = openRepository()
|
val repo = openRepository()
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,10 @@ active draft and completed session. A legacy stored delivery may receive those
|
||||||
two omitted fields later only when its revision, complete prior payload and
|
two omitted fields later only when its revision, complete prior payload and
|
||||||
reserved execution identity match exactly; this repairs provenance without
|
reserved execution identity match exactly; this repairs provenance without
|
||||||
altering performed workout facts.
|
altering performed workout facts.
|
||||||
|
Acknowledged historical deliveries use the same exact-identity recovery. A
|
||||||
|
local continuous duration target is validated during generation capture while
|
||||||
|
the completed duration, speed and distance remain represented by the existing
|
||||||
|
`continuous` history payload rather than an invented set target.
|
||||||
|
|
||||||
Android schema v23 adds the durable manual-preparation withdrawal ledger. The
|
Android schema v23 adds the durable manual-preparation withdrawal ledger. The
|
||||||
schema is unchanged by Web proposal withdrawal: the existing durable AI draft
|
schema is unchanged by Web proposal withdrawal: the existing durable AI draft
|
||||||
|
|
|
||||||
|
|
@ -436,6 +436,10 @@ strict same-delivery enrichment repairs completed sessions produced from the
|
||||||
earlier omission without changing workout facts. Android and Web therefore
|
earlier omission without changing workout facts. Android and Web therefore
|
||||||
derive the completed count and per-session state from the same execution
|
derive the completed count and per-session state from the same execution
|
||||||
identity after synchronization and restart.
|
identity after synchronization and restart.
|
||||||
|
The historical S1 A delivery was recovered onto its original completed
|
||||||
|
`session_id`; Android and Web now both report one completed session out of 24.
|
||||||
|
Generation capture also accepts its valid local continuous duration plans
|
||||||
|
without projecting them as unsupported set targets.
|
||||||
|
|
||||||
The private Programs rollout migrated desktop schema v25 to v26 and Android
|
The private Programs rollout migrated desktop schema v25 to v26 and Android
|
||||||
schema v23 to v24 after verified backups, then installed the signed
|
schema v23 to v24 after verified backups, then installed the signed
|
||||||
|
|
|
||||||
|
|
@ -254,6 +254,11 @@ For Program-owned manual preparations, an acknowledged delivery is republished
|
||||||
while its matching canonical execution evidence is absent on the PC. This
|
while its matching canonical execution evidence is absent on the PC. This
|
||||||
bounded recovery lets Android enrich legacy deliveries with Program provenance
|
bounded recovery lets Android enrich legacy deliveries with Program provenance
|
||||||
and stops automatically when the execution returns through this companion.
|
and stops automatically when the execution returns through this companion.
|
||||||
|
The recovery emission retains the delivery's original acknowledged generation
|
||||||
|
relation and requires exact delivery, revision, execution and Program
|
||||||
|
identities. A current-state Sleep Diary snapshot may initialize a fresh peer
|
||||||
|
from a non-root current revision while retaining the unavailable parent
|
||||||
|
identity for subsequent causal advances.
|
||||||
|
|
||||||
Desktop schema v28 adds terminal `deleted` provenance for a completed session
|
Desktop schema v28 adds terminal `deleted` provenance for a completed session
|
||||||
removed through causal history deletion. A retained older
|
removed through causal history deletion. A retained older
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,11 @@ removes `crypto.randomUUID()` and proves the HTTP-origin-compatible UUIDv4
|
||||||
path. A real Firefox run through the production C server and `trainlog.perf`
|
path. A real Firefox run through the production C server and `trainlog.perf`
|
||||||
creates events, medications and intakes, reloads their durable state, checks a
|
creates events, medications and intakes, reloads their durable state, checks a
|
||||||
responsive viewport and captures console errors and unhandled rejections.
|
responsive viewport and captures console errors and unhandled rejections.
|
||||||
|
Program recovery coverage starts from an acknowledged legacy preparation,
|
||||||
|
repairs the same completed Android `session_id`, publishes one completed
|
||||||
|
execution and verifies Web `1 / 24` presentation. Related regressions cover
|
||||||
|
planned continuous activity capture and fresh-peer Sleep Diary bootstrap from
|
||||||
|
non-root current revisions.
|
||||||
|
|
||||||
`web_analysis` covers empty history, every 7/30/90/all period selector,
|
`web_analysis` covers empty history, every 7/30/90/all period selector,
|
||||||
loaded and unloaded repetitions, continuous duration/distance/speed, explicit
|
loaded and unloaded repetitions, continuous duration/distance/speed, explicit
|
||||||
|
|
|
||||||
|
|
@ -168,6 +168,60 @@ class GenerationTest(unittest.TestCase):
|
||||||
"SELECT acknowledged_at FROM session_preparation_withdrawals",
|
"SELECT acknowledged_at FROM session_preparation_withdrawals",
|
||||||
).fetchone()[0],
|
).fetchone()[0],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_acknowledged_program_recovery_keeps_original_generation_relation(self):
|
||||||
|
delivery = "spd_55555555-5555-4555-8555-555555555555"
|
||||||
|
preparation = "sp_66666666-6666-4666-8666-666666666666"
|
||||||
|
revision = "spr_77777777-7777-4777-8777-777777777777"
|
||||||
|
execution = "se_88888888-8888-4888-8888-888888888888"
|
||||||
|
program = "pg_99999999-9999-4999-8999-999999999999"
|
||||||
|
program_session = "pgs_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
||||||
|
original_generation = "gen_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
|
||||||
|
with closing(sqlite3.connect(self.database)) as db:
|
||||||
|
db.execute("ALTER TABLE session_preparations ADD COLUMN source_program_id TEXT")
|
||||||
|
db.execute("ALTER TABLE session_preparations ADD COLUMN source_program_session_id TEXT")
|
||||||
|
db.execute(
|
||||||
|
"CREATE TABLE program_session_executions(program_session_id TEXT PRIMARY KEY,"
|
||||||
|
"program_id TEXT,session_id TEXT UNIQUE,state TEXT,observed_at TEXT)"
|
||||||
|
)
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO session_preparations VALUES(?,?,?,?,?,?)",
|
||||||
|
(preparation, revision, "acknowledged", None, program, program_session),
|
||||||
|
)
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO session_preparation_deliveries VALUES(?,?,?,?,?,?,?,?)",
|
||||||
|
(delivery, preparation, revision, execution, "acknowledged",
|
||||||
|
"2026-09-21T12:00:00Z", original_generation, "2026-09-21T12:05:00Z"),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
artifacts = (("session-preparations", "trainlog-session-preparations", 2,
|
||||||
|
"session-preparations-v2.json", False, "controlled.py", ()),)
|
||||||
|
payload = {
|
||||||
|
"format": "trainlog-session-preparations", "version": 2,
|
||||||
|
"generated_at": "2026-09-21T12:10:00Z", "withdrawals": [],
|
||||||
|
"deliveries": [{
|
||||||
|
"delivery_id": delivery, "preparation_id": preparation,
|
||||||
|
"revision_id": revision, "execution_session_id": execution,
|
||||||
|
"state": "remote_unknown", "source_program_id": program,
|
||||||
|
"source_program_session_id": program_session,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
def exporter(_tool, _extra, output, _snapshot):
|
||||||
|
output.write_text(json.dumps(payload))
|
||||||
|
with mock.patch.object(generation, "ARTIFACTS", artifacts), \
|
||||||
|
mock.patch.object(generation, "SUPPORTED", {("trainlog-session-preparations", 2)}), \
|
||||||
|
mock.patch.object(generation, "run_export", exporter):
|
||||||
|
generation.capture_desktop(
|
||||||
|
self.database, self.root / "program-recovery", PEER_B, RUN, GEN,
|
||||||
|
)
|
||||||
|
with closing(sqlite3.connect(self.database)) as db:
|
||||||
|
self.assertEqual(
|
||||||
|
("acknowledged", original_generation),
|
||||||
|
db.execute(
|
||||||
|
"SELECT state,generation_id FROM session_preparation_deliveries "
|
||||||
|
"WHERE delivery_id=?", (delivery,),
|
||||||
|
).fetchone(),
|
||||||
|
)
|
||||||
def test_substitution_and_transaction_failure_leave_no_consumption(self):
|
def test_substitution_and_transaction_failure_leave_no_consumption(self):
|
||||||
_,manifest,checksum=self.capture();published=self.publish()
|
_,manifest,checksum=self.capture();published=self.publish()
|
||||||
(published/"history.json").write_text("{}")
|
(published/"history.json").write_text("{}")
|
||||||
|
|
|
||||||
|
|
@ -430,7 +430,34 @@ def capture_desktop(database: Path, owned_root: Path, consumer: str,
|
||||||
(generation, delivery["delivery_id"], delivery["revision_id"]),
|
(generation, delivery["delivery_id"], delivery["revision_id"]),
|
||||||
)
|
)
|
||||||
if cursor.rowcount != 1:
|
if cursor.rowcount != 1:
|
||||||
raise GenerationError("preparation delivery changed during capture")
|
# WHY: Program-provenance recovery republishes an
|
||||||
|
# already acknowledged legacy delivery as
|
||||||
|
# remote_unknown until its canonical execution comes
|
||||||
|
# back. CONTRACT: this exception is allowed only for
|
||||||
|
# the exact immutable delivery/revision/execution and
|
||||||
|
# Program identities captured in the artifact.
|
||||||
|
# INVARIANT: no delivery lifecycle or generation
|
||||||
|
# relation is rewritten by the recovery emission.
|
||||||
|
recovery = db.execute(
|
||||||
|
"SELECT 1 FROM session_preparation_deliveries d "
|
||||||
|
"JOIN session_preparations p ON p.preparation_id=d.preparation_id "
|
||||||
|
"WHERE d.delivery_id=? AND d.revision_id=? "
|
||||||
|
"AND d.execution_session_id=? AND d.state='acknowledged' "
|
||||||
|
"AND p.source_program_id=? AND p.source_program_session_id=? "
|
||||||
|
"AND NOT EXISTS(SELECT 1 FROM program_session_executions x "
|
||||||
|
"WHERE x.program_id=p.source_program_id "
|
||||||
|
"AND x.program_session_id=p.source_program_session_id "
|
||||||
|
"AND x.session_id=d.execution_session_id)",
|
||||||
|
(
|
||||||
|
delivery["delivery_id"],
|
||||||
|
delivery["revision_id"],
|
||||||
|
delivery["execution_session_id"],
|
||||||
|
delivery.get("source_program_id"),
|
||||||
|
delivery.get("source_program_session_id"),
|
||||||
|
),
|
||||||
|
).fetchone()
|
||||||
|
if recovery is None:
|
||||||
|
raise GenerationError("preparation delivery changed during capture")
|
||||||
for withdrawal in captured_preparations["withdrawals"]:
|
for withdrawal in captured_preparations["withdrawals"]:
|
||||||
cursor = db.execute(
|
cursor = db.execute(
|
||||||
"UPDATE session_preparation_withdrawals SET generation_id=? "
|
"UPDATE session_preparation_withdrawals SET generation_id=? "
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue