UBER-1198: Upgrade to mongo 7 (#4472)

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev 2024-01-30 14:47:54 +07:00 committed by GitHub
parent 54f167805e
commit 6996945e48
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 197 additions and 1115 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
version: "3" version: "3"
services: services:
mongodb: mongodb:
image: mongo image: 'mongo:7-jammy'
container_name: mongodb container_name: mongodb
environment: environment:
- PUID=1000 - PUID=1000

View File

@ -121,7 +121,7 @@
"got": "^11.8.3", "got": "^11.8.3",
"libphonenumber-js": "^1.9.46", "libphonenumber-js": "^1.9.46",
"mime-types": "~2.1.34", "mime-types": "~2.1.34",
"mongodb": "^4.11.0", "mongodb": "^6.3.0",
"ws": "^8.10.0", "ws": "^8.10.0",
"xml2js": "~0.4.23" "xml2js": "~0.4.23"
} }

View File

@ -566,8 +566,8 @@ export async function fixSkills (
tag: t._id tag: t._id
})) as TagReference[] })) as TagReference[]
const ids = references.map((r) => r._id) const ids = references.map((r) => r._id)
await db.collection(DOMAIN_TAGS).deleteMany({ _id: { $in: ids } }) await db.collection<Doc>(DOMAIN_TAGS).deleteMany({ _id: { $in: ids } })
await db.collection(DOMAIN_TAGS).deleteOne({ _id: t._id }) await db.collection<Doc>(DOMAIN_TAGS).deleteOne({ _id: t._id })
} }
} }
await fixCount() await fixCount()
@ -588,8 +588,8 @@ export async function fixSkills (
tag: t._id tag: t._id
})) as TagReference[] })) as TagReference[]
const ids = references.map((r) => r._id) const ids = references.map((r) => r._id)
await db.collection(DOMAIN_TAGS).deleteMany({ _id: { $in: ids } }) await db.collection<Doc>(DOMAIN_TAGS).deleteMany({ _id: { $in: ids } })
await db.collection(DOMAIN_TAGS).deleteOne({ _id: t._id }) await db.collection<Doc>(DOMAIN_TAGS).deleteOne({ _id: t._id })
} }
console.log('DONE 6 STEP') console.log('DONE 6 STEP')
} }
@ -610,8 +610,8 @@ export async function fixSkills (
tag: t._id tag: t._id
})) as TagReference[] })) as TagReference[]
const ids = references.map((r) => r._id) const ids = references.map((r) => r._id)
await db.collection(DOMAIN_TAGS).deleteMany({ _id: { $in: ids } }) await db.collection<Doc>(DOMAIN_TAGS).deleteMany({ _id: { $in: ids } })
await db.collection(DOMAIN_TAGS).deleteOne({ _id: t._id }) await db.collection<Doc>(DOMAIN_TAGS).deleteOne({ _id: t._id })
} }
} }
await fixCount() await fixCount()

View File

