postgres.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481
  1. // @ts-check
  2. const { Chunk } = require('overleaf-editor-core')
  3. const assert = require('../assert')
  4. const knex = require('../knex')
  5. const knexReadOnly = require('../knex_read_only')
  6. const { ChunkVersionConflictError } = require('./errors')
  7. const {
  8. updateProjectRecord,
  9. lookupMongoProjectIdFromHistoryId,
  10. } = require('./mongo')
  11. const DUPLICATE_KEY_ERROR_CODE = '23505'
  12. /**
  13. * @import { Knex } from 'knex'
  14. */
  15. /**
  16. * Get the latest chunk's metadata from the database
  17. * @param {string} projectId
  18. * @param {Object} [opts]
  19. * @param {boolean} [opts.readOnly]
  20. */
  21. async function getLatestChunk(projectId, opts = {}) {
  22. assert.postgresId(projectId, 'bad projectId')
  23. const { readOnly = false } = opts
  24. const record = await (readOnly ? knexReadOnly : knex)('chunks')
  25. .where('doc_id', parseInt(projectId, 10))
  26. .orderBy('end_version', 'desc')
  27. .first()
  28. if (record == null) {
  29. return null
  30. }
  31. return chunkFromRecord(record)
  32. }
  33. /**
  34. * Get the metadata for the chunk that contains the given version.
  35. *
  36. * @param {string} projectId
  37. * @param {number} version
  38. * @param {object} [opts]
  39. * @param {boolean} [opts.preferNewer] - If the version is at the boundary of
  40. * two chunks, return the newer chunk.
  41. */
  42. async function getChunkForVersion(projectId, version, opts = {}) {
  43. assert.postgresId(projectId, 'bad projectId')
  44. const record = await knex('chunks')
  45. .where('doc_id', parseInt(projectId, 10))
  46. .where('start_version', '<=', version)
  47. .where('end_version', '>=', version)
  48. .orderBy('end_version', opts.preferNewer ? 'desc' : 'asc')
  49. .first()
  50. if (!record) {
  51. throw new Chunk.VersionNotFoundError(projectId, version)
  52. }
  53. return chunkFromRecord(record)
  54. }
  55. /**
  56. * Get the metadata for the chunk that contains the version that was current at
  57. * the given timestamp.
  58. *
  59. * @param {string} projectId
  60. * @param {Date} timestamp
  61. */
  62. async function getChunkForTimestamp(projectId, timestamp) {
  63. assert.postgresId(projectId, 'bad projectId')
  64. // This query will find the latest chunk after the timestamp (query orders
  65. // in reverse chronological order), OR the latest chunk
  66. // This accounts for the case where the timestamp is ahead of the chunk's
  67. // timestamp and therefore will not return any results
  68. const whereAfterEndTimestampOrLatestChunk = knex.raw(
  69. 'end_timestamp >= ? ' +
  70. 'OR id = ( ' +
  71. 'SELECT id FROM chunks ' +
  72. 'WHERE doc_id = ? ' +
  73. 'ORDER BY end_version desc LIMIT 1' +
  74. ')',
  75. [timestamp, parseInt(projectId, 10)]
  76. )
  77. const record = await knex('chunks')
  78. .where('doc_id', parseInt(projectId, 10))
  79. .where(whereAfterEndTimestampOrLatestChunk)
  80. .orderBy('end_version')
  81. .first()
  82. if (!record) {
  83. throw new Chunk.BeforeTimestampNotFoundError(projectId, timestamp)
  84. }
  85. return chunkFromRecord(record)
  86. }
  87. /**
  88. * Build a chunk metadata object from the database record
  89. */
  90. function chunkFromRecord(record) {
  91. return {
  92. id: record.id.toString(),
  93. startVersion: record.start_version,
  94. endVersion: record.end_version,
  95. endTimestamp: record.end_timestamp,
  96. }
  97. }
  98. /**
  99. * Get all of a project's chunk ids
  100. *
  101. * @param {string} projectId
  102. */
  103. async function getProjectChunkIds(projectId) {
  104. assert.postgresId(projectId, 'bad projectId')
  105. const records = await knex('chunks')
  106. .select('id')
  107. .where('doc_id', parseInt(projectId, 10))
  108. return records.map(record => record.id)
  109. }
  110. /**
  111. * Get all of a projects chunks directly
  112. *
  113. * @param {string} projectId
  114. */
  115. async function getProjectChunks(projectId) {
  116. assert.postgresId(projectId, 'bad projectId')
  117. const records = await knex('chunks')
  118. .select()
  119. .where('doc_id', parseInt(projectId, 10))
  120. .orderBy('end_version')
  121. return records.map(chunkFromRecord)
  122. }
  123. /**
  124. * Copy the data structures for a given project.
  125. * @param {string} sourceProjectId
  126. * @param {string} targetProjectId
  127. */
  128. async function clone(sourceProjectId, targetProjectId) {
  129. assert.postgresId(targetProjectId, 'bad target projectId')
  130. assert.postgresId(sourceProjectId, 'bad source projectId')
  131. const cursor = knex('chunks')
  132. .select()
  133. .where('doc_id', parseInt(sourceProjectId, 10))
  134. .stream()
  135. const chunkIds = new Map()
  136. const batch = []
  137. async function flushBatch() {
  138. const newIds = await knex.raw(
  139. "SELECT nextval('chunks_id_seq'::regclass)::integer AS chunk_id FROM generate_series(1, ?)",
  140. batch.length
  141. )
  142. const newRecords = []
  143. for (const [i, chunk] of batch.entries()) {
  144. const newId = newIds.rows[i].chunk_id
  145. chunkIds.set(chunk.id.toString(), newId.toString())
  146. newRecords.push({
  147. ...chunk,
  148. id: newId,
  149. doc_id: parseInt(targetProjectId, 10),
  150. })
  151. }
  152. await knex('chunks').insert(newRecords)
  153. batch.length = 0
  154. }
  155. for await (const chunk of cursor) {
  156. batch.push(chunk)
  157. if (batch.length > 100) await flushBatch()
  158. }
  159. if (batch.length > 0) await flushBatch()
  160. return chunkIds
  161. }
  162. /**
  163. * Insert a pending chunk before sending it to object storage.
  164. *
  165. * @param {string} projectId
  166. * @param {Chunk} chunk
  167. */
  168. async function insertPendingChunk(projectId, chunk) {
  169. assert.postgresId(projectId, 'bad projectId')
  170. const result = await knex.first(
  171. knex.raw("nextval('chunks_id_seq'::regclass)::integer as chunkid")
  172. )
  173. const chunkId = result.chunkid
  174. await knex('pending_chunks').insert({
  175. id: chunkId,
  176. doc_id: parseInt(projectId, 10),
  177. end_version: chunk.getEndVersion(),
  178. start_version: chunk.getStartVersion(),
  179. end_timestamp: chunk.getEndTimestamp(),
  180. })
  181. return chunkId.toString()
  182. }
  183. /**
  184. * Record that a new chunk was created.
  185. *
  186. * @param {string} projectId
  187. * @param {Chunk} chunk
  188. * @param {string} chunkId
  189. * @param {object} opts
  190. * @param {Date} [opts.earliestChangeTimestamp]
  191. * @param {string} [opts.oldChunkId]
  192. */
  193. async function confirmCreate(projectId, chunk, chunkId, opts = {}) {
  194. assert.postgresId(projectId, 'bad projectId')
  195. await knex.transaction(async tx => {
  196. if (opts.oldChunkId != null) {
  197. await _assertChunkIsNotClosed(tx, projectId, opts.oldChunkId)
  198. await _closeChunk(tx, projectId, opts.oldChunkId)
  199. }
  200. await Promise.all([
  201. _deletePendingChunk(tx, projectId, chunkId),
  202. _insertChunk(tx, projectId, chunk, chunkId),
  203. ])
  204. await updateProjectRecord(
  205. // The history id in Mongo is an integer for Postgres projects
  206. parseInt(projectId, 10),
  207. chunk,
  208. opts.earliestChangeTimestamp
  209. )
  210. })
  211. }
  212. /**
  213. * Record that a chunk was replaced by a new one.
  214. *
  215. * @param {string} projectId
  216. * @param {string} oldChunkId
  217. * @param {Chunk} newChunk
  218. * @param {string} newChunkId
  219. */
  220. async function confirmUpdate(
  221. projectId,
  222. oldChunkId,
  223. newChunk,
  224. newChunkId,
  225. opts = {}
  226. ) {
  227. assert.postgresId(projectId, 'bad projectId')
  228. await knex.transaction(async tx => {
  229. await _assertChunkIsNotClosed(tx, projectId, oldChunkId)
  230. await _deleteChunks(tx, { doc_id: projectId, id: oldChunkId })
  231. await Promise.all([
  232. _deletePendingChunk(tx, projectId, newChunkId),
  233. _insertChunk(tx, projectId, newChunk, newChunkId),
  234. ])
  235. await updateProjectRecord(
  236. // The history id in Mongo is an integer for Postgres projects
  237. parseInt(projectId, 10),
  238. newChunk,
  239. opts.earliestChangeTimestamp
  240. )
  241. })
  242. }
  243. /**
  244. * Delete a pending chunk
  245. *
  246. * @param {Knex} tx
  247. * @param {string} projectId
  248. * @param {string} chunkId
  249. */
  250. async function _deletePendingChunk(tx, projectId, chunkId) {
  251. await tx('pending_chunks')
  252. .where({
  253. doc_id: parseInt(projectId, 10),
  254. id: parseInt(chunkId, 10),
  255. })
  256. .del()
  257. }
  258. /**
  259. * Adds an active chunk
  260. *
  261. * @param {Knex} tx
  262. * @param {string} projectId
  263. * @param {Chunk} chunk
  264. * @param {string} chunkId
  265. */
  266. async function _insertChunk(tx, projectId, chunk, chunkId) {
  267. const startVersion = chunk.getStartVersion()
  268. const endVersion = chunk.getEndVersion()
  269. try {
  270. await tx('chunks').insert({
  271. id: parseInt(chunkId, 10),
  272. doc_id: parseInt(projectId, 10),
  273. start_version: startVersion,
  274. end_version: endVersion,
  275. end_timestamp: chunk.getEndTimestamp(),
  276. })
  277. } catch (err) {
  278. if (
  279. err instanceof Error &&
  280. 'code' in err &&
  281. err.code === DUPLICATE_KEY_ERROR_CODE
  282. ) {
  283. throw new ChunkVersionConflictError(
  284. 'chunk start or end version is not unique',
  285. { projectId, chunkId, startVersion, endVersion }
  286. )
  287. }
  288. throw err
  289. }
  290. }
  291. /**
  292. * Check that a chunk is not closed
  293. *
  294. * This is used to synchronize chunk creations and extensions.
  295. *
  296. * @param {Knex} tx
  297. * @param {string} projectId
  298. * @param {string} chunkId
  299. */
  300. async function _assertChunkIsNotClosed(tx, projectId, chunkId) {
  301. const record = await tx('chunks')
  302. .forUpdate()
  303. .select('closed')
  304. .where('doc_id', parseInt(projectId, 10))
  305. .where('id', parseInt(chunkId, 10))
  306. .first()
  307. if (!record) {
  308. throw new ChunkVersionConflictError('unable to close chunk: not found', {
  309. projectId,
  310. chunkId,
  311. })
  312. }
  313. if (record.closed) {
  314. throw new ChunkVersionConflictError(
  315. 'unable to close chunk: already closed',
  316. {
  317. projectId,
  318. chunkId,
  319. }
  320. )
  321. }
  322. }
  323. /**
  324. * Close a chunk
  325. *
  326. * A closed chunk can no longer be extended.
  327. *
  328. * @param {Knex} tx
  329. * @param {string} projectId
  330. * @param {string} chunkId
  331. */
  332. async function _closeChunk(tx, projectId, chunkId) {
  333. await tx('chunks')
  334. .update({ closed: true })
  335. .where('doc_id', parseInt(projectId, 10))
  336. .where('id', parseInt(chunkId, 10))
  337. }
  338. /**
  339. * Delete a chunk.
  340. *
  341. * @param {string} projectId
  342. * @param {string} chunkId
  343. */
  344. async function deleteChunk(projectId, chunkId) {
  345. assert.postgresId(projectId, 'bad projectId')
  346. assert.chunkId(chunkId, 'bad chunkId')
  347. await _deleteChunks(knex, {
  348. doc_id: parseInt(projectId, 10),
  349. id: parseInt(chunkId, 10),
  350. })
  351. }
  352. /**
  353. * Delete all of a project's chunks
  354. *
  355. * @param {string} projectId
  356. */
  357. async function deleteProjectChunks(projectId) {
  358. assert.postgresId(projectId, 'bad projectId')
  359. await knex.transaction(async tx => {
  360. await _deleteChunks(knex, { doc_id: parseInt(projectId, 10) })
  361. })
  362. }
  363. /**
  364. * Delete many chunks
  365. *
  366. * @param {Knex} tx
  367. * @param {any} whereClause
  368. */
  369. async function _deleteChunks(tx, whereClause) {
  370. const rows = await tx('chunks').where(whereClause).del().returning('*')
  371. if (rows.length === 0) {
  372. return
  373. }
  374. const oldChunks = rows.map(row => ({
  375. doc_id: row.doc_id,
  376. chunk_id: row.id,
  377. start_version: row.start_version,
  378. end_version: row.end_version,
  379. end_timestamp: row.end_timestamp,
  380. deleted_at: tx.fn.now(),
  381. }))
  382. await tx('old_chunks').insert(oldChunks)
  383. }
  384. /**
  385. * Get a batch of old chunks for deletion
  386. *
  387. * @param {number} count
  388. * @param {number} minAgeSecs
  389. */
  390. async function getOldChunksBatch(count, minAgeSecs) {
  391. const maxDeletedAt = new Date(Date.now() - minAgeSecs * 1000)
  392. const records = await knex('old_chunks')
  393. .whereNull('deleted_at')
  394. .orWhere('deleted_at', '<', maxDeletedAt)
  395. .orderBy('chunk_id')
  396. .limit(count)
  397. return records.map(oldChunk => ({
  398. projectId: oldChunk.doc_id.toString(),
  399. chunkId: oldChunk.chunk_id.toString(),
  400. }))
  401. }
  402. /**
  403. * Delete a batch of old chunks from the database
  404. *
  405. * @param {string[]} chunkIds
  406. */
  407. async function deleteOldChunks(chunkIds) {
  408. await knex('old_chunks')
  409. .whereIn(
  410. 'chunk_id',
  411. chunkIds.map(id => parseInt(id, 10))
  412. )
  413. .del()
  414. }
  415. /**
  416. * Generate a new project id
  417. */
  418. async function generateProjectId() {
  419. const record = await knex.first(
  420. knex.raw("nextval('docs_id_seq'::regclass)::integer as doc_id")
  421. )
  422. return record.doc_id.toString()
  423. }
  424. async function resolveHistoryIdToMongoProjectId(projectId) {
  425. return await lookupMongoProjectIdFromHistoryId(parseInt(projectId, 10))
  426. }
  427. module.exports = {
  428. clone,
  429. getLatestChunk,
  430. getChunkForVersion,
  431. getChunkForTimestamp,
  432. getProjectChunkIds,
  433. getProjectChunks,
  434. insertPendingChunk,
  435. confirmCreate,
  436. confirmUpdate,
  437. deleteChunk,
  438. deleteProjectChunks,
  439. getOldChunksBatch,
  440. deleteOldChunks,
  441. generateProjectId,
  442. resolveHistoryIdToMongoProjectId,
  443. }