postgres.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439
  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. * Insert a pending chunk before sending it to object storage.
  125. *
  126. * @param {string} projectId
  127. * @param {Chunk} chunk
  128. */
  129. async function insertPendingChunk(projectId, chunk) {
  130. assert.postgresId(projectId, 'bad projectId')
  131. const result = await knex.first(
  132. knex.raw("nextval('chunks_id_seq'::regclass)::integer as chunkid")
  133. )
  134. const chunkId = result.chunkid
  135. await knex('pending_chunks').insert({
  136. id: chunkId,
  137. doc_id: parseInt(projectId, 10),
  138. end_version: chunk.getEndVersion(),
  139. start_version: chunk.getStartVersion(),
  140. end_timestamp: chunk.getEndTimestamp(),
  141. })
  142. return chunkId.toString()
  143. }
  144. /**
  145. * Record that a new chunk was created.
  146. *
  147. * @param {string} projectId
  148. * @param {Chunk} chunk
  149. * @param {string} chunkId
  150. * @param {object} opts
  151. * @param {Date} [opts.earliestChangeTimestamp]
  152. * @param {string} [opts.oldChunkId]
  153. */
  154. async function confirmCreate(projectId, chunk, chunkId, opts = {}) {
  155. assert.postgresId(projectId, 'bad projectId')
  156. await knex.transaction(async tx => {
  157. if (opts.oldChunkId != null) {
  158. await _assertChunkIsNotClosed(tx, projectId, opts.oldChunkId)
  159. await _closeChunk(tx, projectId, opts.oldChunkId)
  160. }
  161. await Promise.all([
  162. _deletePendingChunk(tx, projectId, chunkId),
  163. _insertChunk(tx, projectId, chunk, chunkId),
  164. ])
  165. await updateProjectRecord(
  166. // The history id in Mongo is an integer for Postgres projects
  167. parseInt(projectId, 10),
  168. chunk,
  169. opts.earliestChangeTimestamp
  170. )
  171. })
  172. }
  173. /**
  174. * Record that a chunk was replaced by a new one.
  175. *
  176. * @param {string} projectId
  177. * @param {string} oldChunkId
  178. * @param {Chunk} newChunk
  179. * @param {string} newChunkId
  180. */
  181. async function confirmUpdate(
  182. projectId,
  183. oldChunkId,
  184. newChunk,
  185. newChunkId,
  186. opts = {}
  187. ) {
  188. assert.postgresId(projectId, 'bad projectId')
  189. await knex.transaction(async tx => {
  190. await _assertChunkIsNotClosed(tx, projectId, oldChunkId)
  191. await _deleteChunks(tx, { doc_id: projectId, id: oldChunkId })
  192. await Promise.all([
  193. _deletePendingChunk(tx, projectId, newChunkId),
  194. _insertChunk(tx, projectId, newChunk, newChunkId),
  195. ])
  196. await updateProjectRecord(
  197. // The history id in Mongo is an integer for Postgres projects
  198. parseInt(projectId, 10),
  199. newChunk,
  200. opts.earliestChangeTimestamp
  201. )
  202. })
  203. }
  204. /**
  205. * Delete a pending chunk
  206. *
  207. * @param {Knex} tx
  208. * @param {string} projectId
  209. * @param {string} chunkId
  210. */
  211. async function _deletePendingChunk(tx, projectId, chunkId) {
  212. await tx('pending_chunks')
  213. .where({
  214. doc_id: parseInt(projectId, 10),
  215. id: parseInt(chunkId, 10),
  216. })
  217. .del()
  218. }
  219. /**
  220. * Adds an active chunk
  221. *
  222. * @param {Knex} tx
  223. * @param {string} projectId
  224. * @param {Chunk} chunk
  225. * @param {string} chunkId
  226. */
  227. async function _insertChunk(tx, projectId, chunk, chunkId) {
  228. const startVersion = chunk.getStartVersion()
  229. const endVersion = chunk.getEndVersion()
  230. try {
  231. await tx('chunks').insert({
  232. id: parseInt(chunkId, 10),
  233. doc_id: parseInt(projectId, 10),
  234. start_version: startVersion,
  235. end_version: endVersion,
  236. end_timestamp: chunk.getEndTimestamp(),
  237. })
  238. } catch (err) {
  239. if (
  240. err instanceof Error &&
  241. 'code' in err &&
  242. err.code === DUPLICATE_KEY_ERROR_CODE
  243. ) {
  244. throw new ChunkVersionConflictError(
  245. 'chunk start or end version is not unique',
  246. { projectId, chunkId, startVersion, endVersion }
  247. )
  248. }
  249. throw err
  250. }
  251. }
  252. /**
  253. * Check that a chunk is not closed
  254. *
  255. * This is used to synchronize chunk creations and extensions.
  256. *
  257. * @param {Knex} tx
  258. * @param {string} projectId
  259. * @param {string} chunkId
  260. */
  261. async function _assertChunkIsNotClosed(tx, projectId, chunkId) {
  262. const record = await tx('chunks')
  263. .forUpdate()
  264. .select('closed')
  265. .where('doc_id', parseInt(projectId, 10))
  266. .where('id', parseInt(chunkId, 10))
  267. .first()
  268. if (!record) {
  269. throw new ChunkVersionConflictError('unable to close chunk: not found', {
  270. projectId,
  271. chunkId,
  272. })
  273. }
  274. if (record.closed) {
  275. throw new ChunkVersionConflictError(
  276. 'unable to close chunk: already closed',
  277. {
  278. projectId,
  279. chunkId,
  280. }
  281. )
  282. }
  283. }
  284. /**
  285. * Close a chunk
  286. *
  287. * A closed chunk can no longer be extended.
  288. *
  289. * @param {Knex} tx
  290. * @param {string} projectId
  291. * @param {string} chunkId
  292. */
  293. async function _closeChunk(tx, projectId, chunkId) {
  294. await tx('chunks')
  295. .update({ closed: true })
  296. .where('doc_id', parseInt(projectId, 10))
  297. .where('id', parseInt(chunkId, 10))
  298. }
  299. /**
  300. * Delete a chunk.
  301. *
  302. * @param {string} projectId
  303. * @param {string} chunkId
  304. */
  305. async function deleteChunk(projectId, chunkId) {
  306. assert.postgresId(projectId, 'bad projectId')
  307. assert.integer(chunkId, 'bad chunkId')
  308. await _deleteChunks(knex, {
  309. doc_id: parseInt(projectId, 10),
  310. id: parseInt(chunkId, 10),
  311. })
  312. }
  313. /**
  314. * Delete all of a project's chunks
  315. *
  316. * @param {string} projectId
  317. */
  318. async function deleteProjectChunks(projectId) {
  319. assert.postgresId(projectId, 'bad projectId')
  320. await knex.transaction(async tx => {
  321. await _deleteChunks(knex, { doc_id: parseInt(projectId, 10) })
  322. })
  323. }
  324. /**
  325. * Delete many chunks
  326. *
  327. * @param {Knex} tx
  328. * @param {any} whereClause
  329. */
  330. async function _deleteChunks(tx, whereClause) {
  331. const rows = await tx('chunks').where(whereClause).del().returning('*')
  332. if (rows.length === 0) {
  333. return
  334. }
  335. const oldChunks = rows.map(row => ({
  336. doc_id: row.doc_id,
  337. chunk_id: row.id,
  338. start_version: row.start_version,
  339. end_version: row.end_version,
  340. end_timestamp: row.end_timestamp,
  341. deleted_at: tx.fn.now(),
  342. }))
  343. await tx('old_chunks').insert(oldChunks)
  344. }
  345. /**
  346. * Get a batch of old chunks for deletion
  347. *
  348. * @param {number} count
  349. * @param {number} minAgeSecs
  350. */
  351. async function getOldChunksBatch(count, minAgeSecs) {
  352. const maxDeletedAt = new Date(Date.now() - minAgeSecs * 1000)
  353. const records = await knex('old_chunks')
  354. .whereNull('deleted_at')
  355. .orWhere('deleted_at', '<', maxDeletedAt)
  356. .orderBy('chunk_id')
  357. .limit(count)
  358. return records.map(oldChunk => ({
  359. projectId: oldChunk.doc_id.toString(),
  360. chunkId: oldChunk.chunk_id.toString(),
  361. }))
  362. }
  363. /**
  364. * Delete a batch of old chunks from the database
  365. *
  366. * @param {string[]} chunkIds
  367. */
  368. async function deleteOldChunks(chunkIds) {
  369. await knex('old_chunks')
  370. .whereIn(
  371. 'chunk_id',
  372. chunkIds.map(id => parseInt(id, 10))
  373. )
  374. .del()
  375. }
  376. /**
  377. * Generate a new project id
  378. */
  379. async function generateProjectId() {
  380. const record = await knex.first(
  381. knex.raw("nextval('docs_id_seq'::regclass)::integer as doc_id")
  382. )
  383. return record.doc_id.toString()
  384. }
  385. async function resolveHistoryIdToMongoProjectId(projectId) {
  386. return await lookupMongoProjectIdFromHistoryId(parseInt(projectId, 10))
  387. }
  388. module.exports = {
  389. getLatestChunk,
  390. getChunkForVersion,
  391. getChunkForTimestamp,
  392. getProjectChunkIds,
  393. getProjectChunks,
  394. insertPendingChunk,
  395. confirmCreate,
  396. confirmUpdate,
  397. deleteChunk,
  398. deleteProjectChunks,
  399. getOldChunksBatch,
  400. deleteOldChunks,
  401. generateProjectId,
  402. resolveHistoryIdToMongoProjectId,
  403. }