@ -46,7 +46,7 @@ import activity, {
// Use 5 minutes to combine similar messages // Use 5 minutes to combine similar messages
const combineThresholdMs = 5 * 60 * 1000 const combineThresholdMs = 5 * 60 * 1000
// Use 10 seconds to combine update messages after creation. // Use 10 seconds to combine update messages after creation.
const createCombineThreshold = 10 * 1000 const createCombineThreshold = parseInt(localStorage.getItem('platform.activity.threshold') ?? '10 * 1000')
const valueTypes: ReadonlyArray<Ref<Class<Doc>>> = [ const valueTypes: ReadonlyArray<Ref<Class<Doc>>> = [
core.class.TypeString, core.class.TypeString,

View File

@ -46,7 +46,7 @@
"@hcengineering/account": "^0.6.0", "@hcengineering/account": "^0.6.0",
"@hcengineering/platform": "^0.6.9", "@hcengineering/platform": "^0.6.9",
"@hcengineering/core": "^0.6.28", "@hcengineering/core": "^0.6.28",
"mongodb": "^4.11.0", "mongodb": "^6.3.0",
"koa": "^2.13.1", "koa": "^2.13.1",
"koa-router": "^12.0.1", "koa-router": "^12.0.1",
"koa-bodyparser": "^4.3.0", "koa-bodyparser": "^4.3.0",

View File

@ -40,7 +40,7 @@
}, },
"dependencies": { "dependencies": {
"@hcengineering/platform": "^0.6.9", "@hcengineering/platform": "^0.6.9",
"mongodb": "^4.11.0", "mongodb": "^6.3.0",
"@hcengineering/server-tool": "^0.6.0", "@hcengineering/server-tool": "^0.6.0",
"@hcengineering/server-token": "^0.6.7", "@hcengineering/server-token": "^0.6.7",
"@hcengineering/client": "^0.6.14", "@hcengineering/client": "^0.6.14",

View File

@ -31,7 +31,7 @@
"prettier-plugin-svelte": "^3.1.0" "prettier-plugin-svelte": "^3.1.0"
}, },
"dependencies": { "dependencies": {
"mongodb": "^4.11.0", "mongodb": "^6.3.0",
"@hcengineering/platform": "^0.6.9", "@hcengineering/platform": "^0.6.9",
"@hcengineering/core": "^0.6.28", "@hcengineering/core": "^0.6.28",
"@hcengineering/contact": "^0.6.20", "@hcengineering/contact": "^0.6.20",

View File

@ -182,7 +182,7 @@ function withProductId (productId: string, query: Filter<Workspace>): Filter<Wor
* @returns * @returns
*/ */
export async function getWorkspace (db: Db, productId: string, workspace: string): Promise<Workspace | null> { export async function getWorkspace (db: Db, productId: string, workspace: string): Promise<Workspace | null> {
return await db.collection(WORKSPACE_COLLECTION).findOne<Workspace>(withProductId(productId, { workspace })) return await db.collection<Workspace>(WORKSPACE_COLLECTION).findOne(withProductId(productId, { workspace }))
} }
function toAccountInfo (account: Account): AccountInfo { function toAccountInfo (account: Account): AccountInfo {
@ -196,7 +196,7 @@ async function getAccountInfo (db: Db, email: string, password: string): Promise
if (account === null) { if (account === null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: email })) throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: email }))
} }
if (!verifyPassword(password, account.hash.buffer, account.salt.buffer)) { if (!verifyPassword(password, Buffer.from(account.hash.buffer), Buffer.from(account.salt.buffer))) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.InvalidPassword, { account: email })) throw new PlatformError(new Status(Severity.ERROR, platform.status.InvalidPassword, { account: email }))
} }
return toAccountInfo(account) return toAccountInfo(account)

View File

@ -58,7 +58,7 @@
"@hocuspocus/transformer": "^2.9.0", "@hocuspocus/transformer": "^2.9.0",
"@tiptap/core": "^2.1.12", "@tiptap/core": "^2.1.12",
"@tiptap/html": "^2.1.12", "@tiptap/html": "^2.1.12",
"mongodb": "^4.11.0", "mongodb": "^6.3.0",
"yjs": "^13.5.52", "yjs": "^13.5.52",
"y-prosemirror": "^1.2.1", "y-prosemirror": "^1.2.1",
"express": "^4.17.1", "express": "^4.17.1",

View File

