@learnpack/learnpack 5.0.353 → 5.0.355
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/commands/serve.js +1 -1
- package/lib/scripts/descriptionsS3Backfill.js +142 -22
- package/lib/utils/awsCredentials.d.ts +20 -0
- package/lib/utils/awsCredentials.js +43 -0
- package/lib/utils/descriptions/backfillEvents.d.ts +60 -0
- package/lib/utils/descriptions/backfillEvents.js +107 -0
- package/lib/utils/descriptions/generateCourseDescriptions.d.ts +7 -0
- package/lib/utils/descriptions/generateCourseDescriptions.js +3 -0
- package/lib/utils/descriptions/publishStage.js +14 -3
- package/lib/utils/descriptions/resumePublication.js +17 -2
- package/lib/utils/descriptions/s3Storage.js +13 -6
- package/lib/utils/packageManifest.d.ts +8 -0
- package/lib/utils/packageManifest.js +8 -0
- package/lib/utils/repair/legacyPackageRepair.d.ts +131 -0
- package/lib/utils/repair/legacyPackageRepair.js +492 -0
- package/lib/utils/repair/repairStorage.d.ts +68 -0
- package/lib/utils/repair/repairStorage.js +89 -0
- package/lib/utils/s3/packageManifestBackfill.js +3 -8
- package/lib/utils/s3/packageSourcesAudit.d.ts +75 -0
- package/lib/utils/s3/packageSourcesAudit.js +184 -0
- package/package.json +3 -1
- package/src/commands/serve.ts +1 -1
- package/src/scripts/README.md +244 -0
- package/src/scripts/descriptionsS3Backfill.ts +188 -20
- package/src/utils/awsCredentials.ts +57 -0
- package/src/utils/descriptions/backfillEvents.ts +152 -0
- package/src/utils/descriptions/generateCourseDescriptions.ts +10 -0
- package/src/utils/descriptions/publishStage.ts +394 -382
- package/src/utils/descriptions/resumePublication.ts +217 -200
- package/src/utils/descriptions/s3Storage.ts +214 -206
- package/src/utils/packageManifest.ts +8 -1
- package/src/utils/repair/legacyPackageRepair.ts +731 -0
- package/src/utils/repair/repairStorage.ts +168 -0
- package/src/utils/s3/packageManifestBackfill.ts +771 -776
- package/src/utils/s3/packageSourcesAudit.ts +311 -0
|
@@ -1,200 +1,217 @@
|
|
|
1
|
-
import {
|
|
2
|
-
abandonJournal,
|
|
3
|
-
finalizeJournal,
|
|
4
|
-
incrementAttempts,
|
|
5
|
-
isJournalStale,
|
|
6
|
-
JournalStorage,
|
|
7
|
-
markStage,
|
|
8
|
-
PublishJournal,
|
|
9
|
-
} from "../publishJournal"
|
|
10
|
-
import { PackageManifest } from "../packageManifest"
|
|
11
|
-
import {
|
|
12
|
-
buildPackageManifestUpdatedEvent,
|
|
13
|
-
fetchPackageInfo,
|
|
14
|
-
sendPublishEvent,
|
|
15
|
-
} from "../publishEvents"
|
|
16
|
-
import {
|
|
17
|
-
CourseDescriptionsStorage,
|
|
18
|
-
GenerateCourseDescriptionsOptions,
|
|
19
|
-
} from "./generateCourseDescriptions"
|
|
20
|
-
import { runPublishDescriptionsStage } from "./publishStage"
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Recovery of a publication whose background stage never finished.
|
|
24
|
-
*
|
|
25
|
-
* The stage runs inside the web process, so a dyno restart mid-flight leaves a
|
|
26
|
-
* package published but not described, and — worse — breathecode waiting for a
|
|
27
|
-
* manifest event that was promised and will never arrive. Content-wise the
|
|
28
|
-
* missing descriptions would eventually be picked up by the next publication,
|
|
29
|
-
* but the promised event has no such second chance: emitting it is the reason
|
|
30
|
-
* this exists.
|
|
31
|
-
*/
|
|
32
|
-
|
|
33
|
-
export const MAX_SWEEP_ATTEMPTS = 3
|
|
34
|
-
|
|
35
|
-
/** Only resume publications nobody is working on any more. */
|
|
36
|
-
export const DEFAULT_STALE_AFTER_MS = 15 * 60 * 1000
|
|
37
|
-
|
|
38
|
-
export type ResumeOutcome =
|
|
39
|
-
| "skipped-recent"
|
|
40
|
-
| "skipped-abandoned"
|
|
41
|
-
| "abandoned"
|
|
42
|
-
| "resumed"
|
|
43
|
-
| "nothing-to-do";
|
|
44
|
-
|
|
45
|
-
export type ResumeDeps = {
|
|
46
|
-
journalStorage: JournalStorage;
|
|
47
|
-
publishedStorage: CourseDescriptionsStorage;
|
|
48
|
-
draftStorage: CourseDescriptionsStorage;
|
|
49
|
-
rigobotToken: string;
|
|
50
|
-
breathecodeToken?: string;
|
|
51
|
-
staleAfterMs?: number;
|
|
52
|
-
now?: number;
|
|
53
|
-
/** Injection points for tests. */
|
|
54
|
-
emit?: typeof sendPublishEvent;
|
|
55
|
-
readPackageInfo?: typeof fetchPackageInfo;
|
|
56
|
-
generate?: GenerateCourseDescriptionsOptions["generate"];
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
function isDone(
|
|
60
|
-
journal: PublishJournal,
|
|
61
|
-
stage: "descriptions" | "gcsMirror" | "manifestEvent"
|
|
62
|
-
): boolean {
|
|
63
|
-
return journal.stages[stage].status === "done"
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Whether breathecode is still owed a manifest event: one was promised (the
|
|
68
|
-
* publish event announced `queued`) and it never went out.
|
|
69
|
-
*/
|
|
70
|
-
export function owesManifestEvent(journal: PublishJournal): boolean {
|
|
71
|
-
const promised =
|
|
72
|
-
(
|
|
73
|
-
journal.stages.publishedEvent.meta as
|
|
74
|
-
| { descriptions?: string }
|
|
75
|
-
| undefined
|
|
76
|
-
)?.descriptions === "queued"
|
|
77
|
-
return promised && !isDone(journal, "manifestEvent")
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export async function resumePublication(
|
|
81
|
-
journal: PublishJournal,
|
|
82
|
-
deps: ResumeDeps
|
|
83
|
-
): Promise<ResumeOutcome> {
|
|
84
|
-
const { journalStorage } = deps
|
|
85
|
-
const now = deps.now ?? Date.now()
|
|
86
|
-
|
|
87
|
-
if (journal.abandoned) {
|
|
88
|
-
return "skipped-abandoned"
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// A publication still in flight would race with its own stage.
|
|
92
|
-
if (
|
|
93
|
-
!isJournalStale(journal, deps.staleAfterMs ?? DEFAULT_STALE_AFTER_MS, now)
|
|
94
|
-
) {
|
|
95
|
-
return "skipped-recent"
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const needsDescriptions =
|
|
99
|
-
!isDone(journal, "descriptions") || !isDone(journal, "gcsMirror")
|
|
100
|
-
const needsEvent = owesManifestEvent(journal)
|
|
101
|
-
|
|
102
|
-
if (!needsDescriptions && !needsEvent) {
|
|
103
|
-
// Nothing left that the sweep can do; stage A failures are not its job.
|
|
104
|
-
await finalizeJournal(journalStorage, journal)
|
|
105
|
-
return "nothing-to-do"
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
if (journal.attempts >= MAX_SWEEP_ATTEMPTS) {
|
|
109
|
-
await abandonJournal(journalStorage, journal)
|
|
110
|
-
console.error(
|
|
111
|
-
`[sweep] Giving up on "${journal.courseSlug}" (${journal.publishId}) after ${journal.attempts} attempts`
|
|
112
|
-
)
|
|
113
|
-
return "abandoned"
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
await incrementAttempts(journalStorage, journal)
|
|
117
|
-
|
|
118
|
-
let succeeded = true
|
|
119
|
-
let stats: { generated: number; failed: number; missing: number } | undefined
|
|
120
|
-
|
|
121
|
-
if (needsDescriptions) {
|
|
122
|
-
const outcome = await runPublishDescriptionsStage({
|
|
123
|
-
courseSlug: journal.courseSlug,
|
|
124
|
-
rigobotToken: deps.rigobotToken,
|
|
125
|
-
journal,
|
|
126
|
-
journalStorage,
|
|
127
|
-
publishedStorage: deps.publishedStorage,
|
|
128
|
-
draftStorage: deps.draftStorage,
|
|
129
|
-
generate: deps.generate,
|
|
130
|
-
})
|
|
131
|
-
succeeded = outcome.succeeded
|
|
132
|
-
if (outcome.generation) {
|
|
133
|
-
stats = {
|
|
134
|
-
generated: outcome.generation.generated,
|
|
135
|
-
failed: outcome.generation.failed,
|
|
136
|
-
missing: outcome.generation.missing,
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
if (needsEvent) {
|
|
142
|
-
await emitPendingManifestEvent(journal, deps, succeeded, stats)
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
await finalizeJournal(journalStorage, journal)
|
|
146
|
-
return "resumed"
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
async function emitPendingManifestEvent(
|
|
150
|
-
journal: PublishJournal,
|
|
151
|
-
deps: ResumeDeps,
|
|
152
|
-
succeeded: boolean,
|
|
153
|
-
stats?: { generated: number; failed: number; missing: number }
|
|
154
|
-
): Promise<void> {
|
|
155
|
-
const emit = deps.emit ?? sendPublishEvent
|
|
156
|
-
const readPackageInfo = deps.readPackageInfo ?? fetchPackageInfo
|
|
157
|
-
|
|
158
|
-
if (!deps.breathecodeToken) {
|
|
159
|
-
await markStage(deps.journalStorage, journal, "manifestEvent", "failed", {
|
|
160
|
-
error: "BREATHECODE_SYSTEM_TOKEN is not configured",
|
|
161
|
-
})
|
|
162
|
-
return
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
1
|
+
import {
|
|
2
|
+
abandonJournal,
|
|
3
|
+
finalizeJournal,
|
|
4
|
+
incrementAttempts,
|
|
5
|
+
isJournalStale,
|
|
6
|
+
JournalStorage,
|
|
7
|
+
markStage,
|
|
8
|
+
PublishJournal,
|
|
9
|
+
} from "../publishJournal"
|
|
10
|
+
import { PackageManifest } from "../packageManifest"
|
|
11
|
+
import {
|
|
12
|
+
buildPackageManifestUpdatedEvent,
|
|
13
|
+
fetchPackageInfo,
|
|
14
|
+
sendPublishEvent,
|
|
15
|
+
} from "../publishEvents"
|
|
16
|
+
import {
|
|
17
|
+
CourseDescriptionsStorage,
|
|
18
|
+
GenerateCourseDescriptionsOptions,
|
|
19
|
+
} from "./generateCourseDescriptions"
|
|
20
|
+
import { runPublishDescriptionsStage } from "./publishStage"
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Recovery of a publication whose background stage never finished.
|
|
24
|
+
*
|
|
25
|
+
* The stage runs inside the web process, so a dyno restart mid-flight leaves a
|
|
26
|
+
* package published but not described, and — worse — breathecode waiting for a
|
|
27
|
+
* manifest event that was promised and will never arrive. Content-wise the
|
|
28
|
+
* missing descriptions would eventually be picked up by the next publication,
|
|
29
|
+
* but the promised event has no such second chance: emitting it is the reason
|
|
30
|
+
* this exists.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
export const MAX_SWEEP_ATTEMPTS = 3
|
|
34
|
+
|
|
35
|
+
/** Only resume publications nobody is working on any more. */
|
|
36
|
+
export const DEFAULT_STALE_AFTER_MS = 15 * 60 * 1000
|
|
37
|
+
|
|
38
|
+
export type ResumeOutcome =
|
|
39
|
+
| "skipped-recent"
|
|
40
|
+
| "skipped-abandoned"
|
|
41
|
+
| "abandoned"
|
|
42
|
+
| "resumed"
|
|
43
|
+
| "nothing-to-do";
|
|
44
|
+
|
|
45
|
+
export type ResumeDeps = {
|
|
46
|
+
journalStorage: JournalStorage;
|
|
47
|
+
publishedStorage: CourseDescriptionsStorage;
|
|
48
|
+
draftStorage: CourseDescriptionsStorage;
|
|
49
|
+
rigobotToken: string;
|
|
50
|
+
breathecodeToken?: string;
|
|
51
|
+
staleAfterMs?: number;
|
|
52
|
+
now?: number;
|
|
53
|
+
/** Injection points for tests. */
|
|
54
|
+
emit?: typeof sendPublishEvent;
|
|
55
|
+
readPackageInfo?: typeof fetchPackageInfo;
|
|
56
|
+
generate?: GenerateCourseDescriptionsOptions["generate"];
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function isDone(
|
|
60
|
+
journal: PublishJournal,
|
|
61
|
+
stage: "descriptions" | "gcsMirror" | "manifestEvent"
|
|
62
|
+
): boolean {
|
|
63
|
+
return journal.stages[stage].status === "done"
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Whether breathecode is still owed a manifest event: one was promised (the
|
|
68
|
+
* publish event announced `queued`) and it never went out.
|
|
69
|
+
*/
|
|
70
|
+
export function owesManifestEvent(journal: PublishJournal): boolean {
|
|
71
|
+
const promised =
|
|
72
|
+
(
|
|
73
|
+
journal.stages.publishedEvent.meta as
|
|
74
|
+
| { descriptions?: string }
|
|
75
|
+
| undefined
|
|
76
|
+
)?.descriptions === "queued"
|
|
77
|
+
return promised && !isDone(journal, "manifestEvent")
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function resumePublication(
|
|
81
|
+
journal: PublishJournal,
|
|
82
|
+
deps: ResumeDeps
|
|
83
|
+
): Promise<ResumeOutcome> {
|
|
84
|
+
const { journalStorage } = deps
|
|
85
|
+
const now = deps.now ?? Date.now()
|
|
86
|
+
|
|
87
|
+
if (journal.abandoned) {
|
|
88
|
+
return "skipped-abandoned"
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// A publication still in flight would race with its own stage.
|
|
92
|
+
if (
|
|
93
|
+
!isJournalStale(journal, deps.staleAfterMs ?? DEFAULT_STALE_AFTER_MS, now)
|
|
94
|
+
) {
|
|
95
|
+
return "skipped-recent"
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const needsDescriptions =
|
|
99
|
+
!isDone(journal, "descriptions") || !isDone(journal, "gcsMirror")
|
|
100
|
+
const needsEvent = owesManifestEvent(journal)
|
|
101
|
+
|
|
102
|
+
if (!needsDescriptions && !needsEvent) {
|
|
103
|
+
// Nothing left that the sweep can do; stage A failures are not its job.
|
|
104
|
+
await finalizeJournal(journalStorage, journal)
|
|
105
|
+
return "nothing-to-do"
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (journal.attempts >= MAX_SWEEP_ATTEMPTS) {
|
|
109
|
+
await abandonJournal(journalStorage, journal)
|
|
110
|
+
console.error(
|
|
111
|
+
`[sweep] Giving up on "${journal.courseSlug}" (${journal.publishId}) after ${journal.attempts} attempts`
|
|
112
|
+
)
|
|
113
|
+
return "abandoned"
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
await incrementAttempts(journalStorage, journal)
|
|
117
|
+
|
|
118
|
+
let succeeded = true
|
|
119
|
+
let stats: { generated: number; failed: number; missing: number } | undefined
|
|
120
|
+
|
|
121
|
+
if (needsDescriptions) {
|
|
122
|
+
const outcome = await runPublishDescriptionsStage({
|
|
123
|
+
courseSlug: journal.courseSlug,
|
|
124
|
+
rigobotToken: deps.rigobotToken,
|
|
125
|
+
journal,
|
|
126
|
+
journalStorage,
|
|
127
|
+
publishedStorage: deps.publishedStorage,
|
|
128
|
+
draftStorage: deps.draftStorage,
|
|
129
|
+
generate: deps.generate,
|
|
130
|
+
})
|
|
131
|
+
succeeded = outcome.succeeded
|
|
132
|
+
if (outcome.generation) {
|
|
133
|
+
stats = {
|
|
134
|
+
generated: outcome.generation.generated,
|
|
135
|
+
failed: outcome.generation.failed,
|
|
136
|
+
missing: outcome.generation.missing,
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (needsEvent) {
|
|
142
|
+
await emitPendingManifestEvent(journal, deps, succeeded, stats)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
await finalizeJournal(journalStorage, journal)
|
|
146
|
+
return "resumed"
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function emitPendingManifestEvent(
|
|
150
|
+
journal: PublishJournal,
|
|
151
|
+
deps: ResumeDeps,
|
|
152
|
+
succeeded: boolean,
|
|
153
|
+
stats?: { generated: number; failed: number; missing: number }
|
|
154
|
+
): Promise<void> {
|
|
155
|
+
const emit = deps.emit ?? sendPublishEvent
|
|
156
|
+
const readPackageInfo = deps.readPackageInfo ?? fetchPackageInfo
|
|
157
|
+
|
|
158
|
+
if (!deps.breathecodeToken) {
|
|
159
|
+
await markStage(deps.journalStorage, journal, "manifestEvent", "failed", {
|
|
160
|
+
error: "BREATHECODE_SYSTEM_TOKEN is not configured",
|
|
161
|
+
})
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Enriching the payload must never prevent the delivery: this event is the
|
|
166
|
+
// one breathecode has been waiting for since the publication stalled, and
|
|
167
|
+
// both the manifest and the package info are optional in the contract.
|
|
168
|
+
let manifest: PackageManifest | null = null
|
|
169
|
+
let packageInfo: Record<string, unknown> | null = null
|
|
170
|
+
try {
|
|
171
|
+
if (deps.publishedStorage.readManifest) {
|
|
172
|
+
manifest = await deps.publishedStorage.readManifest(journal.courseSlug)
|
|
173
|
+
}
|
|
174
|
+
} catch (error) {
|
|
175
|
+
console.error(
|
|
176
|
+
`[sweep] Could not read the manifest of "${journal.courseSlug}", reporting without it:`,
|
|
177
|
+
(error as Error).message
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
packageInfo = await readPackageInfo(journal.courseSlug, deps.rigobotToken)
|
|
183
|
+
} catch (error) {
|
|
184
|
+
console.error(
|
|
185
|
+
`[sweep] Could not read the package of "${journal.courseSlug}", reporting without it:`,
|
|
186
|
+
(error as Error).message
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
const delivered = await emit(
|
|
192
|
+
buildPackageManifestUpdatedEvent(
|
|
193
|
+
{
|
|
194
|
+
publishId: journal.publishId,
|
|
195
|
+
courseSlug: journal.courseSlug,
|
|
196
|
+
packageInfo,
|
|
197
|
+
manifest,
|
|
198
|
+
},
|
|
199
|
+
succeeded ? "success" : "failed",
|
|
200
|
+
stats
|
|
201
|
+
),
|
|
202
|
+
deps.breathecodeToken
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
await markStage(
|
|
206
|
+
deps.journalStorage,
|
|
207
|
+
journal,
|
|
208
|
+
"manifestEvent",
|
|
209
|
+
delivered ? "done" : "failed",
|
|
210
|
+
{ error: delivered ? undefined : "delivery failed" }
|
|
211
|
+
)
|
|
212
|
+
} catch (error) {
|
|
213
|
+
await markStage(deps.journalStorage, journal, "manifestEvent", "failed", {
|
|
214
|
+
error: (error as Error).message,
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
}
|