Skip to content

Commit 890d11d

Browse files
authored
[opt](audit) use one line in audit log and origin statement in audit table (#52032)
### What problem does this PR solve? Previously, when auditing, we use replace all `\n`, `\t` in origin sql string with `\\n`, `\\t`, so that the sql string can be written in one line. But this lead to some problem: 1. User can not direct use the sql in audit log to execute. 2. Some replacement is wrong, eg, replace the `\n` in a quota string. This PR changes the logic: 1. For audit log, only replace `\n` with `\\n` to keep SQL in one line. 2. For audit table, keep the origin string. 3. Use special column and line separator for audit log load data, to avoid conflict with char in SQL
1 parent af68d8b commit 890d11d

File tree

6 files changed

+113
-112
lines changed

6 files changed

+113
-112
lines changed

fe/fe-core/src/main/java/org/apache/doris/plugin/AuditEvent.java

Lines changed: 48 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -42,123 +42,125 @@ public enum EventType {
4242
}
4343

4444
@Retention(RetentionPolicy.RUNTIME)
45-
public static @interface AuditField {
45+
public @interface AuditField {
4646
String value() default "";
47+
48+
String colName() default "";
4749
}
4850

4951
public EventType type;
5052

51-
// all fields which is about to be audit should be annotated by "@AuditField"
53+
// all fields which is about to be audited should be annotated by "@AuditField"
5254
// make them all "public" so that easy to visit.
5355

5456
// uuid and time
55-
@AuditField(value = "QueryId")
57+
@AuditField(value = "QueryId", colName = "query_id")
5658
public String queryId = "";
57-
@AuditField(value = "Timestamp")
59+
@AuditField(value = "Timestamp", colName = "time")
5860
public long timestamp = -1;
5961

6062
// cs info
61-
@AuditField(value = "Client")
63+
@AuditField(value = "Client", colName = "client_ip")
6264
public String clientIp = "";
63-
@AuditField(value = "User")
65+
@AuditField(value = "User", colName = "user")
6466
public String user = "";
65-
@AuditField(value = "FeIp")
67+
@AuditField(value = "FeIp", colName = "frontend_ip")
6668
public String feIp = "";
6769

6870
// default ctl and db
69-
@AuditField(value = "Ctl")
71+
@AuditField(value = "Ctl", colName = "catalog")
7072
public String ctl = "";
71-
@AuditField(value = "Db")
73+
@AuditField(value = "Db", colName = "db")
7274
public String db = "";
7375

7476
// query state
75-
@AuditField(value = "State")
77+
@AuditField(value = "State", colName = "state")
7678
public String state = "";
77-
@AuditField(value = "ErrorCode")
79+
@AuditField(value = "ErrorCode", colName = "error_code")
7880
public int errorCode = 0;
79-
@AuditField(value = "ErrorMessage")
81+
@AuditField(value = "ErrorMessage", colName = "error_message")
8082
public String errorMessage = "";
8183

8284
// execution info
83-
@AuditField(value = "Time(ms)")
85+
@AuditField(value = "Time(ms)", colName = "query_time")
8486
public long queryTime = -1;
85-
@AuditField(value = "CpuTimeMS")
87+
@AuditField(value = "CpuTimeMS", colName = "cpu_time_ms")
8688
public long cpuTimeMs = -1;
87-
@AuditField(value = "PeakMemoryBytes")
89+
@AuditField(value = "PeakMemoryBytes", colName = "peak_memory_bytes")
8890
public long peakMemoryBytes = -1;
89-
@AuditField(value = "ScanBytes")
91+
@AuditField(value = "ScanBytes", colName = "scan_bytes")
9092
public long scanBytes = -1;
91-
@AuditField(value = "ScanRows")
93+
@AuditField(value = "ScanRows", colName = "scan_rows")
9294
public long scanRows = -1;
93-
@AuditField(value = "ReturnRows")
95+
@AuditField(value = "ReturnRows", colName = "return_rows")
9496
public long returnRows = -1;
95-
@AuditField(value = "ShuffleSendBytes")
96-
public long shuffleSendBytes = -1;
97-
@AuditField(value = "ShuffleSendRows")
97+
@AuditField(value = "ShuffleSendRows", colName = "shuffle_send_rows")
9898
public long shuffleSendRows = -1;
99-
@AuditField(value = "SpillWriteBytesToLocalStorage")
99+
@AuditField(value = "ShuffleSendBytes", colName = "shuffle_send_bytes")
100+
public long shuffleSendBytes = -1;
101+
@AuditField(value = "SpillWriteBytesToLocalStorage", colName = "spill_write_bytes_from_local_storage")
100102
public long spillWriteBytesToLocalStorage = -1;
101-
@AuditField(value = "SpillReadBytesFromLocalStorage")
103+
@AuditField(value = "SpillReadBytesFromLocalStorage", colName = "spill_read_bytes_from_local_storage")
102104
public long spillReadBytesFromLocalStorage = -1;
103-
@AuditField(value = "ScanBytesFromLocalStorage")
105+
@AuditField(value = "ScanBytesFromLocalStorage", colName = "scan_bytes_from_local_storage")
104106
public long scanBytesFromLocalStorage = -1;
105-
@AuditField(value = "ScanBytesFromRemoteStorage")
107+
@AuditField(value = "ScanBytesFromRemoteStorage", colName = "scan_bytes_from_remote_storage")
106108
public long scanBytesFromRemoteStorage = -1;
107109

108110
// plan info
109-
@AuditField(value = "ParseTimeMs")
111+
@AuditField(value = "ParseTimeMs", colName = "parse_time_ms")
110112
public int parseTimeMs = -1;
111-
@AuditField(value = "PlanTimesMs")
113+
@AuditField(value = "PlanTimesMs", colName = "plan_times_ms")
112114
public String planTimesMs = "";
113-
@AuditField(value = "GetMetaTimesMs")
115+
@AuditField(value = "GetMetaTimesMs", colName = "get_meta_times_ms")
114116
public String getMetaTimesMs = "";
115-
@AuditField(value = "ScheduleTimesMs")
117+
@AuditField(value = "ScheduleTimesMs", colName = "schedule_times_ms")
116118
public String scheduleTimesMs = "";
117-
@AuditField(value = "HitSqlCache")
119+
@AuditField(value = "HitSqlCache", colName = "hit_sql_cache")
118120
public boolean hitSqlCache = false;
119-
@AuditField(value = "isHandledInFe")
121+
@AuditField(value = "isHandledInFe", colName = "handled_in_fe")
120122
public boolean isHandledInFe = false;
121123

122124
// table, view, m-view
123-
@AuditField(value = "queriedTablesAndViews")
125+
@AuditField(value = "queriedTablesAndViews", colName = "queried_tables_and_views")
124126
public String queriedTablesAndViews = "";
125-
@AuditField(value = "chosenMViews")
127+
@AuditField(value = "chosenMViews", colName = "chosen_m_views")
126128
public String chosenMViews = "";
127129

128130
// variable and configs
129-
@AuditField(value = "ChangedVariables")
131+
@AuditField(value = "ChangedVariables", colName = "changed_variables")
130132
public String changedVariables = "";
131133
@AuditField(value = "FuzzyVariables")
132134
public String fuzzyVariables = "";
133-
@AuditField(value = "SqlMode")
135+
@AuditField(value = "SqlMode", colName = "sql_mode")
134136
public String sqlMode = "";
135137

136138
// type and digest
137139
@AuditField(value = "CommandType")
138140
public String commandType = "";
139-
@AuditField(value = "StmtType")
141+
@AuditField(value = "StmtType", colName = "stmt_type")
140142
public String stmtType = "";
141-
@AuditField(value = "StmtId")
143+
@AuditField(value = "StmtId", colName = "stmt_id")
142144
public long stmtId = -1;
143-
@AuditField(value = "SqlHash")
145+
@AuditField(value = "SqlHash", colName = "sql_hash")
144146
public String sqlHash = "";
145-
@AuditField(value = "SqlDigest")
147+
@AuditField(value = "SqlDigest", colName = "sql_digest")
146148
public String sqlDigest = "";
147-
@AuditField(value = "IsQuery")
149+
@AuditField(value = "IsQuery", colName = "is_query")
148150
public boolean isQuery = false;
149-
@AuditField(value = "IsNereids")
151+
@AuditField(value = "IsNereids", colName = "is_nereids")
150152
public boolean isNereids = false;
151-
@AuditField(value = "IsInternal")
153+
@AuditField(value = "IsInternal", colName = "is_internal")
152154
public boolean isInternal = false;
153155

154156
// resource
155-
@AuditField(value = "ComputeGroupName")
156-
public String cloudClusterName = "";
157-
@AuditField(value = "WorkloadGroup")
157+
@AuditField(value = "WorkloadGroup", colName = "workload_group")
158158
public String workloadGroup = "";
159+
@AuditField(value = "ComputeGroupName", colName = "compute_group")
160+
public String cloudClusterName = "";
159161

160162
// stmt should be last one
161-
@AuditField(value = "Stmt")
163+
@AuditField(value = "Stmt", colName = "stmt")
162164
public String stmt = "";
163165

164166
public long pushToAuditLogQueueTime;

fe/fe-core/src/main/java/org/apache/doris/plugin/audit/AuditLoader.java

Lines changed: 50 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,14 @@ public class AuditLoader extends Plugin implements AuditPlugin {
4646

4747
public static final String AUDIT_LOG_TABLE = "audit_log";
4848

49+
// the "\\u001F" and "\\u001E" are used to separate columns and lines in audit log data
50+
public static final String AUDIT_TABLE_COL_SEPARATOR = "\\u001F";
51+
public static final String AUDIT_TABLE_LINE_DELIMITER = "\\u001E";
52+
// the "\\x1F" and "\\x1E" are used to specified column and line delimiter in stream load request
53+
// which is corresponding to the "\\u001F" and "\\u001E" in audit log data.
54+
public static final String AUDIT_TABLE_COL_SEPARATOR_STR = "\\x1F";
55+
public static final String AUDIT_TABLE_LINE_DELIMITER_STR = "\\x1E";
56+
4957
private StringBuilder auditLogBuffer = new StringBuilder();
5058
private int auditLogNum = 0;
5159
private long lastLoadTimeAuditLog = 0;
@@ -141,73 +149,73 @@ private void fillLogBuffer(AuditEvent event, StringBuilder logBuffer) {
141149
// should be same order as InternalSchema.AUDIT_SCHEMA
142150

143151
// uuid and time
144-
logBuffer.append(event.queryId).append("\t");
145-
logBuffer.append(TimeUtils.longToTimeStringWithms(event.timestamp)).append("\t");
152+
logBuffer.append(event.queryId).append(AUDIT_TABLE_COL_SEPARATOR);
153+
logBuffer.append(TimeUtils.longToTimeStringWithms(event.timestamp)).append(AUDIT_TABLE_COL_SEPARATOR);
146154

147155
// cs info
148-
logBuffer.append(event.clientIp).append("\t");
149-
logBuffer.append(event.user).append("\t");
150-
logBuffer.append(event.feIp).append("\t");
156+
logBuffer.append(event.clientIp).append(AUDIT_TABLE_COL_SEPARATOR);
157+
logBuffer.append(event.user).append(AUDIT_TABLE_COL_SEPARATOR);
158+
logBuffer.append(event.feIp).append(AUDIT_TABLE_COL_SEPARATOR);
151159

152160
// default ctl and db
153-
logBuffer.append(event.ctl).append("\t");
154-
logBuffer.append(event.db).append("\t");
161+
logBuffer.append(event.ctl).append(AUDIT_TABLE_COL_SEPARATOR);
162+
logBuffer.append(event.db).append(AUDIT_TABLE_COL_SEPARATOR);
155163

156164
// query state
157-
logBuffer.append(event.state).append("\t");
158-
logBuffer.append(event.errorCode).append("\t");
159-
logBuffer.append(event.errorMessage).append("\t");
165+
logBuffer.append(event.state).append(AUDIT_TABLE_COL_SEPARATOR);
166+
logBuffer.append(event.errorCode).append(AUDIT_TABLE_COL_SEPARATOR);
167+
logBuffer.append(event.errorMessage).append(AUDIT_TABLE_COL_SEPARATOR);
160168

161169
// execution info
162-
logBuffer.append(event.queryTime).append("\t");
163-
logBuffer.append(event.cpuTimeMs).append("\t");
164-
logBuffer.append(event.peakMemoryBytes).append("\t");
165-
logBuffer.append(event.scanBytes).append("\t");
166-
logBuffer.append(event.scanRows).append("\t");
167-
logBuffer.append(event.returnRows).append("\t");
168-
logBuffer.append(event.shuffleSendRows).append("\t");
169-
logBuffer.append(event.shuffleSendBytes).append("\t");
170-
logBuffer.append(event.spillWriteBytesToLocalStorage).append("\t");
171-
logBuffer.append(event.spillReadBytesFromLocalStorage).append("\t");
172-
logBuffer.append(event.scanBytesFromLocalStorage).append("\t");
173-
logBuffer.append(event.scanBytesFromRemoteStorage).append("\t");
170+
logBuffer.append(event.queryTime).append(AUDIT_TABLE_COL_SEPARATOR);
171+
logBuffer.append(event.cpuTimeMs).append(AUDIT_TABLE_COL_SEPARATOR);
172+
logBuffer.append(event.peakMemoryBytes).append(AUDIT_TABLE_COL_SEPARATOR);
173+
logBuffer.append(event.scanBytes).append(AUDIT_TABLE_COL_SEPARATOR);
174+
logBuffer.append(event.scanRows).append(AUDIT_TABLE_COL_SEPARATOR);
175+
logBuffer.append(event.returnRows).append(AUDIT_TABLE_COL_SEPARATOR);
176+
logBuffer.append(event.shuffleSendRows).append(AUDIT_TABLE_COL_SEPARATOR);
177+
logBuffer.append(event.shuffleSendBytes).append(AUDIT_TABLE_COL_SEPARATOR);
178+
logBuffer.append(event.spillWriteBytesToLocalStorage).append(AUDIT_TABLE_COL_SEPARATOR);
179+
logBuffer.append(event.spillReadBytesFromLocalStorage).append(AUDIT_TABLE_COL_SEPARATOR);
180+
logBuffer.append(event.scanBytesFromLocalStorage).append(AUDIT_TABLE_COL_SEPARATOR);
181+
logBuffer.append(event.scanBytesFromRemoteStorage).append(AUDIT_TABLE_COL_SEPARATOR);
174182

175183
// plan info
176-
logBuffer.append(event.parseTimeMs).append("\t");
177-
logBuffer.append(event.planTimesMs).append("\t");
178-
logBuffer.append(event.getMetaTimesMs).append("\t");
179-
logBuffer.append(event.scheduleTimesMs).append("\t");
180-
logBuffer.append(event.hitSqlCache ? 1 : 0).append("\t");
181-
logBuffer.append(event.isHandledInFe ? 1 : 0).append("\t");
184+
logBuffer.append(event.parseTimeMs).append(AUDIT_TABLE_COL_SEPARATOR);
185+
logBuffer.append(event.planTimesMs).append(AUDIT_TABLE_COL_SEPARATOR);
186+
logBuffer.append(event.getMetaTimesMs).append(AUDIT_TABLE_COL_SEPARATOR);
187+
logBuffer.append(event.scheduleTimesMs).append(AUDIT_TABLE_COL_SEPARATOR);
188+
logBuffer.append(event.hitSqlCache ? 1 : 0).append(AUDIT_TABLE_COL_SEPARATOR);
189+
logBuffer.append(event.isHandledInFe ? 1 : 0).append(AUDIT_TABLE_COL_SEPARATOR);
182190

183191
// queried tables, views and m-views
184-
logBuffer.append(event.queriedTablesAndViews).append("\t");
185-
logBuffer.append(event.chosenMViews).append("\t");
192+
logBuffer.append(event.queriedTablesAndViews).append(AUDIT_TABLE_COL_SEPARATOR);
193+
logBuffer.append(event.chosenMViews).append(AUDIT_TABLE_COL_SEPARATOR);
186194

187195
// variable and configs
188-
logBuffer.append(event.changedVariables).append("\t");
189-
logBuffer.append(event.sqlMode).append("\t");
196+
logBuffer.append(event.changedVariables).append(AUDIT_TABLE_COL_SEPARATOR);
197+
logBuffer.append(event.sqlMode).append(AUDIT_TABLE_COL_SEPARATOR);
190198

191199

192200
// type and digest
193-
logBuffer.append(event.stmtType).append("\t");
194-
logBuffer.append(event.stmtId).append("\t");
195-
logBuffer.append(event.sqlHash).append("\t");
196-
logBuffer.append(event.sqlDigest).append("\t");
197-
logBuffer.append(event.isQuery ? 1 : 0).append("\t");
198-
logBuffer.append(event.isNereids ? 1 : 0).append("\t");
199-
logBuffer.append(event.isInternal ? 1 : 0).append("\t");
201+
logBuffer.append(event.stmtType).append(AUDIT_TABLE_COL_SEPARATOR);
202+
logBuffer.append(event.stmtId).append(AUDIT_TABLE_COL_SEPARATOR);
203+
logBuffer.append(event.sqlHash).append(AUDIT_TABLE_COL_SEPARATOR);
204+
logBuffer.append(event.sqlDigest).append(AUDIT_TABLE_COL_SEPARATOR);
205+
logBuffer.append(event.isQuery ? 1 : 0).append(AUDIT_TABLE_COL_SEPARATOR);
206+
logBuffer.append(event.isNereids ? 1 : 0).append(AUDIT_TABLE_COL_SEPARATOR);
207+
logBuffer.append(event.isInternal ? 1 : 0).append(AUDIT_TABLE_COL_SEPARATOR);
200208

201209
// resource
202-
logBuffer.append(event.workloadGroup).append("\t");
203-
logBuffer.append(event.cloudClusterName).append("\t");
210+
logBuffer.append(event.workloadGroup).append(AUDIT_TABLE_COL_SEPARATOR);
211+
logBuffer.append(event.cloudClusterName).append(AUDIT_TABLE_COL_SEPARATOR);
204212

205213
// already trim the query in org.apache.doris.qe.AuditLogHelper#logAuditLog
206214
String stmt = event.stmt;
207215
if (LOG.isDebugEnabled()) {
208216
LOG.debug("receive audit event with stmt: {}", stmt);
209217
}
210-
logBuffer.append(stmt).append("\n");
218+
logBuffer.append(stmt).append(AUDIT_TABLE_LINE_DELIMITER);
211219
}
212220

213221
// public for external call.

fe/fe-core/src/main/java/org/apache/doris/plugin/audit/AuditLogBuilder.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,12 @@ private String getAuditLogString(AuditEvent event) throws IllegalAccessException
121121
}
122122
}
123123

124+
// replace new line characters with escaped characters to make sure the stmt in one line
125+
if (af.value().equals("Stmt")) {
126+
fieldValue = ((String) fieldValue).replace("\n", "\\n")
127+
.replace("\r", "\\r");
128+
}
129+
124130
sb.append("|").append(af.value()).append("=").append(fieldValue);
125131
}
126132

