From dev-return-1900-archive-asf-public=cust-asf.ponee.io@tephra.incubator.apache.org Tue Jan 9 14:40:13 2018 Return-Path: X-Original-To: archive-asf-public@eu.ponee.io Delivered-To: archive-asf-public@eu.ponee.io Received: from cust-asf.ponee.io (cust-asf.ponee.io [163.172.22.183]) by mx-eu-01.ponee.io (Postfix) with ESMTP id 05CB218072F for ; Tue, 9 Jan 2018 14:40:13 +0100 (CET) Received: by cust-asf.ponee.io (Postfix) id E9FCD160C43; Tue, 9 Jan 2018 13:40:12 +0000 (UTC) Delivered-To: archive-asf-public@cust-asf.ponee.io Received: from mail.apache.org (hermes.apache.org [140.211.11.3]) by cust-asf.ponee.io (Postfix) with SMTP id 9A96D160C40 for ; Tue, 9 Jan 2018 14:40:11 +0100 (CET) Received: (qmail 26295 invoked by uid 500); 9 Jan 2018 13:40:10 -0000 Mailing-List: contact dev-help@tephra.incubator.apache.org; run by ezmlm Precedence: bulk List-Help: List-Unsubscribe: List-Post: List-Id: Reply-To: dev@tephra.incubator.apache.org Delivered-To: mailing list dev@tephra.incubator.apache.org Received: (qmail 25973 invoked by uid 99); 9 Jan 2018 13:40:10 -0000 Received: from pnap-us-west-generic-nat.apache.org (HELO spamd1-us-west.apache.org) (209.188.14.142) by apache.org (qpsmtpd/0.29) with ESMTP; Tue, 09 Jan 2018 13:40:10 +0000 Received: from localhost (localhost [127.0.0.1]) by spamd1-us-west.apache.org (ASF Mail Server at spamd1-us-west.apache.org) with ESMTP id 04F23C4237 for ; Tue, 9 Jan 2018 13:40:10 +0000 (UTC) X-Virus-Scanned: Debian amavisd-new at spamd1-us-west.apache.org X-Spam-Flag: NO X-Spam-Score: -100.711 X-Spam-Level: X-Spam-Status: No, score=-100.711 tagged_above=-999 required=6.31 tests=[RCVD_IN_DNSWL_LOW=-0.7, SPF_PASS=-0.001, T_RP_MATCHES_RCVD=-0.01, USER_IN_WHITELIST=-100] autolearn=disabled Received: from mx1-lw-eu.apache.org ([10.40.0.8]) by localhost (spamd1-us-west.apache.org [10.40.0.7]) (amavisd-new, port 10024) with ESMTP id 7g5M3LqV9IHU for ; Tue, 9 Jan 2018 13:40:03 +0000 (UTC) Received: from mailrelay1-us-west.apache.org (mailrelay1-us-west.apache.org [209.188.14.139]) by mx1-lw-eu.apache.org (ASF Mail Server at mx1-lw-eu.apache.org) with ESMTP id EE4B55FB71 for ; Tue, 9 Jan 2018 13:40:02 +0000 (UTC) Received: from jira-lw-us.apache.org (unknown [207.244.88.139]) by mailrelay1-us-west.apache.org (ASF Mail Server at mailrelay1-us-west.apache.org) with ESMTP id 1D989E25B6 for ; Tue, 9 Jan 2018 13:40:02 +0000 (UTC) Received: from jira-lw-us.apache.org (localhost [127.0.0.1]) by jira-lw-us.apache.org (ASF Mail Server at jira-lw-us.apache.org) with ESMTP id 73667240FE for ; Tue, 9 Jan 2018 13:40:01 +0000 (UTC) Date: Tue, 9 Jan 2018 13:40:01 +0000 (UTC) From: "ASF GitHub Bot (JIRA)" To: dev@tephra.incubator.apache.org Message-ID: In-Reply-To: References: Subject: [jira] [Commented] (TEPHRA-272) Add HBase 2.0 compatibility module MIME-Version: 1.0 Content-Type: text/plain; charset=utf-8 Content-Transfer-Encoding: 7bit X-JIRA-FingerPrint: 30527f35849b9dde25b450d4833f0394 [ https://issues.apache.org/jira/browse/TEPHRA-272?page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel&focusedCommentId=16318412#comment-16318412 ] ASF GitHub Bot commented on TEPHRA-272: --------------------------------------- Github user poornachandra commented on a diff in the pull request: https://github.com/apache/incubator-tephra/pull/67#discussion_r160400229 --- Diff: tephra-hbase-compat-2.0/src/main/java/org/apache/tephra/hbase/TransactionAwareHTable.java --- @@ -0,0 +1,729 @@ +/* + * 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.tephra.hbase; + +import com.google.protobuf.Descriptors.MethodDescriptor; +import com.google.protobuf.Message; +import com.google.protobuf.Service; +import com.google.protobuf.ServiceException; + +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.hbase.Cell; +import org.apache.hadoop.hbase.CellUtil; +import org.apache.hadoop.hbase.CompareOperator; +import org.apache.hadoop.hbase.HColumnDescriptor; +import org.apache.hadoop.hbase.HTableDescriptor; +import org.apache.hadoop.hbase.TableName; +import org.apache.hadoop.hbase.client.Append; +import org.apache.hadoop.hbase.client.Delete; +import org.apache.hadoop.hbase.client.Durability; +import org.apache.hadoop.hbase.client.Get; +import org.apache.hadoop.hbase.client.Increment; +import org.apache.hadoop.hbase.client.Mutation; +import org.apache.hadoop.hbase.client.OperationWithAttributes; +import org.apache.hadoop.hbase.client.Put; +import org.apache.hadoop.hbase.client.Result; +import org.apache.hadoop.hbase.client.ResultScanner; +import org.apache.hadoop.hbase.client.Row; +import org.apache.hadoop.hbase.client.RowMutations; +import org.apache.hadoop.hbase.client.Scan; +import org.apache.hadoop.hbase.client.Table; +import org.apache.hadoop.hbase.client.TableDescriptor; +import org.apache.hadoop.hbase.client.coprocessor.Batch; +import org.apache.hadoop.hbase.client.coprocessor.Batch.Callback; +import org.apache.hadoop.hbase.filter.CompareFilter; +import org.apache.hadoop.hbase.ipc.CoprocessorRpcChannel; +import org.apache.tephra.AbstractTransactionAwareTable; +import org.apache.tephra.Transaction; +import org.apache.tephra.TransactionAware; +import org.apache.tephra.TxConstants; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.NavigableMap; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +/** + * A Transaction Aware HTable implementation for HBase 2.0. Operations are committed as usual, but + * upon a failed or aborted transaction, they are rolled back to the state before the transaction + * was started. + */ +public class TransactionAwareHTable extends AbstractTransactionAwareTable + implements Table, TransactionAware { + + private static final Logger LOG = LoggerFactory.getLogger(TransactionAwareHTable.class); + private final Table hTable; + + /** + * Create a transactional aware instance of the passed HTable + * @param hTable underlying HBase table to use + */ + public TransactionAwareHTable(Table hTable) { + this(hTable, false); + } + + /** + * Create a transactional aware instance of the passed HTable + * @param hTable underlying HBase table to use + * @param conflictLevel level of conflict detection to perform (defaults to {@code COLUMN}) + */ + public TransactionAwareHTable(Table hTable, TxConstants.ConflictDetection conflictLevel) { + this(hTable, conflictLevel, false); + } + + /** + * Create a transactional aware instance of the passed HTable, with the option of allowing + * non-transactional operations. + * @param hTable underlying HBase table to use + * @param allowNonTransactional if true, additional operations (checkAndPut, increment, + * checkAndDelete) will be available, though non-transactional + */ + public TransactionAwareHTable(Table hTable, boolean allowNonTransactional) { + this(hTable, TxConstants.ConflictDetection.COLUMN, allowNonTransactional); + } + + /** + * Create a transactional aware instance of the passed HTable, with the option of allowing + * non-transactional operations. + * @param hTable underlying HBase table to use + * @param conflictLevel level of conflict detection to perform (defaults to {@code COLUMN}) + * @param allowNonTransactional if true, additional operations (checkAndPut, increment, + * checkAndDelete) will be available, though non-transactional + */ + public TransactionAwareHTable(Table hTable, TxConstants.ConflictDetection conflictLevel, + boolean allowNonTransactional) { + super(conflictLevel, allowNonTransactional); + this.hTable = hTable; + } + + /* AbstractTransactionAwareTable implementation */ + + @Override + protected byte[] getTableKey() { + return hTable.getName().getName(); + } + + @Override + protected boolean doCommit() throws IOException { + return true; + } + + @Override + protected boolean doRollback() throws Exception { + try { + // pre-size arraylist of deletes + int size = 0; + for (Set cs : changeSets.values()) { + size += cs.size(); + } + List rollbackDeletes = new ArrayList<>(size); + for (Map.Entry> entry : changeSets.entrySet()) { + long transactionTimestamp = entry.getKey(); + for (ActionChange change : entry.getValue()) { + byte[] row = change.getRow(); + byte[] family = change.getFamily(); + byte[] qualifier = change.getQualifier(); + Delete rollbackDelete = new Delete(row); + makeRollbackOperation(rollbackDelete); + switch (conflictLevel) { + case ROW: + case NONE: + // issue family delete for the tx write pointer + rollbackDelete.addFamilyVersion(change.getFamily(), transactionTimestamp); + break; + case COLUMN: + if (family != null && qualifier == null) { + rollbackDelete.addFamilyVersion(family, transactionTimestamp); + } else if (family != null && qualifier != null) { + rollbackDelete.addColumn(family, qualifier, transactionTimestamp); + } + break; + default: + throw new IllegalStateException( + "Unknown conflict detection level: " + conflictLevel); + } + rollbackDeletes.add(rollbackDelete); + } + } + hTable.delete(rollbackDeletes); + return true; + } finally { + tx = null; + changeSets.clear(); + } + } + + /* HTableInterface implementation */ + + @Override + public TableName getName() { + return hTable.getName(); + } + + @Override + public Configuration getConfiguration() { + return hTable.getConfiguration(); + } + + @Override + public HTableDescriptor getTableDescriptor() throws IOException { + return hTable.getTableDescriptor(); + } + + @Override + public boolean exists(Get get) throws IOException { + if (tx == null) { + throw new IOException("Transaction not started"); + } + return hTable.exists(transactionalizeAction(get)); + } + + @Override + public void batch(List actions, Object[] results) + throws IOException, InterruptedException { + if (tx == null) { + throw new IOException("Transaction not started"); + } + hTable.batch(transactionalizeActions(actions), results); + } + + @Override + public void batchCallback(List actions, Object[] results, + Batch.Callback callback) throws IOException, InterruptedException { + if (tx == null) { + throw new IOException("Transaction not started"); + } + hTable.batchCallback(transactionalizeActions(actions), results, callback); + } + + @Override + public Result get(Get get) throws IOException { + if (tx == null) { + throw new IOException("Transaction not started"); + } + return hTable.get(transactionalizeAction(get)); + } + + @Override + public Result[] get(List gets) throws IOException { + if (tx == null) { + throw new IOException("Transaction not started"); + } + ArrayList transactionalizedGets = new ArrayList<>(); + for (Get get : gets) { + transactionalizedGets.add(transactionalizeAction(get)); + } + return hTable.get(transactionalizedGets); + } + + @Override + public ResultScanner getScanner(Scan scan) throws IOException { + if (tx == null) { + throw new IOException("Transaction not started"); + } + return hTable.getScanner(transactionalizeAction(scan)); + } + + @Override + public ResultScanner getScanner(byte[] family) throws IOException { + if (tx == null) { + throw new IOException("Transaction not started"); + } + Scan scan = new Scan(); + scan.addFamily(family); + return hTable.getScanner(transactionalizeAction(scan)); + } + + @Override + public ResultScanner getScanner(byte[] family, byte[] qualifier) throws IOException { + if (tx == null) { + throw new IOException("Transaction not started"); + } + Scan scan = new Scan(); + scan.addColumn(family, qualifier); + return hTable.getScanner(transactionalizeAction(scan)); + } + + @Override + public void put(Put put) throws IOException { + if (tx == null) { + throw new IOException("Transaction not started"); + } + Put txPut = transactionalizeAction(put); + hTable.put(txPut); + } + + @Override + public void put(List puts) throws IOException { + if (tx == null) { + throw new IOException("Transaction not started"); + } + List transactionalizedPuts = new ArrayList<>(puts.size()); + for (Put put : puts) { + Put txPut = transactionalizeAction(put); + transactionalizedPuts.add(txPut); + } + hTable.put(transactionalizedPuts); + } + + @Override + public boolean checkAndPut(byte[] row, byte[] family, byte[] qualifier, byte[] value, Put put) + throws IOException { + if (allowNonTransactional) { + return hTable.checkAndPut(row, family, qualifier, value, put); + } else { + throw new UnsupportedOperationException("Operation is not supported transactionally"); + } + } + + @Override + public void delete(Delete delete) throws IOException { + if (tx == null) { + throw new IOException("Transaction not started"); + } + hTable.delete(transactionalizeAction(delete)); + } + + @Override + public void delete(List deletes) throws IOException { + if (tx == null) { + throw new IOException("Transaction not started"); + } + List transactionalizedDeletes = new ArrayList<>(deletes.size()); + for (Delete delete : deletes) { + Delete txDelete = transactionalizeAction(delete); + transactionalizedDeletes.add(txDelete); + } + hTable.delete(transactionalizedDeletes); + } + + @Override + public boolean checkAndDelete(byte[] row, byte[] family, byte[] qualifier, byte[] value, + Delete delete) throws IOException { + if (allowNonTransactional) { + return hTable.checkAndDelete(row, family, qualifier, value, delete); + } else { + throw new UnsupportedOperationException("Operation is not supported transactionally"); + } + } + + @Override + public boolean checkAndDelete(byte[] bytes, byte[] bytes1, byte[] bytes2, + CompareFilter.CompareOp compareOp, byte[] bytes3, Delete delete) throws IOException { + if (allowNonTransactional) { + return hTable.checkAndDelete(bytes, bytes1, bytes2, compareOp, bytes3, delete); + } else { + throw new UnsupportedOperationException("Operation is not supported transactionally"); + } + } + + @Override + public boolean checkAndPut(byte[] bytes, byte[] bytes1, byte[] bytes2, + CompareFilter.CompareOp compareOp, byte[] bytes3, Put put) throws IOException { + if (allowNonTransactional) { + return hTable.checkAndPut(bytes, bytes1, bytes2, compareOp, bytes3, put); + } else { + throw new UnsupportedOperationException("Operation is not supported transactionally"); + } + } + + @Override + public boolean[] existsAll(List gets) throws IOException { + if (tx == null) { + throw new IOException("Transaction not started"); + } + List transactionalizedGets = new ArrayList<>(gets.size()); + for (Get get : gets) { + transactionalizedGets.add(transactionalizeAction(get)); + } + return hTable.existsAll(transactionalizedGets); + } + + @Override + public boolean checkAndMutate(byte[] row, byte[] family, byte[] qualifier, + CompareFilter.CompareOp compareOp, byte[] value, RowMutations rowMutations) + throws IOException { + if (allowNonTransactional) { + return hTable.checkAndMutate(row, family, qualifier, compareOp, value, rowMutations); + } + + throw new UnsupportedOperationException( + "checkAndMutate operation is not supported transactionally"); + } + + @Override + public void mutateRow(RowMutations rm) throws IOException { + if (tx == null) { + throw new IOException("Transaction not started"); + } + RowMutations transactionalMutations = new RowMutations(rm.getRow()); + for (Mutation mutation : rm.getMutations()) { + if (mutation instanceof Put) { + transactionalMutations.add(transactionalizeAction((Put) mutation)); + } else if (mutation instanceof Delete) { + transactionalMutations.add(transactionalizeAction((Delete) mutation)); + } + } + hTable.mutateRow(transactionalMutations); + } + + @Override + public Result append(Append append) throws IOException { + if (allowNonTransactional) { + return hTable.append(append); + } else { + throw new UnsupportedOperationException("Operation is not supported transactionally"); + } + } + + @Override + public Result increment(Increment increment) throws IOException { + if (allowNonTransactional) { + return hTable.increment(increment); + } else { + throw new UnsupportedOperationException("Operation is not supported transactionally"); + } + } + + @Override + public long incrementColumnValue(byte[] row, byte[] family, byte[] qualifier, long amount) + throws IOException { + if (allowNonTransactional) { + return hTable.incrementColumnValue(row, family, qualifier, amount); + } else { + throw new UnsupportedOperationException("Operation is not supported transactionally"); + } + } + + @Override + public long incrementColumnValue(byte[] row, byte[] family, byte[] qualifier, long amount, + Durability durability) throws IOException { + if (allowNonTransactional) { + return hTable.incrementColumnValue(row, family, qualifier, amount, durability); + } else { + throw new UnsupportedOperationException("Operation is not supported transactionally"); + } + } + + @Override + public void close() throws IOException { + hTable.close(); + } + + @Override + public CoprocessorRpcChannel coprocessorService(byte[] row) { + return hTable.coprocessorService(row); + } + + @Override + public Map coprocessorService(Class service, + byte[] startKey, byte[] endKey, Batch.Call callable) + throws ServiceException, Throwable { + return hTable.coprocessorService(service, startKey, endKey, callable); + } + + @Override + public void coprocessorService(Class service, byte[] startKey, + byte[] endKey, Batch.Call callable, Batch.Callback callback) + throws ServiceException, Throwable { + hTable.coprocessorService(service, startKey, endKey, callable, callback); + } + + @Override + public Map batchCoprocessorService( + MethodDescriptor methodDescriptor, Message request, byte[] startKey, byte[] endKey, + R responsePrototype) throws ServiceException, Throwable { + return hTable.batchCoprocessorService(methodDescriptor, request, startKey, endKey, + responsePrototype); + } + + @Override + public void batchCoprocessorService(MethodDescriptor methodDescriptor, + Message request, byte[] startKey, byte[] endKey, R responsePrototype, + Callback callback) throws ServiceException, Throwable { + hTable.batchCoprocessorService(methodDescriptor, request, startKey, endKey, + responsePrototype, callback); + } + + // Helpers to get copies of objects with the timestamp set to the current transaction timestamp. + + private Get transactionalizeAction(Get get) throws IOException { + addToOperation(get, tx); + return get; + } + + private Scan transactionalizeAction(Scan scan) throws IOException { + addToOperation(scan, tx); + return scan; + } + + private Put transactionalizeAction(Put put) throws IOException { + Put txPut = new Put(put.getRow(), tx.getWritePointer()); + Set>> familyMap = put.getFamilyCellMap().entrySet(); + if (!familyMap.isEmpty()) { + for (Map.Entry> family : familyMap) { + List familyValues = family.getValue(); + if (!familyValues.isEmpty()) { + for (Cell value : familyValues) { + txPut.addColumn(CellUtil.cloneFamily(value), CellUtil.cloneQualifier(value), + tx.getWritePointer(), CellUtil.cloneValue(value)); + addToChangeSet(txPut.getRow(), CellUtil.cloneFamily(value), + CellUtil.cloneQualifier(value)); + } + } + } + } + for (Map.Entry entry : put.getAttributesMap().entrySet()) { + txPut.setAttribute(entry.getKey(), entry.getValue()); + } + txPut.setDurability(put.getDurability()); + addToOperation(txPut, tx); + return txPut; + } + + private Delete transactionalizeAction(Delete delete) throws IOException { + long transactionTimestamp = tx.getWritePointer(); + + byte[] deleteRow = delete.getRow(); + Delete txDelete = new Delete(deleteRow, transactionTimestamp); + + Map> familyToDelete = delete.getFamilyCellMap(); + if (familyToDelete.isEmpty()) { + // perform a row delete if we are using row-level conflict detection + if (conflictLevel == TxConstants.ConflictDetection.ROW + || conflictLevel == TxConstants.ConflictDetection.NONE) { + // Row delete leaves delete markers in all column families of the table + // Therefore get all the column families of the hTable from the HTableDescriptor and + // add them to the + // changeSet + for (HColumnDescriptor columnDescriptor : hTable.getTableDescriptor() + .getColumnFamilies()) { + // no need to identify individual columns deleted + addToChangeSet(deleteRow, columnDescriptor.getName(), null); + } + } else { + Result result = get(new Get(delete.getRow())); + // Delete everything + NavigableMap> resultMap = + result.getNoVersionMap(); + for (Map.Entry> familyEntry : resultMap + .entrySet()) { + NavigableMap familyColumns = + result.getFamilyMap(familyEntry.getKey()); + for (Map.Entry column : familyColumns.entrySet()) { + txDelete.addColumns(familyEntry.getKey(), column.getKey(), + transactionTimestamp); + addToChangeSet(deleteRow, familyEntry.getKey(), column.getKey()); + } + } + } + } else { + for (Map.Entry> familyEntry : familyToDelete.entrySet()) { + byte[] family = familyEntry.getKey(); + List entries = familyEntry.getValue(); + boolean isFamilyDelete = false; + if (entries.size() == 1) { + Cell cell = entries.get(0); + isFamilyDelete = CellUtil.isDeleteFamily(cell); + } + if (isFamilyDelete) { + if (conflictLevel == TxConstants.ConflictDetection.ROW + || conflictLevel == TxConstants.ConflictDetection.NONE) { + // no need to identify individual columns deleted + txDelete.addFamily(family); + addToChangeSet(deleteRow, family, null); + } else { + Result result = get(new Get(delete.getRow()).addFamily(family)); + // Delete entire family + NavigableMap familyColumns = result.getFamilyMap(family); + for (Map.Entry column : familyColumns.entrySet()) { + txDelete.addColumns(family, column.getKey(), transactionTimestamp); + addToChangeSet(deleteRow, family, column.getKey()); + } + } + } else { + for (Cell value : entries) { + txDelete.addColumn(CellUtil.cloneFamily(value), + CellUtil.cloneQualifier(value), transactionTimestamp); + addToChangeSet(deleteRow, CellUtil.cloneFamily(value), + CellUtil.cloneQualifier(value)); + } + } + } + } + for (Map.Entry entry : delete.getAttributesMap().entrySet()) { + txDelete.setAttribute(entry.getKey(), entry.getValue()); + } + txDelete.setDurability(delete.getDurability()); + addToOperation(txDelete, tx); + return txDelete; + } + + private List transactionalizeActions(List actions) + throws IOException { + List transactionalizedActions = new ArrayList<>(actions.size()); + for (Row action : actions) { + if (action instanceof Get) { + transactionalizedActions.add(transactionalizeAction((Get) action)); + } else if (action instanceof Put) { + transactionalizedActions.add(transactionalizeAction((Put) action)); + } else if (action instanceof Delete) { + transactionalizedActions.add(transactionalizeAction((Delete) action)); + } else { + transactionalizedActions.add(action); + } + } + return transactionalizedActions; + } + + public void addToOperation(OperationWithAttributes op, Transaction tx) throws IOException { + op.setAttribute(TxConstants.TX_OPERATION_ATTRIBUTE_KEY, txCodec.encode(tx)); + } + + protected void makeRollbackOperation(Delete delete) { + delete.setAttribute(TxConstants.TX_ROLLBACK_ATTRIBUTE_KEY, new byte[0]); + } + + @Override + public TableDescriptor getDescriptor() throws IOException { + // TODO Auto-generated method stub + return null; + } + + @Override + public boolean[] exists(List gets) throws IOException { --- End diff -- It would be good to move all the public methods before the private helpers for readability. > Add HBase 2.0 compatibility module > ---------------------------------- > > Key: TEPHRA-272 > URL: https://issues.apache.org/jira/browse/TEPHRA-272 > Project: Tephra > Issue Type: Improvement > Reporter: Ankit Singhal > Assignee: Ankit Singhal > Labels: HBase-2.0 > Fix For: 0.14.0-incubating > > -- This message was sent by Atlassian JIRA (v6.4.14#64029)