@ -13,7 +13,7 @@
// limitations under the License. // limitations under the License.
// //
import { MeasureContext, toWorkspaceString } from '@hcengineering/core' import { Doc, MeasureContext, Ref, toWorkspaceString } from '@hcengineering/core'
import { Transformer } from '@hocuspocus/transformer' import { Transformer } from '@hocuspocus/transformer'
import { MongoClient } from 'mongodb' import { MongoClient } from 'mongodb'
import { Doc as YDoc } from 'yjs' import { Doc as YDoc } from 'yjs'
@ -66,10 +66,12 @@ export class MongodbStorageAdapter implements StorageAdapter {
return await this.ctx.with('load-document', {}, async (ctx) => { return await this.ctx.with('load-document', {}, async (ctx) => {
const doc = await ctx.with('query', {}, async () => { const doc = await ctx.with('query', {}, async () => {
const db = this.mongodb.db(toWorkspaceString(context.workspaceId)) const db = this.mongodb.db(toWorkspaceString(context.workspaceId))
return await db.collection(objectDomain).findOne({ _id: objectId }, { projection: { [objectAttr]: 1 } }) return await db
.collection<Doc>(objectDomain)
.findOne({ _id: objectId as Ref<Doc> }, { projection: { [objectAttr]: 1 } })
}) })
const content = doc !== null && objectAttr in doc ? (doc[objectAttr] as string) : '' const content = doc !== null && objectAttr in doc ? ((doc as any)[objectAttr] as string) : ''
return await ctx.with('transform', {}, () => { return await ctx.with('transform', {}, () => {
return this.transformer.toYdoc(content, objectAttr) return this.transformer.toYdoc(content, objectAttr)

View File

@ -34,6 +34,6 @@
"@hcengineering/core": "^0.6.28", "@hcengineering/core": "^0.6.28",
"@hcengineering/platform": "^0.6.9", "@hcengineering/platform": "^0.6.9",
"@hcengineering/server-core": "^0.6.1", "@hcengineering/server-core": "^0.6.1",
"mongodb": "^4.11.0" "mongodb": "^6.3.0"
} }
} }

View File

@ -18,6 +18,7 @@ import core, {
DOMAIN_TX, DOMAIN_TX,
SortingOrder, SortingOrder,
TxProcessor, TxProcessor,
cutObjectArray,
escapeLikeForRegexp, escapeLikeForRegexp,
getTypeOf, getTypeOf,
isOperator, isOperator,
@ -57,8 +58,8 @@ import core, {
type WorkspaceId type WorkspaceId
} from '@hcengineering/core' } from '@hcengineering/core'
import type { DbAdapter, TxAdapter } from '@hcengineering/server-core' import type { DbAdapter, TxAdapter } from '@hcengineering/server-core'
import serverCore from '@hcengineering/server-core'
import { import {
type AbstractCursor,
type AnyBulkWriteOperation, type AnyBulkWriteOperation,
type Collection, type Collection,
type Db, type Db,
@ -70,8 +71,6 @@ import {
} from 'mongodb' } from 'mongodb'
import { createHash } from 'node:crypto' import { createHash } from 'node:crypto'
import { getMongoClient, getWorkspaceDB } from './utils' import { getMongoClient, getWorkspaceDB } from './utils'
import { cutObjectArray } from '@hcengineering/core'
import { getMetadata } from '@hcengineering/platform'
function translateDoc (doc: Doc): Document { function translateDoc (doc: Doc): Document {
return { ...doc, '%hash%': null } return { ...doc, '%hash%': null }
@ -111,12 +110,21 @@ abstract class MongoAdapterBase implements DbAdapter {
async init (): Promise<void> {} async init (): Promise<void> {}
async toArray<T>(cursor: AbstractCursor<T>): Promise<T[]> {
const data: T[] = []
for await (const r of cursor.stream()) {
data.push(r)
}
await cursor.close()
return data
}
async createIndexes (domain: Domain, config: Pick<IndexingConfiguration<Doc>, 'indexes'>): Promise<void> { async createIndexes (domain: Domain, config: Pick<IndexingConfiguration<Doc>, 'indexes'>): Promise<void> {
for (const vv of config.indexes) { for (const vv of config.indexes) {
try { try {
await this.db.collection(domain).createIndex(vv) await this.db.collection(domain).createIndex(vv)
} catch (err: any) { } catch (err: any) {
console.error(err) console.error('failed to create index', domain, vv, err)
} }
} }
} }
@ -451,16 +459,18 @@ abstract class MongoAdapterBase implements DbAdapter {
checkKeys: false, checkKeys: false,
enableUtf8Validation: false enableUtf8Validation: false
}) })
cursor.maxTimeMS(parseInt(getMetadata(serverCore.metadata.CursorMaxTimeMS) ?? '30000')) const result: WithLookup<T>[] = []
let res: Document = [] let total = options?.total === true ? 0 : -1
try { try {
res = (await cursor.toArray())[0] const rres = await this.toArray(cursor)
for (const r of rres) {
result.push(...r.results)
total = options?.total === true ? r.totalCount?.shift()?.count ?? 0 : -1
}
} catch (e) { } catch (e) {
console.error('error during executing cursor in findWithPipeline', clazz, cutObjectArray(query), options, e) console.error('error during executing cursor in findWithPipeline', clazz, cutObjectArray(query), options, e)
throw e throw e
} }
const result = res.results as WithLookup<T>[]
const total = options?.total === true ? res.totalCount?.shift()?.count ?? 0 : -1
for (const row of result) { for (const row of result) {
await this.fillLookupValue(clazz, options?.lookup, row) await this.fillLookupValue(clazz, options?.lookup, row)
this.clearExtraLookups(row) this.clearExtraLookups(row)
@ -596,11 +606,8 @@ abstract class MongoAdapterBase implements DbAdapter {
} }
// Error in case of timeout // Error in case of timeout
cursor.maxTimeMS(parseInt(getMetadata(serverCore.metadata.CursorMaxTimeMS) ?? '30000'))
cursor.maxAwaitTimeMS(30000)
let res: T[] = []
try { try {
res = await cursor.toArray() const res: T[] = await this.toArray(cursor)
if (options?.total === true && options?.limit === undefined) { if (options?.total === true && options?.limit === undefined) {
total = res.length total = res.length
} }
@ -712,14 +719,9 @@ abstract class MongoAdapterBase implements DbAdapter {
} }
async load (domain: Domain, docs: Ref<Doc>[]): Promise<Doc[]> { async load (domain: Domain, docs: Ref<Doc>[]): Promise<Doc[]> {
return this.stripHash( const cursor = this.db.collection<Doc>(domain).find<Doc>({ _id: { $in: docs } })
this.stripHash( const result = await this.toArray(cursor)
await this.db return this.stripHash(this.stripHash(result))
.collection(domain)
.find<Doc>({ _id: { $in: docs } })
.toArray()
)
)
} }
async upload (domain: Domain, docs: Doc[]): Promise<void> { async upload (domain: Domain, docs: Doc[]): Promise<void> {
@ -783,7 +785,7 @@ abstract class MongoAdapterBase implements DbAdapter {
} }
async clean (domain: Domain, docs: Ref<Doc>[]): Promise<void> { async clean (domain: Domain, docs: Ref<Doc>[]): Promise<void> {
await this.db.collection(domain).deleteMany({ _id: { $in: docs } }) await this.db.collection<Doc>(domain).deleteMany({ _id: { $in: docs } })
} }
} }
@ -1087,7 +1089,7 @@ class MongoAdapter extends MongoAdapterBase {
'%hash%': null '%hash%': null
} }
} as unknown as UpdateFilter<Document>, } as unknown as UpdateFilter<Document>,
{ returnDocument: 'after' } { returnDocument: 'after', includeResultMetadata: true }
) )
return { object: result.value } return { object: result.value }
} }
@ -1128,7 +1130,7 @@ class MongoAdapter extends MongoAdapterBase {
? async (): Promise<TxResult> => { ? async (): Promise<TxResult> => {
const result = await this.db const result = await this.db
.collection(domain) .collection(domain)
.findOneAndUpdate(filter, update, { returnDocument: 'after' }) .findOneAndUpdate(filter, update, { returnDocument: 'after', includeResultMetadata: true })
return { object: result.value } return { object: result.value }
} }
: async () => await this.db.collection(domain).updateOne(filter, update) : async () => await this.db.collection(domain).updateOne(filter, update)
@ -1163,11 +1165,11 @@ class MongoTxAdapter extends MongoAdapterBase implements TxAdapter {
} }
async getModel (): Promise<Tx[]> { async getModel (): Promise<Tx[]> {
const model = await this.db const cursor = this.db
.collection(DOMAIN_TX) .collection(DOMAIN_TX)
.find<Tx>({ objectSpace: core.space.Model }) .find<Tx>({ objectSpace: core.space.Model })
.sort({ _id: 1, modifiedOn: 1 }) .sort({ _id: 1, modifiedOn: 1 })
.toArray() const model = await this.toArray(cursor)
// We need to put all core.account.System transactions first // We need to put all core.account.System transactions first
const systemTx: Tx[] = [] const systemTx: Tx[] = []
const userTx: Tx[] = [] const userTx: Tx[] = []

View File

@ -30,7 +30,7 @@
"prettier-plugin-svelte": "^3.1.0" "prettier-plugin-svelte": "^3.1.0"
}, },
"dependencies": { "dependencies": {
"mongodb": "^4.11.0", "mongodb": "^6.3.0",
"@hcengineering/platform": "^0.6.9", "@hcengineering/platform": "^0.6.9",
"@hcengineering/core": "^0.6.28", "@hcengineering/core": "^0.6.28",
"@hcengineering/contact": "^0.6.20", "@hcengineering/contact": "^0.6.20",

View File

@ -275,6 +275,10 @@ async function createUpdateIndexes (connection: CoreClient, db: Db, logger: Mode
} }
for (const [d, v] of domains.entries()) { for (const [d, v] of domains.entries()) {
const collInfo = await db.listCollections({ name: d }).next()
if (collInfo === null) {
await db.createCollection(d)
}
const collection = db.collection(d) const collection = db.collection(d)
const bb: (string | FieldIndex<Doc>)[] = [] const bb: (string | FieldIndex<Doc>)[] = []
for (const vv of v.values()) { for (const vv of v.values()) {
@ -286,7 +290,7 @@ async function createUpdateIndexes (connection: CoreClient, db: Db, logger: Mode
await collection.createIndex(vv) await collection.createIndex(vv)
} }
} catch (err: any) { } catch (err: any) {
logger.log('error', JSON.stringify(err)) logger.log('error: failed to create index', d, vv, JSON.stringify(err))
} }
bb.push(vv) bb.push(vv)
} }