fe/fe-core/src/main/java/org/apache/doris/plugin/audit/AuditStreamLoader.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ private HttpURLConnection getConnection(String urlStr, String label, String clus
6868
InternalSchema.AUDIT_SCHEMA.stream().map(c -> c.getName()).collect(
6969
Collectors.joining(",")));
7070
conn.addRequestProperty("redirect-policy", "random-be");
71+
conn.addRequestProperty("column_separator", AuditLoader.AUDIT_TABLE_COL_SEPARATOR_STR);
72+
conn.addRequestProperty("line_delimiter", AuditLoader.AUDIT_TABLE_LINE_DELIMITER_STR);
7173
conn.setDoOutput(true);
7274
conn.setDoInput(true);
7375
return conn;

fe/fe-core/src/main/java/org/apache/doris/qe/AuditLogHelper.java

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -115,9 +115,7 @@ public static String handleStmt(String origStmt, StatementBase parsedStmt) {
115115
int maxLen = GlobalVariable.auditPluginMaxSqlLength;
116116
origStmt = truncateByBytes(origStmt, maxLen, " ... /* truncated. audit_plugin_max_sql_length=" + maxLen
117117
+ " */");
118-
return origStmt.replace("\n", "\\n")
119-
.replace("\t", "\\t")
120-
.replace("\r", "\\r");
118+
return origStmt;
121119
}
122120

