/** * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.apache.hadoop.hbase.crosssite.verifier; import java.io.IOException; import java.net.URI; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.Set; import java.util.concurrent.ExecutorService; import java.util.concurrent.Future; import java.util.concurrent.ScheduledThreadPoolExecutor; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.conf.Configured; import org.apache.hadoop.fs.Path; import org.apache.hadoop.hbase.HBaseConfiguration; import org.apache.hadoop.hbase.HColumnDescriptor; import org.apache.hadoop.hbase.HConstants; import org.apache.hadoop.hbase.HTableDescriptor; import org.apache.hadoop.hbase.client.CrossSiteCallable; import org.apache.hadoop.hbase.client.HBaseAdmin; import org.apache.hadoop.hbase.crosssite.ClusterInfo; import org.apache.hadoop.hbase.crosssite.CrossSiteDummyAbortable; import org.apache.hadoop.hbase.crosssite.CrossSiteUtil; import org.apache.hadoop.hbase.crosssite.CrossSiteZNodes; import org.apache.hadoop.hbase.crosssite.CrossSiteZNodes.TableState; import org.apache.hadoop.hbase.crosssite.locator.ClusterLocator; import org.apache.hadoop.hbase.util.Bytes; import org.apache.hadoop.hbase.zookeeper.ZKUtil; import org.apache.hadoop.hbase.zookeeper.ZooKeeperWatcher; import org.apache.hadoop.util.Tool; import org.apache.hadoop.util.ToolRunner; import org.apache.zookeeper.KeeperException; /** * This is a tool that verifies the status of the CSBT cluster The following * things would be taken care by this tool and additions may be done to this in * future * 1) Verifies the tables in the cluster. All the tables should exist as * in the crosssite tableznode * 2) Verifies if the HTD in the actual tables and * the one in the crosssite znodes are same * 3) If replication is enabled check * if the peers have the required tables with the required CFs */ public class CSBTClusterVerifier extends Configured implements Tool { private static final Log LOG = LogFactory.getLog(CSBTClusterVerifier.class); private ExecutorService executor; private final static int MAX_NUM_THREADS = 50; private int retCode = 0; private boolean fixTables = false; private boolean fixTableStates = false; private boolean fixHTDs = false; private CrossSiteZNodes crossSiteZnodes; public CSBTClusterVerifier(Configuration conf) { super(conf); int numThreads = conf.getInt("hbase.crosssite.verifier.numthreads", MAX_NUM_THREADS); executor = new ScheduledThreadPoolExecutor(numThreads); } public CSBTClusterVerifier(Configuration conf, ExecutorService executor) { super(conf); this.executor = executor; } public static void main(String args[]) throws Exception { Configuration conf = HBaseConfiguration.create(); Path hbasedir = new Path(conf.get(HConstants.HBASE_DIR)); URI defaultFs = hbasedir.getFileSystem(conf).getUri(); conf.set("fs.defaultFS", defaultFs.toString()); // for hadoop 0.21+ conf.set("fs.default.name", defaultFs.toString()); // for hadoop 0.20 int ret = ToolRunner.run(new CSBTClusterVerifier(conf), args); System.exit(ret); } @Override public int run(String[] args) throws Exception { exec(executor, args); return getReturnCode(); } int getReturnCode() { return retCode; } void setReturnCode(int retCode) { this.retCode = retCode; } void exec(ExecutorService executor, String[] args) throws IOException, KeeperException { for (int i = 0; i < args.length; i++) { String cmd = args[i]; if (cmd.equals("-createTables")) { fixTables(true); } else if (cmd.equals("-fixTableStates")) { fixTableStates(true); } else if (cmd.equals("-fixHTDs")) { fixHTDs(true); } } // Create connection with CrossSiteHBaseAdmin connect(); onlineVerification(executor); } void connect() throws IOException, KeeperException { ZooKeeperWatcher zooKeeperWatcher = new ZooKeeperWatcher(getConf(), "connection to global zookeeper from CSBTClusterVerifier ", new CrossSiteDummyAbortable(), false); crossSiteZnodes = new CrossSiteZNodes(zooKeeperWatcher); } void onlineVerification(ExecutorService executor) throws IOException, KeeperException { // Step 1 : Verify the clusters and the table znodes. // Ensure that the clusters have all the tables created as specified in the // table znode verifyClusterAndTables(); // Step 2 : Verify the HTDs. If there is a mismatch make the appropriate // step to make the HTDs in the crosssite // znode and the actual HTD of the tables in the cluster to be in sync verifyHTDs(); //Step 3: Verify the states of the tables in the cluster and the peers // Currently will not do rectification here for all cases because if state is in DISABLING/ENABLING // we cannot do the correction easily. If the state is DISABLED instead of ENABLED, or if // the state is ENABLED instead of DISABLED those can be corrected verifyTableStatesInClusterAndPeers(); } void verifyTableStatesInClusterAndPeers() throws KeeperException, IOException { LOG.debug("Collecting the table state in the cluster and the peers"); final Map<String, ClusterInfo> clusterInfos = crossSiteZnodes.listClusterInfos(); try { if (!clusterInfos.isEmpty()) { final HTableDescriptor[] tableDescsFromZnode = crossSiteZnodes.listTableDescs(); final Map<String, TableState> tableStates = crossSiteZnodes.listTableStates(); for (Iterator<Entry<String, TableState>> tableStatesIr = tableStates.entrySet().iterator(); tableStatesIr .hasNext();) { Entry<String, TableState> tableStateEntry = tableStatesIr.next(); TableState state = tableStateEntry.getValue(); if (state != TableState.ENABLED && state != TableState.DISABLED) { LOG.error("Table " + tableStateEntry.getKey() + " in abnormal state[" + state.toString() + "]. Cannot handle through this tool."); tableStatesIr.remove(); setReturnCode(RETURN_CODE.REPORT_ERROR.ordinal()); } } if (tableStates.isEmpty()) { return; } List<Future<Map<String, Map<String, TableState>>>> results = new ArrayList<Future<Map<String, Map<String, TableState>>>>(); Map<String, Map<String, TableState>> workingMap = new HashMap<String, Map<String, TableState>>(); for (final Entry<String, ClusterInfo> entry : clusterInfos.entrySet()) { results.add(executor.submit(new CrossSiteCallable<Map<String, Map<String, TableState>>>( getConf()) { @Override public Map<String, Map<String, TableState>> call() throws Exception { Map<String, Map<String, TableState>> clusterTableStates = Collections.emptyMap(); if (tableDescsFromZnode != null && tableDescsFromZnode.length > 0) { clusterTableStates = new HashMap<String, Map<String, TableState>>(); HBaseAdmin admin = createHBaseAdmin(configuration, entry.getValue().getAddress()); try { Map<String, TableState> states = new HashMap<String, TableState>(); clusterTableStates.put(entry.getValue().getAddress(), states); for (HTableDescriptor htd : tableDescsFromZnode) { boolean peerShouldEnabled = false; String tableName = Bytes.toString(htd.getName()); TableState state = tableStates.get(tableName); String clusterTableName = CrossSiteUtil.getClusterTableName(tableName, entry.getKey()); if (state == TableState.ENABLED) { peerShouldEnabled = true; if (admin.tableExists(clusterTableName) && !admin.isTableEnabled(clusterTableName)) { states.put(clusterTableName, TableState.ENABLED); LOG.error("The state of the table " + clusterTableName + " in the cluster " + entry.getKey() + " is disabled, should be corrected to the enabled"); } } else if (state == TableState.DISABLED) { if (admin.tableExists(clusterTableName) && !admin.isTableDisabled(clusterTableName)) { states.put(clusterTableName, TableState.DISABLED); LOG.error("The state of the table " + clusterTableName + " in the cluster " + entry.getKey() + " is enabled, should be corrected to the disabled"); } } else { LOG.error("Table " + tableName + " in abnormal state[" + (state == null ? "" : state.toString()) + "]. Cannot handle through this tool."); setReturnCode(RETURN_CODE.REPORT_ERROR.ordinal()); } // If the primary table is enabled, we must guarantee the peer tables are // enabled. // if the primary table is disabled, we disregards the states of the peer // tables. if (peerShouldEnabled) { boolean createTableInPeers = ClusterVerifierUtil.isReplicatedTable(htd); if (createTableInPeers) { ClusterInfo ci = entry.getValue(); if (ci.getPeers() != null) { for (ClusterInfo peer : ci.getPeers()) { HBaseAdmin peerAdmin = ClusterVerifierUtil.createHBaseAmin( configuration, peer.getAddress()); try { String peerTableName = CrossSiteUtil.getPeerClusterTableName( tableName, ci.getName(), peer.getName()); if (peerAdmin.tableExists(peerTableName)) { if (!peerAdmin.isTableEnabled(peerTableName)) { Map<String, TableState> peerStates = clusterTableStates.get(peer .getAddress()); if (peerStates == null) { peerStates = new HashMap<String, TableState>(); clusterTableStates.put(peer.getAddress(), peerStates); } peerStates.put(peerTableName, TableState.ENABLED); LOG.error("The state of the peer table " + peerTableName + " in the cluster " + peer.getName() + " is disabled, should be corrected to the enabled"); } } } finally { closeHBaseAdmin(peerAdmin); } } } } } } } finally { closeHBaseAdmin(admin); } } return clusterTableStates; } })); } for (Future<Map<String, Map<String, TableState>>> result : results) { Map<String, Map<String, TableState>> partialResult = result.get(); for (Entry<String, Map<String, TableState>> partialResultEntry : partialResult.entrySet()) { Map<String, TableState> tableStateMap = workingMap.get(partialResultEntry.getKey()); if (tableStateMap == null) { workingMap.put(partialResultEntry.getKey(), partialResultEntry.getValue()); } else { tableStateMap.putAll(partialResultEntry.getValue()); } } } // This tries to rectiy if the table is not in enabled or disabled state LOG.debug("Verifying the results"); if (!workingMap.isEmpty()) { if (shouldFixTableStates()) { setReturnCode(RETURN_CODE.REPORT_ERROR.ordinal()); for (Entry<String, Map<String, TableState>> workingEntry : workingMap.entrySet()) { HBaseAdmin admin = createHBaseAdmin(getConf(), workingEntry.getKey()); try { for (Entry<String, TableState> tableStateEntry : workingEntry.getValue().entrySet()) { String tableName = tableStateEntry.getKey(); TableState state = tableStateEntry.getValue(); if (state == TableState.ENABLED) { ClusterVerifierUtil.enableTable(admin, tableName); } else { // the state must be disabled ClusterVerifierUtil.disableTable(admin, tableName); } } } finally { closeHBaseAdmin(admin); } } setReturnCode(RETURN_CODE.ERROR_FIXED.ordinal()); LOG.info("The states of the tables have been corrected"); } else { for (Entry<String, Map<String, TableState>> workingEntry : workingMap.entrySet()) { // TODO : Create an error report as in HBCK if (workingEntry.getValue() != null && !workingEntry.getValue().isEmpty()) { String message = "The cluster " + workingEntry.getKey(); StringBuilder tables = new StringBuilder(); for (String tableName : workingEntry.getValue().keySet()) { tables.append(tableName).append(" "); } message += " has the following tables " + tables.toString() + " with inconsistent state"; System.out.println(message); System.out.println(); setReturnCode(RETURN_CODE.REPORT_ERROR.ordinal()); } } } } else { LOG.info("All the tables are in the correct state"); } } } catch (Exception e) { LOG.error("Exception while verifying table status in cluster and peer", e); setReturnCode(RETURN_CODE.EXCEPTION_ON_ERROR_FIX.ordinal()); throw new IOException(e); } } void verifyHTDs() throws IOException, KeeperException { // This considers that the htd in the crosssite znodes are the truth. The // ones inside the cluster // would be modified if there are any changes LOG.debug("Collecting the list of clusters and the list of tables for verifying HTDs"); Map<String, ClusterInfo> clusterInfos = crossSiteZnodes.listClusterInfos(); final HTableDescriptor[] tableDescsFromZnode = crossSiteZnodes.listTableDescs(); List<Future<Map<String, List<HTableDescriptor>>>> results = new ArrayList<Future<Map<String, List<HTableDescriptor>>>>(); for (final Entry<String, ClusterInfo> entry : clusterInfos.entrySet()) { results.add(executor.submit(new CrossSiteCallable<Map<String, List<HTableDescriptor>>>(getConf()) { @Override public Map<String, List<HTableDescriptor>> call() throws Exception { Map<String, List<HTableDescriptor>> results = new HashMap<String, List<HTableDescriptor>>(); String clusterName = entry.getKey(); ClusterInfo clusterInfo = entry.getValue(); HBaseAdmin admin = createHBaseAdmin(configuration, entry.getValue().getAddress()); HTableDescriptor[] listTables = null; try { listTables = admin.listTables(); } finally { closeHBaseAdmin(admin); } for (HTableDescriptor tableDescFromZNode : tableDescsFromZnode) { String clusterTableName = CrossSiteUtil.getClusterTableName( Bytes.toString(tableDescFromZNode.getName()), clusterName); boolean found = false; HTableDescriptor tempHTD = null; for (HTableDescriptor tableDesc : listTables) { if (clusterTableName.equals(Bytes.toString(tableDesc.getName()))) { found = true; tempHTD = tableDesc; break; } } if (found) { HTableDescriptor clonedDescFromZNode = new HTableDescriptor(tableDescFromZNode); clonedDescFromZNode.setName(Bytes.toBytes(clusterTableName)); if (!clonedDescFromZNode.equals(tempHTD)) { List<HTableDescriptor> issues = results.get(clusterName); if (issues == null) { issues = new ArrayList<HTableDescriptor>(); results.put(clusterName, issues); } issues.add(clonedDescFromZNode); } } if (ClusterVerifierUtil.isReplicatedTable(tableDescFromZNode)) { // check the tables in peers if (clusterInfo.getPeers() != null) { for (ClusterInfo peer : clusterInfo.getPeers()) { HBaseAdmin peerAdmin = createHBaseAdmin(configuration, peer.getAddress()); try { if (peerAdmin.isTableAvailable(clusterTableName)) { HTableDescriptor peerHtd = peerAdmin.getTableDescriptor(Bytes.toBytes(clusterTableName)); HTableDescriptor clonedDescFromZNode = new HTableDescriptor(tableDescFromZNode); clonedDescFromZNode.setName(Bytes.toBytes(clusterTableName)); for (HColumnDescriptor hcd : clonedDescFromZNode.getColumnFamilies()) { if (hcd.getScope()>0) { hcd.setScope(0); } } if (!clonedDescFromZNode.equals(peerHtd)) { List<HTableDescriptor> issues = results.get(peer.getName()); if (issues == null) { issues = new ArrayList<HTableDescriptor>(); results.put(peer.getName(), issues); } issues.add(clonedDescFromZNode); } } } finally { closeHBaseAdmin(peerAdmin); } } } } } return results; } })); } LOG.debug("Verifying the htd results"); try { Map<String, List<HTableDescriptor>> issues = new HashMap<String, List<HTableDescriptor>>(); for (Future<Map<String, List<HTableDescriptor>>> result : results) { Map<String, List<HTableDescriptor>> htdMap = result.get(); for (Entry<String, List<HTableDescriptor>> htdEntry : htdMap.entrySet()) { List<HTableDescriptor> issuesPerCluster = issues.get(htdEntry.getKey()); if (issuesPerCluster == null) { issues.put(htdEntry.getKey(), htdEntry.getValue()); } else { issuesPerCluster.addAll(htdEntry.getValue()); } } } if(!issues.isEmpty()) { if (!shouldFixHTDs()) { // TODO : Create an error report as in HBCK String message = "The following htds do not match with the ones in the crosssite htd znode" + issues; System.out.println(message); System.out.println(); setReturnCode(RETURN_CODE.REPORT_ERROR.ordinal()); } else { for(Entry<String, List<HTableDescriptor>> issueEntry : issues.entrySet()) { ClusterInfo clusterInfo = crossSiteZnodes.getClusterInfo(issueEntry.getKey()); if (clusterInfo != null) { HBaseAdmin admin = createHBaseAdmin(getConf(), clusterInfo.getAddress()); try { for (HTableDescriptor htd : issueEntry.getValue()) { boolean enabled = false; if (!admin.isTableDisabled(htd.getName())) { LOG.debug("Disabling the table " + htd.getNameAsString()); admin.disableTable(htd.getName()); enabled = true; } LOG.debug("Modifying the table " + htd.getNameAsString()); admin.modifyTable(htd.getName(), htd); if (enabled) { LOG.debug("Enabling the table " + htd.getNameAsString()); admin.enableTable(htd.getName()); } } } finally { closeHBaseAdmin(admin); } } } } } } catch (Exception e) { LOG.error("Exception while verifying HTDs", e); setReturnCode(RETURN_CODE.EXCEPTION_ON_ERROR_FIX.ordinal()); throw new IOException(e); } } void verifyClusterAndTables() throws KeeperException, IOException { LOG.debug("Collecting the list of clusters and the list of tables"); Map<String, ClusterInfo> clusterInfos = crossSiteZnodes.listClusterInfos(); final HTableDescriptor[] tableDescsFromZnode = crossSiteZnodes.listTableDescs(); List<Future<Map<String, Map<String, HTableDescriptor>>>> results = new ArrayList<Future<Map<String, Map<String, HTableDescriptor>>>>(); for (final Entry<String, ClusterInfo> entry : clusterInfos.entrySet()) { results .add(executor .submit(new CrossSiteCallable<Map<String, Map<String, HTableDescriptor>>>( getConf()) { @Override public Map<String, Map<String, HTableDescriptor>> call() throws Exception { Map<String, Map<String, HTableDescriptor>> results = new HashMap<String, Map<String, HTableDescriptor>>(); String clusterName = entry.getKey(); ClusterInfo clusterInfo = entry.getValue(); HBaseAdmin admin = createHBaseAdmin(configuration, entry.getValue().getAddress()); HTableDescriptor[] listTables = null; try { listTables = admin.listTables(); } finally { closeHBaseAdmin(admin); } for (HTableDescriptor tableDescFromZNode : tableDescsFromZnode) { boolean found = false; String clusterTableName = CrossSiteUtil.getClusterTableName( Bytes.toString(tableDescFromZNode.getName()), clusterName); for (HTableDescriptor tableDesc : listTables) { if (Bytes.toString(tableDesc.getName()).equals(clusterTableName)) { found = true; break; } } if (!found) { Map<String, HTableDescriptor> notFoundTableMap = results.get(clusterName); if (results.get(clusterName) == null) { notFoundTableMap = new HashMap<String, HTableDescriptor>(); results.put(clusterName, notFoundTableMap); } HTableDescriptor htd = new HTableDescriptor(tableDescFromZNode); htd.setName(Bytes.toBytes(clusterTableName)); notFoundTableMap.put(clusterTableName, htd); } if (ClusterVerifierUtil.isReplicatedTable(tableDescFromZNode)) { // check the tables in peers if (clusterInfo.getPeers() != null) { for (ClusterInfo peer : clusterInfo.getPeers()) { HBaseAdmin peerAdmin = createHBaseAdmin(configuration, peer.getAddress()); try { if (!peerAdmin.isTableAvailable(clusterTableName)) { Map<String, HTableDescriptor> notFoundTableMap = results.get(peer.getName()); if (results.get(peer.getName()) == null) { notFoundTableMap = new HashMap<String, HTableDescriptor>(); results.put(peer.getName(), notFoundTableMap); } HTableDescriptor htd = new HTableDescriptor(tableDescFromZNode); htd.setName(Bytes.toBytes(clusterTableName)); for (HColumnDescriptor hcd : htd.getColumnFamilies()) { if (hcd.getScope()>0) { hcd.setScope(0); } } notFoundTableMap.put(clusterTableName, htd); } } finally { closeHBaseAdmin(peerAdmin); } } } } } return results; } })); } LOG.debug("Verifying the results"); try { Map<String, Map<String, HTableDescriptor>> issues = new HashMap<String, Map<String, HTableDescriptor>>(); for (Future<Map<String, Map<String, HTableDescriptor>>> result : results) { Map<String, Map<String, HTableDescriptor>> htdMap = result.get(); for (Entry<String, Map<String, HTableDescriptor>> htdEntry : htdMap.entrySet()) { Map<String, HTableDescriptor> issuesPerCluster = issues.get(htdEntry.getKey()); if (issuesPerCluster == null) { issues.put(htdEntry.getKey(), htdEntry.getValue()); } else { issuesPerCluster.putAll(htdEntry.getValue()); } } } if (!issues.isEmpty()) { LOG.debug("Mismatch in the tables actually created in the clusters with the list of tables in the crosssite table znode:" + issues); Set<Entry<String, Map<String, HTableDescriptor>>> clusterWithIssues = issues.entrySet(); if (!shouldFixTables()) { for (Entry<String, Map<String, HTableDescriptor>> entry : clusterWithIssues) { // TODO : Create an error report as in HBCK String message = "The cluster " + entry.getKey(); StringBuilder tables = new StringBuilder(); if (entry.getValue() != null) { for (String table : entry.getValue().keySet()) { tables.append(table).append(" "); } } message += " has the following missing tables " + tables.toString(); System.out.println(message); System.out.println(); } setReturnCode(RETURN_CODE.REPORT_ERROR.ordinal()); } else { LOG.debug("Creating tables in the clusters which does not have the table as given in the table znode"); for (Entry<String, Map<String, HTableDescriptor>> entry : clusterWithIssues) { Map<String, HTableDescriptor> tableDescs = entry.getValue(); if (tableDescs != null) { for (Entry<String, HTableDescriptor> tableDesc : tableDescs.entrySet()) { String clusterName = entry.getKey(); LOG.debug("Creating table " + tableDesc.getKey() + " in the cluster " + clusterName); String tableName = CrossSiteUtil.getCrossSiteTableName(tableDesc.getKey()); String actualClusterName = CrossSiteUtil.getClusterName(tableDesc.getKey()); ClusterInfo clusterInfo = crossSiteZnodes.getClusterInfo(clusterName); ClusterLocator clusterLocator = crossSiteZnodes.getClusterLocator(tableName); byte[][] tableSplitKeys = clusterLocator.getSplitKeys(actualClusterName, crossSiteZnodes.getTableSplitKeys(tableName)); if (clusterInfo != null) { HBaseAdmin admin = createHBaseAdmin(getConf(), clusterInfo.getAddress()); try { admin.createTable(tableDesc.getValue(), tableSplitKeys); } finally { closeHBaseAdmin(admin); } LOG.debug("Created table " + tableDesc.getKey() + " in the cluster " + clusterName); setReturnCode(RETURN_CODE.ERROR_FIXED.ordinal()); } } } } } } } catch (Exception e) { LOG.error("Exception while verifying the cluster and table results", e); setReturnCode(RETURN_CODE.EXCEPTION_ON_ERROR_FIX.ordinal()); throw new IOException(e); } } private static HBaseAdmin createHBaseAdmin(Configuration baseConf, String clusterAddress) throws IOException { Configuration clusterConf = new Configuration(baseConf); ZKUtil.applyClusterKeyToConf(clusterConf, clusterAddress); return new HBaseAdmin(clusterConf); } private static void closeHBaseAdmin(HBaseAdmin admin) { if (admin != null) { try { admin.close(); } catch (IOException e) { LOG.warn("Fail to close the HBaseAdmin", e); } } } void fixTables(boolean fixTables) { this.fixTables = fixTables; } boolean shouldFixTables() { return this.fixTables; } boolean shouldFixTableStates() { return this.fixTableStates; } private boolean shouldFixHTDs() { return this.fixHTDs; } void fixTableStates(boolean fixTablesInPeers) { this.fixTableStates = fixTablesInPeers; } void fixHTDs(boolean fixHTDs) { this.fixHTDs = fixHTDs; } enum RETURN_CODE { REPORT_ERROR, // this error code is returned when only an error is reported // and there is no rectification ERROR_FIXED, // this error code is returned after the error is reported and // fixed EXCEPTION_ON_ERROR_FIX // this error code is returned after there is an exception // while fixing the error } }