View File

@ -194,6 +194,6 @@ export class MigrateClientImpl implements MigrationClient {
} }
async deleteMany<T extends Doc>(domain: Domain, query: DocumentQuery<T>): Promise<void> { async deleteMany<T extends Doc>(domain: Domain, query: DocumentQuery<T>): Promise<void> {
await this.db.collection(domain).deleteMany(query) await this.db.collection<Doc>(domain).deleteMany(query as any)
} }
} }

View File

@ -1,7 +1,7 @@
version: "3" version: "3"
services: services:
mongodb: mongodb:
image: mongo image: 'mongo:7-jammy'
command: mongod --port 27018 command: mongod --port 27018
environment: environment:
- PUID=1000 - PUID=1000

View File

@ -20,6 +20,7 @@
"name": "#platform.notification.timeout", "name": "#platform.notification.timeout",
"value": "0" "value": "0"
}, },
{ {
"name": "#platform.testing.enabled", "name": "#platform.testing.enabled",
"value": "true" "value": "true"

View File

@ -16,14 +16,19 @@
"name": "login:metadata:LoginEndpoint", "name": "login:metadata:LoginEndpoint",
"value": "ws://localhost:3334" "value": "ws://localhost:3334"
}, },
{
"name": "#platform.notification.logging",
"value": "false"
},
{ {
"name": "#platform.notification.timeout", "name": "#platform.notification.timeout",
"value": "0" "value": "0"
}, },
{
"name": "#platform.testing.enabled",
"value": "true"
},
{
"name": "#platform.notification.logging",
"value": "false"
},
{ {
"name": "#platform.lazy.loading", "name": "#platform.lazy.loading",
"value": "false" "value": "false"

View File

@ -20,10 +20,7 @@
"name": "#platform.notification.timeout", "name": "#platform.notification.timeout",
"value": "0" "value": "0"
}, },
{
"name": "#platform.notification.timeout",
"value": "0"
},
{ {
"name": "#platform.testing.enabled", "name": "#platform.testing.enabled",
"value": "true" "value": "true"

View File

@ -16,6 +16,15 @@
"name": "login:metadata:LoginEndpoint", "name": "login:metadata:LoginEndpoint",
"value": "ws://localhost:3334" "value": "ws://localhost:3334"
}, },
{
"name": "#platform.notification.timeout",
"value": "0"
},
{
"name": "#platform.testing.enabled",
"value": "true"
},
{ {
"name": "#platform.notification.logging", "name": "#platform.notification.logging",
"value": "false" "value": "false"

View File

@ -50,6 +50,9 @@ test.describe('Collaborative test for issue', () => {
// check created issued by second user // check created issued by second user
const issuesPageSecond = new IssuesPage(userSecondPage) const issuesPageSecond = new IssuesPage(userSecondPage)
await userSecondPage.evaluate(() => {
localStorage.setItem('platform.activity.threshold', '0')
})
await issuesPageSecond.linkSidebarAll.click() await issuesPageSecond.linkSidebarAll.click()
await issuesPageSecond.modelSelectorAll.click() await issuesPageSecond.modelSelectorAll.click()
await issuesPageSecond.searchIssueByName(newIssue.title) await issuesPageSecond.searchIssueByName(newIssue.title)
@ -99,6 +102,9 @@ test.describe('Collaborative test for issue', () => {
await issuesPageSecond.openIssueByName(issue.title) await issuesPageSecond.openIssueByName(issue.title)
const issuesDetailsPageSecond = new IssuesDetailsPage(userSecondPage) const issuesDetailsPageSecond = new IssuesDetailsPage(userSecondPage)
await userSecondPage.evaluate(() => {
localStorage.setItem('platform.activity.threshold', '0')
})
await issuesDetailsPageSecond.checkIssue({ await issuesDetailsPageSecond.checkIssue({
...issue, ...issue,
status: 'In Progress' status: 'In Progress'

View File

@ -9,7 +9,7 @@ export class CommonRecruitingPage extends CalendarPage {
readonly buttonSendComment: Locator readonly buttonSendComment: Locator
readonly textComment: Locator readonly textComment: Locator
readonly inputAddAttachment: Locator readonly inputAddAttachment: Locator
readonly textAttachmentName: Locator textAttachmentName: Locator
readonly buttonCreateFirstReview: Locator readonly buttonCreateFirstReview: Locator
readonly buttonMoreActions: Locator readonly buttonMoreActions: Locator
readonly buttonDelete: Locator readonly buttonDelete: Locator
@ -60,7 +60,7 @@ export class CommonRecruitingPage extends CalendarPage {
async addAttachments (filePath: string): Promise<void> { async addAttachments (filePath: string): Promise<void> {
await this.inputAddAttachment.setInputFiles(path.join(__dirname, `../../files/${filePath}`)) await this.inputAddAttachment.setInputFiles(path.join(__dirname, `../../files/${filePath}`))
await expect(this.textAttachmentName.filter({ hasText: filePath })).toBeVisible() await expect(this.textAttachmentName.filter({ hasText: filePath }).first()).toBeVisible()
} }
async addFirstReview (reviewTitle: string, reviewDescription: string): Promise<void> { async addFirstReview (reviewTitle: string, reviewDescription: string): Promise<void> {

View File

@ -45,7 +45,7 @@ export class TalentDetailsPage extends CommonRecruitingPage {
} }
async checkSkill (skillTag: string): Promise<void> { async checkSkill (skillTag: string): Promise<void> {
await expect(this.textTagItem).toContainText(skillTag) await expect(this.textTagItem.first()).toContainText(skillTag)
} }
async addTitle (title: string): Promise<void> { async addTitle (title: string): Promise<void> {

View File

@ -33,7 +33,7 @@ export class VacancyDetailsPage extends CommonRecruitingPage {
async addAttachments (filePath: string): Promise<void> { async addAttachments (filePath: string): Promise<void> {
await this.inputAttachFile.setInputFiles(path.join(__dirname, `../../files/${filePath}`)) await this.inputAttachFile.setInputFiles(path.join(__dirname, `../../files/${filePath}`))
await expect(this.textAttachmentName).toHaveAttribute('download', filePath) await expect(this.textAttachmentName.first()).toHaveAttribute('download', filePath)
} }
async addDescription (description: string): Promise<void> { async addDescription (description: string): Promise<void> {

View File

@ -178,6 +178,6 @@ test.describe('candidate/talents tests', () => {
await talentsPage.createNewTalentWithName(talentName.firstName, talentName.lastName) await talentsPage.createNewTalentWithName(talentName.firstName, talentName.lastName)
await talentsPage.rightClickAction(talentName, 'Match to vacancy') await talentsPage.rightClickAction(talentName, 'Match to vacancy')
await talentsPage.checkMatchVacancy(`${talentName.lastName} ${talentName.firstName}`, '0.') await talentsPage.checkMatchVacancy(`${talentName.lastName} ${talentName.firstName}`, '0')
}) })
}) })