123121
private static Optional<String> handleInsertStmt(String origStmt, StatementBase parsedStmt) {
@@ -148,9 +146,6 @@ private static Optional<String> handleInsertStmt(String origStmt, StatementBase
148146
Math.min(GlobalVariable.auditPluginMaxInsertStmtLength, GlobalVariable.auditPluginMaxSqlLength));
149147
origStmt = truncateByBytes(origStmt, maxLen, " ... /* total " + rowCnt
150148
+ " rows, truncated. audit_plugin_max_insert_stmt_length=" + maxLen + " */");
151-
origStmt = origStmt.replace("\n", "\\n")
152-
.replace("\t", "\\t")
153-
.replace("\r", "\\r");
154149
return Optional.of(origStmt);
155150
} else {
156151
return Optional.empty();

fe/fe-core/src/test/java/org/apache/doris/plugin/audit/AuditLogBuilderTest.java

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,9 @@ public void testHandleStmtTruncationForNonInsertStmt() {
8080
// 4. Test statement with newlines, tabs, carriage returns
8181
String stmtWithSpecialChars = "SELECT *\nFROM table1\tWHERE id = 1\r";
8282
result = AuditLogHelper.handleStmt(stmtWithSpecialChars, nonInsertStmt);
83-
Assert.assertTrue("Should escape newlines", result.contains("\\n"));
84-
Assert.assertTrue("Should escape tabs", result.contains("\\t"));
85-
Assert.assertTrue("Should escape carriage returns", result.contains("\\r"));
86-
Assert.assertFalse("Should not contain actual newlines", result.contains("\n"));
87-
Assert.assertFalse("Should not contain actual tabs", result.contains("\t"));
88-
Assert.assertFalse("Should not contain actual carriage returns", result.contains("\r"));
83+
Assert.assertTrue("Should contain actual newlines", result.contains("\n"));
84+
Assert.assertTrue("Should contain actual tabs", result.contains("\t"));
85+
Assert.assertTrue("Should contain actual carriage returns", result.contains("\r"));
8986

9087
// 5. Test long statement with Chinese characters truncation
9188
String chineseStmt
@@ -118,12 +115,6 @@ public void testHandleStmtTruncationForNonInsertStmt() {
118115
String emptyStmt = "";
119116
result = AuditLogHelper.handleStmt(emptyStmt, nonInsertStmt);
120117
Assert.assertEquals("Empty string should remain empty", "", result);
121-
122-
// 9. Test statement with only special characters
123-
String specialCharsStmt = "\n\t\r\n\t\r";
124-
result = AuditLogHelper.handleStmt(specialCharsStmt, nonInsertStmt);
125-
Assert.assertEquals("Should escape all special characters", "\\n\\t\\r\\n\\t\\r", result);
126-
127118
} finally {
128119
// Restore original values
129120
GlobalVariable.auditPluginMaxSqlLength = originalMaxSqlLength;
@@ -172,12 +163,9 @@ public void testHandleStmtTruncationForInsertStmt() {
172163
result = AuditLogHelper.handleStmt(insertWithSpecialChars, insertStmt);
173164

174165
// Verify special characters are properly escaped
175-
Assert.assertTrue("Should escape newlines in INSERT", result.contains("\\n"));
176-
Assert.assertTrue("Should escape tabs in INSERT", result.contains("\\t"));
177-
Assert.assertTrue("Should escape carriage returns in INSERT", result.contains("\\r"));
178-
Assert.assertFalse("Should not contain actual newlines", result.contains("\n"));
179-
Assert.assertFalse("Should not contain actual tabs", result.contains("\t"));
180-
Assert.assertFalse("Should not contain actual carriage returns", result.contains("\r"));
166+
Assert.assertTrue("Should contain actual newlines", result.contains("\n"));
167+
Assert.assertTrue("Should contain actual tabs", result.contains("\t"));
168+
Assert.assertTrue("Should contain actual carriage returns", result.contains("\r"));
181169

182170
// 4. Test comparison: same length statements, different handling for INSERT vs non-INSERT
183171
// Create a statement with length between 80-200

0 commit comments

Comments
 (0)